From 4337d14a89ba89a4b885bdf7dded556dcd7cda44 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 8 Jun 2026 19:15:59 -0400 Subject: [PATCH 1/4] feat(security-questionnaire): add browser extension --- .env.example | 1 + ...curity-questionnaire-extension-release.yml | 250 +++++++++++ .gitignore | 2 + apps/api/src/auth/auth-server-origins.spec.ts | 198 +++++---- apps/api/src/auth/auth.server.ts | 67 +-- .../src/auth/cors-origin.middleware.spec.ts | 186 ++++++++ apps/api/src/auth/cors-origin.middleware.ts | 86 ++++ .../src/auth/origin-check.middleware.spec.ts | 108 ++++- apps/api/src/auth/origin-check.middleware.ts | 34 +- apps/api/src/auth/origin-policy.ts | 112 +++++ .../common/filters/cors-exception.filter.ts | 11 +- apps/api/src/main.ts | 21 +- .../dto/answer-single-question.dto.ts | 25 ++ .../questionnaire/questionnaire.controller.ts | 8 +- .../questionnaire.service.spec.ts | 1 + .../questionnaire/questionnaire.service.ts | 2 + .../utils/questionnaire-storage.ts | 22 + .../auto-answer-questionnaire.ts | 1 + .../security-questionnaire-ext/.env.example | 12 + .../security-questionnaire-ext/README.md | 141 ++++++ .../security-questionnaire-ext/package.json | 21 + .../public/icon/128.png | Bin 0 -> 5248 bytes .../public/icon/16.png | Bin 0 -> 554 bytes .../public/icon/32.png | Bin 0 -> 1107 bytes .../public/icon/48.png | Bin 0 -> 1798 bytes .../src/entrypoints/background.ts | 28 ++ .../src/entrypoints/content.ts | 296 +++++++++++++ .../src/entrypoints/popup/index.html | 13 + .../src/entrypoints/popup/main.ts | 207 +++++++++ .../src/entrypoints/popup/style.css | 191 +++++++++ .../src/entrypoints/sidepanel/active-tab.ts | 15 + .../src/entrypoints/sidepanel/answer-edits.ts | 58 +++ .../sidepanel/content-collector.ts | 131 ++++++ .../src/entrypoints/sidepanel/dialog.ts | 19 + .../src/entrypoints/sidepanel/index.html | 12 + .../src/entrypoints/sidepanel/main.ts | 300 +++++++++++++ .../entrypoints/sidepanel/queue-polish.css | 95 +++++ .../src/entrypoints/sidepanel/render.ts | 281 ++++++++++++ .../sidepanel/sheet-mapping-actions.ts | 45 ++ .../sidepanel/sheet-mapping-dialog.ts | 82 ++++ .../entrypoints/sidepanel/sheet-mapping-ui.ts | 125 ++++++ .../entrypoints/sidepanel/sheet-mapping.css | 114 +++++ .../sidepanel/sheet-paste-actions.ts | 175 ++++++++ .../sidepanel/sheet-paste-dialog.ts | 90 ++++ .../entrypoints/sidepanel/sticky-footer.css | 26 ++ .../src/entrypoints/sidepanel/style.css | 298 +++++++++++++ .../src/entrypoints/sidepanel/surface-ui.ts | 47 ++ .../security-questionnaire-ext/src/lib/api.ts | 152 +++++++ .../src/lib/async-pool.test.ts | 22 + .../src/lib/async-pool.ts | 24 ++ .../src/lib/background/auth.ts | 119 ++++++ .../src/lib/background/batch-generation.ts | 91 ++++ .../content-script-injection.test.ts | 15 + .../background/content-script-injection.ts | 80 ++++ .../lib/background/google-sheets-api.test.ts | 114 +++++ .../src/lib/background/google-sheets-api.ts | 300 +++++++++++++ .../background/google-sheets-formatting.ts | 156 +++++++ .../google-sheets-target-resolution.test.ts | 109 +++++ .../google-sheets-target-resolution.ts | 171 ++++++++ .../src/lib/background/handlers.ts | 187 ++++++++ .../src/lib/background/insert-answers.ts | 75 ++++ .../src/lib/background/queue-actions.ts | 249 +++++++++++ .../src/lib/background/queue-scope.test.ts | 55 +++ .../src/lib/background/queue-scope.ts | 50 +++ .../src/lib/background/queue-store.ts | 75 ++++ .../src/lib/background/sheet-actions.ts | 109 +++++ .../src/lib/brand.ts | 7 + .../src/lib/config.ts | 13 + .../src/lib/content-messaging.ts | 55 +++ .../src/lib/dom/content-dialog.ts | 147 +++++++ .../src/lib/dom/content-messaging.ts | 33 ++ .../src/lib/dom/content-styles.ts | 16 + .../src/lib/dom/csv.test.ts | 29 ++ .../src/lib/dom/csv.ts | 59 +++ .../src/lib/dom/field-actions.test.ts | 30 ++ .../src/lib/dom/field-actions.ts | 41 ++ .../src/lib/dom/field-detection.test.ts | 119 ++++++ .../src/lib/dom/field-detection.ts | 277 ++++++++++++ .../src/lib/dom/inline-button.ts | 106 +++++ .../src/lib/dom/inline-preview.ts | 265 ++++++++++++ .../src/lib/dom/page-surface.test.ts | 46 ++ .../src/lib/dom/page-surface.ts | 66 +++ .../src/lib/dom/review-panel.ts | 142 +++++++ .../src/lib/dom/safe-runtime.ts | 15 + .../src/lib/dom/sheets-debug.ts | 228 ++++++++++ .../src/lib/dom/sheets-detection.test.ts | 216 ++++++++++ .../src/lib/dom/sheets-detection.ts | 97 +++++ .../src/lib/dom/sheets-dom.test.ts | 31 ++ .../src/lib/dom/sheets-dom.ts | 85 ++++ .../src/lib/dom/sheets-insert.test.ts | 25 ++ .../src/lib/dom/sheets-insert.ts | 103 +++++ .../lib/dom/sheets-mapping-detection.test.ts | 166 ++++++++ .../src/lib/dom/sheets-question-cells.ts | 287 +++++++++++++ .../src/lib/dom/sheets-runtime.ts | 44 ++ .../src/lib/dom/sheets-table.ts | 78 ++++ .../src/lib/message-utils.ts | 38 ++ .../src/lib/messaging.ts | 283 ++++++++++++ .../src/lib/queue-approval.test.ts | 81 ++++ .../src/lib/queue-approval.ts | 33 ++ .../src/lib/queue.test.ts | 129 ++++++ .../src/lib/queue.ts | 272 ++++++++++++ .../src/lib/response-guards.ts | 63 +++ .../src/lib/scan-debug.ts | 66 +++ .../src/lib/sheet-columns.ts | 28 ++ .../src/lib/sheet-mapping-storage.ts | 32 ++ .../src/lib/sheet-mapping.test.ts | 109 +++++ .../src/lib/sheet-mapping.ts | 195 +++++++++ .../src/lib/sheets-paste-plan.ts | 96 +++++ .../src/lib/storage.ts | 87 ++++ .../src/lib/types.ts | 133 ++++++ .../src/types/env.d.ts | 9 + .../security-questionnaire-ext/tsconfig.json | 15 + .../security-questionnaire-ext/wxt.config.ts | 98 +++++ bun.lock | 401 ++++++++++++++++-- package.json | 1 + packages/docs/openapi.json | 68 ++- turbo.json | 1 + 117 files changed, 10947 insertions(+), 227 deletions(-) create mode 100644 .github/workflows/security-questionnaire-extension-release.yml create mode 100644 apps/api/src/auth/cors-origin.middleware.spec.ts create mode 100644 apps/api/src/auth/cors-origin.middleware.ts create mode 100644 apps/api/src/auth/origin-policy.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/.env.example create mode 100644 apps/browser-extension/security-questionnaire-ext/README.md create mode 100644 apps/browser-extension/security-questionnaire-ext/package.json create mode 100644 apps/browser-extension/security-questionnaire-ext/public/icon/128.png create mode 100644 apps/browser-extension/security-questionnaire-ext/public/icon/16.png create mode 100644 apps/browser-extension/security-questionnaire-ext/public/icon/32.png create mode 100644 apps/browser-extension/security-questionnaire-ext/public/icon/48.png create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/background.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/content.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/popup/index.html create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/popup/main.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/popup/style.css create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/active-tab.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/answer-edits.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/content-collector.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/dialog.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/index.html create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/main.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/queue-polish.css create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/render.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping-actions.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping-dialog.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping-ui.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping.css create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-paste-actions.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-paste-dialog.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sticky-footer.css create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/style.css create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/surface-ui.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/api.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/async-pool.test.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/async-pool.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/background/auth.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/background/content-script-injection.test.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/background/content-script-injection.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-api.test.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-api.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-formatting.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-target-resolution.test.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-target-resolution.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/background/handlers.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/background/insert-answers.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-actions.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-scope.test.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-scope.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-store.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/background/sheet-actions.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/brand.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/config.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/content-messaging.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/content-dialog.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/content-messaging.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/content-styles.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/csv.test.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/csv.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-actions.test.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-actions.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-detection.test.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-detection.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/inline-button.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/inline-preview.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/page-surface.test.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/page-surface.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/review-panel.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/safe-runtime.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-debug.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-detection.test.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-detection.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-dom.test.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-dom.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-insert.test.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-insert.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-mapping-detection.test.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-question-cells.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-runtime.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-table.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/message-utils.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/messaging.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/queue-approval.test.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/queue-approval.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/queue.test.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/queue.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/response-guards.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/scan-debug.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/sheet-columns.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/sheet-mapping-storage.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/sheet-mapping.test.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/sheet-mapping.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/storage.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/types.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/types/env.d.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/tsconfig.json create mode 100644 apps/browser-extension/security-questionnaire-ext/wxt.config.ts diff --git a/.env.example b/.env.example index 773b5d9959..5249c2f2d3 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,4 @@ FIRECRAWL_API_KEY="" # For research, self-host or use cloud-version @ https://fi TRUST_APP_URL="http://localhost:3008" # Trust portal public site for NDA signing and access requests AUTH_TRUSTED_ORIGINS=http://localhost:3000,https://*.trycomp.ai,http://localhost:3002 +COMP_EXTENSION_TRUSTED_ORIGINS=chrome-extension:// diff --git a/.github/workflows/security-questionnaire-extension-release.yml b/.github/workflows/security-questionnaire-extension-release.yml new file mode 100644 index 0000000000..aa403a0fe8 --- /dev/null +++ b/.github/workflows/security-questionnaire-extension-release.yml @@ -0,0 +1,250 @@ +name: Security Questionnaire Extension Release + +on: + workflow_dispatch: + push: + branches: + - release + paths: + - '.github/workflows/security-questionnaire-extension-release.yml' + - 'apps/browser-extension/security-questionnaire-ext/**' + +permissions: + contents: write + +concurrency: + group: security-questionnaire-extension-release + cancel-in-progress: false + +env: + EXTENSION_DIR: apps/browser-extension/security-questionnaire-ext + EXTENSION_TAG_PREFIX: security-questionnaire-ext-v + WXT_PUBLIC_API_BASE_URL: https://api.trycomp.ai + WXT_PUBLIC_APP_BASE_URL: https://app.trycomp.ai + +jobs: + release: + name: Publish Chrome extension + if: ${{ github.ref == 'refs/heads/release' }} + runs-on: warp-ubuntu-latest-arm64-4x + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Compute next extension version + id: version + run: | + LATEST_TAG=$(git tag -l "${EXTENSION_TAG_PREFIX}*" --sort=-v:refname | grep -E "^${EXTENSION_TAG_PREFIX}[0-9]+\.[0-9]+\.[0-9]+$" | head -1) + + if [ -z "$LATEST_TAG" ]; then + NEXT_VERSION="0.1.0" + else + CURRENT_VERSION="${LATEST_TAG#${EXTENSION_TAG_PREFIX}}" + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" + NEXT_VERSION="$MAJOR.$MINOR.$((PATCH + 1))" + fi + + TAG_NAME="${EXTENSION_TAG_PREFIX}${NEXT_VERSION}" + echo "version=$NEXT_VERSION" >> "$GITHUB_OUTPUT" + echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" + echo "zip_name=security-questionnaire-extension-${NEXT_VERSION}.zip" >> "$GITHUB_OUTPUT" + + echo "--- Extension version ---" + echo "Latest tag: ${LATEST_TAG:-none}" + echo "Next version: $NEXT_VERSION" + echo "Tag name: $TAG_NAME" + + - name: Set extension package version + working-directory: ${{ env.EXTENSION_DIR }} + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + node -e " + const fs = require('fs'); + const path = 'package.json'; + const pkg = JSON.parse(fs.readFileSync(path, 'utf8')); + pkg.version = process.env.VERSION; + fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n'); + " + + - name: Typecheck extension + run: bun run --filter '@trycompai/security-questionnaire-extension' typecheck + + - name: Test extension + run: bun run --filter '@trycompai/security-questionnaire-extension' test + + - name: Build production extension + env: + WXT_GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_GOOGLE_OAUTH_CLIENT_ID }} + run: bun run --filter '@trycompai/security-questionnaire-extension' build + + - name: Verify manifest version + env: + EXTENSION_DIR: ${{ env.EXTENSION_DIR }} + VERSION: ${{ steps.version.outputs.version }} + run: | + node -e " + const fs = require('fs'); + const manifestPath = process.env.EXTENSION_DIR + '/dist/chrome-mv3/manifest.json'; + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (manifest.version !== process.env.VERSION) { + throw new Error(\`Expected manifest version ${process.env.VERSION}, got ${manifest.version}\`); + } + if (!manifest.oauth2?.client_id) { + throw new Error('Missing oauth2.client_id in production manifest'); + } + " + + - name: Package extension + working-directory: ${{ env.EXTENSION_DIR }}/dist/chrome-mv3 + env: + ZIP_NAME: ${{ steps.version.outputs.zip_name }} + run: zip -r "../$ZIP_NAME" . + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: security-questionnaire-extension-${{ steps.version.outputs.version }} + path: ${{ env.EXTENSION_DIR }}/dist/${{ steps.version.outputs.zip_name }} + if-no-files-found: error + + - name: Validate Chrome Web Store secrets + env: + CLIENT_ID: ${{ secrets.CHROME_WEB_STORE_CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.CHROME_WEB_STORE_CLIENT_SECRET }} + REFRESH_TOKEN: ${{ secrets.CHROME_WEB_STORE_REFRESH_TOKEN }} + PUBLISHER_ID: ${{ secrets.CHROME_WEB_STORE_PUBLISHER_ID }} + EXTENSION_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_ID }} + OAUTH_CLIENT_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_GOOGLE_OAUTH_CLIENT_ID }} + run: | + missing=0 + for name in CLIENT_ID CLIENT_SECRET REFRESH_TOKEN PUBLISHER_ID EXTENSION_ID OAUTH_CLIENT_ID; do + if [ -z "${!name}" ]; then + echo "::error::Missing required secret: $name" + missing=1 + fi + done + if [ "$missing" -ne 0 ]; then + exit 1 + fi + + - name: Get Chrome Web Store access token + id: token + env: + CLIENT_ID: ${{ secrets.CHROME_WEB_STORE_CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.CHROME_WEB_STORE_CLIENT_SECRET }} + REFRESH_TOKEN: ${{ secrets.CHROME_WEB_STORE_REFRESH_TOKEN }} + run: | + RESPONSE=$(curl -fsS "https://oauth2.googleapis.com/token" \ + -d "client_secret=$CLIENT_SECRET" \ + -d "grant_type=refresh_token" \ + -d "refresh_token=$REFRESH_TOKEN" \ + -d "client_id=$CLIENT_ID") + + ACCESS_TOKEN=$(RESPONSE="$RESPONSE" node -e " + const response = JSON.parse(process.env.RESPONSE); + if (!response.access_token) throw new Error('No access_token returned'); + process.stdout.write(response.access_token); + ") + + echo "::add-mask::$ACCESS_TOKEN" + echo "access_token=$ACCESS_TOKEN" >> "$GITHUB_OUTPUT" + + - name: Upload package to Chrome Web Store + id: upload + env: + ACCESS_TOKEN: ${{ steps.token.outputs.access_token }} + EXTENSION_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_ID }} + PUBLISHER_ID: ${{ secrets.CHROME_WEB_STORE_PUBLISHER_ID }} + ZIP_PATH: ${{ env.EXTENSION_DIR }}/dist/${{ steps.version.outputs.zip_name }} + run: | + RESPONSE=$(curl -fsS \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/zip" \ + -X POST \ + -T "$ZIP_PATH" \ + "https://chromewebstore.googleapis.com/upload/v2/publishers/$PUBLISHER_ID/items/$EXTENSION_ID:upload") + + echo "$RESPONSE" + UPLOAD_STATE=$(RESPONSE="$RESPONSE" node -e " + const response = JSON.parse(process.env.RESPONSE); + const state = response.uploadState; + if (state !== 'SUCCEEDED' && state !== 'IN_PROGRESS') { + throw new Error(\`Chrome upload failed with state ${state || 'unknown'}\`); + } + process.stdout.write(state); + ") + + echo "upload_state=$UPLOAD_STATE" >> "$GITHUB_OUTPUT" + + - name: Wait for async Chrome Web Store upload + if: ${{ steps.upload.outputs.upload_state == 'IN_PROGRESS' }} + env: + ACCESS_TOKEN: ${{ steps.token.outputs.access_token }} + EXTENSION_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_ID }} + PUBLISHER_ID: ${{ secrets.CHROME_WEB_STORE_PUBLISHER_ID }} + run: | + for attempt in $(seq 1 30); do + RESPONSE=$(curl -fsS \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://chromewebstore.googleapis.com/v2/publishers/$PUBLISHER_ID/items/$EXTENSION_ID:fetchStatus") + + echo "$RESPONSE" + STATE=$(RESPONSE="$RESPONSE" node -e " + const response = JSON.parse(process.env.RESPONSE); + process.stdout.write(response.lastAsyncUploadState || 'UNKNOWN'); + ") + + if [ "$STATE" = "SUCCEEDED" ]; then + exit 0 + fi + if [ "$STATE" = "FAILED" ]; then + echo "::error::Chrome Web Store async upload failed" + exit 1 + fi + + echo "Upload still $STATE; waiting before retry $attempt" + sleep 10 + done + + echo "::error::Timed out waiting for Chrome Web Store async upload" + exit 1 + + - name: Publish Chrome Web Store item + env: + ACCESS_TOKEN: ${{ steps.token.outputs.access_token }} + EXTENSION_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_ID }} + PUBLISHER_ID: ${{ secrets.CHROME_WEB_STORE_PUBLISHER_ID }} + run: | + RESPONSE=$(curl -fsS \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST \ + -d '{"publishType":"DEFAULT_PUBLISH","blockOnWarnings":true}' \ + "https://chromewebstore.googleapis.com/v2/publishers/$PUBLISHER_ID/items/$EXTENSION_ID:publish") + + echo "$RESPONSE" + + - name: Tag published extension version + env: + TAG_NAME: ${{ steps.version.outputs.tag_name }} + VERSION: ${{ steps.version.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG_NAME" -m "Security questionnaire extension v$VERSION" + git push origin "$TAG_NAME" diff --git a/.gitignore b/.gitignore index 2c65017d5f..78f9c65c52 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,8 @@ playwright/.cache/ debug-setup-page.png packages/*/dist +apps/browser-extension/*/.wxt +apps/browser-extension/*/dist # Generated Prisma Client **/src/db/generated/ diff --git a/apps/api/src/auth/auth-server-origins.spec.ts b/apps/api/src/auth/auth-server-origins.spec.ts index 7bb79a7709..f017d9ac79 100644 --- a/apps/api/src/auth/auth-server-origins.spec.ts +++ b/apps/api/src/auth/auth-server-origins.spec.ts @@ -1,132 +1,157 @@ -/** - * Tests for the getTrustedOrigins / isTrustedOrigin logic. - * - * Because auth.server.ts has side effects at module load time (better-auth - * initialization, DB connections, validateSecurityConfig), we test the logic - * in isolation rather than importing the module directly. - */ - -function getTrustedOriginsLogic( - authTrustedOrigins: string | undefined, -): string[] { - if (authTrustedOrigins) { - return authTrustedOrigins.split(',').map((o) => o.trim()); +import { + getBetterAuthTrustedOrigins, + getCompExtensionTrustedOrigins, + getTrustedOrigins, + isChromeExtensionOrigin, + isCompExtensionOriginAllowedForRequest, + isStaticTrustedOrigin, +} from './origin-policy'; + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; } - - return [ - 'http://localhost:3000', - 'http://localhost:3002', - 'http://localhost:3333', - 'https://app.trycomp.ai', - 'https://portal.trycomp.ai', - 'https://api.trycomp.ai', - 'https://app.staging.trycomp.ai', - 'https://portal.staging.trycomp.ai', - 'https://api.staging.trycomp.ai', - 'https://dev.trycomp.ai', - ]; + process.env[name] = value; } -/** - * Mirror of isStaticTrustedOrigin from auth.server.ts for isolated testing. - * The full isTrustedOrigin is async (checks DB for custom domains) — - * that path is tested via integration tests. - */ -function isStaticTrustedOriginLogic( - origin: string, - trustedOrigins: string[], -): boolean { - if (trustedOrigins.includes(origin)) { - return true; - } +const originalAuthTrustedOrigins = process.env.AUTH_TRUSTED_ORIGINS; +const originalExtensionTrustedOrigins = + process.env.COMP_EXTENSION_TRUSTED_ORIGINS; - try { - const url = new URL(origin); - return ( - url.hostname.endsWith('.trycomp.ai') || - url.hostname.endsWith('.staging.trycomp.ai') || - url.hostname.endsWith('.trust.inc') || - url.hostname === 'trust.inc' - ); - } catch { - return false; - } -} +beforeEach(() => { + delete process.env.AUTH_TRUSTED_ORIGINS; + delete process.env.COMP_EXTENSION_TRUSTED_ORIGINS; +}); + +afterAll(() => { + restoreEnv('AUTH_TRUSTED_ORIGINS', originalAuthTrustedOrigins); + restoreEnv( + 'COMP_EXTENSION_TRUSTED_ORIGINS', + originalExtensionTrustedOrigins, + ); +}); describe('getTrustedOrigins', () => { it('should return env-configured origins when AUTH_TRUSTED_ORIGINS is set', () => { - const origins = getTrustedOriginsLogic('https://a.com, https://b.com'); - expect(origins).toEqual(['https://a.com', 'https://b.com']); + process.env.AUTH_TRUSTED_ORIGINS = 'https://a.com, https://b.com'; + + expect(getTrustedOrigins()).toEqual(['https://a.com', 'https://b.com']); }); it('should return hardcoded origins when AUTH_TRUSTED_ORIGINS is not set', () => { - const origins = getTrustedOriginsLogic(undefined); + const origins = getTrustedOrigins(); + expect(origins).toContain('https://app.trycomp.ai'); }); it('should never include wildcard origin', () => { - const origins = getTrustedOriginsLogic(undefined); + const origins = getTrustedOrigins(); + expect(origins.every((o: string) => o !== '*' && o !== 'true')).toBe(true); }); it('should trim whitespace from comma-separated origins', () => { - const origins = getTrustedOriginsLogic( - ' https://a.com , https://b.com ', - ); - expect(origins).toEqual(['https://a.com', 'https://b.com']); + process.env.AUTH_TRUSTED_ORIGINS = + ' https://a.com , https://b.com '; + + expect(getTrustedOrigins()).toEqual(['https://a.com', 'https://b.com']); }); }); -describe('isStaticTrustedOrigin', () => { - const defaults = getTrustedOriginsLogic(undefined); +describe('COMP_EXTENSION_TRUSTED_ORIGINS', () => { + const extensionOrigin = + 'chrome-extension://panomgbokjppnleifmpcnpchjgpcngan'; - it('should allow static trusted origins', () => { - expect(isStaticTrustedOriginLogic('https://app.trycomp.ai', defaults)).toBe( - true, - ); + beforeEach(() => { + process.env.COMP_EXTENSION_TRUSTED_ORIGINS = extensionOrigin; }); - it('should allow trust portal subdomains of trycomp.ai', () => { + it('should parse first-party extension origins separately', () => { + expect(getCompExtensionTrustedOrigins()).toEqual([extensionOrigin]); + }); + + it('should include extension origins only in better-auth trusted origins', () => { + expect(getBetterAuthTrustedOrigins()).toContain(extensionOrigin); + expect(getTrustedOrigins()).not.toContain(extensionOrigin); + }); + + it('should allow extension origins only on extension routes', () => { expect( - isStaticTrustedOriginLogic('https://security.trycomp.ai', defaults), + isCompExtensionOriginAllowedForRequest({ + method: 'POST', + origin: extensionOrigin, + path: '/v1/questionnaire/answer-single', + }), ).toBe(true); expect( - isStaticTrustedOriginLogic('https://acme.trycomp.ai', defaults), - ).toBe(true); + isCompExtensionOriginAllowedForRequest({ + method: 'GET', + origin: extensionOrigin, + path: '/v1/controls', + }), + ).toBe(false); + }); + + it('should reject wrong methods on extension paths', () => { + expect( + isCompExtensionOriginAllowedForRequest({ + method: 'POST', + origin: extensionOrigin, + path: '/v1/auth/me', + }), + ).toBe(false); + }); + + it('should reject allowed paths from unknown extension origins', () => { + expect( + isCompExtensionOriginAllowedForRequest({ + method: 'POST', + origin: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + path: '/v1/questionnaire/answer-single', + }), + ).toBe(false); + }); + + it('should identify Chrome extension origins', () => { + expect(isChromeExtensionOrigin(extensionOrigin)).toBe(true); + expect(isChromeExtensionOrigin('https://app.trycomp.ai')).toBe(false); + }); +}); + +describe('isStaticTrustedOrigin', () => { + it('should allow static trusted origins', () => { + expect(isStaticTrustedOrigin('https://app.trycomp.ai')).toBe(true); + }); + + it('should allow trust portal subdomains of trycomp.ai', () => { + expect(isStaticTrustedOrigin('https://security.trycomp.ai')).toBe(true); + expect(isStaticTrustedOrigin('https://acme.trycomp.ai')).toBe(true); }); it('should allow trust portal subdomains of staging.trycomp.ai', () => { expect( - isStaticTrustedOriginLogic( - 'https://security.staging.trycomp.ai', - defaults, - ), + isStaticTrustedOrigin('https://security.staging.trycomp.ai'), ).toBe(true); }); it('should allow trust.inc and its subdomains', () => { - expect(isStaticTrustedOriginLogic('https://trust.inc', defaults)).toBe( - true, - ); - expect(isStaticTrustedOriginLogic('https://acme.trust.inc', defaults)).toBe( - true, - ); + expect(isStaticTrustedOrigin('https://trust.inc')).toBe(true); + expect(isStaticTrustedOrigin('https://acme.trust.inc')).toBe(true); }); it('should reject unknown origins', () => { - expect(isStaticTrustedOriginLogic('https://evil.com', defaults)).toBe( - false, - ); + expect(isStaticTrustedOrigin('https://evil.com')).toBe(false); expect( - isStaticTrustedOriginLogic('https://trycomp.ai.evil.com', defaults), + isStaticTrustedOrigin('https://trycomp.ai.evil.com'), ).toBe(false); }); it('should handle invalid origins gracefully', () => { - expect(isStaticTrustedOriginLogic('not-a-url', defaults)).toBe(false); + expect(isStaticTrustedOrigin('not-a-url')).toBe(false); }); - it('main.ts should use isTrustedOrigin for CORS', () => { + it('main.ts should use path-aware CORS middleware', () => { const fs = require('fs'); const path = require('path'); const mainTs = fs.readFileSync( @@ -134,9 +159,10 @@ describe('isStaticTrustedOrigin', () => { 'utf-8', ) as string; expect(mainTs).not.toContain('origin: true'); - expect(mainTs).toContain('isTrustedOrigin'); + expect(mainTs).not.toContain('app.enableCors'); + expect(mainTs).toContain('app.use(corsOriginMiddleware)'); expect(mainTs).toContain( - "import { isTrustedOrigin } from './auth/auth.server'", + "import { corsOriginMiddleware } from './auth/cors-origin.middleware'", ); }); }); diff --git a/apps/api/src/auth/auth.server.ts b/apps/api/src/auth/auth.server.ts index 83f6d1bd68..8ee17de51b 100644 --- a/apps/api/src/auth/auth.server.ts +++ b/apps/api/src/auth/auth.server.ts @@ -18,6 +18,22 @@ import { ac, allRoles } from '@trycompai/auth'; import { createAuthMiddleware } from 'better-auth/api'; import { Redis } from '@upstash/redis'; import type { AccessControl } from 'better-auth/plugins/access'; +import { + getBetterAuthTrustedOrigins, + isStaticTrustedOrigin, +} from './origin-policy'; + +export { + getBetterAuthTrustedOrigins, + getCompExtensionTrustedOrigins, + getTrustedOrigins, + isChromeExtensionOrigin, + isCompExtensionAllowedRoute, + isCompExtensionOrigin, + isCompExtensionOriginAllowedForRequest, + isStaticTrustedOrigin, + isStaticTrustedOriginForRequest, +} from './origin-policy'; const MAGIC_LINK_EXPIRES_IN_SECONDS = 60 * 60; // 1 hour @@ -36,55 +52,6 @@ function getCookieDomain(): string | undefined { return undefined; } -/** - * Get trusted origins for CORS/auth - */ -export function getTrustedOrigins(): string[] { - const origins = process.env.AUTH_TRUSTED_ORIGINS; - if (origins) { - return origins.split(',').map((o) => o.trim()); - } - - return [ - 'http://localhost:3000', - 'http://localhost:3002', - 'http://localhost:3333', - 'http://localhost:3004', - 'http://localhost:3008', - 'https://app.trycomp.ai', - 'https://portal.trycomp.ai', - 'https://api.trycomp.ai', - 'https://app.staging.trycomp.ai', - 'https://portal.staging.trycomp.ai', - 'https://api.staging.trycomp.ai', - 'https://dev.trycomp.ai', - 'https://framework-editor.trycomp.ai', - ]; -} - -/** - * Check if an origin matches a known trusted pattern (static list + subdomains). - * This is a fast synchronous check that doesn't hit the DB. - */ -export function isStaticTrustedOrigin(origin: string): boolean { - const trustedOrigins = getTrustedOrigins(); - if (trustedOrigins.includes(origin)) { - return true; - } - - try { - const url = new URL(origin); - return ( - url.hostname.endsWith('.trycomp.ai') || - url.hostname.endsWith('.staging.trycomp.ai') || - url.hostname.endsWith('.trust.inc') || - url.hostname === 'trust.inc' - ); - } catch { - return false; - } -} - // ── Custom domain lookup via Redis cache ───────────────────────────────────── const CORS_DOMAINS_CACHE_KEY = 'cors:custom-domains'; @@ -279,7 +246,7 @@ export const auth = betterAuth({ // after OAuth processing, better-auth redirects to the correct app. // Cross-subdomain cookies (.trycomp.ai) ensure the session works everywhere. baseURL: process.env.BASE_URL || 'http://localhost:3333', - trustedOrigins: getTrustedOrigins(), + trustedOrigins: getBetterAuthTrustedOrigins(), emailAndPassword: { enabled: true, }, diff --git a/apps/api/src/auth/cors-origin.middleware.spec.ts b/apps/api/src/auth/cors-origin.middleware.spec.ts new file mode 100644 index 0000000000..eb99a69684 --- /dev/null +++ b/apps/api/src/auth/cors-origin.middleware.spec.ts @@ -0,0 +1,186 @@ +import type { NextFunction, Request, Response } from 'express'; +import { isTrustedOrigin } from './auth.server'; +import { corsOriginMiddleware } from './cors-origin.middleware'; + +jest.mock('./auth.server', () => ({ + isTrustedOrigin: jest.fn(), +})); + +type MockResponse = Partial & { + body?: unknown; + headers: Record; + statusCode?: number; +}; + +const extensionOrigin = + 'chrome-extension://panomgbokjppnleifmpcnpchjgpcngan'; + +function createRequest(params: { + method: string; + path: string; + origin?: string; + requestedMethod?: string; + requestedHeaders?: string; +}): Partial { + return { + method: params.method, + path: params.path, + headers: { + ...(params.origin ? { origin: params.origin } : {}), + ...(params.requestedMethod + ? { 'access-control-request-method': params.requestedMethod } + : {}), + ...(params.requestedHeaders + ? { 'access-control-request-headers': params.requestedHeaders } + : {}), + }, + }; +} + +function createResponse(): MockResponse { + const response = { + headers: {}, + } as MockResponse; + response.setHeader = jest + .fn() + .mockImplementation((name: string, value: string) => { + response.headers[name] = value; + return response; + }); + response.status = jest.fn().mockImplementation((statusCode: number) => { + response.statusCode = statusCode; + return response; + }); + response.send = jest.fn().mockImplementation((body?: unknown) => { + response.body = body; + return response; + }); + return response; +} + +function runCors(params: { + request: Partial; + response: MockResponse; + next: NextFunction; +}): void { + corsOriginMiddleware( + params.request as Request, + params.response as Response, + params.next, + ); +} + +const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); + +describe('corsOriginMiddleware', () => { + const originalExtensionOrigins = process.env.COMP_EXTENSION_TRUSTED_ORIGINS; + + beforeEach(() => { + process.env.COMP_EXTENSION_TRUSTED_ORIGINS = extensionOrigin; + jest.mocked(isTrustedOrigin).mockResolvedValue(false); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + if (originalExtensionOrigins === undefined) { + delete process.env.COMP_EXTENSION_TRUSTED_ORIGINS; + return; + } + process.env.COMP_EXTENSION_TRUSTED_ORIGINS = originalExtensionOrigins; + }); + + it('allows configured extension origins on extension paths', async () => { + const request = createRequest({ + method: 'OPTIONS', + origin: extensionOrigin, + path: '/v1/questionnaire/answer-single', + requestedMethod: 'POST', + requestedHeaders: 'Content-Type', + }); + const response = createResponse(); + const next = jest.fn(); + + runCors({ request, response, next }); + await flushPromises(); + + expect(response.headers['Access-Control-Allow-Origin']).toBe( + extensionOrigin, + ); + expect(response.status).toHaveBeenCalledWith(204); + expect(next).not.toHaveBeenCalled(); + }); + + it('does not allow configured extension origins with wrong methods', async () => { + const request = createRequest({ + method: 'OPTIONS', + origin: extensionOrigin, + path: '/v1/auth/me', + requestedMethod: 'POST', + }); + const response = createResponse(); + const next = jest.fn(); + + runCors({ request, response, next }); + await flushPromises(); + + expect(response.headers['Access-Control-Allow-Origin']).toBeUndefined(); + expect(response.status).toHaveBeenCalledWith(204); + expect(next).not.toHaveBeenCalled(); + }); + + it('does not allow configured extension origins on unrelated paths', async () => { + const request = createRequest({ + method: 'OPTIONS', + origin: extensionOrigin, + path: '/v1/controls', + }); + const response = createResponse(); + const next = jest.fn(); + + runCors({ request, response, next }); + await flushPromises(); + + expect(response.headers['Access-Control-Allow-Origin']).toBeUndefined(); + expect(response.status).toHaveBeenCalledWith(204); + expect(next).not.toHaveBeenCalled(); + }); + + it('does not allow unknown extension origins on extension paths', async () => { + const request = createRequest({ + method: 'OPTIONS', + origin: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + path: '/v1/auth/me', + }); + const response = createResponse(); + const next = jest.fn(); + + runCors({ request, response, next }); + await flushPromises(); + + expect(response.headers['Access-Control-Allow-Origin']).toBeUndefined(); + expect(response.status).toHaveBeenCalledWith(204); + expect(next).not.toHaveBeenCalled(); + }); + + it('allows normal trusted origins on any API path', async () => { + jest.mocked(isTrustedOrigin).mockResolvedValue(true); + const request = createRequest({ + method: 'GET', + origin: 'https://app.trycomp.ai', + path: '/v1/controls', + }); + const response = createResponse(); + const next = jest.fn(); + + runCors({ request, response, next }); + await flushPromises(); + + expect(response.headers['Access-Control-Allow-Origin']).toBe( + 'https://app.trycomp.ai', + ); + expect(next).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/auth/cors-origin.middleware.ts b/apps/api/src/auth/cors-origin.middleware.ts new file mode 100644 index 0000000000..2aaa6e3a1c --- /dev/null +++ b/apps/api/src/auth/cors-origin.middleware.ts @@ -0,0 +1,86 @@ +import type { NextFunction, Request, Response } from 'express'; +import { isTrustedOrigin } from './auth.server'; +import { isCompExtensionOriginAllowedForRequest } from './origin-policy'; + +const CORS_METHODS = 'GET,POST,PUT,DELETE,PATCH,OPTIONS'; +const DEFAULT_CORS_HEADERS = + 'Content-Type,Authorization,X-API-Key,X-Service-Token,X-Organization-Id'; + +function getHeaderValue(value: string | string[] | undefined): string | null { + if (typeof value === 'string') return value; + if (Array.isArray(value)) return value[0] ?? null; + return null; +} + +function setCorsHeaders(params: { + request: Request; + response: Response; + origin: string; +}): void { + const requestedHeaders = getHeaderValue( + params.request.headers['access-control-request-headers'], + ); + params.response.setHeader('Access-Control-Allow-Origin', params.origin); + params.response.setHeader('Access-Control-Allow-Credentials', 'true'); + params.response.setHeader('Access-Control-Allow-Methods', CORS_METHODS); + params.response.setHeader( + 'Access-Control-Allow-Headers', + requestedHeaders ?? DEFAULT_CORS_HEADERS, + ); + params.response.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); +} + +async function isCorsOriginAllowed(params: { + method: string; + origin: string; + path: string; +}): Promise { + if (isCompExtensionOriginAllowedForRequest(params)) return true; + return isTrustedOrigin(params.origin); +} + +function endPreflight(response: Response): void { + response.status(204).send(); +} + +export function corsOriginMiddleware( + request: Request, + response: Response, + next: NextFunction, +): void { + const origin = getHeaderValue(request.headers.origin); + const requestedMethod = getHeaderValue( + request.headers['access-control-request-method'], + ); + const method = + request.method === 'OPTIONS' && requestedMethod + ? requestedMethod + : request.method; + if (!origin) { + if (request.method === 'OPTIONS') { + endPreflight(response); + return; + } + next(); + return; + } + + isCorsOriginAllowed({ method, origin, path: request.path }) + .then((allowed) => { + if (allowed) { + setCorsHeaders({ request, response, origin }); + } + if (request.method === 'OPTIONS') { + endPreflight(response); + return; + } + next(); + }) + .catch(() => { + if (request.method === 'OPTIONS') { + endPreflight(response); + return; + } + next(); + }); +} diff --git a/apps/api/src/auth/origin-check.middleware.spec.ts b/apps/api/src/auth/origin-check.middleware.spec.ts index f679d97f6e..6698b4cd7c 100644 --- a/apps/api/src/auth/origin-check.middleware.spec.ts +++ b/apps/api/src/auth/origin-check.middleware.spec.ts @@ -1,4 +1,5 @@ import { originCheckMiddleware } from './origin-check.middleware'; +import type { NextFunction, Request, Response } from 'express'; // Mock isTrustedOrigin (async version) jest.mock('./auth.server', () => ({ @@ -28,7 +29,7 @@ function createMockReq( method: string, path: string, origin?: string, -): Record { +): Partial { return { method, path, @@ -39,11 +40,11 @@ function createMockReq( /** Flush the microtask queue so async middleware completes. */ const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); -function createMockRes(): Record & { +function createMockRes(): Partial & { statusCode?: number; body?: unknown; } { - const res: Record & { statusCode?: number; body?: unknown } = + const res: Partial & { statusCode?: number; body?: unknown } = {}; res.status = jest.fn().mockImplementation((code: number) => { res.statusCode = code; @@ -56,13 +57,41 @@ function createMockRes(): Record & { return res; } +function runOriginCheck(params: { + req: Partial; + res: Partial; + next: NextFunction; +}): void { + originCheckMiddleware( + params.req as Request, + params.res as Response, + params.next, + ); +} + describe('originCheckMiddleware', () => { + const originalExtensionOrigins = process.env.COMP_EXTENSION_TRUSTED_ORIGINS; + const extensionOrigin = + 'chrome-extension://panomgbokjppnleifmpcnpchjgpcngan'; + + beforeEach(() => { + process.env.COMP_EXTENSION_TRUSTED_ORIGINS = extensionOrigin; + }); + + afterAll(() => { + if (originalExtensionOrigins === undefined) { + delete process.env.COMP_EXTENSION_TRUSTED_ORIGINS; + return; + } + process.env.COMP_EXTENSION_TRUSTED_ORIGINS = originalExtensionOrigins; + }); + it('should allow GET requests regardless of origin', () => { const req = createMockReq('GET', '/v1/controls', 'http://evil.com'); const res = createMockRes(); const next = jest.fn(); - originCheckMiddleware(req as any, res as any, next); + runOriginCheck({ req, res, next }); expect(next).toHaveBeenCalled(); }); @@ -72,7 +101,7 @@ describe('originCheckMiddleware', () => { const res = createMockRes(); const next = jest.fn(); - originCheckMiddleware(req as any, res as any, next); + runOriginCheck({ req, res, next }); expect(next).toHaveBeenCalled(); }); @@ -82,7 +111,7 @@ describe('originCheckMiddleware', () => { const res = createMockRes(); const next = jest.fn(); - originCheckMiddleware(req as any, res as any, next); + runOriginCheck({ req, res, next }); expect(next).toHaveBeenCalled(); }); @@ -96,7 +125,7 @@ describe('originCheckMiddleware', () => { const res = createMockRes(); const next = jest.fn(); - originCheckMiddleware(req as any, res as any, next); + runOriginCheck({ req, res, next }); await flushPromises(); expect(next).toHaveBeenCalled(); @@ -111,7 +140,7 @@ describe('originCheckMiddleware', () => { const res = createMockRes(); const next = jest.fn(); - originCheckMiddleware(req as any, res as any, next); + runOriginCheck({ req, res, next }); await flushPromises(); expect(next).not.toHaveBeenCalled(); @@ -123,7 +152,7 @@ describe('originCheckMiddleware', () => { const res = createMockRes(); const next = jest.fn(); - originCheckMiddleware(req as any, res as any, next); + runOriginCheck({ req, res, next }); await flushPromises(); expect(next).not.toHaveBeenCalled(); @@ -139,7 +168,7 @@ describe('originCheckMiddleware', () => { const res = createMockRes(); const next = jest.fn(); - originCheckMiddleware(req as any, res as any, next); + runOriginCheck({ req, res, next }); await flushPromises(); expect(next).not.toHaveBeenCalled(); @@ -151,7 +180,7 @@ describe('originCheckMiddleware', () => { const res = createMockRes(); const next = jest.fn(); - originCheckMiddleware(req as any, res as any, next); + runOriginCheck({ req, res, next }); expect(next).toHaveBeenCalled(); }); @@ -161,7 +190,7 @@ describe('originCheckMiddleware', () => { const res = createMockRes(); const next = jest.fn(); - originCheckMiddleware(req as any, res as any, next); + runOriginCheck({ req, res, next }); expect(next).toHaveBeenCalled(); }); @@ -171,7 +200,7 @@ describe('originCheckMiddleware', () => { const res = createMockRes(); const next = jest.fn(); - originCheckMiddleware(req as any, res as any, next); + runOriginCheck({ req, res, next }); expect(next).toHaveBeenCalled(); }); @@ -185,9 +214,60 @@ describe('originCheckMiddleware', () => { const res = createMockRes(); const next = jest.fn(); - originCheckMiddleware(req as any, res as any, next); + runOriginCheck({ req, res, next }); await flushPromises(); expect(next).toHaveBeenCalled(); }); + + it('should allow configured extension origin on extension routes', () => { + const req = createMockReq( + 'POST', + '/v1/questionnaire/answer-single', + extensionOrigin, + ); + const res = createMockRes(); + const next = jest.fn(); + + runOriginCheck({ req, res, next }); + + expect(next).toHaveBeenCalled(); + }); + + it('should block configured extension origin on unrelated GET routes', () => { + const req = createMockReq('GET', '/v1/controls', extensionOrigin); + const res = createMockRes(); + const next = jest.fn(); + + runOriginCheck({ req, res, next }); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('should block configured extension origin on unrelated auth routes', () => { + const req = createMockReq('POST', '/api/auth/sign-out', extensionOrigin); + const res = createMockRes(); + const next = jest.fn(); + + runOriginCheck({ req, res, next }); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('should block unknown extension origins on extension routes', () => { + const req = createMockReq( + 'GET', + '/v1/auth/me', + 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ); + const res = createMockRes(); + const next = jest.fn(); + + runOriginCheck({ req, res, next }); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); }); diff --git a/apps/api/src/auth/origin-check.middleware.ts b/apps/api/src/auth/origin-check.middleware.ts index 44e4c868e7..68d9f0d4ff 100644 --- a/apps/api/src/auth/origin-check.middleware.ts +++ b/apps/api/src/auth/origin-check.middleware.ts @@ -1,5 +1,10 @@ import type { Request, Response, NextFunction } from 'express'; import { isTrustedOrigin } from './auth.server'; +import { + isChromeExtensionOrigin, + isCompExtensionOrigin, + isCompExtensionOriginAllowedForRequest, +} from './origin-policy'; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); @@ -32,12 +37,35 @@ export function originCheckMiddleware( res: Response, next: NextFunction, ): void { - // Allow safe (read-only) methods + const origin = Array.isArray(req.headers.origin) + ? req.headers.origin[0] + : req.headers.origin; + + // Chrome extension origins are intentionally route-scoped. + if (origin && isChromeExtensionOrigin(origin)) { + if ( + isCompExtensionOrigin(origin) && + isCompExtensionOriginAllowedForRequest({ + method: req.method, + origin, + path: req.path, + }) + ) { + return next(); + } + res.status(403).json({ + statusCode: 403, + message: 'Forbidden', + }); + return; + } + + // Allow safe (read-only) methods for regular browser origins. if (SAFE_METHODS.has(req.method)) { return next(); } - // Allow exempt paths (webhooks, auth, etc.) + // Allow exempt paths (webhooks, auth, etc.) for non-extension origins. const isExempt = EXEMPT_PATH_PREFIXES.some((prefix) => req.path.startsWith(prefix), ); @@ -45,8 +73,6 @@ export function originCheckMiddleware( return next(); } - const origin = req.headers['origin']; - // No Origin header = not a browser request (API key, service token, curl, etc.) // These are authenticated via HybridAuthGuard, not cookies, so no CSRF risk. if (!origin) { diff --git a/apps/api/src/auth/origin-policy.ts b/apps/api/src/auth/origin-policy.ts new file mode 100644 index 0000000000..207c6019fb --- /dev/null +++ b/apps/api/src/auth/origin-policy.ts @@ -0,0 +1,112 @@ +const DEFAULT_TRUSTED_ORIGINS = [ + 'http://localhost:3000', + 'http://localhost:3002', + 'http://localhost:3333', + 'http://localhost:3004', + 'http://localhost:3008', + 'https://app.trycomp.ai', + 'https://portal.trycomp.ai', + 'https://api.trycomp.ai', + 'https://app.staging.trycomp.ai', + 'https://portal.staging.trycomp.ai', + 'https://api.staging.trycomp.ai', + 'https://dev.trycomp.ai', + 'https://framework-editor.trycomp.ai', +]; + +const COMP_EXTENSION_ALLOWED_ROUTES = [ + { method: 'GET', path: '/api/auth/get-session' }, + { method: 'GET', path: '/v1/auth/me' }, + { method: 'POST', path: '/api/auth/organization/set-active' }, + { method: 'POST', path: '/v1/questionnaire/answer-single' }, +]; + +function parseOriginList(value: string | undefined): string[] { + if (!value) return []; + return value + .split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); +} + +function normalizePath(path: string): string { + if (path.length <= 1) return path; + return path.replace(/\/+$/, ''); +} + +export function getTrustedOrigins(): string[] { + const origins = parseOriginList(process.env.AUTH_TRUSTED_ORIGINS); + return origins.length > 0 ? origins : [...DEFAULT_TRUSTED_ORIGINS]; +} + +export function getCompExtensionTrustedOrigins(): string[] { + return parseOriginList(process.env.COMP_EXTENSION_TRUSTED_ORIGINS); +} + +export function getBetterAuthTrustedOrigins(): string[] { + return [...getTrustedOrigins(), ...getCompExtensionTrustedOrigins()]; +} + +export function isCompExtensionOrigin(origin: string): boolean { + return getCompExtensionTrustedOrigins().includes(origin); +} + +export function isChromeExtensionOrigin(origin: string): boolean { + try { + return new URL(origin).protocol === 'chrome-extension:'; + } catch { + return false; + } +} + +export function isCompExtensionAllowedRoute(params: { + method: string; + path: string; +}): boolean { + const method = params.method.toUpperCase(); + const path = normalizePath(params.path); + return COMP_EXTENSION_ALLOWED_ROUTES.some( + (route) => route.method === method && route.path === path, + ); +} + +export function isCompExtensionOriginAllowedForRequest(params: { + method: string; + origin: string; + path: string; +}): boolean { + return ( + isCompExtensionOrigin(params.origin) && + isCompExtensionAllowedRoute({ method: params.method, path: params.path }) + ); +} + +export function isStaticTrustedOrigin(origin: string): boolean { + const trustedOrigins = getTrustedOrigins(); + if (trustedOrigins.includes(origin)) { + return true; + } + + try { + const url = new URL(origin); + return ( + url.hostname.endsWith('.trycomp.ai') || + url.hostname.endsWith('.staging.trycomp.ai') || + url.hostname.endsWith('.trust.inc') || + url.hostname === 'trust.inc' + ); + } catch { + return false; + } +} + +export function isStaticTrustedOriginForRequest(params: { + method: string; + origin: string; + path: string; +}): boolean { + return ( + isStaticTrustedOrigin(params.origin) || + isCompExtensionOriginAllowedForRequest(params) + ); +} diff --git a/apps/api/src/common/filters/cors-exception.filter.ts b/apps/api/src/common/filters/cors-exception.filter.ts index f8fe0a8aa5..e34cb45962 100644 --- a/apps/api/src/common/filters/cors-exception.filter.ts +++ b/apps/api/src/common/filters/cors-exception.filter.ts @@ -5,7 +5,7 @@ import { HttpException, } from '@nestjs/common'; import type { Response, Request } from 'express'; -import { isStaticTrustedOrigin } from '../../auth/auth.server'; +import { isStaticTrustedOriginForRequest } from '../../auth/origin-policy'; @Catch(HttpException) export class CorsExceptionFilter implements ExceptionFilter { @@ -21,7 +21,14 @@ export class CorsExceptionFilter implements ExceptionFilter { // Set CORS headers on error responses for trusted origins. // Uses the sync check only — the main CORS middleware already validated // custom domains on the way in, so this is a best-effort fallback. - if (origin && isStaticTrustedOrigin(origin)) { + if ( + origin && + isStaticTrustedOriginForRequest({ + method: request.method, + origin, + path: request.path, + }) + ) { response.setHeader('Access-Control-Allow-Origin', origin); response.setHeader('Access-Control-Allow-Credentials', 'true'); response.setHeader( diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 62b5f72c6a..ab6d69e729 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -13,7 +13,7 @@ import { PUBLIC_OPENAPI_DESCRIPTION, PUBLIC_OPENAPI_TITLE, } from './openapi/public-docs-metadata'; -import { isTrustedOrigin } from './auth/auth.server'; +import { corsOriginMiddleware } from './auth/cors-origin.middleware'; import { adminAuthRateLimiter } from './auth/admin-rate-limit.middleware'; import { originCheckMiddleware } from './auth/origin-check.middleware'; import { mkdirSync, writeFileSync, existsSync } from 'fs'; @@ -40,22 +40,9 @@ async function bootstrap(): Promise { bodyParser: false, }); - // Enable CORS with origin validation. - // Uses a callback to support dynamic trust portal subdomains - // (e.g. security.trycomp.ai, acme.trust.inc) and verified custom domains. - app.enableCors({ - origin: (origin, callback) => { - // Allow requests with no origin (non-browser clients, same-origin, etc.) - if (!origin) { - return callback(null, true); - } - isTrustedOrigin(origin) - .then((trusted) => callback(null, trusted)) - .catch(() => callback(null, false)); - }, - credentials: true, - exposedHeaders: ['Content-Disposition'], - }); + // Enable path-aware CORS with origin validation. + // Comp extension origins are allowed only on explicitly supported routes. + app.use(corsOriginMiddleware); // STEP 2: Security headers app.use( diff --git a/apps/api/src/questionnaire/dto/answer-single-question.dto.ts b/apps/api/src/questionnaire/dto/answer-single-question.dto.ts index 88ba6a107b..a7d0fe7abf 100644 --- a/apps/api/src/questionnaire/dto/answer-single-question.dto.ts +++ b/apps/api/src/questionnaire/dto/answer-single-question.dto.ts @@ -1,20 +1,45 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsInt, IsOptional, IsString, Min } from 'class-validator'; export class AnswerSingleQuestionDto { + @ApiProperty({ + description: 'Security questionnaire question to answer.', + example: 'Do you encrypt customer data at rest?', + }) @IsString() question!: string; + @ApiProperty({ + description: 'Zero-based index of this question in the questionnaire or page batch.', + example: 0, + minimum: 0, + }) @IsInt() @Min(0) questionIndex!: number; + @ApiProperty({ + description: 'Total number of questions in the current questionnaire or page batch.', + example: 12, + minimum: 1, + }) @IsInt() @Min(1) totalQuestions!: number; + @ApiProperty({ + description: + 'Organization ID for validation. The API uses the authenticated active organization and overwrites this value server-side.', + example: 'org_abc123', + }) @IsString() organizationId!: string; + @ApiPropertyOptional({ + description: + 'Optional questionnaire record to save the generated answer into. Omit for webpage-only answer generation.', + example: 'qst_abc123', + }) @IsOptional() @IsString() questionnaireId?: string; diff --git a/apps/api/src/questionnaire/questionnaire.controller.ts b/apps/api/src/questionnaire/questionnaire.controller.ts index 64d127334d..2d17571bc1 100644 --- a/apps/api/src/questionnaire/questionnaire.controller.ts +++ b/apps/api/src/questionnaire/questionnaire.controller.ts @@ -174,8 +174,13 @@ export class QuestionnaireController { @Post('answer-single') @RequirePermission('questionnaire', 'update') - @ApiOperation({ summary: 'Answer a single questionnaire question' }) + @ApiOperation({ + summary: 'Answer a single questionnaire question', + description: + 'Generate one questionnaire answer from the active organization knowledge base. Omit questionnaireId for webpage-only drafts that should not be saved.', + }) @ApiConsumes('application/json') + @ApiBody({ type: AnswerSingleQuestionDto }) @ApiOkResponse({ description: 'Generated single answer result', schema: { @@ -728,6 +733,7 @@ export class QuestionnaireController { try { await this.questionnaireService.saveGeneratedAnswerPublic({ questionnaireId: dto.questionnaireId, + organizationId: dto.organizationId, questionIndex: qa.index, answer: result.answer, sources: result.sources, diff --git a/apps/api/src/questionnaire/questionnaire.service.spec.ts b/apps/api/src/questionnaire/questionnaire.service.spec.ts index cfecd51767..60d81f13bf 100644 --- a/apps/api/src/questionnaire/questionnaire.service.spec.ts +++ b/apps/api/src/questionnaire/questionnaire.service.spec.ts @@ -590,6 +590,7 @@ describe('QuestionnaireService', () => { expect(result.answer).toBe('A1'); expect(saveGeneratedAnswer).toHaveBeenCalledWith({ questionnaireId: 'q1', + organizationId: 'org_1', questionIndex: 0, answer: 'A1', sources: [], diff --git a/apps/api/src/questionnaire/questionnaire.service.ts b/apps/api/src/questionnaire/questionnaire.service.ts index 9330106bf4..b7a15e4320 100644 --- a/apps/api/src/questionnaire/questionnaire.service.ts +++ b/apps/api/src/questionnaire/questionnaire.service.ts @@ -350,6 +350,7 @@ export class QuestionnaireService { if (result.success && result.answer && dto.questionnaireId) { await saveGeneratedAnswer({ questionnaireId: dto.questionnaireId, + organizationId: dto.organizationId, questionIndex: dto.questionIndex, answer: result.answer, sources: result.sources, @@ -565,6 +566,7 @@ export class QuestionnaireService { */ async saveGeneratedAnswerPublic(params: { questionnaireId: string; + organizationId: string; questionIndex: number; answer: string; sources?: AnswerQuestionResult['sources']; diff --git a/apps/api/src/questionnaire/utils/questionnaire-storage.ts b/apps/api/src/questionnaire/utils/questionnaire-storage.ts index 2a7ddce9ec..ef892eb8b8 100644 --- a/apps/api/src/questionnaire/utils/questionnaire-storage.ts +++ b/apps/api/src/questionnaire/utils/questionnaire-storage.ts @@ -168,6 +168,7 @@ export async function uploadQuestionnaireFile(params: { */ export async function saveGeneratedAnswer(params: { questionnaireId: string; + organizationId: string; questionIndex: number; answer: string; sources?: unknown; @@ -176,9 +177,30 @@ export async function saveGeneratedAnswer(params: { where: { questionnaireId: params.questionnaireId, questionIndex: params.questionIndex, + questionnaire: { + is: { + organizationId: params.organizationId, + }, + }, }, }); + if (!question) { + const questionnaire = await db.questionnaire.findFirst({ + where: { + id: params.questionnaireId, + organizationId: params.organizationId, + }, + select: { id: true }, + }); + + if (!questionnaire) { + throw new Error( + `Questionnaire ${params.questionnaireId} not found for organization ${params.organizationId}`, + ); + } + } + if (question) { await db.questionnaireQuestionAnswer.update({ where: { id: question.id }, diff --git a/apps/api/src/trigger/questionnaire/auto-answer-questionnaire.ts b/apps/api/src/trigger/questionnaire/auto-answer-questionnaire.ts index 7e3bde1f0a..e3cb0bdcc8 100644 --- a/apps/api/src/trigger/questionnaire/auto-answer-questionnaire.ts +++ b/apps/api/src/trigger/questionnaire/auto-answer-questionnaire.ts @@ -96,6 +96,7 @@ export const autoAnswerQuestionnaireTask = task({ } await saveGeneratedAnswer({ questionnaireId, + organizationId, questionIndex: toAnswer[i].questionIndex, answer: result.answer, sources: result.sources, diff --git a/apps/browser-extension/security-questionnaire-ext/.env.example b/apps/browser-extension/security-questionnaire-ext/.env.example new file mode 100644 index 0000000000..1d118d0cda --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/.env.example @@ -0,0 +1,12 @@ +WXT_PUBLIC_API_BASE_URL=http://localhost:3333 +WXT_PUBLIC_APP_BASE_URL=http://localhost:3000 + +# Optional: set a stable unpacked-extension ID for local/staging CORS allowlists. +# Generate from a Chrome extension private key and add chrome-extension:// +# to COMP_EXTENSION_TRUSTED_ORIGINS on the API. +WXT_EXTENSION_KEY= + +# Optional: enables direct Google Sheets API insertion instead of guided paste. +# This must be a Google Cloud OAuth client of type "Chrome extension" for the +# extension id produced by WXT_EXTENSION_KEY. +WXT_GOOGLE_OAUTH_CLIENT_ID= diff --git a/apps/browser-extension/security-questionnaire-ext/README.md b/apps/browser-extension/security-questionnaire-ext/README.md new file mode 100644 index 0000000000..5ae64d85f8 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/README.md @@ -0,0 +1,141 @@ +# Security Questionnaire Extension + +Chrome MV3 extension for generating security questionnaire answers from Comp AI +and inserting them into third-party questionnaire pages. + +## Current UX + +- Popup: session status, active organization switcher, question detection toggle, + and entry point to the review panel. +- Side panel: persistent questionnaire queue with generate, edit, approve, and + insert actions. +- Content script: inline Comp buttons on generic web forms, Shadow DOM answer + preview, page detection, and field insertion. +- Guardrails: first generate/insert per domain requires workspace confirmation; + batch insert always asks for confirmation; switching organizations clears + uninserted generated drafts and re-arms domain confirmation. + +Google Docs and Google Sheets are detected as special surfaces. Google Sheets +supports mapped answer columns. When Google OAuth is configured, answers are +inserted directly through the Sheets API; otherwise the extension falls back to +a guided mapped paste flow. + +## Local Development + +1. Install dependencies from the repo root: + + ```bash + bun install + ``` + +2. Configure the extension: + + ```bash + cp apps/browser-extension/security-questionnaire-ext/.env.example apps/browser-extension/security-questionnaire-ext/.env + ``` + + To enable direct Google Sheets insertion, set: + + ```text + WXT_GOOGLE_OAUTH_CLIENT_ID= + ``` + + The OAuth client must be a Google Cloud OAuth client of type + `Chrome extension`, created for the exact extension id. For local/staging, + use `WXT_EXTENSION_KEY` so the unpacked extension id stays stable. + +3. Add the unpacked extension origin to the API + `COMP_EXTENSION_TRUSTED_ORIGINS`: + + ```text + chrome-extension:// + ``` + +4. Build the static unpacked extension: + + ```bash + bun run --filter '@trycompai/security-questionnaire-extension' build + ``` + +5. Load the generated unpacked extension in Chrome: + + ```text + apps/browser-extension/security-questionnaire-ext/dist/chrome-mv3 + ``` + + The `dist/chrome-mv3-dev` directory is only for WXT dev-server sessions. It + references `localhost:3100`, so the popup is blank when that server is not + running. + +The extension uses the existing Comp session flow and calls +`POST /v1/questionnaire/answer-single` without `questionnaireId` for webpage +questions, so generated answers are not persisted as questionnaire records. + +## Configuration Model + +Local development uses `apps/browser-extension/security-questionnaire-ext/.env`. +That file is ignored by git and is only for your local unpacked extension. + +Production builds do not use the local `.env`. The release workflow injects the +production values from GitHub Actions: + +```text +WXT_PUBLIC_API_BASE_URL=https://api.trycomp.ai +WXT_PUBLIC_APP_BASE_URL=https://app.trycomp.ai +WXT_GOOGLE_OAUTH_CLIENT_ID= +``` + +The `WXT_PUBLIC_*` values and the Google OAuth client id are public build-time +configuration. They are expected to appear in the built extension. The real +secrets are the Chrome Web Store OAuth credentials and refresh token used by CI +to upload the package. + +Before the published extension can authenticate against production, the API +production `COMP_EXTENSION_TRUSTED_ORIGINS` must include: + +```text +chrome-extension:// +``` + +## Chrome Web Store Release + +Publishing is handled by the `Security Questionnaire Extension Release` +workflow. It only runs on the `release` branch, which is the production branch, +and only when this extension or the workflow file changes. + +The workflow: + +- computes the next `security-questionnaire-ext-vX.Y.Z` tag, +- injects that version into the extension package before building, +- runs typecheck and tests, +- builds `dist/chrome-mv3` with production API/app URLs, +- uploads the ZIP to the existing Chrome Web Store item, +- calls the Chrome Web Store publish API, and +- creates the extension version tag after a successful publish. + +Required GitHub secrets: + +```text +CHROME_WEB_STORE_CLIENT_ID +CHROME_WEB_STORE_CLIENT_SECRET +CHROME_WEB_STORE_REFRESH_TOKEN +CHROME_WEB_STORE_PUBLISHER_ID +SECURITY_QUESTIONNAIRE_EXTENSION_ID +SECURITY_QUESTIONNAIRE_EXTENSION_GOOGLE_OAUTH_CLIENT_ID +``` + +The Chrome Web Store item must already exist and have its listing/privacy fields +ready in the Developer Dashboard. The workflow updates an existing item; it does +not create the first listing. + +## Troubleshooting + +- If Chrome reports `Extension context invalidated`, reload the affected browser + tab after reloading the extension. Existing tabs can keep an old content script + until the page is refreshed. +- If the console shows `ws://localhost:3100` or `[wxt] Failed to connect to dev + server`, the loaded extension is `dist/chrome-mv3-dev`. Remove that unpacked + extension, or run `bun run --filter '@trycompai/security-questionnaire-extension' dev`. + For normal local testing, load `dist/chrome-mv3`. +- Chrome keeps previous entries on the extension Errors page. Clear them after + reloading if you want to verify only new errors. diff --git a/apps/browser-extension/security-questionnaire-ext/package.json b/apps/browser-extension/security-questionnaire-ext/package.json new file mode 100644 index 0000000000..cbc2b559bf --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/package.json @@ -0,0 +1,21 @@ +{ + "name": "@trycompai/security-questionnaire-extension", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "wxt", + "build": "wxt build", + "typecheck": "wxt prepare && tsc --noEmit", + "test": "vitest run --environment jsdom" + }, + "dependencies": { + "zod": "^4.3.6" + }, + "devDependencies": { + "jsdom": "^26.1.0", + "typescript": "^5.9.3", + "vitest": "^3.2.4", + "wxt": "^0.20.26" + } +} diff --git a/apps/browser-extension/security-questionnaire-ext/public/icon/128.png b/apps/browser-extension/security-questionnaire-ext/public/icon/128.png new file mode 100644 index 0000000000000000000000000000000000000000..f090c72e8b9782667867100f470e9509fbc6bcec GIT binary patch literal 5248 zcmch5XEYp6*!`?tve8TQ=!75$Nt9)Ywc4r?q9?HuqDP4qZS@jSBdqA1=&Vi0 zYC+|hvHEi@Eg*cw$p8dn2Z*j1!{*%F~DDU>5|IQg-PxGQ#ZprD)Z^iet7iVT zDZLk&m)i!<+idvetOTJ1{G@@z;0p2s`kEZ={tws-sMG2g-`Cz=acLq=%7AahiutcM zAE1WKXRG=6x~sVaEo)1yJhpyisaHC`PmxV5d(xmxOd+R|i%jI@%xsau`YQ^XHC7k4 zSqg)*BfR#>{`j+M&prXS(|-=$D_L(SsIjmY69qTBwOMqA<@TcNV3rghVCv=fl585D zpq0hGg#BKg*osA_`q)01SHG}p-!9?VJUj&sKfhlREVe}5cI#Y%I4yLkM?}PAJ?l3@ zwFeTrJ`--+UCE9eBjt3<$%6Y7T6a_2?%FzSS{MTejh`A9ooXXgif0Jb2>OQB!@FCS zJ!gX-WD2!6r|ECpc<9)292%YerFHWi0DB!BYE=_rOradS78!RW|5mWaY0I)@A?7ZVIezCS1qAUpg=X_6Ht_oI6x`XF+0D5O*1Z#*DIN}ORxCti zDIcm!r-VziAQOe@X=znTO>1&6oPH?^rVSBaV%T;3FODC@3>y}p-aaHcN=SZ01(%e6 zi8mtvnCt9$HS6DXm6d7Rn@Iok)8%DmN#VDB3^Ue2PAaiuFvZH$XIx zRY0@?PS2+-#L>Tj@S`&O+B;F6R-VA8)rR!=Q7TcL%fcnW73(W zsv*^%x9yRPqIq_lLs%ZQm@wP-Dc%Qb(k>p3f&mi+R`6Vr&<0y$@C4@>{(LwCUUl?$ zqHsK7fB;`^(W>p(e8AuRMH`!6Y^YyNzW58Np^@F*(uw|2Hq)`K|C#62Orz>jzoPoA zuXlW1olK(0V`cNGn`Kb)0N3Rqa+1==RYu~7zCuDfzm3YV#re^A5-Jvij=Z5c1N`D- zot{tYBmZ++|4Uyb-Ir~70ZLOPkM$#BBN?=L4HH+ms zaCJXAQj599HP=qK_wm(@I!UyP55dbs-gf(jKT07Z3P*1)8kU*GvkR7fIxf9=u-gPP z>mh8bH~qY*zq?0;bts9DAlA3`d;Xk6+HNf69n+u(*`CsVU)t1^p@ z(CNQT9ZDwts#@gm&JEkk0#m5d?=~ftKg+{WbGIiN(|5Soa-c6-_4BFFnwsy$PbO&h z_g5wNyXcl&d^b8mC@1RXz%=fdwR>L8984SKAgsJMYZ#@3Wy3OjjWRU`8ppw^h(uwb zX(8s3*mNac&C0Vcl`8S-)ZM*9!%voyiv?fpsu&KmdLIZoOua*jn$~6|l}?X+O)Msk zThx4C7Rz>=_%I}RRA$azHyw&N{_OW@P{wiglVP!fKzT;9kk<~a^4xxsTQ1~x9y>b& z&o%j|N>u(XQ6GVG)-D{brrX@O=CfxzoRQsz)!m=6y>I6@6?mC!MExT|vKWS}|i_|Z3hXi=ZmB;?U zMql0iDz{&)Qn%E7gGj{mhchxJDmzb$!Wmdub)4_JUl%9-Ok`H zp#p*~;-6e(1HLBS>KSjjkc#7D1xmk^5a{}Cu7!KK_pwVHw$!IohuK$84n=-l!TGt1 zF!=rAV>_j|h2vyOXQOW(mWt7mo2TQ#@aI2TsD5w5-XDD=vS^!AQ2jGb1W{XhlZ>nH zcdN&oUB5E{Pk~YCa+SQ_@h(FQjHPp^h*){^);CgE_Sv7o54zkBiYL5zd)5HLk-=~ZUfD^B8XJp71P-~PmlfSDy*%K3zlM9Stf1uqR!O*0`y(y~k z6?3Qmy_aUe&UOWA$LvIO-S*)_e%7R0b`)wHHi#fsWCqq58@=|eSdH#WY;Gu&wYLiG zQhEPee$B)U(b~f0d2q zy}+eN@mYOFKYDe%bJ`BaOv_)He}RmH!5gr6$iO&28Wd*E|K4W%M`n%HW{9FBBf zGzcx76W`Pfi(N`=ovr;WINQ^x;&VpN5z1H*?W#>f#%%PeDt{+h#++^%`!ISf;@Q20 zyyqZ5rGq+16)sxH}pvmO4W@<$->9zjK7NfSNj0|}jIzkeIyiORv;vR)lrrf@ekEOalesW+@ zyyp0ES}ZKJvx^+>S!Qm?{Go`Vf=ANeF7M}79mRiIT|eCjri5RdpFn@rm06qKf6Z5z z6Uq;keSuh>6k2D<;9W507NQnlkz&PB-YhY zZ7Mp!7}(7h3U8F`Dd7-=Od=(#IDWZEt=yF4;S_~vLampR>3xFV-qw1o&+6|J_K&Q5 zn0;3UxM)aWVE#v$sZO)^L2fsjCgelMY;bp?gw3aJ`XZB>A_CMug-D+`Z=&e;Vip^QT%>$qtw(v!HfX5M#Rhfz%BIJ!Ez^iAo&NS z4Xp~H>E^?-Fgp90#>5dK@dXzR=5;GKcS~C;qcv1$S}=R}JdG|}74>av!VT%*ZxivE zUhYufMjzFM^W+d)t$lf^;BflS)bPB=Su8SvsUkrLhGsz1^8Eyd+pJPLZBG6s;{Mk$ z$#p2h9|E}Y?U}>ry^^OH=(^^lmLlZty{aupzy-3lJwsk`Z|IkTa`Qrfz#F=f!?reH zQg3-%`qhSZov~^^-Iq6Kwdi>{mb(LwE?i@NpeYUfP^d66Gt!ii=RCY{0ORKUGn_{km%WknW?16#$eyZ=kv=N3d6-RL&w2ma?*#^J~{|Q z4o8N3_iS@X>+(M{tqSt5js2Guiq!6+%Qch(j8TFIk5{eH0LwE%GWov2riaR!kc2x% zoXSH5i9m4cl+S*;-K_I>W3zQ05)eZ`G(t3qVe0Q`d%Zdxbe%&EEe3TN#B5s5G>g8XpFN^=ocqn|t&)ky57hK6&=W3a5V<;-=>!H7@J!eDJ7et*Fll z6}`qpcO=sf9~{PpLdxf(Ov-)XRc_t^-H)C7)Rl+sYZF<88{_3KdlZ!G11n!JiE|^E zeTFRMrlEa5u4GvN*!Z7rnxj@aLLyc2tBtbI@cSBL7J7pt~0PH z@#Oai!qd6t73Vd1gu)ps1md@VU9*R}jf8Yc!@j@9m$2Ip&Q_CSMCd1eVF}`IF3D1J z#|>X@{_2A>8*AL7B^L;>#z%Z-R9FdmZPgl_Z|gyEouyA>m_dpVn|GDl%AYhK$MdxE zy>MQ%&bvb?I`F|y<{}sLHsPl+!yJuAvCoxRYc$6^71IwD7kmO~`Ko6`OYQ;Qg@t`8 zbdXuo>wC{2#CjMyxvkl5-P@{Cb=&QpB?1#mZF+2u<2Wz3`0y@4TOS+*b%-`C@w}-Y&be3d;f+Lp!T1L(D-n+zPPk zwzQ0;9iDHzblB7HZ@B?PS5Kq2-WbMb=+eZw|64W0K;gt7U)#Y?K)1d^F{kChp1uqK zG{5^}V{RniZ|V;58kw($j*_qTgpP%6qq3HEO^o~$^KSvHt1QxCDuTh9u>0biR7J2R zz6SDLrRYb2c7lM`@1vm5#k_2~4w&N%1Vk%$D8<|KL^Ij$wVTMWkYRTv>Kv!5B{)TB zlJm{F4h82YFWo7D8!Nm+Z`LpcfZH{tB;~=gq52T9sjRcZ3n?^hL`-*;n~0 z9J1az3zHbNSxr7a4dEXtz8f%MgX>{MoQFb}m<(|@-IeMxLjfqjm~|TG9B+4~vhBF` z3vqO%*OM_x*7~)NP%hH-AJjuN3(rF#fQ5?Ow6CYl5-<3D{Q&l~z^A%cFBfT@>m2si z^1I%bNji{rW#*v8$KrWfBdUmMU(N6nNYG%lUczH27=J(7Z{_i^}CY<^!ATMyVvktQ-J4nks2~Z00}S-Oy=_cAlV>yw?1V zPa~o^=&FF$^9?K{IWsT|(EzZu10Mx7@fq{ZG(Cu84uWP+?gYuiajQon0a?mSjfSlsX%?yUZx6auzElceDQphzeVo&m za{be@Fd?Br%mZ`njcb?UZo9NV83QM03VU^Rt4F&}YH75?B!i}xcB);n6Z+g(xNdb? zZdm=ocnp{HO#ty*<5mBDS}hYkE$uofv!E~rBVvwHcB#ZbtG8qhDFRlKWzwn#2m<(@ zes|pHR6aP2co+>uJ#NeoAeop>p O=c%h|tCT5Q2L2EC(Zw79 literal 0 HcmV?d00001 diff --git a/apps/browser-extension/security-questionnaire-ext/public/icon/16.png b/apps/browser-extension/security-questionnaire-ext/public/icon/16.png new file mode 100644 index 0000000000000000000000000000000000000000..bc9709acc6443adce01e1d04bf21726887db3519 GIT binary patch literal 554 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Ea{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBDB+SC;hE;^%b*2hb1*Qr zXELw=S&Tp|1;h*t%nKM9n1M7SNNfQUTvlKKGlC6LXcAiZ38*a9)5S5wLU3!4uQyYm zh^;c$qKu#^0o`U>PiS;x#a~#qz}3l(&9RBYwSM6X{gB|lydPIbg@?>?yzIC3pu+#= zMcd2c`*_bPG?qLtnqGX~a!>hv4V4DD2QLn4ul+AwP+ZNvE-&Cs*@X;~f|^B(LbMJ) z3^0)7Qk>Xv_+?4dW{U$oMLV4~M$EagnfuYch?-@sM^(G>a)XzCN}F~{j_q=mpz37J zsQ#QFyZ8kmR?f+fg|CQ9EV;Y4c2|AHk`rzrSy4K9i#H#s+S}KBSoy^+zU{Yl`~KN- zv9$~Etv~qsqeK1mrEOc;tQZ*%J}q**9Jg`LrmkD*yw}4cW1d~QbmC6;GEd$G5_O5$ z&HYnPKFi3eYO}e=@beGz7Ui;oM{SF3l6zmWUEwrnT6L9wr@{RF@e=i2(@$)^zky}L zf}1&)R#y2PR$ja*k9$2(Z(Pw>v@n7bv#RtT_ Wx7tMf4eWXk3V2UfKbLh*2~7Zl$I$%% literal 0 HcmV?d00001 diff --git a/apps/browser-extension/security-questionnaire-ext/public/icon/32.png b/apps/browser-extension/security-questionnaire-ext/public/icon/32.png new file mode 100644 index 0000000000000000000000000000000000000000..a61c8cea41c0c1fa436b23d91e2cf09a9b2ce037 GIT binary patch literal 1107 zcmV-Z1g!gsP)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw0000) zWmrjOO-%qQ0000800D<-00aO40096102%-Q00003paB2_0000100961paK8{00001 z0000WpaTE|000010000W00000tcGIq000AyNklvPfGfT?C z;Q7A^fAbfA3Iz$5zGV8(yluXD%S^qHBiKRm-od@u)*b+!2y7_=X0rt>7R%P^nA&hN z)YjIHv4`R@CN>V+-*#-q1+xKnEoWzEv841q1_a75MVE?aQ_f@H9y#NY%w2K|KwsZ? zwyA%h6!rR2oSU<9V-EEt1_mNE?IQN}_PK>&LjabSm$A9|8LHIbT*%{*!oQlHzj%rM z0Wu5=*J6{gd9`@W^|}BomMP4=dL4J|4#b?CT%4GgWX`6ir!g$d zqOi5~HCnA!Y<$whY!Z`_v8($Xvsvp$6oAcU!-|S33{j}@a9k4p_}TBUud8oh&SmmY zRI0+*pp&aL`_RaxiKqVl0gQ`3g25pwtf+j1^Yin($muJ35133QsII96?!>UPv;^bh z3T$SZ{!qmRBH=NZ5&Z2h<@1Un240 zS*$zk_C?6ZNQd%=WuQ_i9Wv30SYE_^=1a&E z?E;~Ka83_R+J#F_RZal-E+JWO-!?o(LJ;)i8VqHqP-;*ftYm}crG^K%?a#3gNPBw+ z#wQ%bgv4ZgBl0>5bzRNZEke=4Sz*%OF)BihM(s+?rEE(8TzcN1Q1l|&pzVKA^8oNf Z;4dWgH^(L-!wmoc002ovPDHLkV1gSj)9?TQ literal 0 HcmV?d00001 diff --git a/apps/browser-extension/security-questionnaire-ext/public/icon/48.png b/apps/browser-extension/security-questionnaire-ext/public/icon/48.png new file mode 100644 index 0000000000000000000000000000000000000000..fd96c22f76c80f4d7c2003c57fa4c6b8d41d13c1 GIT binary patch literal 1798 zcmV+h2l@DkP)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw0000) zWmrjOO-%qQ0000800D<-00aO40096102%-Q00003paB2_0000100961paK8{00001 z0000mpaTE|000010000m00000+c#_s000I-Nkl%5t*f?&G67{0F%e~ z4;c~++Ke;6vMZkMxz4w_yRzRdyF2B~o|$jH$35rV^E;3GoqJu75j?@<`UKm~O?LwL zuOsM;fHML%dcc`NHagH|afe1gtJT8&`}b`&(wqW^0cdM)hb2pwvFi=Ee*JfIHrO^s zt%SYl@R4IUAtDOLc>1DZj1NXcPR3)$D(yWD*9Z30+S}WUX&6(Ix^81y+Cxxsg%8e{`4V2d z__N(G*bHD`U;qmXcVS@AQz(;rWB7zftT=Q8A3S(qVC2DyL--6!E5#UJ3<`c43$_=c zMx!y{8O@7L0NUF-aPg95Jl84T=Py`@&CM+%+R@h5hOz3Gd5U>?D{;}{M7(jMbwqwk z21X5_vhoB*Oq`5zg*U1K$74=zKKA$b8_aq2>aW<)(5U~`+1ZKexL43msX}ibf1YlW zqNd>S<0thz);?Gb;O^bOF@1do^P-9yK!tkl+>ZwHvh^QJc9-&02_6@MrKRO~s_ILe z{A@IOdMSCjtw>$XY9^LDfC_x$#?9E**Jq%|k{7E0T)KRjdytk!@?gsQso359m%%(Y zZ{Ebj#3c0c_Q$aS!Th3CiM&qN%$QiL{{Rh~8V-g2VbL=TC zLz>eP5(UuH(~C)OCUe7;{(%@49)Z`c{br)OxOg`&X5uk6TzC&qjbq*pz@nYSCh|?q z%@`UMfh6z69FUZ>oJF_WgwObcXe+_e2CA!TKrWYqo0}VGG(&LZ%2i;?q(KW16%_?x zVPT-tjokaNWfOve0%2-&w1F%$3hL?_fO;pILoSm6OEb81`4srG{Fnz(0LC(;)WgFAzCKe8Y~6?2@6YO4LPA2|c;!*Z z%gq6)hm?DiyegA5D z^AAXQZv|v$=R$w~03UL9cZZCObolCY6+}l*;aAnElaR3?ou>yevA;KL%gKXx-c13D zfI^`V)LMOaw_4&60f|IHivX0CmGinND%uXyrcLD>F*9edl=|jjPP6(EsHv$Lwq$OF z`uckAb&13#6F@WqWFV!%NJJ?`9;{;qEnKt|SkI%+e@{*f>gs}(tI}Z0mMpk; z?;fuq{h-mqzXiVlX2-=Djheo?Fb|8KjncMo@4io=q@_UEqPb3ymdh0k=q6a7z80vS#Ki2%pRb9L<~~>rKu~t##21j8SHOQ}w1$s{=!d6LsUSBe8 { + setupAuthFlowWatcher(); + setupContentScriptAutoInjection(); + browser.runtime.onMessage.addListener((message, sender, sendResponse) => { + const request = parseBackgroundRequest(message); + if (!request) return false; + + void handleBackgroundRequest({ + request, + senderTabId: sender.tab?.id ?? null, + }) + .then((response) => sendResponse(response)) + .catch((error: unknown) => { + sendResponse({ + ok: false, + error: error instanceof Error ? error.message : 'Unexpected error', + }); + }); + + return true; + }); +}); diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/content.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/content.ts new file mode 100644 index 0000000000..4067ded997 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/content.ts @@ -0,0 +1,296 @@ +import { browser } from 'wxt/browser'; +import { sendWithDomainConfirmation } from '../lib/dom/content-messaging'; +import { contentStyles } from '../lib/dom/content-styles'; +import { detectQuestionFields, type FieldCandidate, type WritableField } from '../lib/dom/field-detection'; +import { insertAnswerIntoField } from '../lib/dom/field-actions'; +import { createInlineButtonHost, setInlineButtonState } from '../lib/dom/inline-button'; +import { InlinePreview } from '../lib/dom/inline-preview'; +import { getPageSurface, shouldSkipQuestionnaireInjection } from '../lib/dom/page-surface'; +import { detectSheetQuestionsForPage } from '../lib/dom/sheets-runtime'; +import { prepareSheetPaste } from '../lib/dom/sheets-insert'; +import { sendRuntimeMessage } from '../lib/dom/safe-runtime'; +import { parseContentRequest } from '../lib/messaging'; +import { getResponseError, isCountResponse, isItemResponse, isQueueResponse } from '../lib/response-guards'; +import type { DetectedQuestion, QuestionQueueItem, TabQuestionQueue } from '../lib/types'; + +const FIELD_ID_ATTR = 'data-comp-sq-field-id'; +const BUTTON_ATTR = 'data-comp-sq-button-for'; + +let nextFieldId = 1; +let refreshTimer: number | null = null; +let detectionEnabled = true; +let activePreview: InlinePreview | null = null; + +export default defineContentScript({ + matches: [''], + main() { + if (shouldSkipQuestionnaireInjection(window.location)) return; + injectStyles(); + scheduleInitialRefresh(); + observePageChanges(); + browser.runtime.onMessage.addListener((message, _sender, sendResponse) => { + const request = parseContentRequest(message); + if (!request) return false; + + void handleContentRequest(request) + .then((response) => sendResponse(response)) + .catch((error: unknown) => { + sendResponse({ + ok: false, + error: error instanceof Error ? error.message : 'Unexpected error', + }); + }); + return true; + }); + }, +}); + +async function handleContentRequest( + request: NonNullable>, +) { + if (request.type === 'comp:set-detection-enabled') { + detectionEnabled = request.enabled; + refreshInlineButtons(); + return { ok: true, count: (await collectDetectedQuestions()).length }; + } + if ( + request.type === 'comp:collect-questions' || + request.type === 'comp:scan-visible-questions' + ) { + refreshInlineButtons(); + const queue = await syncDetectedQuestions(); + return { ok: true, count: queue.items.length }; + } + if (request.type === 'comp:ensure-inline-buttons') { + refreshInlineButtons(); + return { ok: true, count: (await collectDetectedQuestions()).length }; + } + if (request.type === 'comp:insert-answers') { + return insertAnswers(request.answers); + } + if (request.type === 'comp:focus-question') { + focusQuestion(request.fieldId); + return { ok: true, count: 1 }; + } + return { ok: false, error: 'Unsupported content request.' }; +} + +function injectStyles(): void { + if (document.getElementById('comp-sq-styles')) return; + const style = document.createElement('style'); + style.id = 'comp-sq-styles'; + style.textContent = contentStyles; + document.head.appendChild(style); +} + +function observePageChanges(): void { + const observer = new MutationObserver(() => { + if (refreshTimer) window.clearTimeout(refreshTimer); + refreshTimer = window.setTimeout(refreshInlineButtons, 400); + }); + observer.observe(document.body, { childList: true, subtree: true }); +} + +function scheduleInitialRefresh(): void { + const delayMs = document.readyState === 'complete' ? 800 : 1400; + window.setTimeout(refreshInlineButtons, delayMs); +} + +function refreshInlineButtons(): void { + if (shouldSkipQuestionnaireInjection(window.location)) return; + if (!detectionEnabled || !usesFieldDetection()) { + removeInlineButtons(); + void syncDetectedQuestions().catch(() => undefined); + return; + } + + for (const candidate of collectFields()) { + const fieldId = getOrCreateFieldId(candidate.element); + if (document.querySelector(`[${BUTTON_ATTR}="${fieldId}"]`)) continue; + candidate.element.insertAdjacentElement( + 'afterend', + createInlineButtonHost({ + fieldId, + buttonAttribute: BUTTON_ATTR, + onClick: () => { + void handleSingleGenerate(fieldId); + }, + }), + ); + } + void syncDetectedQuestions().catch(() => undefined); +} + +async function handleSingleGenerate(fieldId: string): Promise { + const candidate = findCandidateById(fieldId); + const button = findButtonHost(fieldId); + if (!candidate || !button) return; + + setInlineButtonState(button, 'busy'); + activePreview?.close(); + activePreview = new InlinePreview(button); + activePreview.showLoading(candidate.question); + + try { + const queue = await syncDetectedQuestions(); + const response = await sendWithDomainConfirmation({ + type: 'comp:generate-queue-item', + tabId: queue.tabId, + itemId: fieldId, + }); + if (!isItemResponse(response)) throw new Error(getResponseError(response)); + activePreview.showResult(response.item, { + onDismiss: () => setInlineButtonState(button, 'idle'), + onRegenerate: () => void handleSingleGenerate(fieldId), + onInsert: (answer) => { + void insertInlineAnswer({ queue, item: response.item, answer, button }) + .catch((error: unknown) => { + window.alert(error instanceof Error ? error.message : 'Unable to insert answer'); + setInlineButtonState(button, 'idle'); + }); + }, + }); + } catch (error) { + activePreview?.close(); + window.alert(error instanceof Error ? error.message : 'Unable to generate answer'); + setInlineButtonState(button, 'idle'); + } +} + +async function insertInlineAnswer(params: { + queue: TabQuestionQueue; + item: QuestionQueueItem; + answer: string; + button: HTMLElement; +}): Promise { + const editResponse = await sendRuntimeMessage({ + type: 'comp:edit-queue-item', + tabId: params.queue.tabId, + itemId: params.item.id, + answer: params.answer, + }); + if (editResponse === null) throw new Error('Extension was reloaded. Refresh this page and try again.'); + const response = await sendWithDomainConfirmation({ + type: 'comp:insert-queue-item', + tabId: params.queue.tabId, + itemId: params.item.id, + }); + if (!isCountResponse(response)) throw new Error(getResponseError(response)); + activePreview?.close(); + setInlineButtonState(params.button, 'inserted'); +} + +async function syncDetectedQuestions(): Promise { + const questions = await collectDetectedQuestions(); + const response = await sendRuntimeMessage({ + type: 'comp:sync-questions', + url: window.location.href, + host: window.location.host, + surface: getPageSurface(window.location), + questions, + }); + if (response === null) { + throw new Error('Extension context invalidated. Reload this page.'); + } + if (!isQueueResponse(response)) throw new Error(getResponseError(response)); + return response.queue; +} + +async function insertAnswers( + answers: { fieldId: string; answer: string }[], +): Promise<{ ok: true; insertedCount: number; failedIds: string[] }> { + if (getPageSurface(window.location) === 'sheets') { + const result = await prepareSheetPaste({ + answers, + root: document, + }); + return { + ok: true, + insertedCount: result.insertedIds.length, + failedIds: result.failedIds, + }; + } + + const failedIds: string[] = []; + for (const answer of answers) { + const candidate = findCandidateById(answer.fieldId); + if (!candidate) { + failedIds.push(answer.fieldId); + continue; + } + insertAnswerIntoField({ field: candidate.element, answer: answer.answer }); + flashField(candidate.element); + setInlineButtonState(findButtonHost(answer.fieldId), 'inserted'); + } + return { + ok: true, + insertedCount: answers.length - failedIds.length, + failedIds, + }; +} + +function collectFields(): FieldCandidate[] { + if (shouldSkipQuestionnaireInjection(window.location)) return []; + if (!detectionEnabled || !usesFieldDetection()) return []; + return detectQuestionFields(document, { visibleOnly: true }); +} + +async function collectDetectedQuestions(): Promise { + const surface = getPageSurface(window.location); + if (surface === 'sheets') { + return detectSheetQuestionsForPage({ location: window.location, root: document }); + } + return collectFields().map(toDetectedQuestion); +} + +function usesFieldDetection(): boolean { + const surface = getPageSurface(window.location); + return surface === 'generic' || surface === 'forms'; +} +function toDetectedQuestion(candidate: FieldCandidate): DetectedQuestion { + return { + id: getOrCreateFieldId(candidate.element), + question: candidate.question, + value: candidate.value, + isEmpty: candidate.isEmpty, + tag: candidate.tag, + }; +} + +function findCandidateById(fieldId: string): FieldCandidate | null { + return ( + collectFields().find( + (candidate) => candidate.element.getAttribute(FIELD_ID_ATTR) === fieldId, + ) ?? null + ); +} + +function getOrCreateFieldId(field: WritableField): string { + const existing = field.getAttribute(FIELD_ID_ATTR); + if (existing) return existing; + const fieldId = `comp-sq-${nextFieldId}`; + nextFieldId += 1; + field.setAttribute(FIELD_ID_ATTR, fieldId); + return fieldId; +} + +function findButtonHost(fieldId: string): HTMLElement | null { + const element = document.querySelector(`[${BUTTON_ATTR}="${fieldId}"]`); + return element instanceof HTMLElement ? element : null; +} + +function focusQuestion(fieldId: string): void { + const candidate = findCandidateById(fieldId); + if (!candidate) return; + candidate.element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + flashField(candidate.element); +} + +function flashField(field: WritableField): void { + field.classList.add('comp-sq-flash'); + window.setTimeout(() => field.classList.remove('comp-sq-flash'), 900); +} + +function removeInlineButtons(): void { + document.querySelectorAll(`[${BUTTON_ATTR}]`).forEach((element) => element.remove()); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/popup/index.html b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/popup/index.html new file mode 100644 index 0000000000..fa007915f8 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/popup/index.html @@ -0,0 +1,13 @@ + + + + + + Comp AI Security Questionnaire + + + +
+ + + diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/popup/main.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/popup/main.ts new file mode 100644 index 0000000000..bd7c378d81 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/popup/main.ts @@ -0,0 +1,207 @@ +import { browser } from 'wxt/browser'; +import { compMarkSvg } from '../../lib/brand'; +import { + getResponseError, + isOkResponse, + isPanelStateResponse, +} from '../../lib/response-guards'; +import type { PanelState } from '../../lib/types'; + +const app = document.getElementById('app'); +if (!app) throw new Error('Popup root not found'); +const appRoot = app; + +let activeTab: Browser.tabs.Tab | null = null; +let panelState: PanelState | null = null; + +void render(); +browser.runtime.onMessage.addListener((message) => { + if (isAuthUpdate(message)) void render('Signed in.'); +}); + +async function render(statusText = 'Checking session...'): Promise { + appRoot.innerHTML = shell(`
${escapeHtml(statusText)}
`); + activeTab = await getActiveTab(); + if (activeTab?.id) await collectQuestions(activeTab.id); + + const response = await browser.runtime.sendMessage({ + type: 'comp:get-panel-state', + tabId: activeTab?.id ?? -1, + url: activeTab?.url, + host: activeTab?.url ? getHost(activeTab.url) : undefined, + }); + if (!isPanelStateResponse(response)) { + appRoot.innerHTML = shell(unauthenticatedHtml(getResponseError(response))); + bindSignIn(); + return; + } + + panelState = response.panelState; + if (panelState.auth.status !== 'authenticated') { + appRoot.innerHTML = shell(unauthenticatedHtml('Sign in to use Comp answers.')); + bindSignIn(); + return; + } + + appRoot.innerHTML = shell(authenticatedHtml(panelState)); + bindAuthenticatedControls(); +} + +function shell(body: string): string { + return ` +
+
+ +
+

Comp AI Questionnaire

+

Review answers before anything is inserted.

+
+ Connected +
+ ${body} +
+ `; +} + +function unauthenticatedHtml(message: string): string { + return ` +
+
${escapeHtml(message)}
+ + +
+ `; +} + +function authenticatedHtml(state: PanelState): string { + const selected = state.auth.selectedOrganizationId ?? ''; + const orgOptions = state.auth.organizations + .map( + (org) => + ``, + ) + .join(''); + const actionDisabled = selected ? '' : 'disabled'; + const detectClass = state.detectionEnabled ? 'toggle' : 'toggle off'; + + return ` +
+ + +

${escapeHtml(state.auth.user?.email ?? '')}

+
+
+
+
+
Detect questions
+

${state.queue.items.length} found on this page

+
+ +
+ +

Answers preview before they're inserted. Nothing is written automatically.

+
+
+ +
+ `; +} + +function bindSignIn(): void { + appRoot.querySelector('[data-action="sign-in"]')?.addEventListener('click', () => { + void browser.runtime.sendMessage({ type: 'comp:open-sign-in' }); + }); + bindRefresh(); +} + +function bindAuthenticatedControls(): void { + bindRefresh(); + const select = appRoot.querySelector('#org-select'); + if (select instanceof HTMLSelectElement) { + select.addEventListener('change', () => { + if (select.value) void handleOrgChange(select.value); + }); + } + appRoot.querySelector('[data-action="open-panel"]')?.addEventListener('click', () => { + void handleOpenPanel(); + }); + appRoot.querySelector('[data-action="toggle-detection"]')?.addEventListener('click', () => { + void handleDetectionToggle(); + }); +} + +function bindRefresh(): void { + appRoot.querySelector('[data-action="refresh"]')?.addEventListener('click', () => { + void render('Refreshing...'); + }); +} + +async function handleOrgChange(organizationId: string): Promise { + const response = await browser.runtime.sendMessage({ + type: 'comp:set-active-org', + organizationId, + tabId: activeTab?.id, + }); + if (!isOkResponse(response)) { + appRoot.innerHTML = shell(`
${escapeHtml(getResponseError(response))}
`); + return; + } + await render('Workspace updated.'); +} + +async function handleOpenPanel(): Promise { + if (!activeTab?.id) return; + await browser.runtime.sendMessage({ + type: 'comp:open-side-panel', + tabId: activeTab.id, + windowId: activeTab.windowId, + }); + window.close(); +} + +async function handleDetectionToggle(): Promise { + if (!panelState || !activeTab?.id) return; + const enabled = !panelState.detectionEnabled; + await browser.runtime.sendMessage({ + type: 'comp:set-detection-enabled', + host: panelState.queue.host, + enabled, + }); + await browser.tabs.sendMessage(activeTab.id, { + type: 'comp:set-detection-enabled', + enabled, + }).catch(() => undefined); + await render(enabled ? 'Detection enabled.' : 'Detection disabled.'); +} + +async function collectQuestions(tabId: number): Promise { + await browser.tabs.sendMessage(tabId, { type: 'comp:collect-questions' }).catch(() => undefined); +} + +async function getActiveTab(): Promise { + const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); + return tab ?? null; +} + +function getHost(url: string): string { + try { + return new URL(url).host; + } catch { + return 'current page'; + } +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function isAuthUpdate(value: unknown): value is { type: 'comp:auth-updated' } { + return typeof value === 'object' && value !== null && 'type' in value && value.type === 'comp:auth-updated'; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/popup/style.css b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/popup/style.css new file mode 100644 index 0000000000..220d1bf697 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/popup/style.css @@ -0,0 +1,191 @@ +:root { + --comp-primary: #00dc73; + --comp-primary-strong: #009e54; + color: #111827; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +body { + margin: 0; + width: 360px; +} + +button, +select { + font: inherit; +} + +.shell { + display: grid; + gap: 14px; + padding: 16px; +} + +.header { + align-items: center; + display: flex; + gap: 12px; +} + +.brand-mark { + display: inline-flex; + flex: none; + height: 24px; + width: 24px; +} + +.brand-mark svg { + display: block; + fill: #16171b; + height: 100%; + width: 100%; +} + +.title { + font-size: 15px; + font-weight: 750; + margin: 0; +} + +.subtitle, +.muted { + color: #6b7280; + font-size: 12px; + line-height: 1.4; + margin: 4px 0 0; +} + +.connected { + background: #d7f9e7; + border-radius: 2px; + color: var(--comp-primary-strong); + font-size: 10px; + font-weight: 800; + margin-left: auto; + padding: 3px 6px; + text-transform: uppercase; +} + +.section { + border: 1px solid #e5e7eb; + border-radius: 8px; + display: grid; + gap: 10px; + padding: 12px; +} + +.label { + color: #374151; + display: block; + font-size: 12px; + font-weight: 700; + margin-bottom: 6px; +} + +.no-margin { + margin-bottom: 0; +} + +.select { + background: #ffffff; + border: 1px solid #d1d5db; + border-radius: 6px; + box-sizing: border-box; + color: #111827; + min-height: 34px; + padding: 6px 8px; + width: 100%; +} + +.actions { + display: flex; + gap: 8px; +} + +.toggle-row { + align-items: center; + display: flex; + justify-content: space-between; +} + +.toggle { + background: var(--comp-primary); + border: 0; + border-radius: 999px; + cursor: pointer; + height: 22px; + padding: 2px; + position: relative; + width: 38px; +} + +.toggle::after { + background: #ffffff; + border-radius: 999px; + content: ""; + height: 18px; + position: absolute; + right: 2px; + top: 2px; + width: 18px; +} + +.toggle.off { + background: #d1d5db; +} + +.toggle.off::after { + left: 2px; + right: auto; +} + +.button { + align-items: center; + border-radius: 6px; + cursor: pointer; + display: inline-flex; + font-size: 12px; + font-weight: 700; + justify-content: center; + min-height: 34px; + padding: 7px 10px; +} + +.block { + width: 100%; +} + +.button-primary { + background: #111827; + border: 1px solid #111827; + color: #ffffff; +} + +.button-secondary { + background: #ffffff; + border: 1px solid #d1d5db; + color: #374151; +} + +.button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.status { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 6px; + color: #374151; + font-size: 12px; + line-height: 1.4; + padding: 8px; +} + +.helper { + color: #6b7280; + font-size: 10.5px; + line-height: 1.45; + margin: 0; + text-align: center; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/active-tab.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/active-tab.ts new file mode 100644 index 0000000000..6fe04db726 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/active-tab.ts @@ -0,0 +1,15 @@ +import { browser } from 'wxt/browser'; + +export async function getActiveTab(): Promise { + const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); + if (!tab) throw new Error('No active tab found.'); + return tab; +} + +export function getHost(url: string): string { + try { + return new URL(url).host; + } catch { + return 'current page'; + } +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/answer-edits.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/answer-edits.ts new file mode 100644 index 0000000000..e846a9b7da --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/answer-edits.ts @@ -0,0 +1,58 @@ +import { browser } from 'wxt/browser'; + +export function bindAnswerAutosave(params: { + root: ParentNode; + tabId: number | null; +}): void { + params.root.querySelectorAll('[data-answer-for]').forEach((element) => { + if (!(element instanceof HTMLTextAreaElement)) return; + element.addEventListener('change', () => { + void saveTextareaAnswer({ tabId: params.tabId, textarea: element }); + }); + }); +} + +export async function saveAnswerForItem(params: { + root: ParentNode; + tabId: number | null; + itemId: string; +}): Promise { + const textarea = params.root.querySelector( + `[data-answer-for="${cssEscape(params.itemId)}"]`, + ); + if (!(textarea instanceof HTMLTextAreaElement)) return; + await saveTextareaAnswer({ tabId: params.tabId, textarea }); +} + +export async function saveAllVisibleAnswers(params: { + root: ParentNode; + tabId: number | null; +}): Promise { + const textareas = Array.from(params.root.querySelectorAll('[data-answer-for]')) + .filter((element): element is HTMLTextAreaElement => + element instanceof HTMLTextAreaElement, + ); + await Promise.all( + textareas.map((textarea) => + saveTextareaAnswer({ tabId: params.tabId, textarea }), + ), + ); +} + +async function saveTextareaAnswer(params: { + tabId: number | null; + textarea: HTMLTextAreaElement; +}): Promise { + const itemId = params.textarea.dataset.answerFor; + if (!params.tabId || !itemId) return; + await browser.runtime.sendMessage({ + type: 'comp:edit-queue-item', + tabId: params.tabId, + itemId, + answer: params.textarea.value, + }); +} + +function cssEscape(value: string): string { + return value.replace(/["\\]/g, '\\$&'); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/content-collector.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/content-collector.ts new file mode 100644 index 0000000000..0a73e0392a --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/content-collector.ts @@ -0,0 +1,131 @@ +import { browser } from 'wxt/browser'; +import { + isRecord, + parseDetectedQuestion, +} from '../../lib/message-utils'; +import { + getResponseError, + isCountResponse, +} from '../../lib/response-guards'; +import { formatScanDebug, getScanDebug } from '../../lib/scan-debug'; +import { parseSheetMapping } from '../../lib/sheet-mapping'; +import type { DetectedQuestion, SheetMapping } from '../../lib/types'; + +export async function collectQuestions(tabId: number): Promise { + const sheetScan = await collectSheetQuestionsFromBackground(tabId); + if (sheetScan?.count && sheetScan.count > 0) return ''; + + const response = await sendCollectMessage(tabId); + if (isCountResponse(response)) { + if (response.count > 0) return ''; + return sheetScan?.message ?? formatDebugResponse(response); + } + if (!shouldRetryWithInjectedScript(response)) return getResponseError(response); + + const injected = await injectContentScript(tabId); + if (!injected) { + return 'Unable to attach the page scanner. Reload this tab and press Refresh.'; + } + const retry = await sendCollectMessage(tabId); + if (!isCountResponse(retry)) return getResponseError(retry); + if (retry.count > 0) return ''; + return sheetScan?.message ?? formatDebugResponse(retry); +} + +async function sendCollectMessage(tabId: number): Promise { + return browser.tabs.sendMessage(tabId, { type: 'comp:collect-questions' }).catch((error: unknown) => ({ + ok: false, + error: error instanceof Error ? error.message : 'Unable to scan this page.', + })); +} + +async function collectSheetQuestionsFromBackground(tabId: number): Promise<{ + count: number; + message: string; +} | null> { + const tab = await browser.tabs.get(tabId).catch(() => null); + const sheet = parseSheetTab(tab?.url); + if (!sheet) return null; + + const response = await browser.runtime.sendMessage({ + type: 'comp:detect-sheet-questions', + pathname: sheet.pathname, + hash: sheet.hash, + }).catch((error: unknown) => ({ + ok: false, + error: error instanceof Error ? error.message : 'Background sheet scan failed.', + })); + const debug = getScanDebug(response); + const questions = getDetectedQuestions(response); + const sheetMapping = getDetectedSheetMapping(response); + await browser.runtime.sendMessage({ + type: 'comp:sync-questions', + tabId, + url: sheet.url, + host: sheet.host, + surface: 'sheets', + questions, + sheetMapping, + }); + return { + count: questions.length, + message: questions.length > 0 + ? '' + : debug + ? formatScanDebug(debug) + : '', + }; +} + +function parseSheetTab(url: string | undefined): { + hash: string; + host: string; + pathname: string; + url: string; +} | null { + if (!url) return null; + try { + const parsed = new URL(url); + if (parsed.hostname !== 'docs.google.com') return null; + if (!parsed.pathname.startsWith('/spreadsheets/')) return null; + return { + hash: parsed.hash, + host: parsed.host, + pathname: parsed.pathname, + url, + }; + } catch { + return null; + } +} + +function getDetectedQuestions(value: unknown): DetectedQuestion[] { + if (!isRecord(value) || !Array.isArray(value.questions)) return []; + return value.questions.flatMap(parseDetectedQuestion); +} + +function getDetectedSheetMapping(value: unknown): SheetMapping | null { + if (!isRecord(value)) return null; + return parseSheetMapping(value.mapping); +} + +function formatDebugResponse(response: unknown): string { + const debug = getScanDebug(response); + return debug ? formatScanDebug(debug) : ''; +} + +async function injectContentScript(tabId: number): Promise { + return browser.scripting.executeScript({ + target: { tabId }, + files: ['/content-scripts/content.js'], + injectImmediately: true, + }).then(() => true).catch(() => false); +} + +function shouldRetryWithInjectedScript(response: unknown): boolean { + const error = getResponseError(response).toLowerCase(); + return ( + error.includes('receiving end') || + error.includes('could not establish connection') + ); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/dialog.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/dialog.ts new file mode 100644 index 0000000000..9cc38fa940 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/dialog.ts @@ -0,0 +1,19 @@ +export function showDialog(html: string): Promise { + return new Promise((resolve) => { + const container = document.createElement('div'); + container.innerHTML = html; + document.body.appendChild(container); + + const close = (confirmed: boolean): void => { + container.remove(); + resolve(confirmed); + }; + + container + .querySelector('[data-dialog="cancel"]') + ?.addEventListener('click', () => close(false)); + container + .querySelector('[data-dialog="confirm"]') + ?.addEventListener('click', () => close(true)); + }); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/index.html b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/index.html new file mode 100644 index 0000000000..4a1ac0af8d --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/index.html @@ -0,0 +1,12 @@ + + + + + + Comp AI Questionnaire + + + +
+ + diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/main.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/main.ts new file mode 100644 index 0000000000..464eefeb49 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/main.ts @@ -0,0 +1,300 @@ +import { browser } from 'wxt/browser'; +import { + getResponseError, + isConfirmationResponse, + isCountResponse, + isOkResponse, + isPanelStateResponse, + isQueueResponse, +} from '../../lib/response-guards'; +import type { DomainConfirmationRequest, PanelState } from '../../lib/types'; +import { + bindAnswerAutosave, + saveAllVisibleAnswers, + saveAnswerForItem, +} from './answer-edits'; +import { getActiveTab, getHost } from './active-tab'; +import { collectQuestions } from './content-collector'; +import { showDialog } from './dialog'; +import { + renderDomainDialog, + renderInsertDialog, + renderSidePanel, + renderStaleDialog, +} from './render'; +import { handleSheetMappingChange } from './sheet-mapping-actions'; +import { handleSheetPaste } from './sheet-paste-actions'; +import './style.css'; +import './queue-polish.css'; +import './sticky-footer.css'; +import './sheet-mapping.css'; + +const app = document.getElementById('app'); +if (!app) throw new Error('Side panel root not found'); +const appRoot = app; + +let state: PanelState | null = null; +let activeTabId: number | null = null; +let statusMessage = ''; +let isRefreshing = false; + +void initialize(); + +async function initialize(): Promise { + await refreshFromPage(); + browser.runtime.onMessage.addListener((message) => { + if (isPanelRefreshMessage(message)) void refreshState(); + }); +} + +async function refreshFromPage(): Promise { + const tab = await getActiveTab(); + activeTabId = tab.id ?? null; + if (!activeTabId) { + renderMessage('No active tab found.'); + return; + } + statusMessage = await collectQuestions(activeTabId); + await refreshState(tab.url); +} + +async function refreshState(url?: string): Promise { + if (!activeTabId) return; + const response = await browser.runtime.sendMessage({ + type: 'comp:get-panel-state', + tabId: activeTabId, + url, + host: url ? getHost(url) : undefined, + }); + if (!isPanelStateResponse(response)) { + renderMessage(getResponseError(response)); + return; + } + state = response.panelState; + render(); +} + +function render(message = statusMessage): void { + if (!state) { + renderMessage('Loading questionnaire queue...'); + return; + } + const scrollTop = getListScrollTop(); + appRoot.innerHTML = renderSidePanel(state, message, isRefreshing); + bindEvents(); + restoreListScrollTop(scrollTop); +} + +function bindEvents(): void { + bindAnswerAutosave({ root: appRoot, tabId: activeTabId }); + appRoot.querySelectorAll('[data-action]').forEach((element) => { + element.addEventListener('click', (event) => { + const target = event.currentTarget; + if (target instanceof HTMLElement) void handleAction(target); + }); + }); + const orgSelect = appRoot.querySelector('[data-action="switch-org"]'); + if (orgSelect instanceof HTMLSelectElement) { + orgSelect.addEventListener('change', () => { + void handleOrgSwitch(orgSelect.value); + }); + } +} + +async function handleAction(target: HTMLElement): Promise { + const action = target.dataset.action; + const itemId = target.dataset.itemId; + if (action === 'sign-in') await browser.runtime.sendMessage({ type: 'comp:open-sign-in' }); + if (action === 'refresh') { + isRefreshing = true; + render('Refreshing page scan...'); + try { + await refreshFromPage(); + } catch (error) { + statusMessage = error instanceof Error ? error.message : 'Unable to refresh scan.'; + } finally { isRefreshing = false; } + render(statusMessage || `Scan refreshed · ${state?.queue.items.length ?? 0} found.`); + } + if (action === 'close') await closePanel(); + if (action === 'generate-all') await runQueueAction({ type: 'comp:generate-all' }); + if (action === 'approve-all') await runQueueAction({ type: 'comp:approve-all-generated' }); + if (action === 'insert-approved') await handleInsertApproved(); + if (action === 'change-sheet-mapping') { + await handleSheetMappingChange({ + state, + refreshFromPage, + setStatus, + }); + } + if (action === 'select-item' && itemId) await handleSelectItem(itemId); + if (action === 'approve-item' && itemId) { + await saveAnswerForItem({ root: appRoot, tabId: activeTabId, itemId }); + await runQueueAction({ type: 'comp:approve-queue-item', itemId }); + } + if (action === 'insert-item' && itemId) { + if (state?.queue.surface === 'sheets') { + await handleSheetPaste({ + activeTabId, + itemId, + refreshState, + root: appRoot, + setStatus, + state, + }); + return; + } + await saveAnswerForItem({ root: appRoot, tabId: activeTabId, itemId }); + await runQueueAction({ type: 'comp:insert-queue-item', itemId }); + } +} + +async function handleOrgSwitch(organizationId: string): Promise { + if (!activeTabId) return; + const response = await browser.runtime.sendMessage({ + type: 'comp:set-active-org', + organizationId, + tabId: activeTabId, + }); + if (!isOkResponse(response) && !isStaleResponse(response)) { + setStatus(getResponseError(response)); + return; + } + await refreshState(); + if (isStaleResponse(response) && response.staleDraftCount > 0) { + await showDialog(renderStaleDialog(response.staleDraftCount)); + } +} + +async function handleSelectItem(itemId: string): Promise { + await runQueueAction({ type: 'comp:select-queue-item', itemId }); + await browser.tabs.sendMessage(activeTabId ?? 0, { + type: 'comp:focus-question', + fieldId: itemId, + }).catch(() => undefined); +} + +async function handleInsertApproved(): Promise { + const currentState = state; + if (!currentState) return; + if (currentState.queue.surface === 'sheets') { + await handleSheetPaste({ + activeTabId, + refreshState, + root: appRoot, + setStatus, + state: currentState, + }); + return; + } + await saveAllVisibleAnswers({ root: appRoot, tabId: activeTabId }); + const approved = currentState.queue.items.filter((item) => item.status === 'approved'); + if (approved.length === 0) return; + const org = currentState.auth.organizations.find( + (entry) => entry.id === currentState.auth.selectedOrganizationId, + ); + const confirmed = await showDialog(renderInsertDialog({ + count: approved.length, + host: currentState.queue.host, + organizationName: org?.name ?? 'selected organization', + operation: 'Insert', + lowConfidenceCount: currentState.queue.items.filter( + (item) => item.confidence === 'low' && item.status !== 'approved', + ).length, + })); + if (confirmed) await runQueueAction({ type: 'comp:insert-approved' }); +} + +async function runQueueAction(params: { + type: + | 'comp:generate-all' + | 'comp:approve-all-generated' + | 'comp:insert-approved' + | 'comp:approve-queue-item' + | 'comp:insert-queue-item' + | 'comp:select-queue-item'; + itemId?: string; +}): Promise { + if (!activeTabId) return; + setStatus(''); + const response = await browser.runtime.sendMessage({ + type: params.type, + tabId: activeTabId, + itemId: params.itemId, + }); + if (isConfirmationResponse(response)) { + await handleDomainConfirmation(response.confirmation, params); + return; + } + if (!isQueueResponse(response) && !isCountResponse(response)) { + setStatus(getResponseError(response)); + return; + } + await refreshState(); +} + +async function handleDomainConfirmation( + confirmation: DomainConfirmationRequest, + retry: Parameters[0], +): Promise { + const confirmed = await showDialog(renderDomainDialog({ + host: confirmation.host, + organizationName: confirmation.organizationName, + })); + if (!confirmed) return; + await browser.runtime.sendMessage({ + type: 'comp:confirm-domain', + host: confirmation.host, + organizationId: confirmation.organizationId, + }); + await runQueueAction(retry); +} + +async function closePanel(): Promise { + if (!activeTabId) return; + await browser.sidePanel.close({ tabId: activeTabId }).catch(() => window.close()); +} + +function setStatus(message: string): void { + statusMessage = message; + render(message); +} + +function renderMessage(message: string): void { + appRoot.innerHTML = `

${escapeHtml(message)}

`; +} + +function getListScrollTop(): number | null { + const list = appRoot.querySelector('.list'); + return list instanceof HTMLElement ? list.scrollTop : null; +} + +function restoreListScrollTop(value: number | null): void { + if (value === null) return; + const list = appRoot.querySelector('.list'); + if (list instanceof HTMLElement) list.scrollTop = value; +} + +function isPanelRefreshMessage(value: unknown): value is { type: string } { + return typeof value === 'object' && value !== null && 'type' in value && + (value.type === 'comp:queue-updated' || value.type === 'comp:auth-updated'); +} + +function isStaleResponse(value: unknown): value is { ok: true; staleDraftCount: number } { + return ( + typeof value === 'object' && + value !== null && + 'ok' in value && + value.ok === true && + 'staleDraftCount' in value && + typeof value.staleDraftCount === 'number' + ); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/queue-polish.css b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/queue-polish.css new file mode 100644 index 0000000000..aba5c3a01c --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/queue-polish.css @@ -0,0 +1,95 @@ +.row { + border-left: 0; + border-radius: 0; + border-right: 0; +} + +.row.generated, +.row.generating, +.row.approved, +.row.inserted, +.row.flagged { + background: #fff; +} + +.row.generated, +.row.generating { + border-color: #eee7d6; +} + +.row.approved { + border-color: #dbe4f4; +} + +.row.inserted { + border-color: #d7eadf; +} + +.row.flagged { + border-color: #eadcdf; +} + +.answer.flagged { + background: #fafafa; + border: 1px solid #dedede; + border-radius: 6px; + box-sizing: border-box; + color: #52525b; + display: grid; + gap: 6px; +} + +.answer.flagged strong { + color: #8f1d2c; + font-size: 10px; + font-weight: 850; + letter-spacing: 0; + line-height: 14px; + text-transform: uppercase; +} + +.answer.flagged span { + font-size: 12px; + line-height: 17px; +} + +.conf.low { + background: #f4f4f5; + color: #8f1d2c; +} + +.paste-mark { + border: 1.5px solid currentColor; + border-radius: 2px; + height: 12px; + position: relative; + width: 12px; +} + +.paste-mark::before { + border-bottom: 1.5px solid currentColor; + border-right: 1.5px solid currentColor; + content: ""; + height: 5px; + left: 3px; + position: absolute; + top: -4px; + transform: rotate(45deg); + width: 5px; +} + +.paste-mark::after { + background: currentColor; + content: ""; + height: 8px; + left: 6px; + position: absolute; + top: -5px; + width: 1.5px; +} + +.paste-status { + background: var(--surface); + border-radius: 6px; + padding: 8px; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/render.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/render.ts new file mode 100644 index 0000000000..9458e45d6c --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/render.ts @@ -0,0 +1,281 @@ +import { compMarkSvg } from '../../lib/brand'; +import { extensionConfig } from '../../lib/config'; +import type { + AnswerConfidence, + Organization, + PanelState, + QuestionQueueItem, + QueueStatus, +} from '../../lib/types'; +import { renderSheetMappingBar } from './sheet-mapping-ui'; +import { + footerAction, + footerButtonLabel, + footerDisabled, + renderSurfaceNote, +} from './surface-ui'; + +export function renderSidePanel( + state: PanelState, + message = '', + isRefreshing = false, +): string { + if (state.auth.status !== 'authenticated') { + return shell(` +
+

Sign in to Comp AI

+

Use your Comp session to generate questionnaire answers.

+ +
+ `); + } + + const total = state.queue.items.length; + const inserted = countByStatus(state.queue.items, 'inserted'); + const approved = countByStatus(state.queue.items, 'approved'); + const generated = countByStatus(state.queue.items, 'generated'); + const generating = countByStatus(state.queue.items, 'generating'); + const flagged = countByStatus(state.queue.items, 'flagged'); + const answerCount = state.queue.items.filter((item) => item.answer).length; + const progress = total > 0 ? Math.round((inserted / total) * 100) : 0; + + return shell(` +
+
+
+ +

Questionnaire

+ + +
+ ${renderOrgSelect(state.auth.organizations, state.auth.selectedOrganizationId)} +
+
+ ${legendItem('inserted', `Inserted ${inserted}`)} + ${legendItem('approved', `Approved ${approved}`)} + ${generating > 0 ? legendItem('generating', `Drafting ${generating}`) : ''} + ${generated > 0 ? legendItem('generated', `Drafted ${generated}`) : ''} + ${flagged > 0 ? legendItem('flagged', `Review ${flagged}`) : ''} +
+
+
+ + +
+ ${renderSurfaceNote(state.queue.surface)} + ${renderSheetMappingBar({ + mapping: state.queue.sheetMapping, + surface: state.queue.surface, + })} + ${message ? `
${escapeHtml(message)}
` : ''} +
+ ${total > 0 ? state.queue.items.map((item) => renderQueueRow({ + item, + canInsertIntoSurface: + state.queue.surface !== 'docs', + surface: state.queue.surface, + })).join('') : renderEmptyQueue()} +
+
+ +
+
+ `); +} + +export function renderDomainDialog(params: { + host: string; + organizationName: string; +}): string { + return dialog(` +
Confirm workspace
+

Use ${escapeHtml(params.organizationName)} here?

+

Answers will be generated or inserted on ${escapeHtml(params.host)}.

+
${escapeHtml(params.organizationName)}
+
+ + +
+ `); +} + +export function renderInsertDialog(params: { + count: number; + host: string; + organizationName: string; + lowConfidenceCount: number; + operation?: 'Insert' | 'Copy'; +}): string { + const operation = params.operation ?? 'Insert'; + const destination = operation === 'Copy' + ? 'Answers will be copied for guided paste into the detected answer cells.' + : `Destination: ${escapeHtml(params.host)}`; + return dialog(` +
Confirm ${operation.toLowerCase()}
+

${operation} ${params.count} approved answer${params.count === 1 ? '' : 's'}?

+

${destination}

+
${escapeHtml(params.organizationName)}
+ ${ + params.lowConfidenceCount > 0 + ? `

${params.lowConfidenceCount} low-confidence draft${params.lowConfidenceCount === 1 ? '' : 's'} excluded.

` + : '' + } +
+ + +
+ `); +} + +export function renderStaleDialog(staleDraftCount: number): string { + return dialog(` +
Workspace changed
+

${staleDraftCount} draft${staleDraftCount === 1 ? '' : 's'} cleared

+

Uninserted answers must be regenerated for the new workspace.

+
+ +
+ `); +} + +function renderOrgSelect( + organizations: Organization[], + selectedOrganizationId: string | null, +): string { + const options = organizations + .map( + (org) => + ``, + ) + .join(''); + return ` + + `; +} + +function renderQueueRow(params: { + item: QuestionQueueItem; + canInsertIntoSurface: boolean; + surface: PanelState['queue']['surface']; +}): string { + const { item } = params; + const canApprove = item.status === 'generated' && Boolean(item.answer); + const canInsert = + params.canInsertIntoSurface && + (item.status === 'approved' || item.status === 'generated') && + Boolean(item.answer); + const sources = item.sources.length; + return ` +
+ + ${renderAnswer(item)} +
+ ${item.confidence ? confidence(item.confidence) : `${statusLabel(item.status)}`} + ${sources > 0 ? `Sources · ${sources}` : ''} + + ${canApprove ? `` : ''} + ${canInsert ? renderInsertButton({ itemId: item.id, surface: params.surface }) : ''} +
+
+ `; +} + +function renderInsertButton(params: { + itemId: string; + surface: PanelState['queue']['surface']; +}): string { + const itemId = escapeHtml(params.itemId); + if (params.surface !== 'sheets') { + return ``; + } + const title = extensionConfig.googleSheetsApiEnabled + ? 'Insert into mapped cell' + : 'Prepare mapped paste'; + return [ + `', + ].join(' '); +} + +function renderAnswer(item: QuestionQueueItem): string { + if (item.status === 'generating') { + return '
Drafting from knowledge base...
'; + } + if (item.status === 'flagged') { + return [ + '
', + 'Needs review', + `${escapeHtml(item.error ?? 'No knowledge-base match.')}`, + '
', + ].join(''); + } + if (!item.answer) return ''; + return ``; +} + +function renderEmptyQueue(): string { + return ` +
+

No fields detected

+

Scan the page or click a Comp AI button next to a questionnaire field.

+
+ `; +} + +function shell(body: string): string { + return `
${body}
`; +} + +function dialog(body: string): string { + return ``; +} + +function legendItem(status: QueueStatus, label: string): string { + return `${escapeHtml(label)}`; +} + +function confidence(level: AnswerConfidence): string { + return `${level}`; +} + +function statusLabel(status: QueueStatus): string { + if (status === 'pending') return 'Pending'; + if (status === 'generating') return 'Drafting'; + if (status === 'generated') return 'Drafted'; + if (status === 'approved') return 'Approved'; + if (status === 'inserted') return 'Inserted'; + return 'Review'; +} + +function countByStatus(items: QuestionQueueItem[], status: QueueStatus): number { + return items.filter((item) => item.status === status).length; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping-actions.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping-actions.ts new file mode 100644 index 0000000000..9322245a9e --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping-actions.ts @@ -0,0 +1,45 @@ +import { browser } from 'wxt/browser'; +import { + getResponseError, + isOkResponse, +} from '../../lib/response-guards'; +import { + createDefaultSheetMapping, + createManualSheetMapping, + parseSheetIdentityFromUrl, +} from '../../lib/sheet-mapping'; +import type { PanelState } from '../../lib/types'; +import { showSheetMappingDialog } from './sheet-mapping-dialog'; +import { renderSheetMappingDialog } from './sheet-mapping-ui'; + +export async function handleSheetMappingChange(params: { + state: PanelState | null; + refreshFromPage(): Promise; + setStatus(message: string): void; +}): Promise { + if (!params.state) return; + + const identity = parseSheetIdentityFromUrl(params.state.queue.url); + if (!identity) { + params.setStatus('Open a Google Sheet and refresh before setting mapping.'); + return; + } + + const mapping = params.state.queue.sheetMapping ?? + createDefaultSheetMapping(identity); + const draft = await showSheetMappingDialog( + renderSheetMappingDialog(mapping), + ); + if (!draft) return; + + const response = await browser.runtime.sendMessage({ + type: 'comp:set-sheet-mapping', + mapping: createManualSheetMapping({ identity, draft }), + }); + if (!isOkResponse(response)) { + params.setStatus(getResponseError(response)); + return; + } + + await params.refreshFromPage(); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping-dialog.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping-dialog.ts new file mode 100644 index 0000000000..26e4e3ae3c --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping-dialog.ts @@ -0,0 +1,82 @@ +import { + normalizeColumnName, +} from '../../lib/sheet-columns'; +import type { SheetMappingDraft } from '../../lib/sheet-mapping'; + +export function showSheetMappingDialog( + html: string, +): Promise { + return new Promise((resolve) => { + const container = document.createElement('div'); + container.innerHTML = html; + document.body.appendChild(container); + + const form = container.querySelector('[data-sheet-mapping-form]'); + if (!(form instanceof HTMLFormElement)) { + container.remove(); + resolve(null); + return; + } + + const close = (value: SheetMappingDraft | null): void => { + container.remove(); + resolve(value); + }; + + container + .querySelector('[data-dialog="cancel"]') + ?.addEventListener('click', () => close(null)); + form.addEventListener('submit', (event) => { + event.preventDefault(); + const draft = readDraft(form); + if (!draft) return; + close(draft); + }); + }); +} + +function readDraft(form: HTMLFormElement): SheetMappingDraft | null { + const data = new FormData(form); + const questionColumn = readColumn(data.get('questionColumn')); + const answerColumn = readColumn(data.get('answerColumn')); + const startRow = readPositiveInteger(data.get('startRow')); + const endRow = readOptionalEndRow(data.get('endRow')); + + if (!questionColumn || !answerColumn) { + showError(form, 'Use column letters like B, C, or AA.'); + return null; + } + if (!startRow) { + showError(form, 'Start row must be a positive number.'); + return null; + } + if (endRow !== null && endRow < startRow) { + showError(form, 'End row must be after the start row.'); + return null; + } + + return { questionColumn, answerColumn, startRow, endRow }; +} + +function readColumn(value: FormDataEntryValue | null): string | null { + return typeof value === 'string' ? normalizeColumnName(value) : null; +} + +function readPositiveInteger(value: FormDataEntryValue | null): number | null { + if (typeof value !== 'string') return null; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 1 ? parsed : null; +} + +function readOptionalEndRow(value: FormDataEntryValue | null): number | null { + if (typeof value !== 'string' || value.trim().length === 0) return null; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 1 ? parsed : null; +} + +function showError(form: HTMLFormElement, message: string): void { + const error = form.querySelector('[data-sheet-mapping-error]'); + if (!(error instanceof HTMLElement)) return; + error.hidden = false; + error.textContent = message; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping-ui.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping-ui.ts new file mode 100644 index 0000000000..06e959f60d --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping-ui.ts @@ -0,0 +1,125 @@ +import type { SheetMapping } from '../../lib/types'; + +export function renderSheetMappingBar(params: { + mapping: SheetMapping | null; + surface: string; +}): string { + if (params.surface !== 'sheets') return ''; + if (!params.mapping) { + return ` +
+
+ Sheet mapping + Set question and answer columns + No saved mapping for this tab. +
+ +
+ `; + } + + const badge = params.mapping.confirmed ? 'Saved' : 'Auto'; + return ` +
+
+ Sheet mapping + Questions ${escapeHtml(params.mapping.questionColumn)} · Answers ${escapeHtml(params.mapping.answerColumn)} + Rows ${escapeHtml(formatRows(params.mapping))} +
+ ${badge} + +
+ `; +} + +export function renderSheetMappingDialog(mapping: SheetMapping): string { + return ` + + `; +} + +function inputField(params: { + label: string; + name: string; + value: string; +}): string { + return ` + + `; +} + +function numberField(params: { + label: string; + name: string; + value: string; +}): string { + return ` + + `; +} + +function formatRows(mapping: SheetMapping): string { + return mapping.endRow ? `${mapping.startRow}-${mapping.endRow}` : `${mapping.startRow}+`; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping.css b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping.css new file mode 100644 index 0000000000..111fc1c4fb --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping.css @@ -0,0 +1,114 @@ +.sheet-map { + align-items: center; + background: #fafafa; + border-bottom: 1px solid var(--border); + display: grid; + gap: 8px; + grid-template-columns: minmax(0, 1fr) auto auto; + padding: 10px 12px; +} + +.sheet-map-text { + display: grid; + gap: 2px; + min-width: 0; +} + +.sheet-map-text strong, +.sheet-map-text span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sheet-map-text strong { + font-size: 12px; + line-height: 17px; +} + +.sheet-map-text span:last-child { + color: var(--muted); + font-size: 11px; + line-height: 15px; +} + +.map-pill { + border-radius: 999px; + font-size: 9px; + font-weight: 850; + line-height: 14px; + padding: 2px 6px; + text-transform: uppercase; + white-space: nowrap; +} + +.map-pill.confirmed { + background: #ecfdf3; + color: var(--primary); +} + +.map-pill.auto { + background: #eff6ff; + color: var(--blue); +} + +.sheet-map-form { + display: grid; + gap: 12px; +} + +.map-grid { + display: grid; + gap: 10px; + grid-template-columns: 1fr 1fr; +} + +.map-grid label { + display: grid; + gap: 5px; +} + +.map-grid label span { + color: var(--muted); + font-size: 10px; + font-weight: 750; +} + +.map-grid input { + border: 1px solid var(--border); + border-radius: 6px; + box-sizing: border-box; + font: inherit; + font-size: 13px; + min-height: 34px; + padding: 7px 8px; + width: 100%; +} + +.form-error { + color: var(--danger); + font-size: 11px; + line-height: 16px; + margin: 0; +} + +@media (max-width: 260px) { + .sheet-map { + grid-template-columns: minmax(0, 1fr) auto; + } + + .map-pill { + grid-column: 1; + grid-row: 2; + justify-self: start; + } + + .sheet-map .secondary { + grid-column: 2; + grid-row: 1 / span 2; + } + + .map-grid { + grid-template-columns: 1fr; + } +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-paste-actions.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-paste-actions.ts new file mode 100644 index 0000000000..9049f091ea --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-paste-actions.ts @@ -0,0 +1,175 @@ +import { browser } from 'wxt/browser'; +import { + getResponseError, + isConfirmationResponse, + isCountResponse, + isQueueResponse, + isSheetPasteResponse, +} from '../../lib/response-guards'; +import { extensionConfig } from '../../lib/config'; +import type { DomainConfirmationRequest, PanelState } from '../../lib/types'; +import { + saveAllVisibleAnswers, + saveAnswerForItem, +} from './answer-edits'; +import { showDialog } from './dialog'; +import { renderDomainDialog, renderInsertDialog } from './render'; +import { showSheetPasteDialog } from './sheet-paste-dialog'; + +export async function handleSheetPaste(params: { + activeTabId: number | null; + itemId?: string; + refreshState(): Promise; + root: HTMLElement; + setStatus(message: string): void; + state: PanelState | null; +}): Promise { + if (!params.activeTabId || !params.state) return; + const activeTabId = params.activeTabId; + await saveAnswersBeforeInsert({ + activeTabId, + itemId: params.itemId, + root: params.root, + }); + + if (extensionConfig.googleSheetsApiEnabled) { + await handleSheetApiInsert({ + activeTabId, + itemId: params.itemId, + refreshState: params.refreshState, + setStatus: params.setStatus, + state: params.state, + }); + return; + } + + const prepared = await prepareSheetPaste({ + activeTabId, + itemId: params.itemId, + }); + if (!isSheetPasteResponse(prepared)) { + params.setStatus(getResponseError(prepared)); + return; + } + + const confirmed = await showSheetPasteDialog(prepared.sheetPaste); + if (!confirmed) return; + + const marked = await browser.runtime.sendMessage({ + type: 'comp:mark-sheet-paste-inserted', + tabId: activeTabId, + itemIds: prepared.sheetPaste.itemIds, + }); + if (!isQueueResponse(marked)) { + params.setStatus(getResponseError(marked)); + return; + } + await params.refreshState(); +} + +async function handleSheetApiInsert(params: { + activeTabId: number; + itemId?: string; + refreshState(): Promise; + setStatus(message: string): void; + state: PanelState; +}): Promise { + if (!params.itemId && !(await confirmBatchInsert(params.state))) return; + + const response = await insertSheetWithApi({ + activeTabId: params.activeTabId, + itemId: params.itemId, + }); + if (!isCountResponse(response) && !isQueueResponse(response)) { + params.setStatus(getResponseError(response)); + return; + } + await params.refreshState(); +} + +async function saveAnswersBeforeInsert(params: { + activeTabId: number; + itemId?: string; + root: HTMLElement; +}): Promise { + if (params.itemId) { + await saveAnswerForItem({ + root: params.root, + tabId: params.activeTabId, + itemId: params.itemId, + }); + return; + } + await saveAllVisibleAnswers({ + root: params.root, + tabId: params.activeTabId, + }); +} + +async function confirmBatchInsert(state: PanelState): Promise { + const approved = state.queue.items.filter((item) => item.status === 'approved'); + if (approved.length === 0) return false; + const org = state.auth.organizations.find( + (entry) => entry.id === state.auth.selectedOrganizationId, + ); + return showDialog(renderInsertDialog({ + count: approved.length, + host: state.queue.host, + organizationName: org?.name ?? 'selected organization', + operation: 'Insert', + lowConfidenceCount: state.queue.items.filter( + (item) => item.confidence === 'low' && item.status !== 'approved', + ).length, + })); +} + +async function prepareSheetPaste(params: { + activeTabId: number; + itemId?: string; +}): Promise { + const response = await browser.runtime.sendMessage({ + type: 'comp:prepare-sheet-paste', + tabId: params.activeTabId, + itemId: params.itemId, + }); + if (!isConfirmationResponse(response)) return response; + + const confirmed = await confirmDomain(response.confirmation); + if (!confirmed) return { ok: false, error: 'Paste cancelled.' }; + await browser.runtime.sendMessage({ + type: 'comp:confirm-domain', + host: response.confirmation.host, + organizationId: response.confirmation.organizationId, + }); + return prepareSheetPaste(params); +} + +async function insertSheetWithApi(params: { + activeTabId: number; + itemId?: string; +}): Promise { + const response = await browser.runtime.sendMessage({ + type: 'comp:insert-sheet-api', + tabId: params.activeTabId, + itemId: params.itemId, + }); + if (!isConfirmationResponse(response)) return response; + + const confirmed = await confirmDomain(response.confirmation); + if (!confirmed) return { ok: false, error: 'Insert cancelled.' }; + await browser.runtime.sendMessage({ + type: 'comp:confirm-domain', + host: response.confirmation.host, + organizationId: response.confirmation.organizationId, + }); + return insertSheetWithApi(params); +} + +async function confirmDomain( + confirmation: DomainConfirmationRequest, +): Promise { + return showDialog(renderDomainDialog({ + host: confirmation.host, + organizationName: confirmation.organizationName, + })); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-paste-dialog.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-paste-dialog.ts new file mode 100644 index 0000000000..8ef240d2b1 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-paste-dialog.ts @@ -0,0 +1,90 @@ +import type { SheetPastePayload } from '../../lib/sheets-paste-plan'; + +export function showSheetPasteDialog( + paste: SheetPastePayload, +): Promise { + return new Promise((resolve) => { + const container = document.createElement('div'); + container.innerHTML = renderDialog(paste); + document.body.appendChild(container); + + const close = (confirmed: boolean): void => { + container.remove(); + resolve(confirmed); + }; + const handleCopy = (): void => { + void copyText(paste.tsv).then( + () => setStatus(container, 'Copied. Select the first answer cell, then paste.'), + (error: unknown) => { + const message = error instanceof Error ? error.message : 'Unable to copy answers.'; + setStatus(container, message); + }, + ); + }; + + handleCopy(); + container + .querySelector('[data-dialog="cancel"]') + ?.addEventListener('click', () => close(false)); + container + .querySelector('[data-dialog="confirm"]') + ?.addEventListener('click', () => close(true)); + container + .querySelector('[data-dialog="copy"]') + ?.addEventListener('click', handleCopy); + }); +} + +function renderDialog(paste: SheetPastePayload): string { + return ` + + `; +} + +async function copyText(text: string): Promise { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text).catch(() => copyWithSelection(text)); + return; + } + await copyWithSelection(text); +} + +async function copyWithSelection(text: string): Promise { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.left = '-9999px'; + textarea.style.position = 'fixed'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + const copied = document.execCommand('copy'); + textarea.remove(); + if (!copied) throw new Error('Unable to copy answers to the clipboard.'); +} + +function setStatus(container: HTMLElement, message: string): void { + const status = container.querySelector('[data-paste-status]'); + if (status instanceof HTMLElement) status.textContent = message; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sticky-footer.css b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sticky-footer.css new file mode 100644 index 0000000000..b88d844128 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sticky-footer.css @@ -0,0 +1,26 @@ +body { + overflow: hidden; +} + +.shell, +.panel { + height: 100vh; + min-height: 0; +} + +.panel { + overflow: hidden; +} + +.list { + min-height: 0; +} + +.foot { + background: #fff; + box-shadow: 0 -8px 18px -16px rgb(9 9 11 / 35%); + flex: none; + position: sticky; + bottom: 0; + z-index: 2; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/style.css b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/style.css new file mode 100644 index 0000000000..9a6af415f1 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/style.css @@ -0,0 +1,298 @@ +:root { + --primary: lab(29.0654 -26.8582 6.12442); + --fg: #09090b; + --muted: #71717a; + --surface: #f4f4f5; + --border: #e4e4e7; + --blue: #3b82f6; + --yellow: #eab308; + --danger: #dc2626; + color: var(--fg); + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +body { margin: 0; } +button, select, textarea { font: inherit; } +.shell, .panel { min-height: 100vh; } + +.panel { + background: #fff; + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; +} + +.head { + border-bottom: 1px solid var(--border); + display: grid; + gap: 10px; + padding: 12px; +} + +.title-row, .legend, .row-foot, .toolbar { + align-items: center; + display: flex; + gap: 8px; +} + +.mark, .mark svg { display: block; height: 18px; width: 18px; } +.mark { flex: none; } +.mark svg { fill: #16171b; } + +h1 { flex: 1; font-size: 13px; font-weight: 700; line-height: 18px; margin: 0; } + +.icon { + align-items: center; + background: #fff; + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + display: inline-flex; + height: 26px; + justify-content: center; + width: 26px; +} +.icon.refreshing { animation: spin 700ms linear infinite; color: var(--primary); opacity: 1; } +@keyframes spin { to { transform: rotate(360deg); } } + +.org-select { + background: #ecfdf3; + border: 1px solid #bbf7d0; + border-radius: 6px; + display: grid; + gap: 5px; + padding: 8px; +} + +.org-select span, .eyebrow { + color: var(--muted); + font-size: 9px; + font-weight: 800; + text-transform: uppercase; +} + +select { + background: transparent; + border: 0; + color: var(--fg); + font-size: 13px; + font-weight: 700; + outline: 0; +} + +.progress { + background: var(--surface); + border-radius: 999px; + height: 5px; + overflow: hidden; +} + +.progress span { + background: var(--primary); + display: block; + height: 100%; +} + +.legend { + color: var(--muted); + flex-wrap: wrap; + font-size: 10px; +} + +.legend i, .sq { + display: inline-block; + height: 8px; + margin-right: 5px; + width: 8px; +} +.sq { flex: none; margin-right: 0; margin-top: 4px; } + +.legend i.inserted, .sq.inserted { background: var(--primary); } +.legend i.approved, .sq.approved { background: var(--blue); } +.legend i.generated, .legend i.generating, .sq.generated, .sq.generating { background: var(--yellow); } +.legend i.flagged, .sq.flagged { background: var(--danger); } +.legend i.pending, .sq.pending { background: #6b7280; } + +.toolbar { border-bottom: 1px solid var(--border); flex-wrap: wrap; padding: 10px; } + +.list { + display: grid; + flex: 1; + gap: 9px; + overflow-y: auto; + padding: 10px 0; +} + +.row { + background: #fff; + border: 1px solid var(--border); + border-radius: 6px; + display: grid; + gap: 12px; + padding: 10px; +} +.row.generated, .row.generating { background: #fffbeb; border-color: #fde68a; } +.row.approved { background: #eff6ff; border-color: #bfdbfe; } .row.inserted { background: #ecfdf3; border-color: #bbf7d0; } .row.flagged { background: #fef2f2; border-color: #fecaca; } + +.row-main { + align-items: flex-start; + background: transparent; + border: 0; + color: var(--fg); + cursor: pointer; + display: grid; + font-size: 12px; + font-weight: 650; + gap: 8px; + grid-template-columns: 8px minmax(0, 1fr); + line-height: 17px; + padding: 0; + text-align: left; +} + +.row-foot { flex-wrap: wrap; min-height: 28px; padding-left: 16px; row-gap: 6px; } + +textarea { + border: 1px solid var(--border); + border-radius: 6px; + box-sizing: border-box; + color: var(--fg); + font-size: 12px; + line-height: 17px; + min-height: 92px; + padding: 8px; + resize: vertical; + width: 100%; +} +.row > textarea, .row > .answer { margin-left: 16px; width: calc(100% - 16px); } + +.answer { + background: var(--surface); + border-radius: 6px; + color: #52525b; + font-size: 12px; + line-height: 17px; + padding: 8px; +} + +.answer.flagged { background: #fef2f2; color: var(--danger); } +.spacer, .flex { flex: 1; } + +button.primary, button.secondary, button.ghost { + border-radius: 6px; + cursor: pointer; + font-size: 12px; + font-weight: 800; + min-height: 32px; + padding: 7px 10px; +} + +button.primary { + background: var(--primary); + border: 1px solid var(--primary); + color: #fff; +} +button.primary:hover { background: color-mix(in srgb, var(--primary) 88%, #000); } +button.secondary, button.ghost { + background: #fff; + border: 1px solid #d4d4d8; + color: #3f3f46; +} + +button.ghost { border-color: transparent; } +button.block { width: 100%; } +button.icon-action { align-items: center; display: inline-flex; justify-content: center; min-width: 32px; padding: 5px; } +button.xs { font-size: 11px; min-height: 28px; padding: 5px 8px; } +button:disabled { cursor: not-allowed; opacity: 0.5; } + +.conf, .status, .sources { + align-items: center; color: var(--muted); display: inline-flex; flex: none; + font-size: 10px; font-weight: 800; text-transform: uppercase; + white-space: nowrap; +} + +.conf { + border-radius: 2px; + padding: 2px 5px; +} +.conf.high, .conf.low { background: #d7f9e7; color: #009e54; } +.conf.med { background: #fdecd2; color: #c2740a; } + +.notice { + background: #fffbeb; + border-bottom: 1px solid #fde68a; + color: #92400e; + font-size: 12px; + line-height: 17px; + padding: 9px 12px; +} +.notice.info { background: #f4f4f5; border-bottom-color: var(--border); color: #3f3f46; } + +.foot { + border-top: 1px solid var(--border); + padding: 10px; +} + +.foot p, .empty p, .dialog p { + color: var(--muted); + font-size: 11px; + line-height: 16px; + margin: 8px 0 0; +} + +.empty { + align-content: center; + display: grid; + justify-items: center; + min-height: 220px; + padding: 20px; + text-align: center; +} + +.modal, .backdrop { + inset: 0; + position: fixed; +} + +.modal { z-index: 100; } +.backdrop { background: rgb(9 9 11 / 38%); } + +.dialog { + background: #fff; + border-radius: 12px; + box-shadow: 0 12px 32px -8px rgb(16 24 40 / 12%); + box-sizing: border-box; + display: grid; + gap: 12px; + left: 16px; + padding: 16px; + position: absolute; + right: 16px; + top: 90px; +} + +.dialog h2 { + font-size: 17px; + line-height: 22px; + margin: 0; +} +.dialog code { + background: var(--surface); + border-radius: 4px; + padding: 1px 4px; +} + +.dialog-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.org-banner { + background: #ecfdf3; + border: 1px solid #bbf7d0; + border-radius: 6px; + font-size: 13px; + font-weight: 800; + padding: 10px; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/surface-ui.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/surface-ui.ts new file mode 100644 index 0000000000..6109edb818 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/surface-ui.ts @@ -0,0 +1,47 @@ +import { extensionConfig } from '../../lib/config'; +import type { PanelState } from '../../lib/types'; + +type Surface = PanelState['queue']['surface']; + +export function renderSurfaceNote(surface: Surface): string { + if (surface === 'docs') { + return '
Google Docs detected. Inline buttons are disabled; use copy or side-panel review first.
'; + } + if (surface === 'sheets') return ''; + if (surface === 'forms') { + return '
Google Forms detected. Text and paragraph answer fields can be generated and inserted.
'; + } + return ''; +} + +export function footerButtonLabel(params: { + approved: number; + answerCount: number; + surface: Surface; +}): string { + if (params.surface === 'sheets') { + if (extensionConfig.googleSheetsApiEnabled) { + return params.approved > 0 + ? `Insert into sheet (${params.approved})` + : 'Approve answers to insert'; + } + return params.approved > 0 + ? `Prepare paste (${params.approved})` + : 'Approve answers to paste'; + } + if (params.approved > 0) return `Insert ${params.approved} approved`; + return 'Approve answers to insert'; +} + +export function footerAction(surface: Surface): string { + return surface === 'docs' ? 'copy-sheet-answers' : 'insert-approved'; +} + +export function footerDisabled(params: { + approved: number; + answerCount: number; + surface: Surface; +}): string { + if (params.surface === 'docs') return 'disabled'; + return params.approved === 0 ? 'disabled' : ''; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/api.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/api.ts new file mode 100644 index 0000000000..9c588f653e --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/api.ts @@ -0,0 +1,152 @@ +import { z } from 'zod'; +import { extensionConfig } from './config'; +import type { AuthState, GeneratedAnswer, Organization } from './types'; + +const OrganizationSchema = z.object({ + id: z.string(), + name: z.string(), + logo: z.string().nullable().optional(), + memberRole: z.string().nullable().optional(), + memberId: z.string().nullable().optional(), +}); + +const AuthMeSchema = z.object({ + user: z + .object({ + id: z.string(), + email: z.string(), + name: z.string().nullable().optional(), + }) + .nullable(), + organizations: z.array(OrganizationSchema), +}); + +const SessionSchema = z + .object({ + session: z + .object({ + activeOrganizationId: z.string().nullable().optional(), + }) + .nullable() + .optional(), + }) + .nullable(); + +const GeneratedAnswerSchema = z.object({ + success: z.boolean(), + data: z.object({ + questionIndex: z.number(), + question: z.string(), + answer: z.string().nullable().optional(), + sources: z.array(z.unknown()).optional(), + error: z.string().nullable().optional(), + }), +}); + +async function readJson(response: Response): Promise { + const text = await response.text(); + if (!text) return null; + return JSON.parse(text); +} + +function getErrorMessage(data: unknown, fallback: string): string { + if (typeof data === 'object' && data !== null && 'message' in data) { + const message = data.message; + if (typeof message === 'string') return message; + } + return fallback; +} + +async function fetchJson(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${extensionConfig.apiBaseUrl}${path}`, { + credentials: 'include', + ...init, + headers: { + 'Content-Type': 'application/json', + ...init.headers, + }, + }); + const data = await readJson(response); + if (!response.ok) { + throw new Error(getErrorMessage(data, `Request failed: ${response.status}`)); + } + return data; +} + +export async function getAuthState( + selectedOrganizationId: string | null, +): Promise { + const [data, sessionData] = await Promise.all([ + fetchJson('/v1/auth/me', { method: 'GET' }), + fetchJson('/api/auth/get-session', { method: 'GET' }).catch(() => null), + ]); + const parsed = AuthMeSchema.parse(data); + const session = SessionSchema.parse(sessionData); + const validSelectedOrg = resolveSelectedOrganization({ + organizations: parsed.organizations, + selectedOrganizationId, + activeOrganizationId: session?.session?.activeOrganizationId ?? null, + }); + + return { + status: parsed.user ? 'authenticated' : 'unauthenticated', + user: parsed.user, + organizations: parsed.organizations, + selectedOrganizationId: validSelectedOrg, + apiBaseUrl: extensionConfig.apiBaseUrl, + appBaseUrl: extensionConfig.appBaseUrl, + }; +} + +export async function setActiveOrganization( + organizationId: string, +): Promise { + await fetchJson('/api/auth/organization/set-active', { + method: 'POST', + body: JSON.stringify({ organizationId }), + }); +} + +export async function generateAnswer(params: { + organizationId: string; + question: string; + questionIndex: number; + totalQuestions: number; +}): Promise { + const data = await fetchJson('/v1/questionnaire/answer-single', { + method: 'POST', + body: JSON.stringify({ + question: params.question, + questionIndex: params.questionIndex, + totalQuestions: params.totalQuestions, + organizationId: params.organizationId, + }), + }); + const parsed = GeneratedAnswerSchema.parse(data); + return { + questionIndex: parsed.data.questionIndex, + question: parsed.data.question, + answer: parsed.data.answer ?? null, + sources: parsed.data.sources ?? [], + error: parsed.data.error, + }; +} + +function resolveSelectedOrganization(params: { + organizations: Organization[]; + selectedOrganizationId: string | null; + activeOrganizationId: string | null; +}): string | null { + if (params.organizations.length === 0) return null; + const storedSelection = params.organizations.find( + (org) => org.id === params.selectedOrganizationId, + ); + if (storedSelection) return storedSelection.id; + + const activeSelection = params.organizations.find( + (org) => org.id === params.activeOrganizationId, + ); + if (activeSelection) return activeSelection.id; + + return params.organizations.length === 1 ? params.organizations[0].id : null; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/async-pool.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/async-pool.test.ts new file mode 100644 index 0000000000..355d2a483b --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/async-pool.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { runConcurrent } from './async-pool'; + +describe('runConcurrent', () => { + it('limits active work to the configured concurrency', async () => { + let active = 0; + let maxActive = 0; + + await runConcurrent({ + concurrency: 3, + items: Array.from({ length: 10 }, (_value, index) => index), + run: async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 1)); + active -= 1; + }, + }); + + expect(maxActive).toBeLessThanOrEqual(3); + }); +}); diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/async-pool.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/async-pool.ts new file mode 100644 index 0000000000..ffa16007aa --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/async-pool.ts @@ -0,0 +1,24 @@ +export async function runConcurrent(params: { + concurrency: number; + items: readonly T[]; + run(item: T, index: number): Promise; +}): Promise { + if (params.items.length === 0) return; + const workerCount = Math.min( + Math.max(1, Math.floor(params.concurrency)), + params.items.length, + ); + let nextIndex = 0; + + async function runWorker(): Promise { + while (nextIndex < params.items.length) { + const index = nextIndex; + nextIndex += 1; + await params.run(params.items[index], index); + } + } + + await Promise.all( + Array.from({ length: workerCount }, () => runWorker()), + ); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/auth.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/auth.ts new file mode 100644 index 0000000000..fab700ab5f --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/auth.ts @@ -0,0 +1,119 @@ +import { browser } from 'wxt/browser'; +import { + getAuthState, + setActiveOrganization, +} from '../api'; +import { extensionConfig } from '../config'; +import { + clearConfirmedDomains, + getSelectedOrganizationId, + setSelectedOrganizationId, +} from '../storage'; +import type { AuthState } from '../types'; + +export type ActiveAuthState = AuthState & { selectedOrganizationId: string }; + +const AUTH_UPDATED_MESSAGE = 'comp:auth-updated'; +const AUTH_WINDOW_WIDTH = 520; +const AUTH_WINDOW_HEIGHT = 720; + +let authWindow: { tabId: number | null; windowId: number | null } | null = null; +let isCheckingAuthWindow = false; + +export function setupAuthFlowWatcher(): void { + browser.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (tabId !== authWindow?.tabId) return; + if (!changeInfo.url && changeInfo.status !== 'complete') return; + void closeAuthWindowIfSignedIn(); + }); + browser.tabs.onRemoved.addListener((tabId) => { + if (tabId === authWindow?.tabId) authWindow = null; + }); + browser.windows.onRemoved.addListener((windowId) => { + if (windowId === authWindow?.windowId) authWindow = null; + }); +} + +export async function ensureActiveOrganization(): Promise { + const selectedOrganizationId = await getSelectedOrganizationId(); + const state = await getAuthState(selectedOrganizationId); + if (!state.selectedOrganizationId) { + throw new Error('Select an organization before generating answers.'); + } + + await setActiveOrganization(state.selectedOrganizationId); + if (state.selectedOrganizationId !== selectedOrganizationId) { + await setSelectedOrganizationId(state.selectedOrganizationId); + } + + return { + ...state, + selectedOrganizationId: state.selectedOrganizationId, + }; +} + +export async function getExtensionAuthState(): Promise { + const selectedOrganizationId = await getSelectedOrganizationId(); + const state = await getAuthState(selectedOrganizationId); + if ( + state.selectedOrganizationId && + state.selectedOrganizationId !== selectedOrganizationId + ) { + await setSelectedOrganizationId(state.selectedOrganizationId); + } + return state; +} + +export async function openSignIn(): Promise { + if (authWindow?.windowId) { + await browser.windows.update(authWindow.windowId, { focused: true }).catch(() => { + authWindow = null; + }); + if (authWindow) return; + } + + const createdWindow = await browser.windows.create({ + focused: true, + height: AUTH_WINDOW_HEIGHT, + type: 'popup', + url: buildSignInUrl(), + width: AUTH_WINDOW_WIDTH, + }); + if (!createdWindow) throw new Error('Unable to open the Comp AI sign-in window.'); + authWindow = { + tabId: createdWindow.tabs?.[0]?.id ?? null, + windowId: createdWindow.id ?? null, + }; +} + +export async function switchActiveOrganization( + organizationId: string, +): Promise { + await setActiveOrganization(organizationId); + await setSelectedOrganizationId(organizationId); + await clearConfirmedDomains(); +} + +function buildSignInUrl(): string { + const url = new URL('/auth', extensionConfig.appBaseUrl); + url.searchParams.set('source', 'browser-extension'); + return url.toString(); +} + +async function closeAuthWindowIfSignedIn(): Promise { + if (!authWindow || isCheckingAuthWindow) return; + isCheckingAuthWindow = true; + try { + const state = await getExtensionAuthState().catch(() => null); + if (state?.status !== 'authenticated') return; + + const windowId = authWindow.windowId; + authWindow = null; + if (windowId !== null) await browser.windows.remove(windowId).catch(() => undefined); + await browser.runtime + .sendMessage({ type: AUTH_UPDATED_MESSAGE }) + .catch(() => undefined); + } finally { + isCheckingAuthWindow = false; + } +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.ts new file mode 100644 index 0000000000..adceecddeb --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.ts @@ -0,0 +1,91 @@ +import { generateAnswer } from '../api'; +import { runConcurrent } from '../async-pool'; +import { + applyGeneratedAnswer, + updateQueueItemStatus, +} from '../queue'; +import type { + GeneratedAnswer, + QuestionQueueItem, + TabQuestionQueue, +} from '../types'; + +const DEFAULT_GENERATE_CONCURRENCY = 4; + +export async function generateQueueItemsInBatches(params: { + auth: { selectedOrganizationId: string }; + concurrency?: number; + queue: TabQuestionQueue; + saveQueue(queue: TabQuestionQueue): Promise; +}): Promise { + const candidates = params.queue.items.filter( + (item) => item.status !== 'inserted' && item.status !== 'generating', + ); + if (candidates.length === 0) return params.queue; + + let queue = markCandidatesGenerating({ + candidates, + queue: params.queue, + }); + await params.saveQueue(queue); + + let writeQueue: Promise = Promise.resolve(); + const indexedItems = queue.items; + await runConcurrent({ + concurrency: params.concurrency ?? DEFAULT_GENERATE_CONCURRENCY, + items: candidates, + run: async (item) => { + const questionIndex = indexedItems.findIndex((entry) => entry.id === item.id); + const answer = await generateAnswerSafely({ + auth: params.auth, + item, + questionIndex, + totalQuestions: indexedItems.length, + }); + queue = applyGeneratedAnswer({ queue, itemId: item.id, answer }); + const snapshot = queue; + writeQueue = writeQueue.then(() => params.saveQueue(snapshot)); + await writeQueue; + }, + }); + await writeQueue; + return queue; +} + +function markCandidatesGenerating(params: { + candidates: QuestionQueueItem[]; + queue: TabQuestionQueue; +}): TabQuestionQueue { + return params.candidates.reduce( + (queue, item) => updateQueueItemStatus({ + queue, + itemId: item.id, + status: 'generating', + }), + params.queue, + ); +} + +async function generateAnswerSafely(params: { + auth: { selectedOrganizationId: string }; + item: QuestionQueueItem; + questionIndex: number; + totalQuestions: number; +}): Promise { + try { + return await generateAnswer({ + organizationId: params.auth.selectedOrganizationId, + question: params.item.question, + questionIndex: params.questionIndex, + totalQuestions: params.totalQuestions, + }); + } catch (error) { + return { + questionIndex: params.questionIndex, + question: params.item.question, + answer: null, + sources: [], + error: error instanceof Error ? error.message : 'Unable to generate answer.', + }; + } +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/content-script-injection.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/content-script-injection.test.ts new file mode 100644 index 0000000000..279c4f2d68 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/content-script-injection.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { canInjectQuestionnaireUrl } from './content-script-injection'; + +describe('canInjectQuestionnaireUrl', () => { + it('allows Google Forms and vendor questionnaire pages', () => { + expect(canInjectQuestionnaireUrl('https://docs.google.com/forms/d/e/1/viewform')).toBe(true); + expect(canInjectQuestionnaireUrl('https://vendor.example/security')).toBe(true); + }); + + it('skips protected app/API origins and browser pages', () => { + expect(canInjectQuestionnaireUrl('http://localhost:3000/auth')).toBe(false); + expect(canInjectQuestionnaireUrl('https://api.trycomp.ai/v1/auth/me')).toBe(false); + expect(canInjectQuestionnaireUrl('chrome://extensions')).toBe(false); + }); +}); diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/content-script-injection.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/content-script-injection.ts new file mode 100644 index 0000000000..a044768779 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/content-script-injection.ts @@ -0,0 +1,80 @@ +import { browser } from 'wxt/browser'; +import { shouldSkipQuestionnaireInjection } from '../dom/page-surface'; + +const CONTENT_SCRIPT_FILE = '/content-scripts/content.js'; + +const pendingTabs = new Set(); + +export function setupContentScriptAutoInjection(): void { + void ensureActiveTabs(); + browser.runtime.onInstalled.addListener(() => { + void ensureActiveTabs(); + }); + browser.runtime.onStartup.addListener(() => { + void ensureActiveTabs(); + }); + browser.tabs.onActivated.addListener((activeInfo) => { + void ensureContentScript(activeInfo.tabId); + }); + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status !== 'complete') return; + void ensureContentScript(tabId, tab.url); + }); +} + +export function canInjectQuestionnaireUrl(url: string | undefined): boolean { + if (!url) return false; + try { + const parsed = new URL(url); + if (!['http:', 'https:', 'file:'].includes(parsed.protocol)) return false; + return !shouldSkipQuestionnaireInjection(parsed); + } catch { + return false; + } +} + +async function ensureActiveTabs(): Promise { + const tabs = await browser.tabs.query({ active: true }).catch(() => []); + await Promise.all(tabs.map((tab) => ensureContentScript(tab.id, tab.url))); +} + +async function ensureContentScript( + tabId: number | undefined, + url?: string, +): Promise { + if (typeof tabId !== 'number' || pendingTabs.has(tabId)) return; + pendingTabs.add(tabId); + try { + const tabUrl = url ?? (await browser.tabs.get(tabId).catch(() => null))?.url; + if (!canInjectQuestionnaireUrl(tabUrl)) return; + if (await ensureInlineButtons(tabId)) return; + await browser.scripting.executeScript({ + files: [CONTENT_SCRIPT_FILE], + injectImmediately: true, + target: { tabId }, + }); + await ensureInlineButtons(tabId); + } catch { + // Some pages cannot be scripted even with host permissions. + } finally { + pendingTabs.delete(tabId); + } +} + +async function ensureInlineButtons(tabId: number): Promise { + const response = await browser.tabs + .sendMessage(tabId, { type: 'comp:ensure-inline-buttons' }) + .catch(() => null); + return isCountResponse(response); +} + +function isCountResponse(value: unknown): value is { ok: true; count: number } { + return ( + typeof value === 'object' && + value !== null && + 'ok' in value && + value.ok === true && + 'count' in value && + typeof value.count === 'number' + ); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-api.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-api.test.ts new file mode 100644 index 0000000000..89dc105f5f --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-api.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { + buildSheetValueUpdates, + quoteSheetTitle, +} from './google-sheets-api'; +import { buildSheetFormattingRequests } from './google-sheets-formatting'; + +describe('Google Sheets API value updates', () => { + it('builds exact single-cell ranges for each answer', () => { + const updates = buildSheetValueUpdates({ + sheetTitle: 'Vendor QA', + targets: [ + { + fieldId: 'sheet:123:2:3', + gid: '123', + row: 2, + col: 3, + answer: 'First answer', + }, + { + fieldId: 'sheet:123:4:3', + gid: '123', + row: 4, + col: 3, + answer: 'Third answer', + }, + ], + }); + + expect(updates).toEqual([ + { + fieldId: 'sheet:123:2:3', + range: "'Vendor QA'!C2", + values: [['First answer']], + }, + { + fieldId: 'sheet:123:4:3', + range: "'Vendor QA'!C4", + values: [['Third answer']], + }, + ]); + }); + + it('quotes sheet titles for A1 notation', () => { + expect(quoteSheetTitle("Vendor's QA")).toBe("'Vendor''s QA'"); + }); +}); + +describe('Google Sheets API formatting requests', () => { + it('widens answer columns, wraps cells, and auto-resizes touched rows', () => { + const requests = buildSheetFormattingRequests({ + gid: '456', + targets: [ + { + fieldId: 'sheet:456:2:3', + gid: '456', + row: 2, + col: 3, + answer: `${'x'.repeat(120)}\nshort line`, + }, + { + fieldId: 'sheet:456:3:3', + gid: '456', + row: 3, + col: 3, + answer: 'Second answer', + }, + ], + }); + + expect(requests).toEqual([ + { + updateDimensionProperties: { + range: { + sheetId: 456, + dimension: 'COLUMNS', + startIndex: 2, + endIndex: 3, + }, + properties: { pixelSize: 720 }, + fields: 'pixelSize', + }, + }, + { + repeatCell: { + range: { + sheetId: 456, + startRowIndex: 1, + endRowIndex: 3, + startColumnIndex: 2, + endColumnIndex: 3, + }, + cell: { + userEnteredFormat: { + verticalAlignment: 'TOP', + wrapStrategy: 'WRAP', + }, + }, + fields: 'userEnteredFormat.verticalAlignment,userEnteredFormat.wrapStrategy', + }, + }, + { + autoResizeDimensions: { + dimensions: { + sheetId: 456, + dimension: 'ROWS', + startIndex: 1, + endIndex: 3, + }, + }, + }, + ]); + }); +}); diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-api.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-api.ts new file mode 100644 index 0000000000..ef0fa4ac6f --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-api.ts @@ -0,0 +1,300 @@ +import { extensionConfig } from '../config'; +import { parseSheetIdentityFromUrl } from '../sheet-mapping'; +import type { TabQuestionQueue } from '../types'; +import { + buildSheetFormattingRequests, + type SheetBatchUpdateRequest, +} from './google-sheets-formatting'; +import { + parseSheetTargets, + resolveSheetTargets, + type SheetAnswer, + type SheetApiTarget, +} from './google-sheets-target-resolution'; + +const SHEETS_SCOPE = 'https://www.googleapis.com/auth/spreadsheets'; +const SHEETS_API_BASE = 'https://sheets.googleapis.com/v4/spreadsheets'; + +interface IdentityTokenDetails { + interactive: boolean; + scopes: string[]; +} + +interface ChromeIdentityApi { + getAuthToken(details: IdentityTokenDetails): Promise; + removeCachedAuthToken(details: { token: string }): Promise; +} + +export interface SheetValueUpdate { + fieldId: string; + range: string; + values: string[][]; +} + +export async function insertAnswersWithGoogleSheetsApi(params: { + queue: TabQuestionQueue; + answers: SheetAnswer[]; +}): Promise { + if (!extensionConfig.googleSheetsApiEnabled) { + throw new Error('Google Sheets API OAuth is not configured for this extension build.'); + } + + const spreadsheetId = getSpreadsheetId(params.queue); + const targets = parseSheetTargets(params.answers); + if (targets.length === 0) throw new Error('No mapped sheet answers are ready to insert.'); + + const gid = targets[0].gid; + if (targets.some((target) => target.gid !== gid)) { + throw new Error('Answers span multiple sheet tabs. Insert one tab at a time.'); + } + + return withFreshToken(async (token) => { + const sheetTitle = await getSheetTitle({ gid, spreadsheetId, token }); + const resolvedTargets = await resolveSheetTargets({ + queue: params.queue, + targets, + readColumn: (request) => readSheetColumnValues({ + ...request, + sheetTitle, + spreadsheetId, + token, + }), + }); + const updates = buildSheetValueUpdates({ sheetTitle, targets: resolvedTargets }); + await batchUpdateValues({ spreadsheetId, token, updates }); + await batchUpdateFormatting({ + spreadsheetId, + token, + requests: buildSheetFormattingRequests({ gid, targets: resolvedTargets }), + }).catch(() => undefined); + return updates.map((update) => update.fieldId); + }); +} + +export function buildSheetValueUpdates(params: { + sheetTitle: string; + targets: SheetApiTarget[]; +}): SheetValueUpdate[] { + return params.targets.map((target) => ({ + fieldId: target.fieldId, + range: `${quoteSheetTitle(params.sheetTitle)}!${columnName(target.col)}${target.row}`, + values: [[target.answer]], + })); +} + +export function quoteSheetTitle(title: string): string { + return `'${title.replace(/'/g, "''")}'`; +} + +async function withFreshToken( + operation: (token: string) => Promise, +): Promise { + const token = await getGoogleAccessToken(); + try { + return await operation(token); + } catch (error) { + if (!(error instanceof UnauthorizedSheetsError)) throw error; + await getChromeIdentityApi().removeCachedAuthToken({ token }); + return operation(await getGoogleAccessToken()); + } +} + +async function getGoogleAccessToken(): Promise { + const result = await getChromeIdentityApi().getAuthToken({ + interactive: true, + scopes: [SHEETS_SCOPE], + }); + if (typeof result === 'string' && result.length > 0) return result; + if (isRecord(result) && typeof result.token === 'string' && result.token.length > 0) { + return result.token; + } + throw new Error('Google authorization did not return an access token.'); +} + +function getChromeIdentityApi(): ChromeIdentityApi { + const chromeApi = readRecordProperty(globalThis, 'chrome'); + const identity = readRecordProperty(chromeApi, 'identity'); + if (!isChromeIdentityApi(identity)) { + throw new Error( + 'Chrome Identity API is unavailable. Rebuild the extension with WXT_GOOGLE_OAUTH_CLIENT_ID set, reload it, and confirm the manifest includes the identity permission.', + ); + } + return identity; +} + +function readRecordProperty( + value: unknown, + key: string, +): Record | null { + if (!isRecord(value)) return null; + const property = value[key]; + return isRecord(property) ? property : null; +} + +function isChromeIdentityApi(value: unknown): value is ChromeIdentityApi { + return ( + isRecord(value) && + typeof value.getAuthToken === 'function' && + typeof value.removeCachedAuthToken === 'function' + ); +} + +async function getSheetTitle(params: { + gid: string; + spreadsheetId: string; + token: string; +}): Promise { + const fields = encodeURIComponent('sheets(properties(sheetId,title))'); + const metadata = await requestSheetsJson({ + token: params.token, + url: `${SHEETS_API_BASE}/${encodeURIComponent(params.spreadsheetId)}?fields=${fields}`, + }); + const title = readSheetTitle({ gid: params.gid, metadata }); + if (!title) throw new Error('Could not find the active Google Sheets tab.'); + return title; +} + +async function batchUpdateValues(params: { + spreadsheetId: string; + token: string; + updates: SheetValueUpdate[]; +}): Promise { + await requestSheetsJson({ + token: params.token, + url: `${SHEETS_API_BASE}/${encodeURIComponent(params.spreadsheetId)}/values:batchUpdate`, + init: { + method: 'POST', + body: JSON.stringify({ + data: params.updates.map((update) => ({ + range: update.range, + values: update.values, + })), + valueInputOption: 'RAW', + }), + }, + }); +} + +async function batchUpdateFormatting(params: { + spreadsheetId: string; + token: string; + requests: SheetBatchUpdateRequest[]; +}): Promise { + if (params.requests.length === 0) return; + await requestSheetsJson({ + token: params.token, + url: `${SHEETS_API_BASE}/${encodeURIComponent(params.spreadsheetId)}:batchUpdate`, + init: { + method: 'POST', + body: JSON.stringify({ requests: params.requests }), + }, + }); +} + +async function readSheetColumnValues(params: { + column: string; + endRow: number | null; + sheetTitle: string; + spreadsheetId: string; + startRow: number; + token: string; +}): Promise { + const end = params.endRow ?? ''; + const range = `${quoteSheetTitle(params.sheetTitle)}!${params.column}${params.startRow}:${params.column}${end}`; + const response = await requestSheetsJson({ + token: params.token, + url: [ + `${SHEETS_API_BASE}/${encodeURIComponent(params.spreadsheetId)}/values/`, + encodeURIComponent(range), + '?majorDimension=ROWS&valueRenderOption=FORMATTED_VALUE', + ].join(''), + }); + return readValueRows(response); +} + +async function requestSheetsJson(params: { + token: string; + url: string; + init?: RequestInit; +}): Promise { + const response = await fetch(params.url, { + ...params.init, + headers: { + ...(params.init?.headers ?? {}), + Authorization: `Bearer ${params.token}`, + 'Content-Type': 'application/json', + }, + }); + if (response.status === 401) throw new UnauthorizedSheetsError(); + if (!response.ok) throw new Error(await readApiError(response)); + return response.json(); +} + +function readValueRows(value: unknown): string[] { + if (!isRecord(value) || !Array.isArray(value.values)) return []; + return value.values.map((row) => { + if (!Array.isArray(row)) return ''; + const first = row[0]; + if (typeof first === 'string') return first; + if (typeof first === 'number' || typeof first === 'boolean') return String(first); + return ''; + }); +} + +function getSpreadsheetId(queue: TabQuestionQueue): string { + if (queue.sheetMapping?.spreadsheetId) return queue.sheetMapping.spreadsheetId; + const identity = parseSheetIdentityFromUrl(queue.url); + if (!identity) throw new Error('Could not identify the active spreadsheet.'); + return identity.spreadsheetId; +} + +function readSheetTitle(params: { + gid: string; + metadata: unknown; +}): string | null { + if (!isRecord(params.metadata) || !Array.isArray(params.metadata.sheets)) { + return null; + } + + for (const sheet of params.metadata.sheets) { + if (!isRecord(sheet) || !isRecord(sheet.properties)) continue; + const sheetId = sheet.properties.sheetId; + const title = sheet.properties.title; + if (String(sheetId) === params.gid && typeof title === 'string') return title; + } + return null; +} + +async function readApiError(response: Response): Promise { + const text = await response.text(); + try { + const parsed: unknown = JSON.parse(text); + if ( + isRecord(parsed) && + isRecord(parsed.error) && + typeof parsed.error.message === 'string' + ) { + return parsed.error.message; + } + } catch { + return `Google Sheets API request failed with HTTP ${response.status}.`; + } + return `Google Sheets API request failed with HTTP ${response.status}.`; +} + +function columnName(column: number): string { + let value = column; + let name = ''; + while (value > 0) { + const remainder = (value - 1) % 26; + name = String.fromCharCode(65 + remainder) + name; + value = Math.floor((value - 1) / 26); + } + return name; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +class UnauthorizedSheetsError extends Error {} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-formatting.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-formatting.ts new file mode 100644 index 0000000000..5c00508e19 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-formatting.ts @@ -0,0 +1,156 @@ +const MIN_ANSWER_COLUMN_WIDTH_PX = 360; +const MAX_ANSWER_COLUMN_WIDTH_PX = 720; +const COLUMN_PADDING_PX = 48; +const PX_PER_CHARACTER = 7; + +interface SheetApiTarget { + fieldId: string; + answer: string; + gid: string; + row: number; + col: number; +} + +interface DimensionRange { + sheetId: number; + dimension: 'COLUMNS' | 'ROWS'; + startIndex: number; + endIndex: number; +} + +interface GridRange { + sheetId: number; + startRowIndex: number; + endRowIndex: number; + startColumnIndex: number; + endColumnIndex: number; +} + +export type SheetBatchUpdateRequest = { + autoResizeDimensions?: { dimensions: DimensionRange }; + repeatCell?: { + range: GridRange; + cell: { + userEnteredFormat: { + verticalAlignment: 'TOP'; + wrapStrategy: 'WRAP'; + }; + }; + fields: string; + }; + updateDimensionProperties?: { + range: DimensionRange; + properties: { pixelSize: number }; + fields: 'pixelSize'; + }; +}; + +export function buildSheetFormattingRequests(params: { + gid: string; + targets: SheetApiTarget[]; +}): SheetBatchUpdateRequest[] { + const sheetId = Number(params.gid); + if (!Number.isSafeInteger(sheetId)) return []; + + return [ + ...buildColumnWidthRequests({ sheetId, targets: params.targets }), + ...buildCellWrapRequests({ sheetId, targets: params.targets }), + ...buildRowAutoResizeRequests({ sheetId, targets: params.targets }), + ]; +} + +function buildColumnWidthRequests(params: { + sheetId: number; + targets: SheetApiTarget[]; +}): SheetBatchUpdateRequest[] { + return Array.from(groupTargetsByColumn(params.targets)).map(([col, targets]) => ({ + updateDimensionProperties: { + range: { + sheetId: params.sheetId, + dimension: 'COLUMNS', + startIndex: col - 1, + endIndex: col, + }, + properties: { pixelSize: calculateAnswerColumnWidth(targets) }, + fields: 'pixelSize', + }, + })); +} + +function buildCellWrapRequests(params: { + sheetId: number; + targets: SheetApiTarget[]; +}): SheetBatchUpdateRequest[] { + return Array.from(groupTargetsByColumn(params.targets)).flatMap(([col, targets]) => + compactIndexes(targets.map((target) => target.row - 1)).map((span) => ({ + repeatCell: { + range: { + sheetId: params.sheetId, + startRowIndex: span.start, + endRowIndex: span.end, + startColumnIndex: col - 1, + endColumnIndex: col, + }, + cell: { + userEnteredFormat: { + verticalAlignment: 'TOP', + wrapStrategy: 'WRAP', + }, + }, + fields: 'userEnteredFormat.verticalAlignment,userEnteredFormat.wrapStrategy', + }, + })), + ); +} + +function buildRowAutoResizeRequests(params: { + sheetId: number; + targets: SheetApiTarget[]; +}): SheetBatchUpdateRequest[] { + return compactIndexes(params.targets.map((target) => target.row - 1)).map((span) => ({ + autoResizeDimensions: { + dimensions: { + sheetId: params.sheetId, + dimension: 'ROWS', + startIndex: span.start, + endIndex: span.end, + }, + }, + })); +} + +function groupTargetsByColumn(targets: SheetApiTarget[]): Map { + const groups = new Map(); + for (const target of targets) { + groups.set(target.col, [...(groups.get(target.col) ?? []), target]); + } + return groups; +} + +function calculateAnswerColumnWidth(targets: SheetApiTarget[]): number { + const longestLine = Math.max( + 0, + ...targets.flatMap((target) => + target.answer.split(/\r?\n/).map((line) => line.trim().length), + ), + ); + const width = Math.ceil(longestLine * PX_PER_CHARACTER + COLUMN_PADDING_PX); + return Math.min( + MAX_ANSWER_COLUMN_WIDTH_PX, + Math.max(MIN_ANSWER_COLUMN_WIDTH_PX, width), + ); +} + +function compactIndexes(indexes: number[]): { start: number; end: number }[] { + const sorted = Array.from(new Set(indexes)).sort((first, second) => first - second); + const spans: { start: number; end: number }[] = []; + for (const index of sorted) { + const previous = spans.at(-1); + if (previous && previous.end === index) { + previous.end = index + 1; + continue; + } + spans.push({ start: index, end: index + 1 }); + } + return spans; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-target-resolution.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-target-resolution.test.ts new file mode 100644 index 0000000000..930036d4d0 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-target-resolution.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { + parseSheetTargets, + resolveSheetTargets, +} from './google-sheets-target-resolution'; +import type { QuestionQueueItem, TabQuestionQueue } from '../types'; + +describe('Google Sheets target resolution', () => { + it('retargets a stale row by matching the selected question text', async () => { + const queue = queueWithItems([ + item({ + id: 'sheet:7:2:3', + question: '2.1.1 Has the organization implemented a security program?', + tag: 'sheets:B2->C2', + }), + item({ + id: 'sheet:7:3:3', + question: '2.1.2 Has the organization determined the scope of the program?', + tag: 'sheets:B3->C3', + }), + ]); + const targets = parseSheetTargets([{ + fieldId: 'sheet:7:3:3', + answer: 'Scoped and documented.', + }]); + + const resolved = await resolveSheetTargets({ + queue, + targets, + readColumn: async () => [ + '2.1.1 Has the organization implemented a security program?', + '2.1.2 Has the organization determined the scope of the program?', + ], + }); + + expect(resolved).toEqual([{ + fieldId: 'sheet:7:3:3', + answer: 'Scoped and documented.', + gid: '7', + row: 4, + col: 3, + }]); + }); + + it('throws when a question cannot be uniquely verified', async () => { + const queue = queueWithItems([ + item({ + id: 'sheet:7:3:3', + question: 'Do you support SSO?', + tag: 'sheets:B3->C3', + }), + ]); + const targets = parseSheetTargets([{ fieldId: 'sheet:7:3:3', answer: 'Yes.' }]); + + await expect(resolveSheetTargets({ + queue, + targets, + readColumn: async () => ['Different question', 'Do you support SSO?', 'Do you support SSO?'], + })).rejects.toThrow('multiple times'); + }); +}); + +function queueWithItems(items: QuestionQueueItem[]): TabQuestionQueue { + return { + tabId: 1, + url: 'https://docs.google.com/spreadsheets/d/sheet_123/edit#gid=7', + host: 'docs.google.com', + surface: 'sheets', + sheetMapping: { + spreadsheetId: 'sheet_123', + gid: '7', + questionColumn: 'B', + answerColumn: 'C', + startRow: 3, + endRow: null, + source: 'manual', + confirmed: true, + updatedAt: 1, + }, + organizationId: 'org_1', + selectedItemId: null, + staleDraftCount: 0, + items, + updatedAt: 1, + }; +} + +function item(params: { + fieldId?: string; + id: string; + question: string; + tag: string; +}): QuestionQueueItem { + return { + id: params.id, + fieldId: params.fieldId ?? params.id, + question: params.question, + value: '', + isEmpty: true, + tag: params.tag, + status: 'generated', + answer: 'Answer', + confidence: 'high', + sources: [], + edited: false, + createdAt: 1, + updatedAt: 1, + }; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-target-resolution.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-target-resolution.ts new file mode 100644 index 0000000000..b275b562c3 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-target-resolution.ts @@ -0,0 +1,171 @@ +import { + columnIndexToName, + columnNameToIndex, +} from '../sheet-columns'; +import type { QuestionQueueItem, SheetMapping, TabQuestionQueue } from '../types'; + +export interface SheetAnswer { + fieldId: string; + answer: string; +} + +export interface SheetApiTarget extends SheetAnswer { + gid: string; + row: number; + col: number; +} + +interface TargetPlan { + answerColumn: number; + endRow: number | null; + question: string; + questionColumn: string; + startRow: number; + target: SheetApiTarget; +} + +export function parseSheetTargets(answers: SheetAnswer[]): SheetApiTarget[] { + return answers.flatMap((answer) => { + const match = answer.fieldId.match(/^sheet:([^:]+):(\d+):(\d+)$/); + if (!match) return []; + return [{ + ...answer, + gid: match[1], + row: Number(match[2]), + col: Number(match[3]), + }]; + }); +} + +export async function resolveSheetTargets(params: { + queue: TabQuestionQueue; + readColumn(request: { + column: string; + endRow: number | null; + startRow: number; + }): Promise; + targets: SheetApiTarget[]; +}): Promise { + const plans = params.targets.map((target) => buildTargetPlan({ + queue: params.queue, + target, + })); + const values = new Map(); + + for (const plan of plans) { + const key = columnRangeKey(plan); + if (!values.has(key)) { + values.set(key, await params.readColumn({ + column: plan.questionColumn, + endRow: plan.endRow, + startRow: plan.startRow, + })); + } + } + + return plans.map((plan) => + resolveTarget({ plan, values: values.get(columnRangeKey(plan)) ?? [] }), + ); +} + +function buildTargetPlan(params: { + queue: TabQuestionQueue; + target: SheetApiTarget; +}): TargetPlan { + const item = findQueueItem(params.queue, params.target.fieldId); + const tag = parseSheetTag(item.tag); + const mapping = getMatchingMapping({ + gid: params.target.gid, + mapping: params.queue.sheetMapping, + }); + const questionColumn = mapping?.questionColumn ?? tag?.questionColumn; + if (!questionColumn) throw new Error(`Cannot verify sheet row for "${item.question}".`); + + return { + answerColumn: getAnswerColumn({ mapping, tag, target: params.target }), + endRow: mapping?.endRow ?? null, + question: item.question, + questionColumn, + startRow: mapping?.startRow ?? Math.max(1, params.target.row - 25), + target: params.target, + }; +} + +function resolveTarget(params: { + plan: TargetPlan; + values: string[]; +}): SheetApiTarget { + const expected = normalizeQuestion(params.plan.question); + const currentIndex = params.plan.target.row - params.plan.startRow; + if (normalizeQuestion(params.values[currentIndex] ?? '') === expected) { + return retarget({ plan: params.plan, row: params.plan.target.row }); + } + + const matches = params.values.flatMap((value, index) => + normalizeQuestion(value) === expected ? [params.plan.startRow + index] : [], + ); + if (matches.length === 1) return retarget({ plan: params.plan, row: matches[0] }); + if (matches.length > 1) { + throw new Error(`Question appears multiple times in the sheet: "${params.plan.question}".`); + } + throw new Error(`Could not verify the target row for "${params.plan.question}".`); +} + +function retarget(params: { + plan: TargetPlan; + row: number; +}): SheetApiTarget { + return { + ...params.plan.target, + col: params.plan.answerColumn, + row: params.row, + }; +} + +function findQueueItem(queue: TabQuestionQueue, fieldId: string): QuestionQueueItem { + const item = queue.items.find((entry) => entry.fieldId === fieldId || entry.id === fieldId); + if (!item) throw new Error('Could not find the selected sheet question in the queue.'); + return item; +} + +function getMatchingMapping(params: { + gid: string; + mapping: SheetMapping | null; +}): SheetMapping | null { + if (!params.mapping || params.mapping.gid !== params.gid) return null; + return params.mapping; +} + +function getAnswerColumn(params: { + mapping: SheetMapping | null; + tag: ReturnType; + target: SheetApiTarget; +}): number { + const mapped = params.mapping + ? columnNameToIndex(params.mapping.answerColumn) + : null; + const tagged = params.tag + ? columnNameToIndex(params.tag.answerColumn) + : null; + return (mapped ?? tagged ?? params.target.col - 1) + 1; +} + +function parseSheetTag(tag: string): { + answerColumn: string; + questionColumn: string; +} | null { + const match = tag.match(/^sheets:([A-Z]+)\d+->([A-Z]+)\d+$/); + if (!match) return null; + const questionColumn = columnIndexToName(columnNameToIndex(match[1] ?? '') ?? -1); + const answerColumn = columnIndexToName(columnNameToIndex(match[2] ?? '') ?? -1); + if (!questionColumn || !answerColumn) return null; + return { answerColumn, questionColumn }; +} + +function columnRangeKey(plan: TargetPlan): string { + return `${plan.questionColumn}:${plan.startRow}:${plan.endRow ?? ''}`; +} + +function normalizeQuestion(value: string): string { + return value.replace(/\s+/g, ' ').trim().toLowerCase(); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/handlers.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/handlers.ts new file mode 100644 index 0000000000..e19e0aec06 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/handlers.ts @@ -0,0 +1,187 @@ +import { browser } from 'wxt/browser'; +import { + createEmptyQueue, + setQueueOrganization, + syncDetectedQuestions, +} from '../queue'; +import { detectSheetQuestionsWithDebug } from '../dom/sheets-debug'; +import { parseSheetIdentity } from '../sheet-mapping'; +import { + getSavedSheetMapping, + saveSheetMapping, +} from '../sheet-mapping-storage'; +import { + getDetectionEnabled, + getSelectedOrganizationId, + setConfirmedDomain, + setDetectionEnabled, +} from '../storage'; +import type { AuthState, TabQuestionQueue } from '../types'; +import type { BackgroundRequest, BackgroundResponse } from '../messaging'; +import { + getExtensionAuthState, + openSignIn, + switchActiveOrganization, +} from './auth'; +import { + generateLegacyAnswer, + handleQueueAction, + saveQueueAndNotify, +} from './queue-actions'; +import { + getQueueHost, + getQueueSurface, + shouldResetQueueForUrl, +} from './queue-scope'; +import { loadTabQueue, saveTabQueue } from './queue-store'; + +export async function handleBackgroundRequest(params: { + request: BackgroundRequest; + senderTabId: number | null; +}): Promise { + const { request } = params; + if (request.type === 'comp:get-auth-state') { + return { ok: true, state: await getExtensionAuthState() }; + } + if (request.type === 'comp:open-sign-in') { + await openSignIn(); + return { ok: true }; + } + if (request.type === 'comp:open-side-panel') { + await browser.sidePanel.open({ tabId: request.tabId }); + return { ok: true }; + } + if (request.type === 'comp:set-active-org') { + return switchOrg(request.organizationId, 'tabId' in request ? request.tabId : null); + } + if (request.type === 'comp:confirm-domain') { + await setConfirmedDomain(request); + return { ok: true }; + } + if (request.type === 'comp:set-detection-enabled') { + await setDetectionEnabled(request); + return { ok: true }; + } + if (request.type === 'comp:detect-sheet-questions') { + const identity = parseSheetIdentity({ + hash: request.hash, + pathname: request.pathname, + }); + const mapping = identity ? await getSavedSheetMapping(identity) : null; + const result = await detectSheetQuestionsWithDebug({ + location: { + hash: request.hash, + pathname: request.pathname, + }, + mapping, + }); + return { + ok: true, + questions: result.questions, + debug: result.debug, + mapping: result.mapping, + }; + } + if (request.type === 'comp:set-sheet-mapping') { + await saveSheetMapping(request.mapping); + return { ok: true }; + } + if (request.type === 'comp:sync-questions') { + return syncQuestions({ request, senderTabId: params.senderTabId }); + } + if (request.type === 'comp:get-panel-state') { + return getPanelState(request.tabId, request.url, request.host); + } + if (request.type === 'comp:generate-answer') { + return generateLegacyAnswer(request); + } + return handleQueueAction(request); +} + +async function switchOrg( + organizationId: string, + tabId: number | null, +): Promise { + await switchActiveOrganization(organizationId); + if (tabId === null) return { ok: true, staleDraftCount: 0 }; + + const queue = await loadTabQueue(tabId); + if (!queue) return { ok: true, staleDraftCount: 0 }; + + const updated = setQueueOrganization({ queue, organizationId }); + await saveQueueAndNotify(updated); + return { ok: true, staleDraftCount: updated.staleDraftCount }; +} + +async function syncQuestions(params: { + request: Extract; + senderTabId: number | null; +}): Promise { + const tabId = params.request.tabId ?? params.senderTabId; + if (typeof tabId !== 'number') { + throw new Error('Unable to identify the active tab.'); + } + + const organizationId = await getSelectedOrganizationId(); + const current = await loadTabQueue(tabId); + const queue = syncDetectedQuestions({ + queue: shouldResetQueueForUrl({ queue: current, url: params.request.url }) + ? null + : current, + tabId, + url: params.request.url, + host: params.request.host, + surface: params.request.surface, + organizationId, + questions: params.request.questions, + sheetMapping: params.request.sheetMapping, + }); + await saveQueueAndNotify(queue); + return { ok: true, count: queue.items.length, queue }; +} + +async function getPanelState( + tabId: number, + url?: string, + host?: string, +): Promise { + const auth = await getExtensionAuthState(); + const queue = await loadOrCreateQueue({ tabId, auth, url, host }); + const detectionEnabled = await getDetectionEnabled(queue.host); + return { ok: true, panelState: { auth, queue, detectionEnabled } }; +} + +async function loadOrCreateQueue(params: { + tabId: number; + auth: AuthState; + url?: string; + host?: string; +}): Promise { + const existing = await loadTabQueue(params.tabId); + const organizationId = params.auth.selectedOrganizationId; + if (existing) { + if (shouldResetQueueForUrl({ queue: existing, url: params.url })) { + const queue = createEmptyQueue({ + tabId: params.tabId, + url: params.url ?? '', + host: params.host ?? getQueueHost(params.url), + surface: getQueueSurface(params.url), + organizationId, + }); + await saveTabQueue(queue); + return queue; + } + const updated = setQueueOrganization({ queue: existing, organizationId }); + if (updated !== existing) await saveTabQueue(updated); + return updated; + } + const queue = createEmptyQueue({ + tabId: params.tabId, + url: params.url ?? '', + host: params.host ?? getQueueHost(params.url), + surface: getQueueSurface(params.url), + organizationId, + }); + await saveTabQueue(queue); + return queue; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/insert-answers.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/insert-answers.ts new file mode 100644 index 0000000000..e7d51d3f7f --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/insert-answers.ts @@ -0,0 +1,75 @@ +import { browser } from 'wxt/browser'; +import type { InsertAnswerRequest } from '../types'; + +interface InsertResponse { + ok: true; + insertedCount: number; + failedIds: string[]; +} + +export async function insertAnswersIntoTab(params: { + tabId: number; + answers: InsertAnswerRequest[]; +}): Promise { + const response = await sendInsertMessage(params).catch(async (error: unknown) => { + if (!shouldInjectContentScript(error)) throw error; + await injectContentScript(params.tabId); + return sendInsertMessage(params); + }); + + if (!isInsertResponse(response)) { + throw new Error(getInsertError(response)); + } + return params.answers + .filter((answer) => !response.failedIds.includes(answer.fieldId)) + .map((answer) => answer.fieldId); +} + +function sendInsertMessage(params: { + tabId: number; + answers: InsertAnswerRequest[]; +}): Promise { + return browser.tabs.sendMessage(params.tabId, { + type: 'comp:insert-answers', + answers: params.answers, + }); +} + +async function injectContentScript(tabId: number): Promise { + await browser.scripting.executeScript({ + target: { tabId }, + files: ['/content-scripts/content.js'], + injectImmediately: true, + }); +} + +function shouldInjectContentScript(error: unknown): boolean { + const message = error instanceof Error ? error.message.toLowerCase() : ''; + return ( + message.includes('receiving end') || + message.includes('could not establish connection') + ); +} + +function isInsertResponse(value: unknown): value is InsertResponse { + return ( + typeof value === 'object' && + value !== null && + 'ok' in value && + value.ok === true && + 'failedIds' in value && + Array.isArray(value.failedIds) + ); +} + +function getInsertError(value: unknown): string { + if ( + typeof value === 'object' && + value !== null && + 'error' in value && + typeof value.error === 'string' + ) { + return value.error; + } + return 'Unable to copy or insert answers on this page.'; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-actions.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-actions.ts new file mode 100644 index 0000000000..a049a7b6c7 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-actions.ts @@ -0,0 +1,249 @@ +import { browser } from 'wxt/browser'; +import { generateAnswer } from '../api'; +import { + applyGeneratedAnswer, + approveQueueItem, + editQueueItem, + getApprovedInsertRequests, + markQueueItemsInserted, + selectQueueItem, + setQueueOrganization, + updateQueueItemStatus, +} from '../queue'; +import { + approveGeneratedItems, + approveHighConfidenceItems, +} from '../queue-approval'; +import { isDomainConfirmed } from '../storage'; +import type { + AuthState, + DomainConfirmationRequest, + GeneratedAnswer, + TabQuestionQueue, +} from '../types'; +import type { BackgroundRequest, BackgroundResponse } from '../messaging'; +import { ensureActiveOrganization } from './auth'; +import { generateQueueItemsInBatches } from './batch-generation'; +import { insertAnswersIntoTab } from './insert-answers'; +import { loadTabQueue, saveTabQueue } from './queue-store'; +import { + insertSheetAnswersWithApi, + prepareSheetPaste, +} from './sheet-actions'; + +const UPDATE_MESSAGE = 'comp:queue-updated'; + +export async function handleQueueAction( + request: BackgroundRequest, +): Promise { + if (request.type === 'comp:generate-queue-item') { + return generateQueueItem(request.tabId, request.itemId); + } + if (request.type === 'comp:generate-all') return generateAll(request.tabId); + if (request.type === 'comp:approve-queue-item') { + return updateAndReturn(approveQueueItem({ + queue: await requireQueue(request.tabId), + itemId: request.itemId, + })); + } + if (request.type === 'comp:approve-high-confidence') { + return updateAndReturn(approveHighConfidenceItems(await requireQueue(request.tabId))); + } + if (request.type === 'comp:approve-all-generated') { + return updateAndReturn(approveGeneratedItems(await requireQueue(request.tabId))); + } + if (request.type === 'comp:edit-queue-item') { + return updateAndReturn(editQueueItem({ + queue: await requireQueue(request.tabId), + itemId: request.itemId, + answer: request.answer, + })); + } + if (request.type === 'comp:select-queue-item') { + return updateAndReturn(selectQueueItem({ + queue: await requireQueue(request.tabId), + itemId: request.itemId, + })); + } + if (request.type === 'comp:insert-approved') return insertApproved(request.tabId); + if (request.type === 'comp:insert-queue-item') { + return insertSingle(request.tabId, request.itemId); + } + if (request.type === 'comp:prepare-sheet-paste') { + return prepareSheetPaste(request.tabId, request.itemId); + } + if (request.type === 'comp:insert-sheet-api') { + return insertSheetAnswersWithApi(request.tabId, request.itemId); + } + if (request.type === 'comp:mark-sheet-paste-inserted') { + return updateAndReturn(markQueueItemsInserted({ + queue: await requireQueue(request.tabId), + itemIds: request.itemIds, + })); + } + throw new Error('Unsupported request.'); +} + +export async function generateLegacyAnswer( + request: Extract, +): Promise { + const auth = await ensureActiveOrganization(); + return { + ok: true, + answer: await generateAnswerForItem({ + auth, + question: request.question, + questionIndex: request.questionIndex, + totalQuestions: request.totalQuestions, + }), + }; +} + +export async function saveQueueAndNotify(queue: TabQuestionQueue): Promise { + await saveTabQueue(queue); + await browser.runtime + .sendMessage({ type: UPDATE_MESSAGE, tabId: queue.tabId }) + .catch(() => undefined); +} + +async function generateQueueItem( + tabId: number, + itemId: string, +): Promise { + const auth = await ensureActiveOrganization(); + const queue = setQueueOrganization({ + queue: await requireQueue(tabId), + organizationId: auth.selectedOrganizationId, + }); + const confirmation = await getDomainConfirmation({ auth, queue }); + if (confirmation) return { ok: false, confirmation }; + + const item = queue.items.find((entry) => entry.id === itemId); + if (!item) throw new Error('Question not found on this page.'); + + await saveQueueAndNotify(updateQueueItemStatus({ queue, itemId, status: 'generating' })); + const answer = await generateAnswerForItem({ + auth, + question: item.question, + questionIndex: queue.items.findIndex((entry) => entry.id === itemId), + totalQuestions: queue.items.length, + }); + const updated = applyGeneratedAnswer({ + queue: await requireQueue(tabId), + itemId, + answer, + }); + await saveQueueAndNotify(updated); + const updatedItem = updated.items.find((entry) => entry.id === itemId); + if (!updatedItem) throw new Error('Generated item was not found.'); + return { ok: true, item: updatedItem, queue: updated }; +} + +async function generateAll(tabId: number): Promise { + const auth = await ensureActiveOrganization(); + const queue = setQueueOrganization({ + queue: await requireQueue(tabId), + organizationId: auth.selectedOrganizationId, + }); + const confirmation = await getDomainConfirmation({ auth, queue }); + if (confirmation) return { ok: false, confirmation }; + + const updated = await generateQueueItemsInBatches({ + auth, + queue, + saveQueue: saveQueueAndNotify, + }); + return { ok: true, count: updated.items.length, queue: updated }; +} + +async function insertApproved(tabId: number): Promise { + const queue = await requireQueue(tabId); + const confirmation = await getInsertConfirmation(queue); + if (confirmation) return { ok: false, confirmation }; + + const requests = getApprovedInsertRequests(queue); + const insertedFieldIds = await insertAnswersIntoTab({ + tabId, + answers: requests.answers, + }); + const insertedItemIds = queue.items + .filter((item) => insertedFieldIds.includes(item.fieldId)) + .map((item) => item.id); + const updated = markQueueItemsInserted({ queue, itemIds: insertedItemIds }); + await saveQueueAndNotify(updated); + return { ok: true, count: insertedItemIds.length, queue: updated }; +} + +async function insertSingle( + tabId: number, + itemId: string, +): Promise { + const queue = await requireQueue(tabId); + const item = queue.items.find((entry) => entry.id === itemId); + if (!item?.answer) throw new Error('No answer is ready to insert.'); + const confirmation = await getInsertConfirmation(queue); + if (confirmation) return { ok: false, confirmation }; + + const insertedFieldIds = await insertAnswersIntoTab({ + tabId, + answers: [{ fieldId: item.fieldId, answer: item.answer }], + }); + const updated = markQueueItemsInserted({ + queue, + itemIds: insertedFieldIds.includes(item.fieldId) ? [item.id] : [], + }); + await saveQueueAndNotify(updated); + return { ok: true, count: insertedFieldIds.length, queue: updated }; +} + +async function requireQueue(tabId: number): Promise { + const queue = await loadTabQueue(tabId); + if (!queue) throw new Error('Scan the page before generating answers.'); + return queue; +} + +async function generateAnswerForItem(params: { + auth: { selectedOrganizationId: string }; + question: string; + questionIndex: number; + totalQuestions: number; +}): Promise { + return generateAnswer({ + organizationId: params.auth.selectedOrganizationId, + question: params.question, + questionIndex: params.questionIndex, + totalQuestions: params.totalQuestions, + }); +} + +async function getInsertConfirmation( + queue: TabQuestionQueue, +): Promise { + const auth = await ensureActiveOrganization(); + return getDomainConfirmation({ auth, queue }); +} + +async function getDomainConfirmation(params: { + auth: { selectedOrganizationId: string; organizations: AuthState['organizations'] }; + queue: TabQuestionQueue; +}): Promise { + const confirmed = await isDomainConfirmed({ + host: params.queue.host, + organizationId: params.auth.selectedOrganizationId, + }); + if (confirmed) return null; + + const org = params.auth.organizations.find( + (entry) => entry.id === params.auth.selectedOrganizationId, + ); + return { + host: params.queue.host, + organizationId: params.auth.selectedOrganizationId, + organizationName: org?.name ?? 'selected organization', + }; +} + +async function updateAndReturn(queue: TabQuestionQueue): Promise { + await saveQueueAndNotify(queue); + return { ok: true, queue }; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-scope.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-scope.test.ts new file mode 100644 index 0000000000..5c296dc3f1 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-scope.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { + getQueueHost, + getQueueScope, + getQueueSurface, + shouldResetQueueForUrl, +} from './queue-scope'; +import type { TabQuestionQueue } from '../types'; + +describe('queue scope', () => { + it('scopes Google Sheets queues by spreadsheet id and gid', () => { + expect(getQueueScope( + 'https://docs.google.com/spreadsheets/d/sheet_1/edit#gid=10&range=B2', + )).toBe('sheets:sheet_1:10'); + expect(getQueueScope( + 'https://docs.google.com/spreadsheets/d/sheet_1/edit#gid=20', + )).toBe('sheets:sheet_1:20'); + }); + + it('resets when the active tab moved to another spreadsheet', () => { + expect(shouldResetQueueForUrl({ + queue: queue('https://docs.google.com/spreadsheets/d/sheet_1/edit#gid=10'), + url: 'https://docs.google.com/spreadsheets/d/sheet_2/edit#gid=10', + })).toBe(true); + }); + + it('does not reset for range-only sheet hash changes', () => { + expect(shouldResetQueueForUrl({ + queue: queue('https://docs.google.com/spreadsheets/d/sheet_1/edit#gid=10&range=B2'), + url: 'https://docs.google.com/spreadsheets/d/sheet_1/edit#gid=10&range=C3', + })).toBe(false); + }); + + it('reads host and surface from the current url', () => { + expect(getQueueHost('https://docs.google.com/forms/d/form_1/edit')).toBe( + 'docs.google.com', + ); + expect(getQueueSurface('https://docs.google.com/forms/d/form_1/edit')).toBe('forms'); + }); +}); + +function queue(url: string): TabQuestionQueue { + return { + tabId: 1, + url, + host: 'docs.google.com', + surface: 'sheets', + sheetMapping: null, + organizationId: 'org_1', + selectedItemId: null, + staleDraftCount: 0, + items: [], + updatedAt: 1, + }; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-scope.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-scope.ts new file mode 100644 index 0000000000..fd129e066b --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-scope.ts @@ -0,0 +1,50 @@ +import { getPageSurface } from '../dom/page-surface'; +import { parseSheetIdentityFromUrl } from '../sheet-mapping'; +import type { TabQuestionQueue } from '../types'; + +export function shouldResetQueueForUrl(params: { + queue: TabQuestionQueue | null; + url?: string; +}): boolean { + if (!params.queue || !params.url) return false; + const existingScope = getQueueScope(params.queue.url); + const nextScope = getQueueScope(params.url); + return Boolean(existingScope && nextScope && existingScope !== nextScope); +} + +export function getQueueSurface(url?: string): TabQuestionQueue['surface'] { + if (!url) return 'generic'; + try { + return getPageSurface(new URL(url)); + } catch { + return 'generic'; + } +} + +export function getQueueHost(url?: string): string { + if (!url) return 'current page'; + try { + return new URL(url).host; + } catch { + return 'current page'; + } +} + +export function getQueueScope(url: string): string | null { + try { + const parsed = new URL(url); + const surface = getPageSurface(parsed); + if (surface === 'sheets') { + const identity = parseSheetIdentityFromUrl(url); + return identity + ? `sheets:${identity.spreadsheetId}:${identity.gid}` + : `sheets:${parsed.origin}${parsed.pathname}${parsed.hash}`; + } + if (surface === 'docs' || surface === 'forms') { + return `${surface}:${parsed.origin}${parsed.pathname}`; + } + return `${surface}:${parsed.origin}${parsed.pathname}${parsed.search}`; + } catch { + return null; + } +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-store.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-store.ts new file mode 100644 index 0000000000..7d0d9795ee --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-store.ts @@ -0,0 +1,75 @@ +import { z } from 'zod'; +import { browser } from 'wxt/browser'; +import type { TabQuestionQueue } from '../types'; + +const QUEUE_KEY_PREFIX = 'comp.securityQuestionnaire.queue.'; + +const QueueItemSchema = z.object({ + id: z.string(), + fieldId: z.string(), + question: z.string(), + value: z.string(), + isEmpty: z.boolean(), + tag: z.string(), + status: z.enum([ + 'pending', + 'generating', + 'generated', + 'approved', + 'inserted', + 'flagged', + ]), + answer: z.string().nullable(), + confidence: z.enum(['high', 'med', 'low']).nullable(), + sources: z.array(z.unknown()), + error: z.string().optional(), + edited: z.boolean(), + createdAt: z.number(), + updatedAt: z.number(), +}); + +const SheetMappingSchema = z.object({ + spreadsheetId: z.string(), + gid: z.string(), + questionColumn: z.string(), + answerColumn: z.string(), + startRow: z.number(), + endRow: z.number().nullable(), + source: z.enum(['auto', 'manual']), + confirmed: z.boolean(), + updatedAt: z.number(), +}); + +const TabQuestionQueueSchema = z.object({ + tabId: z.number(), + url: z.string(), + host: z.string(), + surface: z.enum(['generic', 'docs', 'sheets', 'forms']), + sheetMapping: SheetMappingSchema.nullable().default(null), + organizationId: z.string().nullable(), + selectedItemId: z.string().nullable(), + staleDraftCount: z.number(), + items: z.array(QueueItemSchema), + updatedAt: z.number(), +}); + +export async function loadTabQueue( + tabId: number, +): Promise { + const key = getQueueKey(tabId); + const result = await browser.storage.session.get(key); + const parsed = TabQuestionQueueSchema.safeParse(result[key]); + return parsed.success ? parsed.data : null; +} + +export async function saveTabQueue(queue: TabQuestionQueue): Promise { + await browser.storage.session.set({ [getQueueKey(queue.tabId)]: queue }); +} + +export async function clearTabQueue(tabId: number): Promise { + await browser.storage.session.remove(getQueueKey(tabId)); +} + +function getQueueKey(tabId: number): string { + return `${QUEUE_KEY_PREFIX}${tabId}`; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/sheet-actions.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/sheet-actions.ts new file mode 100644 index 0000000000..45b78c5a96 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/sheet-actions.ts @@ -0,0 +1,109 @@ +import { browser } from 'wxt/browser'; +import { + getApprovedInsertRequests, + markQueueItemsInserted, +} from '../queue'; +import { buildSheetPastePlan } from '../sheets-paste-plan'; +import { isDomainConfirmed } from '../storage'; +import type { + DomainConfirmationRequest, + TabQuestionQueue, +} from '../types'; +import type { BackgroundResponse } from '../messaging'; +import { ensureActiveOrganization } from './auth'; +import { insertAnswersWithGoogleSheetsApi } from './google-sheets-api'; +import { loadTabQueue, saveTabQueue } from './queue-store'; + +const UPDATE_MESSAGE = 'comp:queue-updated'; + +export async function prepareSheetPaste( + tabId: number, + itemId?: string, +): Promise { + const queue = await requireSheetQueue(tabId); + const confirmation = await getInsertConfirmation(queue); + if (confirmation) return { ok: false, confirmation }; + + const requests = getSheetInsertRequests({ queue, itemId }); + const plan = buildSheetPastePlan(requests.answers); + if (!plan) throw new Error('No mapped sheet answers are ready to paste.'); + + const itemIds = queue.items + .filter((item) => plan.targetIds.includes(item.fieldId)) + .map((item) => item.id); + return { ok: true, sheetPaste: { ...plan, itemIds } }; +} + +export async function insertSheetAnswersWithApi( + tabId: number, + itemId?: string, +): Promise { + const queue = await requireSheetQueue(tabId); + const confirmation = await getInsertConfirmation(queue); + if (confirmation) return { ok: false, confirmation }; + + const requests = getSheetInsertRequests({ queue, itemId }); + const insertedFieldIds = await insertAnswersWithGoogleSheetsApi({ + queue, + answers: requests.answers, + }); + const insertedItemIds = queue.items + .filter((item) => insertedFieldIds.includes(item.fieldId)) + .map((item) => item.id); + const updated = markQueueItemsInserted({ + queue, + itemIds: insertedItemIds, + }); + await saveQueueAndNotify(updated); + return { ok: true, count: insertedItemIds.length, queue: updated }; +} + +function getSheetInsertRequests(params: { + queue: TabQuestionQueue; + itemId?: string; +}): { + itemIds: string[]; + answers: { fieldId: string; answer: string }[]; +} { + if (!params.itemId) return getApprovedInsertRequests(params.queue); + const item = params.queue.items.find((entry) => entry.id === params.itemId); + if (!item?.answer) throw new Error('No answer is ready to insert.'); + return { + itemIds: [item.id], + answers: [{ fieldId: item.fieldId, answer: item.answer }], + }; +} + +async function requireSheetQueue(tabId: number): Promise { + const queue = await loadTabQueue(tabId); + if (!queue) throw new Error('Scan the page before inserting answers.'); + if (queue.surface !== 'sheets') throw new Error('Open a Google Sheet first.'); + return queue; +} + +async function getInsertConfirmation( + queue: TabQuestionQueue, +): Promise { + const auth = await ensureActiveOrganization(); + const confirmed = await isDomainConfirmed({ + host: queue.host, + organizationId: auth.selectedOrganizationId, + }); + if (confirmed) return null; + + const org = auth.organizations.find( + (entry) => entry.id === auth.selectedOrganizationId, + ); + return { + host: queue.host, + organizationId: auth.selectedOrganizationId, + organizationName: org?.name ?? 'selected organization', + }; +} + +async function saveQueueAndNotify(queue: TabQuestionQueue): Promise { + await saveTabQueue(queue); + await browser.runtime + .sendMessage({ type: UPDATE_MESSAGE, tabId: queue.tabId }) + .catch(() => undefined); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/brand.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/brand.ts new file mode 100644 index 0000000000..f83aad847b --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/brand.ts @@ -0,0 +1,7 @@ +export function compMarkSvg(): string { + return ` + + + + `; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/config.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/config.ts new file mode 100644 index 0000000000..a90fef4748 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/config.ts @@ -0,0 +1,13 @@ +function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, ''); +} + +export const extensionConfig = { + apiBaseUrl: trimTrailingSlash( + import.meta.env.WXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3333', + ), + appBaseUrl: trimTrailingSlash( + import.meta.env.WXT_PUBLIC_APP_BASE_URL ?? 'http://localhost:3000', + ), + googleSheetsApiEnabled: Boolean(import.meta.env.WXT_GOOGLE_OAUTH_CLIENT_ID), +}; diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/content-messaging.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/content-messaging.ts new file mode 100644 index 0000000000..4ba4f3ae7e --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/content-messaging.ts @@ -0,0 +1,55 @@ +import type { InsertAnswerRequest, ScanDebug } from './types'; +import { isRecord } from './message-utils'; + +export type ContentRequest = + | { type: 'comp:collect-questions' } + | { type: 'comp:ensure-inline-buttons' } + | { type: 'comp:scan-visible-questions' } + | { type: 'comp:generate-visible-page' } + | { type: 'comp:set-detection-enabled'; enabled: boolean } + | { type: 'comp:insert-answers'; answers: InsertAnswerRequest[] } + | { type: 'comp:focus-question'; fieldId: string }; + +export type ContentResponse = + | { ok: true; count: number; debug?: ScanDebug } + | { ok: true; started: true; count: number; debug?: ScanDebug } + | { ok: true; insertedCount: number; failedIds: string[] } + | { ok: false; error: string }; + +export function parseContentRequest(value: unknown): ContentRequest | null { + if (!isRecord(value) || typeof value.type !== 'string') return null; + if (value.type === 'comp:collect-questions') return { type: value.type }; + if (value.type === 'comp:ensure-inline-buttons') return { type: value.type }; + if (value.type === 'comp:scan-visible-questions') return { type: value.type }; + if (value.type === 'comp:generate-visible-page') return { type: value.type }; + if ( + value.type === 'comp:set-detection-enabled' && + typeof value.enabled === 'boolean' + ) { + return { type: value.type, enabled: value.enabled }; + } + if (value.type === 'comp:insert-answers' && Array.isArray(value.answers)) { + return { + type: value.type, + answers: value.answers.flatMap(parseInsertAnswer), + }; + } + if ( + value.type === 'comp:focus-question' && + typeof value.fieldId === 'string' + ) { + return { type: value.type, fieldId: value.fieldId }; + } + return null; +} + +function parseInsertAnswer(answer: unknown): InsertAnswerRequest[] { + if ( + isRecord(answer) && + typeof answer.fieldId === 'string' && + typeof answer.answer === 'string' + ) { + return [{ fieldId: answer.fieldId, answer: answer.answer }]; + } + return []; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/content-dialog.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/content-dialog.ts new file mode 100644 index 0000000000..6a20caa696 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/content-dialog.ts @@ -0,0 +1,147 @@ +import type { DomainConfirmationRequest } from '../types'; + +export function confirmDomainInContent( + confirmation: DomainConfirmationRequest, +): Promise { + return new Promise((resolve) => { + const host = document.createElement('div'); + host.dataset.compSqRoot = 'true'; + document.body.appendChild(host); + const shadow = host.attachShadow({ mode: 'open' }); + shadow.innerHTML = ` + + + + `; + + const close = (confirmed: boolean): void => { + host.remove(); + resolve(confirmed); + }; + + shadow.querySelector('[data-action="cancel"]')?.addEventListener('click', () => { + close(false); + }); + shadow.querySelector('[data-action="confirm"]')?.addEventListener('click', () => { + close(true); + }); + }); +} + +const dialogStyles = ` + :host { + color: #111827; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + } + * { + box-sizing: border-box; + } + .backdrop { + background: rgb(9 9 11 / 38%); + inset: 0; + position: fixed; + z-index: 2147483646; + } + .dialog { + background: #ffffff; + border: 1px solid #e4e4e7; + border-radius: 12px; + box-shadow: 0 12px 32px -8px rgb(16 24 40 / 12%); + box-sizing: border-box; + display: grid; + gap: 12px; + left: 50%; + padding: 18px; + position: fixed; + top: 50%; + transform: translate(-50%, -50%); + width: min(380px, calc(100vw - 32px)); + z-index: 2147483647; + color: #111827; + } + .eyebrow { + color: #4b5563; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + } + h2 { + color: #111827; + font-size: 17px; + line-height: 22px; + margin: 0; + } + p { + color: #374151; + font-size: 13px; + line-height: 18px; + margin: 0; + } + code { + background: #f4f4f5; + border-radius: 4px; + color: #111827; + padding: 1px 4px; + } + .org { + background: #ecfdf3; + border: 1px solid #bbf7d0; + border-radius: 6px; + color: #064e3b; + font-size: 13px; + font-weight: 700; + padding: 10px; + } + .actions { + display: flex; + gap: 8px; + justify-content: flex-end; + } + button { + border-radius: 6px; + color: #111827; + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 700; + min-height: 34px; + padding: 7px 12px; + } + .primary { + background: #111827; + border: 1px solid #111827; + color: #ffffff; + } + .primary:hover { + background: #030712; + } + .secondary { + background: #ffffff; + border: 1px solid #d4d4d8; + color: #374151; + } + .secondary:hover { + background: #f9fafb; + } +`; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/content-messaging.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/content-messaging.ts new file mode 100644 index 0000000000..892366f207 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/content-messaging.ts @@ -0,0 +1,33 @@ +import { isConfirmationResponse } from '../response-guards'; +import { confirmDomainInContent } from './content-dialog'; +import { sendRuntimeMessage } from './safe-runtime'; + +export async function sendWithDomainConfirmation(message: { + type: 'comp:generate-queue-item' | 'comp:generate-all' | 'comp:insert-queue-item'; + tabId: number; + itemId?: string; +}): Promise { + const response = await sendRuntimeMessage(message); + if (response === null) return extensionReloadedResponse(); + if (!isConfirmationResponse(response)) return response; + + const confirmed = await confirmDomainInContent(response.confirmation); + if (!confirmed) { + return { ok: false, error: 'Workspace confirmation cancelled.' }; + } + + await sendRuntimeMessage({ + type: 'comp:confirm-domain', + host: response.confirmation.host, + organizationId: response.confirmation.organizationId, + }); + const retry = await sendRuntimeMessage(message); + return retry ?? extensionReloadedResponse(); +} + +function extensionReloadedResponse(): { ok: false; error: string } { + return { + ok: false, + error: 'Extension was reloaded. Refresh this page and try again.', + }; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/content-styles.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/content-styles.ts new file mode 100644 index 0000000000..f2c80e9eb6 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/content-styles.ts @@ -0,0 +1,16 @@ +export const contentStyles = ` +[data-comp-sq-root="true"] { + all: initial; +} + +.comp-sq-flash { + animation: comp-sq-flash 900ms ease-out; + outline: 2px solid #00dc73; + outline-offset: 2px; +} + +@keyframes comp-sq-flash { + 0% { box-shadow: 0 0 0 0 rgb(0 220 115 / 38%); } + 100% { box-shadow: 0 0 0 12px rgb(0 220 115 / 0%); } +} +`; diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/csv.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/csv.test.ts new file mode 100644 index 0000000000..c840a6cfd3 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/csv.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { parseCsvRows } from './csv'; + +describe('CSV parsing', () => { + it('parses quoted commas and multiline cells', () => { + const rows = parseCsvRows([ + 'Question,Answer', + '"Do you encrypt data, including backups?","Yes"', + '"Do you review', + 'access quarterly?",', + ].join('\n')); + + expect(rows).toEqual([ + ['Question', 'Answer'], + ['Do you encrypt data, including backups?', 'Yes'], + ['Do you review\naccess quarterly?', ''], + ]); + }); + + it('can preserve empty rows for physical spreadsheet row numbers', () => { + expect(parseCsvRows('Question,Answer\n,\nDo you encrypt data?,', { + keepEmptyRows: true, + })).toEqual([ + ['Question', 'Answer'], + ['', ''], + ['Do you encrypt data?', ''], + ]); + }); +}); diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/csv.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/csv.ts new file mode 100644 index 0000000000..d625d324c7 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/csv.ts @@ -0,0 +1,59 @@ +export function parseCsvRows( + text: string, + options: { keepEmptyRows?: boolean } = {}, +): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let cell = ''; + let index = 0; + let quoted = false; + + while (index < text.length) { + const char = text[index] ?? ''; + const next = text[index + 1] ?? ''; + + if (quoted) { + if (char === '"' && next === '"') { + cell += '"'; + index += 2; + continue; + } + if (char === '"') { + quoted = false; + index += 1; + continue; + } + cell += char; + index += 1; + continue; + } + + if (char === '"') { + quoted = true; + index += 1; + continue; + } + if (char === ',') { + row.push(cell.trim()); + cell = ''; + index += 1; + continue; + } + if (char === '\n' || char === '\r') { + row.push(cell.trim()); + if (options.keepEmptyRows || row.some((value) => value.length > 0)) { + rows.push(row); + } + row = []; + cell = ''; + index += char === '\r' && next === '\n' ? 2 : 1; + continue; + } + cell += char; + index += 1; + } + + row.push(cell.trim()); + if (options.keepEmptyRows || row.some((value) => value.length > 0)) rows.push(row); + return rows; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-actions.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-actions.test.ts new file mode 100644 index 0000000000..e212bf1c1d --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-actions.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; +import { insertAnswerIntoField } from './field-actions'; + +describe('insertAnswerIntoField', () => { + it('sets textarea value and dispatches edit events', () => { + const textarea = document.createElement('textarea'); + const handleInput = vi.fn(); + const handleChange = vi.fn(); + textarea.addEventListener('input', handleInput); + textarea.addEventListener('change', handleChange); + + insertAnswerIntoField({ field: textarea, answer: 'Yes, data is encrypted.' }); + + expect(textarea.value).toBe('Yes, data is encrypted.'); + expect(handleInput).toHaveBeenCalledTimes(1); + expect(handleChange).toHaveBeenCalledTimes(1); + }); + + it('sets contenteditable text and dispatches edit events', () => { + const editable = document.createElement('div'); + editable.contentEditable = 'true'; + const handleInput = vi.fn(); + editable.addEventListener('input', handleInput); + + insertAnswerIntoField({ field: editable, answer: 'Documented annually.' }); + + expect(editable.textContent).toBe('Documented annually.'); + expect(handleInput).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-actions.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-actions.ts new file mode 100644 index 0000000000..0ad7753e18 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-actions.ts @@ -0,0 +1,41 @@ +import type { WritableField } from './field-detection'; + +export function insertAnswerIntoField(params: { + field: WritableField; + answer: string; +}): void { + if (params.field instanceof HTMLInputElement) { + setNativeValue(params.field, params.answer); + dispatchEditEvents(params.field); + return; + } + + if (params.field instanceof HTMLTextAreaElement) { + setNativeValue(params.field, params.answer); + dispatchEditEvents(params.field); + return; + } + + params.field.textContent = params.answer; + dispatchEditEvents(params.field); +} + +function setNativeValue( + element: HTMLInputElement | HTMLTextAreaElement, + value: string, +): void { + const prototype = Object.getPrototypeOf(element) as + | HTMLInputElement + | HTMLTextAreaElement; + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value'); + if (descriptor?.set) { + descriptor.set.call(element, value); + } else { + element.value = value; + } +} + +function dispatchEditEvents(element: HTMLElement): void { + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-detection.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-detection.test.ts new file mode 100644 index 0000000000..04efa26f19 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-detection.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { detectQuestionFields } from './field-detection'; + +describe('detectQuestionFields', () => { + it('extracts a question from an associated label', () => { + document.body.innerHTML = ` + + + `; + + const fields = detectQuestionFields(document, { visibleOnly: false }); + + expect(fields).toHaveLength(1); + expect(fields[0].question).toBe('Do you encrypt customer data at rest?'); + }); + + it('extracts a question from table-row context', () => { + document.body.innerHTML = ` + + + + + +
Describe your incident response process.
+ `; + + const fields = detectQuestionFields(document, { visibleOnly: false }); + + expect(fields).toHaveLength(1); + expect(fields[0].question).toBe('Describe your incident response process.'); + }); + + it('extracts a question from Google Forms list-item context', () => { + document.body.innerHTML = ` +
+
Describe your business continuity testing.
+ +
+ `; + + const fields = detectQuestionFields(document, { visibleOnly: false }); + + expect(fields).toHaveLength(1); + expect(fields[0].question).toBe('Describe your business continuity testing.'); + }); + + it('ignores generic Google Forms input labels', () => { + document.body.innerHTML = ` +
+
Do you require MFA for administrator access?
+ +
+ `; + + const fields = detectQuestionFields(document, { visibleOnly: false }); + + expect(fields).toHaveLength(1); + expect(fields[0].question).toBe('Do you require MFA for administrator access?'); + }); + + it('ignores password and hidden inputs', () => { + document.body.innerHTML = ` + + + `; + + const fields = detectQuestionFields(document, { visibleOnly: false }); + + expect(fields).toHaveLength(0); + }); + + it('ignores generic app fields that are not questionnaire prompts', () => { + document.body.innerHTML = ` + + + `; + + const fields = detectQuestionFields(document, { visibleOnly: false }); + + expect(fields).toHaveLength(0); + }); + + it('keeps security questionnaire prompts without question marks', () => { + document.body.innerHTML = ` + + + `; + + const fields = detectQuestionFields(document, { visibleOnly: false }); + + expect(fields).toHaveLength(1); + expect(fields[0].question).toBe('Information security policy and access control overview'); + }); + + it('ignores chat composer prompts', () => { + document.body.innerHTML = ` +
+
How can I help you today?
+
+
+ `; + + const fields = detectQuestionFields(document, { visibleOnly: false }); + + expect(fields).toHaveLength(0); + }); + + it('ignores search fields even when the placeholder is phrased as a question', () => { + document.body.innerHTML = ` +
+ +
+ `; + + const fields = detectQuestionFields(document, { visibleOnly: false }); + + expect(fields).toHaveLength(0); + }); +}); diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-detection.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-detection.ts new file mode 100644 index 0000000000..0787e731eb --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-detection.ts @@ -0,0 +1,277 @@ +import type { DetectedQuestion } from '../types'; + +export type WritableField = + | HTMLInputElement + | HTMLTextAreaElement + | HTMLElement; + +export interface FieldCandidate extends Omit { + element: WritableField; +} + +const FIELD_SELECTOR = [ + 'textarea', + 'input:not([type])', + 'input[type="text"]', + 'input[type="search"]', + 'input[type="email"]', + 'input[type="url"]', + 'input[type="tel"]', + '[contenteditable="true"]', + '[role="textbox"]', +].join(','); + +const SKIP_TYPES = new Set([ + 'button', + 'checkbox', + 'color', + 'file', + 'hidden', + 'image', + 'password', + 'radio', + 'range', + 'reset', + 'search', + 'submit', +]); + +export function detectQuestionFields( + root: ParentNode = document, + options: { visibleOnly?: boolean } = {}, +): FieldCandidate[] { + const visibleOnly = options.visibleOnly ?? true; + const elements = Array.from(root.querySelectorAll(FIELD_SELECTOR)); + + return elements.flatMap((element) => { + if (!isWritableField(element)) return []; + if (!isEligibleField(element, visibleOnly)) return []; + + const question = extractQuestionText(element); + if (!question) return []; + + return [ + { + element, + question, + value: getFieldValue(element), + isEmpty: getFieldValue(element).trim().length === 0, + tag: getFieldTag(element), + }, + ]; + }); +} + +export function getFieldValue(element: WritableField): string { + if (element instanceof HTMLInputElement) return element.value; + if (element instanceof HTMLTextAreaElement) return element.value; + return element.textContent ?? ''; +} + +function isWritableField(element: Element): element is WritableField { + return element instanceof HTMLElement; +} + +function isEligibleField(element: WritableField, visibleOnly: boolean): boolean { + if (element.closest('[data-comp-sq-root="true"]')) return false; + if (element instanceof HTMLInputElement) { + const type = element.type.toLowerCase(); + if (SKIP_TYPES.has(type)) return false; + if (element.disabled || element.readOnly) return false; + } + if (element instanceof HTMLTextAreaElement) { + if (element.disabled || element.readOnly) return false; + } + if (isSearchSurface(element)) return false; + if (isChatOrComposerSurface(element)) return false; + if (visibleOnly && !isVisible(element)) return false; + return true; +} + +function isVisible(element: HTMLElement): boolean { + const style = window.getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden') return false; + if (element.getClientRects().length === 0) return false; + return true; +} + +function extractQuestionText(element: WritableField): string | null { + const candidates = [ + getAriaLabelText(element), + getAssociatedLabelText(element), + getContainerText(element), + getPlaceholderText(element), + ]; + + for (const candidate of candidates) { + const normalized = normalizeText(candidate); + if (isGenericAnswerLabel(normalized)) continue; + if (isQuestionnairePrompt(normalized)) return normalized; + } + return null; +} + +function getAriaLabelText(element: HTMLElement): string { + const ariaLabel = element.getAttribute('aria-label'); + if (ariaLabel) return ariaLabel; + + const labelledBy = element.getAttribute('aria-labelledby'); + if (!labelledBy) return ''; + + return labelledBy + .split(/\s+/) + .map((id) => document.getElementById(id)?.textContent ?? '') + .join(' '); +} + +function getAssociatedLabelText(element: WritableField): string { + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + if (element.labels && element.labels.length > 0) { + return Array.from(element.labels) + .map((label) => label.textContent ?? '') + .join(' '); + } + } + + const wrappingLabel = element.closest('label'); + return wrappingLabel?.textContent ?? ''; +} + +function getPlaceholderText(element: WritableField): string { + if (element instanceof HTMLInputElement) return element.placeholder; + if (element instanceof HTMLTextAreaElement) return element.placeholder; + return ''; +} + +function getContainerText(element: WritableField): string { + const container = + element.closest('tr') ?? + element.closest('[role="listitem"]') ?? + element.closest('[data-item-id]') ?? + element.closest('[jsmodel][data-params]') ?? + element.closest('fieldset') ?? + element.closest('[role="group"]') ?? + element.closest('li') ?? + element.parentElement; + + if (!container) return ''; + return collectReadableText(container, element); +} + +function isSearchSurface(element: WritableField): boolean { + return Boolean(element.closest('[role="search"], form[role="search"]')); +} + +function isChatOrComposerSurface(element: WritableField): boolean { + const directHint = normalizeHint( + [ + getPlaceholderText(element), + element.getAttribute('aria-label'), + element.getAttribute('name'), + element.getAttribute('id'), + ].join(' '), + ); + if (isChatPromptText(directHint)) return true; + + const ancestorHint = getAncestorHintText(element); + if (isChatComposerHint(ancestorHint)) return true; + + const containerText = normalizeHint(getContainerText(element)); + return isChatPromptText(containerText); +} + +function getAncestorHintText(element: WritableField): string { + const parts: string[] = []; + let current: Element | null = element; + let depth = 0; + + while (current && depth < 5) { + parts.push( + current.getAttribute('aria-label') ?? '', + current.getAttribute('role') ?? '', + current.getAttribute('id') ?? '', + current.getAttribute('class') ?? '', + current.getAttribute('data-testid') ?? '', + current.getAttribute('data-test') ?? '', + current.getAttribute('data-qa') ?? '', + ); + current = current.parentElement; + depth += 1; + } + + return normalizeHint(parts.join(' ')); +} + +function normalizeHint(value: string): string { + return value + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/[^a-z0-9]+/gi, ' ') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); +} + +function isChatPromptText(value: string): boolean { + return /\b(how can i help|ask (claude|chatgpt|gemini|copilot|assistant|ai|anything)|message (claude|chatgpt|gemini|copilot|assistant|ai)|send a message|write a message|new message|reply to|chat with)\b/i + .test(value); +} + +function isChatComposerHint(value: string): boolean { + const hasAssistantHint = /\b(assistant|claude|chatgpt|gemini|copilot)\b/i.test(value); + const hasComposerHint = /\b(chat|composer|compose|conversation|message|prompt|reply)\b/i + .test(value); + const hasInputHint = /\b(box|editor|field|form|input|textarea|textbox)\b/i.test(value); + return (hasAssistantHint && hasComposerHint) || (hasComposerHint && hasInputHint); +} + +function collectReadableText(container: Element, field: WritableField): string { + const doc = container.ownerDocument; + const walker = doc.createTreeWalker(container, NodeFilter.SHOW_TEXT); + const parts: string[] = []; + let node = walker.nextNode(); + + while (node) { + const parent = node.parentElement; + if (parent && isReadableTextNode(parent, field)) { + parts.push(node.textContent ?? ''); + } + node = walker.nextNode(); + } + + return parts.join(' '); +} + +function isReadableTextNode(parent: Element, field: WritableField): boolean { + if (parent === field || parent.closest('[data-comp-sq-root="true"]')) { + return false; + } + return !parent.closest('button,input,textarea,select,script,style'); +} + +function getFieldTag(element: WritableField): string { + if (element instanceof HTMLInputElement) return `input:${element.type}`; + if (element instanceof HTMLTextAreaElement) return 'textarea'; + if (element.isContentEditable) return 'contenteditable'; + return element.tagName.toLowerCase(); +} + +function normalizeText(value: string | null | undefined): string { + return (value ?? '').replace(/\s+/g, ' ').trim().slice(0, 600); +} + +function isGenericAnswerLabel(value: string): boolean { + return /^(answer|your answer|short answer|long answer|paragraph)$/i.test(value); +} + +function isQuestionnairePrompt(value: string): boolean { + if (value.length < 6) return false; + if (value.includes('?')) return true; + if ( + /^(are|can|confirm|describe|detail|do|does|explain|have|has|how|identify|is|list|outline|provide|what|where|which|who|will)\b/i + .test(value) + ) { + return true; + } + return /\b(access|audit|availability|backup|business continuity|compliance|control|data|disaster recovery|encryption|incident|information security|mfa|password|policy|privacy|risk|soc 2|security|subprocessor|vendor)\b/i + .test(value); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/inline-button.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/inline-button.ts new file mode 100644 index 0000000000..9c65efae19 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/inline-button.ts @@ -0,0 +1,106 @@ +import { compMarkSvg } from '../brand'; + +export type InlineButtonState = 'idle' | 'busy' | 'inserted'; + +const clickHandlers = new WeakMap void>(); + +export function createInlineButtonHost(params: { + fieldId: string; + buttonAttribute: string; + onClick(): void; +}): HTMLElement { + const host = document.createElement('span'); + host.dataset.compSqRoot = 'true'; + host.setAttribute(params.buttonAttribute, params.fieldId); + host.attachShadow({ mode: 'open' }); + clickHandlers.set(host, params.onClick); + setInlineButtonState(host, 'idle'); + return host; +} + +export function setInlineButtonState( + host: HTMLElement | null, + state: InlineButtonState, +): void { + if (!host?.shadowRoot) return; + const label = + state === 'busy' + ? 'Drafting answer from Comp AI' + : state === 'inserted' + ? 'Answer inserted by Comp AI' + : 'Generate answer with Comp AI'; + host.shadowRoot.innerHTML = ` + + + `; + const handleClick = clickHandlers.get(host); + if (handleClick) { + host.shadowRoot.querySelector('button')?.addEventListener('click', handleClick); + } +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/inline-preview.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/inline-preview.ts new file mode 100644 index 0000000000..66453d6caf --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/inline-preview.ts @@ -0,0 +1,265 @@ +import type { QuestionQueueItem } from '../types'; + +interface InlinePreviewActions { + onInsert(answer: string): void; + onDismiss(): void; + onRegenerate(): void; +} + +export class InlinePreview { + private host: HTMLElement; + private shadow: ShadowRoot; + private draftAnswer = ''; + + constructor(private anchor: HTMLElement) { + this.host = document.createElement('div'); + this.host.dataset.compSqRoot = 'true'; + document.body.appendChild(this.host); + this.shadow = this.host.attachShadow({ mode: 'open' }); + } + + showLoading(question: string): void { + this.shadow.innerHTML = this.wrap(` +
+ Suggested answer + +
+
Q.${escapeHtml(question)}
+
Drafting from Comp AI knowledge base...
+ `); + this.position(); + this.bindDismiss(() => this.close()); + } + + showResult(item: QuestionQueueItem, actions: InlinePreviewActions): void { + this.draftAnswer = item.answer ?? ''; + if (item.status === 'flagged' || !item.answer) { + this.shadow.innerHTML = this.wrap(` +
+ No knowledge-base match + +
+
Q.${escapeHtml(item.question)}
+

Answer manually or add a source before inserting.

+
+ +
+ `); + this.position(); + this.bindDismiss(actions.onDismiss); + return; + } + + this.shadow.innerHTML = this.wrap(` +
+ Suggested answer + ${escapeHtml(item.confidence ?? 'med')} + +
+
Q.${escapeHtml(item.question)}
+ +
Sources · ${item.sources.length}
+
+ + + +
+ `); + this.position(); + this.bindDismiss(actions.onDismiss); + this.shadow.querySelector('textarea')?.addEventListener('input', (event) => { + const target = event.target; + if (target instanceof HTMLTextAreaElement) this.draftAnswer = target.value; + }); + this.shadow + .querySelector('[data-action="regenerate"]') + ?.addEventListener('click', actions.onRegenerate); + this.shadow.querySelector('[data-action="insert"]')?.addEventListener('click', () => { + actions.onInsert(this.draftAnswer); + }); + } + + close(): void { + this.host.remove(); + } + + private wrap(body: string): string { + return `
${body}
`; + } + + private bindDismiss(onDismiss: () => void): void { + this.shadow.querySelectorAll('[data-action="dismiss"]').forEach((button) => { + button.addEventListener('click', () => { + this.close(); + onDismiss(); + }); + }); + } + + private position(): void { + const rect = this.anchor.getBoundingClientRect(); + const width = 360; + const left = Math.min( + Math.max(12, rect.right - width), + Math.max(12, window.innerWidth - width - 12), + ); + const top = Math.min(rect.bottom + 8, window.innerHeight - 340); + const popover = this.shadow.querySelector('.popover'); + if (popover instanceof HTMLElement) { + popover.style.left = `${left}px`; + popover.style.top = `${Math.max(12, top)}px`; + } + } +} + +const previewStyles = ` + :host { + color: #111827; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + } + * { + box-sizing: border-box; + } + .popover { + background: #ffffff; + border: 1px solid #e4e4e7; + border-radius: 8px; + box-shadow: 0 12px 32px -8px rgb(16 24 40 / 12%); + box-sizing: border-box; + display: grid; + gap: 10px; + max-height: min(440px, calc(100vh - 24px)); + overflow: auto; + padding: 12px; + position: fixed; + width: 360px; + z-index: 2147483647; + color: #111827; + } + .head { + align-items: center; + display: flex; + gap: 8px; + } + strong { + color: #111827; + flex: 1; + font-size: 13px; + font-weight: 700; + } + .icon { + align-items: center; + background: #ffffff; + border: 1px solid #e4e4e7; + border-radius: 6px; + color: #111827; + cursor: pointer; + display: inline-flex; + font-size: 18px; + height: 26px; + justify-content: center; + line-height: 1; + width: 26px; + } + .icon:hover { + background: #f9fafb; + } + .question { + color: #374151; + font-size: 12px; + line-height: 17px; + } + .question span, + .sources { + color: #4b5563; + font-size: 10px; + font-weight: 700; + margin-right: 4px; + text-transform: uppercase; + } + textarea { + background: #ffffff; + border: 1px solid #e4e4e7; + border-radius: 6px; + box-sizing: border-box; + color: #111827; + font: inherit; + font-size: 13px; + line-height: 18px; + min-height: 112px; + padding: 9px; + resize: vertical; + width: 100%; + } + .loading, + .muted { + color: #374151; + font-size: 12px; + line-height: 17px; + } + .spin { + animation: comp-spin 800ms linear infinite; + border: 2px solid #e4e4e7; + border-top-color: #00dc73; + border-radius: 999px; + display: inline-block; + height: 12px; + margin-right: 6px; + vertical-align: -2px; + width: 12px; + } + .conf { + border-radius: 2px; + font-size: 10px; + font-weight: 800; + padding: 2px 5px; + text-transform: uppercase; + } + .conf.high { background: #d7f9e7; color: #009e54; } + .conf.med { background: #fdecd2; color: #c2740a; } + .conf.low { background: #fde2e1; color: #e0413b; } + .foot { + display: flex; + gap: 8px; + justify-content: flex-end; + } + button { + color: #111827; + font: inherit; + } + .primary, + .secondary { + border-radius: 6px; + cursor: pointer; + font-size: 12px; + font-weight: 700; + min-height: 32px; + padding: 6px 10px; + } + .primary { + background: #111827; + border: 1px solid #111827; + color: #ffffff; + } + .primary:hover { + background: #030712; + } + .secondary { + background: #ffffff; + border: 1px solid #d4d4d8; + color: #374151; + } + .secondary:hover { + background: #f9fafb; + } + @keyframes comp-spin { to { transform: rotate(360deg); } } +`; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/page-surface.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/page-surface.test.ts new file mode 100644 index 0000000000..b5de583abf --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/page-surface.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { + getPageSurface, + shouldSkipQuestionnaireInjection, +} from './page-surface'; + +describe('page surface detection', () => { + it('skips Comp app and API origins', () => { + expect( + shouldSkipQuestionnaireInjection(new URL('http://localhost:3000/auth')), + ).toBe(true); + expect( + shouldSkipQuestionnaireInjection(new URL('http://localhost:3333/v1/auth/me')), + ).toBe(true); + expect( + shouldSkipQuestionnaireInjection(new URL('https://app.staging.trycomp.ai/auth')), + ).toBe(true); + }); + + it('allows non-Comp questionnaire pages', () => { + expect( + shouldSkipQuestionnaireInjection(new URL('https://vendor.example/security')), + ).toBe(false); + }); + + it('skips dedicated AI assistant pages', () => { + expect(shouldSkipQuestionnaireInjection(new URL('https://claude.ai/new'))).toBe( + true, + ); + expect(shouldSkipQuestionnaireInjection(new URL('https://chatgpt.com/'))).toBe( + true, + ); + }); + + it('detects Google document surfaces', () => { + expect(getPageSurface(new URL('https://docs.google.com/document/d/123'))).toBe( + 'docs', + ); + expect( + getPageSurface(new URL('https://docs.google.com/spreadsheets/d/123')), + ).toBe('sheets'); + expect(getPageSurface(new URL('https://docs.google.com/forms/d/123'))).toBe( + 'forms', + ); + }); +}); diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/page-surface.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/page-surface.ts new file mode 100644 index 0000000000..1c9d2752d7 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/page-surface.ts @@ -0,0 +1,66 @@ +import { extensionConfig } from '../config'; +import type { QuestionnaireSurface } from '../types'; + +interface PageLocation { + host: string; + hostname: string; + pathname: string; +} + +export function getPageSurface(location: PageLocation): QuestionnaireSurface { + if ( + location.hostname === 'docs.google.com' && + location.pathname.startsWith('/document/') + ) { + return 'docs'; + } + if ( + location.hostname === 'docs.google.com' && + location.pathname.startsWith('/spreadsheets/') + ) { + return 'sheets'; + } + if ( + location.hostname === 'docs.google.com' && + location.pathname.startsWith('/forms/') + ) { + return 'forms'; + } + return 'generic'; +} + +export function shouldSkipQuestionnaireInjection(location: PageLocation): boolean { + const protectedHosts = new Set([ + getUrlHost(extensionConfig.appBaseUrl), + getUrlHost(extensionConfig.apiBaseUrl), + 'app.trycomp.ai', + 'api.trycomp.ai', + 'app.staging.trycomp.ai', + 'api.staging.trycomp.ai', + ]); + return protectedHosts.has(location.host) || isAssistantHost(location.hostname); +} + +function getUrlHost(url: string): string { + try { + return new URL(url).host; + } catch { + return ''; + } +} + +function isAssistantHost(hostname: string): boolean { + const assistantHosts = [ + 'chatgpt.com', + 'chat.openai.com', + 'claude.ai', + 'gemini.google.com', + 'copilot.microsoft.com', + 'perplexity.ai', + 'poe.com', + ]; + + return assistantHosts.some( + (host) => hostname === host || hostname.endsWith(`.${host}`), + ); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/review-panel.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/review-panel.ts new file mode 100644 index 0000000000..9688bb76bd --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/review-panel.ts @@ -0,0 +1,142 @@ +import type { BatchProgressItem, GeneratedAnswer } from '../types'; +import type { WritableField } from './field-detection'; +import { insertAnswerIntoField } from './field-actions'; + +export class ReviewPanel { + private root: HTMLElement; + + constructor() { + this.root = document.createElement('div'); + this.root.dataset.compSqRoot = 'true'; + document.body.appendChild(this.root); + } + + showLoading(question: string): void { + this.root.innerHTML = this.wrapPanel({ + title: 'Generating answer', + body: ` +
+ Question +
${escapeHtml(question)}
+
+
Searching Comp AI knowledge base...
+ `, + footer: this.closeButtonHtml(), + }); + this.bindClose(); + } + + showSingle(params: { + field: WritableField; + question: string; + result: GeneratedAnswer; + }): void { + const answer = params.result.answer; + this.root.innerHTML = this.wrapPanel({ + title: 'Review answer', + body: ` +
+ Question +
${escapeHtml(params.question)}
+
+
+ Generated answer +
${escapeHtml( + answer ?? params.result.error ?? 'No answer generated.', + )}
+
+ `, + footer: ` + ${this.closeButtonHtml('Cancel')} + + `, + }); + this.bindClose(); + const insertButton = this.root.querySelector('[data-comp-sq-insert="true"]'); + insertButton?.addEventListener('click', () => { + if (!answer) return; + if (params.field instanceof HTMLElement && !isOverwriteAllowed(params.field)) { + return; + } + insertAnswerIntoField({ field: params.field, answer }); + this.close(); + }); + } + + showBatch(items: BatchProgressItem[]): void { + this.root.innerHTML = this.wrapPanel({ + title: 'Visible questions', + body: ` +
+ ${items.map((item) => this.renderBatchItem(item)).join('')} +
+ `, + footer: this.closeButtonHtml('Done'), + }); + this.bindClose(); + } + + close(): void { + this.root.remove(); + } + + private wrapPanel(params: { + title: string; + body: string; + footer: string; + }): string { + return ` +
+
+
${escapeHtml(params.title)}
+ +
+
${params.body}
+ +
+ `; + } + + private renderBatchItem(item: BatchProgressItem): string { + const answer = item.answer + ? `
${escapeHtml(item.answer)}
` + : ''; + return ` +
+
${escapeHtml(item.question)}
+ ${answer} +
${escapeHtml(item.error ?? item.status)}
+
+ `; + } + + private closeButtonHtml(label = 'Close'): string { + return ``; + } + + private bindClose(): void { + this.root.querySelectorAll('[data-comp-sq-close="true"]').forEach((button) => { + button.addEventListener('click', () => this.close()); + }); + } +} + +function isOverwriteAllowed(field: HTMLElement): boolean { + const currentValue = + field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement + ? field.value + : field.textContent ?? ''; + if (currentValue.trim().length === 0) return true; + return window.confirm('This field already has text. Replace it with the generated answer?'); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/safe-runtime.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/safe-runtime.ts new file mode 100644 index 0000000000..de552b37f1 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/safe-runtime.ts @@ -0,0 +1,15 @@ +import { browser } from 'wxt/browser'; + +export async function sendRuntimeMessage(message: unknown): Promise { + try { + return await browser.runtime.sendMessage(message); + } catch (error) { + if (isInvalidatedContextError(error)) return null; + throw error; + } +} + +export function isInvalidatedContextError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return error.message.toLowerCase().includes('extension context invalidated'); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-debug.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-debug.ts new file mode 100644 index 0000000000..d32ca97abf --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-debug.ts @@ -0,0 +1,228 @@ +import { + alignSheetMappingToQuestions, + describeSheetMapping, + inferSheetMappingFromQuestions, + parseSheetIdentity, + type SheetIdentity, +} from '../sheet-mapping'; +import type { + DetectedQuestion, + ScanDebug, + ScanDebugStep, + SheetMapping, +} from '../types'; +import { + tableToQuestions, +} from './sheets-detection'; +import { + csvToTable, + parseGvizTable, + type GvizTable, +} from './sheets-table'; + +interface SheetLocation { + hash: string; + pathname: string; +} + +interface Endpoint { + name: string; + url: string; + parse(text: string): GvizTable | null; +} + +export async function detectSheetQuestionsWithDebug(params: { + location: SheetLocation; + mapping?: SheetMapping | null; +}): Promise<{ + questions: DetectedQuestion[]; + debug: ScanDebug; + mapping: SheetMapping | null; +}> { + const steps: ScanDebugStep[] = []; + const identity = parseSheetIdentity(params.location); + if (!identity) { + return buildResult({ + questions: [], + source: 'none', + steps: identityFailureFallbackSteps(steps), + mapping: null, + }); + } + + if (params.mapping) { + steps.push({ + name: 'sheet-mapping', + status: 'ok', + detail: `Using saved mapping: ${describeSheetMapping(params.mapping)}.`, + }); + } + + for (const endpoint of getEndpoints({ + identity, + preferCsv: true, + })) { + const fetched = await fetchEndpoint(endpoint); + steps.push(fetched.step); + if (!fetched.ok) continue; + + const table = endpoint.parse(fetched.text); + if (!table) { + steps.push({ name: `${endpoint.name}: parse`, status: 'fail', detail: 'No table parsed.' }); + continue; + } + + const questions = tableToQuestions({ + table, + gid: identity.gid, + mapping: params.mapping, + }); + const mapping = getResultMapping({ + identity, + mapping: params.mapping, + questions, + }); + steps.push({ + name: `${endpoint.name}: questions`, + status: questions.length > 0 ? 'ok' : 'fail', + detail: `${questions.length} questionnaire questions detected.`, + count: questions.length, + sample: questions[0]?.question, + }); + if (!params.mapping && mapping) { + steps.push({ + name: 'sheet-mapping', + status: 'ok', + detail: `Auto detected mapping: ${describeSheetMapping(mapping)}.`, + }); + } + if (questions.length > 0) { + return buildResult({ + questions, + source: endpoint.name, + steps, + mapping, + }); + } + } + + return buildResult({ + questions: [], + source: 'none', + steps: identityFailureFallbackSteps(steps), + mapping: params.mapping ?? null, + }); +} + +function identityFailureFallbackSteps( + steps: ScanDebugStep[], +): ScanDebugStep[] { + if (steps.length > 0) return steps; + return [{ + name: 'sheet-url', + status: 'fail', + detail: 'Could not parse spreadsheet id from the current URL.', + }]; +} + +function getEndpoints(params: { + identity: SheetIdentity; + preferCsv: boolean; +}): Endpoint[] { + const encodedId = encodeURIComponent(params.identity.spreadsheetId); + const encodedGid = encodeURIComponent(params.identity.gid); + const base = `https://docs.google.com/spreadsheets/d/${encodedId}`; + const jsonEndpoint: Endpoint = { + name: 'background gviz json', + url: `${base}/gviz/tq?tqx=out:json&gid=${encodedGid}`, + parse: parseGvizTable, + }; + const gvizCsvEndpoint: Endpoint = { + name: 'background gviz csv', + url: `${base}/gviz/tq?tqx=out:csv&gid=${encodedGid}`, + parse: csvToTable, + }; + const exportCsvEndpoint: Endpoint = { + name: 'background export csv', + url: `${base}/export?format=csv&id=${encodedId}&gid=${encodedGid}`, + parse: csvToTable, + }; + return params.preferCsv + ? [gvizCsvEndpoint, exportCsvEndpoint, jsonEndpoint] + : [jsonEndpoint, gvizCsvEndpoint, exportCsvEndpoint]; +} + +async function fetchEndpoint(endpoint: Endpoint): Promise<{ + ok: boolean; + text: string; + step: ScanDebugStep; +}> { + try { + const response = await fetch(endpoint.url, { credentials: 'include' }); + const text = await response.text(); + return { + ok: response.ok, + text, + step: { + name: endpoint.name, + status: response.ok ? 'ok' : 'fail', + detail: `HTTP ${response.status}; ${text.length} chars returned.`, + sample: sampleText(text), + }, + }; + } catch (error) { + return { + ok: false, + text: '', + step: { + name: endpoint.name, + status: 'fail', + detail: error instanceof Error ? error.message : 'Fetch failed.', + }, + }; + } +} + +function buildResult(params: { + questions: DetectedQuestion[]; + source: string; + steps: ScanDebugStep[]; + mapping: SheetMapping | null; +}): { + questions: DetectedQuestion[]; + debug: ScanDebug; + mapping: SheetMapping | null; +} { + return { + questions: params.questions, + debug: { + surface: 'sheets', + source: params.source, + questionCount: params.questions.length, + steps: params.steps, + updatedAt: Date.now(), + }, + mapping: params.mapping, + }; +} + +function getResultMapping(params: { + identity: SheetIdentity; + mapping?: SheetMapping | null; + questions: DetectedQuestion[]; +}): SheetMapping | null { + if (params.mapping) { + return alignSheetMappingToQuestions({ + mapping: params.mapping, + questions: params.questions, + }); + } + return inferSheetMappingFromQuestions({ + identity: params.identity, + questions: params.questions, + }); +} + +function sampleText(text: string): string { + return text.replace(/\s+/g, ' ').trim().slice(0, 160); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-detection.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-detection.test.ts new file mode 100644 index 0000000000..15eb3a5ce2 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-detection.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest'; +import { detectSheetQuestions, tableToQuestions } from './sheets-detection'; + +describe('Google Sheets detection', () => { + it('extracts questions from a Question column and targets Answer cells', () => { + const questions = tableToQuestions({ + gid: '0', + table: { + cols: [{ label: '#' }, { label: 'Question' }, { label: 'Answer' }], + rows: [ + { c: [{ v: 1 }, { v: 'Do you encrypt data at rest?' }, null] }, + { c: [{ v: 2 }, { v: 'Do you support SSO?' }, { v: 'Yes' }] }, + ], + }, + }); + + expect(questions).toEqual([ + { + id: 'sheet:0:2:3', + question: 'Do you encrypt data at rest?', + value: '', + isEmpty: true, + tag: 'sheets:B2->C2', + }, + { + id: 'sheet:0:3:3', + question: 'Do you support SSO?', + value: 'Yes', + isEmpty: false, + tag: 'sheets:B3->C3', + }, + ]); + }); + + it('extracts statement-style requirements from mapped headers', () => { + const questions = tableToQuestions({ + gid: '0', + table: { + cols: [{ label: '#' }, { label: 'Requirement' }, { label: 'Response' }], + rows: [ + { c: [{ v: 1 }, { v: 'MFA' }, null] }, + { c: [{ v: 2 }, { v: 'Encryption at rest' }, { v: 'Enabled' }] }, + ], + }, + }); + + expect(questions).toEqual([ + { + id: 'sheet:0:2:3', + question: 'MFA', + value: '', + isEmpty: true, + tag: 'sheets:B2->C2', + }, + { + id: 'sheet:0:3:3', + question: 'Encryption at rest', + value: 'Enabled', + isEmpty: false, + tag: 'sheets:B3->C3', + }, + ]); + }); + + it('falls back to gviz json with the active gid', async () => { + const response = [ + 'google.visualization.Query.setResponse(', + JSON.stringify({ + table: { + cols: [{ label: 'Question' }, { label: 'Answer' }], + rows: [{ c: [{ v: 'Do you review access quarterly?' }, null] }], + }, + }), + ');', + ].join(''); + + const questions = await detectSheetQuestions({ + location: { + hash: '#gid=123', + pathname: '/spreadsheets/d/sheet_abc/edit', + }, + fetcher: async (url) => { + if (url.includes('tqx=out:csv') || url.includes('/export?')) { + return { ok: true, text: async () => '{"status":"error"}' }; + } + return { ok: true, text: async () => response }; + }, + }); + + expect(questions[0]?.id).toBe('sheet:123:2:2'); + }); + + it('preserves physical row numbers when blank rows precede questions', async () => { + const questions = await detectSheetQuestions({ + location: { + hash: '#gid=321', + pathname: '/spreadsheets/d/sheet_blank_row/edit', + }, + fetcher: async () => ({ + ok: true, + text: async () => [ + 'Question,Answer', + ',', + 'Do you encrypt production data?,', + ].join('\n'), + }), + }); + + expect(questions[0]?.id).toBe('sheet:321:3:2'); + expect(questions[0]?.tag).toBe('sheets:A3->B3'); + }); + + it('falls back to csv export when gviz does not return a table', async () => { + const requestedUrls: string[] = []; + const questions = await detectSheetQuestions({ + location: { + hash: '#gid=456', + pathname: '/spreadsheets/d/sheet_xyz/edit', + }, + fetcher: async (url) => { + requestedUrls.push(url); + if (url.includes('/gviz/tq')) { + return { ok: true, text: async () => '{"status":"error"}' }; + } + return { + ok: true, + text: async () => [ + '#,Question,Answer', + '1,Do you encrypt production data?,', + '2,Do you support SAML SSO?,Yes', + ].join('\n'), + }; + }, + }); + + expect(requestedUrls).toHaveLength(2); + expect(requestedUrls[0]).toContain('tqx=out:csv'); + expect(requestedUrls[1]).toContain('/export?format=csv&id=sheet_xyz&gid=456'); + expect(questions.map((question) => question.tag)).toEqual([ + 'sheets:B2->C2', + 'sheets:B3->C3', + ]); + }); + + it('uses gviz csv before export csv', async () => { + const requestedUrls: string[] = []; + const questions = await detectSheetQuestions({ + location: { + hash: '#gid=789', + pathname: '/spreadsheets/d/sheet_csv/edit', + }, + fetcher: async (url) => { + requestedUrls.push(url); + if (url.includes('tqx=out:json')) { + return { ok: true, text: async () => '{"status":"error"}' }; + } + return { + ok: true, + text: async () => 'Question,Answer\nDo you monitor audit logs?,', + }; + }, + }); + + expect(requestedUrls).toHaveLength(1); + expect(requestedUrls[0]).toContain('tqx=out:csv'); + expect(questions[0]?.tag).toBe('sheets:A2->B2'); + }); + + it('falls back to json when csv endpoints have no detectable questions', async () => { + const requestedUrls: string[] = []; + const questions = await detectSheetQuestions({ + location: { + hash: '#gid=777', + pathname: '/spreadsheets/d/sheet_noisy/edit', + }, + fetcher: async (url) => { + requestedUrls.push(url); + if (url.includes('tqx=out:json')) { + return { + ok: true, + text: async () => `google.visualization.Query.setResponse(${JSON.stringify({ + table: { + cols: [{ label: 'Question' }, { label: 'Answer' }], + rows: [{ c: [{ v: 'Do you encrypt databases?' }, null] }], + }, + })});`, + }; + } + return { + ok: true, + text: async () => 'A\nx', + }; + }, + }); + + expect(requestedUrls).toHaveLength(3); + expect(questions[0]?.tag).toBe('sheets:A2->B2'); + }); + + it('treats first row as headers when gviz column labels are A/B/C', () => { + const questions = tableToQuestions({ + gid: '9', + table: { + cols: [{ label: 'A' }, { label: 'B' }, { label: 'C' }], + rows: [ + { c: [{ v: '#' }, { v: 'Question' }, { v: 'Answer' }] }, + { c: [{ v: 1 }, { v: 'Do you test backups?' }, null] }, + ], + }, + }); + + expect(questions[0]?.id).toBe('sheet:9:2:3'); + expect(questions[0]?.tag).toBe('sheets:B2->C2'); + }); + +}); diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-detection.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-detection.ts new file mode 100644 index 0000000000..23f3b00a05 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-detection.ts @@ -0,0 +1,97 @@ +import { parseSheetIdentity } from '../sheet-mapping'; +import type { DetectedQuestion, SheetMapping } from '../types'; +import { + csvToTable, + parseGvizTable, + type GvizTable, +} from './sheets-table'; +import { tableToQuestions } from './sheets-question-cells'; + +export { matrixToQuestions, tableToQuestions } from './sheets-question-cells'; + +interface SheetLocation { + hash: string; + pathname: string; +} + +interface SheetFetchResponse { + ok: boolean; + text(): Promise; +} + +type SheetFetch = ( + url: string, + init: { credentials: 'include' }, +) => Promise; + +interface Endpoint { + url: string; + parse(text: string): GvizTable | null; +} + +export async function detectSheetQuestions(params: { + location: SheetLocation; + fetcher?: SheetFetch; + mapping?: SheetMapping | null; +}): Promise { + const identity = parseSheetIdentity(params.location); + if (!identity) return []; + + const fetcher = params.fetcher ?? fetch; + for (const endpoint of getEndpoints({ + spreadsheetId: identity.spreadsheetId, + gid: identity.gid, + preferCsv: true, + })) { + const table = await fetchTable({ + fetcher, + url: endpoint.url, + parse: endpoint.parse, + }); + if (!table) continue; + + const questions = tableToQuestions({ + table, + gid: identity.gid, + mapping: params.mapping, + }); + if (questions.length > 0) return questions; + } + + return []; +} + +function getEndpoints(params: { + spreadsheetId: string; + gid: string; + preferCsv: boolean; +}): Endpoint[] { + const encodedId = encodeURIComponent(params.spreadsheetId); + const encodedGid = encodeURIComponent(params.gid); + const base = `https://docs.google.com/spreadsheets/d/${encodedId}`; + const jsonEndpoint: Endpoint = { + url: `${base}/gviz/tq?tqx=out:json&gid=${encodedGid}`, + parse: parseGvizTable, + }; + const gvizCsvEndpoint: Endpoint = { + url: `${base}/gviz/tq?tqx=out:csv&gid=${encodedGid}`, + parse: csvToTable, + }; + const exportCsvEndpoint: Endpoint = { + url: `${base}/export?format=csv&id=${encodedId}&gid=${encodedGid}`, + parse: csvToTable, + }; + return params.preferCsv + ? [gvizCsvEndpoint, exportCsvEndpoint, jsonEndpoint] + : [jsonEndpoint, gvizCsvEndpoint, exportCsvEndpoint]; +} + +async function fetchTable(params: { + fetcher: SheetFetch; + url: string; + parse: (text: string) => GvizTable | null; +}): Promise { + const response = await params.fetcher(params.url, { credentials: 'include' }); + if (!response.ok) return null; + return params.parse(await response.text()); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-dom.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-dom.test.ts new file mode 100644 index 0000000000..d391fab63b --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-dom.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { detectVisibleSheetQuestions } from './sheets-dom'; + +describe('visible Google Sheets grid detection', () => { + it('extracts questions from accessible grid cells', () => { + document.body.innerHTML = ` +
+
#
+
Question
+
Answer
+
Do you test backups?
+
+
+ `; + + const questions = detectVisibleSheetQuestions({ + root: document, + location: { hash: '#gid=42' }, + }); + + expect(questions).toEqual([ + { + id: 'sheet:42:3:3', + question: 'Do you test backups?', + value: '', + isEmpty: true, + tag: 'sheets:B3->C3', + }, + ]); + }); +}); diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-dom.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-dom.ts new file mode 100644 index 0000000000..cbf28bfa16 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-dom.ts @@ -0,0 +1,85 @@ +import type { DetectedQuestion } from '../types'; +import { matrixToQuestions } from './sheets-detection'; + +interface SheetDomLocation { + hash: string; +} + +interface SheetCell { + row: number; + col: number; + text: string; +} + +const CELL_SELECTOR = [ + '[role="gridcell"][aria-rowindex][aria-colindex]', + '[data-row][data-col]', + '[data-row-index][data-column-index]', +].join(','); + +export function detectVisibleSheetQuestions(params: { + root: ParentNode; + location: SheetDomLocation; +}): DetectedQuestion[] { + const cells = collectCells(params.root); + if (cells.length === 0) return []; + + const maxRow = Math.max(...cells.map((cell) => cell.row)); + const maxCol = Math.max(...cells.map((cell) => cell.col)); + const rows = Array.from({ length: maxRow }, () => + Array.from({ length: maxCol }, () => ''), + ); + + for (const cell of cells) { + rows[cell.row - 1][cell.col - 1] = cell.text; + } + + return matrixToQuestions({ rows, gid: parseGid(params.location.hash) }); +} + +function collectCells(root: ParentNode): SheetCell[] { + return Array.from(root.querySelectorAll(CELL_SELECTOR)).flatMap((element) => { + if (!(element instanceof HTMLElement)) return []; + const text = (element.textContent ?? '').replace(/\s+/g, ' ').trim(); + if (!text) return []; + + const row = readIndex({ + element, + oneBasedAttrs: ['aria-rowindex'], + zeroBasedAttrs: ['data-row', 'data-row-index'], + }); + const col = readIndex({ + element, + oneBasedAttrs: ['aria-colindex'], + zeroBasedAttrs: ['data-col', 'data-column-index'], + }); + return row && col ? [{ row, col, text }] : []; + }); +} + +function readIndex(params: { + element: HTMLElement; + oneBasedAttrs: string[]; + zeroBasedAttrs: string[]; +}): number | null { + for (const attr of params.oneBasedAttrs) { + const value = parsePositiveInteger(params.element.getAttribute(attr)); + if (value !== null) return value; + } + for (const attr of params.zeroBasedAttrs) { + const value = parsePositiveInteger(params.element.getAttribute(attr)); + if (value !== null) return value + 1; + } + return null; +} + +function parsePositiveInteger(value: string | null): number | null { + if (!value || !/^\d+$/.test(value)) return null; + const number = Number(value); + return number >= 0 ? number : null; +} + +function parseGid(hash: string): string { + const match = hash.match(/(?:^|[&#])gid=([^&]+)/); + return match?.[1] ? decodeURIComponent(match[1]) : '0'; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-insert.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-insert.test.ts new file mode 100644 index 0000000000..6a0d7245d5 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-insert.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { buildSheetPastePlan } from '../sheets-paste-plan'; + +describe('Google Sheets paste plan', () => { + it('builds a target range and TSV for contiguous answer cells', () => { + const plan = buildSheetPastePlan([ + { fieldId: 'sheet:123:2:3', answer: 'First answer' }, + { fieldId: 'sheet:123:3:3', answer: 'Second answer' }, + ]); + + expect(plan?.range).toBe('C2:C3'); + expect(plan?.tsv).toBe('First answer\nSecond answer'); + expect(plan?.targetIds).toEqual(['sheet:123:2:3', 'sheet:123:3:3']); + }); + + it('keeps blank rows when approved sheet answers are not contiguous', () => { + const plan = buildSheetPastePlan([ + { fieldId: 'sheet:123:2:3', answer: 'First answer' }, + { fieldId: 'sheet:123:4:3', answer: 'Third answer' }, + ]); + + expect(plan?.range).toBe('C2:C4'); + expect(plan?.tsv).toBe('First answer\n\nThird answer'); + }); +}); diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-insert.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-insert.ts new file mode 100644 index 0000000000..173cbc6392 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-insert.ts @@ -0,0 +1,103 @@ +import { buildSheetPastePlan } from '../sheets-paste-plan'; + +interface SheetAnswer { + fieldId: string; + answer: string; +} + +export async function prepareSheetPaste(params: { + answers: SheetAnswer[]; + root: Document; +}): Promise<{ insertedIds: string[]; failedIds: string[] }> { + const plan = buildSheetPastePlan(params.answers); + if (!plan) return { insertedIds: [], failedIds: params.answers.map((entry) => entry.fieldId) }; + + await copyText(plan.tsv, params.root); + const confirmed = await showPasteDialog({ + root: params.root, + count: plan.targetIds.length, + range: plan.range, + onCopyAgain: () => copyText(plan.tsv, params.root), + }); + + return confirmed + ? { insertedIds: plan.targetIds, failedIds: plan.failedIds } + : { insertedIds: [], failedIds: params.answers.map((entry) => entry.fieldId) }; +} + +async function copyText(text: string, root: Document): Promise { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text).catch(() => copyTextWithSelection({ + root, + text, + })); + return; + } + + await copyTextWithSelection({ root, text }); +} + +async function copyTextWithSelection(params: { + root: Document; + text: string; +}): Promise { + const textarea = params.root.createElement('textarea'); + textarea.value = params.text; + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + params.root.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + const copied = params.root.execCommand('copy'); + textarea.remove(); + if (!copied) throw new Error('Unable to copy answers to the clipboard.'); +} + +function showPasteDialog(params: { + root: Document; + count: number; + range: string; + onCopyAgain(): Promise; +}): Promise { + return new Promise((resolve) => { + const host = params.root.createElement('div'); + host.dataset.compSqRoot = 'true'; + params.root.body.appendChild(host); + const shadow = host.attachShadow({ mode: 'open' }); + shadow.innerHTML = ` + + + `; + + const close = (confirmed: boolean): void => { + host.remove(); + resolve(confirmed); + }; + shadow.querySelector('[data-action="copy"]')?.addEventListener('click', () => { + void params.onCopyAgain(); + }); + shadow.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false)); + shadow.querySelector('[data-action="confirm"]')?.addEventListener('click', () => close(true)); + }); +} + +const pasteDialogStyles = ` + :host { color: #09090b; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } + .dialog { background: #fff; border: 1px solid #e4e4e7; border-radius: 12px; box-shadow: 0 12px 32px -8px rgb(16 24 40 / 12%); box-sizing: border-box; display: grid; gap: 10px; padding: 16px; position: fixed; right: 24px; top: 80px; width: min(360px, calc(100vw - 48px)); z-index: 2147483647; } + h2 { font-size: 16px; line-height: 22px; margin: 0; } + p { color: #52525b; font-size: 13px; line-height: 18px; margin: 0; } + code { background: #f4f4f5; border-radius: 4px; padding: 1px 4px; } + .actions { display: flex; gap: 8px; justify-content: flex-end; } + button { border-radius: 6px; cursor: pointer; font: inherit; font-size: 12px; font-weight: 800; min-height: 32px; padding: 6px 10px; } + .primary { background: #09090b; border: 1px solid #09090b; color: #fff; } + .secondary { background: #fff; border: 1px solid #d4d4d8; color: #3f3f46; } +`; diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-mapping-detection.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-mapping-detection.test.ts new file mode 100644 index 0000000000..c7b212412e --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-mapping-detection.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import { detectSheetQuestions, tableToQuestions } from './sheets-detection'; +import type { SheetMapping } from '../types'; + +describe('Google Sheets mapped detection', () => { + it('rescans saved sheet columns when the stored start row skips contiguous rows', () => { + const questions = tableToQuestions({ + gid: '0', + mapping: { + questionColumn: 'B', + answerColumn: 'C', + startRow: 5, + endRow: null, + }, + table: { + cols: [{ label: '#' }, { label: 'Question' }, { label: 'Answer' }], + rows: [ + row(['#', 'Question', 'Answer']), + row(['', '', '']), + row(['1', 'Encryption at rest', '']), + row(['2', 'SAML SSO support', '']), + row(['3', 'Quarterly access reviews', '']), + ], + }, + }); + + expect(questions.map((question) => question.tag)).toEqual([ + 'sheets:B3->C3', + 'sheets:B4->C4', + 'sheets:B5->C5', + ]); + }); + + it('keeps the saved start row when earlier detected rows are separated', () => { + const questions = tableToQuestions({ + gid: '0', + mapping: { + questionColumn: 'B', + answerColumn: 'C', + startRow: 4, + endRow: null, + }, + table: { + cols: [{ label: '#' }, { label: 'Question' }, { label: 'Answer' }], + rows: [ + row(['#', 'Question', 'Answer']), + row(['', 'Onboarding help', '']), + row(['', '', '']), + row(['1', 'Encryption at rest', '']), + row(['2', 'Audit log monitoring', '']), + ], + }, + }); + + expect(questions.map((question) => question.tag)).toEqual([ + 'sheets:B4->C4', + 'sheets:B5->C5', + ]); + }); + + it('accepts short statement-style rows under a saved open-ended mapping', () => { + const questions = tableToQuestions({ + gid: '0', + mapping: { + questionColumn: 'B', + answerColumn: 'C', + startRow: 4, + endRow: null, + }, + table: { + cols: [{ label: '#' }, { label: 'Requirement' }, { label: 'Response' }], + rows: [ + row(['#', 'Requirement', 'Response']), + row(['', '', '']), + row(['1', 'MFA', '']), + row(['2', 'SCIM', '']), + ], + }, + }); + + expect(questions.map((question) => question.question)).toEqual(['MFA', 'SCIM']); + expect(questions.map((question) => question.tag)).toEqual([ + 'sheets:B3->C3', + 'sheets:B4->C4', + ]); + }); + + it('uses explicit finite sheet mapping for custom columns and rows', () => { + const questions = tableToQuestions({ + gid: '0', + mapping: { + questionColumn: 'D', + answerColumn: 'H', + startRow: 4, + endRow: 5, + }, + table: { + cols: Array.from({ length: 8 }, (_value, index) => ({ + label: String.fromCharCode(65 + index), + })), + rows: [ + row(['Section', '', '', 'Question', '', '', '', 'Answer']), + row(['', '', '', 'Ignored intro text', '', '', '', '']), + row(['', '', '', 'Ignored because start row is 4', '', '', '', '']), + row(['', '', '', 'Encryption databases', '', '', '', 'Yes']), + row(['', '', '', 'SCIM provisioning', '', '', '', '']), + ], + }, + }); + + expect(questions).toEqual([ + { + id: 'sheet:0:4:8', + question: 'Encryption databases', + value: 'Yes', + isEmpty: false, + tag: 'sheets:D4->H4', + }, + { + id: 'sheet:0:5:8', + question: 'SCIM provisioning', + value: '', + isEmpty: true, + tag: 'sheets:D5->H5', + }, + ]); + }); + + it('prefers csv endpoints when explicit mapping is present', async () => { + const requestedUrls: string[] = []; + const mapping: SheetMapping = { + spreadsheetId: 'sheet_csv', + gid: '789', + questionColumn: 'B', + answerColumn: 'C', + startRow: 2, + endRow: null, + source: 'manual', + confirmed: true, + updatedAt: 1, + }; + const questions = await detectSheetQuestions({ + location: { + hash: '#gid=789', + pathname: '/spreadsheets/d/sheet_csv/edit', + }, + mapping, + fetcher: async (url) => { + requestedUrls.push(url); + return { + ok: true, + text: async () => '#,Question,Answer\n1,Do you review access?,', + }; + }, + }); + + expect(requestedUrls[0]).toContain('tqx=out:csv'); + expect(questions[0]?.tag).toBe('sheets:B2->C2'); + }); +}); + +function row(values: string[]): { c: { v: string }[] } { + return { + c: values.map((value) => ({ v: value })), + }; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-question-cells.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-question-cells.ts new file mode 100644 index 0000000000..d664741c37 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-question-cells.ts @@ -0,0 +1,287 @@ +import { + columnIndexToName, + columnNameToIndex, +} from '../sheet-columns'; +import type { DetectedQuestion, SheetMapping } from '../types'; +import { + cellText, + matrixToTable, + readCells, + readColumns, + readRows, + type GvizRow, + type GvizTable, +} from './sheets-table'; + +type SheetMappingConfig = Pick< + SheetMapping, + 'questionColumn' | 'answerColumn' | 'startRow' | 'endRow' +>; + +export function tableToQuestions(params: { + table: GvizTable; + gid: string; + mapping?: SheetMappingConfig | null; +}): DetectedQuestion[] { + if (params.mapping) { + return tableToMappedQuestions({ + table: params.table, + gid: params.gid, + mapping: params.mapping, + }); + } + + const rows = readRows(params.table.rows); + const labels = readColumns(params.table.cols).map((column) => + cellText({ v: column.label }), + ); + const labelHeader = labels.filter(Boolean); + const firstRowHeader = readCells(rows[0]?.c).map(cellText); + const labelsAreHeaders = hasQuestionnaireHeaders(labelHeader); + const firstRowIsHeader = hasQuestionnaireHeaders(firstRowHeader); + const header = labelsAreHeaders ? labels : firstRowHeader; + const questionColumn = findQuestionColumn({ header, rows }); + if (questionColumn === null) return []; + + const answerColumn = findAnswerColumn({ header, questionColumn }); + const hasHeaderRow = !labelsAreHeaders && firstRowIsHeader; + const dataRows = hasHeaderRow ? rows.slice(1) : rows; + const firstDataRowNumber = labelsAreHeaders || hasHeaderRow ? 2 : 1; + + return dataRows.flatMap((row, index) => { + const cells = readCells(row.c); + const question = normalizeQuestionCell({ + value: cellText(cells[questionColumn]), + permissive: Boolean(labelsAreHeaders || firstRowIsHeader), + }); + if (!question) return []; + + return [buildQuestion({ + answer: cellText(cells[answerColumn]), + answerColumn, + gid: params.gid, + question, + questionColumn, + rowNumber: firstDataRowNumber + index, + })]; + }).slice(0, 100); +} + +export function matrixToQuestions(params: { + rows: string[][]; + gid: string; +}): DetectedQuestion[] { + return tableToQuestions({ + gid: params.gid, + table: matrixToTable(params.rows), + }); +} + +function tableToMappedQuestions(params: { + table: GvizTable; + gid: string; + mapping: SheetMappingConfig; +}): DetectedQuestion[] { + const questionColumn = columnNameToIndex(params.mapping.questionColumn); + const answerColumn = columnNameToIndex(params.mapping.answerColumn); + if (questionColumn === null || answerColumn === null) return []; + + const rows = readRows(params.table.rows); + const endRow = Math.min(params.mapping.endRow ?? rows.length, rows.length); + const boundedQuestions = buildMappedQuestions({ + rows, + gid: params.gid, + questionColumn, + answerColumn, + startRow: params.mapping.startRow, + endRow, + }); + if (params.mapping.endRow !== null) return boundedQuestions; + + const columnQuestions = buildMappedQuestions({ + rows, + gid: params.gid, + questionColumn, + answerColumn, + startRow: 1, + endRow, + }); + + return shouldUseColumnQuestions({ + boundedQuestions, + columnQuestions, + }) ? columnQuestions : boundedQuestions; +} + +function buildMappedQuestions(params: { + rows: GvizRow[]; + gid: string; + questionColumn: number; + answerColumn: number; + startRow: number; + endRow: number; +}): DetectedQuestion[] { + return params.rows.flatMap((row, index) => { + const rowNumber = index + 1; + if (rowNumber < params.startRow || rowNumber > params.endRow) return []; + + const cells = readCells(row.c); + const question = normalizeQuestionCell({ + value: cellText(cells[params.questionColumn]), + permissive: true, + }); + if (!question) return []; + + return [buildQuestion({ + answer: cellText(cells[params.answerColumn]), + answerColumn: params.answerColumn, + gid: params.gid, + question, + questionColumn: params.questionColumn, + rowNumber, + })]; + }).slice(0, 100); +} + +function shouldUseColumnQuestions(params: { + boundedQuestions: DetectedQuestion[]; + columnQuestions: DetectedQuestion[]; +}): boolean { + if (params.columnQuestions.length <= params.boundedQuestions.length) return false; + if (params.boundedQuestions.length === 0) return params.columnQuestions.length > 0; + + const boundedFirstRow = getQuestionRow(params.boundedQuestions[0]); + if (boundedFirstRow === null) return false; + + const columnRows = params.columnQuestions.flatMap((question) => { + const row = getQuestionRow(question); + return row === null ? [] : [row]; + }); + if (columnRows.length === 0) return false; + + const firstColumnRow = Math.min(...columnRows); + const rowSet = new Set(columnRows); + for (let row = firstColumnRow; row < boundedFirstRow; row += 1) { + if (!rowSet.has(row)) return false; + } + return firstColumnRow < boundedFirstRow; +} + +function findQuestionColumn(params: { + header: string[]; + rows: GvizRow[]; +}): number | null { + const headerIndex = params.header.findIndex(isQuestionHeader); + if (headerIndex >= 0) return headerIndex; + + let bestIndex = -1; + let bestScore = 0; + for (let index = 0; index < 12; index += 1) { + const score = params.rows.reduce((total, row) => { + const text = normalizeQuestionCell({ + value: cellText(readCells(row.c)[index]), + permissive: false, + }); + return total + scoreQuestionCell(text); + }, 0); + if (score > bestScore) { + bestScore = score; + bestIndex = index; + } + } + return bestIndex >= 0 && bestScore > 80 ? bestIndex : null; +} + +function findAnswerColumn(params: { + header: string[]; + questionColumn: number; +}): number { + const answerIndex = params.header.findIndex((value) => + /answer|response|reply|vendor response/i.test(value), + ); + return answerIndex >= 0 ? answerIndex : params.questionColumn + 1; +} + +function normalizeQuestionCell(params: { + value: string; + permissive: boolean; +}): string { + const text = params.value.replace(/\s+/g, ' ').trim(); + const minLength = params.permissive ? 3 : 8; + if (text.length < minLength || isQuestionHeader(text) || isMetadataValue(text)) return ''; + return text.slice(0, 700); +} + +function scoreQuestionCell(text: string): number { + if (!text) return 0; + const semanticBoost = /[?]/.test(text) || + /^\s*(?:\d+(?:\.\d+)*|[A-Z]{1,6}[- ]?\d+(?:\.\d+)*)\b/.test(text) || + /^\s*(?:do|does|did|is|are|has|have|can|will|should|must)\b/i.test(text) + ? 80 + : 0; + return Math.min(text.length, 160) + semanticBoost; +} + +function isQuestionHeader(value: string | undefined): boolean { + const text = value?.trim() ?? ''; + return Boolean( + text.length > 0 && + text.length <= 48 && + /question|requirement|control|prompt|description/i.test(text), + ); +} + +function hasQuestionnaireHeaders(values: string[]): boolean { + return values.some(isQuestionHeader) || values.some((value) => + /answer|response|reply|vendor response/i.test(value), + ); +} + +function isMetadataValue(value: string): boolean { + return Boolean( + /^[#\d.,:/\-\s]+$/.test(value) || + /^(?:yes|no|n\/a|na|none|owner|status|notes?)$/i.test(value), + ); +} + +function buildQuestion(params: { + answer: string; + answerColumn: number; + gid: string; + question: string; + questionColumn: number; + rowNumber: number; +}): DetectedQuestion { + return { + id: getSheetFieldId({ + gid: params.gid, + rowNumber: params.rowNumber, + answerColumn: params.answerColumn, + }), + question: params.question, + value: params.answer, + isEmpty: params.answer.trim().length === 0, + tag: [ + 'sheets:', + columnIndexToName(params.questionColumn), + params.rowNumber, + '->', + columnIndexToName(params.answerColumn), + params.rowNumber, + ].join(''), + }; +} + +function getQuestionRow(question: DetectedQuestion | undefined): number | null { + const match = question?.tag.match(/^sheets:[A-Z]+(\d+)->[A-Z]+(\d+)$/); + if (!match || match[1] !== match[2]) return null; + return Number(match[1]); +} + +function getSheetFieldId(params: { + gid: string; + rowNumber: number; + answerColumn: number; +}): string { + return `sheet:${params.gid}:${params.rowNumber}:${params.answerColumn + 1}`; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-runtime.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-runtime.ts new file mode 100644 index 0000000000..33e5885999 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-runtime.ts @@ -0,0 +1,44 @@ +import type { DetectedQuestion } from '../types'; +import { sendRuntimeMessage } from './safe-runtime'; +import { detectVisibleSheetQuestions } from './sheets-dom'; +import { detectSheetQuestions } from './sheets-detection'; + +export async function detectSheetQuestionsForPage(params: { + location: Location; + root: ParentNode; +}): Promise { + const response = await sendRuntimeMessage({ + type: 'comp:detect-sheet-questions', + pathname: params.location.pathname, + hash: params.location.hash, + }); + if (isSheetQuestionsResponse(response) && response.questions.length > 0) { + return response.questions; + } + + const pageQuestions = await detectSheetQuestions({ + location: params.location, + }).catch(() => []); + if (pageQuestions.length > 0) return pageQuestions; + + const visibleQuestions = detectVisibleSheetQuestions({ + root: params.root, + location: params.location, + }); + if (visibleQuestions.length > 0) return visibleQuestions; + + return isSheetQuestionsResponse(response) ? response.questions : []; +} + +function isSheetQuestionsResponse( + value: unknown, +): value is { ok: true; questions: DetectedQuestion[] } { + return ( + typeof value === 'object' && + value !== null && + 'ok' in value && + value.ok === true && + 'questions' in value && + Array.isArray(value.questions) + ); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-table.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-table.ts new file mode 100644 index 0000000000..305d3f2654 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-table.ts @@ -0,0 +1,78 @@ +import { columnIndexToName } from '../sheet-columns'; +import { parseCsvRows } from './csv'; + +export interface GvizCell { + v?: unknown; + f?: unknown; +} + +export interface GvizColumn { + label?: unknown; +} + +export interface GvizRow { + c?: unknown; +} + +export interface GvizTable { + cols?: unknown; + rows?: unknown; +} + +export function parseGvizTable(text: string): GvizTable | null { + const start = text.indexOf('{'); + const end = text.lastIndexOf('}'); + if (start < 0 || end <= start) return null; + + const parsed: unknown = JSON.parse(text.slice(start, end + 1)); + if (!isRecord(parsed) || !isRecord(parsed.table)) return null; + return parsed.table; +} + +export function csvToTable(text: string): GvizTable | null { + if (/^\s*= 2 ? table : null; +} + +export function matrixToTable(rows: string[][]): GvizTable { + const width = rows.reduce((max, row) => Math.max(max, row.length), 0); + return { + cols: Array.from({ length: width }, (_value, index) => ({ + label: columnIndexToName(index), + })), + rows: rows.map((row) => ({ + c: Array.from({ length: width }, (_value, index) => ({ + v: row[index] ?? '', + })), + })), + }; +} + +export function readColumns(value: unknown): GvizColumn[] { + if (!Array.isArray(value)) return []; + return value.filter(isRecord); +} + +export function readRows(value: unknown): GvizRow[] { + if (!Array.isArray(value)) return []; + return value.filter(isRecord); +} + +export function readCells(value: unknown): GvizCell[] { + if (!Array.isArray(value)) return []; + return value.map((cell) => (isRecord(cell) ? cell : {})); +} + +export function cellText(cell: GvizCell | undefined): string { + const value = cell?.f ?? cell?.v; + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return ''; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/message-utils.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/message-utils.ts new file mode 100644 index 0000000000..97d6be4db2 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/message-utils.ts @@ -0,0 +1,38 @@ +import type { DetectedQuestion, QuestionnaireSurface } from './types'; + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function parseDetectedQuestion(value: unknown): DetectedQuestion[] { + if ( + !isRecord(value) || + typeof value.id !== 'string' || + typeof value.question !== 'string' || + typeof value.value !== 'string' || + typeof value.isEmpty !== 'boolean' || + typeof value.tag !== 'string' + ) { + return []; + } + return [ + { + id: value.id, + question: value.question, + value: value.value, + isEmpty: value.isEmpty, + tag: value.tag, + }, + ]; +} + +export function isQuestionnaireSurface( + value: unknown, +): value is QuestionnaireSurface { + return ( + value === 'generic' || + value === 'docs' || + value === 'sheets' || + value === 'forms' + ); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/messaging.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/messaging.ts new file mode 100644 index 0000000000..4e939e88b0 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/messaging.ts @@ -0,0 +1,283 @@ +import type { + AuthState, + DetectedQuestion, + DomainConfirmationRequest, + GeneratedAnswer, + PanelState, + QuestionQueueItem, + QuestionnaireSurface, + ScanDebug, + SheetMapping, + TabQuestionQueue, +} from './types'; +import type { SheetPastePayload } from './sheets-paste-plan'; +import { + isQuestionnaireSurface, + isRecord, + parseDetectedQuestion, +} from './message-utils'; +import { parseSheetMapping } from './sheet-mapping'; +export { + parseContentRequest, + type ContentRequest, + type ContentResponse, +} from './content-messaging'; + +export type BackgroundRequest = + | { type: 'comp:get-auth-state' } + | { type: 'comp:get-panel-state'; tabId: number; url?: string; host?: string } + | { type: 'comp:open-sign-in' } + | { type: 'comp:open-side-panel'; tabId: number; windowId?: number } + | { type: 'comp:set-active-org'; organizationId: string } + | { type: 'comp:set-active-org'; organizationId: string; tabId: number } + | { type: 'comp:confirm-domain'; host: string; organizationId: string } + | { type: 'comp:set-detection-enabled'; host: string; enabled: boolean } + | { type: 'comp:detect-sheet-questions'; pathname: string; hash: string } + | { type: 'comp:set-sheet-mapping'; mapping: SheetMapping } + | { + type: 'comp:sync-questions'; + tabId?: number; + url: string; + host: string; + surface: QuestionnaireSurface; + questions: DetectedQuestion[]; + sheetMapping?: SheetMapping | null; + } + | { type: 'comp:generate-queue-item'; tabId: number; itemId: string } + | { type: 'comp:generate-all'; tabId: number } + | { type: 'comp:approve-queue-item'; tabId: number; itemId: string } + | { type: 'comp:approve-high-confidence'; tabId: number } + | { type: 'comp:approve-all-generated'; tabId: number } + | { + type: 'comp:edit-queue-item'; + tabId: number; + itemId: string; + answer: string; + } + | { type: 'comp:select-queue-item'; tabId: number; itemId: string } + | { type: 'comp:insert-approved'; tabId: number } + | { type: 'comp:insert-queue-item'; tabId: number; itemId: string } + | { type: 'comp:prepare-sheet-paste'; tabId: number; itemId?: string } + | { type: 'comp:insert-sheet-api'; tabId: number; itemId?: string } + | { + type: 'comp:mark-sheet-paste-inserted'; + tabId: number; + itemIds: string[]; + } + | { + type: 'comp:generate-answer'; + question: string; + questionIndex: number; + totalQuestions: number; + }; + +export type BackgroundResponse = + | { ok: true; state: AuthState } + | { ok: true; panelState: PanelState } + | { ok: true; answer: GeneratedAnswer } + | { + ok: true; + questions: DetectedQuestion[]; + debug?: ScanDebug; + mapping?: SheetMapping | null; + } + | { ok: true; queue: TabQuestionQueue } + | { ok: true; item: QuestionQueueItem; queue: TabQuestionQueue } + | { ok: true; sheetPaste: SheetPastePayload } + | { ok: true; count: number; queue?: TabQuestionQueue } + | { ok: true; staleDraftCount: number } + | { ok: true } + | { ok: false; confirmation: DomainConfirmationRequest } + | { ok: false; error: string }; + +export function parseBackgroundRequest( + value: unknown, +): BackgroundRequest | null { + if (!isRecord(value) || typeof value.type !== 'string') return null; + + if (value.type === 'comp:get-auth-state') return { type: value.type }; + if (value.type === 'comp:open-sign-in') return { type: value.type }; + + if ( + value.type === 'comp:get-panel-state' && + typeof value.tabId === 'number' + ) { + return { + type: value.type, + tabId: value.tabId, + url: typeof value.url === 'string' ? value.url : undefined, + host: typeof value.host === 'string' ? value.host : undefined, + }; + } + + if ( + value.type === 'comp:open-side-panel' && + typeof value.tabId === 'number' + ) { + return { + type: value.type, + tabId: value.tabId, + windowId: typeof value.windowId === 'number' ? value.windowId : undefined, + }; + } + + if ( + value.type === 'comp:set-active-org' && + typeof value.organizationId === 'string' + ) { + if (typeof value.tabId === 'number') { + return { + type: value.type, + organizationId: value.organizationId, + tabId: value.tabId, + }; + } + return { type: value.type, organizationId: value.organizationId }; + } + + if ( + value.type === 'comp:confirm-domain' && + typeof value.host === 'string' && + typeof value.organizationId === 'string' + ) { + return { + type: value.type, + host: value.host, + organizationId: value.organizationId, + }; + } + + if ( + value.type === 'comp:set-detection-enabled' && + typeof value.host === 'string' && + typeof value.enabled === 'boolean' + ) { + return { type: value.type, host: value.host, enabled: value.enabled }; + } + + if ( + value.type === 'comp:detect-sheet-questions' && + typeof value.pathname === 'string' && + typeof value.hash === 'string' + ) { + return { type: value.type, pathname: value.pathname, hash: value.hash }; + } + + if (value.type === 'comp:set-sheet-mapping') { + const mapping = parseSheetMapping(value.mapping); + return mapping ? { type: value.type, mapping } : null; + } + + if (value.type === 'comp:sync-questions') { + return parseSyncQuestionsRequest(value); + } + + const queueAction = parseQueueAction(value); + if (queueAction) return queueAction; + + if ( + value.type === 'comp:edit-queue-item' && + typeof value.tabId === 'number' && + typeof value.itemId === 'string' && + typeof value.answer === 'string' + ) { + return { + type: value.type, + tabId: value.tabId, + itemId: value.itemId, + answer: value.answer, + }; + } + + if ( + value.type === 'comp:mark-sheet-paste-inserted' && + typeof value.tabId === 'number' && + Array.isArray(value.itemIds) + ) { + return { + type: value.type, + tabId: value.tabId, + itemIds: value.itemIds.filter((itemId) => typeof itemId === 'string'), + }; + } + + if ( + value.type === 'comp:generate-answer' && + typeof value.question === 'string' && + typeof value.questionIndex === 'number' && + typeof value.totalQuestions === 'number' + ) { + return { + type: value.type, + question: value.question, + questionIndex: value.questionIndex, + totalQuestions: value.totalQuestions, + }; + } + + return null; +} + +function parseSyncQuestionsRequest( + value: Record, +): BackgroundRequest | null { + if ( + typeof value.url !== 'string' || + typeof value.host !== 'string' || + !isQuestionnaireSurface(value.surface) || + !Array.isArray(value.questions) + ) { + return null; + } + const sheetMapping = 'sheetMapping' in value + ? parseSheetMapping(value.sheetMapping) + : undefined; + + return { + type: 'comp:sync-questions', + tabId: typeof value.tabId === 'number' ? value.tabId : undefined, + url: value.url, + host: value.host, + surface: value.surface, + questions: value.questions.flatMap(parseDetectedQuestion), + ...(sheetMapping !== undefined ? { sheetMapping } : {}), + }; +} + +function parseQueueAction( + value: Record, +): BackgroundRequest | null { + if (typeof value.tabId !== 'number') return null; + + if ( + (value.type === 'comp:generate-queue-item' || + value.type === 'comp:approve-queue-item' || + value.type === 'comp:select-queue-item' || + value.type === 'comp:insert-queue-item') && + typeof value.itemId === 'string' + ) { + return { type: value.type, tabId: value.tabId, itemId: value.itemId }; + } + + if ( + value.type === 'comp:generate-all' || + value.type === 'comp:approve-high-confidence' || + value.type === 'comp:approve-all-generated' || + value.type === 'comp:insert-approved' + ) { + return { type: value.type, tabId: value.tabId }; + } + + if ( + value.type === 'comp:prepare-sheet-paste' || + value.type === 'comp:insert-sheet-api' + ) { + return { + type: value.type, + tabId: value.tabId, + itemId: typeof value.itemId === 'string' ? value.itemId : undefined, + }; + } + + return null; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/queue-approval.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/queue-approval.test.ts new file mode 100644 index 0000000000..4998728d09 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/queue-approval.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { + approveGeneratedItems, + approveHighConfidenceItems, +} from './queue-approval'; +import { + applyGeneratedAnswer, + syncDetectedQuestions, +} from './queue'; +import type { DetectedQuestion, TabQuestionQueue } from './types'; + +const firstQuestion: DetectedQuestion = { + id: 'field-1', + question: 'Do you encrypt customer data at rest?', + value: '', + isEmpty: true, + tag: 'textarea', +}; + +const secondQuestion: DetectedQuestion = { + id: 'field-2', + question: 'Do you support SSO?', + value: '', + isEmpty: true, + tag: 'textarea', +}; + +describe('queue approval reducers', () => { + it('approves all generated answers with text', () => { + const queue = withGeneratedAnswers(); + + const approved = approveGeneratedItems(queue); + + expect(approved.items.map((item) => item.status)).toEqual([ + 'approved', + 'approved', + ]); + }); + + it('keeps high-confidence approval scoped to high-confidence answers', () => { + const queue = withGeneratedAnswers(); + + const approved = approveHighConfidenceItems(queue); + + expect(approved.items.map((item) => item.status)).toEqual([ + 'approved', + 'generated', + ]); + }); +}); + +function withGeneratedAnswers(): TabQuestionQueue { + const queue = syncDetectedQuestions({ + queue: null, + tabId: 1, + url: 'https://vendor.example/security', + host: 'vendor.example', + surface: 'generic', + organizationId: 'org_a', + questions: [firstQuestion, secondQuestion], + }); + return applyGeneratedAnswer({ + queue: applyGeneratedAnswer({ + queue, + itemId: 'field-1', + answer: { + questionIndex: 0, + question: firstQuestion.question, + answer: 'Yes.', + sources: ['a', 'b'], + }, + }), + itemId: 'field-2', + answer: { + questionIndex: 1, + question: secondQuestion.question, + answer: 'Yes, SSO is supported.', + sources: ['a'], + }, + }); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/queue-approval.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/queue-approval.ts new file mode 100644 index 0000000000..7d01e99db0 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/queue-approval.ts @@ -0,0 +1,33 @@ +import type { QuestionQueueItem, TabQuestionQueue } from './types'; + +export function approveGeneratedItems(queue: TabQuestionQueue): TabQuestionQueue { + return approveMatchingGeneratedItems({ + queue, + matches: (item) => Boolean(item.answer), + }); +} + +export function approveHighConfidenceItems( + queue: TabQuestionQueue, +): TabQuestionQueue { + return approveMatchingGeneratedItems({ + queue, + matches: (item) => item.confidence === 'high' && Boolean(item.answer), + }); +} + +function approveMatchingGeneratedItems(params: { + queue: TabQuestionQueue; + matches(item: QuestionQueueItem): boolean; +}): TabQuestionQueue { + const now = Date.now(); + return { + ...params.queue, + items: params.queue.items.map((item) => + item.status === 'generated' && params.matches(item) + ? { ...item, status: 'approved', updatedAt: now } + : item, + ), + updatedAt: now, + }; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/queue.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/queue.test.ts new file mode 100644 index 0000000000..f19fd6f205 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/queue.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import { + applyGeneratedAnswer, + approveQueueItem, + editQueueItem, + markQueueItemsInserted, + setQueueOrganization, + syncDetectedQuestions, +} from './queue'; +import type { DetectedQuestion, TabQuestionQueue } from './types'; + +const firstQuestion: DetectedQuestion = { + id: 'field-1', + question: 'Do you encrypt customer data at rest?', + value: '', + isEmpty: true, + tag: 'textarea', +}; + +const secondQuestion: DetectedQuestion = { + id: 'field-2', + question: 'Do you support SSO?', + value: '', + isEmpty: true, + tag: 'textarea', +}; + +function queueWithQuestions(): TabQuestionQueue { + return syncDetectedQuestions({ + queue: null, + tabId: 1, + url: 'https://vendor.example/security', + host: 'vendor.example', + surface: 'generic', + organizationId: 'org_a', + questions: [firstQuestion, secondQuestion], + }); +} + +describe('queue reducer', () => { + it('preserves generated answers while syncing detected fields', () => { + const queue = applyGeneratedAnswer({ + queue: queueWithQuestions(), + itemId: 'field-1', + answer: { + questionIndex: 0, + question: firstQuestion.question, + answer: 'Yes, customer data is encrypted at rest.', + sources: ['source-a'], + }, + }); + + const synced = syncDetectedQuestions({ + queue, + tabId: 1, + url: 'https://vendor.example/security', + host: 'vendor.example', + surface: 'generic', + organizationId: 'org_a', + questions: [{ ...firstQuestion, value: 'draft' }, secondQuestion], + }); + + expect(synced.items[0].status).toBe('generated'); + expect(synced.items[0].answer).toBe('Yes, customer data is encrypted at rest.'); + expect(synced.items[0].value).toBe('draft'); + }); + + it('keeps inserted answers but clears uninserted drafts on org switch', () => { + const generated = applyGeneratedAnswer({ + queue: queueWithQuestions(), + itemId: 'field-1', + answer: { + questionIndex: 0, + question: firstQuestion.question, + answer: 'Yes.', + sources: ['source-a', 'source-b'], + }, + }); + const approved = approveQueueItem({ queue: generated, itemId: 'field-1' }); + const inserted = markQueueItemsInserted({ + queue: approved, + itemIds: ['field-1'], + }); + const withDraft = applyGeneratedAnswer({ + queue: inserted, + itemId: 'field-2', + answer: { + questionIndex: 1, + question: secondQuestion.question, + answer: 'Yes, SSO is supported.', + sources: ['source-c'], + }, + }); + + const switched = setQueueOrganization({ + queue: withDraft, + organizationId: 'org_b', + }); + + expect(switched.items[0].status).toBe('inserted'); + expect(switched.items[0].answer).toBe('Yes.'); + expect(switched.items[1].status).toBe('pending'); + expect(switched.items[1].answer).toBeNull(); + expect(switched.staleDraftCount).toBe(1); + }); + + it('keeps approved answers approved when edited', () => { + const generated = applyGeneratedAnswer({ + queue: queueWithQuestions(), + itemId: 'field-1', + answer: { + questionIndex: 0, + question: firstQuestion.question, + answer: 'Yes.', + sources: ['source-a'], + }, + }); + const approved = approveQueueItem({ queue: generated, itemId: 'field-1' }); + + const edited = editQueueItem({ + queue: approved, + itemId: 'field-1', + answer: 'Yes, data is encrypted at rest.', + }); + + expect(edited.items[0].status).toBe('approved'); + expect(edited.items[0].answer).toBe('Yes, data is encrypted at rest.'); + }); +}); diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/queue.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/queue.ts new file mode 100644 index 0000000000..816d1116ef --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/queue.ts @@ -0,0 +1,272 @@ +import type { + AnswerConfidence, + DetectedQuestion, + GeneratedAnswer, + QuestionQueueItem, + QuestionnaireSurface, + QueueStatus, + SheetMapping, + TabQuestionQueue, +} from './types'; + +const DRAFT_STATUSES = new Set([ + 'generating', + 'generated', + 'approved', + 'flagged', +]); + +export function createEmptyQueue(params: { + tabId: number; + url: string; + host: string; + surface: QuestionnaireSurface; + organizationId: string | null; +}): TabQuestionQueue { + return { + tabId: params.tabId, + url: params.url, + host: params.host, + surface: params.surface, + sheetMapping: null, + organizationId: params.organizationId, + selectedItemId: null, + staleDraftCount: 0, + items: [], + updatedAt: Date.now(), + }; +} + +export function syncDetectedQuestions(params: { + queue: TabQuestionQueue | null; + tabId: number; + url: string; + host: string; + surface: QuestionnaireSurface; + sheetMapping?: SheetMapping | null; + organizationId: string | null; + questions: DetectedQuestion[]; +}): TabQuestionQueue { + const now = Date.now(); + const existing = params.queue?.items ?? []; + const items = params.questions.map((question) => { + const current = existing.find((item) => item.fieldId === question.id); + if (current && current.question === question.question) { + return { + ...current, + value: question.value, + isEmpty: question.isEmpty, + tag: question.tag, + updatedAt: now, + }; + } + + return createPendingItem({ question, now }); + }); + + const selectedItemId = + items.find((item) => item.id === params.queue?.selectedItemId)?.id ?? + items[0]?.id ?? + null; + const sheetMapping = params.surface === 'sheets' + ? params.sheetMapping ?? params.queue?.sheetMapping ?? null + : null; + + return { + tabId: params.tabId, + url: params.url, + host: params.host, + surface: params.surface, + sheetMapping, + organizationId: params.organizationId, + selectedItemId, + staleDraftCount: params.queue?.staleDraftCount ?? 0, + items, + updatedAt: now, + }; +} + +export function setQueueOrganization(params: { + queue: TabQuestionQueue; + organizationId: string | null; +}): TabQuestionQueue { + if (params.queue.organizationId === params.organizationId) { + return { ...params.queue, updatedAt: Date.now() }; + } + + const now = Date.now(); + let staleDraftCount = 0; + const items = params.queue.items.map((item) => { + if (!DRAFT_STATUSES.has(item.status)) return item; + staleDraftCount += 1; + return resetDraftItem({ item, now }); + }); + + return { + ...params.queue, + organizationId: params.organizationId, + staleDraftCount, + items, + updatedAt: now, + }; +} + +export function updateQueueItemStatus(params: { + queue: TabQuestionQueue; + itemId: string; + status: QueueStatus; +}): TabQuestionQueue { + return updateItem(params.queue, params.itemId, (item, now) => ({ + ...item, + status: params.status, + updatedAt: now, + })); +} + +export function applyGeneratedAnswer(params: { + queue: TabQuestionQueue; + itemId: string; + answer: GeneratedAnswer; +}): TabQuestionQueue { + return updateItem(params.queue, params.itemId, (item, now) => { + const answer = params.answer.answer; + const hasAnswer = typeof answer === 'string' && answer.trim().length > 0; + return { + ...item, + status: hasAnswer ? 'generated' : 'flagged', + answer: hasAnswer ? answer : null, + confidence: hasAnswer ? getAnswerConfidence(params.answer) : 'low', + sources: params.answer.sources, + error: params.answer.error ?? undefined, + updatedAt: now, + }; + }); +} + +export function approveQueueItem(params: { + queue: TabQuestionQueue; + itemId: string; +}): TabQuestionQueue { + return updateItem(params.queue, params.itemId, (item, now) => { + if (item.status !== 'generated' || !item.answer) return item; + return { ...item, status: 'approved', updatedAt: now }; + }); +} + +export function editQueueItem(params: { + queue: TabQuestionQueue; + itemId: string; + answer: string; +}): TabQuestionQueue { + return updateItem(params.queue, params.itemId, (item, now) => ({ + ...item, + status: item.status === 'approved' ? 'approved' : 'generated', + answer: params.answer, + confidence: item.confidence ?? 'med', + edited: true, + error: undefined, + updatedAt: now, + })); +} + +export function markQueueItemsInserted(params: { + queue: TabQuestionQueue; + itemIds: string[]; +}): TabQuestionQueue { + const now = Date.now(); + const insertedIds = new Set(params.itemIds); + return { + ...params.queue, + items: params.queue.items.map((item) => + insertedIds.has(item.id) + ? { ...item, status: 'inserted', updatedAt: now } + : item, + ), + updatedAt: now, + }; +} + +export function selectQueueItem(params: { + queue: TabQuestionQueue; + itemId: string; +}): TabQuestionQueue { + return { + ...params.queue, + selectedItemId: params.itemId, + updatedAt: Date.now(), + }; +} + +export function getApprovedInsertRequests(queue: TabQuestionQueue): { + itemIds: string[]; + answers: { fieldId: string; answer: string }[]; +} { + const approved = queue.items.filter( + (item) => item.status === 'approved' && Boolean(item.answer), + ); + return { + itemIds: approved.map((item) => item.id), + answers: approved.flatMap((item) => + item.answer ? [{ fieldId: item.fieldId, answer: item.answer }] : [], + ), + }; +} + +function createPendingItem(params: { + question: DetectedQuestion; + now: number; +}): QuestionQueueItem { + return { + id: params.question.id, + fieldId: params.question.id, + question: params.question.question, + value: params.question.value, + isEmpty: params.question.isEmpty, + tag: params.question.tag, + status: 'pending', + answer: null, + confidence: null, + sources: [], + edited: false, + createdAt: params.now, + updatedAt: params.now, + }; +} + +function resetDraftItem(params: { + item: QuestionQueueItem; + now: number; +}): QuestionQueueItem { + return { + ...params.item, + status: 'pending', + answer: null, + confidence: null, + sources: [], + error: undefined, + edited: false, + updatedAt: params.now, + }; +} + +function updateItem( + queue: TabQuestionQueue, + itemId: string, + updater: (item: QuestionQueueItem, now: number) => QuestionQueueItem, +): TabQuestionQueue { + const now = Date.now(); + return { + ...queue, + items: queue.items.map((item) => + item.id === itemId ? updater(item, now) : item, + ), + selectedItemId: itemId, + updatedAt: now, + }; +} + +function getAnswerConfidence(answer: GeneratedAnswer): AnswerConfidence { + if (answer.sources.length >= 2) return 'high'; + if (answer.sources.length === 1) return 'med'; + return 'low'; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/response-guards.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/response-guards.ts new file mode 100644 index 0000000000..47349e4f09 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/response-guards.ts @@ -0,0 +1,63 @@ +import type { + DomainConfirmationRequest, + PanelState, + QuestionQueueItem, + TabQuestionQueue, +} from './types'; +import type { SheetPastePayload } from './sheets-paste-plan'; + +export function isPanelStateResponse( + value: unknown, +): value is { ok: true; panelState: PanelState } { + return isRecord(value) && value.ok === true && isRecord(value.panelState); +} + +export function isQueueResponse( + value: unknown, +): value is { ok: true; queue: TabQuestionQueue } { + return isRecord(value) && value.ok === true && isRecord(value.queue); +} + +export function isItemResponse( + value: unknown, +): value is { ok: true; item: QuestionQueueItem; queue: TabQuestionQueue } { + return isRecord(value) && value.ok === true && isRecord(value.item); +} + +export function isSheetPasteResponse( + value: unknown, +): value is { ok: true; sheetPaste: SheetPastePayload } { + return ( + isRecord(value) && + value.ok === true && + isRecord(value.sheetPaste) && + typeof value.sheetPaste.range === 'string' && + typeof value.sheetPaste.tsv === 'string' && + Array.isArray(value.sheetPaste.itemIds) + ); +} + +export function isCountResponse( + value: unknown, +): value is { ok: true; count: number; queue?: TabQuestionQueue } { + return isRecord(value) && value.ok === true && typeof value.count === 'number'; +} + +export function isConfirmationResponse( + value: unknown, +): value is { ok: false; confirmation: DomainConfirmationRequest } { + return isRecord(value) && value.ok === false && isRecord(value.confirmation); +} + +export function isOkResponse(value: unknown): value is { ok: true } { + return isRecord(value) && value.ok === true; +} + +export function getResponseError(value: unknown): string { + if (isRecord(value) && typeof value.error === 'string') return value.error; + return 'Unable to complete request.'; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/scan-debug.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/scan-debug.ts new file mode 100644 index 0000000000..ca7f60b24f --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/scan-debug.ts @@ -0,0 +1,66 @@ +import type { ScanDebug, ScanDebugStep } from './types'; +import { isRecord } from './message-utils'; + +export function getScanDebug(value: unknown): ScanDebug | null { + if (!isRecord(value) || !isRecord(value.debug)) return null; + const debug = value.debug; + if ( + debug.surface !== 'sheets' && + debug.surface !== 'docs' && + debug.surface !== 'forms' && + debug.surface !== 'generic' + ) { + return null; + } + if ( + typeof debug.source !== 'string' || + typeof debug.questionCount !== 'number' || + typeof debug.updatedAt !== 'number' || + !Array.isArray(debug.steps) + ) { + return null; + } + + return { + surface: debug.surface, + source: debug.source, + questionCount: debug.questionCount, + updatedAt: debug.updatedAt, + steps: debug.steps.flatMap(parseStep), + }; +} + +export function formatScanDebug(debug: ScanDebug): string { + const header = `Scan debug: ${debug.questionCount} found; source=${debug.source}.`; + const steps = debug.steps + .slice(0, 8) + .map((step) => { + const count = typeof step.count === 'number' ? ` count=${step.count}` : ''; + const sample = step.sample ? ` sample="${step.sample}"` : ''; + return `${step.status} ${step.name}: ${step.detail}${count}${sample}`; + }) + .join(' | '); + return steps ? `${header} ${steps}` : header; +} + +function parseStep(value: unknown): ScanDebugStep[] { + if ( + !isRecord(value) || + typeof value.name !== 'string' || + typeof value.detail !== 'string' || + !isStepStatus(value.status) + ) { + return []; + } + return [{ + name: value.name, + status: value.status, + detail: value.detail, + count: typeof value.count === 'number' ? value.count : undefined, + sample: typeof value.sample === 'string' ? value.sample : undefined, + }]; +} + +function isStepStatus(value: unknown): value is ScanDebugStep['status'] { + return value === 'ok' || value === 'fail' || value === 'skip'; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/sheet-columns.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/sheet-columns.ts new file mode 100644 index 0000000000..f90db95494 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/sheet-columns.ts @@ -0,0 +1,28 @@ +export function columnIndexToName(index: number): string { + if (!Number.isInteger(index) || index < 0) return ''; + + let value = index + 1; + let name = ''; + while (value > 0) { + const remainder = (value - 1) % 26; + name = String.fromCharCode(65 + remainder) + name; + value = Math.floor((value - 1) / 26); + } + return name; +} + +export function columnNameToIndex(value: string): number | null { + const normalized = normalizeColumnName(value); + if (!normalized) return null; + + let index = 0; + for (const character of normalized) { + index = index * 26 + character.charCodeAt(0) - 64; + } + return index - 1; +} + +export function normalizeColumnName(value: string): string | null { + const normalized = value.trim().toUpperCase(); + return /^[A-Z]{1,3}$/.test(normalized) ? normalized : null; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/sheet-mapping-storage.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/sheet-mapping-storage.ts new file mode 100644 index 0000000000..df65a22b61 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/sheet-mapping-storage.ts @@ -0,0 +1,32 @@ +import { browser } from 'wxt/browser'; +import { + parseSheetMapping, + type SheetIdentity, +} from './sheet-mapping'; +import type { SheetMapping } from './types'; + +const SHEET_MAPPING_PREFIX = 'comp.securityQuestionnaire.sheetMapping'; + +export async function getSavedSheetMapping( + identity: SheetIdentity, +): Promise { + const key = getSheetMappingKey(identity); + const result = await browser.storage.local.get(key); + return parseSheetMapping(result[key]); +} + +export async function saveSheetMapping( + mapping: SheetMapping, +): Promise { + await browser.storage.local.set({ + [getSheetMappingKey(mapping)]: mapping, + }); +} + +function getSheetMappingKey(identity: SheetIdentity): string { + return [ + SHEET_MAPPING_PREFIX, + identity.spreadsheetId, + identity.gid, + ].join(':'); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/sheet-mapping.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/sheet-mapping.test.ts new file mode 100644 index 0000000000..14f86b856a --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/sheet-mapping.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { + alignSheetMappingToQuestions, + inferSheetMappingFromQuestions, + parseSheetIdentity, + parseSheetMapping, +} from './sheet-mapping'; +import type { DetectedQuestion } from './types'; + +describe('sheet mapping helpers', () => { + it('parses a spreadsheet identity from a Google Sheets URL shape', () => { + expect(parseSheetIdentity({ + pathname: '/spreadsheets/d/sheet_123/edit', + hash: '#gid=456', + })).toEqual({ + spreadsheetId: 'sheet_123', + gid: '456', + }); + }); + + it('infers columns and row range from detected sheet tags', () => { + const questions: DetectedQuestion[] = [ + question({ tag: 'sheets:D12->H12' }), + question({ tag: 'sheets:D14->H14' }), + ]; + + expect(inferSheetMappingFromQuestions({ + identity: { spreadsheetId: 'sheet_abc', gid: '7' }, + questions, + })).toMatchObject({ + spreadsheetId: 'sheet_abc', + gid: '7', + questionColumn: 'D', + answerColumn: 'H', + startRow: 12, + endRow: 14, + source: 'auto', + confirmed: false, + }); + }); + + it('normalizes and validates stored mapping objects', () => { + expect(parseSheetMapping({ + spreadsheetId: 'sheet_abc', + gid: '0', + questionColumn: 'aa', + answerColumn: 'ab', + startRow: 5, + endRow: null, + source: 'manual', + confirmed: true, + updatedAt: 1, + })).toMatchObject({ + questionColumn: 'AA', + answerColumn: 'AB', + }); + + expect(parseSheetMapping({ + spreadsheetId: 'sheet_abc', + gid: '0', + questionColumn: 'B', + answerColumn: 'C', + startRow: 8, + endRow: 2, + source: 'manual', + confirmed: true, + updatedAt: 1, + })).toBeNull(); + }); + + it('aligns a saved mapping to the first detected question row', () => { + const aligned = alignSheetMappingToQuestions({ + mapping: { + spreadsheetId: 'sheet_abc', + gid: '0', + questionColumn: 'B', + answerColumn: 'C', + startRow: 2, + endRow: null, + source: 'manual', + confirmed: true, + updatedAt: 1, + }, + questions: [ + question({ tag: 'sheets:B3->C3' }), + question({ tag: 'sheets:B4->C4' }), + ], + }); + + expect(aligned).toMatchObject({ + questionColumn: 'B', + answerColumn: 'C', + startRow: 3, + endRow: null, + source: 'manual', + confirmed: true, + }); + }); +}); + +function question(params: { tag: string }): DetectedQuestion { + return { + id: params.tag, + question: 'Do you encrypt data at rest?', + value: '', + isEmpty: true, + tag: params.tag, + }; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/sheet-mapping.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/sheet-mapping.ts new file mode 100644 index 0000000000..2e66bd7a4e --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/sheet-mapping.ts @@ -0,0 +1,195 @@ +import { normalizeColumnName } from './sheet-columns'; +import type { DetectedQuestion, SheetMapping } from './types'; + +export interface SheetIdentity { + spreadsheetId: string; + gid: string; +} + +export interface SheetMappingDraft { + questionColumn: string; + answerColumn: string; + startRow: number; + endRow: number | null; +} + +export function parseSheetIdentity(params: { + pathname: string; + hash: string; +}): SheetIdentity | null { + const spreadsheetId = parseSpreadsheetId(params.pathname); + if (!spreadsheetId) return null; + return { spreadsheetId, gid: parseGid(params.hash) }; +} + +export function parseSheetIdentityFromUrl(url: string): SheetIdentity | null { + try { + const parsed = new URL(url); + return parseSheetIdentity({ + pathname: parsed.pathname, + hash: parsed.hash, + }); + } catch { + return null; + } +} + +export function createDefaultSheetMapping( + identity: SheetIdentity, +): SheetMapping { + return { + spreadsheetId: identity.spreadsheetId, + gid: identity.gid, + questionColumn: 'B', + answerColumn: 'C', + startRow: 2, + endRow: null, + source: 'manual', + confirmed: false, + updatedAt: Date.now(), + }; +} + +export function createManualSheetMapping(params: { + identity: SheetIdentity; + draft: SheetMappingDraft; +}): SheetMapping { + return { + spreadsheetId: params.identity.spreadsheetId, + gid: params.identity.gid, + questionColumn: params.draft.questionColumn, + answerColumn: params.draft.answerColumn, + startRow: params.draft.startRow, + endRow: params.draft.endRow, + source: 'manual', + confirmed: true, + updatedAt: Date.now(), + }; +} + +export function inferSheetMappingFromQuestions(params: { + identity: SheetIdentity; + questions: DetectedQuestion[]; +}): SheetMapping | null { + const targets = params.questions.flatMap(parseQuestionTag); + if (targets.length === 0) return null; + + const first = targets[0]; + const startRow = Math.min(...targets.map((target) => target.row)); + const endRow = Math.max(...targets.map((target) => target.row)); + return { + spreadsheetId: params.identity.spreadsheetId, + gid: params.identity.gid, + questionColumn: first.questionColumn, + answerColumn: first.answerColumn, + startRow, + endRow, + source: 'auto', + confirmed: false, + updatedAt: Date.now(), + }; +} + +export function alignSheetMappingToQuestions(params: { + mapping: SheetMapping; + questions: DetectedQuestion[]; +}): SheetMapping { + const targets = params.questions.flatMap(parseQuestionTag); + if (targets.length === 0) return params.mapping; + + return { + ...params.mapping, + startRow: Math.min(...targets.map((target) => target.row)), + endRow: params.mapping.endRow === null + ? null + : Math.max(...targets.map((target) => target.row)), + updatedAt: Date.now(), + }; +} + +export function parseSheetMapping(value: unknown): SheetMapping | null { + if (!isRecord(value)) return null; + const questionColumn = readColumn(value.questionColumn); + const answerColumn = readColumn(value.answerColumn); + if (!questionColumn || !answerColumn) return null; + if ( + typeof value.spreadsheetId !== 'string' || + typeof value.gid !== 'string' || + typeof value.startRow !== 'number' || + !Number.isInteger(value.startRow) || + value.startRow < 1 || + !isEndRow(value.endRow, value.startRow) || + !isSource(value.source) || + typeof value.confirmed !== 'boolean' || + typeof value.updatedAt !== 'number' + ) { + return null; + } + + return { + spreadsheetId: value.spreadsheetId, + gid: value.gid, + questionColumn, + answerColumn, + startRow: value.startRow, + endRow: value.endRow, + source: value.source, + confirmed: value.confirmed, + updatedAt: value.updatedAt, + }; +} + +export function describeSheetMapping(mapping: SheetMapping): string { + const rows = mapping.endRow + ? `${mapping.startRow}-${mapping.endRow}` + : `${mapping.startRow}+`; + return `questions ${mapping.questionColumn}, answers ${mapping.answerColumn}, rows ${rows}`; +} + +function parseQuestionTag(question: DetectedQuestion): { + questionColumn: string; + answerColumn: string; + row: number; +}[] { + const match = question.tag.match(/^sheets:([A-Z]+)(\d+)->([A-Z]+)(\d+)$/); + if (!match || match[2] !== match[4]) return []; + const questionColumn = normalizeColumnName(match[1] ?? ''); + const answerColumn = normalizeColumnName(match[3] ?? ''); + if (!questionColumn || !answerColumn) return []; + return [{ + questionColumn, + answerColumn, + row: Number(match[2]), + }]; +} + +function parseSpreadsheetId(pathname: string): string | null { + const match = pathname.match(/\/spreadsheets\/d\/([^/]+)/); + return match?.[1] ?? null; +} + +function parseGid(hash: string): string { + const match = hash.match(/(?:^|[&#])gid=([^&]+)/); + return match?.[1] ? decodeURIComponent(match[1]) : '0'; +} + +function readColumn(value: unknown): string | null { + return typeof value === 'string' ? normalizeColumnName(value) : null; +} + +function isEndRow(value: unknown, startRow: number): value is number | null { + return ( + value === null || + (typeof value === 'number' && + Number.isInteger(value) && + value >= startRow) + ); +} + +function isSource(value: unknown): value is SheetMapping['source'] { + return value === 'auto' || value === 'manual'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.ts new file mode 100644 index 0000000000..14346531f3 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.ts @@ -0,0 +1,96 @@ +interface SheetAnswer { + fieldId: string; + answer: string; +} + +interface SheetTarget extends SheetAnswer { + gid: string; + row: number; + col: number; +} + +export interface SheetPastePlan { + gid: string; + range: string; + tsv: string; + targetIds: string[]; + failedIds: string[]; +} + +export interface SheetPastePayload extends SheetPastePlan { + itemIds: string[]; +} + +export function buildSheetPastePlan( + answers: SheetAnswer[], +): SheetPastePlan | null { + const targets = answers.flatMap(parseSheetTarget); + if (targets.length === 0) return null; + + const gid = targets[0].gid; + const eligible = targets.filter((target) => target.gid === gid); + const failedIds = answers + .filter((answer) => !eligible.some((target) => target.fieldId === answer.fieldId)) + .map((answer) => answer.fieldId); + const rows = eligible.map((target) => target.row); + const cols = eligible.map((target) => target.col); + const minRow = Math.min(...rows); + const maxRow = Math.max(...rows); + const minCol = Math.min(...cols); + const maxCol = Math.max(...cols); + const byCell = new Map(eligible.map((target) => [`${target.row}:${target.col}`, target.answer])); + const matrix: string[][] = []; + + for (let row = minRow; row <= maxRow; row += 1) { + const values: string[] = []; + for (let col = minCol; col <= maxCol; col += 1) { + values.push(escapeTsvCell(byCell.get(`${row}:${col}`) ?? '')); + } + matrix.push(values); + } + + return { + gid, + range: getRange({ minRow, maxRow, minCol, maxCol }), + tsv: matrix.map((row) => row.join('\t')).join('\n'), + targetIds: eligible.map((target) => target.fieldId), + failedIds, + }; +} + +function parseSheetTarget(answer: SheetAnswer): SheetTarget[] { + const match = answer.fieldId.match(/^sheet:([^:]+):(\d+):(\d+)$/); + if (!match) return []; + return [{ + ...answer, + gid: match[1], + row: Number(match[2]), + col: Number(match[3]), + }]; +} + +function getRange(params: { + minRow: number; + maxRow: number; + minCol: number; + maxCol: number; +}): string { + const start = `${columnName(params.minCol)}${params.minRow}`; + const end = `${columnName(params.maxCol)}${params.maxRow}`; + return start === end ? start : `${start}:${end}`; +} + +function columnName(column: number): string { + let value = column; + let name = ''; + while (value > 0) { + const remainder = (value - 1) % 26; + name = String.fromCharCode(65 + remainder) + name; + value = Math.floor((value - 1) / 26); + } + return name; +} + +function escapeTsvCell(value: string): string { + return value.replace(/\r?\n/g, ' ').replace(/\t/g, ' ').trim(); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/storage.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/storage.ts new file mode 100644 index 0000000000..f1cc6ad785 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/storage.ts @@ -0,0 +1,87 @@ +import { browser } from 'wxt/browser'; + +const SELECTED_ORG_KEY = 'comp.securityQuestionnaire.selectedOrganizationId'; +const CONFIRMED_DOMAINS_KEY = 'comp.securityQuestionnaire.confirmedDomains'; +const DETECTION_ENABLED_KEY = 'comp.securityQuestionnaire.detectionEnabled'; + +export async function getSelectedOrganizationId(): Promise { + const result = await browser.storage.local.get(SELECTED_ORG_KEY); + const value = result[SELECTED_ORG_KEY]; + return typeof value === 'string' && value.length > 0 ? value : null; +} + +export async function setSelectedOrganizationId( + organizationId: string, +): Promise { + await browser.storage.local.set({ [SELECTED_ORG_KEY]: organizationId }); +} + +export async function getConfirmedDomains(): Promise> { + const result = await browser.storage.local.get(CONFIRMED_DOMAINS_KEY); + return readStringMap(result[CONFIRMED_DOMAINS_KEY]); +} + +export async function setConfirmedDomain(params: { + host: string; + organizationId: string; +}): Promise { + const domains = await getConfirmedDomains(); + await browser.storage.local.set({ + [CONFIRMED_DOMAINS_KEY]: { + ...domains, + [params.host]: params.organizationId, + }, + }); +} + +export async function clearConfirmedDomains(): Promise { + await browser.storage.local.set({ [CONFIRMED_DOMAINS_KEY]: {} }); +} + +export async function isDomainConfirmed(params: { + host: string; + organizationId: string; +}): Promise { + const domains = await getConfirmedDomains(); + return domains[params.host] === params.organizationId; +} + +export async function getDetectionEnabled(host: string): Promise { + const result = await browser.storage.local.get(DETECTION_ENABLED_KEY); + const settings = readBooleanMap(result[DETECTION_ENABLED_KEY]); + return settings[host] ?? true; +} + +export async function setDetectionEnabled(params: { + host: string; + enabled: boolean; +}): Promise { + const result = await browser.storage.local.get(DETECTION_ENABLED_KEY); + const settings = readBooleanMap(result[DETECTION_ENABLED_KEY]); + await browser.storage.local.set({ + [DETECTION_ENABLED_KEY]: { + ...settings, + [params.host]: params.enabled, + }, + }); +} + +function readStringMap(value: unknown): Record { + if (!isRecord(value)) return {}; + const entries = Object.entries(value).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ); + return Object.fromEntries(entries); +} + +function readBooleanMap(value: unknown): Record { + if (!isRecord(value)) return {}; + const entries = Object.entries(value).filter( + (entry): entry is [string, boolean] => typeof entry[1] === 'boolean', + ); + return Object.fromEntries(entries); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/types.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/types.ts new file mode 100644 index 0000000000..330384df60 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/types.ts @@ -0,0 +1,133 @@ +export interface Organization { + id: string; + name: string; + logo?: string | null; + memberRole?: string | null; + memberId?: string | null; +} + +export interface AuthUser { + id: string; + email: string; + name?: string | null; +} + +export interface AuthState { + status: 'authenticated' | 'unauthenticated'; + user: AuthUser | null; + organizations: Organization[]; + selectedOrganizationId: string | null; + apiBaseUrl: string; + appBaseUrl: string; +} + +export interface DetectedQuestion { + id: string; + question: string; + value: string; + isEmpty: boolean; + tag: string; +} + +export interface ScanDebugStep { + name: string; + status: 'ok' | 'fail' | 'skip'; + detail: string; + count?: number; + sample?: string; +} + +export interface ScanDebug { + surface: QuestionnaireSurface; + source: string; + questionCount: number; + steps: ScanDebugStep[]; + updatedAt: number; +} + +export interface SheetMapping { + spreadsheetId: string; + gid: string; + questionColumn: string; + answerColumn: string; + startRow: number; + endRow: number | null; + source: 'auto' | 'manual'; + confirmed: boolean; + updatedAt: number; +} + +export interface GeneratedAnswer { + questionIndex: number; + question: string; + answer: string | null; + sources: unknown[]; + error?: string | null; +} + +export type QuestionnaireSurface = 'generic' | 'docs' | 'sheets' | 'forms'; + +export type AnswerConfidence = 'high' | 'med' | 'low'; + +export type QueueStatus = + | 'pending' + | 'generating' + | 'generated' + | 'approved' + | 'inserted' + | 'flagged'; + +export interface QuestionQueueItem { + id: string; + fieldId: string; + question: string; + value: string; + isEmpty: boolean; + tag: string; + status: QueueStatus; + answer: string | null; + confidence: AnswerConfidence | null; + sources: unknown[]; + error?: string; + edited: boolean; + createdAt: number; + updatedAt: number; +} + +export interface TabQuestionQueue { + tabId: number; + url: string; + host: string; + surface: QuestionnaireSurface; + sheetMapping: SheetMapping | null; + organizationId: string | null; + selectedItemId: string | null; + staleDraftCount: number; + items: QuestionQueueItem[]; + updatedAt: number; +} + +export interface PanelState { + auth: AuthState; + queue: TabQuestionQueue; + detectionEnabled: boolean; +} + +export interface DomainConfirmationRequest { + host: string; + organizationId: string; + organizationName: string; +} + +export interface InsertAnswerRequest { + fieldId: string; + answer: string; +} + +export interface BatchProgressItem { + fieldId: string; + question: string; + status: 'pending' | 'generating' | 'ready' | 'error' | 'inserted' | 'skipped'; + answer: string | null; + error?: string; +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/types/env.d.ts b/apps/browser-extension/security-questionnaire-ext/src/types/env.d.ts new file mode 100644 index 0000000000..5b5307498e --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/types/env.d.ts @@ -0,0 +1,9 @@ +interface ImportMetaEnv { + readonly WXT_GOOGLE_OAUTH_CLIENT_ID?: string; + readonly WXT_PUBLIC_API_BASE_URL?: string; + readonly WXT_PUBLIC_APP_BASE_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/browser-extension/security-questionnaire-ext/tsconfig.json b/apps/browser-extension/security-questionnaire-ext/tsconfig.json new file mode 100644 index 0000000000..68e700575e --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.wxt/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true + } +} diff --git a/apps/browser-extension/security-questionnaire-ext/wxt.config.ts b/apps/browser-extension/security-questionnaire-ext/wxt.config.ts new file mode 100644 index 0000000000..1a2fe44d93 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/wxt.config.ts @@ -0,0 +1,98 @@ +import { readFileSync } from 'node:fs'; +import { defineConfig } from 'wxt'; + +function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, ''); +} + +function readEnv(name: string): string | undefined { + return process.env[name] ?? readDotEnv()[name]; +} + +function readDotEnv(): Record { + try { + const text = readFileSync(new URL('.env', import.meta.url), 'utf8'); + return Object.fromEntries( + text.split(/\r?\n/).flatMap((line) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) return []; + const separator = trimmed.indexOf('='); + if (separator <= 0) return []; + return [[trimmed.slice(0, separator), trimmed.slice(separator + 1)]]; + }), + ); + } catch { + return {}; + } +} + +function normalizeExtensionKey(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + return /^[a-p]{32}$/.test(trimmed) ? undefined : trimmed; +} + +const apiBaseUrl = trimTrailingSlash( + readEnv('WXT_PUBLIC_API_BASE_URL') ?? 'http://localhost:3333', +); +const appBaseUrl = trimTrailingSlash( + readEnv('WXT_PUBLIC_APP_BASE_URL') ?? 'http://localhost:3000', +); +const extensionKey = normalizeExtensionKey(readEnv('WXT_EXTENSION_KEY')); +const googleOAuthClientId = readEnv('WXT_GOOGLE_OAUTH_CLIENT_ID'); +const googleSheetsScope = 'https://www.googleapis.com/auth/spreadsheets'; + +export default defineConfig({ + srcDir: 'src', + outDir: 'dist', + dev: { + server: { + port: 3100, + strictPort: true, + }, + }, + manifest: { + name: 'Comp AI Security Questionnaire', + description: + 'Generate and insert security questionnaire answers from Comp AI.', + permissions: [ + 'activeTab', + 'clipboardWrite', + ...(googleOAuthClientId ? ['identity'] : []), + 'scripting', + 'storage', + 'tabs', + ], + host_permissions: [ + '', + `${apiBaseUrl}/*`, + `${appBaseUrl}/*`, + 'https://api.staging.trycomp.ai/*', + 'https://app.staging.trycomp.ai/*', + ], + icons: { + 16: 'icon/16.png', + 32: 'icon/32.png', + 48: 'icon/48.png', + 128: 'icon/128.png', + }, + action: { + default_icon: { + 16: 'icon/16.png', + 32: 'icon/32.png', + 48: 'icon/48.png', + 128: 'icon/128.png', + }, + default_title: 'Comp AI Security Questionnaire', + }, + ...(googleOAuthClientId + ? { + oauth2: { + client_id: googleOAuthClientId, + scopes: [googleSheetsScope], + }, + } + : {}), + ...(extensionKey ? { key: extensionKey } : {}), + }, +}); diff --git a/bun.lock b/bun.lock index e5cfd7955e..b60b6c1e5f 100644 --- a/bun.lock +++ b/bun.lock @@ -397,6 +397,19 @@ "react-dom": "^19.1.0", }, }, + "apps/browser-extension/security-questionnaire-ext": { + "name": "@trycompai/security-questionnaire-extension", + "version": "0.0.0", + "dependencies": { + "zod": "^4.3.6", + }, + "devDependencies": { + "jsdom": "^26.1.0", + "typescript": "^5.9.3", + "vitest": "^3.2.4", + "wxt": "^0.20.26", + }, + }, "apps/framework-editor": { "name": "@trycompai/framework-editor", "dependencies": { @@ -799,6 +812,8 @@ "packages": { "7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="], + "@1natsu/wait-element": ["@1natsu/wait-element@4.2.0", "", { "dependencies": { "defu": "^6.1.4", "many-keys-map": "^3.0.0" } }, "sha512-Om0Q+WE9mNrpY4AwMTvkFiYHv8VM7TML3PvOqXy+w6kAjLjKhGYHYX+305+a6J8RVpds9s7IF2Z5aOPYwULFNw=="], + "@actions/core": ["@actions/core@3.0.1", "", { "dependencies": { "@actions/exec": "^3.0.0", "@actions/http-client": "^4.0.0" } }, "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA=="], "@actions/exec": ["@actions/exec@3.0.0", "", { "dependencies": { "@actions/io": "^3.0.2" } }, "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw=="], @@ -847,6 +862,8 @@ "@ai-sdk/xai": ["@ai-sdk/xai@2.0.72", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.39", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RXpfCTliybesXOmc+jGB7NhobJzzZc2rr7gSy7kGj0eHDYXkCmoo4/llpE8yKIUJMwU098DP1cBGdltPezNRiw=="], + "@aklinker1/rollup-plugin-visualizer": ["@aklinker1/rollup-plugin-visualizer@5.12.0", "", { "dependencies": { "open": "^8.4.0", "picomatch": "^2.3.1", "source-map": "^0.7.4", "yargs": "^17.5.1" }, "peerDependencies": { "rollup": "2.x || 3.x || 4.x" }, "optionalPeers": ["rollup"], "bin": { "rollup-plugin-visualizer": "dist/bin/cli.js" } }, "sha512-X24LvEGw6UFmy0lpGJDmXsMyBD58XmX1bbwsaMLhNoM+UMQfQ3b2RtC+nz4b/NoRK5r6QJSKJHBNVeUdwqybaQ=="], + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], @@ -1311,6 +1328,12 @@ "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], + "@devicefarmer/adbkit": ["@devicefarmer/adbkit@3.3.8", "", { "dependencies": { "@devicefarmer/adbkit-logcat": "^2.1.2", "@devicefarmer/adbkit-monkey": "~1.2.1", "bluebird": "~3.7", "commander": "^9.1.0", "debug": "~4.3.1", "node-forge": "^1.3.1", "split": "~1.0.1" }, "bin": { "adbkit": "bin/adbkit" } }, "sha512-7rBLLzWQnBwutH2WZ0EWUkQdihqrnLYCUMaB44hSol9e0/cdIhuNFcqZO0xNheAU6qqHVA8sMiLofkYTgb+lmw=="], + + "@devicefarmer/adbkit-logcat": ["@devicefarmer/adbkit-logcat@2.1.3", "", {}, "sha512-yeaGFjNBc/6+svbDeul1tNHtNChw6h8pSHAt5D+JsedUrMTN7tla7B15WLDyekxsuS2XlZHRxpuC6m92wiwCNw=="], + + "@devicefarmer/adbkit-monkey": ["@devicefarmer/adbkit-monkey@1.2.1", "", {}, "sha512-ZzZY/b66W2Jd6NHbAhLyDWOEIBWC11VizGFk7Wx7M61JZRz7HR9Cq5P+65RKWUU7u6wgsE8Lmh9nE4Mz+U2eTg=="], + "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], "@discordjs/builders": ["@discordjs/builders@1.14.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ=="], @@ -2729,6 +2752,8 @@ "@trycompai/portal": ["@trycompai/portal@workspace:apps/portal"], + "@trycompai/security-questionnaire-extension": ["@trycompai/security-questionnaire-extension@workspace:apps/browser-extension/security-questionnaire-ext"], + "@trycompai/tsconfig": ["@trycompai/tsconfig@workspace:packages/tsconfig"], "@trycompai/ui": ["@trycompai/ui@workspace:packages/ui"], @@ -2877,10 +2902,16 @@ "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.1", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A=="], + "@types/filesystem": ["@types/filesystem@0.0.36", "", { "dependencies": { "@types/filewriter": "*" } }, "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA=="], + + "@types/filewriter": ["@types/filewriter@0.0.33", "", {}, "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g=="], + "@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="], "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="], + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], @@ -2925,6 +2956,8 @@ "@types/methods": ["@types/methods@1.1.4", "", {}, "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ=="], + "@types/minimatch": ["@types/minimatch@3.0.5", "", {}, "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ=="], + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/multer": ["@types/multer@1.4.13", "", { "dependencies": { "@types/express": "*" } }, "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw=="], @@ -3013,6 +3046,8 @@ "@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="], + "@types/webextension-polyfill": ["@types/webextension-polyfill@0.12.5", "", {}, "sha512-uKSAv6LgcVdINmxXMKBuVIcg/2m5JZugoZO8x20g7j2bXJkPIl/lVGQcDlbV+aXAiTyXT2RA5U5mI4IGCDMQeg=="], + "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -3169,8 +3204,18 @@ "@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="], + "@webext-core/fake-browser": ["@webext-core/fake-browser@1.5.2", "", { "dependencies": { "@types/webextension-polyfill": ">=0.10.5", "lodash.merge": "^4.6.2" } }, "sha512-nkDQwOJ23X5Q7cEtN6LRuBtVFf1KVOFi5GoQAro0lzqdh59F5E+K350j1isbnqYbzsXRh1NJtboudIcHfZtvOQ=="], + + "@webext-core/isolated-element": ["@webext-core/isolated-element@1.1.5", "", { "dependencies": { "is-potential-custom-element-name": "^1.0.1" } }, "sha512-4m6oP8Vzm/68YO1QmkUOZqqUcmyBtA53tji2g00/nYXE3E3IceYgeub7eIqvXDV2Z7xU6cm6qO1IMt4XFVwtvQ=="], + + "@webext-core/match-patterns": ["@webext-core/match-patterns@1.0.3", "", {}, "sha512-NY39ACqCxdKBmHgw361M9pfJma8e4AZo20w9AY+5ZjIj1W2dvXC8J31G5fjfOGbulW9w4WKpT8fPooi0mLkn9A=="], + "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], + "@wxt-dev/browser": ["@wxt-dev/browser@0.1.42", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-BSb0H09i4+0WlqFnN7LnZW0M6uCPH9KndciGx7mwOFKRVjNCorqwPk3hiVB58yQ+cyJNpDvYWL6IoPBxvP8qEA=="], + + "@wxt-dev/storage": ["@wxt-dev/storage@1.2.8", "", { "dependencies": { "@wxt-dev/browser": "^0.1.37", "async-mutex": "^0.5.0", "dequal": "^2.0.3" } }, "sha512-GWCFKgF5+d7eslOxUDFC70ypA9njupmJb1nQM8uZoX0J3sWT2BO5xJLzb1sYahWAfID9p2BMtnUBN1lkWxPsbQ=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], "@xobotyi/scrollbar-width": ["@xobotyi/scrollbar-width@1.9.5", "", {}, "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ=="], @@ -3219,6 +3264,8 @@ "ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], + "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], @@ -3259,6 +3306,8 @@ "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + "array-differ": ["array-differ@4.0.0", "", {}, "sha512-Q6VPTLMsmXZ47ENG3V+wQyZS1ZxXMxFyYzA+Z/GMrJ6yIutAIEf9wTyroTzmGjNfox9/h3GdGBCVh43GVFx4Uw=="], + "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], "array-ify": ["array-ify@1.0.0", "", {}, "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng=="], @@ -3267,6 +3316,8 @@ "array-timsort": ["array-timsort@1.0.3", "", {}, "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="], + "array-union": ["array-union@3.0.1", "", {}, "sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw=="], + "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="], "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="], @@ -3301,6 +3352,8 @@ "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + "async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="], + "async-retry": ["async-retry@1.3.3", "", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], @@ -3415,6 +3468,8 @@ "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + "boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="], + "brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -3547,6 +3602,8 @@ "clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="], + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], @@ -3555,7 +3612,7 @@ "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], - "cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="], + "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], @@ -3619,6 +3676,8 @@ "config-file-ts": ["config-file-ts@0.2.8-rc1", "", { "dependencies": { "glob": "^10.3.12", "typescript": "^5.4.3" } }, "sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg=="], + "configstore": ["configstore@7.1.0", "", { "dependencies": { "atomically": "^2.0.3", "dot-prop": "^9.0.0", "graceful-fs": "^4.2.11", "xdg-basedir": "^5.1.0" } }, "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg=="], + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], "console-control-strings": ["console-control-strings@1.1.0", "", {}, "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ=="], @@ -3697,6 +3756,8 @@ "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="], + "cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -4031,6 +4092,8 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-goat": ["escape-goat@4.0.0", "", {}, "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -4151,6 +4214,8 @@ "fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="], + "fast-redact": ["fast-redact@3.5.0", "", {}, "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A=="], + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], @@ -4197,6 +4262,8 @@ "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], + "filesize": ["filesize@11.0.17", "", {}, "sha512-oHLTvMLw6imZUl1se/RBQrFlyy50nXce4sU7yGR6Qc0JgCwqnfiFsAnEwotdGmfKLD7SArGUk2/5STU0k8LOBQ=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="], @@ -4213,6 +4280,8 @@ "firecrawl": ["firecrawl@4.16.0", "", { "dependencies": { "axios": "^1.13.5", "typescript-event-target": "^1.1.1", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.0" } }, "sha512-7SJ/FWhZBtW2gTCE/BsvU+gbfIpfTq+D9IH82l9MacauLVptaY6EdYAhrK3YSMC9yr5NxvxRcpZKcXG/nqjiiQ=="], + "firefox-profile": ["firefox-profile@4.7.0", "", { "dependencies": { "adm-zip": "~0.5.x", "fs-extra": "^11.2.0", "ini": "^4.1.3", "minimist": "^1.2.8", "xml2js": "^0.6.2" }, "bin": { "firefox-profile": "lib/cli.js" } }, "sha512-aGApEu5bfCNbA4PGUZiRJAIU6jKmghV2UVdklXAofnNtiDjqYw0czLS46W7IfFqVKgKhFB8Ao2YoNGHY4BoIMQ=="], + "fix-dts-default-cjs-exports": ["fix-dts-default-cjs-exports@1.0.1", "", { "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], @@ -4279,6 +4348,8 @@ "fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="], + "fx-runner": ["fx-runner@1.4.0", "", { "dependencies": { "commander": "2.9.0", "shell-quote": "1.7.3", "spawn-sync": "1.0.15", "when": "3.7.7", "which": "1.2.4", "winreg": "0.0.12" }, "bin": { "fx-runner": "bin/fx-runner" } }, "sha512-rci1g6U0rdTg6bAaBboP7XdRu01dzTAaKXxFf+PUqGuCv6Xu7o8NZdY1D5MvKGIjb6EdS1g3VlXOgksir1uGkg=="], + "fzf": ["fzf@0.5.2", "", {}, "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="], "gauge": ["gauge@2.7.4", "", { "dependencies": { "aproba": "^1.0.3", "console-control-strings": "^1.0.0", "has-unicode": "^2.0.0", "object-assign": "^4.1.0", "signal-exit": "^3.0.0", "string-width": "^1.0.1", "strip-ansi": "^3.0.1", "wide-align": "^1.1.0" } }, "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg=="], @@ -4321,7 +4392,7 @@ "getpass": ["getpass@0.1.7", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng=="], - "giget": ["giget@1.2.5", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.5.4", "pathe": "^2.0.3", "tar": "^6.2.1" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug=="], + "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "git-last-commit": ["git-last-commit@1.0.1", "", {}, "sha512-FDSgeMqa7GnJDxt/q0AbrxbfeTyxp4ImxEw1e4nw6NUHA5FMhFUq33dTXI4Xdgcj1VQ1q5QLWF6WxFrJ8KCBOg=="], @@ -4363,6 +4434,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "graceful-readlink": ["graceful-readlink@1.0.1", "", {}, "sha512-8tLu60LgxF6XpdbK8OW3FA+IfTNBn1ZHGHKF4KQbEeSkajYw5PlYJcKluntgegDPTg8UkHjpet1T82vk6TQ68w=="], + "gradient-string": ["gradient-string@2.0.2", "", { "dependencies": { "chalk": "^4.1.2", "tinygradient": "^1.1.5" } }, "sha512-rEDCuqUQ4tbD78TpzsMtt5OIf0cBCSDWSJtUDaF6JsAh+k0v9r++NzxNEG87oDZx9ZwGhD8DaezR2L/yrw0Jdw=="], "grammex": ["grammex@3.1.12", "", {}, "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ=="], @@ -4373,6 +4446,8 @@ "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + "growly": ["growly@1.3.0", "", {}, "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw=="], + "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], @@ -4445,6 +4520,8 @@ "hook-std": ["hook-std@4.0.0", "", {}, "sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ=="], + "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], + "hosted-git-info": ["hosted-git-info@8.1.0", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw=="], "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], @@ -4537,6 +4614,8 @@ "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-absolute": ["is-absolute@0.1.7", "", { "dependencies": { "is-relative": "^0.1.0" } }, "sha512-Xi9/ZSn4NFapG8RP98iNPMOeaV3mXPisxKxzKtHVqr3g56j/fBn+yZmnxSVAA8lmZbl2J9b/a4kJvfU3hqQYgA=="], + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], @@ -4585,10 +4664,14 @@ "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-in-ci": ["is-in-ci@1.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg=="], + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + "is-installed-globally": ["is-installed-globally@1.0.0", "", { "dependencies": { "global-directory": "^4.0.1", "is-path-inside": "^4.0.0" } }, "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ=="], + "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], "is-lambda": ["is-lambda@1.0.1", "", {}, "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ=="], @@ -4601,16 +4684,24 @@ "is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="], + "is-npm": ["is-npm@6.1.0", "", {}, "sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], "is-obj": ["is-obj@3.0.0", "", {}, "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ=="], + "is-path-inside": ["is-path-inside@4.0.0", "", {}, "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-primitive": ["is-primitive@3.0.1", "", {}, "sha512-GljRxhWvlCNRfZyORiH77FwdFwGcMO620o37EOYC0ORWdq+WYNVqW0w2Juzew4M+L81l6/QS3t5gkkihyRqv9w=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], @@ -4621,6 +4712,8 @@ "is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], + "is-relative": ["is-relative@0.1.3", "", {}, "sha512-wBOr+rNM4gkAZqoLRJI4myw5WzzIdQosFAAbnvfXP5z1LyzgAI3ivOKehC5KfqlQJZoihVhirgtCBj378Eg8GA=="], + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], @@ -4647,7 +4740,7 @@ "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], - "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], @@ -4655,6 +4748,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="], + "isstream": ["isstream@0.1.2", "", {}, "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g=="], "issue-parser": ["issue-parser@7.0.2", "", { "dependencies": { "lodash.capitalize": "^4.2.1", "lodash.escaperegexp": "^4.1.2", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.uniqby": "^4.7.0" } }, "sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw=="], @@ -4813,6 +4908,8 @@ "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "ky": ["ky@1.14.3", "", {}, "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw=="], + "kysely": ["kysely@0.28.17", "", {}, "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q=="], "langium": ["langium@4.2.3", "", { "dependencies": { "@chevrotain/regexp-to-ast": "~12.0.0", "chevrotain": "~12.0.0", "chevrotain-allstar": "~0.4.3", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-sOPIi4hISFnY7twwV97ca1TsxpBtXq0URu/LL1AvxwccPG/RIBBlKS7a/f/EL6w8lTNaS0EFs/F+IdSOaqYpng=="], @@ -4823,6 +4920,8 @@ "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], + "latest-version": ["latest-version@9.0.0", "", { "dependencies": { "package-json": "^10.0.0" } }, "sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA=="], + "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], @@ -4869,12 +4968,16 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "linkedom": ["linkedom@0.18.12", "", { "dependencies": { "css-select": "^5.1.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", "htmlparser2": "^10.0.0", "uhyphen": "^0.2.0" }, "peerDependencies": { "canvas": ">= 2" }, "optionalPeers": ["canvas"] }, "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q=="], + "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], "linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="], "listenercount": ["listenercount@1.0.1", "", {}, "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ=="], + "listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="], + "load-esm": ["load-esm@1.0.3", "", {}, "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA=="], "load-json-file": ["load-json-file@4.0.0", "", { "dependencies": { "graceful-fs": "^4.1.2", "parse-json": "^4.0.0", "pify": "^3.0.0", "strip-bom": "^3.0.0" } }, "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw=="], @@ -4885,6 +4988,8 @@ "loader-utils": ["loader-utils@2.0.4", "", { "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", "json5": "^2.1.2" } }, "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw=="], + "local-pkg": ["local-pkg@1.2.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], @@ -4949,6 +5054,8 @@ "log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], + "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], @@ -4991,6 +5098,8 @@ "mammoth": ["mammoth@1.12.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w=="], + "many-keys-map": ["many-keys-map@3.0.3", "", {}, "sha512-1DiZmDHPXMBgMRjeUtHy1q1VYmeJscHxhIAexX9z/zjRMP80+0ETuPfssi8z+kMY4DwUgsKuHqpjxgmeA9gBNA=="], + "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], @@ -5199,6 +5308,8 @@ "multer": ["multer@2.1.1", "", { "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", "type-is": "^1.6.18" } }, "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A=="], + "multimatch": ["multimatch@6.0.0", "", { "dependencies": { "@types/minimatch": "^3.0.5", "array-differ": "^4.0.0", "array-union": "^3.0.1", "minimatch": "^3.0.4" } }, "sha512-I7tSVxHGPlmPN/enE3mS1aOSo6bWBfls+3HmuEeCUBCE7gWnm3cBXCBkpurzFjVRwC6Kld8lLaZ1Iv5vOcjvcQ=="], + "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], @@ -5215,8 +5326,12 @@ "nano-css": ["nano-css@5.6.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "css-tree": "^1.1.2", "csstype": "^3.1.2", "fastest-stable-stringify": "^2.0.2", "inline-style-prefixer": "^7.0.1", "rtl-css-js": "^1.16.1", "stacktrace-js": "^2.0.2", "stylis": "^4.3.0" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-+6bHaC8dSDGALM1HJjOHVXpuastdu2xFoZlC77Jh4cg+33Zcgm+Gxd+1xsnpZK14eyHObSp82+ll5y3SX75liw=="], + "nano-spawn": ["nano-spawn@2.1.0", "", {}, "sha512-yTW+2okrElHiH4fsiz/+/zc0EDo9BDDoC3iKk8dpv1GeRc9nUWzUZHx6TofMWErchhUQR8hY9/Eu1Uja9x1nqA=="], + "nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], + "nanospinner": ["nanospinner@1.2.2", "", { "dependencies": { "picocolors": "^1.1.1" } }, "sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA=="], + "nanostores": ["nanostores@1.3.0", "", {}, "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA=="], "napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="], @@ -5255,6 +5370,8 @@ "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], + "node-gyp": ["node-gyp@3.8.0", "", { "dependencies": { "fstream": "^1.0.0", "glob": "^7.0.3", "graceful-fs": "^4.1.2", "mkdirp": "^0.5.0", "nopt": "2 || 3", "npmlog": "0 || 1 || 2 || 3 || 4", "osenv": "0", "request": "^2.87.0", "rimraf": "2", "semver": "~5.3.0", "tar": "^2.0.0", "which": "1" }, "bin": { "node-gyp": "./bin/node-gyp.js" } }, "sha512-3g8lYefrRRzvGeSowdJKAKyks8oUpLEd/DyPV4eMhVlhJ0aNaZqIrNUIPuEWWTAoPqyFkfGrM67MC69baqn6vA=="], "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], @@ -5263,6 +5380,8 @@ "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], + "node-notifier": ["node-notifier@10.0.1", "", { "dependencies": { "growly": "^1.3.0", "is-wsl": "^2.2.0", "semver": "^7.3.5", "shellwords": "^0.1.1", "uuid": "^8.3.2", "which": "^2.0.2" } }, "sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ=="], + "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="], "nopt": ["nopt@3.0.6", "", { "dependencies": { "abbrev": "1" }, "bin": { "nopt": "./bin/nopt.js" } }, "sha512-4GUt3kSEYmk4ITxzB/b9vaIDfUVWN/Ml1Fwl11IlnIG2iaJ9O6WXZ9SrYM9NLI8OCBieN2Y8SWC2oJV0RQ7qYg=="], @@ -5317,7 +5436,9 @@ "obliterator": ["obliterator@1.6.1", "", {}, "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig=="], - "ohash": ["ohash@1.1.6", "", {}, "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg=="], + "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], "ollama-ai-provider-v2": ["ollama-ai-provider-v2@1.5.5", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.17" }, "peerDependencies": { "zod": "^4.0.16" } }, "sha512-1YwTFdPjhPNHny/DrOHO+s8oVGGIE5Jib61/KnnjPRNWQhVVimrJJdaAX3e6nNRRDXrY5zbb9cfm2+yVvgsrqw=="], @@ -5353,6 +5474,8 @@ "os-paths": ["os-paths@7.4.0", "", { "optionalDependencies": { "fsevents": "*" } }, "sha512-Ux1J4NUqC6tZayBqLN1kUlDAEvLiQlli/53sSddU4IN+h+3xxnv2HmRSMpVSvr1hvJzotfMs3ERvETGK+f4OwA=="], + "os-shim": ["os-shim@0.1.3", "", {}, "sha512-jd0cvB8qQ5uVt0lvCIexBaROw1KyKm5sbulg2fWOHjETisuCzWyt+eTZKEMs8v6HwzoGs8xik26jg7eCM6pS+A=="], + "os-tmpdir": ["os-tmpdir@1.0.2", "", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="], "osenv": ["osenv@0.1.5", "", { "dependencies": { "os-homedir": "^1.0.0", "os-tmpdir": "^1.0.0" } }, "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g=="], @@ -5393,6 +5516,8 @@ "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], + "package-json": ["package-json@10.0.1", "", { "dependencies": { "ky": "^1.2.0", "registry-auth-token": "^5.0.2", "registry-url": "^6.0.1", "semver": "^7.6.0" } }, "sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], @@ -5457,7 +5582,7 @@ "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], - "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], + "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="], @@ -5589,6 +5714,8 @@ "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + "promise-toolbox": ["promise-toolbox@0.21.0", "", { "dependencies": { "make-error": "^1.3.2" } }, "sha512-NV8aTmpwrZv+Iys54sSFOBx3tuVaOBvvrft5PNppnxy9xpU/akHbaWIril22AB22zaPgrgwKdD0KsrM0ptUtpg=="], + "promise-worker-transferable": ["promise-worker-transferable@1.0.4", "", { "dependencies": { "is-promise": "^2.1.0", "lie": "^3.0.2" } }, "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw=="], "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], @@ -5647,18 +5774,24 @@ "psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w=="], + "publish-browser-extension": ["publish-browser-extension@4.0.5", "", { "dependencies": { "cac": "^6.7.14", "consola": "^3.4.2", "dotenv": "^17.2.4", "form-data-encoder": "^4.1.0", "formdata-node": "^6.0.3", "jsonwebtoken": "^9.0.3", "listr2": "^10.1.0", "ofetch": "^1.5.1", "zod": "3.25.76 || ^4.3.6" }, "bin": { "publish-extension": "bin/publish-extension.mjs" } }, "sha512-EePAn3VIHJS/jqCuvs1NgPgoecCT8+RsES76hbgYe2Ze1dyvB0tX60C1PCrV8Z8fv56mW3E59s9Gd/GwWiw7dw=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + "pupa": ["pupa@3.3.0", "", { "dependencies": { "escape-goat": "^4.0.0" } }, "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA=="], + "puppeteer-core": ["puppeteer-core@24.42.0", "", { "dependencies": { "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1595872", "typed-query-selector": "^2.12.1", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.19.0" } }, "sha512-T4zXokk/izH01fYPhyyev1A4piWiOKrYq7CUFpdoYQxmOnXoV6YjUabmfIjCYkNspSoAXIxRid3Tw+Vg0fthYg=="], "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], "qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="], + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], + "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -5787,6 +5920,8 @@ "registry-auth-token": ["registry-auth-token@5.1.1", "", { "dependencies": { "@pnpm/npm-conf": "^3.0.2" } }, "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q=="], + "registry-url": ["registry-url@6.0.1", "", { "dependencies": { "rc": "1.2.8" } }, "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q=="], + "rehype-harden": ["rehype-harden@1.1.8", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw=="], "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], @@ -5851,6 +5986,8 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="], "rimraf": ["rimraf@6.1.2", "", { "dependencies": { "glob": "^13.0.0", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g=="], @@ -5907,6 +6044,8 @@ "screenfull": ["screenfull@5.2.0", "", {}, "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA=="], + "scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="], + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], @@ -5953,6 +6092,8 @@ "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + "set-value": ["set-value@4.1.0", "", { "dependencies": { "is-plain-object": "^2.0.4", "is-primitive": "^3.0.1" } }, "sha512-zTEg4HL0RwVrqcWs3ztF+x1vkxfm0lP+MQQFPiMJTKVceBwEV0A569Ou8l9IYQG8jOZdMVI1hGsc0tmeD2o/Lw=="], + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], @@ -5967,6 +6108,8 @@ "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "shellwords": ["shellwords@0.1.1", "", {}, "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww=="], + "shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], @@ -5995,7 +6138,7 @@ "slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], - "slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], + "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], "slug": ["slug@6.1.0", "", {}, "sha512-x6vLHCMasg4DR2LPiyFGI0gJJhywY6DTiGhCrOMzb3SOk/0JVLIaL4UhyFSHu04SD3uAavrKY/K3zZ3i6iRcgA=="], @@ -6037,6 +6180,8 @@ "spawn-error-forwarder": ["spawn-error-forwarder@1.0.0", "", {}, "sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g=="], + "spawn-sync": ["spawn-sync@1.0.15", "", { "dependencies": { "concat-stream": "^1.4.7", "os-shim": "^0.1.2" } }, "sha512-9DWBgrgYZzNghseho0JOuh+5fg9u6QWhAWa51QC7+U5rCheZ/j1DrEZnyE0RBBRqZ9uEXGPgSSM0nky6burpVw=="], + "spdx-correct": ["spdx-correct@3.2.0", "", { "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" } }, "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA=="], "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="], @@ -6045,6 +6190,8 @@ "spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="], + "split": ["split@1.0.1", "", { "dependencies": { "through": "2" } }, "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg=="], + "split2": ["split2@1.0.0", "", { "dependencies": { "through2": "~2.0.0" } }, "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg=="], "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], @@ -6411,6 +6558,8 @@ "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], + "uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="], + "uid": ["uid@2.0.2", "", { "dependencies": { "@lukeed/csprng": "^1.0.0" } }, "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g=="], "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], @@ -6435,6 +6584,8 @@ "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + "unimport": ["unimport@6.3.0", "", { "dependencies": { "acorn": "^8.16.0", "escape-string-regexp": "^5.0.0", "estree-walker": "^3.0.3", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "mlly": "^1.8.2", "pathe": "^2.0.3", "picomatch": "^4.0.4", "pkg-types": "^2.3.1", "scule": "^1.3.0", "strip-literal": "^3.1.0", "tinyglobby": "^0.2.16", "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1" }, "peerDependencies": { "oxc-parser": "*", "rolldown": "^1.0.0" }, "optionalPeers": ["oxc-parser", "rolldown"] }, "sha512-M+Dxk5W9WRd+8j56W9tp8lGW/dmMc7g5zj7BWQnEjKQhryBstqsi1V0izb0zHwSkEN8cSYV7K75/bykairV2tA=="], + "unique-filename": ["unique-filename@2.0.1", "", { "dependencies": { "unique-slug": "^3.0.0" } }, "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A=="], "unique-slug": ["unique-slug@3.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w=="], @@ -6463,6 +6614,10 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], + + "unplugin-utils": ["unplugin-utils@0.3.1", "", { "dependencies": { "pathe": "^2.0.3", "picomatch": "^4.0.3" } }, "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog=="], + "unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="], "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="], @@ -6471,6 +6626,8 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "update-notifier": ["update-notifier@7.3.1", "", { "dependencies": { "boxen": "^8.0.1", "chalk": "^5.3.0", "configstore": "^7.0.0", "is-in-ci": "^1.0.0", "is-installed-globally": "^1.0.0", "is-npm": "^6.0.0", "latest-version": "^9.0.0", "pupa": "^3.1.0", "semver": "^7.6.3", "xdg-basedir": "^5.1.0" } }, "sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA=="], + "uploadthing": ["uploadthing@7.7.4", "", { "dependencies": { "@effect/platform": "0.90.3", "@standard-schema/spec": "1.0.0-beta.4", "@uploadthing/mime-types": "0.3.6", "@uploadthing/shared": "7.1.10", "effect": "3.17.7" }, "peerDependencies": { "express": "*", "h3": "*", "tailwindcss": "^3.0.0 || ^4.0.0-beta.0" }, "optionalPeers": ["express", "h3", "tailwindcss"] }, "sha512-rlK/4JWHW5jP30syzWGBFDDXv3WJDdT8gn9OoxRJmXLoXi94hBmyyjxihGlNrKhBc81czyv8TkzMioe/OuKGfA=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -6565,6 +6722,8 @@ "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], + "web-ext-run": ["web-ext-run@0.2.4", "", { "dependencies": { "@babel/runtime": "7.28.2", "@devicefarmer/adbkit": "3.3.8", "chrome-launcher": "1.2.0", "debounce": "1.2.1", "es6-error": "4.1.1", "firefox-profile": "4.7.0", "fx-runner": "1.4.0", "multimatch": "6.0.0", "node-notifier": "10.0.1", "parse-json": "7.1.1", "pino": "9.7.0", "promise-toolbox": "0.21.0", "set-value": "4.1.0", "source-map-support": "0.5.21", "strip-bom": "5.0.0", "strip-json-comments": "5.0.2", "tmp": "0.2.5", "update-notifier": "7.3.1", "watchpack": "2.4.4", "zip-dir": "2.0.0" } }, "sha512-rQicL7OwuqWdQWI33JkSXKcp7cuv1mJG8u3jRQwx/8aDsmhbTHs9ZRmNYOL+LX0wX8edIEQX8jj4bB60GoXtKA=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], @@ -6587,6 +6746,8 @@ "webpack-sources": ["webpack-sources@3.4.1", "", {}, "sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "wemoji": ["wemoji@0.1.9", "", {}, "sha512-TfrWnxn9cXQ6yZUUQuOxJkppsNMSPHDTtjH/ktX/vzfCgXiXx/FhRl7t6tP3j9acyfVXUYuIvqeUv5mY9fUZEg=="], "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], @@ -6595,6 +6756,8 @@ "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + "when": ["when@3.7.7", "", {}, "sha512-9lFZp/KHoqH6bPKjbWqa+3Dg/K/r2v0X/3/G2x4DBGchVS2QX2VXL3cZV994WQVnTM1/PD71Az25nAzryEUugw=="], + "when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -6611,6 +6774,10 @@ "wide-align": ["wide-align@1.1.5", "", { "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } }, "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg=="], + "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], + + "winreg": ["winreg@0.0.12", "", {}, "sha512-typ/+JRmi7RqP1NanzFULK36vczznSNN8kWVA9vIqXyv8GhghUlwhGp1Xj3Nms1FsPcNnsQrJOR10N58/nQ9hQ=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], @@ -6627,8 +6794,12 @@ "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "wxt": ["wxt@0.20.26", "", { "dependencies": { "@1natsu/wait-element": "^4.1.2", "@aklinker1/rollup-plugin-visualizer": "5.12.0", "@webext-core/fake-browser": "^1.3.4", "@webext-core/isolated-element": "^1.1.3", "@webext-core/match-patterns": "^1.0.3", "@wxt-dev/browser": "^0.1.42", "@wxt-dev/storage": "^1.0.0", "async-mutex": "^0.5.0", "c12": "^3.3.3", "cac": "^6.7.14 || ^7.0.0", "chokidar": "^5.0.0", "ci-info": "^4.4.0", "consola": "^3.4.2", "defu": "^6.1.4", "dotenv-expand": "^12.0.3", "esbuild": "^0.27.1", "filesize": "^11.0.15", "get-port-please": "^3.2.0", "giget": "^1.2.3 || ^2.0.0 || ^3.0.0", "hookable": "^6.1.0", "import-meta-resolve": "^4.2.0", "is-wsl": "^3.1.1", "json5": "^2.2.3", "jszip": "^3.10.1", "linkedom": "^0.18.12", "magicast": "^0.5.2", "nano-spawn": "^2.0.0", "nanospinner": "^1.2.2", "normalize-path": "^3.0.0", "nypm": "^0.6.5", "ohash": "^2.0.11", "open": "^11.0.0", "perfect-debounce": "^2.1.0", "picomatch": "^4.0.3", "prompts": "^2.4.2", "publish-browser-extension": "^2.3.0 || ^3.0.2 || ^4.0.5", "scule": "^1.3.0", "tinyglobby": "^0.2.15", "unimport": "^3.13.1 || ^4.0.0 || ^5.0.0 || ^6.0.0", "vite": "^5.4.19 || ^6.3.4 || ^7.0.0 || ^8.0.0-0", "vite-node": "^3.2.4 || ^5.0.0 || ^6.0.0", "web-ext-run": "^0.2.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["eslint"], "bin": { "wxt": "bin/wxt.mjs", "wxt-publish-extension": "bin/wxt-publish-extension.mjs" } }, "sha512-PMGz7sAlONJgwBkOriInXOoEU6/jlGKrhSFvZfiBPHZocyYPfnw1lod9rGDra957H83WO+TnGjYwJiGYciSIqA=="], + "xdg-app-paths": ["xdg-app-paths@8.3.0", "", { "dependencies": { "xdg-portable": "^10.6.0" }, "optionalDependencies": { "fsevents": "*" } }, "sha512-mgxlWVZw0TNWHoGmXq+NC3uhCIc55dDpAlDkMQUaIAcQzysb0kxctwv//fvuW61/nAAeUBJMQ8mnZjMmuYwOcQ=="], + "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], + "xdg-portable": ["xdg-portable@10.6.0", "", { "dependencies": { "os-paths": "^7.4.0" }, "optionalDependencies": { "fsevents": "*" } }, "sha512-xrcqhWDvtZ7WLmt8G4f3hHy37iK7D2idtosRgkeiSPZEPmBShp0VfmRBLWAPC6zLF48APJ21yfea+RfQMF4/Aw=="], "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], @@ -6669,6 +6840,8 @@ "zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="], + "zip-dir": ["zip-dir@2.0.0", "", { "dependencies": { "async": "^3.2.0", "jszip": "^3.2.2" } }, "sha512-uhlsJZWz26FLYXOD6WVuq+fIcZ3aBPGo/cFdiLlv3KNwpa52IF3ISV8fLhQLiqVu5No3VhlqlgthN6gehil1Dg=="], + "zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], @@ -6735,6 +6908,10 @@ "@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="], + "@aklinker1/rollup-plugin-visualizer/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + + "@aklinker1/rollup-plugin-visualizer/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "@angular-devkit/core/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "@angular-devkit/core/rxjs": ["rxjs@7.8.1", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg=="], @@ -6821,6 +6998,12 @@ "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@devicefarmer/adbkit/bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="], + + "@devicefarmer/adbkit/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], + + "@devicefarmer/adbkit/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], "@discordjs/rest/@sapphire/snowflake": ["@sapphire/snowflake@3.5.5", "", {}, "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ=="], @@ -7405,6 +7588,14 @@ "body-parser/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], + "boxen/camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], + + "boxen/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "boxen/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "boxen/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "builder-util/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], "builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -7415,10 +7606,16 @@ "c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "c12/giget": ["giget@1.2.5", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.5.4", "pathe": "^2.0.3", "tar": "^6.2.1" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug=="], + "c12/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + "c12/ohash": ["ohash@1.1.6", "", {}, "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg=="], + "c12/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + "c12/perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], + "cacache/chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], "cacache/glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="], @@ -7445,6 +7642,8 @@ "chalk-template/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "cli-highlight/parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], @@ -7453,6 +7652,8 @@ "cli-highlight/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], + "cli-truncate/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], @@ -7603,6 +7804,8 @@ "firecrawl/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "firefox-profile/ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], + "fleetctl/axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="], "fleetctl/tar": ["tar@7.5.11", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ=="], @@ -7623,6 +7826,12 @@ "fstream/rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="], + "fx-runner/commander": ["commander@2.9.0", "", { "dependencies": { "graceful-readlink": ">= 1.0.0" } }, "sha512-bmkUukX8wAOjHdN26xj5c4ctEV22TQ7dQYhSmuckKhToXrkUn0iIaolHdIxYYqD55nhpSPA9zPQ1yP57GdXP2A=="], + + "fx-runner/shell-quote": ["shell-quote@1.7.3", "", {}, "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw=="], + + "fx-runner/which": ["which@1.2.4", "", { "dependencies": { "is-absolute": "^0.1.7", "isexe": "^1.1.1" }, "bin": { "which": "./bin/which" } }, "sha512-zDRAqDSBudazdfM9zpiI30Fu9ve47htYXcGi3ln0wfKu2a7SmrT6F3VDoYONu//48V8Vz4TdCRNPjtvyRO3yBA=="], + "gauge/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "gauge/string-width": ["string-width@1.0.2", "", { "dependencies": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", "strip-ansi": "^3.0.0" } }, "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw=="], @@ -7633,10 +7842,6 @@ "get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], - "giget/nypm": ["nypm@0.5.4", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "tinyexec": "^0.3.2", "ufo": "^1.5.4" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA=="], - - "giget/tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], - "git-raw-commits/meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="], "git-raw-commits/split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], @@ -7663,14 +7868,14 @@ "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="], + "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "is-ci/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], - "is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], - "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "jest-changed-files/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -7727,8 +7932,20 @@ "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "linkedom/html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], + + "listr2/eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "listr2/wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], + "load-json-file/parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], + "local-pkg/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], + + "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + + "log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "make-fetch-happen/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], "make-fetch-happen/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], @@ -7781,6 +7998,8 @@ "msw/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], + "multimatch/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "mysql2/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], @@ -7799,6 +8018,8 @@ "node-gyp/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], + "node-notifier/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "normalize-package-data/hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], "npm/@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], @@ -8159,6 +8380,10 @@ "proxy-agent/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "publish-browser-extension/form-data-encoder": ["form-data-encoder@4.1.0", "", {}, "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw=="], + + "publish-browser-extension/formdata-node": ["formdata-node@6.0.3", "", {}, "sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg=="], + "puppeteer-core/devtools-protocol": ["devtools-protocol@0.0.1595872", "", {}, "sha512-kRfgp8vWVjBu/fbYCiVFiOqsCk3CrMKEo3WbgGT2NXK2dG7vawWPBljixajVgGK9II8rDO9G0oD0zLt3I1daRg=="], "raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], @@ -8247,6 +8472,10 @@ "sirv/@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "socket.io-adapter/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], "socket.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], @@ -8255,6 +8484,8 @@ "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "spawn-sync/concat-stream": ["concat-stream@1.6.2", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^2.2.2", "typedarray": "^0.0.6" } }, "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw=="], + "ssri/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], @@ -8357,8 +8588,14 @@ "unbzip2-stream/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "unimport/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "unimport/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], + "unzipper/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "update-notifier/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "uploadthing/@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.4", "", {}, "sha512-d3IxtzLo7P1oZ8s8YNvxzBUXRXojSut8pbPrTYtzsc5sn4+53jVqbk66pQerSZbZSJZQux6LkclB/+8IDordHg=="], "uploadthing/effect": ["effect@3.17.7", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-dpt0ONUn3zzAuul6k4nC/coTTw27AL5nhkORXgTi6NfMPzqWYa1M05oKmOMTxpVSTKepqXVcW9vIwkuaaqx9zA=="], @@ -8371,17 +8608,43 @@ "vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + "web-ext-run/@babel/runtime": ["@babel/runtime@7.28.2", "", {}, "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA=="], + + "web-ext-run/chrome-launcher": ["chrome-launcher@1.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-JbuGuBNss258bvGil7FT4HKdC3SC2K7UAEUqiPy3ACS3Yxo3hAW6bvFpCu2HsIJLgTqxgEX6BkujvzZfLpUD0Q=="], + + "web-ext-run/debounce": ["debounce@1.2.1", "", {}, "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug=="], + + "web-ext-run/parse-json": ["parse-json@7.1.1", "", { "dependencies": { "@babel/code-frame": "^7.21.4", "error-ex": "^1.3.2", "json-parse-even-better-errors": "^3.0.0", "lines-and-columns": "^2.0.3", "type-fest": "^3.8.0" } }, "sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw=="], + + "web-ext-run/pino": ["pino@9.7.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-vnMCM6xZTb1WDmLvtG2lE/2p+t9hDEIvTWJsu6FejkE62vB7gDhvzrpFR4Cw2to+9JNQxVnkAKVPA1KPB98vWg=="], + + "web-ext-run/strip-bom": ["strip-bom@5.0.0", "", {}, "sha512-p+byADHF7SzEcVnLvc/r3uognM1hUhObuHXxJcgLCfD194XAkaLbjq3Wzb0N5G2tgIjH0dgT708Z51QxMeu60A=="], + + "web-ext-run/strip-json-comments": ["strip-json-comments@5.0.2", "", {}, "sha512-4X2FR3UwhNUE9G49aIsJW5hRRR3GXGTBTZRMfv568O60ojM8HcWjV/VxAxCDW3SUND33O6ZY66ZuRcdkj73q2g=="], + + "web-ext-run/watchpack": ["watchpack@2.4.4", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA=="], + "webpack/eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], "webpack/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "webpack/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], + "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "wxt/c12": ["c12@3.3.4", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.4", "defu": "^6.1.6", "dotenv": "^17.3.1", "exsolve": "^1.0.8", "giget": "^3.2.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "rc9": "^3.0.1" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA=="], + + "wxt/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "wxt/magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], + + "wxt/nypm": ["nypm@0.6.6", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.1.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q=="], + + "wxt/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], @@ -8403,6 +8666,12 @@ "@ai-sdk/rsc/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@aklinker1/rollup-plugin-visualizer/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], + + "@aklinker1/rollup-plugin-visualizer/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + + "@aklinker1/rollup-plugin-visualizer/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "@angular-devkit/core/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@angular-devkit/schematics/ora/cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], @@ -8737,11 +9006,9 @@ "@prisma/config/c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], - "@prisma/config/c12/giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], - "@prisma/config/c12/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "@prisma/config/c12/ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + "@prisma/config/c12/perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], "@prisma/config/c12/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], @@ -8925,12 +9192,22 @@ "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "boxen/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "boxen/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "c12/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "c12/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "c12/giget/nypm": ["nypm@0.5.4", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "tinyexec": "^0.3.2", "ufo": "^1.5.4" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA=="], + + "c12/giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "c12/giget/tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + "cacache/glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "cacache/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], @@ -8945,6 +9222,8 @@ "chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "chrome-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + "cli-highlight/parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], "cli-highlight/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], @@ -9107,24 +9386,16 @@ "fstream/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "fx-runner/which/isexe": ["isexe@1.1.2", "", {}, "sha512-d2eJzK691yZwPHcv1LbeAOa91yMJ9QmfTgSO1oXB65ezVhXQsxBac2vEB4bMVms9cGzaA99n6V2viHMq82VLDw=="], + "gauge/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@1.0.0", "", { "dependencies": { "number-is-nan": "^1.0.0" } }, "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw=="], "gauge/strip-ansi/ansi-regex": ["ansi-regex@2.1.1", "", {}, "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA=="], - "giget/nypm/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], - - "giget/tar/chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], - - "giget/tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], - - "giget/tar/minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], - - "giget/tar/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], - - "giget/tar/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "glob/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], + "istanbul-lib-report/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "jest-changed-files/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], @@ -9173,6 +9444,18 @@ "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "listr2/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "listr2/wrap-ansi/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + + "log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "log-update/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "make-fetch-happen/http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "make-fetch-happen/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], @@ -9199,6 +9482,8 @@ "msw/tough-cookie/tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="], + "multimatch/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "next/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], @@ -9207,6 +9492,8 @@ "node-gyp/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "node-notifier/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + "normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="], "npm/minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -9337,6 +9624,8 @@ "signale/figures/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + "spawn-sync/concat-stream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "ssri/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "stream-combiner2/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], @@ -9489,6 +9778,14 @@ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "web-ext-run/chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "web-ext-run/parse-json/json-parse-even-better-errors": ["json-parse-even-better-errors@3.0.2", "", {}, "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ=="], + + "web-ext-run/parse-json/lines-and-columns": ["lines-and-columns@2.0.4", "", {}, "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A=="], + + "web-ext-run/parse-json/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="], + "webpack/eslint-scope/estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], "webpack/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -9499,10 +9796,26 @@ "webpack/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], + "widest-line/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "wxt/c12/giget": ["giget@3.2.0", "", { "bin": { "giget": "dist/cli.mjs" } }, "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A=="], + + "wxt/c12/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "wxt/c12/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], + + "wxt/c12/rc9": ["rc9@3.0.1", "", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="], + + "wxt/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + + "wxt/nypm/citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], + + "wxt/open/wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "@angular-devkit/schematics/ora/cli-cursor/restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], "@angular-devkit/schematics/ora/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -9739,6 +10052,18 @@ "c12/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "c12/giget/nypm/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "c12/giget/tar/chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], + + "c12/giget/tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], + + "c12/giget/tar/minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], + + "c12/giget/tar/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + + "c12/giget/tar/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "cacache/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "cacache/tar/minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -9775,14 +10100,14 @@ "fstream/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "giget/tar/minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "jest-config/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "jest-runtime/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "log-update/wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "msw/@inquirer/confirm/@inquirer/core/@inquirer/ansi": ["@inquirer/ansi@2.0.5", "", {}, "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw=="], "msw/@inquirer/confirm/@inquirer/core/@inquirer/figures": ["@inquirer/figures@2.0.5", "", {}, "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ=="], @@ -9805,11 +10130,9 @@ "prisma/@prisma/config/c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], - "prisma/@prisma/config/c12/giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], - "prisma/@prisma/config/c12/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "prisma/@prisma/config/c12/ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + "prisma/@prisma/config/c12/perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], "prisma/@prisma/config/c12/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], @@ -10211,12 +10534,16 @@ "semantic-release/aggregate-error/clean-stack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "shadcn/open/wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], - "signale/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "signale/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + "spawn-sync/concat-stream/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "spawn-sync/concat-stream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "spawn-sync/concat-stream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "terser-webpack-plugin/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "test-exclude/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], @@ -10227,6 +10554,8 @@ "trigger.dev/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "web-ext-run/chrome-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + "webpack/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@angular-devkit/schematics/ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], @@ -10265,6 +10594,8 @@ "archiver-utils/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "c12/giget/tar/minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "cacache/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], "cli-highlight/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/package.json b/package.json index 7ca0e64a78..3fd3cd288c 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ }, "workspaces": [ "apps/*", + "apps/browser-extension/*", "!apps/mcp-server", "packages/*" ], diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index c88967fb05..dd7fe56119 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -7286,9 +7286,9 @@ "metadata": { "title": "List compliance policies | Comp AI API", "sidebarTitle": "List compliance policies", - "description": "Lists active compliance policies by default. Use includeArchived=true to include archived rows.", + "description": "Lists active compliance policies by default. Use includeArchived=true to include archived rows and excludeContent=true when you only need policy metadata.", "og:title": "List compliance policies | Comp AI API", - "og:description": "Lists active compliance policies by default. Use includeArchived=true to include archived rows." + "og:description": "Lists active compliance policies by default. Use includeArchived=true to include archived rows and excludeContent=true when you only need policy metadata." } }, "x-codeSamples": [ @@ -7364,7 +7364,6 @@ "signedBy": [], "reviewDate": "2024-12-31T00:00:00.000Z", "isArchived": false, - "archivedAt": null, "createdAt": "2024-01-01T00:00:00.000Z", "updatedAt": "2024-01-15T00:00:00.000Z", "organizationId": "org_abc123def456", @@ -8486,7 +8485,6 @@ ], "reviewDate": "2024-12-31T00:00:00.000Z", "isArchived": false, - "archivedAt": null, "createdAt": "2024-01-01T00:00:00.000Z", "updatedAt": "2024-01-15T00:00:00.000Z", "organizationId": "org_abc123def456", @@ -16716,6 +16714,7 @@ }, "/v1/questionnaire/answer-single": { "post": { + "description": "Generate an answer for one security questionnaire item using the organization evidence library and return source references for review.", "operationId": "QuestionnaireController_answerSingleQuestion_v1", "parameters": [], "requestBody": { @@ -16782,7 +16781,6 @@ "tags": [ "Questionnaire" ], - "description": "Generate an answer for one security questionnaire item using the organization evidence library and return source references for review.", "x-mint": { "href": "/api-reference/questionnaire/answer-a-single-questionnaire-question", "metadata": { @@ -22152,7 +22150,7 @@ }, "/v1/evidence-forms/{formType}/upload-submission": { "post": { - "description": "Upload a file as an evidence submission in Comp AI. Collect, review, upload, and export structured evidence submissions for compliance tasks and document requirements.", + "description": "Upload a PDF or image file and create a submission for the given form type, bypassing form-specific validation. Accepts session, API key, or service token auth. For API key / service token callers without an explicit user attribution, the.", "operationId": "EvidenceFormsController_uploadSubmission_v1", "parameters": [ { @@ -22194,9 +22192,9 @@ "metadata": { "title": "Upload a file as an evidence submission | Comp AI API", "sidebarTitle": "Upload a file as an evidence submission", - "description": "Upload a file as an evidence submission in Comp AI. Collect, review, upload, and export structured evidence submissions for compliance tasks and document.", + "description": "Upload a PDF or image file and create a submission for the given form type, bypassing form-specific validation. Accepts session, API key, or service token.", "og:title": "Upload a file as an evidence submission | Comp AI API", - "og:description": "Upload a file as an evidence submission in Comp AI. Collect, review, upload, and export structured evidence submissions for compliance tasks and document." + "og:description": "Upload a PDF or image file and create a submission for the given form type, bypassing form-specific validation. Accepts session, API key, or service token." } }, "x-speakeasy-mcp": { @@ -23685,14 +23683,6 @@ "type": "string" } }, - { - "name": "frameworkInstanceId", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, { "name": "formType", "required": true, @@ -23715,6 +23705,14 @@ ], "type": "string" } + }, + { + "name": "frameworkInstanceId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } } ], "responses": { @@ -28552,7 +28550,41 @@ }, "AnswerSingleQuestionDto": { "type": "object", - "properties": {} + "properties": { + "question": { + "type": "string", + "description": "Security questionnaire question to answer.", + "example": "Do you encrypt customer data at rest?" + }, + "questionIndex": { + "type": "number", + "description": "Zero-based index of this question in the questionnaire or page batch.", + "example": 0, + "minimum": 0 + }, + "totalQuestions": { + "type": "number", + "description": "Total number of questions in the current questionnaire or page batch.", + "example": 12, + "minimum": 1 + }, + "organizationId": { + "type": "string", + "description": "Organization ID for validation. The API uses the authenticated active organization and overwrites this value server-side.", + "example": "org_abc123" + }, + "questionnaireId": { + "type": "string", + "description": "Optional questionnaire record to save the generated answer into. Omit for webpage-only answer generation.", + "example": "qst_abc123" + } + }, + "required": [ + "question", + "questionIndex", + "totalQuestions", + "organizationId" + ] }, "SaveAnswerDto": { "type": "object", @@ -29652,4 +29684,4 @@ } } } -} +} \ No newline at end of file diff --git a/turbo.json b/turbo.json index fd11a464c6..c3141527b8 100644 --- a/turbo.json +++ b/turbo.json @@ -21,6 +21,7 @@ "AUTH_SECRET", "AUTH_TRUSTED_ORIGINS", "BETTER_AUTH_SECRET", + "COMP_EXTENSION_TRUSTED_ORIGINS", "DATABASE_URL", "DISCORD_WEBHOOK_URL", "DUB_API_KEY", From 16487c1e34c7f7eeda448dcb0971ad5d8e143672 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 27 Jul 2026 11:23:46 -0400 Subject: [PATCH 2/4] fix(security-questionnaire-extension): address review findings - Use a closed shadow root for the domain confirmation dialog so the host page cannot reach its controls. - Escape leading formula characters when building Sheets paste TSV, so answers starting with "-" stay text instead of being evaluated. - Restrict the wildcard trusted-origin match to HTTPS; explicit localhost entries are unaffected. - Stop generate-all from overwriting approved or manually edited answers. - Hold the inline preview locally so concurrent generates cannot render a result into another field's preview. - Finalize queue item status when single generation fails, instead of leaving the item stuck in "generating". - Implement the Google Docs footer copy action, which had no handler, and label/enable the button to match. Adds coverage for the paste plan, batch candidate selection, docs footer, clipboard text, and the origin policy. --- apps/api/src/auth/origin-policy.spec.ts | 49 ++++++++ apps/api/src/auth/origin-policy.ts | 3 + .../src/entrypoints/content.ts | 20 +++- .../src/entrypoints/sidepanel/main.ts | 18 +++ .../entrypoints/sidepanel/surface-ui.test.ts | 37 ++++++ .../src/entrypoints/sidepanel/surface-ui.ts | 11 +- .../src/lib/answers-clipboard.test.ts | 45 ++++++++ .../src/lib/answers-clipboard.ts | 15 +++ .../lib/background/batch-generation.test.ts | 109 ++++++++++++++++++ .../src/lib/background/batch-generation.ts | 18 ++- .../src/lib/background/queue-actions.ts | 24 +++- .../src/lib/dom/content-dialog.ts | 4 +- .../src/lib/sheets-paste-plan.test.ts | 39 +++++++ .../src/lib/sheets-paste-plan.ts | 12 +- 14 files changed, 385 insertions(+), 19 deletions(-) create mode 100644 apps/api/src/auth/origin-policy.spec.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/surface-ui.test.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/answers-clipboard.test.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/answers-clipboard.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.test.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.test.ts diff --git a/apps/api/src/auth/origin-policy.spec.ts b/apps/api/src/auth/origin-policy.spec.ts new file mode 100644 index 0000000000..fb9f7ca8e3 --- /dev/null +++ b/apps/api/src/auth/origin-policy.spec.ts @@ -0,0 +1,49 @@ +import { isStaticTrustedOrigin } from './origin-policy'; + +describe('isStaticTrustedOrigin', () => { + const originalTrustedOrigins = process.env.AUTH_TRUSTED_ORIGINS; + + beforeEach(() => { + delete process.env.AUTH_TRUSTED_ORIGINS; + }); + + afterAll(() => { + if (originalTrustedOrigins === undefined) { + delete process.env.AUTH_TRUSTED_ORIGINS; + return; + } + process.env.AUTH_TRUSTED_ORIGINS = originalTrustedOrigins; + }); + + it('trusts HTTPS subdomains of the wildcard domains', () => { + expect(isStaticTrustedOrigin('https://anything.trycomp.ai')).toBe(true); + expect(isStaticTrustedOrigin('https://anything.staging.trycomp.ai')).toBe(true); + expect(isStaticTrustedOrigin('https://anything.trust.inc')).toBe(true); + expect(isStaticTrustedOrigin('https://trust.inc')).toBe(true); + }); + + it('does not extend the wildcard match to plain HTTP', () => { + expect(isStaticTrustedOrigin('http://anything.trycomp.ai')).toBe(false); + expect(isStaticTrustedOrigin('http://anything.staging.trycomp.ai')).toBe(false); + expect(isStaticTrustedOrigin('http://anything.trust.inc')).toBe(false); + expect(isStaticTrustedOrigin('http://trust.inc')).toBe(false); + }); + + it('still trusts the explicitly listed http localhost origins', () => { + expect(isStaticTrustedOrigin('http://localhost:3000')).toBe(true); + expect(isStaticTrustedOrigin('http://localhost:3333')).toBe(true); + }); + + it('honours an explicit AUTH_TRUSTED_ORIGINS list', () => { + process.env.AUTH_TRUSTED_ORIGINS = 'http://localhost:4000'; + expect(isStaticTrustedOrigin('http://localhost:4000')).toBe(true); + expect(isStaticTrustedOrigin('http://localhost:3000')).toBe(false); + }); + + it('rejects unrelated and malformed origins', () => { + expect(isStaticTrustedOrigin('https://trycomp.ai.evil.example')).toBe(false); + expect(isStaticTrustedOrigin('https://nottrust.inc')).toBe(false); + expect(isStaticTrustedOrigin('not-a-url')).toBe(false); + expect(isStaticTrustedOrigin('')).toBe(false); + }); +}); diff --git a/apps/api/src/auth/origin-policy.ts b/apps/api/src/auth/origin-policy.ts index 207c6019fb..30268bd6dd 100644 --- a/apps/api/src/auth/origin-policy.ts +++ b/apps/api/src/auth/origin-policy.ts @@ -89,6 +89,9 @@ export function isStaticTrustedOrigin(origin: string): boolean { try { const url = new URL(origin); + // Only the explicit DEFAULT_TRUSTED_ORIGINS entries above may be plain + // HTTP (localhost). The wildcard suffix match is HTTPS-only. + if (url.protocol !== 'https:') return false; return ( url.hostname.endsWith('.trycomp.ai') || url.hostname.endsWith('.staging.trycomp.ai') || diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/content.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/content.ts index 4067ded997..3934639f7d 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/content.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/content.ts @@ -128,8 +128,11 @@ async function handleSingleGenerate(fieldId: string): Promise { setInlineButtonState(button, 'busy'); activePreview?.close(); - activePreview = new InlinePreview(button); - activePreview.showLoading(candidate.question); + // Held locally so a generate started later cannot render its result into + // this field's preview, or close a preview it no longer owns. + const preview = new InlinePreview(button); + activePreview = preview; + preview.showLoading(candidate.question); try { const queue = await syncDetectedQuestions(); @@ -139,11 +142,15 @@ async function handleSingleGenerate(fieldId: string): Promise { itemId: fieldId, }); if (!isItemResponse(response)) throw new Error(getResponseError(response)); - activePreview.showResult(response.item, { + if (activePreview !== preview) { + setInlineButtonState(button, 'idle'); + return; + } + preview.showResult(response.item, { onDismiss: () => setInlineButtonState(button, 'idle'), onRegenerate: () => void handleSingleGenerate(fieldId), onInsert: (answer) => { - void insertInlineAnswer({ queue, item: response.item, answer, button }) + void insertInlineAnswer({ queue, item: response.item, answer, button, preview }) .catch((error: unknown) => { window.alert(error instanceof Error ? error.message : 'Unable to insert answer'); setInlineButtonState(button, 'idle'); @@ -151,7 +158,7 @@ async function handleSingleGenerate(fieldId: string): Promise { }, }); } catch (error) { - activePreview?.close(); + if (activePreview === preview) preview.close(); window.alert(error instanceof Error ? error.message : 'Unable to generate answer'); setInlineButtonState(button, 'idle'); } @@ -162,6 +169,7 @@ async function insertInlineAnswer(params: { item: QuestionQueueItem; answer: string; button: HTMLElement; + preview: InlinePreview; }): Promise { const editResponse = await sendRuntimeMessage({ type: 'comp:edit-queue-item', @@ -176,7 +184,7 @@ async function insertInlineAnswer(params: { itemId: params.item.id, }); if (!isCountResponse(response)) throw new Error(getResponseError(response)); - activePreview?.close(); + params.preview.close(); setInlineButtonState(params.button, 'inserted'); } diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/main.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/main.ts index 464eefeb49..986c514df8 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/main.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/main.ts @@ -7,6 +7,7 @@ import { isPanelStateResponse, isQueueResponse, } from '../../lib/response-guards'; +import { buildAnswersClipboardText } from '../../lib/answers-clipboard'; import type { DomainConfirmationRequest, PanelState } from '../../lib/types'; import { bindAnswerAutosave, @@ -119,6 +120,7 @@ async function handleAction(target: HTMLElement): Promise { if (action === 'generate-all') await runQueueAction({ type: 'comp:generate-all' }); if (action === 'approve-all') await runQueueAction({ type: 'comp:approve-all-generated' }); if (action === 'insert-approved') await handleInsertApproved(); + if (action === 'copy-answers') await handleCopyAnswers(); if (action === 'change-sheet-mapping') { await handleSheetMappingChange({ state, @@ -254,6 +256,22 @@ async function closePanel(): Promise { await browser.sidePanel.close({ tabId: activeTabId }).catch(() => window.close()); } +async function handleCopyAnswers(): Promise { + await saveAllVisibleAnswers({ root: appRoot, tabId: activeTabId }); + await refreshState(); + const text = buildAnswersClipboardText(state?.queue.items ?? []); + if (!text) { + setStatus('Generate answers before copying.'); + return; + } + try { + await navigator.clipboard.writeText(text); + setStatus('Answers copied. Paste them into the document.'); + } catch { + setStatus('Unable to copy answers to the clipboard.'); + } +} + function setStatus(message: string): void { statusMessage = message; render(message); diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/surface-ui.test.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/surface-ui.test.ts new file mode 100644 index 0000000000..d21e6ac5a7 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/surface-ui.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { footerAction, footerButtonLabel, footerDisabled } from './surface-ui'; + +describe('docs footer', () => { + it('offers the copy action', () => { + expect(footerAction('docs')).toBe('copy-answers'); + }); + + it('is enabled once answers exist', () => { + expect(footerDisabled({ approved: 0, answerCount: 3, surface: 'docs' })).toBe(''); + }); + + it('stays disabled while there is nothing to copy', () => { + expect(footerDisabled({ approved: 0, answerCount: 0, surface: 'docs' })).toBe('disabled'); + }); + + it('does not require approvals, which docs has no path to collect', () => { + expect(footerDisabled({ approved: 0, answerCount: 1, surface: 'docs' })).toBe(''); + }); + + it('labels the button as a copy rather than an insert', () => { + expect(footerButtonLabel({ approved: 0, answerCount: 2, surface: 'docs' })) + .toBe('Copy 2 answers'); + expect(footerButtonLabel({ approved: 0, answerCount: 0, surface: 'docs' })) + .toBe('Generate answers to copy'); + }); +}); + +describe('generic footer', () => { + it('still gates on approvals', () => { + expect(footerAction('generic')).toBe('insert-approved'); + expect(footerDisabled({ approved: 0, answerCount: 5, surface: 'generic' })).toBe('disabled'); + expect(footerDisabled({ approved: 2, answerCount: 5, surface: 'generic' })).toBe(''); + expect(footerButtonLabel({ approved: 2, answerCount: 5, surface: 'generic' })) + .toBe('Insert 2 approved'); + }); +}); diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/surface-ui.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/surface-ui.ts index 6109edb818..8aa08ea645 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/surface-ui.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/surface-ui.ts @@ -19,6 +19,11 @@ export function footerButtonLabel(params: { answerCount: number; surface: Surface; }): string { + if (params.surface === 'docs') { + return params.answerCount > 0 + ? `Copy ${params.answerCount} answers` + : 'Generate answers to copy'; + } if (params.surface === 'sheets') { if (extensionConfig.googleSheetsApiEnabled) { return params.approved > 0 @@ -34,7 +39,7 @@ export function footerButtonLabel(params: { } export function footerAction(surface: Surface): string { - return surface === 'docs' ? 'copy-sheet-answers' : 'insert-approved'; + return surface === 'docs' ? 'copy-answers' : 'insert-approved'; } export function footerDisabled(params: { @@ -42,6 +47,8 @@ export function footerDisabled(params: { answerCount: number; surface: Surface; }): string { - if (params.surface === 'docs') return 'disabled'; + // Docs has no insertion path, so the footer copies instead of inserting and + // gates on generated answers rather than approvals. + if (params.surface === 'docs') return params.answerCount === 0 ? 'disabled' : ''; return params.approved === 0 ? 'disabled' : ''; } diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/answers-clipboard.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/answers-clipboard.test.ts new file mode 100644 index 0000000000..2c0d33f294 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/answers-clipboard.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { buildAnswersClipboardText } from './answers-clipboard'; +import type { QuestionQueueItem } from './types'; + +function item(overrides: Partial): QuestionQueueItem { + return { + id: 'field-1', + fieldId: 'field-1', + question: 'Do you encrypt data at rest?', + value: '', + isEmpty: true, + tag: 'textarea', + status: 'generated', + answer: 'Yes.', + confidence: 'high', + sources: [], + edited: false, + createdAt: 0, + updatedAt: 0, + ...overrides, + }; +} + +describe('buildAnswersClipboardText', () => { + it('pairs each question with its answer', () => { + const text = buildAnswersClipboardText([ + item({ id: 'a', question: 'Q1', answer: 'A1' }), + item({ id: 'b', question: 'Q2', answer: 'A2' }), + ]); + expect(text).toBe('Q1\nA1\n\nQ2\nA2'); + }); + + it('skips items with no answer yet', () => { + const text = buildAnswersClipboardText([ + item({ id: 'a', question: 'Q1', answer: null, status: 'pending' }), + item({ id: 'b', question: 'Q2', answer: ' ' }), + item({ id: 'c', question: 'Q3', answer: 'A3' }), + ]); + expect(text).toBe('Q3\nA3'); + }); + + it('returns an empty string when nothing is generated', () => { + expect(buildAnswersClipboardText([])).toBe(''); + }); +}); diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/answers-clipboard.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/answers-clipboard.ts new file mode 100644 index 0000000000..061cacc3de --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/answers-clipboard.ts @@ -0,0 +1,15 @@ +import type { QuestionQueueItem } from './types'; + +/** + * Google Docs has no reliable programmatic insertion path, so the side panel + * hands the reviewed answers back to the user as clipboard text instead. + */ +export function buildAnswersClipboardText(items: QuestionQueueItem[]): string { + return items + .flatMap((item) => { + const answer = item.answer?.trim(); + if (!answer) return []; + return [`${item.question.trim()}\n${answer}`]; + }) + .join('\n\n'); +} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.test.ts new file mode 100644 index 0000000000..e033222210 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { QuestionQueueItem, QueueStatus, TabQuestionQueue } from '../types'; + +const generateAnswer = vi.fn(); +vi.mock('../api', () => ({ + generateAnswer: (...args: unknown[]) => generateAnswer(...args), +})); + +const { generateQueueItemsInBatches } = await import('./batch-generation'); + +function item(overrides: Partial & { id: string }): QuestionQueueItem { + return { + fieldId: overrides.id, + question: `Question ${overrides.id}`, + value: '', + isEmpty: true, + tag: 'textarea', + status: 'pending', + answer: null, + confidence: null, + sources: [], + edited: false, + createdAt: 0, + updatedAt: 0, + ...overrides, + }; +} + +function queueWith(items: QuestionQueueItem[]): TabQuestionQueue { + return { + tabId: 1, + url: 'https://vendor.example/security', + host: 'vendor.example', + surface: 'generic', + organizationId: 'org_a', + items, + updatedAt: 0, + } as TabQuestionQueue; +} + +async function runBatch(items: QuestionQueueItem[]): Promise { + return generateQueueItemsInBatches({ + auth: { selectedOrganizationId: 'org_a' }, + concurrency: 1, + queue: queueWith(items), + saveQueue: async () => undefined, + }); +} + +function generatedIds(): string[] { + return generateAnswer.mock.calls.map((call) => { + const [params] = call as [{ question: string }]; + return params.question.replace('Question ', ''); + }); +} + +beforeEach(() => { + generateAnswer.mockReset(); + generateAnswer.mockImplementation(async (params: { question: string }) => ({ + questionIndex: 0, + question: params.question, + answer: 'generated answer', + sources: [], + })); +}); + +describe('generateQueueItemsInBatches', () => { + it('generates for pending, generated and flagged items', async () => { + await runBatch([ + item({ id: 'a', status: 'pending' }), + item({ id: 'b', status: 'generated', answer: 'old' }), + item({ id: 'c', status: 'flagged' as QueueStatus }), + ]); + expect(generatedIds().sort()).toEqual(['a', 'b', 'c']); + }); + + it('does not overwrite an answer the user edited', async () => { + const result = await runBatch([ + item({ id: 'a', status: 'pending' }), + item({ id: 'edited', status: 'generated', answer: 'my wording', edited: true }), + ]); + expect(generatedIds()).toEqual(['a']); + expect(result.items.find((entry) => entry.id === 'edited')?.answer).toBe('my wording'); + }); + + it('does not regenerate approved answers', async () => { + const result = await runBatch([ + item({ id: 'a', status: 'pending' }), + item({ id: 'approved', status: 'approved', answer: 'signed off' }), + ]); + expect(generatedIds()).toEqual(['a']); + const approved = result.items.find((entry) => entry.id === 'approved'); + expect(approved?.status).toBe('approved'); + expect(approved?.answer).toBe('signed off'); + }); + + it('skips already inserted items', async () => { + await runBatch([item({ id: 'inserted', status: 'inserted', answer: 'done' })]); + expect(generateAnswer).not.toHaveBeenCalled(); + }); + + it('flags an item when generation fails instead of leaving it generating', async () => { + generateAnswer.mockRejectedValueOnce(new Error('API is down')); + const result = await runBatch([item({ id: 'a', status: 'pending' })]); + const updated = result.items.find((entry) => entry.id === 'a'); + expect(updated?.status).toBe('flagged'); + expect(updated?.error).toBe('API is down'); + }); +}); diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.ts index adceecddeb..83f8da02e5 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.ts @@ -7,20 +7,32 @@ import { import type { GeneratedAnswer, QuestionQueueItem, + QueueStatus, TabQuestionQueue, } from '../types'; const DEFAULT_GENERATE_CONCURRENCY = 4; +// Generate-all must not overwrite work the user already curated: approved +// answers and manual edits are only replaced by an explicit single regenerate. +const SKIPPED_BATCH_STATUSES = new Set([ + 'inserted', + 'generating', + 'approved', +]); + +function isBatchCandidate(item: QuestionQueueItem): boolean { + if (SKIPPED_BATCH_STATUSES.has(item.status)) return false; + return !item.edited; +} + export async function generateQueueItemsInBatches(params: { auth: { selectedOrganizationId: string }; concurrency?: number; queue: TabQuestionQueue; saveQueue(queue: TabQuestionQueue): Promise; }): Promise { - const candidates = params.queue.items.filter( - (item) => item.status !== 'inserted' && item.status !== 'generating', - ); + const candidates = params.queue.items.filter(isBatchCandidate); if (candidates.length === 0) return params.queue; let queue = markCandidatesGenerating({ diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-actions.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-actions.ts index a049a7b6c7..44585e2a7d 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-actions.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-actions.ts @@ -208,12 +208,24 @@ async function generateAnswerForItem(params: { questionIndex: number; totalQuestions: number; }): Promise { - return generateAnswer({ - organizationId: params.auth.selectedOrganizationId, - question: params.question, - questionIndex: params.questionIndex, - totalQuestions: params.totalQuestions, - }); + try { + return await generateAnswer({ + organizationId: params.auth.selectedOrganizationId, + question: params.question, + questionIndex: params.questionIndex, + totalQuestions: params.totalQuestions, + }); + } catch (error) { + // Return a failed answer rather than throwing, so the caller still writes a + // terminal status and the item cannot be stranded in `generating`. + return { + questionIndex: params.questionIndex, + question: params.question, + answer: null, + sources: [], + error: error instanceof Error ? error.message : 'Unable to generate answer.', + }; + } } async function getInsertConfirmation( diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/content-dialog.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/content-dialog.ts index 6a20caa696..457f1f7081 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/content-dialog.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/content-dialog.ts @@ -7,7 +7,9 @@ export function confirmDomainInContent( const host = document.createElement('div'); host.dataset.compSqRoot = 'true'; document.body.appendChild(host); - const shadow = host.attachShadow({ mode: 'open' }); + // Closed so the host page cannot reach into the dialog and drive the + // confirmation controls on the user's behalf. + const shadow = host.attachShadow({ mode: 'closed' }); shadow.innerHTML = ` diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.test.ts new file mode 100644 index 0000000000..1914c19864 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { buildSheetPastePlan } from './sheets-paste-plan'; + +function planFor(answer: string): string { + const plan = buildSheetPastePlan([{ fieldId: 'sheet:0:2:3', answer }]); + if (!plan) throw new Error('Expected a paste plan'); + return plan.tsv; +} + +describe('buildSheetPastePlan', () => { + it('keeps ordinary answers untouched', () => { + expect(planFor('We encrypt customer data at rest.')).toBe( + 'We encrypt customer data at rest.', + ); + }); + + it.each(['=', '+', '-', '@'])( + 'prevents Sheets from evaluating answers starting with "%s"', + (prefix) => { + expect(planFor(`${prefix}Yes, we do`)).toBe(`'${prefix}Yes, we do`); + }, + ); + + it('neutralizes a leading formula character after whitespace is trimmed', () => { + expect(planFor(' =SUM(A1:A2)')).toBe("'=SUM(A1:A2)"); + }); + + it('flattens newlines and tabs into single cells', () => { + expect(planFor('First line\nsecond\tline')).toBe('First line second line'); + }); + + it('leaves empty cells empty rather than quoting them', () => { + const plan = buildSheetPastePlan([ + { fieldId: 'sheet:0:1:1', answer: 'yes' }, + { fieldId: 'sheet:0:1:3', answer: 'no' }, + ]); + expect(plan?.tsv).toBe('yes\t\tno'); + }); +}); diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.ts index 14346531f3..5673835214 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.ts @@ -92,5 +92,15 @@ function columnName(column: number): string { } function escapeTsvCell(value: string): string { - return value.replace(/\r?\n/g, ' ').replace(/\t/g, ' ').trim(); + const flattened = value.replace(/\r?\n/g, ' ').replace(/\t/g, ' ').trim(); + return neutralizeFormula(flattened); +} + +// Sheets evaluates a pasted cell as a formula when it starts with one of these. +// Answers legitimately start with "-" (bullet lists), so prefix rather than strip. +const FORMULA_PREFIXES = ['=', '+', '-', '@']; + +function neutralizeFormula(value: string): string { + if (!FORMULA_PREFIXES.includes(value[0])) return value; + return `'${value}`; } From 7dd836065694a659239849a73a8bdf3c71fbc8a3 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 27 Jul 2026 13:57:39 -0400 Subject: [PATCH 3/4] fix(security-questionnaire-extension): make Google OAuth optional in release, add docs The release workflow required a Google OAuth client id and failed the build when the manifest had none, so the extension could not ship without direct Google Sheets API access. Treat it as optional: verify the manifest only when the secret is set, and drop it from the required-secret check. Adds the extension documentation page and links it from the docs nav. --- ...curity-questionnaire-extension-release.yml | 15 +++- packages/docs/docs.json | 1 + .../docs/security-questionnaire-extension.mdx | 75 +++++++++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 packages/docs/security-questionnaire-extension.mdx diff --git a/.github/workflows/security-questionnaire-extension-release.yml b/.github/workflows/security-questionnaire-extension-release.yml index aa403a0fe8..c82e5fea50 100644 --- a/.github/workflows/security-questionnaire-extension-release.yml +++ b/.github/workflows/security-questionnaire-extension-release.yml @@ -96,6 +96,7 @@ jobs: env: EXTENSION_DIR: ${{ env.EXTENSION_DIR }} VERSION: ${{ steps.version.outputs.version }} + OAUTH_CLIENT_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_GOOGLE_OAUTH_CLIENT_ID }} run: | node -e " const fs = require('fs'); @@ -104,9 +105,14 @@ jobs: if (manifest.version !== process.env.VERSION) { throw new Error(\`Expected manifest version ${process.env.VERSION}, got ${manifest.version}\`); } - if (!manifest.oauth2?.client_id) { - throw new Error('Missing oauth2.client_id in production manifest'); + // Google OAuth is optional. When the client id secret is set the + // manifest must carry it; without it the extension ships with the + // guided Sheets paste flow instead of direct Sheets API writes. + const wantsOAuth = Boolean(process.env.OAUTH_CLIENT_ID); + if (wantsOAuth && !manifest.oauth2?.client_id) { + throw new Error('OAuth client id was provided but is missing from the built manifest'); } + console.log('Google Sheets API insertion:', wantsOAuth ? 'enabled' : 'disabled (guided paste)'); " - name: Package extension @@ -129,10 +135,11 @@ jobs: REFRESH_TOKEN: ${{ secrets.CHROME_WEB_STORE_REFRESH_TOKEN }} PUBLISHER_ID: ${{ secrets.CHROME_WEB_STORE_PUBLISHER_ID }} EXTENSION_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_ID }} - OAUTH_CLIENT_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_GOOGLE_OAUTH_CLIENT_ID }} run: | + # SECURITY_QUESTIONNAIRE_EXTENSION_GOOGLE_OAUTH_CLIENT_ID is intentionally + # not required — it only enables direct Google Sheets API insertion. missing=0 - for name in CLIENT_ID CLIENT_SECRET REFRESH_TOKEN PUBLISHER_ID EXTENSION_ID OAUTH_CLIENT_ID; do + for name in CLIENT_ID CLIENT_SECRET REFRESH_TOKEN PUBLISHER_ID EXTENSION_ID; do if [ -z "${!name}" ]; then echo "::error::Missing required secret: $name" missing=1 diff --git a/packages/docs/docs.json b/packages/docs/docs.json index 96856d2fb6..8cd5c9ec2b 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -72,6 +72,7 @@ "browser-automations", "device-agent", "security-questionnaire", + "security-questionnaire-extension", "penetration-tests", "mcp-server", { diff --git a/packages/docs/security-questionnaire-extension.mdx b/packages/docs/security-questionnaire-extension.mdx new file mode 100644 index 0000000000..8fe5e91fa8 --- /dev/null +++ b/packages/docs/security-questionnaire-extension.mdx @@ -0,0 +1,75 @@ +--- +title: 'Security Questionnaire Extension' +description: 'Answer security questionnaires directly on the vendor''s page — the Chrome extension drafts answers from your Comp AI policies and inserts the ones you approve.' +--- + +The Comp AI Security Questionnaire extension brings questionnaire answering to wherever the questionnaire actually lives. Instead of exporting a vendor's portal into a spreadsheet, answering it in Comp AI, and copying everything back, the extension finds the questions on the page you're already on, drafts answers from your own policies, and inserts the ones you approve. + +It is a companion to [Security Questionnaire](/security-questionnaire) in the app, and draws on the same knowledge: your policies, context entries, knowledge base documents, and questions you've answered before. + +## Why it's useful + +- **It works where the questionnaire is.** Vendor portals, plain web forms, Google Forms and Google Sheets — no export/import round trip. +- **It drafts from what you've already written.** Answers come from your organization's policies and prior answers, not from generic boilerplate. +- **Nothing is inserted without you.** Every draft is editable and every insertion is an explicit action. +- **It tells you when it doesn't know.** Questions with no supporting material are flagged **Needs review** rather than answered with a guess. + +## Install + +1. Install **Comp AI Security Questionnaire** from the Chrome Web Store. +2. Pin it to your toolbar so the icon is always visible. +3. Sign in to Comp AI in the same browser. The extension uses your existing session — there is no separate login. + +## Answering a questionnaire + +1. Open the questionnaire in your browser. +2. Click the Comp AI icon, then open the side panel. Every question the extension detected is listed there. +3. The first time you use the extension on a new site, confirm which Comp AI workspace applies. This keeps answers from one customer's workspace out of another's. +4. Choose **Generate** for a single question, or generate the whole list at once. +5. Review each draft. Edit anything you want to change, then **Approve** it. +6. Choose **Insert approved** to write your approved answers back into the page. + +Each answer shows a confidence level and the number of sources behind it. Low-confidence and **Needs review** answers are worth a closer look before you approve them. + + + Generating again over the whole list will not overwrite answers you have + edited or approved. To replace one of those, regenerate that specific + question. + + +## Google Sheets + +When a questionnaire arrives as a spreadsheet, the extension asks which columns to use — a question column and an answer column, plus the row range. Confirm the mapping once and it is remembered for that sheet. + +Approved answers are then written into the answer column, in the rows the questions came from. + +## Google Forms and Google Docs + +**Google Forms** works like any other web form: an inline Comp AI button appears next to each answer field, and approved answers are inserted directly. + +**Google Docs** does not support reliable automated insertion, so the extension collects your approved answers and copies them to the clipboard for you to paste in. + +## Switching workspaces + +The active workspace is shown at the top of the side panel. If you switch it, any generated-but-not-yet-inserted drafts are cleared and you will be asked to confirm the site again. This is deliberate — it prevents one organization's answers from being pasted into another organization's questionnaire. + +## Permissions + +The extension asks for access to the websites you visit. Security questionnaires are hosted on many different vendor domains and there is no fixed list to allow, so it needs to be able to look at the page you open. + +In practice: + +- It examines pages to locate questionnaire fields. +- The only page content sent to Comp AI is the text of a question you ask it to answer. +- It does not send page contents or browsing history. +- Answers are written into the page only when you choose to insert them. + +## Troubleshooting + +**No questions detected.** Not every layout is recognized. Use **Refresh scan** in the side panel after the page has fully loaded. Questions rendered inside a nested frame may not be reachable. + +**"Extension context invalidated."** The extension was reloaded or updated while the tab was open. Refresh the tab. + +**An answer says "Needs review — no knowledge-base match."** Comp AI has nothing to base that answer on. Add the relevant policy or a knowledge base document, or answer that question manually. + +**Sign-in problems.** Confirm you are signed in to Comp AI in the same browser profile, then reopen the side panel. From 8d30165cc6fba7468c63a703388bd5c5d707e4ab Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 27 Jul 2026 14:18:08 -0400 Subject: [PATCH 4/4] fix(security-questionnaire-extension): resolve remaining review findings Detection and data correctness: - Stop dropping questionnaire rows whose text looks like a column label. Any cell under 48 characters containing "control", "requirement" or "question" was discarded, which silently lost common questions such as "Are security controls documented?". - Ignore answer-style statements in the keyword fallback so filled-in answers are not re-detected as new questions. - Reject row/column 0 when parsing sheet targets; A1 notation is 1-based. - Reject negative sheet ids when building formatting requests. Concurrency and data loss: - Apply batch results to the stored queue rather than a snapshot, and skip items the user edited or approved while generation was in flight. - Serialize storage read-merge-writes so concurrent updates are not lost. - Keep the sheet mapping dialog populated when a save fails. - Surface autosave failures instead of dropping them. Robustness: - Guard JSON parsing in the API client, GViz table parsing and gid decoding. - Let one Sheets endpoint failure fall through to the next. - Fall back to local detection when the background message fails. - Add Vary headers to CORS responses, including rejected origins. - Trim organization ids and reject empty question ids and whitespace-only approvals. - Tighten isItemResponse to validate the queue it claims to carry. Cleanup: drop the unused ReviewPanel and the unhandled comp:generate-visible-page message, dedupe getHost and surface validation. --- .../src/auth/cors-origin.middleware.spec.ts | 28 ++++ apps/api/src/auth/cors-origin.middleware.ts | 13 ++ .../src/entrypoints/popup/main.ts | 9 +- .../src/entrypoints/sidepanel/answer-edits.ts | 13 +- .../sidepanel/content-collector.ts | 2 +- .../src/entrypoints/sidepanel/main.ts | 2 +- .../sidepanel/sheet-mapping-actions.ts | 28 ++-- .../security-questionnaire-ext/src/lib/api.ts | 8 +- .../lib/background/batch-generation.test.ts | 37 +++++ .../src/lib/background/batch-generation.ts | 31 +++- .../background/google-sheets-formatting.ts | 2 +- .../src/lib/background/queue-actions.ts | 1 + .../src/lib/content-messaging.ts | 2 - .../src/lib/dom/field-detection.test.ts | 11 ++ .../src/lib/dom/field-detection.ts | 7 + .../src/lib/dom/review-panel.ts | 142 ------------------ .../src/lib/dom/sheets-detection.ts | 11 +- .../src/lib/dom/sheets-dom.ts | 7 +- .../src/lib/dom/sheets-question-cells.ts | 13 +- .../lib/dom/sheets-question-header.test.ts | 49 ++++++ .../src/lib/dom/sheets-runtime.ts | 4 +- .../src/lib/dom/sheets-table.ts | 7 +- .../src/lib/message-utils.ts | 1 + .../src/lib/queue-approval.test.ts | 24 +++ .../src/lib/queue-approval.ts | 8 +- .../src/lib/response-guards.ts | 7 +- .../src/lib/scan-debug.ts | 11 +- .../src/lib/sheets-paste-plan.test.ts | 5 + .../src/lib/sheets-paste-plan.ts | 11 +- .../src/lib/storage.ts | 47 ++++-- 30 files changed, 325 insertions(+), 216 deletions(-) delete mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/review-panel.ts create mode 100644 apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-question-header.test.ts diff --git a/apps/api/src/auth/cors-origin.middleware.spec.ts b/apps/api/src/auth/cors-origin.middleware.spec.ts index eb99a69684..391b50bfb4 100644 --- a/apps/api/src/auth/cors-origin.middleware.spec.ts +++ b/apps/api/src/auth/cors-origin.middleware.spec.ts @@ -10,6 +10,7 @@ type MockResponse = Partial & { body?: unknown; headers: Record; statusCode?: number; + varied: string[]; }; const extensionOrigin = @@ -40,7 +41,12 @@ function createRequest(params: { function createResponse(): MockResponse { const response = { headers: {}, + varied: [], } as MockResponse; + response.vary = jest.fn().mockImplementation((field: string) => { + response.varied.push(field); + return response; + }); response.setHeader = jest .fn() .mockImplementation((name: string, value: string) => { @@ -181,6 +187,28 @@ describe('corsOriginMiddleware', () => { expect(response.headers['Access-Control-Allow-Origin']).toBe( 'https://app.trycomp.ai', ); + expect(response.varied).toContain('Origin'); expect(next).toHaveBeenCalled(); }); + + it('varies on origin even when the origin is rejected, so caches cannot mix responses', async () => { + jest.mocked(isTrustedOrigin).mockResolvedValue(false); + const request = createRequest({ + method: 'GET', + origin: 'https://evil.example', + path: '/v1/controls', + }); + const response = createResponse(); + const next = jest.fn(); + + runCors({ request, response, next }); + await flushPromises(); + + expect(response.headers['Access-Control-Allow-Origin']).toBeUndefined(); + expect(response.varied).toEqual([ + 'Origin', + 'Access-Control-Request-Headers', + 'Access-Control-Request-Method', + ]); + }); }); diff --git a/apps/api/src/auth/cors-origin.middleware.ts b/apps/api/src/auth/cors-origin.middleware.ts index 2aaa6e3a1c..c95b970d1b 100644 --- a/apps/api/src/auth/cors-origin.middleware.ts +++ b/apps/api/src/auth/cors-origin.middleware.ts @@ -12,6 +12,18 @@ function getHeaderValue(value: string | string[] | undefined): string | null { return null; } +/** + * These responses differ per origin and per requested headers/method, so any + * cache in front of the API must key on them. This is set for every request + * the middleware sees, including rejected ones — otherwise a cached response + * without CORS headers can be replayed to an allowed origin. + */ +function setVaryHeaders(response: Response): void { + response.vary('Origin'); + response.vary('Access-Control-Request-Headers'); + response.vary('Access-Control-Request-Method'); +} + function setCorsHeaders(params: { request: Request; response: Response; @@ -48,6 +60,7 @@ export function corsOriginMiddleware( response: Response, next: NextFunction, ): void { + setVaryHeaders(response); const origin = getHeaderValue(request.headers.origin); const requestedMethod = getHeaderValue( request.headers['access-control-request-method'], diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/popup/main.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/popup/main.ts index bd7c378d81..d108621d91 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/popup/main.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/popup/main.ts @@ -1,5 +1,6 @@ import { browser } from 'wxt/browser'; import { compMarkSvg } from '../../lib/brand'; +import { getHost } from '../sidepanel/active-tab'; import { getResponseError, isOkResponse, @@ -185,14 +186,6 @@ async function getActiveTab(): Promise { return tab ?? null; } -function getHost(url: string): string { - try { - return new URL(url).host; - } catch { - return 'current page'; - } -} - function escapeHtml(value: string): string { return value .replace(/&/g, '&') diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/answer-edits.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/answer-edits.ts index e846a9b7da..66b2fabbc8 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/answer-edits.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/answer-edits.ts @@ -3,11 +3,22 @@ import { browser } from 'wxt/browser'; export function bindAnswerAutosave(params: { root: ParentNode; tabId: number | null; + onError?(message: string): void; }): void { params.root.querySelectorAll('[data-answer-for]').forEach((element) => { if (!(element instanceof HTMLTextAreaElement)) return; element.addEventListener('change', () => { - void saveTextareaAnswer({ tabId: params.tabId, textarea: element }); + // A dropped autosave loses the user's edit, so it has to be visible + // rather than an unhandled rejection in the console. + void saveTextareaAnswer({ tabId: params.tabId, textarea: element }).catch( + (error: unknown) => { + params.onError?.( + error instanceof Error + ? `Could not save your edit: ${error.message}` + : 'Could not save your edit.', + ); + }, + ); }); }); } diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/content-collector.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/content-collector.ts index 0a73e0392a..8cae105ba3 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/content-collector.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/content-collector.ts @@ -18,7 +18,7 @@ export async function collectQuestions(tabId: number): Promise { const response = await sendCollectMessage(tabId); if (isCountResponse(response)) { if (response.count > 0) return ''; - return sheetScan?.message ?? formatDebugResponse(response); + return sheetScan?.message || formatDebugResponse(response); } if (!shouldRetryWithInjectedScript(response)) return getResponseError(response); diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/main.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/main.ts index 986c514df8..98120b2f89 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/main.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/main.ts @@ -87,7 +87,7 @@ function render(message = statusMessage): void { } function bindEvents(): void { - bindAnswerAutosave({ root: appRoot, tabId: activeTabId }); + bindAnswerAutosave({ root: appRoot, tabId: activeTabId, onError: setStatus }); appRoot.querySelectorAll('[data-action]').forEach((element) => { element.addEventListener('click', (event) => { const target = event.currentTarget; diff --git a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping-actions.ts b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping-actions.ts index 9322245a9e..68fe86a816 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping-actions.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/entrypoints/sidepanel/sheet-mapping-actions.ts @@ -25,20 +25,26 @@ export async function handleSheetMappingChange(params: { return; } - const mapping = params.state.queue.sheetMapping ?? + let mapping = params.state.queue.sheetMapping ?? createDefaultSheetMapping(identity); - const draft = await showSheetMappingDialog( - renderSheetMappingDialog(mapping), - ); - if (!draft) return; - const response = await browser.runtime.sendMessage({ - type: 'comp:set-sheet-mapping', - mapping: createManualSheetMapping({ identity, draft }), - }); - if (!isOkResponse(response)) { + // Reopen the dialog with what the user typed when the save fails, so a + // transient error does not throw away their column mapping. + for (;;) { + const draft = await showSheetMappingDialog( + renderSheetMappingDialog(mapping), + ); + if (!draft) return; + + const nextMapping = createManualSheetMapping({ identity, draft }); + const response = await browser.runtime.sendMessage({ + type: 'comp:set-sheet-mapping', + mapping: nextMapping, + }); + if (isOkResponse(response)) break; + params.setStatus(getResponseError(response)); - return; + mapping = nextMapping; } await params.refreshFromPage(); diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/api.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/api.ts index 9c588f653e..65110e6668 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/api.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/api.ts @@ -46,7 +46,13 @@ const GeneratedAnswerSchema = z.object({ async function readJson(response: Response): Promise { const text = await response.text(); if (!text) return null; - return JSON.parse(text); + try { + return JSON.parse(text); + } catch { + // Gateways and proxies answer with HTML on failure. Returning null lets the + // caller report the HTTP status instead of a bare SyntaxError. + return null; + } } function getErrorMessage(data: unknown, fallback: string): string { diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.test.ts index e033222210..e46aa1cb21 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.test.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.test.ts @@ -99,6 +99,43 @@ describe('generateQueueItemsInBatches', () => { expect(generateAnswer).not.toHaveBeenCalled(); }); + it('applies results to the stored queue, not a stale snapshot', async () => { + const items = [item({ id: 'a' }), item({ id: 'b' })]; + let stored = queueWith(items); + + // The user approves an answer in the side panel while generation runs. + generateAnswer.mockImplementationOnce(async (params: { question: string }) => { + stored = { + ...stored, + items: stored.items.map((entry) => + entry.id === 'b' + ? { ...entry, status: 'approved', answer: 'signed off', edited: true } + : entry, + ), + }; + return { + questionIndex: 0, + question: params.question, + answer: 'generated answer', + sources: [], + }; + }); + + const result = await generateQueueItemsInBatches({ + auth: { selectedOrganizationId: 'org_a' }, + concurrency: 1, + queue: stored, + loadQueue: async () => stored, + saveQueue: async (next) => { + stored = next; + }, + }); + + const b = result.items.find((entry) => entry.id === 'b'); + expect(b?.status).toBe('approved'); + expect(b?.answer).toBe('signed off'); + }); + it('flags an item when generation fails instead of leaving it generating', async () => { generateAnswer.mockRejectedValueOnce(new Error('API is down')); const result = await runBatch([item({ id: 'a', status: 'pending' })]); diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.ts index 83f8da02e5..35ed5a38cf 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/batch-generation.ts @@ -30,6 +30,7 @@ export async function generateQueueItemsInBatches(params: { auth: { selectedOrganizationId: string }; concurrency?: number; queue: TabQuestionQueue; + loadQueue?: () => Promise; saveQueue(queue: TabQuestionQueue): Promise; }): Promise { const candidates = params.queue.items.filter(isBatchCandidate); @@ -41,8 +42,22 @@ export async function generateQueueItemsInBatches(params: { }); await params.saveQueue(queue); - let writeQueue: Promise = Promise.resolve(); const indexedItems = queue.items; + // Generation is slow and the user keeps editing and approving while it runs. + // Each result is applied to the freshly stored queue inside a serialized + // section, so a long-running request cannot write back a stale snapshot. + let writeQueue: Promise = Promise.resolve(); + const commit = ( + apply: (current: TabQuestionQueue) => TabQuestionQueue, + ): Promise => { + writeQueue = writeQueue.then(async () => { + const current = params.loadQueue ? await params.loadQueue() : queue; + queue = apply(current); + await params.saveQueue(queue); + }); + return writeQueue; + }; + await runConcurrent({ concurrency: params.concurrency ?? DEFAULT_GENERATE_CONCURRENCY, items: candidates, @@ -54,10 +69,16 @@ export async function generateQueueItemsInBatches(params: { questionIndex, totalQuestions: indexedItems.length, }); - queue = applyGeneratedAnswer({ queue, itemId: item.id, answer }); - const snapshot = queue; - writeQueue = writeQueue.then(() => params.saveQueue(snapshot)); - await writeQueue; + await commit((current) => { + const existing = current.items.find((entry) => entry.id === item.id); + // We marked this item `generating`. If it no longer is, the user + // edited or approved it while the request was in flight — their + // version wins over the answer we just received. + if (existing && (existing.status !== 'generating' || existing.edited)) { + return current; + } + return applyGeneratedAnswer({ queue: current, itemId: item.id, answer }); + }); }, }); await writeQueue; diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-formatting.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-formatting.ts index 5c00508e19..9bc1bef582 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-formatting.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/google-sheets-formatting.ts @@ -50,7 +50,7 @@ export function buildSheetFormattingRequests(params: { targets: SheetApiTarget[]; }): SheetBatchUpdateRequest[] { const sheetId = Number(params.gid); - if (!Number.isSafeInteger(sheetId)) return []; + if (!Number.isSafeInteger(sheetId) || sheetId < 0) return []; return [ ...buildColumnWidthRequests({ sheetId, targets: params.targets }), diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-actions.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-actions.ts index 44585e2a7d..e669a687a6 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-actions.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/background/queue-actions.ts @@ -151,6 +151,7 @@ async function generateAll(tabId: number): Promise { const updated = await generateQueueItemsInBatches({ auth, queue, + loadQueue: () => requireQueue(tabId), saveQueue: saveQueueAndNotify, }); return { ok: true, count: updated.items.length, queue: updated }; diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/content-messaging.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/content-messaging.ts index 4ba4f3ae7e..f7b351eecf 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/content-messaging.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/content-messaging.ts @@ -5,7 +5,6 @@ export type ContentRequest = | { type: 'comp:collect-questions' } | { type: 'comp:ensure-inline-buttons' } | { type: 'comp:scan-visible-questions' } - | { type: 'comp:generate-visible-page' } | { type: 'comp:set-detection-enabled'; enabled: boolean } | { type: 'comp:insert-answers'; answers: InsertAnswerRequest[] } | { type: 'comp:focus-question'; fieldId: string }; @@ -21,7 +20,6 @@ export function parseContentRequest(value: unknown): ContentRequest | null { if (value.type === 'comp:collect-questions') return { type: value.type }; if (value.type === 'comp:ensure-inline-buttons') return { type: value.type }; if (value.type === 'comp:scan-visible-questions') return { type: value.type }; - if (value.type === 'comp:generate-visible-page') return { type: value.type }; if ( value.type === 'comp:set-detection-enabled' && typeof value.enabled === 'boolean' diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-detection.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-detection.test.ts index 04efa26f19..761d7c2a47 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-detection.test.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-detection.test.ts @@ -2,6 +2,17 @@ import { describe, expect, it } from 'vitest'; import { detectQuestionFields } from './field-detection'; describe('detectQuestionFields', () => { + it('does not treat an answer-style statement as a question', () => { + document.body.innerHTML = ` + + + `; + + const fields = detectQuestionFields(document, { visibleOnly: false }); + + expect(fields).toHaveLength(0); + }); + it('extracts a question from an associated label', () => { document.body.innerHTML = ` diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-detection.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-detection.ts index 0787e731eb..63b7e3d0bc 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-detection.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/field-detection.ts @@ -263,9 +263,16 @@ function isGenericAnswerLabel(value: string): boolean { return /^(answer|your answer|short answer|long answer|paragraph)$/i.test(value); } +// Answer text reuses the same vocabulary as the questions it answers, so the +// keyword fallback below would happily classify a filled-in answer as a new +// question. Statements announce themselves in the first few words. +const STATEMENT_OPENERS = + /^\s*(yes\b|no\b|n\/a\b|not applicable\b|we\s|we've|our\s|the (company|organization|organisation)\s|all\s|there (is|are)\s)/i; + function isQuestionnairePrompt(value: string): boolean { if (value.length < 6) return false; if (value.includes('?')) return true; + if (STATEMENT_OPENERS.test(value)) return false; if ( /^(are|can|confirm|describe|detail|do|does|explain|have|has|how|identify|is|list|outline|provide|what|where|which|who|will)\b/i .test(value) diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/review-panel.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/review-panel.ts deleted file mode 100644 index 9688bb76bd..0000000000 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/review-panel.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { BatchProgressItem, GeneratedAnswer } from '../types'; -import type { WritableField } from './field-detection'; -import { insertAnswerIntoField } from './field-actions'; - -export class ReviewPanel { - private root: HTMLElement; - - constructor() { - this.root = document.createElement('div'); - this.root.dataset.compSqRoot = 'true'; - document.body.appendChild(this.root); - } - - showLoading(question: string): void { - this.root.innerHTML = this.wrapPanel({ - title: 'Generating answer', - body: ` -
- Question -
${escapeHtml(question)}
-
-
Searching Comp AI knowledge base...
- `, - footer: this.closeButtonHtml(), - }); - this.bindClose(); - } - - showSingle(params: { - field: WritableField; - question: string; - result: GeneratedAnswer; - }): void { - const answer = params.result.answer; - this.root.innerHTML = this.wrapPanel({ - title: 'Review answer', - body: ` -
- Question -
${escapeHtml(params.question)}
-
-
- Generated answer -
${escapeHtml( - answer ?? params.result.error ?? 'No answer generated.', - )}
-
- `, - footer: ` - ${this.closeButtonHtml('Cancel')} - - `, - }); - this.bindClose(); - const insertButton = this.root.querySelector('[data-comp-sq-insert="true"]'); - insertButton?.addEventListener('click', () => { - if (!answer) return; - if (params.field instanceof HTMLElement && !isOverwriteAllowed(params.field)) { - return; - } - insertAnswerIntoField({ field: params.field, answer }); - this.close(); - }); - } - - showBatch(items: BatchProgressItem[]): void { - this.root.innerHTML = this.wrapPanel({ - title: 'Visible questions', - body: ` -
- ${items.map((item) => this.renderBatchItem(item)).join('')} -
- `, - footer: this.closeButtonHtml('Done'), - }); - this.bindClose(); - } - - close(): void { - this.root.remove(); - } - - private wrapPanel(params: { - title: string; - body: string; - footer: string; - }): string { - return ` -
-
-
${escapeHtml(params.title)}
- -
-
${params.body}
- -
- `; - } - - private renderBatchItem(item: BatchProgressItem): string { - const answer = item.answer - ? `
${escapeHtml(item.answer)}
` - : ''; - return ` -
-
${escapeHtml(item.question)}
- ${answer} -
${escapeHtml(item.error ?? item.status)}
-
- `; - } - - private closeButtonHtml(label = 'Close'): string { - return ``; - } - - private bindClose(): void { - this.root.querySelectorAll('[data-comp-sq-close="true"]').forEach((button) => { - button.addEventListener('click', () => this.close()); - }); - } -} - -function isOverwriteAllowed(field: HTMLElement): boolean { - const currentValue = - field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement - ? field.value - : field.textContent ?? ''; - if (currentValue.trim().length === 0) return true; - return window.confirm('This field already has text. Replace it with the generated answer?'); -} - -function escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-detection.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-detection.ts index 23f3b00a05..c9f2a09715 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-detection.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-detection.ts @@ -91,7 +91,12 @@ async function fetchTable(params: { url: string; parse: (text: string) => GvizTable | null; }): Promise { - const response = await params.fetcher(params.url, { credentials: 'include' }); - if (!response.ok) return null; - return params.parse(await response.text()); + try { + const response = await params.fetcher(params.url, { credentials: 'include' }); + if (!response.ok) return null; + return params.parse(await response.text()); + } catch { + // One endpoint failing must not abort the fallback chain. + return null; + } } diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-dom.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-dom.ts index cbf28bfa16..eafae3b242 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-dom.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-dom.ts @@ -81,5 +81,10 @@ function parsePositiveInteger(value: string | null): number | null { function parseGid(hash: string): string { const match = hash.match(/(?:^|[&#])gid=([^&]+)/); - return match?.[1] ? decodeURIComponent(match[1]) : '0'; + if (!match?.[1]) return '0'; + try { + return decodeURIComponent(match[1]); + } catch { + return '0'; + } } diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-question-cells.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-question-cells.ts index d664741c37..56be9fb1b3 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-question-cells.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-question-cells.ts @@ -224,11 +224,14 @@ function scoreQuestionCell(text: string): number { function isQuestionHeader(value: string | undefined): boolean { const text = value?.trim() ?? ''; - return Boolean( - text.length > 0 && - text.length <= 48 && - /question|requirement|control|prompt|description/i.test(text), - ); + if (text.length === 0 || text.length > 48) return false; + // A header is a short column label ("Control", "Requirement description"). + // Questionnaire rows routinely contain the same words — "Are security + // controls documented?" — and treating those as headers drops them from + // detection, so require the shape of a label rather than a sentence. + if (text.includes('?')) return false; + if (text.split(/\s+/).length > 3) return false; + return /question|requirement|control|prompt|description/i.test(text); } function hasQuestionnaireHeaders(values: string[]): boolean { diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-question-header.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-question-header.test.ts new file mode 100644 index 0000000000..f308987c77 --- /dev/null +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-question-header.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { tableToQuestions } from './sheets-detection'; +import type { GvizRow } from './sheets-table'; + +function row(values: string[]): GvizRow { + return { c: values.map((value) => ({ v: value })) }; +} + +function questionsFor(cells: string[]): string[] { + const result = tableToQuestions({ + gid: '0', + mapping: null, + table: { + cols: [{ label: '#' }, { label: 'Question' }, { label: 'Answer' }], + rows: [ + row(['#', 'Question', 'Answer']), + ...cells.map((cell, index) => row([String(index + 1), cell, ''])), + ], + }, + }); + return result.map((entry) => entry.question); +} + +describe('question cells that look like header labels', () => { + it('keeps short questions containing header vocabulary', () => { + const questions = questionsFor([ + 'Are security controls documented?', + 'Is there a control owner?', + 'Do you meet this requirement?', + ]); + + expect(questions).toEqual([ + 'Are security controls documented?', + 'Is there a control owner?', + 'Do you meet this requirement?', + ]); + }); + + it('still drops a repeated short column label', () => { + const questions = questionsFor([ + 'Encryption at rest is enforced everywhere', + 'Control description', + 'Quarterly access reviews are performed', + ]); + + expect(questions).not.toContain('Control description'); + expect(questions).toHaveLength(2); + }); +}); diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-runtime.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-runtime.ts index 33e5885999..0f1f6bebff 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-runtime.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-runtime.ts @@ -7,11 +7,13 @@ export async function detectSheetQuestionsForPage(params: { location: Location; root: ParentNode; }): Promise { + // A failed round trip to the background must still fall through to the + // local page and DOM detection below. const response = await sendRuntimeMessage({ type: 'comp:detect-sheet-questions', pathname: params.location.pathname, hash: params.location.hash, - }); + }).catch(() => null); if (isSheetQuestionsResponse(response) && response.questions.length > 0) { return response.questions; } diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-table.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-table.ts index 305d3f2654..e578c7dd9a 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-table.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/dom/sheets-table.ts @@ -24,7 +24,12 @@ export function parseGvizTable(text: string): GvizTable | null { const end = text.lastIndexOf('}'); if (start < 0 || end <= start) return null; - const parsed: unknown = JSON.parse(text.slice(start, end + 1)); + let parsed: unknown; + try { + parsed = JSON.parse(text.slice(start, end + 1)); + } catch { + return null; + } if (!isRecord(parsed) || !isRecord(parsed.table)) return null; return parsed.table; } diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/message-utils.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/message-utils.ts index 97d6be4db2..633c46b9d8 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/message-utils.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/message-utils.ts @@ -8,6 +8,7 @@ export function parseDetectedQuestion(value: unknown): DetectedQuestion[] { if ( !isRecord(value) || typeof value.id !== 'string' || + value.id.length === 0 || typeof value.question !== 'string' || typeof value.value !== 'string' || typeof value.isEmpty !== 'boolean' || diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/queue-approval.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/queue-approval.test.ts index 4998728d09..8adf59fee8 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/queue-approval.test.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/queue-approval.test.ts @@ -26,6 +26,30 @@ const secondQuestion: DetectedQuestion = { }; describe('queue approval reducers', () => { + it('does not approve an answer that is only whitespace', () => { + const queue = applyGeneratedAnswer({ + queue: syncDetectedQuestions({ + queue: null, + tabId: 1, + url: 'https://vendor.example/security', + host: 'vendor.example', + surface: 'generic', + organizationId: 'org_a', + questions: [firstQuestion], + }), + itemId: 'field-1', + answer: { + questionIndex: 0, + question: firstQuestion.question, + answer: ' ', + sources: [], + }, + }); + + const approved = approveGeneratedItems(queue); + expect(approved.items[0].status).not.toBe('approved'); + }); + it('approves all generated answers with text', () => { const queue = withGeneratedAnswers(); diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/queue-approval.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/queue-approval.ts index 7d01e99db0..8f642dd6ca 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/queue-approval.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/queue-approval.ts @@ -1,9 +1,13 @@ import type { QuestionQueueItem, TabQuestionQueue } from './types'; +function hasAnswerText(item: QuestionQueueItem): boolean { + return (item.answer?.trim().length ?? 0) > 0; +} + export function approveGeneratedItems(queue: TabQuestionQueue): TabQuestionQueue { return approveMatchingGeneratedItems({ queue, - matches: (item) => Boolean(item.answer), + matches: hasAnswerText, }); } @@ -12,7 +16,7 @@ export function approveHighConfidenceItems( ): TabQuestionQueue { return approveMatchingGeneratedItems({ queue, - matches: (item) => item.confidence === 'high' && Boolean(item.answer), + matches: (item) => item.confidence === 'high' && hasAnswerText(item), }); } diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/response-guards.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/response-guards.ts index 47349e4f09..20b26b4a46 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/response-guards.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/response-guards.ts @@ -21,7 +21,12 @@ export function isQueueResponse( export function isItemResponse( value: unknown, ): value is { ok: true; item: QuestionQueueItem; queue: TabQuestionQueue } { - return isRecord(value) && value.ok === true && isRecord(value.item); + return ( + isRecord(value) && + value.ok === true && + isRecord(value.item) && + isRecord(value.queue) + ); } export function isSheetPasteResponse( diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/scan-debug.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/scan-debug.ts index ca7f60b24f..7373004dae 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/scan-debug.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/scan-debug.ts @@ -1,17 +1,10 @@ import type { ScanDebug, ScanDebugStep } from './types'; -import { isRecord } from './message-utils'; +import { isQuestionnaireSurface, isRecord } from './message-utils'; export function getScanDebug(value: unknown): ScanDebug | null { if (!isRecord(value) || !isRecord(value.debug)) return null; const debug = value.debug; - if ( - debug.surface !== 'sheets' && - debug.surface !== 'docs' && - debug.surface !== 'forms' && - debug.surface !== 'generic' - ) { - return null; - } + if (!isQuestionnaireSurface(debug.surface)) return null; if ( typeof debug.source !== 'string' || typeof debug.questionCount !== 'number' || diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.test.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.test.ts index 1914c19864..16f73c341e 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.test.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.test.ts @@ -29,6 +29,11 @@ describe('buildSheetPastePlan', () => { expect(planFor('First line\nsecond\tline')).toBe('First line second line'); }); + it('rejects zero row or column, which are not valid A1 coordinates', () => { + expect(buildSheetPastePlan([{ fieldId: 'sheet:0:0:3', answer: 'x' }])).toBeNull(); + expect(buildSheetPastePlan([{ fieldId: 'sheet:0:3:0', answer: 'x' }])).toBeNull(); + }); + it('leaves empty cells empty rather than quoting them', () => { const plan = buildSheetPastePlan([ { fieldId: 'sheet:0:1:1', answer: 'yes' }, diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.ts index 5673835214..ce94304b36 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/sheets-paste-plan.ts @@ -61,12 +61,11 @@ export function buildSheetPastePlan( function parseSheetTarget(answer: SheetAnswer): SheetTarget[] { const match = answer.fieldId.match(/^sheet:([^:]+):(\d+):(\d+)$/); if (!match) return []; - return [{ - ...answer, - gid: match[1], - row: Number(match[2]), - col: Number(match[3]), - }]; + const row = Number(match[2]); + const col = Number(match[3]); + // A1 notation is 1-based; row or column 0 would produce a malformed range. + if (row < 1 || col < 1) return []; + return [{ ...answer, gid: match[1], row, col }]; } function getRange(params: { diff --git a/apps/browser-extension/security-questionnaire-ext/src/lib/storage.ts b/apps/browser-extension/security-questionnaire-ext/src/lib/storage.ts index f1cc6ad785..30108c31de 100644 --- a/apps/browser-extension/security-questionnaire-ext/src/lib/storage.ts +++ b/apps/browser-extension/security-questionnaire-ext/src/lib/storage.ts @@ -13,7 +13,22 @@ export async function getSelectedOrganizationId(): Promise { export async function setSelectedOrganizationId( organizationId: string, ): Promise { - await browser.storage.local.set({ [SELECTED_ORG_KEY]: organizationId }); + const trimmed = organizationId.trim(); + if (trimmed.length === 0) return; + await browser.storage.local.set({ [SELECTED_ORG_KEY]: trimmed }); +} + +/** + * `browser.storage.local` has no transaction, so a read-merge-write from two + * contexts can drop one of the writes. Serializing them here keeps the + * confirmed-domain and detection maps consistent. + */ +let pendingWrite: Promise = Promise.resolve(); + +function serializeWrite(run: () => Promise): Promise { + const result = pendingWrite.then(run, run); + pendingWrite = result.catch(() => undefined); + return result; } export async function getConfirmedDomains(): Promise> { @@ -25,12 +40,14 @@ export async function setConfirmedDomain(params: { host: string; organizationId: string; }): Promise { - const domains = await getConfirmedDomains(); - await browser.storage.local.set({ - [CONFIRMED_DOMAINS_KEY]: { - ...domains, - [params.host]: params.organizationId, - }, + await serializeWrite(async () => { + const domains = await getConfirmedDomains(); + await browser.storage.local.set({ + [CONFIRMED_DOMAINS_KEY]: { + ...domains, + [params.host]: params.organizationId, + }, + }); }); } @@ -56,13 +73,15 @@ export async function setDetectionEnabled(params: { host: string; enabled: boolean; }): Promise { - const result = await browser.storage.local.get(DETECTION_ENABLED_KEY); - const settings = readBooleanMap(result[DETECTION_ENABLED_KEY]); - await browser.storage.local.set({ - [DETECTION_ENABLED_KEY]: { - ...settings, - [params.host]: params.enabled, - }, + await serializeWrite(async () => { + const result = await browser.storage.local.get(DETECTION_ENABLED_KEY); + const settings = readBooleanMap(result[DETECTION_ENABLED_KEY]); + await browser.storage.local.set({ + [DETECTION_ENABLED_KEY]: { + ...settings, + [params.host]: params.enabled, + }, + }); }); }