From 4337d14a89ba89a4b885bdf7dded556dcd7cda44 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 8 Jun 2026 19:15:59 -0400 Subject: [PATCH] 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",