diff --git a/.env.example b/.env.example index fc1ba6cc..53957cfa 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,10 @@ JOIN_WAIT_TIME_MINUTES=2 # RETRY_COUNT=2 means: 1 initial attempt + 2 retries = 3 total attempts RETRY_COUNT=2 +# MediaRecorder target video bitrate (bits/sec) for Zoom tab-capture recordings. +# VP9 under software GL starved the encoder and produced ~187 kbps; this sets a sane target. +# RECORDING_VIDEO_BITS_PER_SECOND=4000000 + # Optional Google Meet identity. # Prefer a dedicated, consented Google account that is invited to meetings. # If you use CDP, launch that Chrome with --auto-accept-this-tab-capture so @@ -22,6 +26,40 @@ RETRY_COUNT=2 # Re-submit anonymous guest requests if Google redirects while still waiting for host admission. GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS=10 +# Optional: connect Zoom bots to an already-running Chrome via CDP. +# Launch that Chrome with --remote-debugging-port and the recording flags the +# Zoom bot needs (see recordingBrowserArgs in src/lib/chromium.ts). Every meeting +# gets a fresh isolated context, optionally seeded with its customer's state. +# ZOOM_CHROME_CDP_URL=http://host.docker.internal:9222 +# Optional customer-specific Zoom session states (export via Playwright storageState). +# Values are read-only secret-mounted file paths selected by exact teamId. +# ZOOM_CHROME_CUSTOMER_STORAGE_STATE_PATHS_JSON={"team-id":"/var/run/secrets/meeting-bot/zoom-profiles/team-id.json"} + +# Zoom transport: browser-only, browser with RTMS fallback, or forced RTMS. +# ZOOM_RECORDING_TRANSPORT=rtms forces RTMS and does not start Chrome for Zoom. +ZOOM_RECORDING_TRANSPORT=browser +# Allow RTMS only after an explicit pre-join automated-bot rejection. +ZOOM_RTMS_FALLBACK_ENABLED=false +# Exact teamId allowed to use the global/internal OAuth credentials below. +# ZOOM_RTMS_GLOBAL_TEAM_ID=internal-team-id +# Exact teamId allowlist for customer S2S OAuth credentials. +# Enforce shared or dedicated customer apps; auto keeps per-entry inference for compatibility. +ZOOM_RTMS_CUSTOMER_CREDENTIAL_MODE=auto +# ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON={"team-id":{"enabled":true,"accountId":"zoom-account-id","clientId":"zoom-s2s-client-id","clientSecret":"zoom-s2s-client-secret","participantUserId":"zoom-user-id"}} +# Add rtmsApp for a dedicated private General app. Its webhook URL is /zoom/rtms/webhook/apps/. +# ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON={"team-id":{"enabled":true,"accountId":"zoom-account-id","clientId":"zoom-s2s-client-id","clientSecret":"zoom-s2s-client-secret","participantUserId":"zoom-user-id","rtmsApp":{"webhookId":"unique-customer-app-id","clientId":"zoom-general-app-client-id","clientSecret":"zoom-general-app-client-secret","webhookSecret":"zoom-general-app-webhook-secret"}}} +# ZOOM_RTMS_CLIENT_ID= +# ZOOM_RTMS_CLIENT_SECRET= +# ZOOM_RTMS_WEBHOOK_SECRET= +# Prefer account-level S2S OAuth credentials. A short-lived access token is useful for local tests. +# ZOOM_RTMS_OAUTH_ACCESS_TOKEN= +# ZOOM_RTMS_OAUTH_ACCOUNT_ID= +# ZOOM_RTMS_OAUTH_CLIENT_ID= +# ZOOM_RTMS_OAUTH_CLIENT_SECRET= +# Required with account-level control when Zoom cannot infer the authorized participant. +# ZOOM_RTMS_PARTICIPANT_USER_ID= +# ZOOM_RTMS_EVENT_TTL_SECONDS=3600 + # GCP bucket configuration for uploading debugging screenshots GCP_ACCESS_KEY_ID= GCP_SECRET_ACCESS_KEY= diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 75a01585..a3b35ee2 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -25,6 +25,26 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install media test dependencies + run: | + sudo apt-get update + sudo apt-get install --yes ffmpeg + + - name: Run tests + run: npm test + + - name: Build application + run: npm run build + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -58,15 +78,6 @@ jobs: exit 1 fi - # Create and push git tag if it doesn't exist (only on main push) - - name: Create and push tag - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag ${{ env.TAG }} - git push origin ${{ env.TAG }} - # Always generate Docker tags for build, but only include version tag on main - name: Extract metadata id: meta @@ -90,6 +101,39 @@ jobs: type=sha,prefix=sha- type=raw,value=latest,enable={{is_default_branch}} + - name: Build Chrome CDP smoke image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.chrome-cdp + platforms: linux/amd64 + load: true + tags: meeting-bot-chrome-cdp:smoke + cache-from: type=gha,scope=chrome-cdp + + - name: Smoke-test Chrome CDP sidecar + run: | + set -euo pipefail + container=meeting-bot-chrome-cdp-smoke + cleanup() { + status=$? + trap - EXIT + if [ "$status" -ne 0 ]; then + docker logs "$container" || true + fi + docker rm -f "$container" >/dev/null 2>&1 || true + exit "$status" + } + trap cleanup EXIT + + docker run --detach \ + --name "$container" \ + --network host \ + --shm-size=1g \ + --env CHROME_NO_SANDBOX=true \ + meeting-bot-chrome-cdp:smoke + CHROME_CDP_CONTAINER="$container" node scripts/smoke-chrome-cdp.mjs + # Build and push on all branches - name: Build and push Docker image uses: docker/build-push-action@v5 @@ -116,3 +160,12 @@ jobs: labels: ${{ steps.chrome-cdp-meta.outputs.labels }} cache-from: type=gha,scope=chrome-cdp cache-to: type=gha,mode=max,scope=chrome-cdp + + # Create and push the release tag only after both images and the smoke test succeed. + - name: Create and push tag + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag ${{ env.TAG }} + git push origin ${{ env.TAG }} diff --git a/.gitignore b/.gitignore index 42134483..94d71ec7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,12 +9,13 @@ assets/videos *.wav __pycache__/ .env +.env.* +!.env.example .chrome/ gcp-key assets/screenshots /data debug-videos/ -.env.native # Local-only docker compose override for signal-handling testing — see compose.override.yml header compose.override.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index b8ec7e2b..13393f00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security - N/A +## [1.3.16] - 2026-09-09 + +### Fixed +- Reduce Google Meet recording load by preferring VP8 for WebM and limiting capture to 25 fps; honor the existing video bitrate setting. + ## [1.0.0] - 2024-01-01 ### Added diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index fad47fda..131631fe 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -28,7 +28,7 @@ The workflow is defined in `.github/workflows/docker-build.yml` and includes: ### Production Image (`Dockerfile.production`) -- **Base**: Node.js 20 Alpine +- **Base**: Node.js 22 Bookworm - **Multi-stage build** for smaller final image - **Security**: Runs as non-root user - **Optimized**: Only production dependencies @@ -36,7 +36,7 @@ The workflow is defined in `.github/workflows/docker-build.yml` and includes: ### Development Image (`Dockerfile`) -- **Base**: Node.js 20 +- **Base**: Node.js 22 Bookworm - **Full dependencies** for development - **Hot reload** support - **Debugging tools** included @@ -269,4 +269,4 @@ For high availability, consider: ### Contributing -We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on how to get involved with the project. \ No newline at end of file +We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on how to get involved with the project. diff --git a/Dockerfile.chrome-cdp b/Dockerfile.chrome-cdp index 4da61910..4f212b02 100644 --- a/Dockerfile.chrome-cdp +++ b/Dockerfile.chrome-cdp @@ -9,6 +9,9 @@ RUN apt-get update && \ dumb-init \ ffmpeg \ fonts-liberation \ + fonts-noto-cjk \ + fonts-noto-color-emoji \ + fonts-noto-core \ gnupg \ libasound2 \ libatk-bridge2.0-0 \ @@ -54,7 +57,7 @@ RUN chmod +x /usr/local/bin/start-chrome-cdp USER chrome ENV XDG_RUNTIME_DIR=/run/user/1001 -EXPOSE 9222 9223 +EXPOSE 9223 ENTRYPOINT ["dumb-init", "--"] CMD ["start-chrome-cdp"] diff --git a/Dockerfile.development b/Dockerfile.development index 3baa9c49..79cd87b3 100644 --- a/Dockerfile.development +++ b/Dockerfile.development @@ -1,4 +1,10 @@ -FROM node:20 +FROM gcc:13-bookworm AS rtms-cxx-runtime +RUN cp "$(g++ -print-file-name=libstdc++.so.6)" /libstdc++.so.6 + +FROM node:22-bookworm + +COPY --from=rtms-cxx-runtime /libstdc++.so.6 /opt/rtms/libstdc++.so.6 +ENV LD_LIBRARY_PATH=/opt/rtms WORKDIR /usr/src/app @@ -57,6 +63,11 @@ RUN npm install -g nodemon COPY package*.json ./ RUN npm install +RUN if [ "$(dpkg --print-architecture)" = "amd64" ]; then \ + cp /opt/rtms/libstdc++.so.6 /usr/src/app/node_modules/@zoom/rtms/build/Release/libstdc++.so.6 && \ + node -e "const rtms=require('@zoom/rtms').default;if(typeof rtms.Client!=='function')process.exit(1)"; \ + fi + # Install Playwright and its dependencies RUN npx playwright install --with-deps @@ -64,7 +75,7 @@ RUN npx playwright install --with-deps COPY . . # Build app -RUN npm run build +RUN npm test && npm run build # Set permissions RUN chmod +x /usr/src/app/start.sh diff --git a/Dockerfile.production b/Dockerfile.production index 059ae13f..2e41c4fd 100644 --- a/Dockerfile.production +++ b/Dockerfile.production @@ -1,5 +1,11 @@ +FROM gcc:13-bookworm AS rtms-cxx-runtime +RUN cp "$(g++ -print-file-name=libstdc++.so.6)" /libstdc++.so.6 + # Use the official Node.js image as a base image -FROM node:20 +FROM node:22-bookworm + +COPY --from=rtms-cxx-runtime /libstdc++.so.6 /opt/rtms/libstdc++.so.6 +ENV LD_LIBRARY_PATH=/opt/rtms # Set the working directory WORKDIR /usr/src/app @@ -63,6 +69,12 @@ COPY package*.json ./ # Install all dependencies (including dev dependencies for build) RUN npm ci && npm cache clean --force +# The Zoom RTMS Linux SDK requires GCC 13's C++ runtime and currently supports amd64 only. +RUN if [ "$(dpkg --print-architecture)" = "amd64" ]; then \ + cp /opt/rtms/libstdc++.so.6 /usr/src/app/node_modules/@zoom/rtms/build/Release/libstdc++.so.6 && \ + node -e "const rtms=require('@zoom/rtms').default;if(typeof rtms.Client!=='function')process.exit(1)"; \ + fi + # Install Playwright and its dependencies RUN npx playwright install --with-deps @@ -77,7 +89,7 @@ RUN dos2unix /usr/src/app/xvfb-run-wrapper && \ chmod +x /usr/src/app/xvfb-run-wrapper # Build the TypeScript code -RUN npm run build +RUN npm test && npm run build USER root RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix diff --git a/README.md b/README.md index bd0266f4..8db0ff6e 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ An open-source automation bot for joining and recording video meetings across mu ### Prerequisites -- Node.js 20+ +- Node.js 22+ - Docker and Docker Compose (for containerized deployment) - Git @@ -89,7 +89,7 @@ Content-Type: application/json For Google Meet, you can optionally connect to an already-running Chrome via `GOOGLE_CHROME_CDP_URL`, or run the browser with a dedicated signed-in Google profile by setting `GOOGLE_CHROME_USER_DATA_DIR` or `GOOGLE_CHROME_STORAGE_STATE_PATH`. The CDP option is useful when the Docker browser is treated differently from a normal Chrome. If you use CDP, launch that Chrome with `--auto-accept-this-tab-capture` so recording can select the current Meet tab without a manual browser prompt. The Chrome window and virtual display should normally use `1280,800`; the bot then sets the page viewport to `1280x720`, leaving room for Chrome's top UI without clipping the Meet controls. -For Kubernetes, run Chrome as a sidecar container in the same pod and point `GOOGLE_CHROME_CDP_URL` at `http://127.0.0.1:9222`. The included `Dockerfile.chrome-cdp` builds a minimal Google Chrome + Xvfb CDP backend for that setup. +For Kubernetes, run Chrome as a sidecar container in the same pod and point `GOOGLE_CHROME_CDP_URL` and `ZOOM_CHROME_CDP_URL` at `http://127.0.0.1:9223`. The included `Dockerfile.chrome-cdp` builds a Google Chrome + Xvfb CDP backend for that setup. For **local testing**, `docker compose up --build` starts the `chrome-cdp` sidecar alongside the bot and wires `GOOGLE_CHROME_CDP_URL` to it automatically (via the sidecar's nginx proxy on `9223`, which rewrites the `Host` header so cross-container CDP works). If host port `6379` is already taken by another local Redis, start with `REDIS_PORT_HOST=6380 docker compose up --build`. Join a Meet that allows guests, make sure a second participant is present (the bot leaves if it's alone), then `POST /google/join` (see above) and follow `docker compose logs -f meeting-bot`. The recording **upload will fail** without configured storage credentials — that's expected; the join and recording are what this exercises. Set `GOOGLE_CHROME_CDP_URL=` (empty) to fall back to the bot's in-container Chromium. This sidecar joins as an anonymous guest; meetings that require sign-in need a signed-in profile (`GOOGLE_CHROME_USER_DATA_DIR`/`GOOGLE_CHROME_STORAGE_STATE_PATH`), not yet wired into the local compose. @@ -125,6 +125,68 @@ Content-Type: application/json } ``` +Zoom transport is deployment-wide: `browser` with fallback disabled is browser-only, `browser` with `ZOOM_RTMS_FALLBACK_ENABLED=true` is browser→RTMS, and `rtms` is forced RTMS without a Zoom browser. Only an explicit Zoom automated-bot rejection before admission can trigger fallback. Host rejection, sign-in requirements, lobby timeouts, browser crashes and recording failures never do. + +See [Zoom RTMS operations and customer onboarding](docs/zoom-rtms.md) for the complete setup, deployment modes, Zoom prerequisites, customer onboarding, and troubleshooting guide. + +Fallback credentials are selected by the job's exact `teamId`: customer teams use `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON`, while the exact `ZOOM_RTMS_GLOBAL_TEAM_ID` uses the internal/global settings. Store customer S2S account credentials, not short-lived access tokens: + +```json +{ + "team-id": { + "enabled": true, + "accountId": "zoom-account-id", + "clientId": "zoom-s2s-client-id", + "clientSecret": "zoom-s2s-client-secret", + "participantUserId": "zoom-user-id" + } +} +``` + +To use global/internal Zoom Realtime Media Streams directly, set `ZOOM_RECORDING_TRANSPORT=rtms`, set `ZOOM_RTMS_GLOBAL_TEAM_ID` to the exact internal job `teamId`, and configure a user-managed Zoom General app with RTMS enabled. Customer RTMS-first operation instead requires an enabled exact customer JSON entry. Set the General app's public HTTPS webhook endpoint to `POST /zoom/rtms/webhook` and subscribe to `meeting.rtms_started`, `meeting.rtms_stopped`, and `meeting.rtms_interrupted`. + +Shared RTMS app settings: + +- `ZOOM_RTMS_CLIENT_ID`, `ZOOM_RTMS_CLIENT_SECRET`, `ZOOM_RTMS_WEBHOOK_SECRET` +- `REDIS_CONSUMER_ENABLED=true` for staging/production so webhook events reach the correct recording replica + +Internal/global mode only: + +- `ZOOM_RTMS_GLOBAL_TEAM_ID` to restrict the global/internal OAuth credentials to one exact Winkk team +- Prefer a Server-to-Server OAuth app with `ZOOM_RTMS_OAUTH_ACCOUNT_ID`, `ZOOM_RTMS_OAUTH_CLIENT_ID`, and `ZOOM_RTMS_OAUTH_CLIENT_SECRET`; a short-lived `ZOOM_RTMS_OAUTH_ACCESS_TOKEN` is also supported for local testing +- `ZOOM_RTMS_PARTICIPANT_USER_ID` to select and correlate the authorized host when using account-level OAuth + +Customer mode uses the exact enabled `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON` entry instead of the internal/global OAuth settings. +Set `ZOOM_RTMS_CUSTOMER_CREDENTIAL_MODE=dedicated_customer` to reject any customer entry that does not contain its own complete `rtmsApp`. Use `shared_customer` to reject dedicated app entries, or `auto` for backwards-compatible mixed deployments. + +Three credential profiles are supported simultaneously: + +- Internal: the exact `ZOOM_RTMS_GLOBAL_TEAM_ID` uses the global RTMS app and global OAuth settings. +- Shared customer: an exact customer entry without `rtmsApp` uses the global RTMS app and that customer's S2S OAuth settings. +- Dedicated customer: an exact customer entry with a complete `rtmsApp` uses that customer's own General app and S2S OAuth app. Configure its webhook as `POST /zoom/rtms/webhook/apps/`. + +Dedicated customer example: + +```json +{ + "team-id": { + "enabled": true, + "accountId": "zoom-account-id", + "clientId": "zoom-s2s-client-id", + "clientSecret": "zoom-s2s-client-secret", + "participantUserId": "zoom-user-id", + "rtmsApp": { + "webhookId": "unique-customer-app-id", + "clientId": "zoom-general-app-client-id", + "clientSecret": "zoom-general-app-client-secret", + "webhookSecret": "zoom-general-app-webhook-secret" + } + } +} +``` + +The General app needs `meeting:read:meeting_audio` and `meeting:read:meeting_video`; the OAuth app needs `meeting:update:participant_rtms_app_status` or its admin variant. The app must be authorized by the meeting host/account. RTMS does not anonymously join arbitrary Zoom links; the password in a join URL is not used. The RTMS output contains Zoom's mixed audio and active-speaker H.264 video and is uploaded as MP4 through the existing uploader. The official RTMS Node SDK currently requires Linux x64 (or macOS arm64); the browser transport remains available on other image architectures. + ### Recording Completion Notifications (Optional) You can configure Meeting Bot to notify external systems when a recording has finished and is ready. Two channels are supported: @@ -471,6 +533,16 @@ Notes: | `TEAMS_PREWARM_ENABLED` | Enable the extra Microsoft Teams warmup browser pass for environments that still show first-run dialogs | `false` | | `TEAMS_AUDIO_STABILIZATION_MS` | Delay before starting Microsoft Teams ffmpeg recording after joining | `1000` | | `GOOGLE_CHROME_CDP_URL` | Optional CDP endpoint for Google Meet joins, e.g. `http://host.docker.internal:9222`, to use an external Chrome instead of Docker Chrome. | - | +| `ZOOM_CHROME_CDP_URL` | Optional CDP endpoint for isolated Zoom browser contexts. | - | +| `ZOOM_RECORDING_TRANSPORT` | Zoom transport: `browser`, or `rtms` for forced RTMS. | `browser` | +| `ZOOM_RTMS_FALLBACK_ENABLED` | With browser transport, allow RTMS only after an explicit pre-join automated-bot block. Ignored by forced RTMS. | `false` | +| `ZOOM_RTMS_GLOBAL_TEAM_ID` | Exact internal team allowed to use global RTMS OAuth credentials. | - | +| `ZOOM_RTMS_CUSTOMER_CREDENTIAL_MODE` | Customer credential policy: `auto`, `shared_customer`, or `dedicated_customer`. | `auto` | +| `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON` | Exact team-keyed customer S2S OAuth credentials, optionally including a dedicated `rtmsApp`. | - | +| `SENTRY_DSN` | Optional Sentry DSN; monitoring is a complete no-op when omitted. | - | +| `SENTRY_ENVIRONMENT` | Sentry environment tag. | `NODE_ENV` | +| `SENTRY_RELEASE` | Sentry release tag. | - | +| `CHROME_NO_SANDBOX` | Keep `true` for the hardened v1.3.6 sidecar; Chrome's setuid sandbox cannot create its namespace with dropped capabilities. | `true` | | `GOOGLE_CHROME_USER_DATA_DIR` | Optional persistent Chrome profile directory for Google Meet joins. Use a dedicated signed-in Google account profile. | - | | `GOOGLE_CHROME_STORAGE_STATE_PATH` | Optional Playwright storage state JSON for Google Meet joins when not using a persistent profile. | - | | `GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS` | Number of times to re-submit an anonymous Google Meet guest request if Meet redirects while waiting for host admission. | `10` | @@ -649,3 +721,15 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file --- **Note**: This project is for educational and legitimate automation purposes. Please ensure compliance with the terms of service of the platforms you're automating. + +### Browser recording quality + +Google Meet and the shared browser recorder prefer VP8/Opus for WebM, with VP9 +as a compatibility fallback. Google Meet capture requests at most 25 fps to reduce +encoding load. `RECORDING_VIDEO_BITS_PER_SECOND` sets the video bitrate target +(default: 4000000). MP4 continues to prefer H.264/AAC. + +If audio stutters, inspect audio packet timestamps as well as browser CPU load: +continuous decoding alone does not rule out gaps in the recording timeline. +Changing encoder settings requires a new recording to verify the improvement; +it cannot recover audio missing from an existing file. diff --git a/docker-compose.yml b/docker-compose.yml index 2e583374..7f6e5195 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,12 @@ services: # Google increasingly blocks). Set GOOGLE_CHROME_CDP_URL= (empty) to fall # back to the in-container browser. See README.md (Usage → Join a Google Meet). - GOOGLE_CHROME_CDP_URL=${GOOGLE_CHROME_CDP_URL-http://chrome-cdp:9223} + # Connect Zoom bots to the bundled chrome-cdp sidecar by default (like Google). + # Set GOOGLE/ZOOM_CHROME_CDP_URL= (empty) to fall back to in-container Chromium. + # Caveat: the bundled sidecar lacks Zoom's rendering flags (swiftshader/MediaRecorder), + # so capture quality may suffer — point at a Chrome with the full recordingBrowserArgs + # for production-quality Zoom recording. See src/lib/chromium.ts. + - ZOOM_CHROME_CDP_URL=${ZOOM_CHROME_CDP_URL-http://chrome-cdp:9223} ports: - "3000:3000" volumes: @@ -65,6 +71,10 @@ services: context: . dockerfile: Dockerfile.chrome-cdp shm_size: "1gb" + environment: + # Compose uses a separate container network; Kubernetes keeps the default + # loopback-only binding because app and sidecar share one pod network. + CHROME_CDP_PROXY_ADDRESS: "0.0.0.0" expose: - "9223" healthcheck: diff --git a/docs/zoom-rtms.md b/docs/zoom-rtms.md new file mode 100644 index 00000000..26e20b00 --- /dev/null +++ b/docs/zoom-rtms.md @@ -0,0 +1,370 @@ +# Zoom RTMS operations and customer onboarding + +This guide covers three supported RTMS credential use cases: + +1. An internal private Zoom app used only by the developer's own Zoom account. +2. One shared Zoom RTMS app with customer OAuth credentials selected by exact Winkk `teamId`. +3. A separate private Zoom RTMS app and OAuth app for each customer, also selected by exact `teamId`. + +RTMS does not join a meeting as an anonymous participant. It streams meeting media for an authorized Zoom user. The meeting URL is used only to extract the meeting ID; its password does not authorize RTMS. + +## Deployment modes + +| Mode | Configuration | Behavior | +| --- | --- | --- | +| Browser only | `ZOOM_RECORDING_TRANSPORT=browser`, fallback disabled | Never uses RTMS | +| Browser then RTMS | `ZOOM_RECORDING_TRANSPORT=browser`, `ZOOM_RTMS_FALLBACK_ENABLED=true` | Uses RTMS only after an explicit automated-bot block before browser admission | +| Forced RTMS | `ZOOM_RECORDING_TRANSPORT=rtms` | Uses RTMS directly and never starts Chrome for Zoom | + +Transport selection is independent from credential selection: + +| Credential profile | Selection | RTMS General app | OAuth control app | +| --- | --- | --- | --- | +| Internal | Exact `ZOOM_RTMS_GLOBAL_TEAM_ID` | Global app settings | Global OAuth settings | +| Shared customer | Exact customer JSON entry without `rtmsApp` | Global app settings | Customer entry | +| Dedicated customer | Exact customer JSON entry with `rtmsApp` | Customer entry | Customer entry | + +Credential selection is fail-closed: + +- An enabled exact customer entry uses that customer's S2S OAuth credentials and either the shared or its dedicated RTMS app. +- The exact `ZOOM_RTMS_GLOBAL_TEAM_ID` uses the global/internal OAuth credentials. +- Disabled, invalid, and unknown teams do not receive global credentials. + +`ZOOM_RECORDING_TRANSPORT` is deployment-wide. A single deployment cannot currently use RTMS first for the internal team while using browser first for customers. That requires a future per-team transport policy or separate deployments. + +`teamId` is a routing key, not proof of tenant ownership. The credential resolver assumes that the job producer already authenticated the caller and verified that the caller belongs to that team. Restrict the HTTP endpoint and Redis queue to trusted producers, validate bearer-token-to-team membership upstream, and never authorize RTMS from an unverified client-supplied `teamId`. + +## Common Zoom prerequisites + +Every shared or dedicated Zoom General app must have RTMS enabled. Configure: + +- `meeting:read:meeting_audio` +- `meeting:read:meeting_video` +- `meeting.rtms_started` +- `meeting.rtms_stopped` +- `meeting.rtms_interrupted` +- A public HTTPS event endpoint. The shared app uses `/zoom/rtms/webhook`; a dedicated app uses `/zoom/rtms/webhook/apps/`. + +The app must have Zoom Developer Pack credits and the Zoom account must allow apps to access shared real-time meeting content. In staging and production, run the meeting bot with `REDIS_CONSUMER_ENABLED=true` so webhook events reach the correct replica. + +The following global app credentials are required for the internal and shared-customer profiles: + +```env +ZOOM_RTMS_CLIENT_ID=general-app-client-id +ZOOM_RTMS_CLIENT_SECRET=general-app-client-secret +ZOOM_RTMS_WEBHOOK_SECRET=general-app-webhook-secret +``` + +They belong to the shared RTMS General app and are used to verify webhooks and connect to RTMS media servers. A complete customer `rtmsApp` replaces them only for that exact customer. + +## Mode A: internal private app + +A private General app can be used without Marketplace publication by users in its own Zoom account. This is different from an unlisted app: an unlisted production app still completes Zoom review before external distribution. + +Create a Server-to-Server OAuth app in the same internal Zoom account with: + +- `meeting:update:participant_rtms_app_status`, or +- `meeting:update:participant_rtms_app_status:admin` + +Configure the internal Winkk team and the account-level OAuth credentials: + +```env +ZOOM_RECORDING_TRANSPORT=rtms +ZOOM_RTMS_GLOBAL_TEAM_ID=internal-winkk-team-id +ZOOM_RTMS_OAUTH_ACCOUNT_ID=internal-zoom-account-id +ZOOM_RTMS_OAUTH_CLIENT_ID=internal-s2s-client-id +ZOOM_RTMS_OAUTH_CLIENT_SECRET=internal-s2s-client-secret +ZOOM_RTMS_PARTICIPANT_USER_ID=internal-zoom-user-id +``` + +Every Zoom job still contains a Winkk `teamId`. Only a job whose `teamId` exactly matches `ZOOM_RTMS_GLOBAL_TEAM_ID` can use these credentials. No customer JSON entry is required for that internal team. + +For a short local test, `ZOOM_RTMS_OAUTH_ACCESS_TOKEN` can replace the account ID, client ID, and client secret. Do not store an expiring access token in production. + +## Mode B: shared app with multiple customers + +External customers must be able to authorize the shared RTMS General app. Use either: + +- a Zoom-approved Beta authorization URL for limited testing, or +- a published or unlisted production app after Zoom review. + +A private app cannot be shared with a different Zoom account. + +Each customer currently creates a private Server-to-Server OAuth app in their own Zoom account and grants: + +```text +meeting:update:participant_rtms_app_status:admin +``` + +Collect these values through an approved secret-sharing channel: + +- Zoom account ID +- S2S client ID +- S2S client secret +- Zoom user ID of the participant or host used for RTMS +- Exact Winkk `teamId` + +Do not accept credentials in chat, email, screenshots, tickets, or source control. + +Add the customer to `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON`: + +```json +{ + "customer-team-id": { + "enabled": true, + "accountId": "zoom-account-id", + "clientId": "zoom-s2s-client-id", + "clientSecret": "zoom-s2s-client-secret", + "participantUserId": "zoom-user-id" + } +} +``` + +The outer key is the Winkk `teamId`. `participantUserId` is the Zoom user ID, not an email address. All values are required. + +For browser-first customer fallback: + +```env +ZOOM_RECORDING_TRANSPORT=browser +ZOOM_RTMS_FALLBACK_ENABLED=true +ZOOM_RTMS_CUSTOMER_CREDENTIAL_MODE=shared_customer +ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON={...} +``` + +Only an explicit Zoom automated-bot block before browser admission triggers RTMS. Host rejection, sign-in requirements, lobby timeout, browser failure, and recording failure do not trigger the fallback. + +For RTMS first, set `ZOOM_RECORDING_TRANSPORT=rtms`. Enabled exact customer mappings and the exact internal team remain eligible; all other teams fail closed. + +## Mode C: dedicated private app per customer + +Use this mode when each customer creates and pays for its own private Zoom General RTMS app and S2S OAuth app. The app stays local to that customer's Zoom account and does not depend on publication of the shared Winkk app. + +### Customer setup + +Complete these steps while signed in to the Zoom account that will own and pay for the RTMS usage. Repeat the complete setup for every customer account and for every environment that needs separate credentials. + +#### 1. Create the private General RTMS app + +1. Open [Build an app in Zoom App Marketplace](https://marketplace.zoom.us/develop/create), select **General App**, and create the app. Zoom's detailed instructions are in [Create an OAuth app](https://developers.zoom.us/docs/integrations/create/). +2. Name it for the owning account, for example `winkk AI Notetaker - Customer Name`. +3. Under **Basic Information**, select **User-managed**. Zoom requires an RTMS app to be user-managed. +4. Keep the app private: do not submit it for Marketplace publication. For an unpublished same-account app, use the **Development** credentials and install it from **Local Test** with **Add App Now**, then **Allow**. Local-test access is limited to members of that Zoom account. +5. Under **Access**, enable **Event Subscription** and add exactly these meeting events: + - `meeting.rtms_started` + - `meeting.rtms_stopped` + - `meeting.rtms_interrupted` +6. Set the event notification endpoint to one unique dedicated webhook URL: + + ```text + Production: https://rtms.winkk.ai/zoom/rtms/webhook/apps/ + Development: https://dev.rtms.winkk.ai/zoom/rtms/webhook/apps/ + ``` + + Choose `` once for this customer and environment. It may contain only letters, numbers, `_`, and `-`, must be unique across all customer entries, and must not be `global`. Zoom validates this public HTTPS endpoint when the subscription is saved. +7. Under **Scopes**, add: + - `meeting:read:meeting_audio` + - `meeting:read:meeting_video` +8. Copy the following values without posting them in chat or source control: + - **Client ID** from the General app's Development credentials + - **Client Secret** from the same credential set + - **Secret Token** from **Access**; this is the webhook secret and is not the Client Secret + +See [Add Realtime Media Streams to your app](https://developers.zoom.us/docs/rtms/meetings/add-features/) for Zoom's current RTMS event, scope, and user-managed-app requirements. + +#### 2. Create the private Server-to-Server OAuth control app + +1. Open [Build an app in Zoom App Marketplace](https://marketplace.zoom.us/develop/create), select **Server-to-Server OAuth App**, and create it. Follow Zoom's [Server-to-Server OAuth app guide](https://developers.zoom.us/docs/internal-apps/create/). +2. Name it for the owning account, for example `winkk AI Notetaker Control - Customer Name`. +3. Complete the required app information. +4. Add exactly this required granular scope: + + ```text + meeting:update:participant_rtms_app_status:admin + ``` + + If the onboarding process must look up the participant's Zoom user ID from their email, also add `user:read:user:admin` and use Zoom's [Get a user API](https://developers.zoom.us/docs/api/users/#tag/users/GET/users/{userId}). It is not required by the meeting bot after `participantUserId` is known. +5. Activate the S2S app. Zoom does not issue usable account-credential tokens while it is inactive. +6. Copy its **Account ID**, **Client ID**, and **Client Secret**. + +The S2S app does not need an event subscription. It only obtains a short-lived account token and calls Zoom's [participant RTMS status API](https://developers.zoom.us/docs/api/meetings/#tag/meetings/PATCH/live_meetings/{meetingId}/rtms_app/status). + +#### 3. Enable RTMS for the Zoom account + +As a Zoom account admin, enable **Share realtime meeting content with apps**, then add the dedicated General app's Client ID under **Allow apps to access meeting content**. The meeting bot starts RTMS through the REST API, so Zoom's auto-start setting is optional. The account also needs Zoom Developer Pack credits. Zoom documents these prerequisites and settings in [Getting started with RTMS](https://developers.zoom.us/docs/rtms/meetings/getting-started/) and the [participant RTMS status API](https://developers.zoom.us/docs/api/meetings/#tag/meetings/PATCH/live_meetings/{meetingId}/rtms_app/status). + +#### 4. Map the customer to its exact Winkk team + +Obtain the exact Winkk `teamId` from the authenticated meeting job and the Zoom `id` of the user whose meeting participation authorizes RTMS. The Zoom user ID is the `id` returned by Zoom's Get a user API; it is not an email address, meeting ID, account ID, or Winkk team ID. + +Map the values as follows: + +| Zoom/Winkk source | Customer JSON field | +| --- | --- | +| Exact Winkk team ID | Outer JSON key | +| S2S Account ID | `accountId` | +| S2S Client ID | `clientId` | +| S2S Client Secret | `clientSecret` | +| Zoom user's `id` | `participantUserId` | +| Chosen unique webhook identifier | `rtmsApp.webhookId` | +| General app Client ID | `rtmsApp.clientId` | +| General app Client Secret | `rtmsApp.clientSecret` | +| General app Access Secret Token | `rtmsApp.webhookSecret` | + +Add the customer's S2S settings plus a complete `rtmsApp` object: + +```json +{ + "customer-team-id": { + "enabled": true, + "accountId": "zoom-account-id", + "clientId": "zoom-s2s-client-id", + "clientSecret": "zoom-s2s-client-secret", + "participantUserId": "zoom-user-id", + "rtmsApp": { + "webhookId": "customer-app-unique-id", + "clientId": "zoom-general-app-client-id", + "clientSecret": "zoom-general-app-client-secret", + "webhookSecret": "zoom-general-app-webhook-secret" + } + } +} +``` + +Multiple dedicated accounts are stored in the same environment variable, keyed by their exact and distinct Winkk team IDs: + +```json +{ + "winkk-team-id": { + "enabled": true, + "accountId": "winkk-zoom-account-id", + "clientId": "winkk-s2s-client-id", + "clientSecret": "winkk-s2s-client-secret", + "participantUserId": "winkk-zoom-user-id", + "rtmsApp": { + "webhookId": "winkk-prod", + "clientId": "winkk-general-app-client-id", + "clientSecret": "winkk-general-app-client-secret", + "webhookSecret": "winkk-general-app-secret-token" + } + }, + "customer-team-id": { + "enabled": true, + "accountId": "customer-zoom-account-id", + "clientId": "customer-s2s-client-id", + "clientSecret": "customer-s2s-client-secret", + "participantUserId": "customer-zoom-user-id", + "rtmsApp": { + "webhookId": "customer-prod", + "clientId": "customer-general-app-client-id", + "clientSecret": "customer-general-app-client-secret", + "webhookSecret": "customer-general-app-secret-token" + } + } +} +``` + +`webhookId` may contain letters, numbers, `_`, and `-`; it must be unique and must not be `global`. Dedicated app settings are all-or-nothing. Invalid, partial, duplicated, disabled, and unknown entries fail closed and never inherit another app. + +Store the complete JSON as the single secret environment variable `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON`. With the current browser-first deployment, the non-secret transport settings are: + +```env +ZOOM_RECORDING_TRANSPORT=browser +ZOOM_RTMS_FALLBACK_ENABLED=true +ZOOM_RTMS_CUSTOMER_CREDENTIAL_MODE=dedicated_customer +``` + +Store `ZOOM_RTMS_CUSTOMER_CREDENTIAL_MODE` in the same 1Password item as the customer JSON. Dedicated mode rejects customer entries without a complete `rtmsApp`, preventing an accidental fallback to the shared Winkk General app. Global `ZOOM_RTMS_*` app and OAuth values are not used for an enabled dedicated entry with `rtmsApp`; they may coexist only for the separate internal/shared profiles. After updating the secret, restart the process or let the 1Password operator restart the pods, then verify one controlled meeting for each exact team ID. + +## Customer onboarding checklist + +1. Confirm the customer's exact Winkk `teamId` from the job payload. +2. For Mode B, ensure the shared Winkk General app is approved for external authorization. For Mode C, activate the customer's private General app. +3. For Mode B, have the customer admin authorize the shared Winkk app. For Mode C, have the customer activate its own app. +4. Enable shared real-time meeting content in the customer's Zoom account settings. +5. Have the customer create and activate their S2S OAuth app with the required admin scope; for Mode C, also collect the dedicated General-app values and unique webhook ID. +6. Obtain the account ID, client ID, client secret, and Zoom participant user ID securely. +7. Add an enabled exact entry to the customer JSON in the correct environment. +8. Restart the process or pods so the environment is reloaded. +9. Start a controlled meeting and verify the webhook, stream connection, finalization, and upload. + +## Own and external meetings + +| Scenario | Browser | RTMS REST start | +| --- | --- | --- | +| Customer hosts the meeting | Can join as a visible guest | Supported when the app and user are authorized | +| Customer is invited to an external meeting and has joined | Can join as a visible guest | Supported subject to host policy and approval | +| Customer only received a shared public link | Can attempt a visible guest join | The link alone is insufficient | +| Customer is the actual host but has not joined | Can attempt a visible guest join | May start before the host only when Zoom's Join Before Host setting permits it | +| Customer is an alternate host or invited participant but has not joined | Can attempt a visible guest join | Unavailable until that authorized user joins | + +For an external meeting, an authorized alternate host or invited participant must have joined; the meeting host or a designated alternate host must also be present. The actual host is the exception: Zoom can permit RTMS before the host joins when Join Before Host is enabled. The meeting host can allow, require approval for, or reject RTMS access. + +An in-meeting Zoom App can also call `startRTMS()` after the user opens it and the host approves. That in-meeting UI is not implemented in this repository. + +## Dev, production, and 1Password + +Use separate Zoom development and production credentials and separate 1Password vault items. + +Production RTMS workloads must run on `linux/amd64`. The Zoom RTMS SDK rejects Linux ARM64 even if a multi-architecture meeting-bot image is available; pin RTMS-capable pods to x86-64 nodes. + +Recommended items: + +- `Meeting Bot App Secrets`: shared General app credentials, internal S2S credentials, Sentry, and other application secrets. +- `Meeting Bot Customer RTMS Credentials`: `ZOOM_RTMS_CUSTOMER_CREDENTIAL_MODE` and `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON`. + +The automatically generated 1Password `Password` field is not used by the meeting bot. The internal team ID and transport flags are not secrets and can live in the deployment configuration. + +Configure a separate public HTTPS `/zoom/rtms/webhook` endpoint for each environment. Dedicated apps append `/apps/`. Verify the rendered URL against the current infrastructure before entering it in Zoom. + +After changing a secret, confirm that the 1Password operator updated the Kubernetes Secret and restarted the meeting-bot pods. Never print secret values while checking the deployment. + +## Rotation and offboarding + +- Rotate a client secret immediately if it appears in chat, a screenshot, a log, or source control. +- Update every environment that uses the credential. +- Set a customer entry to `enabled: false` to revoke RTMS for that team, then restart the pods. +- Remove the entry after the retention period required by the operating process. +- When a customer is offboarded, have that customer remove its installation of the shared General app from its Zoom account. Do not disable the shared app globally while other tenants use it. + +Disabled and invalid customer entries never fall back to internal credentials. + +## Troubleshooting + +| Symptom | Check | +| --- | --- | +| Missing credentials for a team | Exact job `teamId`, spelling, JSON key, and `enabled` | +| Invalid customer configuration | JSON syntax, all required strings, and a complete unique `rtmsApp` when dedicated | +| OAuth token request fails | Customer account ID, S2S client ID, secret, activation, and scopes | +| Zoom code `3000` | Meeting has not started; the bot retries until the join deadline | +| Zoom code `2308` or `2309` | Participant role or external-host authorization; customer credentials wait for approval, while global/internal credentials fail immediately | +| Zoom code `2310` | RTMS entitlement, app authorization, account setting, meeting state, and scope | +| Zoom code `2312` | `participantUserId` is incorrect | +| Zoom code `13262` | The General app client ID is missing from Zoom's "Allow apps to access meeting content" setting | +| Zoom code `13267` | RTMS app access is disabled in Zoom settings | +| Zoom code `13273` | The meeting does not support RTMS | +| No fresh start event | Correct shared/dedicated webhook URL, webhook secret, event subscriptions, Redis, and operator ID | +| SDK unavailable | Run RTMS on Linux x64 or macOS arm64 | +| Duplicate recording rejected | Another stream is already reserved for the meeting and operator | + +The meeting bot waits up to `JOIN_WAIT_TIME_MINUTES` for initial authorization and a fresh `meeting.rtms_started` event. + +## Current product boundary + +Customer onboarding is manual and team-scoped. The repository does not yet implement: + +- a Zoom OAuth authorization-code callback, +- encrypted refresh-token storage per customer, +- a self-service "Connect Zoom" flow, +- per-user RTMS enablement within one Winkk team, +- per-team primary transport selection, or +- an in-meeting `startRTMS()` Zoom App interface. + +For self-service onboarding, use a reviewed shared General app and store OAuth grants dynamically per Winkk team instead of collecting customer S2S secrets. + +## Zoom references + +- [Add Realtime Media Streams to your app](https://developers.zoom.us/docs/rtms/meetings/add-features/) +- [Work with RTMS streams](https://developers.zoom.us/docs/rtms/meetings/work-with-streams/) +- [Zoom OAuth 2.0](https://developers.zoom.us/docs/integrations/oauth/) +- [Zoom app distribution](https://developers.zoom.us/docs/distribute/) +- [Sharing private and Beta apps](https://developers.zoom.us/docs/distribute/sharing-private-and-beta-apps/) diff --git a/package-lock.json b/package-lock.json index 09a4459e..45d96e5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,40 +1,36 @@ { "name": "meeting-bot", - "version": "1.2.34", + "version": "1.3.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.34", + "version": "1.3.16", "license": "MIT", "dependencies": { - "@aws-sdk/client-s3": "^3.787.0", - "@aws-sdk/lib-storage": "^3.876.0", - "@azure/identity": "^4.13.0", - "@azure/storage-blob": "^12.29.1", - "@google-cloud/storage": "^7.16.0", - "axios": "^1.7.7", + "@aws-sdk/client-s3": "^3.1089.0", + "@aws-sdk/lib-storage": "^3.1089.0", + "@azure/identity": "^4.13.1", + "@azure/storage-blob": "^12.33.0", + "@google-cloud/storage": "^7.21.0", + "@sentry/node": "^10.66.0", + "axios": "^1.18.1", "dotenv": "^16.4.5", - "express": "^4.19.2", + "express": "^4.22.2", "fs-extra": "^11.2.0", "moment": "^2.30.1", "moment-timezone": "^0.5.46", - "playwright": "^1.45.3", - "playwright-extra": "^4.3.6", - "playwright-extra-plugin-stealth": "^0.0.1", + "playwright": "^1.61.1", "prom-client": "^15.1.3", - "puppeteer": "^22.15.0", - "puppeteer-extra": "^3.3.6", - "puppeteer-extra-plugin-stealth": "^2.11.2", "redis": "^5.8.1", "serialize-error": "^12.0.0", "ts-node": "^10.9.2", - "uuid": "^11.1.0", + "uuid": "^11.1.1", "winston": "^3.17.0" }, "devDependencies": { - "@playwright/test": "^1.45.3", + "@playwright/test": "^1.61.1", "@types/express": "^4.17.21", "@types/fs-extra": "^11.0.4", "@types/node": "^22.1.0", @@ -43,553 +39,280 @@ "@typescript-eslint/parser": "^5.55.0", "eslint": "^8.36.0", "nodemon": "^3.1.4" - } - }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/crc32c": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", - "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", - "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" + "node": ">=22.0.0" }, - "engines": { - "node": ">=14.0.0" + "optionalDependencies": { + "@zoom/rtms": "1.1.0" } }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "node_modules/@apm-js-collab/code-transformer": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.18.0.tgz", + "integrity": "sha512-aN3Oq8r1J3gPJtCwErP664gM0+HhM1I1lujPr9TMTCcEl/joQQbpGpeMdts9B1+W2wHMsvioDMv5F4PvMWE6gw==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" + "@types/estree": "^1.0.8", + "astring": "^1.9.0", + "esquery": "^1.7.0", + "meriyah": "^6.1.4", + "semifies": "^1.0.0", + "source-map": "^0.6.0" }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" + "bin": { + "code-transformer": "cli.js" } }, - "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", + "node_modules/@apm-js-collab/code-transformer-bundler-plugins": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.6.2.tgz", + "integrity": "sha512-5vBrtIEL+UVbO0YWWoyYG4QMgR+ZfnIL3xlteIkAmU7YaAPhc28k3md/NM14tnkfXKjKOn9yUzEA9AYUmNpvJg==", + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" + "@apm-js-collab/code-transformer": "^0.18.0", + "es-module-lexer": "^2.1.0", + "magic-string": "^0.30.21", + "module-details-from-path": "^1.0.4" }, "engines": { - "node": ">=14.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "node_modules/@apm-js-collab/tracing-hooks": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.13.0.tgz", + "integrity": "sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" + "@apm-js-collab/code-transformer": "^0.18.0", + "debug": "^4.4.1", + "module-details-from-path": "^1.0.4" } }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.18.tgz", + "integrity": "sha512-IImkbEyXdV6/uaF5r6Wkk+8718mQw1ll83j0a4a30R3JM/rHVFdWAiT4jtJpFjJiIwM/oJ6SxIxr0z2TaQUGqw==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.876.0.tgz", - "integrity": "sha512-rrdrB0IlHfRaY+qxo87iSPJJxjCZ2WIV0wKi0EWz02yBpq17c0o6Vzc8f1+ksR+IZGkGttQnD2j4UpItMdLSKg==", + "version": "3.1089.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1089.0.tgz", + "integrity": "sha512-Sc+JOtNeP+v+XdP9Rb4Hh+uhiNO2hh/CZQbKYooNakhOIgK6/K0cjoDCEGeFmkG0vf2DopTmSctzKlcKwy1BPw==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha1-browser": "5.2.0", - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.876.0", - "@aws-sdk/credential-provider-node": "3.876.0", - "@aws-sdk/middleware-bucket-endpoint": "3.873.0", - "@aws-sdk/middleware-expect-continue": "3.873.0", - "@aws-sdk/middleware-flexible-checksums": "3.876.0", - "@aws-sdk/middleware-host-header": "3.873.0", - "@aws-sdk/middleware-location-constraint": "3.873.0", - "@aws-sdk/middleware-logger": "3.876.0", - "@aws-sdk/middleware-recursion-detection": "3.873.0", - "@aws-sdk/middleware-sdk-s3": "3.876.0", - "@aws-sdk/middleware-ssec": "3.873.0", - "@aws-sdk/middleware-user-agent": "3.876.0", - "@aws-sdk/region-config-resolver": "3.873.0", - "@aws-sdk/signature-v4-multi-region": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.873.0", - "@aws-sdk/util-user-agent-browser": "3.873.0", - "@aws-sdk/util-user-agent-node": "3.876.0", - "@aws-sdk/xml-builder": "3.873.0", - "@smithy/config-resolver": "^4.1.5", - "@smithy/core": "^3.8.0", - "@smithy/eventstream-serde-browser": "^4.0.5", - "@smithy/eventstream-serde-config-resolver": "^4.1.3", - "@smithy/eventstream-serde-node": "^4.0.5", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/hash-blob-browser": "^4.0.5", - "@smithy/hash-node": "^4.0.5", - "@smithy/hash-stream-node": "^4.0.5", - "@smithy/invalid-dependency": "^4.0.5", - "@smithy/md5-js": "^4.0.5", - "@smithy/middleware-content-length": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.18", - "@smithy/middleware-retry": "^4.1.19", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.26", - "@smithy/util-defaults-mode-node": "^4.0.26", - "@smithy/util-endpoints": "^3.0.7", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@smithy/util-stream": "^4.2.4", - "@smithy/util-utf8": "^4.0.0", - "@smithy/util-waiter": "^4.0.7", - "@types/uuid": "^9.0.1", - "tslib": "^2.6.2", - "uuid": "^9.0.1" + "@aws-sdk/checksums": "^3.1000.18", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/credential-provider-node": "^3.972.70", + "@aws-sdk/middleware-sdk-s3": "^3.972.64", + "@aws-sdk/signature-v4-multi-region": "^3.996.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/fetch-http-handler": "^5.6.6", + "@smithy/node-http-handler": "^4.9.6", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-sso": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.876.0.tgz", - "integrity": "sha512-Vf0PMF7HVpvllrfPODnBZmlz6kT/y2AvOt1RQG3+qD0VrHWzShc5nwgRZ+yyP3xkKVhZsQ3sJapfZTFnjqMOYA==", + "node_modules/@aws-sdk/core": { + "version": "3.975.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.3.tgz", + "integrity": "sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.876.0", - "@aws-sdk/middleware-host-header": "3.873.0", - "@aws-sdk/middleware-logger": "3.876.0", - "@aws-sdk/middleware-recursion-detection": "3.873.0", - "@aws-sdk/middleware-user-agent": "3.876.0", - "@aws-sdk/region-config-resolver": "3.873.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.873.0", - "@aws-sdk/util-user-agent-browser": "3.873.0", - "@aws-sdk/util-user-agent-node": "3.876.0", - "@smithy/config-resolver": "^4.1.5", - "@smithy/core": "^3.8.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/hash-node": "^4.0.5", - "@smithy/invalid-dependency": "^4.0.5", - "@smithy/middleware-content-length": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.18", - "@smithy/middleware-retry": "^4.1.19", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.26", - "@smithy/util-defaults-mode-node": "^4.0.26", - "@smithy/util-endpoints": "^3.0.7", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@smithy/util-utf8": "^4.0.0", + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.36", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.29.4", + "@smithy/signature-v4": "^5.6.5", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/core": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.876.0.tgz", - "integrity": "sha512-sVFBFkdoPOPyY13NaXO1E/R9O5J6ixzHnnRbqrbXYM2QQgLNPTKIiRtmVEuVoFV9YULg+/aKm7caix8m468y9w==", + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.59.tgz", + "integrity": "sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@aws-sdk/xml-builder": "3.873.0", - "@smithy/core": "^3.8.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/property-provider": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-utf8": "^4.0.0", - "fast-xml-parser": "5.2.5", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/core/node_modules/fast-xml-parser": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", - "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "strnum": "^2.1.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/core/node_modules/strnum": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", - "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.876.0.tgz", - "integrity": "sha512-cof7lwp2AlrAfRs0pt4W2KMS2VMBvEmpcti1UOFfSJIqkn+cyJliMJ8LHg22GI+kUexjvxdAqSbf3M7OHvEW+w==", + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.61.tgz", + "integrity": "sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/fetch-http-handler": "^5.6.6", + "@smithy/node-http-handler": "^4.9.6", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.876.0.tgz", - "integrity": "sha512-wzmef2NBp2+X1l8D4Q8hx1G8oI3+WdvLdPev9VnVpRYZxYGRWVPl++wvCBsCn/ZL0mdWopPkhHA3kFexQhMzvg==", + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.4.tgz", + "integrity": "sha512-e6ZvVsj90aRALf1kHP+J4iqC1496ZpVgqI/+u0LJ5HL7q7ATauGy4gdDvRCP13L1pN/fMiZLah162PGIYkbUVQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/property-provider": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/util-stream": "^4.2.4", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/credential-provider-env": "^3.972.59", + "@aws-sdk/credential-provider-http": "^3.972.61", + "@aws-sdk/credential-provider-login": "^3.972.66", + "@aws-sdk/credential-provider-process": "^3.972.59", + "@aws-sdk/credential-provider-sso": "^3.973.3", + "@aws-sdk/credential-provider-web-identity": "^3.972.65", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/credential-provider-imds": "^4.4.9", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.876.0.tgz", - "integrity": "sha512-JHbW6fqnJsVjGHCyko7B0NVPT1nEAPxkM3CGjUcVGsHgJBkxOLVCMQqTRyHcDdeHR2qeojlLoOHRz97xIHQjYw==", + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.66", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.66.tgz", + "integrity": "sha512-g2fsqm87r/nKthLZ0VkkDBElkGg0PvSa8d97HQ6EilMbJTZ6hxa8FxkSZyJfgPfFdZn0TTmkOffQmTSUcAHIng==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/credential-provider-env": "3.876.0", - "@aws-sdk/credential-provider-http": "3.876.0", - "@aws-sdk/credential-provider-process": "3.876.0", - "@aws-sdk/credential-provider-sso": "3.876.0", - "@aws-sdk/credential-provider-web-identity": "3.876.0", - "@aws-sdk/nested-clients": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.876.0.tgz", - "integrity": "sha512-eHbNt1+Hi43e8ANnwf6toapLSxfMiyGq459y3Uh6i7NBOiWWKEsOVcgOfUC3RCoqeikxovt1tFM2cEElWUIOhg==", + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.70.tgz", + "integrity": "sha512-3xzvkGdykBunxqh8WudmUpSyLWvIhfI6aBQo1b5rb3mDO5mNLadK+0hiI0qBQBMVynJbfLO+Ajy9dztMwy9O8w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "3.876.0", - "@aws-sdk/credential-provider-http": "3.876.0", - "@aws-sdk/credential-provider-ini": "3.876.0", - "@aws-sdk/credential-provider-process": "3.876.0", - "@aws-sdk/credential-provider-sso": "3.876.0", - "@aws-sdk/credential-provider-web-identity": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", + "@aws-sdk/credential-provider-env": "^3.972.59", + "@aws-sdk/credential-provider-http": "^3.972.61", + "@aws-sdk/credential-provider-ini": "^3.973.4", + "@aws-sdk/credential-provider-process": "^3.972.59", + "@aws-sdk/credential-provider-sso": "^3.973.3", + "@aws-sdk/credential-provider-web-identity": "^3.972.65", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/credential-provider-imds": "^4.4.9", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.876.0.tgz", - "integrity": "sha512-SMX4OlHvspu3gF4hxe7WAnZFhxpiCye+WlBSVoWfW/i9XNhtrZS1JMr29MK34GlCTk9qO7FlRwds/Z5k7xPpHg==", + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.59.tgz", + "integrity": "sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.876.0.tgz", - "integrity": "sha512-iP5dz9XqwePbgnh7Bdrq5e1319JpCRKLyomUfHH1XVeXkIHmwIJdmTj1Upeo1J8L/5cLHmhXAN6CTN11bLo8SA==", + "version": "3.973.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.3.tgz", + "integrity": "sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-sso": "3.876.0", - "@aws-sdk/core": "3.876.0", - "@aws-sdk/token-providers": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/token-providers": "3.1088.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.876.0.tgz", - "integrity": "sha512-q/XSCP1uae5aB9veM8zcm6Gqu6A4ckX9ZbhHgCzURXVJDwp+nINW1hM9vppMjGw3ND9Ibx/adR+KfTI0TDMzqw==", + "version": "3.972.65", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.65.tgz", + "integrity": "sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/nested-clients": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/lib-storage": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.876.0.tgz", - "integrity": "sha512-6wydbk8enmPQmj8I0NvRhDcq3J5GhY72RBwQKyShQdD8q6xLEbSnJAudTKSmsVzfjnL8hA/cP/dBY+6T6RvlYQ==", + "version": "3.1089.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1089.0.tgz", + "integrity": "sha512-Ly0bsr49in42ZRtnRbwaBo/0xv7kCWSu1/qT9V56s9JGsunJzzmTIFlvz7dwy6ISK/Dh3mFc5vrO91sj3G3YhQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.18", - "@smithy/smithy-client": "^4.4.10", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "buffer": "5.6.0", "events": "3.3.0", "stream-browserify": "3.0.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "@aws-sdk/client-s3": "^3.876.0" + "@aws-sdk/client-s3": "^3.1089.0" } }, "node_modules/@aws-sdk/lib-storage/node_modules/buffer": { @@ -602,459 +325,189 @@ "ieee754": "^1.1.4" } }, - "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.873.0.tgz", - "integrity": "sha512-b4bvr0QdADeTUs+lPc9Z48kXzbKHXQKgTvxx/jXDgSW9tv4KmYPO1gIj6Z9dcrBkRWQuUtSW3Tu2S5n6pe+zeg==", + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.64", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.64.tgz", + "integrity": "sha512-RBi43anhDBUv+HCfxCOXwGOE7GmT4n7ChV04Mwr22RhXTNcamW/iWnJlOotDPCZSrJ4dEvhZSiWWQMwLX+ZhFA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-arn-parser": "3.873.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-config-provider": "^4.0.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/signature-v4-multi-region": "^3.996.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.873.0.tgz", - "integrity": "sha512-GIqoc8WgRcf/opBOZXFLmplJQKwOMjiOMmDz9gQkaJ8FiVJoAp8EGVmK2TOWZMQUYsavvHYsHaor5R2xwPoGVg==", + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.33.tgz", + "integrity": "sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/signature-v4-multi-region": "^3.996.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/fetch-http-handler": "^5.6.6", + "@smithy/node-http-handler": "^4.9.6", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.876.0.tgz", - "integrity": "sha512-Xfb9/XP0WcQq/yJxUubfzMUF0AYSX10UUIRbCJog0/lnDNocEiGEIaarwuQzoxb9QW9TQ1l5dDc/5bOMa1YVGw==", + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.41.tgz", + "integrity": "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@aws-crypto/crc32c": "5.2.0", - "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/is-array-buffer": "^4.0.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-stream": "^4.2.4", - "@smithy/util-utf8": "^4.0.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.873.0.tgz", - "integrity": "sha512-KZ/W1uruWtMOs7D5j3KquOxzCnV79KQW9MjJFZM/M0l6KI8J6V3718MXxFHsTjUE4fpdV6SeCNLV1lwGygsjJA==", + "node_modules/@aws-sdk/token-providers": { + "version": "3.1088.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1088.0.tgz", + "integrity": "sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.873.0.tgz", - "integrity": "sha512-r+hIaORsW/8rq6wieDordXnA/eAu7xAPLue2InhoEX6ML7irP52BgiibHLpt9R0psiCzIHhju8qqKa4pJOrmiw==", + "node_modules/@aws-sdk/types": { + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.876.0.tgz", - "integrity": "sha512-cpWJhOuMSyz9oV25Z/CMHCBTgafDCbv7fHR80nlRrPdPZ8ETNsahwRgltXP1QJJ8r3X/c1kwpOR7tc+RabVzNA==", + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz", + "integrity": "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.873.0.tgz", - "integrity": "sha512-OtgY8EXOzRdEWR//WfPkA/fXl0+WwE8hq0y9iw2caNyKPtca85dzrrZWnPqyBK/cpImosrpR1iKMYr41XshsCg==", + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.876.0.tgz", - "integrity": "sha512-h+TDs9EKAfXnrkogQpQz3o11zvs6Vh9+ehxyd35OcM7evnDeoV4GFjjnAKq+MxbBk/5Ewnvng+d6/WQDvMbj7Q==", - "license": "Apache-2.0", + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-arn-parser": "3.873.0", - "@smithy/core": "^3.8.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/util-config-provider": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-stream": "^4.2.4", - "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-ssec": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.873.0.tgz", - "integrity": "sha512-AF55J94BoiuzN7g3hahy0dXTVZahVi8XxRBLgzNp6yQf0KTng+hb/V9UQZVYY1GZaDczvvvnqC54RGe9OZZ9zQ==", - "license": "Apache-2.0", + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "license": "MIT", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.876.0.tgz", - "integrity": "sha512-FR+8INfnbNv32QDQ5szxkWX6mB/QgezfNyx8LnAh1ErISZMmEFBxXXir+ZOfuV8vsmal1a6cy9qmnMNDaNnaNQ==", - "license": "Apache-2.0", + "node_modules/@azure/core-client": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "license": "MIT", "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.873.0", - "@smithy/core": "^3.8.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.876.0.tgz", - "integrity": "sha512-R4TZrkM2gUElTsotk8mt3y7iLG8TNi1LL1wgVdEEWSLOYTaFyglGdoNBMtEeP7lmXilaTy00AbYF6BakJvSTHg==", - "license": "Apache-2.0", + "node_modules/@azure/core-http-compat": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.5.0.tgz", + "integrity": "sha512-BoSmXPx2er1Ai+wKlDvj29jIQespCNBwEmKyZVHO2kEFsWbGjAjwMCGzug3DJM5/QYIV3vej0S1zcU5bq9fa8w==", + "license": "MIT", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.876.0", - "@aws-sdk/middleware-host-header": "3.873.0", - "@aws-sdk/middleware-logger": "3.876.0", - "@aws-sdk/middleware-recursion-detection": "3.873.0", - "@aws-sdk/middleware-user-agent": "3.876.0", - "@aws-sdk/region-config-resolver": "3.873.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.873.0", - "@aws-sdk/util-user-agent-browser": "3.873.0", - "@aws-sdk/util-user-agent-node": "3.876.0", - "@smithy/config-resolver": "^4.1.5", - "@smithy/core": "^3.8.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/hash-node": "^4.0.5", - "@smithy/invalid-dependency": "^4.0.5", - "@smithy/middleware-content-length": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.18", - "@smithy/middleware-retry": "^4.1.19", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.26", - "@smithy/util-defaults-mode-node": "^4.0.26", - "@smithy/util-endpoints": "^3.0.7", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" + "@azure/abort-controller": "^2.1.2" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.873.0.tgz", - "integrity": "sha512-q9sPoef+BBG6PJnc4x60vK/bfVwvRWsPgcoQyIra057S/QGjq5VkjvNk6H8xedf6vnKlXNBwq9BaANBXnldUJg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/types": "^4.3.2", - "@smithy/util-config-provider": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "tslib": "^2.6.2" + "node": ">=22.0.0" }, - "engines": { - "node": ">=18.0.0" + "peerDependencies": { + "@azure/core-client": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0" } }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.876.0.tgz", - "integrity": "sha512-OMDcuaVlC2rbze92w4QcNfuEA0IeT2GsT1ByZCwe+Y9tZwxzj7fCiOOU0UmJfa+juuQ/YBzVYxnkrkz3Rg6DEw==", - "license": "Apache-2.0", + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "license": "MIT", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/types": "^4.3.2", + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.876.0.tgz", - "integrity": "sha512-iU08kaQbhXnY0CC2TBcr7y/2PqPwZP2CTWX/Rbq0NvhOyteikfh7ASC+bRfLUp0XMSHKvSb+w2dh8a0lvx4oHg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/nested-clients": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.862.0.tgz", - "integrity": "sha512-Bei+RL0cDxxV+lW2UezLbCYYNeJm6Nzee0TpW0FfyTRBhH9C1XQh4+x+IClriXvgBnRquTMMYsmJfvx8iyLKrg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.873.0.tgz", - "integrity": "sha512-qag+VTqnJWDn8zTAXX4wiVioa0hZDQMtbZcGRERVnLar4/3/VIKBhxX2XibNQXFu1ufgcRn4YntT/XEPecFWcg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.873.0.tgz", - "integrity": "sha512-YByHrhjxYdjKRf/RQygRK1uh0As1FIi9+jXTcIEX/rBgN8mUByczr2u4QXBzw7ZdbdcOBMOkPnLRjNOWW1MkFg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-endpoints": "^3.0.7", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.723.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.723.0.tgz", - "integrity": "sha512-Yf2CS10BqK688DRsrKI/EO6B8ff5J86NXe4C+VCysK7UOgN0l1zOTeTukZ3H8Q9tYYX3oaF1961o8vRkFm7Nmw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.873.0.tgz", - "integrity": "sha512-AcRdbK6o19yehEcywI43blIBhOCSo6UgyWcuOJX5CFF8k39xm1ILCjQlRRjchLAxWrm0lU0Q7XV90RiMMFMZtA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.876.0.tgz", - "integrity": "sha512-/ZIaeUt60JBdI0mNc7sZ8v3Tuzp8Pbe4gIAYnppGyF4KV8QA+Yu8tp2bGHfkKn150t1uvQ6P/4CwFfoGF34dzg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-user-agent": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.873.0.tgz", - "integrity": "sha512-kLO7k7cGJ6KaHiExSJWojZurF7SnGMDHXRuQunFnEoD0n1yB6Lqy/S/zHiQ7oJnBhPr9q0TW9qFkrsZb1Uc54w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/abort-controller": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", - "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/core-auth": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", - "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-util": "^1.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-client": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", - "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-rest-pipeline": "^1.22.0", - "@azure/core-tracing": "^1.3.0", - "@azure/core-util": "^1.13.0", - "@azure/logger": "^1.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-http-compat": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.3.1.tgz", - "integrity": "sha512-az9BkXND3/d5VgdRRQVkiJb2gOmDU8Qcq4GvjtBmDICNiQ9udFmDk4ZpSB5Qq1OmtDJGlQAfBaS4palFsazQ5g==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-client": "^1.10.0", - "@azure/core-rest-pipeline": "^1.22.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-lro": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", - "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-util": "^1.2.0", - "@azure/logger": "^1.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/core-paging": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", - "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", - "license": "MIT", + "node_modules/@azure/core-paging": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", + "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", + "license": "MIT", "dependencies": { "tslib": "^2.6.2" }, @@ -1063,9 +516,9 @@ } }, "node_modules/@azure/core-rest-pipeline": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz", - "integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==", + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz", + "integrity": "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==", "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.1.2", @@ -1073,11 +526,11 @@ "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", - "@typespec/ts-http-runtime": "^0.3.0", + "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/core-tracing": { @@ -1119,40 +572,10 @@ "node": ">=20.0.0" } }, - "node_modules/@azure/core-xml/node_modules/fast-xml-parser": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.1.tgz", - "integrity": "sha512-jbNkWiv2Ec1A7wuuxk0br0d0aTMUtQ4IkL+l/i1r9PRf6pLXjDgsBsWwO+UyczmQlnehi4Tbc8/KIvxGQe+I/A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "strnum": "^2.1.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/@azure/core-xml/node_modules/strnum": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", - "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/@azure/identity": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.0.tgz", - "integrity": "sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.0.0", @@ -1162,8 +585,8 @@ "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^4.2.0", - "@azure/msal-node": "^3.5.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", "open": "^10.1.0", "tslib": "^2.2.0" }, @@ -1172,7 +595,7 @@ } }, "node_modules/@azure/logger": { - "version": "1.3.0", + "version": "1.3.1", "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", "license": "MIT", @@ -1185,53 +608,43 @@ } }, "node_modules/@azure/msal-browser": { - "version": "4.26.1", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.26.1.tgz", - "integrity": "sha512-GGCIsZXxyNm5QcQZ4maA9q+9UWmM+/87G+ybvPkrE32el1URSa9WYt0t67ks3/P0gspZX9RoEqyLqJ/X/JDnBQ==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.17.1.tgz", + "integrity": "sha512-zBhRGzABKSI7hfWh5EaZmril5ybZ7imBN1qEZl5sDTaelr+l8SnPjZO50Q4dnKnm347YPIlBMSnXKZyh3Yu5DQ==", "license": "MIT", "dependencies": { - "@azure/msal-common": "15.13.1" + "@azure/msal-common": "16.11.2" }, "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-common": { - "version": "15.13.1", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.13.1.tgz", - "integrity": "sha512-vQYQcG4J43UWgo1lj7LcmdsGUKWYo28RfEvDQAEMmQIMjSFufvb+pS0FJ3KXmrPmnWlt1vHDl3oip6mIDUQ4uA==", + "version": "16.11.2", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.11.2.tgz", + "integrity": "sha512-yDhtBOGDCdK9ipQ9g3+wmlMEPnZx2pXaDicDd9jYyR1L+7lEbvEohTDmF5qejZDutZY3m9pWPxeYxzNC701A2w==", "license": "MIT", "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-node": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.2.tgz", - "integrity": "sha512-dQrex2LiXwlCe9WuBHnCsY+xxLyuMXSd2SDEYJuhqB7cE8u6QafiC1xy8j8eBjGO30AsRi2M6amH0ZKk7vJpjA==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.4.1.tgz", + "integrity": "sha512-yqgoyOIMCH7TNaSLMBTP+4LUlbMMf1zgC8nzOFG95lmW82CmsAEtUT0J93e4BdqDcnX5qle/9X+yb7A8Mw9M0g==", "license": "MIT", "dependencies": { - "@azure/msal-common": "15.13.1", - "jsonwebtoken": "^9.0.0", - "uuid": "^8.3.0" + "@azure/msal-common": "16.11.2", + "jsonwebtoken": "^9.0.0" }, "engines": { - "node": ">=16" - } - }, - "node_modules/@azure/msal-node/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" + "node": ">=20" } }, "node_modules/@azure/storage-blob": { - "version": "12.29.1", - "resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.29.1.tgz", - "integrity": "sha512-7ktyY0rfTM0vo7HvtK6E3UvYnI9qfd6Oz6z/+92VhGRveWng3kJwMKeUpqmW/NmwcDNbxHpSlldG+vsUnRFnBg==", + "version": "12.33.0", + "resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.33.0.tgz", + "integrity": "sha512-2SX8oP8PyblUcAFZSg39c8Ls+tFjavM6sBeV+qpw33mRzRhI/5hrFJmJ/x0H9xx5l6ECPvgSP8uPxqTeVbHNIA==", "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.1.2", @@ -1245,24 +658,24 @@ "@azure/core-util": "^1.11.0", "@azure/core-xml": "^1.4.5", "@azure/logger": "^1.1.4", - "@azure/storage-common": "^12.1.1", + "@azure/storage-common": "^12.4.1", "events": "^3.0.0", "tslib": "^2.8.1" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/storage-common": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/@azure/storage-common/-/storage-common-12.1.1.tgz", - "integrity": "sha512-eIOH1pqFwI6UmVNnDQvmFeSg0XppuzDLFeUNO/Xht7ODAzRLgGDh7h550pSxoA+lPDxBl1+D2m/KG3jWzCUjTg==", + "version": "12.4.1", + "resolved": "https://registry.npmjs.org/@azure/storage-common/-/storage-common-12.4.1.tgz", + "integrity": "sha512-t14unw/WofGDUi7TKJrsyXyPsN+NLgRm7hMaq0llxNmTIzt7f257+6LE6FKIJPh88zLj6M7LPvzve0fEYg/L3A==", "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-http-compat": "^2.2.0", - "@azure/core-rest-pipeline": "^1.19.1", + "@azure/core-rest-pipeline": "^1.24.0", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.1.4", @@ -1273,40 +686,6 @@ "node": ">=20.0.0" } }, - "node_modules/@babel/code-frame": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", - "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", - "dependencies": { - "@babel/highlight": "^7.24.7", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", - "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", - "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", - "dependencies": { - "@babel/helper-validator-identifier": "^7.24.7", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -1424,18 +803,18 @@ } }, "node_modules/@google-cloud/promisify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", - "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-2.0.4.tgz", + "integrity": "sha512-j8yRSSqswWi1QqUGKVEKOG03Q7qOoZP6/h2zN2YO+F5h2+DHU0bSrHCK9Y7lo2DI9fBd8qGAw795sf+3Jva4yA==", "license": "Apache-2.0", "engines": { - "node": ">=14" + "node": ">=10" } }, "node_modules/@google-cloud/storage": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.16.0.tgz", - "integrity": "sha512-7/5LRgykyOfQENcm6hDKP8SX/u9XxE5YOiWOkgkwcoO+cG8xT/cyOvp9wwN3IxfdYgpHs8CE7Nq2PKX2lNaEXw==", + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.21.0.tgz", + "integrity": "sha512-l+IFTkd+6Y5LoAuXyYCKNAKtw/Ci+rAMqgdTB1jv4iZiLhw0rtq+0qjIRbBizXkNzEFmXiXUW0H7sZQQvk1ffA==", "license": "Apache-2.0", "dependencies": { "@google-cloud/paginator": "^5.0.0", @@ -1444,15 +823,14 @@ "abort-controller": "^3.0.0", "async-retry": "^1.3.3", "duplexify": "^4.1.3", - "fast-xml-parser": "^4.4.1", + "fast-xml-parser": "^5.3.4", "gaxios": "^6.0.2", "google-auth-library": "^9.6.3", "html-entities": "^2.5.2", "mime": "^3.0.0", "p-limit": "^3.0.1", "retry-request": "^7.0.0", - "teeny-request": "^9.0.0", - "uuid": "^8.0.0" + "teeny-request": "^9.0.0" }, "engines": { "node": ">=14" @@ -1470,15 +848,6 @@ "node": ">=10.0.0" } }, - "node_modules/@google-cloud/storage/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1517,6 +886,19 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1526,9 +908,10 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.9", @@ -1539,6 +922,18 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1578,852 +973,383 @@ } }, "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", "engines": { "node": ">=8.0.0" } }, - "node_modules/@playwright/test": { - "version": "1.45.3", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.3.tgz", - "integrity": "sha512-UKF4XsBfy+u3MFWEH44hva1Q8Da28G6RFtR2+5saw+jgAFQV5yYnB1fu68Mz7fO+5GJF3wgwAIs0UelU8TxFrA==", - "dev": true, + "node_modules/@opentelemetry/api-logs": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", + "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", + "license": "Apache-2.0", "dependencies": { - "playwright": "1.45.3" - }, - "bin": { - "playwright": "cli.js" + "@opentelemetry/api": "^1.3.0" }, "engines": { - "node": ">=18" + "node": ">=8.0.0" } }, - "node_modules/@puppeteer/browsers": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz", - "integrity": "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA==", + "node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "license": "Apache-2.0", "dependencies": { - "debug": "^4.3.5", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.4.0", - "semver": "^7.6.3", - "tar-fs": "^3.0.6", - "unbzip2-stream": "^1.4.3", - "yargs": "^17.7.2" - }, - "bin": { - "browsers": "lib/cjs/main-cli.js" + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=18" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@redis/bloom": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.8.1.tgz", - "integrity": "sha512-hJOJr/yX6BttnyZ+nxD3Ddiu2lPig4XJjyAK1v7OSHOJNUTfn3RHBryB9wgnBMBdkg9glVh2AjItxIXmr600MA==", - "license": "MIT", + "node_modules/@opentelemetry/instrumentation": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", + "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, "engines": { - "node": ">= 18" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@redis/client": "^5.8.1" + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@redis/client": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.8.1.tgz", - "integrity": "sha512-hD5Tvv7G0t8b3w8ao3kQ4jEPUmUUC6pqA18c8ciYF5xZGfUGBg0olQHW46v6qSt4O5bxOuB3uV7pM6H5wEjBwA==", - "license": "MIT", + "node_modules/@opentelemetry/resources": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", + "license": "Apache-2.0", "dependencies": { - "cluster-key-slot": "1.1.2" + "@opentelemetry/core": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">= 18" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@redis/json": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.8.1.tgz", - "integrity": "sha512-kyvM8Vn+WjJI++nRsIoI9TbdfCs1/TgD0Hp7Z7GiG6W4IEBzkXGQakli+R5BoJzUfgh7gED2fkncYy1NLprMNg==", - "license": "MIT", - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@redis/client": "^5.8.1" - } - }, - "node_modules/@redis/search": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.8.1.tgz", - "integrity": "sha512-CzuKNTInTNQkxqehSn7QiYcM+th+fhjQn5ilTvksP1wPjpxqK0qWt92oYg3XZc3tO2WuXkqDvTujc4D7kb6r/A==", - "license": "MIT", - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@redis/client": "^5.8.1" - } - }, - "node_modules/@redis/time-series": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.8.1.tgz", - "integrity": "sha512-klvdR96U9oSOyqvcectoAGhYlMOnMS3I5UWUOgdBn1buMODiwM/E4Eds7gxldKmtowe4rLJSF1CyIqyZTjy8Ow==", - "license": "MIT", - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@redis/client": "^5.8.1" - } - }, - "node_modules/@smithy/abort-controller": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.5.tgz", - "integrity": "sha512-jcrqdTQurIrBbUm4W2YdLVMQDoL0sA9DTxYd2s+R/y+2U9NLOP7Xf/YqfSg1FZhlZIYEnvk2mwbyvIfdLEPo8g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/chunked-blob-reader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.0.0.tgz", - "integrity": "sha512-+sKqDBQqb036hh4NPaUiEkYFkTUGYzRsn3EuFhyfQfMy6oGHEUJDurLP9Ufb5dasr/XiAmPNMr6wa9afjQB+Gw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/chunked-blob-reader-native": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.0.0.tgz", - "integrity": "sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-base64": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/config-resolver": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.1.5.tgz", - "integrity": "sha512-viuHMxBAqydkB0AfWwHIdwf/PRH2z5KHGUzqyRtS/Wv+n3IHI993Sk76VCA7dD/+GzgGOmlJDITfPcJC1nIVIw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.1.4", - "@smithy/types": "^4.3.2", - "@smithy/util-config-provider": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.8.0.tgz", - "integrity": "sha512-EYqsIYJmkR1VhVE9pccnk353xhs+lB6btdutJEtsp7R055haMJp2yE16eSxw8fv+G0WUY6vqxyYOP8kOqawxYQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/middleware-serde": "^4.0.9", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-stream": "^4.2.4", - "@smithy/util-utf8": "^4.0.0", - "@types/uuid": "^9.0.1", - "tslib": "^2.6.2", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.7.tgz", - "integrity": "sha512-dDzrMXA8d8riFNiPvytxn0mNwR4B3h8lgrQ5UjAGu6T9z/kRg/Xncf4tEQHE/+t25sY8IH3CowcmWi+1U5B1Gw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.1.4", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-codec": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.0.5.tgz", - "integrity": "sha512-miEUN+nz2UTNoRYRhRqVTJCx7jMeILdAurStT2XoS+mhokkmz1xAPp95DFW9Gxt4iF2VBqpeF9HbTQ3kY1viOA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.3.2", - "@smithy/util-hex-encoding": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.0.5.tgz", - "integrity": "sha512-LCUQUVTbM6HFKzImYlSB9w4xafZmpdmZsOh9rIl7riPC3osCgGFVP+wwvYVw6pXda9PPT9TcEZxaq3XE81EdJQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.1.3.tgz", - "integrity": "sha512-yTTzw2jZjn/MbHu1pURbHdpjGbCuMHWncNBpJnQAPxOVnFUAbSIUSwafiphVDjNV93TdBJWmeVAds7yl5QCkcA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-node": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.0.5.tgz", - "integrity": "sha512-lGS10urI4CNzz6YlTe5EYG0YOpsSp3ra8MXyco4aqSkQDuyZPIw2hcaxDU82OUVtK7UY9hrSvgWtpsW5D4rb4g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.0.5.tgz", - "integrity": "sha512-JFnmu4SU36YYw3DIBVao3FsJh4Uw65vVDIqlWT4LzR6gXA0F3KP0IXFKKJrhaVzCBhAuMsrUUaT5I+/4ZhF7aw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-codec": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.1.1.tgz", - "integrity": "sha512-61WjM0PWmZJR+SnmzaKI7t7G0UkkNFboDpzIdzSoy7TByUzlxo18Qlh9s71qug4AY4hlH/CwXdubMtkcNEb/sQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.1.3", - "@smithy/querystring-builder": "^4.0.5", - "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-blob-browser": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.0.5.tgz", - "integrity": "sha512-F7MmCd3FH/Q2edhcKd+qulWkwfChHbc9nhguBlVjSUE6hVHhec3q6uPQ+0u69S6ppvLtR3eStfCuEKMXBXhvvA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/chunked-blob-reader": "^5.0.0", - "@smithy/chunked-blob-reader-native": "^4.0.0", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-node": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.0.5.tgz", - "integrity": "sha512-cv1HHkKhpyRb6ahD8Vcfb2Hgz67vNIXEp2vnhzfxLFGRukLCNEA5QdsorbUEzXma1Rco0u3rx5VTqbM06GcZqQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "@smithy/util-buffer-from": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-stream-node": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.0.5.tgz", - "integrity": "sha512-IJuDS3+VfWB67UC0GU0uYBG/TA30w+PlOaSo0GPm9UHS88A6rCP6uZxNjNYiyRtOcjv7TXn/60cW8ox1yuZsLg==", + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.9.0.tgz", + "integrity": "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.0.5.tgz", - "integrity": "sha512-IVnb78Qtf7EJpoEVo7qJ8BEXQwgC4n3igeJNNKEj/MLYtapnx8A67Zt/J3RXAj2xSO1910zk0LdFiygSemuLow==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" + "node": "^18.19.0 || >=20.6.0" }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.0.0.tgz", - "integrity": "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@smithy/md5-js": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.0.5.tgz", - "integrity": "sha512-8n2XCwdUbGr8W/XhMTaxILkVlw2QebkVTn5tm3HOcbPbOpWg89zr6dPXsH8xbeTsbTXlJvlJNTQsKAIoqQGbdA==", + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.9.0.tgz", + "integrity": "sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.0.5.tgz", - "integrity": "sha512-l1jlNZoYzoCC7p0zCtBDE5OBXZ95yMKlRlftooE5jPWQn4YBPLgsp+oeHp7iMHaTGoUdFqmHOPa8c9G3gBsRpQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" + "node": "^18.19.0 || >=20.6.0" }, - "engines": { - "node": ">=18.0.0" + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.1.18.tgz", - "integrity": "sha512-ZhvqcVRPZxnZlokcPaTwb+r+h4yOIOCJmx0v2d1bpVlmP465g3qpVSf7wxcq5zZdu4jb0H4yIMxuPwDJSQc3MQ==", + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.8.0", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-middleware": "^4.0.5", - "tslib": "^2.6.2" - }, "engines": { - "node": ">=18.0.0" + "node": ">=14" } }, - "node_modules/@smithy/middleware-retry": { - "version": "4.1.19", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.1.19.tgz", - "integrity": "sha512-X58zx/NVECjeuUB6A8HBu4bhx72EoUz+T5jTMIyeNKx2lf+Gs9TmWPNNkH+5QF0COjpInP/xSpJGJ7xEnAklQQ==", + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.1.4", - "@smithy/protocol-http": "^5.1.3", - "@smithy/service-error-classification": "^4.0.7", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@types/uuid": "^9.0.1", - "tslib": "^2.6.2", - "uuid": "^9.0.1" + "playwright": "1.61.1" }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-retry/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@smithy/middleware-serde": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.0.9.tgz", - "integrity": "sha512-uAFFR4dpeoJPGz8x9mhxp+RPjo5wW0QEEIPPPbLXiRRWeCATf/Km3gKIVR5vaP8bN1kgsPhcEeh+IZvUlBv6Xg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-stack": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.0.5.tgz", - "integrity": "sha512-/yoHDXZPh3ocRVyeWQFvC44u8seu3eYzZRveCMfgMOBcNKnAmOvjbL9+Cp5XKSIi9iYA9PECUuW2teDAk8T+OQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-config-provider": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.1.4.tgz", - "integrity": "sha512-+UDQV/k42jLEPPHSn39l0Bmc4sB1xtdI9Gd47fzo/0PbXzJ7ylgaOByVjF5EeQIumkepnrJyfx86dPa9p47Y+w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.1.1.tgz", - "integrity": "sha512-RHnlHqFpoVdjSPPiYy/t40Zovf3BBHc2oemgD7VsVTFFZrU5erFFe0n52OANZZ/5sbshgD93sOh5r6I35Xmpaw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/abort-controller": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/querystring-builder": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/property-provider": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.0.5.tgz", - "integrity": "sha512-R/bswf59T/n9ZgfgUICAZoWYKBHcsVDurAGX88zsiUtOTA/xUAPyiT+qkNCPwFn43pZqN84M4MiUsbSGQmgFIQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/protocol-http": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.1.3.tgz", - "integrity": "sha512-fCJd2ZR7D22XhDY0l+92pUag/7je2BztPRQ01gU5bMChcyI0rlly7QFibnYHzcxDvccMjlpM/Q1ev8ceRIb48w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-builder": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.0.5.tgz", - "integrity": "sha512-NJeSCU57piZ56c+/wY+AbAw6rxCCAOZLCIniRE7wqvndqxcKKDOXzwWjrY7wGKEISfhL9gBbAaWWgHsUGedk+A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "@smithy/util-uri-escape": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-parser": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.0.5.tgz", - "integrity": "sha512-6SV7md2CzNG/WUeTjVe6Dj8noH32r4MnUeFKZrnVYsQxpGSIcphAanQMayi8jJLZAWm6pdM9ZXvKCpWOsIGg0w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/service-error-classification": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.0.7.tgz", - "integrity": "sha512-XvRHOipqpwNhEjDf2L5gJowZEm5nsxC16pAZOeEcsygdjv9A2jdOh3YoDQvOXBGTsaJk6mNWtzWalOB9976Wlg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.5.tgz", - "integrity": "sha512-YVVwehRDuehgoXdEL4r1tAAzdaDgaC9EQvhK0lEbfnbrd0bd5+CTQumbdPryX3J2shT7ZqQE+jPW4lmNBAB8JQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.1.3.tgz", - "integrity": "sha512-mARDSXSEgllNzMw6N+mC+r1AQlEBO3meEAkR/UlfAgnMzJUB3goRBWgip1EAMG99wh36MDqzo86SfIX5Y+VEaw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.0.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-hex-encoding": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-uri-escape": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/smithy-client": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.4.10.tgz", - "integrity": "sha512-iW6HjXqN0oPtRS0NK/zzZ4zZeGESIFcxj2FkWed3mcK8jdSdHzvnCKXSjvewESKAgGKAbJRA+OsaqKhkdYRbQQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.8.0", - "@smithy/middleware-endpoint": "^4.1.18", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-stream": "^4.2.4", - "tslib": "^2.6.2" + "playwright": "cli.js" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" } }, - "node_modules/@smithy/types": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.2.tgz", - "integrity": "sha512-QO4zghLxiQ5W9UZmX2Lo0nta2PuE1sSrXUYDoaB6HMR762C0P7v/HEPHf6ZdglTVssJG1bsrSBxdc3quvDSihw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, + "node_modules/@redis/bloom": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.8.1.tgz", + "integrity": "sha512-hJOJr/yX6BttnyZ+nxD3Ddiu2lPig4XJjyAK1v7OSHOJNUTfn3RHBryB9wgnBMBdkg9glVh2AjItxIXmr600MA==", + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.8.1" } }, - "node_modules/@smithy/url-parser": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.0.5.tgz", - "integrity": "sha512-j+733Um7f1/DXjYhCbvNXABV53NyCRRA54C7bNEIxNPs0YjfRxeMKjjgm2jvTYrciZyCjsicHwQ6Q0ylo+NAUw==", - "license": "Apache-2.0", + "node_modules/@redis/client": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.8.1.tgz", + "integrity": "sha512-hD5Tvv7G0t8b3w8ao3kQ4jEPUmUUC6pqA18c8ciYF5xZGfUGBg0olQHW46v6qSt4O5bxOuB3uV7pM6H5wEjBwA==", + "license": "MIT", "dependencies": { - "@smithy/querystring-parser": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" + "cluster-key-slot": "1.1.2" }, "engines": { - "node": ">=18.0.0" + "node": ">= 18" } }, - "node_modules/@smithy/util-base64": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.0.0.tgz", - "integrity": "sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, + "node_modules/@redis/json": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.8.1.tgz", + "integrity": "sha512-kyvM8Vn+WjJI++nRsIoI9TbdfCs1/TgD0Hp7Z7GiG6W4IEBzkXGQakli+R5BoJzUfgh7gED2fkncYy1NLprMNg==", + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.8.1" } }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.0.0.tgz", - "integrity": "sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, + "node_modules/@redis/search": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.8.1.tgz", + "integrity": "sha512-CzuKNTInTNQkxqehSn7QiYcM+th+fhjQn5ilTvksP1wPjpxqK0qWt92oYg3XZc3tO2WuXkqDvTujc4D7kb6r/A==", + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.8.1" } }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.0.0.tgz", - "integrity": "sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, + "node_modules/@redis/time-series": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.8.1.tgz", + "integrity": "sha512-klvdR96U9oSOyqvcectoAGhYlMOnMS3I5UWUOgdBn1buMODiwM/E4Eds7gxldKmtowe4rLJSF1CyIqyZTjy8Ow==", + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.8.1" } }, - "node_modules/@smithy/util-buffer-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.0.0.tgz", - "integrity": "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.0.0", - "tslib": "^2.6.2" - }, + "node_modules/@sentry/conventions": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz", + "integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==", + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=14" } }, - "node_modules/@smithy/util-config-provider": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.0.0.tgz", - "integrity": "sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==", - "license": "Apache-2.0", + "node_modules/@sentry/core": { + "version": "10.66.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.66.0.tgz", + "integrity": "sha512-9UbgSvds7bMJsP561eWmeyMLcfOmnwxtnx2QuW3yLobzP2Ob7CyJCOzP4tGzlTAGDrzShkFEZhiyuBUKiEK2oQ==", + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" + "@sentry/conventions": "^0.16.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" } }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.0.26", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.26.tgz", - "integrity": "sha512-xgl75aHIS/3rrGp7iTxQAOELYeyiwBu+eEgAk4xfKwJJ0L8VUjhO2shsDpeil54BOFsqmk5xfdesiewbUY5tKQ==", - "license": "Apache-2.0", + "node_modules/@sentry/node": { + "version": "10.66.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.66.0.tgz", + "integrity": "sha512-5Ow7iQiRjaSaEOmqEIkYV368hFFzShIZCXPoj+wX3JtOmKFrsNu6lr8/VJlQs7cyjFS6tzE50PrLrD8iAPS/8w==", + "license": "MIT", "dependencies": { - "@smithy/property-provider": "^4.0.5", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "bowser": "^2.11.0", - "tslib": "^2.6.2" + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/instrumentation": "^0.220.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.66.0", + "@sentry/node-core": "10.66.0", + "@sentry/opentelemetry": "10.66.0", + "@sentry/server-utils": "10.66.0", + "import-in-the-middle": "^3.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" } }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.0.26", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.26.tgz", - "integrity": "sha512-z81yyIkGiLLYVDetKTUeCZQ8x20EEzvQjrqJtb/mXnevLq2+w3XCEWTJ2pMp401b6BkEkHVfXb/cROBpVauLMQ==", - "license": "Apache-2.0", + "node_modules/@sentry/node-core": { + "version": "10.66.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.66.0.tgz", + "integrity": "sha512-SUnXHROqSdSetKgZC1goDEKCuMz3OmQ1h4rxzWeexLyqan+pensZXfLouP6jzZXlA8e/HP7uQg78LnfqYDcZlQ==", + "license": "MIT", "dependencies": { - "@smithy/config-resolver": "^4.1.5", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/property-provider": "^4.0.5", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.66.0", + "@sentry/opentelemetry": "10.66.0", + "import-in-the-middle": "^3.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + } } }, - "node_modules/@smithy/util-endpoints": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.0.7.tgz", - "integrity": "sha512-klGBP+RpBp6V5JbrY2C/VKnHXn3d5V2YrifZbmMY8os7M6m8wdYFoO6w/fe5VkP+YVwrEktW3IWYaSQVNZJ8oQ==", - "license": "Apache-2.0", + "node_modules/@sentry/opentelemetry": { + "version": "10.66.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.66.0.tgz", + "integrity": "sha512-K5Y9IettN9yIOnpqCs40HRLGqaGUoaQ50+ZsLqX2kPCk3TzJVpKZ9icKwaJ4Nxm72QgGVT5Ovfr/9FXbgd3b/Q==", + "license": "MIT", "dependencies": { - "@smithy/node-config-provider": "^4.1.4", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.66.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" } }, - "node_modules/@smithy/util-hex-encoding": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.0.0.tgz", - "integrity": "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==", - "license": "Apache-2.0", + "node_modules/@sentry/server-utils": { + "version": "10.66.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.66.0.tgz", + "integrity": "sha512-h9EM9Wz9Mc6w2Vn7Fyh6ozjy4JC2UbUvWf9Ra1HYA4KMeYqjFA5/o0oB4pg4w/TF+rb+9OF/HNqxjuczxpudpA==", + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" + "@apm-js-collab/code-transformer": "^0.18.0", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.6.1", + "@apm-js-collab/tracing-hooks": "^0.13.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.66.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" } }, - "node_modules/@smithy/util-middleware": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.0.5.tgz", - "integrity": "sha512-N40PfqsZHRSsByGB81HhSo+uvMxEHT+9e255S53pfBw/wI6WKDI7Jw9oyu5tJTLwZzV5DsMha3ji8jk9dsHmQQ==", + "node_modules/@smithy/core": { + "version": "3.29.5", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.5.tgz", + "integrity": "sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-retry": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.0.7.tgz", - "integrity": "sha512-TTO6rt0ppK70alZpkjwy+3nQlTiqNfoXja+qwuAchIEAIoSZW8Qyd76dvBv3I5bCpE38APafG23Y/u270NspiQ==", + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.10.tgz", + "integrity": "sha512-MJenAe4OKRZUo1LdYYFDCsSHxaHvInIU/z52GsheO9vl1/VSySVCr0zkyKD6TFiGkSUaWGxvKZ/70OvgUZR5HQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.0.7", - "@smithy/types": "^4.3.2", + "@smithy/core": "^3.29.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-stream": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.2.4.tgz", - "integrity": "sha512-vSKnvNZX2BXzl0U2RgCLOwWaAP9x/ddd/XobPK02pCbzRm5s55M53uwb1rl/Ts7RXZvdJZerPkA+en2FDghLuQ==", + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.7", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.7.tgz", + "integrity": "sha512-3zpg8yqqyXzoK2TsRDdkqVOj2RDBFfLXwCczOZ5c7TWB4eiaebfSCsbMjDPYB3PJ9ihV62QaeadZ+wLadZtNGA==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-buffer-from": "^4.0.0", - "@smithy/util-hex-encoding": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", + "@smithy/core": "^3.29.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.0.0.tgz", - "integrity": "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==", + "node_modules/@smithy/node-http-handler": { + "version": "4.9.7", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz", + "integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==", "license": "Apache-2.0", "dependencies": { + "@smithy/core": "^3.29.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-utf8": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.0.0.tgz", - "integrity": "sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==", + "node_modules/@smithy/signature-v4": { + "version": "5.6.6", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.6.tgz", + "integrity": "sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.0.0", + "@smithy/core": "^3.29.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-waiter": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.0.7.tgz", - "integrity": "sha512-mYqtQXPmrwvUljaHyGxYUIIRI3qjBTEb/f5QFi3A6VlxhpmZd5mWXn9W+qUkf2pVE1Hv3SqxefiZOPGdxmO64A==", + "node_modules/@smithy/types": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.0.5", - "@smithy/types": "^4.3.2", "tslib": "^2.6.2" }, "engines": { @@ -2431,19 +1357,14 @@ } }, "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", "license": "MIT", "engines": { "node": ">= 10" } }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==" - }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -2489,13 +1410,11 @@ "@types/node": "*" } }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dependencies": { - "@types/ms": "*" - } + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" }, "node_modules/@types/express": { "version": "4.17.21", @@ -2559,11 +1478,6 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "dev": true }, - "node_modules/@types/ms": { - "version": "0.7.34", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", - "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" - }, "node_modules/@types/node": { "version": "22.1.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.1.0.tgz", @@ -2585,26 +1499,27 @@ "dev": true }, "node_modules/@types/request": { - "version": "2.48.12", - "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.12.tgz", - "integrity": "sha512-G3sY+NpsA9jnwm0ixhAFQSJ3Q9JkpLZpJbI3GMv0mIAT0y3mRabYeINzal5WOChIiaTEGQYlHOKgkaM9EisWHw==", + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", "license": "MIT", "dependencies": { "@types/caseless": "*", "@types/node": "*", "@types/tough-cookie": "*", - "form-data": "^2.5.0" + "form-data": "^2.5.5" } }, "node_modules/@types/request/node_modules/form-data": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.3.tgz", - "integrity": "sha512-XHIrMD0NpDrNM/Ckf7XJiBbLl57KEhT3+i3yY+eWm+cqYZJQTZrKo8Y8AWKnuV5GT4scfuUGt9LzNoIx3dU1nQ==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz", + "integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" }, @@ -2652,12 +1567,6 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, - "node_modules/@types/uuid": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", - "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", - "license": "MIT" - }, "node_modules/@types/ws": { "version": "8.5.13", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", @@ -2668,15 +1577,6 @@ "@types/node": "*" } }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", @@ -2874,9 +1774,9 @@ } }, "node_modules/@typespec/ts-http-runtime": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz", - "integrity": "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.7.tgz", + "integrity": "sha512-JVUD8X2tfDMWjcjLs4yVxxVrS8yR5vnh386GAXT9Qj79nBxxXSaHFQZg5FweLmT8HlPQ3kii6noUB+Z9RN7DvQ==", "license": "MIT", "dependencies": { "http-proxy-agent": "^7.0.0", @@ -2884,16 +1784,41 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", + "version": "1.3.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true, "license": "ISC" }, + "node_modules/@zoom/rtms": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@zoom/rtms/-/rtms-1.1.0.tgz", + "integrity": "sha512-xxd8OpYjpi9mbjMpv3sh4RX0Q0/lT/5Y/tHR4TR+kyOfz8bNuBxy2C80q+GlyQrDgUyPV+fgI5taI3W9RxsC5g==", + "cpu": [ + "x64", + "arm64" + ], + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "linux", + "darwin" + ], + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^8.5.0", + "prebuild-install": "^7.1.3", + "tar": "^7.5.6" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -2962,9 +1887,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2982,21 +1907,11 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "engines": { "node": ">=8" } }, - "node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -3010,6 +1925,18 @@ "node": ">= 8" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -3018,15 +1945,8 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, - "node_modules/arr-union": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", - "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", - "engines": { - "node": ">=0.10.0" - } + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, "node_modules/array-flatten": { "version": "1.1.1", @@ -3052,15 +1972,13 @@ "node": ">=8" } }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" } }, "node_modules/async": { @@ -3085,66 +2003,47 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", - "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, - "node_modules/b4a": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.6.tgz", - "integrity": "sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "node_modules/bare-events": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.4.2.tgz", - "integrity": "sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==", - "optional": true - }, - "node_modules/bare-fs": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.1.tgz", - "integrity": "sha512-W/Hfxc/6VehXlsgFtbB5B4xFcsCl+pAh30cYhoFyXErf6oGrwjh8SwiPAdHgpmWonKuYpZgGywN0SXt7dgsADA==", - "optional": true, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", "dependencies": { - "bare-events": "^2.0.0", - "bare-path": "^2.0.0", - "bare-stream": "^2.0.0" + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" } }, - "node_modules/bare-os": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.4.0.tgz", - "integrity": "sha512-v8DTT08AS/G0F9xrhyLtepoo9EJBJ85FRSMbu1pQUlAf6A8T0tEEQGMVObWeqpjhSPXsE0VGlluFBJu2fdoTNg==", - "optional": true - }, - "node_modules/bare-path": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.3.tgz", - "integrity": "sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==", - "optional": true, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", "dependencies": { - "bare-os": "^2.1.0" + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" } }, - "node_modules/bare-stream": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.1.3.tgz", - "integrity": "sha512-tiDAH9H/kP+tvNO5sczyn9ZAA7utrSMobyDchsnyyXBuUe2FSQWbxhtuHB8jwpHYYevVo2UJpcmvvjrbHboUUQ==", - "optional": true, - "dependencies": { - "streamx": "^2.18.0" - } + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true }, "node_modules/base64-js": { "version": "1.5.1", @@ -3165,18 +2064,10 @@ } ] }, - "node_modules/basic-ftp": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", - "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/bignumber.js": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.2.1.tgz", - "integrity": "sha512-+NzaKgOUvInq9TIUZ1+DRspzf/HApkCwD4btfuasFTdrfnOxqx853TgDpMolp+uv4RpRp7bPcEU2zKr9+fRmyw==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", "license": "MIT", "engines": { "node": "*" @@ -3194,29 +2085,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bintrees": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", "license": "MIT" }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", "dependencies": { - "bytes": "3.1.2", + "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.2", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", "type-is": "~1.6.18", - "unpipe": "1.0.0" + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8", @@ -3227,6 +2141,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -3234,18 +2149,21 @@ "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" }, "node_modules/bowser": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", - "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3281,19 +2199,12 @@ "url": "https://feross.org/support" } ], + "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "engines": { - "node": "*" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3318,27 +2229,10 @@ "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.8" } }, "node_modules/call-bind-apply-helpers": { @@ -3354,27 +2248,31 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "engines": { "node": ">=6" } }, - "node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -3399,46 +2297,21 @@ "fsevents": "~2.3.2" } }, - "node_modules/chromium-bidi": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.3.tgz", - "integrity": "sha512-qXlsCmpCZJAnoTYI83Iu6EdYQpMYdVkCfq08KDh2pmlVqK5t5IA9mGs4/LwCwp4fqisSOMXZxP3HIh8w8aRn0A==", - "dependencies": { - "mitt": "3.0.1", - "urlpattern-polyfill": "10.0.0", - "zod": "3.23.8" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "optional": true, "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/clone-deep": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", - "integrity": "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==", - "dependencies": { - "for-own": "^0.1.3", - "is-plain-object": "^2.0.1", - "kind-of": "^3.0.2", - "lazy-cache": "^1.0.3", - "shallow-clone": "^0.1.2" - }, - "engines": { - "node": ">=0.10.0" - } + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" }, "node_modules/cluster-key-slot": { "version": "1.1.2", @@ -3507,7 +2380,8 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true }, "node_modules/content-disposition": { "version": "0.5.4", @@ -3524,14 +2398,16 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -3541,31 +2417,6 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, - "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -3586,20 +2437,13 @@ "node": ">= 8" } }, - "node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", - "engines": { - "node": ">= 14" - } - }, "node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -3610,6 +2454,32 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3617,14 +2487,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/default-browser": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.3.0.tgz", @@ -3653,22 +2515,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", @@ -3681,19 +2527,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3707,6 +2540,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -3715,20 +2549,27 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/devtools-protocol": { - "version": "0.0.1312386", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", - "integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==" + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } @@ -3808,12 +2649,8 @@ "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" }, "node_modules/enabled": { "version": "2.0.0", @@ -3822,9 +2659,10 @@ "license": "MIT" }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -3837,22 +2675,6 @@ "once": "^1.4.0" } }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "engines": { - "node": ">=6" - } - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3863,13 +2685,19 @@ } }, "node_modules/es-errors": { - "version": "1.3.0", + "version": "1.3.1", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "engines": { "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3897,46 +2725,11 @@ "node": ">= 0.4" } }, - "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", - "engines": { - "node": ">=6" - } - }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" }, "node_modules/eslint": { "version": "8.57.1", @@ -4169,23 +2962,10 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" @@ -4219,6 +2999,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -4227,6 +3008,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -4249,45 +3031,60 @@ "node": ">=0.8.x" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", - "content-disposition": "0.5.4", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express/node_modules/debug": { @@ -4309,25 +3106,6 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4335,11 +3113,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" - }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -4371,10 +3144,26 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", + "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, "node_modules/fast-xml-parser": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", - "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", "funding": [ { "type": "github", @@ -4383,7 +3172,12 @@ ], "license": "MIT", "dependencies": { - "strnum": "^1.1.1" + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.1", + "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -4399,14 +3193,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -4426,6 +3212,13 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT", + "optional": true + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4439,16 +3232,17 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "statuses": "2.0.1", + "statuses": "~2.0.2", "unpipe": "~1.0.0" }, "engines": { @@ -4459,6 +3253,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -4466,7 +3261,8 @@ "node_modules/finalhandler/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" }, "node_modules/find-up": { "version": "5.0.0", @@ -4501,9 +3297,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -4514,9 +3310,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -4533,34 +3329,17 @@ } } }, - "node_modules/for-in": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", - "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/for-own": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", - "integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==", - "dependencies": { - "for-in": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/form-data": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", - "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -4578,10 +3357,18 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT", + "optional": true + }, "node_modules/fs-extra": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", @@ -4598,7 +3385,8 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true }, "node_modules/fsevents": { "version": "2.3.2", @@ -4632,22 +3420,9 @@ "is-stream": "^2.0.0", "node-fetch": "^2.6.9", "uuid": "^9.0.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/gaxios/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" + }, + "engines": { + "node": ">=14" } }, "node_modules/gcp-metadata": { @@ -4664,16 +3439,8 @@ "node": ">=14" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-intrinsic": { - "version": "1.3.0", + "version": "1.3.1", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", @@ -4709,39 +3476,19 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-uri": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.3.tgz", - "integrity": "sha512-BzUrJBS9EcUb4cFol8r4W3v1cPsSyajLSthNkz5BxbpDcHN5tIrM10E2eNvfnvBn3DaT3DUgx0OpsBKkaOpanw==", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4", - "fs-extra": "^11.2.0" - }, - "engines": { - "node": ">= 14" - } + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT", + "optional": true }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -4873,21 +3620,11 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, "engines": { "node": ">=4" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4916,9 +3653,10 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -4943,18 +3681,23 @@ "license": "MIT" }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/http-proxy-agent": { @@ -4985,6 +3728,7 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -5031,6 +3775,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -5042,6 +3787,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-in-the-middle": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.1.tgz", + "integrity": "sha512-0rymlHSFLwZ0ixx8DaQkoIyZojJPY2a0K2nEYslhKJ6jIYO/m0IcCb7iQsFPmS7WmKwISZiIrv5Icstrw/CmqA==", + "license": "Apache-2.0", + "dependencies": { + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -5057,6 +3816,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -5067,17 +3827,12 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, - "engines": { - "node": ">= 12" - } + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC", + "optional": true }, "node_modules/ipaddr.js": { "version": "1.9.1", @@ -5087,11 +3842,6 @@ "node": ">= 0.10" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -5104,11 +3854,6 @@ "node": ">=8" } }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" - }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -5124,14 +3869,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5141,14 +3878,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -5198,17 +3927,6 @@ "node": ">=8" } }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -5221,6 +3939,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-unsafe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/is-wsl": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", @@ -5243,23 +3973,22 @@ "dev": true, "license": "ISC" }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -5267,11 +3996,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" - }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -5288,11 +4012,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5319,12 +4038,12 @@ } }, "node_modules/jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", "license": "MIT", "dependencies": { - "jws": "^3.2.2", + "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", @@ -5340,45 +4059,24 @@ "npm": ">=6" } }, - "node_modules/jsonwebtoken/node_modules/jwa": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", - "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jsonwebtoken/node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", - "license": "MIT", - "dependencies": { - "jwa": "^1.4.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/jwa": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", - "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "license": "MIT", "dependencies": { - "buffer-equal-constant-time": "1.0.1", + "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "node_modules/jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "license": "MIT", "dependencies": { - "jwa": "^2.0.0", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -5392,31 +4090,12 @@ "json-buffer": "3.0.1" } }, - "node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, - "node_modules/lazy-cache": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", - "integrity": "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5431,11 +4110,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5518,12 +4192,13 @@ "node": ">= 12.0.0" } }, - "node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "engines": { - "node": ">=12" + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/make-error": { @@ -5544,27 +4219,19 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, - "node_modules/merge-deep": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz", - "integrity": "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==", - "dependencies": { - "arr-union": "^3.1.0", - "clone-deep": "^0.2.4", - "kind-of": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge2": { "version": "1.4.1", @@ -5576,6 +4243,15 @@ "node": ">= 8" } }, + "node_modules/meriyah": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz", + "integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==", + "license": "ISC", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -5602,6 +4278,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", "bin": { "mime": "cli.js" }, @@ -5628,10 +4305,25 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -5639,31 +4331,52 @@ "node": "*" } }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/mixin-object": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", - "integrity": "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==", - "dependencies": { - "for-in": "^0.1.3", - "is-extendable": "^0.1.1" - }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "optional": true, "engines": { - "node": ">=0.10.0" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/mixin-object/node_modules/for-in": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", - "integrity": "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==", + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^7.1.2" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 18" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT", + "optional": true + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -5686,9 +4399,17 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT", + "optional": true }, "node_modules/natural-compare": { "version": "1.4.0", @@ -5712,12 +4433,27 @@ "node": ">= 0.6" } }, - "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, "engines": { - "node": ">= 0.4.0" + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", + "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^18 || ^20 || >= 21" } }, "node_modules/node-fetch": { @@ -5778,9 +4514,10 @@ } }, "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -5792,6 +4529,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", "dependencies": { "ee-first": "1.1.1" }, @@ -5883,68 +4621,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pac-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.0.2.tgz", - "integrity": "sha512-BFi3vZnO9X5Qt6NRz7ZOaPja3ic0PhlsmCRYLOpN11+mWBCR6XJDqW5RF3j8jm4WGGQZtBA+bTfxYzeKW73eHg==", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.0.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.5", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "dependencies": { "callsites": "^3.0.0" }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=6" } }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -5959,10 +4652,26 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -5978,9 +4687,10 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" }, "node_modules/path-type": { "version": "4.0.0", @@ -5992,21 +4702,12 @@ "node": ">=8" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" - }, - "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" - }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.6" }, @@ -6015,11 +4716,12 @@ } }, "node_modules/playwright": { - "version": "1.45.3", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.3.tgz", - "integrity": "sha512-QhVaS+lpluxCaioejDZ95l4Y4jSFCsBvl2UZkpeXlzxmqS+aABr5c82YmfMHrL6x27nvrvykJAFpkzT2eWdJww==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.45.3" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -6032,9 +4734,10 @@ } }, "node_modules/playwright-core": { - "version": "1.45.3", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.3.tgz", - "integrity": "sha512-+ym0jNbcjikaOwwSZycFbwkWgfruWvYlJfThKYAlImbxUgdWFO2oW70ojPm4OpE4t6TAo2FY/smM+hpVTtkhDA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, @@ -6042,33 +4745,70 @@ "node": ">=18" } }, - "node_modules/playwright-extra": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/playwright-extra/-/playwright-extra-4.3.6.tgz", - "integrity": "sha512-q2rVtcE8V8K3vPVF1zny4pvwZveHLH8KBuVU2MoE3Jw4OKVoBWsHI9CH9zPydovHHOCDxjGN2Vg+2m644q3ijA==", + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "optional": true, "dependencies": { - "debug": "^4.3.4" - }, - "engines": { - "node": ">=12" + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" }, - "peerDependencies": { - "playwright": "*", - "playwright-core": "*" + "bin": { + "prebuild-install": "bin.js" }, - "peerDependenciesMeta": { - "playwright": { - "optional": true - }, - "playwright-core": { - "optional": true - } + "engines": { + "node": ">=10" } }, - "node_modules/playwright-extra-plugin-stealth": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/playwright-extra-plugin-stealth/-/playwright-extra-plugin-stealth-0.0.1.tgz", - "integrity": "sha512-eI0Ujf4MXbcupzlVEXaaOnb+Exjt1sFi7t/3KxIA5pVww+WRAXRWdhqTz0glX62jJq2YM8fLu+GyvULpjTpZrw==" + "node_modules/prebuild-install/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC", + "optional": true + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } }, "node_modules/prelude-ls": { "version": "1.2.1", @@ -6080,14 +4820,6 @@ "node": ">= 0.8.0" } }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/prom-client": { "version": "15.1.3", "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", @@ -6113,29 +4845,15 @@ "node": ">= 0.10" } }, - "node_modules/proxy-agent": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.4.0.tgz", - "integrity": "sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==", - "dependencies": { - "agent-base": "^7.0.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.3", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.0.1", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.2" - }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", "engines": { - "node": ">= 14" + "node": ">=10" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -6146,6 +4864,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "optional": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -6161,189 +4880,14 @@ "node": ">=6" } }, - "node_modules/puppeteer": { - "version": "22.15.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-22.15.0.tgz", - "integrity": "sha512-XjCY1SiSEi1T7iSYuxS82ft85kwDJUS7wj1Z0eGVXKdtr5g4xnVcbjwxhq5xBnpK/E7x1VZZoJDxpjAOasHT4Q==", - "hasInstallScript": true, - "dependencies": { - "@puppeteer/browsers": "2.3.0", - "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1312386", - "puppeteer-core": "22.15.0" - }, - "bin": { - "puppeteer": "lib/esm/puppeteer/node/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer-core": { - "version": "22.15.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-22.15.0.tgz", - "integrity": "sha512-cHArnywCiAAVXa3t4GGL2vttNxh7GqXtIYGym99egkNJ3oG//wL9LkvO4WE8W1TJe95t1F1ocu9X4xWaGsOKOA==", - "dependencies": { - "@puppeteer/browsers": "2.3.0", - "chromium-bidi": "0.6.3", - "debug": "^4.3.6", - "devtools-protocol": "0.0.1312386", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer-extra": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/puppeteer-extra/-/puppeteer-extra-3.3.6.tgz", - "integrity": "sha512-rsLBE/6mMxAjlLd06LuGacrukP2bqbzKCLzV1vrhHFavqQE/taQ2UXv3H5P0Ls7nsrASa+6x3bDbXHpqMwq+7A==", - "dependencies": { - "@types/debug": "^4.1.0", - "debug": "^4.1.1", - "deepmerge": "^4.2.2" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "@types/puppeteer": "*", - "puppeteer": "*", - "puppeteer-core": "*" - }, - "peerDependenciesMeta": { - "@types/puppeteer": { - "optional": true - }, - "puppeteer": { - "optional": true - }, - "puppeteer-core": { - "optional": true - } - } - }, - "node_modules/puppeteer-extra-plugin": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin/-/puppeteer-extra-plugin-3.2.3.tgz", - "integrity": "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q==", - "dependencies": { - "@types/debug": "^4.1.0", - "debug": "^4.1.1", - "merge-deep": "^3.0.1" - }, - "engines": { - "node": ">=9.11.2" - }, - "peerDependencies": { - "playwright-extra": "*", - "puppeteer-extra": "*" - }, - "peerDependenciesMeta": { - "playwright-extra": { - "optional": true - }, - "puppeteer-extra": { - "optional": true - } - } - }, - "node_modules/puppeteer-extra-plugin-stealth": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-stealth/-/puppeteer-extra-plugin-stealth-2.11.2.tgz", - "integrity": "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ==", - "dependencies": { - "debug": "^4.1.1", - "puppeteer-extra-plugin": "^3.2.3", - "puppeteer-extra-plugin-user-preferences": "^2.4.1" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "playwright-extra": "*", - "puppeteer-extra": "*" - }, - "peerDependenciesMeta": { - "playwright-extra": { - "optional": true - }, - "puppeteer-extra": { - "optional": true - } - } - }, - "node_modules/puppeteer-extra-plugin-user-data-dir": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-data-dir/-/puppeteer-extra-plugin-user-data-dir-2.4.1.tgz", - "integrity": "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g==", - "dependencies": { - "debug": "^4.1.1", - "fs-extra": "^10.0.0", - "puppeteer-extra-plugin": "^3.2.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "playwright-extra": "*", - "puppeteer-extra": "*" - }, - "peerDependenciesMeta": { - "playwright-extra": { - "optional": true - }, - "puppeteer-extra": { - "optional": true - } - } - }, - "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/puppeteer-extra-plugin-user-preferences": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-preferences/-/puppeteer-extra-plugin-user-preferences-2.4.1.tgz", - "integrity": "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A==", - "dependencies": { - "debug": "^4.1.1", - "deepmerge": "^4.2.2", - "puppeteer-extra-plugin": "^3.2.3", - "puppeteer-extra-plugin-user-data-dir": "^2.4.1" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "playwright-extra": "*", - "puppeteer-extra": "*" - }, - "peerDependenciesMeta": { - "playwright-extra": { - "optional": true - }, - "puppeteer-extra": { - "optional": true - } - } - }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.4" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -6373,33 +4917,56 @@ ], "license": "MIT" }, - "node_modules/queue-tick": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", - "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -6442,18 +5009,24 @@ "node": ">= 18" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, "engines": { - "node": ">=0.10.0" + "node": ">=9.3.0 || >=8.10.0 <9.0.0" } }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, "engines": { "node": ">=4" } @@ -6497,6 +5070,7 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, "dependencies": { "glob": "^7.1.3" }, @@ -6574,7 +5148,14 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semifies": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semifies/-/semifies-1.0.0.tgz", + "integrity": "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==", + "license": "Apache-2.0" }, "node_modules/semver": { "version": "7.6.3", @@ -6588,23 +5169,24 @@ } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "range-parser": "~1.2.1", - "statuses": "2.0.1" + "statuses": "~2.0.2" }, "engines": { "node": ">= 0.8.0" @@ -6614,6 +5196,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -6621,12 +5204,8 @@ "node_modules/send/node_modules/debug/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" }, "node_modules/serialize-error": { "version": "12.0.0", @@ -6645,83 +5224,36 @@ }, "node_modules/serialize-error/node_modules/type-fest": { "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", - "dependencies": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.18.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "node_modules/shallow-clone": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz", - "integrity": "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==", - "dependencies": { - "is-extendable": "^0.1.1", - "kind-of": "^2.0.1", - "lazy-cache": "^0.2.3", - "mixin-object": "^2.0.1" - }, + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=0.10.0" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/shallow-clone/node_modules/kind-of": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", - "integrity": "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==", + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", "dependencies": { - "is-buffer": "^1.0.2" + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.8.0" } }, - "node_modules/shallow-clone/node_modules/lazy-cache": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz", - "integrity": "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==", - "engines": { - "node": ">=0.10.0" - } + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" }, "node_modules/shebang-command": { "version": "2.0.0", @@ -6747,14 +5279,69 @@ } }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.2", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -6763,6 +5350,53 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -6800,55 +5434,15 @@ "node": ">=8" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", - "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", - "dependencies": { - "ip-address": "^9.0.5", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.4.tgz", - "integrity": "sha512-GNAq/eg8Udq2x0eNiFkr9gRg5bA7PXEWagQdeRX4cPSG+X/8V38v637gim9bjFptMk1QWsCTr0ttrJEiXbNnRw==", - "dependencies": { - "agent-base": "^7.1.1", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "optional": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" - }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -6859,9 +5453,10 @@ } }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -6891,21 +5486,8 @@ "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", "license": "MIT" }, - "node_modules/streamx": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.18.0.tgz", - "integrity": "sha512-LLUC1TWdjVdn1weXGcSxyTR3T4+acB6tVGXT95y0nGbca4t4o/ng1wKAGTljm9VicuCVLvRlqFYXYy5GwgM7sQ==", - "dependencies": { - "fast-fifo": "^1.3.2", - "queue-tick": "^1.0.1", - "text-decoder": "^1.1.0" - }, - "optionalDependencies": { - "bare-events": "^2.2.0" - } - }, "node_modules/string_decoder": { - "version": "1.3.0", + "version": "1.3.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", @@ -6913,23 +5495,11 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -6951,16 +5521,19 @@ } }, "node_modules/strnum": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", - "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } }, "node_modules/stubs": { "version": "3.0.0", @@ -6972,6 +5545,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -6979,27 +5553,21 @@ "node": ">=4" } }, - "node_modules/tar-fs": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.6.tgz", - "integrity": "sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==", + "node_modules/tar": { + "version": "7.5.20", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", + "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", + "license": "BlueOak-1.0.0", + "optional": true, "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, - "optionalDependencies": { - "bare-fs": "^2.1.1", - "bare-path": "^2.1.0" - } - }, - "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" + "engines": { + "node": ">=18" } }, "node_modules/tdigest": { @@ -7066,27 +5634,6 @@ "node": ">= 6" } }, - "node_modules/teeny-request/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/text-decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.1.1.tgz", - "integrity": "sha512-8zll7REEv4GDD3x4/0pW+ppIxSNs7H1J10IKFZsuOMscumCdM2a+toDGLPA3T+1+fLBql4zbt5z83GEQGGV5VA==", - "dependencies": { - "b4a": "^1.6.4" - } - }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -7100,11 +5647,6 @@ "dev": true, "license": "MIT" }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7121,6 +5663,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", "engines": { "node": ">=0.6" } @@ -7220,6 +5763,19 @@ "dev": true, "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -7250,6 +5806,7 @@ "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" @@ -7271,15 +5828,6 @@ "node": ">=14.17" } }, - "node_modules/unbzip2-stream": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", - "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", - "dependencies": { - "buffer": "^5.2.1", - "through": "^2.3.8" - } - }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -7303,6 +5851,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -7317,11 +5866,6 @@ "punycode": "^2.1.0" } }, - "node_modules/urlpattern-polyfill": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz", - "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==" - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -7337,9 +5881,9 @@ } }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -7440,78 +5984,11 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/wrap-ansi/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, - "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", @@ -7527,46 +6004,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=16.0.0" } }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "optional": true, "engines": { - "node": ">=12" - } - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" + "node": ">=18" } }, "node_modules/yn": { @@ -7588,14 +6048,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/package.json b/package.json index f62ba0bd..9cf80182 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.34", + "version": "1.3.16", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", @@ -9,12 +9,16 @@ "dev": "docker compose up --build", "stop": "docker compose down", "build": "tsc", + "test": "node --test -r ts-node/register/transpile-only src/rtms/*.test.ts src/lib/*.test.ts src/bots/*.test.ts src/monitoring/*.test.ts", "lint": "eslint \"*/**/*.{js,ts}\" --quiet --fix" }, + "engines": { + "node": ">=22.0.0" + }, "keywords": [], "author": "screenappai", "devDependencies": { - "@playwright/test": "^1.45.3", + "@playwright/test": "^1.61.1", "@types/express": "^4.17.21", "@types/fs-extra": "^11.0.4", "@types/node": "^22.1.0", @@ -25,28 +29,30 @@ "nodemon": "^3.1.4" }, "dependencies": { - "@aws-sdk/client-s3": "^3.787.0", - "@aws-sdk/lib-storage": "^3.876.0", - "@azure/identity": "^4.13.0", - "@azure/storage-blob": "^12.29.1", - "@google-cloud/storage": "^7.16.0", - "axios": "^1.7.7", + "@aws-sdk/client-s3": "^3.1089.0", + "@aws-sdk/lib-storage": "^3.1089.0", + "@azure/identity": "^4.13.1", + "@azure/storage-blob": "^12.33.0", + "@google-cloud/storage": "^7.21.0", + "@sentry/node": "^10.66.0", + "axios": "^1.18.1", "dotenv": "^16.4.5", - "express": "^4.19.2", + "express": "^4.22.2", "fs-extra": "^11.2.0", "moment": "^2.30.1", "moment-timezone": "^0.5.46", - "playwright": "^1.45.3", - "playwright-extra": "^4.3.6", - "playwright-extra-plugin-stealth": "^0.0.1", + "playwright": "^1.61.1", "prom-client": "^15.1.3", - "puppeteer": "^22.15.0", - "puppeteer-extra": "^3.3.6", - "puppeteer-extra-plugin-stealth": "^2.11.2", "redis": "^5.8.1", "serialize-error": "^12.0.0", "ts-node": "^10.9.2", - "uuid": "^11.1.0", + "uuid": "^11.1.1", "winston": "^3.17.0" + }, + "optionalDependencies": { + "@zoom/rtms": "1.1.0" + }, + "overrides": { + "uuid": "$uuid" } } diff --git a/scripts/smoke-chrome-cdp.mjs b/scripts/smoke-chrome-cdp.mjs new file mode 100644 index 00000000..236d0590 --- /dev/null +++ b/scripts/smoke-chrome-cdp.mjs @@ -0,0 +1,184 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { chromium } from 'playwright'; + +const cdpUrl = process.env.CHROME_CDP_URL ?? 'http://127.0.0.1:9223'; +const containerName = process.env.CHROME_CDP_CONTAINER; +const codec = 'video/webm;codecs=vp8,opus'; + +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + +async function getCdpVersion(timeoutMs = 30_000) { + const deadline = Date.now() + timeoutMs; + let lastError; + + while (Date.now() < deadline) { + try { + const response = await fetch(new URL('/json/version', cdpUrl), { + signal: AbortSignal.timeout(2_000), + }); + assert.equal(response.ok, true, `CDP returned HTTP ${response.status}`); + return await response.json(); + } catch (error) { + lastError = error; + await delay(500); + } + } + + throw new Error(`CDP did not become ready: ${lastError instanceof Error ? lastError.message : lastError}`); +} + +function assertXvfbSocket() { + if (!containerName) return; + + execFileSync('docker', [ + 'exec', + containerName, + 'test', + '-S', + process.env.CHROME_XVFB_SOCKET ?? '/tmp/.X11-unix/X99', + ], { stdio: 'inherit' }); +} + +async function recordCurrentTab(page) { + await page.goto(new URL('/json/version', cdpUrl).toString(), { waitUntil: 'domcontentloaded' }); + await page.setContent(` + + + + + + + + +
Chrome CDP media smoke test
+ + + + `); + + await page.click('#start'); + return page.evaluate(() => window.__chromeSmokeResult); +} + +async function main() { + const initialVersion = await getCdpVersion(); + assert.match(initialVersion.Browser ?? '', /Chrome\//); + assert.match(initialVersion.webSocketDebuggerUrl ?? '', /^ws:\/\//); + assertXvfbSocket(); + + const browser = await chromium.connectOverCDP(cdpUrl); + const context = await browser.newContext({ + locale: 'en-US', + viewport: { width: 1280, height: 720 }, + }); + const page = await context.newPage(); + + try { + const result = await recordCurrentTab(page); + assert.equal(result.error, undefined, result.error); + assert.equal(result.secureContext, true); + assert.equal(result.codecSupported, true, `${codec} is not supported`); + assert.equal(result.displaySurface, 'browser', 'Chrome did not capture the current tab'); + assert.equal(result.videoTrackCount, 1); + assert.equal(result.audioTrackCount, 1); + assert.equal(result.liveDuringRecording, true); + assert.ok(result.chunkCount > 0, 'MediaRecorder produced no chunks'); + assert.ok(result.blobSize > 0, 'MediaRecorder produced an empty recording'); + } finally { + await page.close().catch(() => undefined); + await context.close().catch(() => undefined); + } + + const finalVersion = await getCdpVersion(5_000); + assert.equal(finalVersion.Browser, initialVersion.Browser, 'Chrome did not survive meeting-context cleanup'); + console.log(`Chrome CDP smoke test passed (${finalVersion.Browser}, ${codec})`); +} + +main() + .then(() => process.exit(0)) + .catch(error => { + console.error(error); + process.exit(1); + }); diff --git a/scripts/start-chrome-cdp.sh b/scripts/start-chrome-cdp.sh index b57bd9a8..5fa3e256 100755 --- a/scripts/start-chrome-cdp.sh +++ b/scripts/start-chrome-cdp.sh @@ -5,10 +5,13 @@ export DISPLAY="${DISPLAY:-:99}" export CHROME_USER_DATA_DIR="${CHROME_USER_DATA_DIR:-/tmp/chrome-profile}" export CHROME_REMOTE_DEBUGGING_ADDRESS="${CHROME_REMOTE_DEBUGGING_ADDRESS:-127.0.0.1}" export CHROME_REMOTE_DEBUGGING_PORT="${CHROME_REMOTE_DEBUGGING_PORT:-9222}" -export CHROME_CDP_PROXY_ADDRESS="${CHROME_CDP_PROXY_ADDRESS:-0.0.0.0}" +export CHROME_CDP_PROXY_ADDRESS="${CHROME_CDP_PROXY_ADDRESS:-127.0.0.1}" export CHROME_CDP_PROXY_PORT="${CHROME_CDP_PROXY_PORT:-9223}" export CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1280,800}" export CHROME_URL="${CHROME_URL:-about:blank}" +export LANG="${LANG:-C.UTF-8}" +export LC_ALL="${LC_ALL:-C.UTF-8}" + CHROME_WINDOW_SIZE_FOR_CHROME="${CHROME_WINDOW_SIZE/x/,}" XVFB_SCREEN_SIZE="${CHROME_WINDOW_SIZE/,/x}" @@ -43,12 +46,6 @@ http { proxy_set_header Host 127.0.0.1:${CHROME_REMOTE_DEBUGGING_PORT}; proxy_set_header Upgrade \$http_upgrade; proxy_set_header Connection "upgrade"; - # Chrome reports its DevTools socket using the (rewritten) Host header, so - # /json* responses contain ws://127.0.0.1:${CHROME_REMOTE_DEBUGGING_PORT}/... - # which a remote client (e.g. the bot in another container) can't reach. - # Rewrite it back to the address the client used so connectOverCDP's second - # hop comes through this proxy. Disable upstream compression so sub_filter - # can see the body. proxy_set_header Accept-Encoding ""; sub_filter_types application/json; sub_filter_once off; @@ -68,27 +65,50 @@ cleanup() { kill "$chrome_pid" >/dev/null 2>&1 || true fi kill "$xvfb_pid" >/dev/null 2>&1 || true + wait "$nginx_pid" >/dev/null 2>&1 || true + if [ -n "${chrome_pid:-}" ]; then + wait "$chrome_pid" >/dev/null 2>&1 || true + fi + wait "$xvfb_pid" >/dev/null 2>&1 || true } trap cleanup EXIT INT TERM -google-chrome-stable \ - --remote-debugging-address="$CHROME_REMOTE_DEBUGGING_ADDRESS" \ - --remote-debugging-port="$CHROME_REMOTE_DEBUGGING_PORT" \ - --remote-allow-origins='*' \ - --user-data-dir="$CHROME_USER_DATA_DIR" \ - --window-size="$CHROME_WINDOW_SIZE_FOR_CHROME" \ - --window-position=0,0 \ - --force-device-scale-factor=1 \ - --auto-accept-this-tab-capture \ - --autoplay-policy=no-user-gesture-required \ - --no-first-run \ - --no-default-browser-check \ - --disable-dev-shm-usage \ - --disable-background-timer-throttling \ - --disable-backgrounding-occluded-windows \ - --disable-renderer-backgrounding \ - --no-sandbox \ - "$CHROME_URL" & +chrome_args=( + "--remote-debugging-address=$CHROME_REMOTE_DEBUGGING_ADDRESS" + "--remote-debugging-port=$CHROME_REMOTE_DEBUGGING_PORT" + "--user-data-dir=$CHROME_USER_DATA_DIR" + "--window-size=$CHROME_WINDOW_SIZE_FOR_CHROME" + '--window-position=0,0' + '--force-device-scale-factor=1' + '--lang=en-US' + '--auto-accept-this-tab-capture' + '--autoplay-policy=no-user-gesture-required' + '--no-first-run' + '--no-default-browser-check' + '--disable-background-timer-throttling' + '--disable-blink-features=AutomationControlled' + '--disable-backgrounding-occluded-windows' + '--disable-renderer-backgrounding' +) + +if [ "${CHROME_SOFTWARE_GL:-true}" = 'true' ]; then + chrome_args+=( + '--use-gl=angle' + '--use-angle=swiftshader' + '--enable-unsafe-swiftshader' + ) +fi + +# Keep the v1.3.6 container-compatible default. The setuid sandbox cannot create +# its PID namespace under the hardened Kubernetes/Docker security context. +if [ "${CHROME_NO_SANDBOX:-true}" = 'true' ]; then + chrome_args+=( + '--no-sandbox' + '--disable-setuid-sandbox' + ) +fi + +google-chrome-stable "${chrome_args[@]}" "$CHROME_URL" & chrome_pid="$!" -wait -n "$nginx_pid" "$chrome_pid" +wait -n "$xvfb_pid" "$nginx_pid" "$chrome_pid" diff --git a/src/app/common.ts b/src/app/common.ts index b0f8b073..782507a1 100644 --- a/src/app/common.ts +++ b/src/app/common.ts @@ -3,6 +3,13 @@ import { KnownError, WaitingAtLobbyRetryError } from '../error'; import { getErrorType } from '../util/logger'; import config from '../config'; import { createMeetingFailedPayload, notifyMeetingFailed } from '../services/notificationService'; +import { + createSentryCorrelationId, + inferFallbackResult, + inferMeetingFailurePhase, + inferMeetingFailureTransport, + reportPermanentMeetingFailure, +} from '../monitoring/sentry'; export interface MeetingJoinParams { url: string; @@ -110,6 +117,16 @@ export const notifyMeetingJoinFailure = async ( error: unknown, logger: Logger ) => { + reportPermanentMeetingFailure(error, { + provider: params.provider, + transport: inferMeetingFailureTransport(error), + phase: inferMeetingFailurePhase(error), + fallbackResult: inferFallbackResult(error), + teamId: params.teamId, + eventId: params.eventId, + botId: params.botId, + correlationId: createSentryCorrelationId(params), + }); const payload = createMeetingFailedPayload(params, error); await notifyMeetingFailed(payload, logger); }; diff --git a/src/app/index.ts b/src/app/index.ts index be543803..9be9f81d 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -7,10 +7,25 @@ import microsoftRouter from './microsoft'; import zoomRouter from './zoom'; import { globalJobStore } from '../lib/globalJobStore'; import { RedisConsumerService } from '../connect/RedisConsumerService'; +import { captureRawBody } from '../rtms/webhook'; +import { captureOperationalError } from '../monitoring/sentry'; const app = express(); -app.use(express.json()); +app.disable('x-powered-by'); +app.use((_req, res, next) => { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Content-Security-Policy', "default-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'"); + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + if (NODE_ENV === 'production') { + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + next(); +}); + +app.use(express.json({ verify: captureRawBody })); // Initialize Redis consumer service export const redisConsumerService = new RedisConsumerService(); @@ -83,6 +98,7 @@ export const getIsBusy = () => isbusy; if (config.isRedisEnabled) { redisConsumerService.start().catch((error) => { console.error('Failed to start Redis consumer service:', error); + captureOperationalError(error, { phase: 'redis_consumer_startup' }); }); } else { console.info('Redis consumer service not started - Redis is disabled'); diff --git a/src/app/zoom.ts b/src/app/zoom.ts index 20753ee9..580ac4d6 100644 --- a/src/app/zoom.ts +++ b/src/app/zoom.ts @@ -8,6 +8,7 @@ import { getRecordingNamePrefix } from '../util/recordingName'; import { encodeFileNameSafebase64 } from '../util/strings'; import { MeetingJoinParams, notifyMeetingJoinFailure } from './common'; import { globalJobStore } from '../lib/globalJobStore'; +import zoomRtmsWebhookRouter from '../rtms/webhook'; const router = express.Router(); @@ -126,5 +127,6 @@ const joinZoom = async (req: Request, res: Response) => { }; router.post('/join', joinZoom); +router.use('/rtms/webhook', zoomRtmsWebhookRouter); export default router; diff --git a/src/bots/GoogleMeetBot.ts b/src/bots/GoogleMeetBot.ts index 71d108fb..b3fbc633 100644 --- a/src/bots/GoogleMeetBot.ts +++ b/src/bots/GoogleMeetBot.ts @@ -11,7 +11,11 @@ import { browserLogCaptureCallback } from '../util/logger'; import { getWaitingPromise } from '../lib/promise'; import { retryActionWithWait } from '../util/resilience'; import { uploadDebugImage } from '../services/bugService'; -import createBrowserContext, { isExternalBrowserContext } from '../lib/chromium'; +import createBrowserContext, { + closeBrowserSession, + normalizeBrowserSessionError, + raceBrowserSessionFailure, +} from '../lib/chromium'; import { GOOGLE_LOBBY_MODE_HOST_TEXT, GOOGLE_REQUEST_DENIED, GOOGLE_REQUEST_TIMEOUT } from '../constants'; import { getRecordingMimeTypesForExtension } from '../lib/recording'; import { getGoogleMeetDisplayName } from '../util/googleMeetDisplayName'; @@ -50,6 +54,7 @@ export class GoogleMeetBot extends MeetBotBase { await patchBotStatus({ botId, eventId, provider: 'google', status: _state, token: bearerToken }, this._logger); } catch(error) { + error = normalizeBrowserSessionError(this.page, error); if (!_state.includes('finished') && !_state.includes('failed')) _state.push('failed'); @@ -68,28 +73,18 @@ export class GoogleMeetBot extends MeetBotBase { // Guarantee chrome subprocess tree is reaped regardless of exit path. // No-op if a deeper code path already closed the browser. try { - const context = this.page?.context(); - const browser = context?.browser(); - if (isExternalBrowserContext(context)) { - await this.page?.close(); - this._logger.info('External browser page closed in join finally'); - } else if (browser?.isConnected()) { - await browser.close(); - this._logger.info('Browser closed in join finally'); - } else if (context) { - await context.close(); - this._logger.info('Persistent browser context closed in join finally'); - } + await closeBrowserSession(this.page); + this._logger.info('Browser session closed in join finally'); } catch (cleanupErr) { this._logger.warn('Browser cleanup in join finally failed (non-fatal)', { error: cleanupErr }); } } } - private async joinMeeting({ url, name, teamId, userId, eventId, botId, pushState, uploader }: JoinParams & { pushState(state: BotStatus): void }): Promise { + private async joinMeeting({ url, name, teamId, timezone, userId, eventId, botId, pushState, uploader }: JoinParams & { pushState(state: BotStatus): void }): Promise { this._logger.info('Launching browser...'); - this.page = await createBrowserContext(url, this._correlationId, 'google'); + this.page = await createBrowserContext(url, this._correlationId, 'google', timezone); this._logger.info('Navigating to Google Meet URL...'); await this.page.goto(url, { waitUntil: 'domcontentloaded' }); @@ -530,11 +525,7 @@ export class GoogleMeetBot extends MeetBotBase { } } catch(lobbyError) { this._logger.info('Closing the browser on error...', lobbyError); - if (isExternalBrowserContext(this.page.context())) { - await this.page.close(); - } else { - await this.page.context().browser()?.close(); - } + await closeBrowserSession(this.page); throw lobbyError; } @@ -683,12 +674,13 @@ export class GoogleMeetBot extends MeetBotBase { } }); - const { mimeTypes } = getRecordingMimeTypesForExtension(config.uploaderFileExtension); + // Match the shared recorder: prefer VP8 to reduce real-time encoding load. + const { mimeTypes } = getRecordingMimeTypesForExtension(config.uploaderFileExtension, true); // Inject the MediaRecorder code into the browser context using page.evaluate await this.page.evaluate( - async ({ teamId, duration, inactivityLimit, loneParticipantExitDelayMs, userId, slightlySecretId, activateInactivityDetectionAfter, activateInactivityDetectionAfterMinutes, mimeTypes }: - { teamId:string, userId: string, duration: number, inactivityLimit: number, loneParticipantExitDelayMs: number, slightlySecretId: string, activateInactivityDetectionAfter: string, activateInactivityDetectionAfterMinutes: number, mimeTypes: string[] }) => { + async ({ teamId, duration, inactivityLimit, loneParticipantExitDelayMs, userId, slightlySecretId, activateInactivityDetectionAfter, activateInactivityDetectionAfterMinutes, mimeTypes, videoBitsPerSecond }: + { teamId:string, userId: string, duration: number, inactivityLimit: number, loneParticipantExitDelayMs: number, slightlySecretId: string, activateInactivityDetectionAfter: string, activateInactivityDetectionAfterMinutes: number, mimeTypes: string[], videoBitsPerSecond: number }) => { let timeoutId: NodeJS.Timeout; let inactivitySilenceDetectionTimeout: NodeJS.Timeout; let isOnValidGoogleMeetPageInterval: NodeJS.Timeout; @@ -716,7 +708,7 @@ export class GoogleMeetBot extends MeetBotBase { } const stream: MediaStream = await (navigator.mediaDevices as any).getDisplayMedia({ - video: true, + video: { frameRate: { ideal: 25, max: 25 } }, audio: { autoGainControl: false, channels: 2, @@ -741,7 +733,7 @@ export class GoogleMeetBot extends MeetBotBase { } console.log(`Media Recorder will use ${selectedMimeType} codecs...`); - const mediaRecorder = new MediaRecorder(stream, { mimeType: selectedMimeType }); + const mediaRecorder = new MediaRecorder(stream, { mimeType: selectedMimeType, videoBitsPerSecond }); console.log(`Media Recorder actual mime type: ${mediaRecorder.mimeType}`); let chunkUploadChain: Promise = Promise.resolve(); let isStoppingRecording = false; @@ -1012,7 +1004,7 @@ export class GoogleMeetBot extends MeetBotBase { console.warn('Meet participant detection failed, retrying. Failure count:', detectionFailures); // Log for debugging if (detectionFailures >= maxDetectionFailures) { - console.log('Persistent detection failures:', { bodyText: `${document.body.innerText?.toString()}` }); + console.log('Persistent participant detection failures.'); loneTestDetectionActive = false; } retryWithBackoff(); @@ -1259,7 +1251,8 @@ export class GoogleMeetBot extends MeetBotBase { slightlySecretId: this.slightlySecretId, activateInactivityDetectionAfterMinutes: config.activateInactivityDetectionAfter, activateInactivityDetectionAfter: new Date(new Date().getTime() + config.activateInactivityDetectionAfter * 60 * 1000).toISOString(), - mimeTypes + mimeTypes, + videoBitsPerSecond: config.recordingVideoBitsPerSecond } ); @@ -1267,23 +1260,15 @@ export class GoogleMeetBot extends MeetBotBase { const processingTime = 0.2 * 60 * 1000; const waitingPromise: WaitPromise = getWaitingPromise(processingTime + duration); - waitingPromise.promise.then(async () => { - const context = this.page.context(); - // For an external CDP browser (the chrome-cdp sidecar), browser.close() only - // disconnects Playwright — it leaves the Meet tab open, so the bot stays in - // the call. Close the page (tab) instead, which leaves the meeting and keeps - // the shared sidecar Chrome alive for the next job. - if (isExternalBrowserContext(context)) { - this._logger.info('Closing the page (external CDP browser stays up)...'); - await this.page.close(); - } else { - this._logger.info('Closing the browser...'); - await context.browser()?.close(); - } - - this._logger.info('Recording stopped and meeting left; finalizing upload next...', { eventId, botId, userId, teamId }); - }); + try { + await raceBrowserSessionFailure(this.page, waitingPromise.promise); + } catch (error) { + waitingPromise.resolveEarly(); + throw error; + } - await waitingPromise.promise; + this._logger.info('Closing the browser session...'); + await closeBrowserSession(this.page); + this._logger.info('Recording stopped and meeting left; finalizing upload next...', { eventId, botId, userId, teamId }); } } diff --git a/src/bots/MicrosoftTeamsBot.ts b/src/bots/MicrosoftTeamsBot.ts index 2c121572..f10b249d 100644 --- a/src/bots/MicrosoftTeamsBot.ts +++ b/src/bots/MicrosoftTeamsBot.ts @@ -10,7 +10,11 @@ import { IUploader } from '../middleware/disk-uploader'; import { Logger } from 'winston'; import { retryActionWithWait } from '../util/resilience'; import { uploadDebugImage } from '../services/bugService'; -import createBrowserContext from '../lib/chromium'; +import createBrowserContext, { + closeBrowserSession, + normalizeBrowserSessionError, + raceBrowserSessionFailure, +} from '../lib/chromium'; import { browserLogCaptureCallback } from '../util/logger'; import { MICROSOFT_REQUEST_DENIED } from '../constants'; import { FFmpegRecorder } from '../lib/ffmpegRecorder'; @@ -57,6 +61,7 @@ export class MicrosoftTeamsBot extends MeetBotBase { await patchBotStatus({ botId, eventId, provider: 'microsoft', status: _state, token: bearerToken }, this._logger); } catch(error) { + error = normalizeBrowserSessionError(this.page, error); // Log the actual error that occurred this._logger.error('Error in Microsoft Teams bot join process', { error: error instanceof Error ? error.message : String(error), @@ -81,21 +86,15 @@ export class MicrosoftTeamsBot extends MeetBotBase { // Guarantee chrome subprocess tree is reaped regardless of exit path. // No-op if a deeper code path already closed the browser. try { - const browser = this.page?.context().browser(); - if (browser?.isConnected()) { - await browser.close(); - this._logger.info('Browser closed in join finally'); - } else if (this.page?.context()) { - await this.page.context().close(); - this._logger.info('Persistent browser context closed in join finally'); - } + await closeBrowserSession(this.page); + this._logger.info('Browser session closed in join finally'); } catch (cleanupErr) { this._logger.warn('Browser cleanup in join finally failed (non-fatal)', { error: cleanupErr }); } } } - private async joinMeeting({ url, name, teamId, userId, eventId, botId, pushState, uploader }: JoinParams & { pushState(state: BotStatus): void }): Promise { + private async joinMeeting({ url, name, teamId, timezone, userId, eventId, botId, pushState, uploader }: JoinParams & { pushState(state: BotStatus): void }): Promise { const joinButtonSelectors = [ 'button[aria-label="Join meeting from this browser"]', 'button[aria-label="Continue on this browser"]', @@ -131,7 +130,7 @@ export class MicrosoftTeamsBot extends MeetBotBase { this._logger.info('Pre-warming: Opening browser to trigger first-run dialogs...'); let warmupPage: Page | undefined; try { - warmupPage = await createBrowserContext(url, this._correlationId, 'microsoft'); + warmupPage = await createBrowserContext(url, this._correlationId, 'microsoft', timezone); this._logger.info('Pre-warming: Navigating to Teams meeting...'); await warmupPage.goto(url, { waitUntil: 'domcontentloaded' }); await clickFirstVisibleSelector(warmupPage, joinButtonSelectors, 8000, 'Pre-warming'); @@ -143,11 +142,8 @@ export class MicrosoftTeamsBot extends MeetBotBase { // Guarantee the warmup chrome tree is reaped even if the block above threw // mid-flight. join()'s outer finally only covers this.page, not warmupPage. try { - const browser = warmupPage?.context().browser(); - if (browser?.isConnected()) { - this._logger.info('Pre-warming: Closing warmup browser...'); - await browser.close(); - } + this._logger.info('Pre-warming: Closing warmup browser session...'); + await closeBrowserSession(warmupPage); } catch (cleanupErr) { this._logger.warn('Pre-warming: warmup browser cleanup failed (non-fatal)', { error: cleanupErr }); } @@ -159,7 +155,7 @@ export class MicrosoftTeamsBot extends MeetBotBase { // Second run: Actual meeting join this._logger.info('Launching browser for actual meeting...'); - this.page = await createBrowserContext(url, this._correlationId, 'microsoft'); + this.page = await createBrowserContext(url, this._correlationId, 'microsoft', timezone); this._logger.info('Navigating to Microsoft Teams Meeting URL...'); await this.page.goto(url, { waitUntil: 'domcontentloaded' }); @@ -307,7 +303,7 @@ export class MicrosoftTeamsBot extends MeetBotBase { this._logger.error('Cant finish wait at the lobby check', { userDenied, waitingAtLobbySuccess: false, bodyText }); this._logger.error('Closing the browser on error...', error); - await this.page.context().browser()?.close(); + await closeBrowserSession(this.page); // Don't retry lobby errors - if user doesn't admit bot, retrying won't help throw new WaitingAtLobbyRetryError('Microsoft Teams Meeting bot could not enter the meeting...', bodyText ?? '', false, 0); @@ -634,11 +630,11 @@ export class MicrosoftTeamsBot extends MeetBotBase { } const alonePhrases = [ - "you're the only one here", - "you’re the only one here", + 'you\'re the only one here', + 'you’re the only one here', 'you are the only one here', - "you're the only one in this meeting", - "you’re the only one in this meeting", + 'you\'re the only one in this meeting', + 'you’re the only one in this meeting', 'you are the only one in this meeting', 'only one in this meeting', 'only you are here', @@ -738,7 +734,7 @@ export class MicrosoftTeamsBot extends MeetBotBase { return; } - const { count, samples } = getParticipantCount(); + const { count } = getParticipantCount(); let inferredCount = count; if (typeof inferredCount !== 'number') { if (meetingState === 'empty') { @@ -751,7 +747,7 @@ export class MicrosoftTeamsBot extends MeetBotBase { if (typeof inferredCount !== 'number') { const now = Date.now(); if (now - lastParticipantDetectionLogAt > 30000) { - console.log('Teams participant count not detected yet', { samples }); + console.log('Teams participant count not detected yet.'); lastParticipantDetectionLogAt = now; } return; @@ -776,10 +772,15 @@ export class MicrosoftTeamsBot extends MeetBotBase { } ); - // Wait for either timeout, meeting end, or FFmpeg failure - while (!meetingEnded && !ffmpegFailed && (Date.now() - startedAt) < duration) { - await new Promise(resolve => setTimeout(resolve, 1000)); - } + // Wait for either timeout, meeting end, FFmpeg failure, or browser-session failure. + // Race the whole monitoring period once so long meetings do not accumulate + // pending rejection handlers on BrowserSession.failure. + const monitorRecordingPeriod = async () => { + while (!meetingEnded && !ffmpegFailed && (Date.now() - startedAt) < duration) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + }; + await raceBrowserSessionFailure(this.page, monitorRecordingPeriod()); this._logger.info('Recording period ended', { meetingEnded, @@ -833,7 +834,7 @@ export class MicrosoftTeamsBot extends MeetBotBase { // Close browser this._logger.info('Closing the browser...'); - await this.page.context().browser()?.close(); + await closeBrowserSession(this.page); // Log final status. The real remote upload + true completion is logged in // join() after handleUpload: 'Recording and upload completed successfully'. diff --git a/src/bots/ZoomBot.ts b/src/bots/ZoomBot.ts index 58bad13d..289798a8 100644 --- a/src/bots/ZoomBot.ts +++ b/src/bots/ZoomBot.ts @@ -2,17 +2,32 @@ import { Frame, Page } from 'playwright'; import { JoinParams, AbstractMeetBot } from './AbstractMeetBot'; import { BotStatus, WaitPromise } from '../types'; import config from '../config'; -import { RecordingUploadFailedError, WaitingAtLobbyRetryError } from '../error'; +import { + RecordingUploadFailedError, + WaitingAtLobbyRetryError, + ZoomBrowserJoinBlockedError, + ZoomMeetingJoinError, +} from '../error'; import { v4 } from 'uuid'; import { patchBotStatus } from '../services/botService'; import { RecordingTask } from '../tasks/RecordingTask'; import { ContextBridgeTask } from '../tasks/ContextBridgeTask'; import { getWaitingPromise } from '../lib/promise'; -import createBrowserContext from '../lib/chromium'; +import createBrowserContext, { closeBrowserSession, getBrowserSession } from '../lib/chromium'; import { uploadDebugImage } from '../services/bugService'; import { Logger } from 'winston'; import { handleWaitingAtLobbyError } from './MeetBotBase'; -import { ZOOM_REQUEST_DENIED } from '../constants'; +import { ZoomRtmsTransport } from '../rtms/ZoomRtmsTransport'; +import { + classifyZoomJoinState, + shouldUseZoomRtmsFallback, + ZoomJoinState, +} from './zoomJoinState'; +import { reportRecoveredZoomFallback } from '../monitoring/sentry'; +import { + clickZoomJoinWithOptionalMediaPromptRetry, + dismissZoomOptionalMediaPrompt, +} from './zoomPrejoinModal'; class BotBase extends AbstractMeetBot { protected page: Page; @@ -31,6 +46,8 @@ class BotBase extends AbstractMeetBot { } export class ZoomBot extends BotBase { + private joinState: ZoomJoinState = 'prejoin'; + constructor(logger: Logger, correlationId: string) { super(logger, correlationId); } @@ -39,6 +56,21 @@ export class ZoomBot extends BotBase { // TODO Lift the JoinParams to the constructor argument async join({ url, name, bearerToken, teamId, timezone, userId, eventId, botId, uploader }: JoinParams): Promise { const _state: BotStatus[] = ['processing']; + let recoveredBrowserError: ZoomBrowserJoinBlockedError | undefined; + let recordingTransport: 'browser' | 'rtms' = + config.zoomTransportStrategy === 'rtms_only' ? 'rtms' : 'browser'; + let fallbackResult = 'not_attempted'; + + const annotateFailure = (error: unknown, phase: string) => { + if (error && typeof error === 'object') { + Object.assign(error, { + transport: recordingTransport, + fallbackResult, + phase, + }); + } + return error; + }; const handleUpload = async () => { this._logger.info('Begin recording upload to server', { userId, teamId }); @@ -49,39 +81,85 @@ export class ZoomBot extends BotBase { try { const pushState = (st: BotStatus) => _state.push(st); - await this.joinMeeting({ url, name, bearerToken, teamId, timezone, userId, eventId, botId, pushState, uploader }); - await patchBotStatus({ botId, eventId, provider: 'zoom', status: _state, token: bearerToken }, this._logger); + const joinParams = { url, name, bearerToken, teamId, timezone, userId, eventId, botId, uploader }; + if (config.zoomTransportStrategy === 'rtms_only') { + try { + await new ZoomRtmsTransport(this._logger).record(joinParams, pushState); + } catch (rtmsError) { + throw annotateFailure(rtmsError, 'recording'); + } + } else { + try { + await this.joinMeeting({ ...joinParams, pushState }); + } catch (browserError) { + if (!shouldUseZoomRtmsFallback( + browserError, + config.zoomTransportStrategy === 'browser_then_rtms', + this.joinState + )) { + throw browserError; + } + + this._logger.warn('Zoom browser join was blocked before admission; trying RTMS fallback', { + teamId, + eventId, + botId, + }); + await closeBrowserSession(this.page); + + recordingTransport = 'rtms'; + fallbackResult = 'failed'; + try { + await new ZoomRtmsTransport(this._logger).record(joinParams, pushState); + } catch (fallbackError) { + throw annotateFailure(fallbackError, 'fallback'); + } + + fallbackResult = 'recovered'; + recoveredBrowserError = browserError; + } + } // Finish the upload from the temp video - const uploadResult = await handleUpload(); + let uploadResult: boolean; + try { + uploadResult = await handleUpload(); + } catch (uploadError) { + throw annotateFailure(uploadError, 'upload'); + } if (_state.includes('finished') && !uploadResult) { _state.splice(_state.indexOf('finished'), 1, 'failed'); - throw new RecordingUploadFailedError('Zoom recording completed but upload failed'); + throw annotateFailure( + new RecordingUploadFailedError('Zoom recording completed but upload failed'), + 'upload' + ); + } + + await patchBotStatus({ botId, eventId, provider: 'zoom', status: _state, token: bearerToken }, this._logger); + if (recoveredBrowserError) { + reportRecoveredZoomFallback({ + phase: 'fallback', + teamId, + eventId, + botId, + correlationId: this._correlationId, + }, recoveredBrowserError); } } catch(error) { if (!_state.includes('finished') && !_state.includes('failed')) _state.push('failed'); await patchBotStatus({ botId, eventId, provider: 'zoom', status: _state, token: bearerToken }, this._logger); - + if (error instanceof WaitingAtLobbyRetryError) { await handleWaitingAtLobbyError({ token: bearerToken, botId, eventId, provider: 'zoom', error }, this._logger); } throw error; } finally { - // Guarantee chrome subprocess tree is reaped regardless of exit path. - // No-op if a deeper code path already closed the browser. try { - const browser = this.page?.context().browser(); - if (browser?.isConnected()) { - await browser.close(); - this._logger.info('Browser closed in join finally'); - } else if (this.page?.context()) { - await this.page.context().close(); - this._logger.info('Persistent browser context closed in join finally'); - } + await closeBrowserSession(this.page); } catch (cleanupErr) { this._logger.warn('Browser cleanup in join finally failed (non-fatal)', { error: cleanupErr }); } @@ -89,16 +167,83 @@ export class ZoomBot extends BotBase { } private async joinMeeting({ pushState, ...params }: JoinParams & { pushState(state: BotStatus): void }): Promise { - const { url, name } = params; + this.joinState = 'prejoin'; this._logger.info('Launching browser for Zoom...', { userId: params.userId }); + this.page = await createBrowserContext( + params.url, + this._correlationId, + 'zoom', + params.timezone, + params.teamId, + ); + const browserSession = getBrowserSession(this.page); + const joinWork = this.joinMeetingInBrowser({ ...params, pushState }); + + if (browserSession) { + await Promise.race([ + joinWork, + browserSession.failure.then(error => Promise.reject(error)), + ]); + return; + } - this.page = await createBrowserContext(url, this._correlationId, 'zoom'); + await joinWork; + } - await this.page.route('**/*.exe', (route) => { + private async joinMeetingInBrowser({ pushState, ...params }: JoinParams & { pushState(state: BotStatus): void }): Promise { + const { url, name } = params; + + await this.page.route('**/*.exe', async (route) => { this._logger.info(`Detected .exe download: ${route.request().url()?.split('download')[0]}`); + await route.abort(); }); - this._logger.info('Navigating to Zoom Meeting URL...'); + const readVisiblePageText = async (): Promise => { + const frameTexts = await Promise.all( + this.page.frames().map(frame => frame.locator('body').innerText({ timeout: 2_000 }).catch(() => '')) + ); + return frameTexts.join('\n'); + }; + + const applyJoinState = (bodyText: string): ZoomJoinState => { + const state = classifyZoomJoinState(bodyText); + if (state !== 'unknown') this.joinState = state; + if (state === 'automated_bot_blocked') { + throw new ZoomBrowserJoinBlockedError(); + } + if (state === 'host_rejected' || state === 'sign_in_required' || state === 'meeting_ended') { + throw new ZoomMeetingJoinError(state); + } + return state; + }; + + const inspectJoinState = async (): Promise => + applyJoinState(await readVisiblePageText()); + + let usingDirectWebClient = false; + const visitWebClientByUrl = async (): Promise => { + usingDirectWebClient = true; + try { + const wcUrl = new URL(url); + wcUrl.pathname = wcUrl.pathname.replace('/j/', '/wc/join/'); + this._logger.info('Navigating to the Zoom Web Client fallback...', { + botId: params.botId, + userId: params.userId, + }); + await this.page.goto(wcUrl.toString(), { waitUntil: 'domcontentloaded' }); + await inspectJoinState(); + return true; + } catch(err) { + if (err instanceof ZoomBrowserJoinBlockedError || err instanceof ZoomMeetingJoinError) { + throw err; + } + usingDirectWebClient = false; + this._logger.info('Failed to access ZOOM web client by URL', { botId: params.botId, userId: params.userId }); + return false; + } + }; + + this._logger.info('Navigating to the original Zoom meeting URL...'); await this.page.goto(url, { waitUntil: 'domcontentloaded' }); // Accept cookies @@ -108,8 +253,8 @@ export class ZoomBot extends BotBase { await acceptCookies.waitFor({ timeout: 2500 }); this._logger.info('Clicking the "Accept Cookies" button...', await acceptCookies.count()); - await acceptCookies.click({ force: true }); - + await acceptCookies.click(); + } catch (error) { this._logger.info('Unable to accept cookies...', error); } @@ -118,7 +263,6 @@ export class ZoomBot extends BotBase { this._logger.info(`Page focus status: ${hasFocus}`); const attempts = 3; - let usingDirectWebClient = false; const findAndEnableJoinFromBrowserButton = async (retry: number): Promise => { try { if (retry >= attempts) { @@ -128,20 +272,11 @@ export class ZoomBot extends BotBase { const launchMeetingGetByRole = this.page.getByRole('button', { name: /Launch Meeting/i }).first(); this._logger.info('Does Launch Meeting exist', await launchMeetingGetByRole.isVisible({ timeout: 1000 }).catch(() => false)); - const launchDownloadGetByRole = this.page.getByRole('button', { name: /Download Now/i }).first(); - const launchDownloadVisible = await launchDownloadGetByRole.isVisible({ timeout: 1000 }).catch(() => false); - this._logger.info('Does Download Now exist', launchDownloadVisible); - - if (launchDownloadVisible) { - this._logger.info('Click on Download Now...'); - await launchDownloadGetByRole.click({ force: true }); - } - const joinFromBrowser = this.page.locator('a', { hasText: 'Join from your browser' }).first(); await joinFromBrowser.waitFor({ timeout: 4000 }); if (await joinFromBrowser.isVisible({ timeout: 500 }).catch(() => false)) { - await joinFromBrowser.click({ force: true }); + await joinFromBrowser.click(); return true; } else { @@ -149,6 +284,7 @@ export class ZoomBot extends BotBase { return await findAndEnableJoinFromBrowserButton(retry + 1); } } catch(error) { + await inspectJoinState(); this._logger.info('Error on try find the web client', error); if (retry >= attempts) { return false; @@ -157,21 +293,6 @@ export class ZoomBot extends BotBase { } }; - const visitWebClientByUrl = async (): Promise => { - usingDirectWebClient = true; - try { - const wcUrl = new URL(url); - wcUrl.pathname = wcUrl.pathname.replace('/j/', '/wc/join/'); - this._logger.info('Navigating to Zoom Web Client URL...', { wcUrl: wcUrl.toString(), botId: params.botId, userId: params.userId }); - await this.page.goto(wcUrl.toString(), { waitUntil: 'domcontentloaded' }); - return true; - } catch(err) { - usingDirectWebClient = false; - this._logger.info('Failed to access ZOOM web client by URL', { botId: params.botId, userId: params.userId }); - return false; - } - }; - const waitForJoinFromBrowserNav = async (): Promise => { try { const maxAttempts = 10; @@ -220,25 +341,28 @@ export class ZoomBot extends BotBase { } }; - // Join from browser - this._logger.info('Waiting for Join from your browser to be visible...'); - const foundAndClickedJoinFromBrowser = await findAndEnableJoinFromBrowserButton(0); - - let navSuccess = false; - if (foundAndClickedJoinFromBrowser) { - this._logger.info('Verify the meeting web client is visible...'); - // Ensure the page has navigated to the web client... - navSuccess = await waitForJoinFromBrowserNav(); - } - - if (!foundAndClickedJoinFromBrowser || !navSuccess) { - await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'enable-join-from-browser', params.userId, this._logger, params.botId); - this._logger.info('Failed to enable Join from your browser button...', params.userId); - this._logger.info('Zoom Bot will now attempt to access the Web Client by URL...', params.userId); - const canAccess = await visitWebClientByUrl(); - if (!canAccess) { - await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'direct-access-webclient', params.userId, this._logger, params.botId); - throw new Error('Unable to join meeting after trying to access the web client by /wc/join/'); + if (!usingDirectWebClient) { + // Join from browser + this._logger.info('Waiting for Join from your browser to be visible...'); + const foundAndClickedJoinFromBrowser = await findAndEnableJoinFromBrowserButton(0); + + let navSuccess = false; + if (foundAndClickedJoinFromBrowser) { + this._logger.info('Verify the meeting web client is visible...'); + // Ensure the page has navigated to the web client... + navSuccess = await waitForJoinFromBrowserNav(); + } + + if (!foundAndClickedJoinFromBrowser || !navSuccess) { + await inspectJoinState(); + await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'enable-join-from-browser', params.userId, this._logger, params.botId); + this._logger.info('Failed to enable Join from your browser button...', params.userId); + this._logger.info('Zoom Bot will now attempt to access the Web Client by URL...', params.userId); + const canAccess = await visitWebClientByUrl(); + if (!canAccess) { + await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'direct-access-webclient', params.userId, this._logger, params.botId); + throw new Error('Unable to join meeting after trying to access the web client by /wc/join/'); + } } } @@ -278,6 +402,7 @@ export class ZoomBot extends BotBase { return true; } catch(err) { + await inspectJoinState(); this._logger.info('Cannot detect the App container for Zoom Web Client', startWith, err); await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'detect-app-container', params.userId, this._logger, params.botId); return await detectAppContainer(startWith === 'app' ? 'iframe' : 'app'); @@ -287,97 +412,65 @@ export class ZoomBot extends BotBase { const foundAppContainer = await detectAppContainer(usingDirectWebClient ? 'app' : 'iframe'); if (!iframe || !foundAppContainer) { + await inspectJoinState(); throw new Error(`Failed to get the Zoom PWA iframe on user ${params.userId}`); } this._logger.info('Waiting for the input field to be visible...'); await iframe.waitForSelector('input[type="text"]', { timeout: 60000 }); + if (await dismissZoomOptionalMediaPrompt(iframe)) { + this._logger.info('Continuing Zoom pre-join without microphone and camera...'); + } + this._logger.info('Filling the input field with the name...'); - await iframe.fill('input[type="text"]', name ? name : 'ScreenApp Notetaker'); + await iframe.fill('input[type="text"]', name ? name : 'winkk AI Notetaker'); this._logger.info('Clicking the "Join" button...'); + await dismissZoomOptionalMediaPrompt(iframe, 500); const joinButton = iframe.locator('button', { hasText: 'Join' }).first(); await joinButton.waitFor({ timeout: 15000 }); - await joinButton.click(); - - // Wait in waiting room - try { - const wanderingTime = config.joinWaitTime * 60 * 1000; // Give some time to be let in - - let waitTimeout: NodeJS.Timeout; - let waitInterval: NodeJS.Timeout; - const waitAtLobbyPromise = new Promise((resolveMe) => { - waitTimeout = setTimeout(() => { - clearInterval(waitInterval); - resolveMe(false); - }, wanderingTime); - - waitInterval = setInterval(async () => { - try { - const footerInfo = await iframe.locator('#wc-footer'); - await footerInfo.waitFor({ state: 'attached' }); - const footerText = await footerInfo?.innerText(); - - const tokens1 = footerText.split('\n'); - const tokens2 = footerText.split(' '); - const tokens = tokens1.length > tokens2.length ? tokens1 : tokens2; - - const filtered: string[] = []; - for (const tok of tokens) { - if (!tok) continue; - if (!Number.isNaN(Number(tok.trim()))) - filtered.push(tok); - else if (tok.trim().toLowerCase() === 'participants') { - filtered.push(tok.trim().toLowerCase()); - break; - } - } - const joinedText = filtered.join(''); - - if (joinedText === 'participants') - return; - - const isValid = joinedText.match(/\d+(.*)participants/i); - if (!isValid) { - return; - } - - const num = joinedText.match(/\d+/); - this._logger.info('Final Number of participants while waiting...', num); - if (num && Number(num[0]) === 0) - this._logger.info('Waiting on host...'); - else { - clearInterval(waitInterval); - clearTimeout(waitTimeout); - resolveMe(true); - } - } catch(e) { - // Do nothing - } - }, 2000); - }); - - const joined = await waitAtLobbyPromise; - if (!joined) { - const bodyText = await this.page.evaluate(() => document.body.innerText); - - const userDenied = (bodyText || '')?.includes(ZOOM_REQUEST_DENIED); - - this._logger.error('Cant finish wait at the lobby check', { userDenied, waitingAtLobbySuccess: joined, bodyText }); - - // Don't retry lobby errors - if user doesn't admit bot, retrying won't help - throw new WaitingAtLobbyRetryError('Zoom bot could not enter the meeting...', bodyText ?? '', false, 0); + await clickZoomJoinWithOptionalMediaPromptRetry( + iframe, + () => joinButton.click({ timeout: 2_000 }) + ); + this.joinState = 'waiting_room'; + + const lobbyDeadline = Date.now() + config.joinWaitTime * 60 * 1000; + let joined = false; + while (Date.now() < lobbyDeadline) { + const state = await inspectJoinState(); + if (state === 'waiting_room') this.joinState = state; + + const footerText = await iframe.locator('#wc-footer') + .innerText({ timeout: 1_500 }) + .catch(() => ''); + const participantCount = footerText.match(/(\d+)\s*participants?/i); + if (participantCount && Number(participantCount[1]) > 0) { + joined = true; + break; } - this._logger.info('Bot is entering the meeting after wait room...'); - } catch (error) { - this._logger.info('Closing the browser on error...', error); - await this.page.context().browser()?.close(); + await new Promise(resolve => setTimeout(resolve, 2_000)); + } - throw error; + if (!joined) { + await inspectJoinState(); + this._logger.warn('Zoom participant was not admitted before the lobby timeout', { + botId: params.botId, + userId: params.userId, + }); + throw new WaitingAtLobbyRetryError( + 'Zoom recording participant was not admitted before the lobby timeout', + '', + false, + 0 + ); } + this.joinState = 'joined'; + this._logger.info('Bot is entering the meeting after wait room...'); + // Wait for device notifications and close the notifications let notifyInternval: NodeJS.Timeout; let notifyTimeout: NodeJS.Timeout; @@ -518,12 +611,14 @@ export class ZoomBot extends BotBase { await recordingTask.runAsync(null); this._logger.info('Waiting for recording duration:', config.maxRecordingDuration, 'minutes...'); - waitingPromise.promise.then(async () => { - this._logger.info('Closing the browser...'); - await this.page.context().browser()?.close(); + await waitingPromise.promise; - this._logger.info('Recording stopped; finalizing upload next...', { botId, eventId, userId, teamId }); + this._logger.info('Recording stopped; closing the meeting browser context...', { + botId, + eventId, + userId, + teamId, }); - await waitingPromise.promise; + await closeBrowserSession(this.page); } } diff --git a/src/bots/botService.test.ts b/src/bots/botService.test.ts new file mode 100644 index 00000000..e8df91cb --- /dev/null +++ b/src/bots/botService.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { hasUsableLegacyCoreToken } from '../services/botService'; + +test('legacy Core callback skips missing and placeholder bearer tokens', () => { + assert.equal(hasUsableLegacyCoreToken(''), false); + assert.equal(hasUsableLegacyCoreToken(' your-auth-token '), false); + assert.equal(hasUsableLegacyCoreToken('PLACEHOLDER'), false); + assert.equal(hasUsableLegacyCoreToken('unused'), false); +}); + +test('legacy Core callback accepts a real bearer token value', () => { + assert.equal(hasUsableLegacyCoreToken('eyJhbGciOiJSUzI1NiJ9.payload.signature'), true); +}); diff --git a/src/bots/zoomJoinState.test.ts b/src/bots/zoomJoinState.test.ts new file mode 100644 index 00000000..cc70e9fa --- /dev/null +++ b/src/bots/zoomJoinState.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ZoomBrowserJoinBlockedError } from '../error'; +import { classifyZoomJoinState, shouldUseZoomRtmsFallback } from './zoomJoinState'; + +test('classifies explicit Zoom browser-security blocks as bot blocked', () => { + assert.equal( + classifyZoomJoinState('Automated bots aren\'t allowed to join this meeting. Sign in to join.'), + 'automated_bot_blocked' + ); + assert.equal( + classifyZoomJoinState('Zoom needs to review the security of your connection before proceeding.'), + 'automated_bot_blocked' + ); + assert.equal(classifyZoomJoinState('Sign in to join this meeting'), 'sign_in_required'); +}); + +test('allows RTMS fallback only for an explicit pre-join automated-bot block', () => { + const blocked = new ZoomBrowserJoinBlockedError(); + + assert.equal(shouldUseZoomRtmsFallback(blocked, true, 'prejoin'), true); + assert.equal(shouldUseZoomRtmsFallback(blocked, false, 'prejoin'), false); + assert.equal(shouldUseZoomRtmsFallback(blocked, true, 'joined'), false); + assert.equal(shouldUseZoomRtmsFallback(new Error('automated bot blocked'), true, 'prejoin'), false); +}); + +test('classifies terminal Zoom join states', () => { + assert.equal(classifyZoomJoinState('You have been removed'), 'host_rejected'); + assert.equal(classifyZoomJoinState('This meeting has been ended by host'), 'meeting_ended'); + assert.equal( + classifyZoomJoinState('Please wait, the meeting host will let you in soon.'), + 'waiting_room' + ); +}); diff --git a/src/bots/zoomJoinState.ts b/src/bots/zoomJoinState.ts new file mode 100644 index 00000000..d6b26aef --- /dev/null +++ b/src/bots/zoomJoinState.ts @@ -0,0 +1,59 @@ +export type ZoomJoinState = + | 'prejoin' + | 'waiting_room' + | 'joined' + | 'host_rejected' + | 'sign_in_required' + | 'meeting_ended' + | 'automated_bot_blocked' + | 'unknown'; + +const normalize = (value?: string | null): string => + (value ?? '').replace(/\s+/g, ' ').trim().toLowerCase(); + +export const classifyZoomJoinState = (bodyText?: string | null): ZoomJoinState => { + const text = normalize(bodyText); + if (!text) return 'unknown'; + + if ( + /automated bots? (?:are not|aren't) allowed to join/.test(text) + || /this meeting (?:does not|doesn't) allow automated bots?/.test(text) + || text.includes('zoom needs to review the security of your connection before proceeding') + ) { + return 'automated_bot_blocked'; + } + + if (text.includes('you have been removed')) return 'host_rejected'; + if ( + text.includes('this meeting has been ended by host') + || text.includes('this meeting has ended') + || text.includes('meeting has been ended') + ) { + return 'meeting_ended'; + } + if ( + text.includes('sign in to join') + || text.includes('you need to sign in to join this meeting') + ) { + return 'sign_in_required'; + } + if ( + text.includes('please wait, the meeting host will let you in soon') + || text.includes('the host will let you in soon') + ) { + return 'waiting_room'; + } + + return 'unknown'; +}; + +export const shouldUseZoomRtmsFallback = ( + error: unknown, + fallbackEnabled: boolean, + joinState: ZoomJoinState +): error is ZoomBrowserJoinBlockedError => + fallbackEnabled + && joinState !== 'joined' + && error instanceof ZoomBrowserJoinBlockedError + && error.reason === 'automated_bot_blocked'; +import { ZoomBrowserJoinBlockedError } from '../error'; diff --git a/src/bots/zoomPrejoinModal.test.ts b/src/bots/zoomPrejoinModal.test.ts new file mode 100644 index 00000000..2070e9e9 --- /dev/null +++ b/src/bots/zoomPrejoinModal.test.ts @@ -0,0 +1,263 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Page } from 'playwright'; +import { + clickZoomJoinWithOptionalMediaPromptRetry, + dismissZoomOptionalMediaPrompt, + isZoomOptionalMediaPromptText, +} from './zoomPrejoinModal'; + +type PromptKind = 'see' | 'hear'; + +const promptTexts: Record = { + see: 'Do you want people to see you in the meeting? You can still turn off your microphone and camera anytime in the meeting Continue without microphone and camera', + hear: 'Do you want people to hear you in the meeting? You can still turn off your microphone and camera anytime in the meeting Continue without microphone and camera', +}; + +const createPromptRoot = ( + initialPrompt?: PromptKind, + transition?: { from: PromptKind; to: PromptKind; delay: number }, +) => { + let activePrompt = initialPrompt; + let buttonVisible = activePrompt !== undefined; + let pendingTransition: Promise | undefined; + const clicks: PromptKind[] = []; + + const waitForState = async (predicate: () => boolean, timeout: number) => { + if (predicate()) return; + const transitionPromise = pendingTransition; + if (!transitionPromise) throw new Error('state is not available'); + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('state timed out')), timeout); + transitionPromise.then(() => { + clearTimeout(timer); + resolve(); + }); + }); + if (!predicate()) throw new Error('state is not available'); + }; + + const locatorFor = (hasText: RegExp) => { + const matchesActivePrompt = () => activePrompt !== undefined + && hasText.test(promptTexts[activePrompt]); + const button = { + first: () => button, + click: async () => { + if (!activePrompt || !matchesActivePrompt() || !buttonVisible) { + throw new Error('button is not visible'); + } + + const clickedPrompt = activePrompt; + clicks.push(clickedPrompt); + buttonVisible = false; + if (transition && clickedPrompt === transition.from) { + pendingTransition = new Promise(resolve => { + setTimeout(() => { + activePrompt = transition.to; + buttonVisible = true; + pendingTransition = undefined; + resolve(); + }, transition.delay); + }); + } else { + activePrompt = undefined; + } + }, + waitFor: async ({ state, timeout = 0 }: { state: 'visible' | 'hidden'; timeout?: number }) => { + await waitForState( + () => state === 'visible' + ? matchesActivePrompt() && buttonVisible + : !matchesActivePrompt() || !buttonVisible, + timeout + ); + }, + }; + const locator = { + last: () => locator, + waitFor: async ({ state, timeout = 0 }: { state: 'visible' | 'hidden'; timeout?: number }) => { + await waitForState( + () => state === 'visible' ? matchesActivePrompt() : !matchesActivePrompt(), + timeout + ); + }, + innerText: async () => activePrompt && matchesActivePrompt() + ? promptTexts[activePrompt] + : '', + getByRole: () => button, + }; + return locator; + }; + + return { + root: { + locator: () => ({ filter: ({ hasText }: { hasText: RegExp }) => locatorFor(hasText) }), + } as unknown as Page, + clicks, + show: (prompt: PromptKind) => { + activePrompt = prompt; + buttonVisible = true; + }, + }; +}; + +test('recognizes only the optional Zoom media permission prompt', () => { + assert.equal(isZoomOptionalMediaPromptText( + 'Do you want people to see you in the meeting? You can still turn off your microphone and camera anytime in the meeting Continue without microphone and camera' + ), true); + assert.equal(isZoomOptionalMediaPromptText( + 'Do you want people to hear you in the meeting? You can still turn off your microphone and camera anytime in the meeting Continue without microphone and camera' + ), true); + assert.equal(isZoomOptionalMediaPromptText( + 'The host requires you to consent before joining. Continue' + ), false); +}); + +test('dismisses the optional Zoom media prompt through its accessible control', async () => { + const prompt = createPromptRoot('see'); + + assert.equal(await dismissZoomOptionalMediaPrompt(prompt.root), true); + assert.deepEqual(prompt.clicks, ['see']); +}); + +test('dismisses sequential prompts after a delayed stale-overlay swap', async () => { + const prompt = createPromptRoot('see', { from: 'see', to: 'hear', delay: 350 }); + + assert.equal(await dismissZoomOptionalMediaPrompt(prompt.root), true); + assert.deepEqual(prompt.clicks, ['see', 'hear']); +}); + +test('does not fail when Zoom keeps the reusable modal overlay visible', async () => { + let overlayHiddenWaits = 0; + let buttonVisible = true; + const button = { + first: () => button, + click: async () => { buttonVisible = false; }, + waitFor: async ({ state }: { state: 'visible' | 'hidden' }) => { + if (state === 'visible' ? buttonVisible : !buttonVisible) return; + throw new Error('button state did not match'); + }, + }; + const prompt = { + last: () => prompt, + innerText: async () => promptTexts.see, + getByRole: () => button, + waitFor: async ({ state }: { state: 'visible' | 'hidden' }) => { + if (state === 'visible') return; + overlayHiddenWaits += 1; + throw new Error('overlay remained visible'); + }, + }; + const missingPrompt = { + last: () => missingPrompt, + waitFor: async () => { throw new Error('prompt is not visible'); }, + }; + const root = { + locator: () => ({ + filter: ({ hasText }: { hasText: RegExp }) => ({ + last: () => hasText.test(promptTexts.hear) && !hasText.test(promptTexts.see) + ? missingPrompt + : prompt, + }), + }), + } as unknown as Page; + + assert.equal(await dismissZoomOptionalMediaPrompt(root), true); + assert.equal(overlayHiddenWaits, 0); +}); + +test('returns false instead of throwing when Zoom does not dismiss the media prompt', async () => { + const button = { + first: () => button, + click: async () => undefined, + waitFor: async ({ state }: { state: 'visible' | 'hidden' }) => { + if (state === 'visible') return; + throw new Error('button remained visible'); + }, + }; + const prompt = { + last: () => prompt, + waitFor: async () => undefined, + innerText: async () => promptTexts.see, + getByRole: () => button, + }; + const root = { + locator: () => ({ filter: () => ({ last: () => prompt }) }), + } as unknown as Page; + + assert.equal(await dismissZoomOptionalMediaPrompt(root), false); +}); + +test('does not interact with an unknown Zoom modal', async () => { + let clicks = 0; + const button = { + first: () => button, + click: async () => { clicks += 1; }, + }; + const prompt = { + last: () => prompt, + waitFor: async () => undefined, + innerText: async () => 'The host requires you to consent before joining. Continue', + getByRole: () => button, + }; + const root = { + locator: () => ({ filter: () => ({ last: () => prompt }) }), + } as unknown as Page; + + assert.equal(await dismissZoomOptionalMediaPrompt(root), false); + assert.equal(clicks, 0); +}); + +test('retries Join after dismissing a late optional media prompt', async () => { + const prompt = createPromptRoot(); + const clickError = new Error('media overlay intercepted the click'); + let joinAttempts = 0; + + await clickZoomJoinWithOptionalMediaPromptRetry(prompt.root, async () => { + joinAttempts += 1; + if (joinAttempts === 1) { + prompt.show('hear'); + throw clickError; + } + }); + + assert.equal(joinAttempts, 2); + assert.deepEqual(prompt.clicks, ['hear']); +}); + +test('propagates the Join error when no optional media prompt was dismissed', async () => { + const prompt = createPromptRoot(); + const clickError = new Error('Join remained disabled'); + let joinAttempts = 0; + + await assert.rejects( + clickZoomJoinWithOptionalMediaPromptRetry(prompt.root, async () => { + joinAttempts += 1; + throw clickError; + }), + error => error === clickError + ); + assert.equal(joinAttempts, 1); +}); + +test('bounds Join retries when each failed click reveals another media prompt', async () => { + const prompt = createPromptRoot(); + const clickErrors = [ + new Error('first click failed'), + new Error('second click failed'), + new Error('third click failed'), + ]; + let joinAttempts = 0; + + await assert.rejects( + clickZoomJoinWithOptionalMediaPromptRetry(prompt.root, async () => { + const clickError = clickErrors[joinAttempts]; + joinAttempts += 1; + if (joinAttempts < clickErrors.length) prompt.show('hear'); + throw clickError; + }), + error => error === clickErrors[2] + ); + assert.equal(joinAttempts, 3); + assert.deepEqual(prompt.clicks, ['hear', 'hear']); +}); diff --git a/src/bots/zoomPrejoinModal.ts b/src/bots/zoomPrejoinModal.ts new file mode 100644 index 00000000..4d1ae155 --- /dev/null +++ b/src/bots/zoomPrejoinModal.ts @@ -0,0 +1,107 @@ +import type { Frame, Page } from 'playwright'; + +type ZoomPrejoinRoot = Frame | Page; + +type ZoomOptionalMediaPromptKind = 'see' | 'hear'; + +const OPTIONAL_MEDIA_PROMPTS: Record = { + see: /Do you want people to see you in the meeting\?/i, + hear: /Do you want people to hear you in the meeting\?/i, +}; +const OPTIONAL_MEDIA_PROMPT = /Do you want people to (?:see|hear) you in the meeting\?/i; +const CONTINUE_WITHOUT_MEDIA = /^(?:Continue without microphone and camera|Ohne Mikrofon und Kamera fortfahren)$/i; +const NEXT_OPTIONAL_MEDIA_PROMPT_TIMEOUT = 1_500; +const ZOOM_JOIN_CLICK_ATTEMPTS = 3; + +const getZoomOptionalMediaPromptKind = (text: string): ZoomOptionalMediaPromptKind | undefined => + (Object.keys(OPTIONAL_MEDIA_PROMPTS) as ZoomOptionalMediaPromptKind[]) + .find(kind => OPTIONAL_MEDIA_PROMPTS[kind].test(text)); + +export const isZoomOptionalMediaPromptText = (text?: string | null): boolean => { + const promptText = text ?? ''; + return getZoomOptionalMediaPromptKind(promptText) !== undefined + && /microphone and camera/i.test(promptText) + && /Continue without microphone and camera/i.test(promptText); +}; + +export async function dismissZoomOptionalMediaPrompt( + root: ZoomPrejoinRoot, + timeout = 2_500, +): Promise { + const overlays = root.locator('.ReactModal__Overlay.ReactModal__Overlay--after-open'); + const prompt = overlays + .filter({ hasText: OPTIONAL_MEDIA_PROMPT }) + .last(); + + if (!await prompt + .waitFor({ state: 'visible', timeout }) + .then(() => true) + .catch(() => false)) return false; + + const dismissedKinds = new Set(); + let dismissed = false; + let currentPrompt = prompt; + + for (let attempt = 0; attempt < Object.keys(OPTIONAL_MEDIA_PROMPTS).length; attempt += 1) { + const promptText = await currentPrompt.innerText().catch(() => ''); + const promptKind = getZoomOptionalMediaPromptKind(promptText); + if (!promptKind || !isZoomOptionalMediaPromptText(promptText)) return dismissed; + + const continueWithoutMedia = overlays + .filter({ hasText: OPTIONAL_MEDIA_PROMPTS[promptKind] }) + .last() + .getByRole('button', { name: CONTINUE_WITHOUT_MEDIA }) + .first(); + if (!await continueWithoutMedia + .waitFor({ state: 'visible', timeout: 1_000 }) + .then(() => true) + .catch(() => false)) return dismissed; + if (dismissedKinds.has(promptKind)) return false; + + if (!await continueWithoutMedia + .click() + .then(() => true) + .catch(() => false)) return false; + const didDismiss = await continueWithoutMedia + .waitFor({ state: 'hidden', timeout: 5_000 }) + .then(() => true) + .catch(() => false); + if (!didDismiss) return false; + + dismissedKinds.add(promptKind); + dismissed = true; + if (attempt === Object.keys(OPTIONAL_MEDIA_PROMPTS).length - 1) return true; + + const nextPromptKind = (Object.keys(OPTIONAL_MEDIA_PROMPTS) as ZoomOptionalMediaPromptKind[]) + .find(kind => !dismissedKinds.has(kind)); + if (!nextPromptKind) return true; + + currentPrompt = overlays + .filter({ hasText: OPTIONAL_MEDIA_PROMPTS[nextPromptKind] }) + .last(); + const nextPromptIsVisible = await currentPrompt + .waitFor({ state: 'visible', timeout: NEXT_OPTIONAL_MEDIA_PROMPT_TIMEOUT }) + .then(() => true) + .catch(() => false); + if (!nextPromptIsVisible) return true; + } + + return dismissed; +} + +export async function clickZoomJoinWithOptionalMediaPromptRetry( + root: ZoomPrejoinRoot, + clickJoin: () => Promise, +): Promise { + for (let attempt = 0; attempt < ZOOM_JOIN_CLICK_ATTEMPTS; attempt += 1) { + try { + await clickJoin(); + return; + } catch (clickError) { + if (attempt === ZOOM_JOIN_CLICK_ATTEMPTS - 1) throw clickError; + if (!await dismissZoomOptionalMediaPrompt(root, 1_000).catch(() => false)) { + throw clickError; + } + } + } +} diff --git a/src/config.ts b/src/config.ts index 556f8057..74500a4c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import dotenv from 'dotenv'; +import path from 'node:path'; import { UploaderType } from './types'; dotenv.config(); @@ -19,6 +20,233 @@ export const NODE_ENV: Environment = ENVIRONMENTS.includes( console.log('NODE_ENV', process.env.NODE_ENV); +export interface ZoomRtmsCustomerCredentials { + enabled: boolean; + /** Server-to-Server OAuth account ID. */ + accountId: string; + /** Server-to-Server OAuth client ID. */ + clientId: string; + /** Server-to-Server OAuth client secret. */ + clientSecret: string; + participantUserId: string; + rtmsApp?: ZoomRtmsCustomerAppCredentials; +} + +export type ZoomRtmsCustomerCredentialMode = + | 'auto' + | 'shared_customer' + | 'dedicated_customer'; + +export interface ZoomRtmsCustomerAppCredentials { + webhookId: string; + clientId: string; + clientSecret: string; + webhookSecret: string; +} + +export interface ZoomRtmsCustomerCredentialsParseResult { + credentials: Record; + entryErrors: Record; + error?: string; +} + +export interface ZoomChromeCustomerStorageStatePathsParseResult { + paths: Record; + entryErrors: Record; + error?: string; +} + +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' + && value !== null + && !Array.isArray(value); + +const isValidExactId = (value: string): boolean => + Boolean(value) + && value.trim() === value + && value.length <= 256 + && !/[\u0000-\u001f\u007f]/.test(value); + +const isValidWebhookId = (value: string): boolean => + /^[A-Za-z0-9_-]{1,128}$/.test(value) + && value !== 'global'; + +export const parseZoomRtmsCustomerCredentials = ( + rawValue?: string, + credentialMode: ZoomRtmsCustomerCredentialMode = 'auto' +): ZoomRtmsCustomerCredentialsParseResult => { + const credentials: Record = Object.create(null); + const entryErrors: Record = Object.create(null); + const teamByWebhookId = new Map(); + if (!rawValue?.trim()) return { credentials, entryErrors }; + + let parsed: unknown; + try { + parsed = JSON.parse(rawValue); + } catch { + return { + credentials, + entryErrors, + error: 'ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON must contain valid JSON', + }; + } + + if (!isPlainObject(parsed)) { + return { + credentials, + entryErrors, + error: 'ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON must be an object keyed by teamId', + }; + } + + for (const [teamId, value] of Object.entries(parsed)) { + if (!isValidExactId(teamId) || !isPlainObject(value)) { + entryErrors[teamId] = 'customer credentials must be an object'; + continue; + } + + const invalidFields: string[] = []; + if (typeof value.enabled !== 'boolean') invalidFields.push('enabled'); + for (const field of ['accountId', 'clientId', 'clientSecret', 'participantUserId'] as const) { + if (typeof value[field] !== 'string' || !value[field].trim()) { + invalidFields.push(field); + } + } + + if (invalidFields.length > 0) { + entryErrors[teamId] = `invalid or missing fields: ${invalidFields.join(', ')}`; + continue; + } + + let rtmsApp: ZoomRtmsCustomerAppCredentials | undefined; + if (typeof value.rtmsApp !== 'undefined') { + if (!isPlainObject(value.rtmsApp)) { + entryErrors[teamId] = 'rtmsApp must be an object'; + continue; + } + + const invalidAppFields: string[] = []; + for (const field of ['webhookId', 'clientId', 'clientSecret', 'webhookSecret'] as const) { + if (typeof value.rtmsApp[field] !== 'string' || !value.rtmsApp[field].trim()) { + invalidAppFields.push(`rtmsApp.${field}`); + } + } + if ( + typeof value.rtmsApp.webhookId === 'string' + && value.rtmsApp.webhookId.trim() + && !isValidWebhookId(value.rtmsApp.webhookId) + ) { + invalidAppFields.push('rtmsApp.webhookId'); + } + if (invalidAppFields.length > 0) { + entryErrors[teamId] = `invalid or missing fields: ${Array.from(new Set(invalidAppFields)).join(', ')}`; + continue; + } + + if (credentialMode === 'shared_customer') { + entryErrors[teamId] = 'rtmsApp is not allowed in shared_customer mode'; + continue; + } + + const webhookId = value.rtmsApp.webhookId as string; + const existingTeamId = teamByWebhookId.get(webhookId); + if (existingTeamId) { + delete credentials[existingTeamId]; + entryErrors[existingTeamId] = 'rtmsApp.webhookId must be unique'; + entryErrors[teamId] = 'rtmsApp.webhookId must be unique'; + continue; + } + + teamByWebhookId.set(webhookId, teamId); + rtmsApp = { + webhookId, + clientId: (value.rtmsApp.clientId as string).trim(), + clientSecret: (value.rtmsApp.clientSecret as string).trim(), + webhookSecret: (value.rtmsApp.webhookSecret as string).trim(), + }; + } + + if (credentialMode === 'dedicated_customer' && !rtmsApp) { + entryErrors[teamId] = 'rtmsApp is required in dedicated_customer mode'; + continue; + } + + credentials[teamId] = { + enabled: value.enabled as boolean, + accountId: (value.accountId as string).trim(), + clientId: (value.clientId as string).trim(), + clientSecret: (value.clientSecret as string).trim(), + participantUserId: (value.participantUserId as string).trim(), + ...(rtmsApp ? { rtmsApp } : {}), + }; + } + + return { credentials, entryErrors }; +}; + +export const parseZoomChromeCustomerStorageStatePaths = ( + rawValue?: string +): ZoomChromeCustomerStorageStatePathsParseResult => { + const paths: Record = Object.create(null); + const entryErrors: Record = Object.create(null); + const teamByPath = new Map(); + if (!rawValue?.trim()) return { paths, entryErrors }; + + let parsed: unknown; + try { + parsed = JSON.parse(rawValue); + } catch { + return { + paths, + entryErrors, + error: 'ZOOM_CHROME_CUSTOMER_STORAGE_STATE_PATHS_JSON must contain valid JSON', + }; + } + + if (!isPlainObject(parsed)) { + return { + paths, + entryErrors, + error: 'ZOOM_CHROME_CUSTOMER_STORAGE_STATE_PATHS_JSON must be an object keyed by teamId', + }; + } + + for (const [teamId, value] of Object.entries(parsed)) { + if ( + !teamId + || teamId.trim() !== teamId + || teamId.length > 256 + || /[\u0000-\u001f\u007f]/.test(teamId) + ) { + entryErrors[teamId] = 'teamId is invalid'; + continue; + } + + if ( + typeof value !== 'string' + || value.trim() !== value + || value.includes('\0') + || !path.isAbsolute(value) + ) { + entryErrors[teamId] = 'storage state path must be an absolute path'; + continue; + } + + const existingTeamId = teamByPath.get(value); + if (existingTeamId) { + delete paths[existingTeamId]; + entryErrors[existingTeamId] = 'storage state path must be unique per teamId'; + entryErrors[teamId] = 'storage state path must be unique per teamId'; + continue; + } + + teamByPath.set(value, teamId); + paths[teamId] = value; + } + + return { paths, entryErrors }; +}; + const requiredSettings = [ 'GCP_DEFAULT_REGION', 'GCP_MISC_BUCKET', @@ -55,6 +283,65 @@ const parseOptionalNumber = (value?: string) => { return Number(value); }; +const zoomRecordingTransport = process.env.ZOOM_RECORDING_TRANSPORT ?? 'browser'; +if (!['browser', 'rtms'].includes(zoomRecordingTransport)) { + throw new Error('ZOOM_RECORDING_TRANSPORT must be browser or rtms'); +} + +export type ZoomRtmsTransportStrategy = + | 'browser_only' + | 'browser_then_rtms' + | 'rtms_only'; + +export const deriveZoomRtmsTransportStrategy = ( + transport: 'browser' | 'rtms', + fallbackEnabled: boolean +): ZoomRtmsTransportStrategy => { + if (transport === 'rtms') return 'rtms_only'; + return fallbackEnabled ? 'browser_then_rtms' : 'browser_only'; +}; + +const zoomRtmsFallbackEnabled = process.env.ZOOM_RTMS_FALLBACK_ENABLED === 'true'; +const zoomTransportStrategy = deriveZoomRtmsTransportStrategy( + zoomRecordingTransport as 'browser' | 'rtms', + zoomRtmsFallbackEnabled +); + +const zoomRtmsEventTtlSeconds = process.env.ZOOM_RTMS_EVENT_TTL_SECONDS + ? Number(process.env.ZOOM_RTMS_EVENT_TTL_SECONDS) + : 3600; +if (!Number.isFinite(zoomRtmsEventTtlSeconds) || zoomRtmsEventTtlSeconds <= 0) { + throw new Error('ZOOM_RTMS_EVENT_TTL_SECONDS must be a positive number'); +} + +const zoomRtmsCustomerCredentialMode = + process.env.ZOOM_RTMS_CUSTOMER_CREDENTIAL_MODE ?? 'auto'; +if (!['auto', 'shared_customer', 'dedicated_customer'].includes(zoomRtmsCustomerCredentialMode)) { + throw new Error( + 'ZOOM_RTMS_CUSTOMER_CREDENTIAL_MODE must be auto, shared_customer or dedicated_customer' + ); +} + +const zoomRtmsCustomerCredentials = parseZoomRtmsCustomerCredentials( + process.env.ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON, + zoomRtmsCustomerCredentialMode as ZoomRtmsCustomerCredentialMode +); +const zoomRtmsGlobalTeamId = process.env.ZOOM_RTMS_GLOBAL_TEAM_ID; +if ( + typeof zoomRtmsGlobalTeamId !== 'undefined' + && ( + !zoomRtmsGlobalTeamId + || zoomRtmsGlobalTeamId.trim() !== zoomRtmsGlobalTeamId + || zoomRtmsGlobalTeamId.length > 256 + || /[\u0000-\u001f\u007f]/.test(zoomRtmsGlobalTeamId) + ) +) { + throw new Error('ZOOM_RTMS_GLOBAL_TEAM_ID must contain one valid exact teamId'); +} +const zoomChromeCustomerStorageStatePaths = parseZoomChromeCustomerStorageStatePaths( + process.env.ZOOM_CHROME_CUSTOMER_STORAGE_STATE_PATHS_JSON +); + export default { port: process.env.PORT || 3000, db: { @@ -70,12 +357,40 @@ export default { googleChromeCdpUrl: process.env.GOOGLE_CHROME_CDP_URL, googleChromeUserDataDir: process.env.GOOGLE_CHROME_USER_DATA_DIR, googleChromeStorageStatePath: process.env.GOOGLE_CHROME_STORAGE_STATE_PATH, + zoomChromeCdpUrl: process.env.ZOOM_CHROME_CDP_URL, + zoomChromeCustomerStorageStatePaths: zoomChromeCustomerStorageStatePaths.paths, + zoomChromeCustomerStorageStatePathErrors: zoomChromeCustomerStorageStatePaths.entryErrors, + zoomChromeCustomerStorageStatePathsError: zoomChromeCustomerStorageStatePaths.error, + zoomRecordingTransport: zoomRecordingTransport as 'browser' | 'rtms', + zoomRtmsFallbackEnabled, + zoomTransportStrategy, + zoomRtms: { + clientId: process.env.ZOOM_RTMS_CLIENT_ID, + clientSecret: process.env.ZOOM_RTMS_CLIENT_SECRET, + webhookSecret: process.env.ZOOM_RTMS_WEBHOOK_SECRET, + oauthAccessToken: process.env.ZOOM_RTMS_OAUTH_ACCESS_TOKEN, + oauthAccountId: process.env.ZOOM_RTMS_OAUTH_ACCOUNT_ID, + oauthClientId: process.env.ZOOM_RTMS_OAUTH_CLIENT_ID, + oauthClientSecret: process.env.ZOOM_RTMS_OAUTH_CLIENT_SECRET, + participantUserId: process.env.ZOOM_RTMS_PARTICIPANT_USER_ID, + globalTeamId: zoomRtmsGlobalTeamId, + eventTtlSeconds: zoomRtmsEventTtlSeconds, + customerCredentialMode: zoomRtmsCustomerCredentialMode as ZoomRtmsCustomerCredentialMode, + customerCredentials: zoomRtmsCustomerCredentials.credentials, + customerCredentialErrors: zoomRtmsCustomerCredentials.entryErrors, + customerCredentialsError: zoomRtmsCustomerCredentials.error, + }, googleAnonymousJoinRequestAttempts: process.env.GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS ? Number(process.env.GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS) : 10, inactivityLimit: process.env.MEETING_INACTIVITY_MINUTES ? Number(process.env.MEETING_INACTIVITY_MINUTES) : 1, activateInactivityDetectionAfter: process.env.INACTIVITY_DETECTION_START_DELAY_MINUTES ? Number(process.env.INACTIVITY_DETECTION_START_DELAY_MINUTES) : 1, loneParticipantExitDelaySeconds: process.env.LONE_PARTICIPANT_EXIT_DELAY_SECONDS ? Number(process.env.LONE_PARTICIPANT_EXIT_DELAY_SECONDS) : 10, + // Target video bitrate for MediaRecorder (bits/sec). The VP9 default produced + // ~187 kbps because the encoder was starved; a sane target avoids blocky/stuttery output. + recordingVideoBitsPerSecond: process.env.RECORDING_VIDEO_BITS_PER_SECOND + ? Number(process.env.RECORDING_VIDEO_BITS_PER_SECOND) + : 4_000_000, serviceKey: process.env.SCREENAPP_BACKEND_SERVICE_API_KEY, joinWaitTime: process.env.JOIN_WAIT_TIME_MINUTES ? Number(process.env.JOIN_WAIT_TIME_MINUTES) : 10, // Number of retries for transient errors (not applied to WaitingAtLobbyRetryError) diff --git a/src/error.ts b/src/error.ts index 5eec3e8c..12f72a07 100644 --- a/src/error.ts +++ b/src/error.ts @@ -39,6 +39,55 @@ export class RecordingUploadFailedError extends KnownError { } } +export class ZoomBrowserJoinBlockedError extends KnownError { + public readonly reason = 'automated_bot_blocked' as const; + public readonly stage = 'prejoin' as const; + + constructor(message = 'Zoom blocked the browser as an automated meeting bot') { + super(message, false, 0); + this.name = 'ZoomBrowserJoinBlockedError'; + } +} + +export class ZoomBrowserProfileConfigurationError extends KnownError { + public readonly teamId: string; + + constructor(teamId: string) { + super(`Zoom browser profile configuration is invalid for team ${teamId}`, false, 0); + this.name = 'ZoomBrowserProfileConfigurationError'; + this.teamId = teamId; + } +} + +export type ZoomMeetingJoinFailureReason = + | 'host_rejected' + | 'sign_in_required' + | 'meeting_ended'; + +export class ZoomMeetingJoinError extends KnownError { + public readonly stage = 'prejoin' as const; + + constructor(public readonly reason: ZoomMeetingJoinFailureReason) { + const messages: Record = { + host_rejected: 'The Zoom host rejected or removed the recording participant', + sign_in_required: 'Zoom requires the recording participant to sign in', + meeting_ended: 'The Zoom meeting ended before the recording participant joined', + }; + super(messages[reason], false, 0); + this.name = 'ZoomMeetingJoinError'; + } +} + +export class ZoomRtmsCredentialsMissingError extends KnownError { + public readonly teamId: string; + + constructor(teamId: string) { + super(`Zoom RTMS fallback is not configured for team ${teamId}`, false, 0); + this.name = 'ZoomRtmsCredentialsMissingError'; + this.teamId = teamId; + } +} + export class UnsupportedMeetingError extends KnownError { public googleMeetPageStatus: 'SIGN_IN_PAGE' | 'GOOGLE_MEET_PAGE' | 'UNSUPPORTED_PAGE' | null; diff --git a/src/index.ts b/src/index.ts index 1ab49917..ef222ae4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import './instrumentation'; // Ensure global Web Crypto API is available (needed by Azure SDK, polyfill for older Node versions) import './shims/crypto-polyfill'; import http from 'http'; @@ -7,6 +8,8 @@ import messageBroker from './connect/messageBroker'; import config from './config'; import { isPodMarkedForDeletion } from './util/k8sLifecycle'; import { loggerFactory } from './util/logger'; +import { zoomRtmsEventStore } from './rtms/ZoomRtmsEventStore'; +import { captureOperationalError, flushSentry } from './monitoring/sentry'; const port = 3000; @@ -35,6 +38,7 @@ setTimeout(() => { }) .catch((err) => { console.error('Startup pod-deletion check threw (continuing normal startup):', err); + captureOperationalError(err, { phase: 'startup_pod_deletion_check' }); }); }, 10000); @@ -64,6 +68,8 @@ const initiateGracefulShutdown = async () => { gracefulShutdownApp(); } catch (error) { console.error('Error during graceful shutdown:', error); + captureOperationalError(error, { phase: 'graceful_shutdown' }); + await flushSentry(); // Force exit if graceful shutdown fails process.exit(1); } @@ -71,10 +77,14 @@ const initiateGracefulShutdown = async () => { process.on('uncaughtException', (err) => { console.error('Uncaught Exception:', err); + captureOperationalError(err, { phase: 'uncaught_exception' }); + void flushSentry(); }); -process.on('unhandledRejection', (reason, promise) => { +process.on('unhandledRejection', (reason) => { console.error('Unhandled Rejection:', reason); + captureOperationalError(reason, { phase: 'unhandled_rejection' }); + void flushSentry(); }); process.on('SIGTERM', () => { @@ -97,15 +107,24 @@ export const gracefulShutdownApp = () => { server.close(async () => { console.log('HTTP server closed. Exiting application'); - // Only shutdown Redis services if Redis is enabled - if (config.isRedisEnabled) { - await redisConsumerService.shutdown(); - await messageBroker.quitClientGracefully(); - } else { - console.log('Redis services not running - skipping Redis shutdown'); + let exitCode = 0; + try { + // Only shutdown Redis services if Redis is enabled + if (config.isRedisEnabled) { + await redisConsumerService.shutdown(); + await messageBroker.quitClientGracefully(); + } else { + console.log('Redis services not running - skipping Redis shutdown'); + } + await zoomRtmsEventStore.close(); + } catch (error) { + exitCode = 1; + console.error('Error while closing application resources:', error); + captureOperationalError(error, { phase: 'resource_shutdown' }); + } finally { + await flushSentry(); + console.log('Exiting.....'); + process.exit(exitCode); } - - console.log('Exiting.....'); - process.exit(0); }); }; diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 00000000..f86e367a --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,4 @@ +import 'dotenv/config'; +import { initializeSentry } from './monitoring/sentry'; + +initializeSentry(); diff --git a/src/lib/browserSession.test.ts b/src/lib/browserSession.test.ts new file mode 100644 index 00000000..80944d6b --- /dev/null +++ b/src/lib/browserSession.test.ts @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { test } from 'node:test'; +import { Browser, BrowserContext, Page } from 'playwright'; +import { + closeBrowserSession, + getValidatedProviderOrigin, + normalizeBrowserSessionError, + normalizeBrowserTimezone, + raceBrowserSessionFailure, + registerBrowserSession, +} from './browserSession'; + +class FakeBrowser extends EventEmitter { + closeCalls = 0; + connected = true; + + isConnected(): boolean { + return this.connected; + } + + async close(): Promise { + this.closeCalls += 1; + this.connected = false; + this.emit('disconnected'); + } +} + +class FakeContext extends EventEmitter { + closeCalls = 0; + + constructor(private readonly fakeBrowser: FakeBrowser) { + super(); + } + + browser(): Browser { + return this.fakeBrowser as unknown as Browser; + } + + async close(): Promise { + this.closeCalls += 1; + this.emit('close'); + } +} + +class FakePage extends EventEmitter { + closeCalls = 0; + closed = false; + + constructor(private readonly fakeContext: FakeContext) { + super(); + } + + context(): BrowserContext { + return this.fakeContext as unknown as BrowserContext; + } + + isClosed(): boolean { + return this.closed; + } + + async close(): Promise { + this.closeCalls += 1; + this.closed = true; + this.emit('close'); + } +} + +function createFakeBrowserPage() { + const browser = new FakeBrowser(); + const context = new FakeContext(browser); + const page = new FakePage(context); + return { browser, context, page: page as unknown as Page, pageState: page }; +} + +test('validates provider-owned HTTPS origins', () => { + assert.equal(getValidatedProviderOrigin('https://us05web.zoom.us/j/123?pwd=secret', 'zoom'), 'https://us05web.zoom.us'); + assert.equal(getValidatedProviderOrigin('https://meet.google.com/abc-defg-hij', 'google'), 'https://meet.google.com'); + assert.equal(getValidatedProviderOrigin('https://teams.microsoft.com/l/meetup-join/123', 'microsoft'), 'https://teams.microsoft.com'); + assert.throws(() => getValidatedProviderOrigin('https://evilzoom.us/j/123', 'zoom')); + assert.throws(() => getValidatedProviderOrigin('http://meet.google.com/abc-defg-hij', 'google')); +}); + +test('uses UTC for missing or invalid timezones', () => { + assert.equal(normalizeBrowserTimezone('Europe/Vienna'), 'Europe/Vienna'); + assert.equal(normalizeBrowserTimezone('Not/A_Timezone'), 'UTC'); + assert.equal(normalizeBrowserTimezone(), 'UTC'); +}); + +test('closing an external CDP session never closes the shared browser', async () => { + const { browser, context, page, pageState } = createFakeBrowserPage(); + registerBrowserSession(page, 'external-cdp', true, '[test]'); + + await closeBrowserSession(page); + await closeBrowserSession(page); + + assert.equal(pageState.closeCalls, 1); + assert.equal(context.closeCalls, 1); + assert.equal(browser.closeCalls, 0); +}); + +test('closing a page in the shared CDP context leaves that context running', async () => { + const { browser, context, page, pageState } = createFakeBrowserPage(); + registerBrowserSession(page, 'external-cdp', false, '[test]'); + + await closeBrowserSession(page); + + assert.equal(pageState.closeCalls, 1); + assert.equal(context.closeCalls, 0); + assert.equal(browser.closeCalls, 0); +}); + +test('closing a session with an owned browser closes the browser once', async () => { + const { browser, page } = createFakeBrowserPage(); + registerBrowserSession(page, 'owned-browser', true, '[test]'); + + await closeBrowserSession(page); + await closeBrowserSession(page); + + assert.equal(browser.closeCalls, 1); +}); + +test('signals unexpected page crashes with a typed failure', async () => { + const { page, pageState } = createFakeBrowserPage(); + const session = registerBrowserSession(page, 'external-cdp', false, '[test]'); + + pageState.emit('crash'); + const failure = await session.failure; + + assert.equal(failure.name, 'BrowserSessionFailureError'); + assert.equal(failure.kind, 'page-crashed'); + assert.equal(normalizeBrowserSessionError(page, new Error('Target closed')), failure); +}); + +test('interrupts active meeting work immediately with the typed browser failure', async () => { + const { page, pageState } = createFakeBrowserPage(); + registerBrowserSession(page, 'external-cdp', false, '[test]'); + let finishWork!: () => void; + const work = new Promise(resolve => { + finishWork = resolve; + }); + + const activeMeeting = raceBrowserSessionFailure(page, work); + pageState.emit('crash'); + + await assert.rejects( + activeMeeting, + (error: unknown) => (error as { kind?: string }).kind === 'page-crashed' + ); + finishWork(); +}); + +test('normalizes disconnected and closed Playwright failures', () => { + const disconnected = createFakeBrowserPage(); + disconnected.browser.connected = false; + const disconnectedError = normalizeBrowserSessionError( + disconnected.page, + new Error('Target page, context or browser has been closed') + ); + assert.equal((disconnectedError as { kind?: string }).kind, 'browser-disconnected'); + + const closed = createFakeBrowserPage(); + closed.pageState.closed = true; + const closedError = normalizeBrowserSessionError(closed.page, new Error('Page has been closed')); + assert.equal((closedError as { kind?: string }).kind, 'page-closed'); +}); diff --git a/src/lib/browserSession.ts b/src/lib/browserSession.ts new file mode 100644 index 00000000..38b460c9 --- /dev/null +++ b/src/lib/browserSession.ts @@ -0,0 +1,212 @@ +import { Browser, BrowserContext, Page } from 'playwright'; + +export type BrowserProvider = 'microsoft' | 'google' | 'zoom'; +export type BrowserOwnership = 'external-cdp' | 'owned-browser' | 'owned-persistent-context'; +export type BrowserSessionFailureKind = 'browser-disconnected' | 'context-closed' | 'page-crashed' | 'page-closed'; + +export class BrowserSessionFailureError extends Error { + constructor(public readonly kind: BrowserSessionFailureKind) { + super(`Browser session failed: ${kind}`); + this.name = 'BrowserSessionFailureError'; + } +} + +const sessions = new WeakMap(); +const externalBrowserContexts = new WeakSet(); + +const PROVIDER_DOMAINS: Record = { + google: ['meet.google.com'], + microsoft: ['teams.microsoft.com', 'teams.live.com', 'teams.cloud.microsoft', 'teams.microsoft.us'], + zoom: ['zoom.us', 'zoom.com', 'zoomgov.com'], +}; + +function isDomainOrSubdomain(hostname: string, domain: string): boolean { + return hostname === domain || hostname.endsWith(`.${domain}`); +} + +export function getValidatedProviderOrigin(url: string, provider: BrowserProvider): string { + let parsed: URL; + + try { + parsed = new URL(url); + } catch { + throw new Error(`Invalid ${provider} meeting URL`); + } + + if (parsed.protocol !== 'https:' || !PROVIDER_DOMAINS[provider].some(domain => isDomainOrSubdomain(parsed.hostname, domain))) { + throw new Error(`Unsupported ${provider} meeting URL`); + } + + return parsed.origin; +} + +export function normalizeBrowserTimezone(timezone?: string): string { + if (!timezone?.trim()) return 'UTC'; + + try { + new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(); + return timezone; + } catch { + return 'UTC'; + } +} + +export class BrowserSession { + public readonly failure: Promise; + + private readonly browser: Browser | null; + private readonly context: BrowserContext; + private resolveFailure!: (error: BrowserSessionFailureError) => void; + private closePromise?: Promise; + private failureSignalled = false; + private failureError?: BrowserSessionFailureError; + private closing = false; + private readonly onBrowserDisconnected = () => this.signalFailure('browser-disconnected'); + private readonly onContextClosed = () => this.signalFailure('context-closed'); + private readonly onPageCrashed = () => this.signalFailure('page-crashed'); + private readonly onPageClosed = () => this.signalFailure('page-closed'); + + constructor( + public readonly page: Page, + public readonly ownership: BrowserOwnership, + private readonly ownsContext: boolean, + private readonly correlationLog: string, + ) { + this.context = page.context(); + this.browser = this.context.browser(); + this.failure = new Promise(resolve => { + this.resolveFailure = resolve; + }); + + if (ownership === 'external-cdp') { + externalBrowserContexts.add(this.context); + } + + this.attachFailureHandlers(); + sessions.set(page, this); + } + + private signalFailure(kind: BrowserSessionFailureKind): void { + if (this.closing || this.failureSignalled) return; + + this.failureSignalled = true; + const error = new BrowserSessionFailureError(kind); + this.failureError = error; + console.error(`${this.correlationLog} ${error.message}`); + this.resolveFailure(error); + } + + private attachFailureHandlers(): void { + this.browser?.on('disconnected', this.onBrowserDisconnected); + this.context.on('close', this.onContextClosed); + this.page.on('crash', this.onPageCrashed); + this.page.on('close', this.onPageClosed); + } + + private detachFailureHandlers(): void { + this.browser?.off('disconnected', this.onBrowserDisconnected); + this.context.off('close', this.onContextClosed); + this.page.off('crash', this.onPageCrashed); + this.page.off('close', this.onPageClosed); + } + + async close(): Promise { + if (this.closePromise) return this.closePromise; + + this.closing = true; + this.closePromise = this.closeOwnedResources(); + return this.closePromise; + } + + getFailureError(): BrowserSessionFailureError | undefined { + return this.failureError; + } + + private async closeOwnedResources(): Promise { + try { + if (this.ownership === 'external-cdp') { + if (!this.page.isClosed()) await this.page.close(); + if (this.ownsContext) await this.context.close().catch(() => undefined); + return; + } + + if (this.ownership === 'owned-persistent-context') { + await this.context.close(); + return; + } + + if (this.browser?.isConnected()) { + await this.browser.close(); + } else if (!this.page.isClosed()) { + await this.context.close(); + } + } finally { + this.detachFailureHandlers(); + sessions.delete(this.page); + } + } +} + +export function registerBrowserSession( + page: Page, + ownership: BrowserOwnership, + ownsContext: boolean, + correlationLog: string, +): BrowserSession { + return new BrowserSession(page, ownership, ownsContext, correlationLog); +} + +export function getBrowserSession(page?: Page | null): BrowserSession | undefined { + return page ? sessions.get(page) : undefined; +} + +export async function closeBrowserSession(page?: Page | null): Promise { + if (!page) return; + + const session = getBrowserSession(page); + if (session) { + await session.close(); + return; + } + + if (!page.isClosed()) await page.close(); +} + +export async function raceBrowserSessionFailure(page: Page, work: Promise): Promise { + const session = getBrowserSession(page); + if (!session) return work; + + const outcome = await Promise.race([ + work.then(value => ({ type: 'completed' as const, value })), + session.failure.then(error => ({ type: 'failed' as const, error })), + ]); + if (outcome.type === 'failed') throw outcome.error; + return outcome.value; +} + +export function normalizeBrowserSessionError(page: Page | undefined, error: unknown): unknown { + if (error instanceof BrowserSessionFailureError || !page) return error; + + const sessionFailure = getBrowserSession(page)?.getFailureError(); + if (sessionFailure) return sessionFailure; + + const message = error instanceof Error ? error.message.toLowerCase() : ''; + const browser = page.context().browser(); + if (browser && !browser.isConnected()) { + return new BrowserSessionFailureError('browser-disconnected'); + } + if (page.isClosed() || message.includes('page has been closed')) { + return new BrowserSessionFailureError('page-closed'); + } + if (message.includes('context') && message.includes('closed')) { + return new BrowserSessionFailureError('context-closed'); + } + if (message.includes('target page, context or browser has been closed')) { + return new BrowserSessionFailureError('page-closed'); + } + return error; +} + +export function isExternalBrowserContext(context?: BrowserContext | null): boolean { + return Boolean(context && externalBrowserContexts.has(context)); +} diff --git a/src/lib/chromium.test.ts b/src/lib/chromium.test.ts new file mode 100644 index 00000000..a66508b4 --- /dev/null +++ b/src/lib/chromium.test.ts @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Browser, BrowserContext } from 'playwright'; +import config, { parseZoomChromeCustomerStorageStatePaths } from '../config'; +import { ZoomBrowserProfileConfigurationError } from '../error'; +import { + cleanupFailedBrowserSetup, + getStorageStatePath, + selectZoomChromeStorageStatePath, +} from './chromium'; + +test('parses customer-scoped Zoom storage state paths without exposing values in errors', () => { + const parsed = parseZoomChromeCustomerStorageStatePaths(JSON.stringify({ + 'team-a': '/var/run/secrets/meeting-bot/zoom-profiles/team-a.json', + 'team-b': 'relative/must-not-appear.json', + })); + + assert.equal( + parsed.paths['team-a'], + '/var/run/secrets/meeting-bot/zoom-profiles/team-a.json', + ); + assert.match(parsed.entryErrors['team-b'], /absolute path/); + assert.equal(parsed.entryErrors['team-b'].includes('must-not-appear'), false); + + const malformed = parseZoomChromeCustomerStorageStatePaths('{secret-value'); + assert.match(malformed.error ?? '', /valid JSON/); + assert.equal(malformed.error?.includes('secret-value'), false); + + const duplicate = parseZoomChromeCustomerStorageStatePaths(JSON.stringify({ + 'team-a': '/profiles/shared.json', + 'team-b': '/profiles/shared.json', + })); + assert.equal(duplicate.paths['team-a'], undefined); + assert.equal(duplicate.paths['team-b'], undefined); + assert.match(duplicate.entryErrors['team-a'], /unique/); + assert.match(duplicate.entryErrors['team-b'], /unique/); +}); + +test('selects Zoom storage state only for the exact teamId', () => { + const paths = Object.assign(Object.create(null), { + 'team-a': '/profiles/team-a.json', + 'team-b': '/profiles/team-b.json', + }); + + assert.equal(selectZoomChromeStorageStatePath('team-a', paths), '/profiles/team-a.json'); + assert.equal(selectZoomChromeStorageStatePath('team-b', paths), '/profiles/team-b.json'); + assert.equal(selectZoomChromeStorageStatePath('TEAM-A', paths), undefined); + assert.equal(selectZoomChromeStorageStatePath('team', paths), undefined); + assert.equal(selectZoomChromeStorageStatePath('__proto__', paths), undefined); + assert.equal(selectZoomChromeStorageStatePath(undefined, paths), undefined); +}); + +test('does not fall back to another Zoom customer profile', () => { + const originalPaths = config.zoomChromeCustomerStorageStatePaths; + const originalEntryErrors = config.zoomChromeCustomerStorageStatePathErrors; + const originalError = config.zoomChromeCustomerStorageStatePathsError; + + try { + config.zoomChromeCustomerStorageStatePaths = Object.assign(Object.create(null), { + 'team-a': '/profiles/team-a.json', + }); + config.zoomChromeCustomerStorageStatePathErrors = Object.create(null); + config.zoomChromeCustomerStorageStatePathsError = undefined; + + assert.equal(getStorageStatePath('zoom', 'team-a'), '/profiles/team-a.json'); + assert.equal(getStorageStatePath('zoom', 'team-b'), undefined); + assert.equal(getStorageStatePath('zoom'), undefined); + } finally { + config.zoomChromeCustomerStorageStatePaths = originalPaths; + config.zoomChromeCustomerStorageStatePathErrors = originalEntryErrors; + config.zoomChromeCustomerStorageStatePathsError = originalError; + } +}); + +test('fails safely for an invalid declared customer profile', () => { + const originalPaths = config.zoomChromeCustomerStorageStatePaths; + const originalEntryErrors = config.zoomChromeCustomerStorageStatePathErrors; + const originalError = config.zoomChromeCustomerStorageStatePathsError; + + try { + config.zoomChromeCustomerStorageStatePaths = Object.create(null); + config.zoomChromeCustomerStorageStatePathErrors = Object.assign(Object.create(null), { + 'team-a': 'invalid path', + }); + config.zoomChromeCustomerStorageStatePathsError = undefined; + + assert.throws( + () => getStorageStatePath('zoom', 'team-a'), + ZoomBrowserProfileConfigurationError, + ); + } finally { + config.zoomChromeCustomerStorageStatePaths = originalPaths; + config.zoomChromeCustomerStorageStatePathErrors = originalEntryErrors; + config.zoomChromeCustomerStorageStatePathsError = originalError; + } +}); + +test('closes an owned browser when setup fails before creating a page', async () => { + let closeCalls = 0; + const browser = { + isConnected: () => true, + close: async () => { + closeCalls += 1; + }, + } as unknown as Browser; + + await cleanupFailedBrowserSetup({ + browser, + closeBrowser: true, + closeContext: true, + correlationId: 'test', + }); + + assert.equal(closeCalls, 1); +}); + +test('closes an isolated external context without closing shared Chrome', async () => { + let browserCloseCalls = 0; + let contextCloseCalls = 0; + const browser = { + isConnected: () => true, + close: async () => { + browserCloseCalls += 1; + }, + } as unknown as Browser; + const context = { + close: async () => { + contextCloseCalls += 1; + }, + } as unknown as BrowserContext; + + await cleanupFailedBrowserSetup({ + browser, + context, + closeBrowser: false, + closeContext: true, + correlationId: 'test', + }); + + assert.equal(contextCloseCalls, 1); + assert.equal(browserCloseCalls, 0); +}); diff --git a/src/lib/chromium.ts b/src/lib/chromium.ts index afefdc96..21cb352e 100644 --- a/src/lib/chromium.ts +++ b/src/lib/chromium.ts @@ -1,23 +1,42 @@ -import { Browser, BrowserContext, Page } from 'playwright'; -import { chromium } from 'playwright-extra'; -import StealthPlugin from 'puppeteer-extra-plugin-stealth'; +import { Browser, BrowserContext, BrowserContextOptions, Page, chromium } from 'playwright'; import config from '../config'; +import { ZoomBrowserProfileConfigurationError } from '../error'; import { getCorrelationIdLog } from '../util/logger'; - -const stealthPlugin = StealthPlugin(); -stealthPlugin.enabledEvasions.delete('iframe.contentWindow'); -stealthPlugin.enabledEvasions.delete('media.codecs'); -chromium.use(stealthPlugin); - -export type BotType = 'microsoft' | 'google' | 'zoom'; - -const externalBrowserContexts = new WeakSet(); - -export function isExternalBrowserContext(context?: BrowserContext | null): boolean { - return Boolean(context && externalBrowserContexts.has(context)); +import { + BrowserProvider, + closeBrowserSession, + getBrowserSession, + getValidatedProviderOrigin, + isExternalBrowserContext, + normalizeBrowserSessionError, + normalizeBrowserTimezone, + raceBrowserSessionFailure, + registerBrowserSession, +} from './browserSession'; + +export type BotType = BrowserProvider; + +export { + closeBrowserSession, + getBrowserSession, + getValidatedProviderOrigin, + isExternalBrowserContext, + normalizeBrowserSessionError, + normalizeBrowserTimezone, + raceBrowserSessionFailure, +}; + +const VIEWPORT_SIZE = { width: 1280, height: 720 }; +const WINDOW_SIZE = { width: 1280, height: 800 }; +const CDP_CONNECT_TIMEOUT_MS = 60_000; +const CDP_RETRY_INTERVAL_MS = 1_000; +const externalBrowserConnections = new Map>(); + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); } -async function resizeBrowserWindow(page: Page, size: { width: number; height: number }, correlationId: string) { +async function resizeBrowserWindow(page: Page, correlationId: string): Promise { const log = getCorrelationIdLog(correlationId); try { @@ -29,289 +48,446 @@ async function resizeBrowserWindow(page: Page, size: { width: number; height: nu windowState: 'normal', left: 0, top: 0, - width: size.width, - height: size.height, + width: WINDOW_SIZE.width, + height: WINDOW_SIZE.height, }, }); - } catch (err: any) { - console.warn(`${log} Unable to resize Chrome window through CDP`, err?.message ?? err); + } catch (error) { + console.warn('%s Unable to resize Chrome window through CDP', log, error instanceof Error ? error.message : error); } } -function attachBrowserErrorHandlers(browser: Browser | null, context: BrowserContext, page: Page, correlationId: string) { +async function applyPageEnvironment(page: Page, timezoneId: string, correlationId: string): Promise { const log = getCorrelationIdLog(correlationId); - browser?.on('disconnected', () => { - console.log(`${log} Browser has disconnected!`); - }); - - context.on('close', () => { - console.log(`${log} Browser has closed!`); - }); - - page.on('crash', (page) => { - console.error(`${log} Page has crashed! ${page?.url()}`); - }); + await page.setExtraHTTPHeaders({ 'Accept-Language': 'en-US,en;q=0.9' }); - page.on('close', (page) => { - console.log(`${log} Page has closed! ${page?.url()}`); - }); + try { + const client = await page.context().newCDPSession(page); + await client.send('Emulation.setLocaleOverride', { locale: 'en-US' }); + await client.send('Emulation.setTimezoneOverride', { timezoneId }); + } catch (error) { + console.warn('%s Unable to apply Chrome locale/timezone through CDP', log, error instanceof Error ? error.message : error); + } } -async function launchBrowserWithTimeout(launchFn: () => Promise, timeoutMs: number, correlationId: string): Promise { +async function launchBrowserWithTimeout( + launchFn: () => Promise, + timeoutMs: number, + correlationId: string, +): Promise { let timeoutId: NodeJS.Timeout; let finished = false; return new Promise((resolve, reject) => { - // Set up timeout timeoutId = setTimeout(() => { - if (!finished) { - finished = true; - reject(new Error(`Browser launch timed out after ${timeoutMs}ms`)); - } + if (finished) return; + finished = true; + reject(new Error(`Browser launch timed out after ${timeoutMs}ms`)); }, timeoutMs); - // Start launch launchFn() - .then(result => { - if (!finished) { - finished = true; - clearTimeout(timeoutId); - console.log(`${getCorrelationIdLog(correlationId)} Browser launch function success!`); - resolve(result); + .then(async browser => { + if (finished) { + await browser.close().catch(() => undefined); + return; } + + finished = true; + clearTimeout(timeoutId); + console.log(`${getCorrelationIdLog(correlationId)} Browser launch function success!`); + resolve(browser); }) - .catch(err => { - console.error(`${getCorrelationIdLog(correlationId)} Error launching browser`, err); - if (!finished) { - finished = true; - clearTimeout(timeoutId); - reject(err); - } + .catch(error => { + if (finished) return; + finished = true; + clearTimeout(timeoutId); + reject(error); }); }); } -async function launchPersistentContextWithTimeout(launchFn: () => Promise, timeoutMs: number, correlationId: string): Promise { +async function launchPersistentContextWithTimeout( + launchFn: () => Promise, + timeoutMs: number, + correlationId: string, +): Promise { let timeoutId: NodeJS.Timeout; let finished = false; return new Promise((resolve, reject) => { timeoutId = setTimeout(() => { - if (!finished) { - finished = true; - reject(new Error(`Persistent browser launch timed out after ${timeoutMs}ms`)); - } + if (finished) return; + finished = true; + reject(new Error(`Persistent browser launch timed out after ${timeoutMs}ms`)); }, timeoutMs); launchFn() - .then(result => { - if (!finished) { - finished = true; - clearTimeout(timeoutId); - console.log(`${getCorrelationIdLog(correlationId)} Persistent browser launch function success!`); - resolve(result); + .then(async context => { + if (finished) { + await context.close().catch(() => undefined); + return; } + + finished = true; + clearTimeout(timeoutId); + console.log(`${getCorrelationIdLog(correlationId)} Persistent browser launch function success!`); + resolve(context); }) - .catch(err => { - console.error(`${getCorrelationIdLog(correlationId)} Error launching persistent browser`, err); - if (!finished) { - finished = true; - clearTimeout(timeoutId); - reject(err); - } + .catch(error => { + if (finished) return; + finished = true; + clearTimeout(timeoutId); + reject(error); }); }); } -async function createBrowserContext(url: string, correlationId: string, botType: BotType = 'google'): Promise { - const size = { width: 1280, height: 720 }; - const browserWindowSize = { width: size.width, height: size.height + 80 }; +async function connectToExternalChrome(cdpUrl: string, correlationId: string): Promise { + const existingConnection = externalBrowserConnections.get(cdpUrl); + if (existingConnection) { + const existingBrowser = await existingConnection.catch(() => undefined); + if (existingBrowser?.isConnected()) return existingBrowser; + externalBrowserConnections.delete(cdpUrl); + } + + const connection = connectToExternalChromeWithRetry(cdpUrl, correlationId); + externalBrowserConnections.set(cdpUrl, connection); + + try { + const browser = await connection; + browser.once('disconnected', () => { + if (externalBrowserConnections.get(cdpUrl) === connection) { + externalBrowserConnections.delete(cdpUrl); + } + }); + return browser; + } catch (error) { + if (externalBrowserConnections.get(cdpUrl) === connection) { + externalBrowserConnections.delete(cdpUrl); + } + throw error; + } +} + +async function connectToExternalChromeWithRetry(cdpUrl: string, correlationId: string): Promise { + const startedAt = Date.now(); + let lastError: unknown; + let attempt = 0; + + while ((Date.now() - startedAt) < CDP_CONNECT_TIMEOUT_MS) { + attempt += 1; + const remainingMs = CDP_CONNECT_TIMEOUT_MS - (Date.now() - startedAt); + + try { + return await chromium.connectOverCDP(cdpUrl, { + timeout: Math.max(1, Math.min(5_000, remainingMs)), + }); + } catch (error) { + lastError = error; + console.warn('%s External Chrome is not ready; retrying', getCorrelationIdLog(correlationId), { attempt }); + const retryDelayMs = Math.min(CDP_RETRY_INTERVAL_MS, CDP_CONNECT_TIMEOUT_MS - (Date.now() - startedAt)); + if (retryDelayMs > 0) await delay(retryDelayMs); + } + } + + const detail = lastError instanceof Error ? lastError.message : String(lastError ?? 'unknown error'); + throw new Error(`Unable to connect to external Chrome within ${CDP_CONNECT_TIMEOUT_MS}ms: ${detail}`); +} + +export function selectZoomChromeStorageStatePath( + teamId: string | undefined, + customerPaths: Record, +): string | undefined { + if (!teamId || !Object.prototype.hasOwnProperty.call(customerPaths, teamId)) return undefined; + return customerPaths[teamId]; +} + +export function getStorageStatePath(botType: BotType, teamId?: string): string | undefined { + if (botType === 'google') return config.googleChromeStorageStatePath; + if (botType === 'zoom') { + if (!teamId) return undefined; + if ( + config.zoomChromeCustomerStorageStatePathsError + || config.zoomChromeCustomerStorageStatePathErrors[teamId] + ) { + throw new ZoomBrowserProfileConfigurationError(teamId); + } + return selectZoomChromeStorageStatePath( + teamId, + config.zoomChromeCustomerStorageStatePaths, + ); + } + return undefined; +} - // Google Meet is sensitive to browser fingerprinting before admission. Keep - // its launch close to normal Chrome and reserve recording-heavy flags for - // platforms that need them. - const firstRunSuppressionArgs: string[] = [ +function getBrowserArgs(botType: BotType): string[] { + const args = [ '--no-first-run', '--no-default-browser-check', '--disable-first-run-ui', '--disable-default-browser-promo', '--disable-default-apps', - ]; - - const googleBrowserArgs: string[] = [ - ...firstRunSuppressionArgs, - '--no-sandbox', - '--disable-setuid-sandbox', - `--window-size=${browserWindowSize.width},${browserWindowSize.height}`, + '--lang=en-US', + `--window-size=${WINDOW_SIZE.width},${WINDOW_SIZE.height}`, '--auto-accept-this-tab-capture', '--autoplay-policy=no-user-gesture-required', + '--disable-background-timer-throttling', + '--disable-blink-features=AutomationControlled', + '--disable-backgrounding-occluded-windows', + '--disable-renderer-backgrounding', ]; - const recordingBrowserArgs: string[] = [ - ...firstRunSuppressionArgs, - '--enable-usermedia-screen-capturing', - '--allow-http-screen-capture', - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-web-security', - '--use-gl=angle', - '--use-angle=swiftshader', - `--window-size=${browserWindowSize.width},${browserWindowSize.height}`, - '--auto-accept-this-tab-capture', - '--enable-features=MediaRecorder', - '--enable-audio-service-out-of-process', - '--autoplay-policy=no-user-gesture-required', - ]; + if (process.env.CHROME_NO_SANDBOX !== 'false') { + args.push('--no-sandbox', '--disable-setuid-sandbox'); + } - // Fake device args - only for Microsoft Teams - // Teams needs fake devices to interact with pre-join screen toggles, - // but actual recording is done via ffmpeg (X11 + PulseAudio) - const fakeDeviceArgs: string[] = [ - '--use-fake-ui-for-media-stream', - '--use-fake-device-for-media-stream', - ]; + if (botType === 'microsoft') { + args.push('--use-fake-device-for-media-stream'); + } + + if (botType !== 'google' && process.env.CHROME_SOFTWARE_GL !== 'false') { + args.push('--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'); + } + + return args; +} + +async function configurePage( + page: Page, + timezoneId: string, + correlationId: string, +): Promise { + await resizeBrowserWindow(page, correlationId); + await page.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + // Automated Chrome returns 'denied' for Notification.permission, real + // Chrome returns 'default' — match real browser behaviour. + if (typeof Notification !== 'undefined' && Notification.permission === 'denied') { + Object.defineProperty(Notification, 'permission', { get: () => 'default' }); + } + const origQuery = navigator.permissions?.query?.bind(navigator.permissions); + if (origQuery) { + navigator.permissions.query = (parameters: PermissionDescriptor) => + parameters.name === 'notifications' + ? Promise.resolve({ state: 'prompt', onchange: null } as PermissionStatus) + : origQuery(parameters); + } + // Ensure navigator.languages matches Accept-Language header. + Object.defineProperty(navigator, 'languages', { + get: () => ['en-US', 'en'], + }); + // Ensure window.chrome.runtime exists (missing in some CDP setups). + const w = window as any; + if (typeof w.chrome === 'undefined') { + w.chrome = {}; + } + if (w.chrome && !w.chrome.runtime) { + w.chrome.runtime = {}; + } + }); + await page.setViewportSize(VIEWPORT_SIZE); + await applyPageEnvironment(page, timezoneId, correlationId); +} + +export async function cleanupFailedBrowserSetup({ + browser, + context, + page, + closeBrowser, + closeContext, + correlationId, +}: { + browser?: Browser | null; + context?: BrowserContext; + page?: Page; + closeBrowser: boolean; + closeContext: boolean; + correlationId: string; +}): Promise { + try { + const session = getBrowserSession(page); + if (session) { + await session.close(); + } else if (closeBrowser && browser?.isConnected()) { + await browser.close(); + } else if (closeContext && context) { + await context.close(); + } else if (page && !page.isClosed()) { + await page.close(); + } + } catch (cleanupError) { + console.warn( + '%s Failed to clean up partial browser setup', + getCorrelationIdLog(correlationId), + cleanupError instanceof Error ? cleanupError.message : cleanupError + ); + } +} - // Google Meet and Zoom use browser-based recording (getDisplayMedia + MediaRecorder) - // and don't need fake devices: - // - Google Meet: clicks "Continue without microphone and camera" - // - Zoom: expects "Cannot detect your camera/microphone" notifications - const browserArgs = botType === 'google' - ? googleBrowserArgs - : botType === 'microsoft' - ? [...recordingBrowserArgs, ...fakeDeviceArgs] - : recordingBrowserArgs; - const ignoreDefaultArgs = botType === 'google' - ? ['--mute-audio', '--enable-automation'] - : ['--mute-audio']; - - // Teams-specific display args: kiosk mode prevents address bar from showing in ffmpeg recording - // Google Meet and Zoom don't need this since they use tab capture (getDisplayMedia) - const displayArgs = botType === 'microsoft' - ? ['--kiosk', '--start-maximized'] - : []; - - console.log(`${getCorrelationIdLog(correlationId)} Launching browser for ${botType} bot (fake devices: ${botType === 'microsoft'})`); - - const contextOptions = { - ...(botType !== 'google' ? { - permissions: ['camera', 'microphone'], - } : {}), - viewport: size, - ignoreHTTPSErrors: true, - // Record video only in development for debugging. Keep Google Meet's - // anonymous admission context close to a regular incognito tab. +async function createBrowserContext( + url: string, + correlationId: string, + botType: BotType = 'google', + timezone?: string, + teamId?: string, +): Promise { + const log = getCorrelationIdLog(correlationId); + const trustedOrigin = getValidatedProviderOrigin(url, botType); + const timezoneId = normalizeBrowserTimezone(timezone); + const browserArgs = getBrowserArgs(botType); + const storageStatePath = getStorageStatePath(botType, teamId); + const contextOptions: BrowserContextOptions = { + viewport: VIEWPORT_SIZE, + locale: 'en-US', + timezoneId, ...(process.env.NODE_ENV === 'development' && botType !== 'google' && { recordVideo: { dir: './debug-videos/', - size: size, + size: VIEWPORT_SIZE, }, }), }; - if (botType === 'google' && config.googleChromeCdpUrl) { - console.log(`${getCorrelationIdLog(correlationId)} Connecting Google bot to external Chrome`, { - cdpUrl: config.googleChromeCdpUrl, - }); + if (timezone && timezoneId === 'UTC' && timezone !== 'UTC') { + console.warn(`${log} Invalid browser timezone; using UTC`); + } - const browser = await launchBrowserWithTimeout( - async () => await chromium.connectOverCDP(config.googleChromeCdpUrl!), - 60000, - correlationId - ); + console.log(`${log} Launching browser for ${botType} bot`); - const context = browser.contexts()[0] ?? await browser.newContext({ - ...contextOptions, - ...(config.googleChromeStorageStatePath ? { - storageState: config.googleChromeStorageStatePath, - } : {}), - }); - externalBrowserContexts.add(context); + const cdpUrl = botType === 'google' + ? config.googleChromeCdpUrl + : botType === 'zoom' + ? config.zoomChromeCdpUrl + : undefined; + + if (cdpUrl) { + console.log(`${log} Connecting ${botType} bot to external Chrome`); + const browser = await connectToExternalChrome(cdpUrl, correlationId); + let context: BrowserContext | undefined; + let page: Page | undefined; + let ownsContext = false; - const page = await context.newPage(); - await resizeBrowserWindow(page, browserWindowSize, correlationId); - await page.setViewportSize(size); - attachBrowserErrorHandlers(browser, context, page, correlationId); + try { + const existingContext = botType === 'google' ? browser.contexts()[0] : undefined; + context = existingContext ?? await browser.newContext({ + ...contextOptions, + ...(storageStatePath + ? { storageState: storageStatePath } + : {}), + }); + ownsContext = !existingContext; - console.log(`${getCorrelationIdLog(correlationId)} External Chrome connected successfully!`); + if (botType === 'microsoft') { + await context.grantPermissions(['microphone', 'camera'], { origin: trustedOrigin }); + } - return page; + page = await context.newPage(); + registerBrowserSession(page, 'external-cdp', ownsContext, log); + await configurePage(page, timezoneId, correlationId); + + console.log(`${log} External Chrome connected successfully!`); + return page; + } catch (error) { + const normalizedError = normalizeBrowserSessionError(page, error); + await cleanupFailedBrowserSetup({ + browser, + context, + page, + closeBrowser: false, + closeContext: ownsContext, + correlationId, + }); + throw normalizedError; + } } if (botType === 'google' && config.googleChromeUserDataDir) { - console.log(`${getCorrelationIdLog(correlationId)} Launching Google bot with persistent Chrome profile`, { - userDataDir: config.googleChromeUserDataDir, - }); - + console.log(`${log} Launching Google bot with persistent Chrome profile`); const context = await launchPersistentContextWithTimeout( - async () => await chromium.launchPersistentContext(config.googleChromeUserDataDir!, { + () => chromium.launchPersistentContext(config.googleChromeUserDataDir!, { ...contextOptions, headless: false, handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false, - args: [ - ...browserArgs, - ...displayArgs, - ], - ignoreDefaultArgs, + args: browserArgs, + ignoreDefaultArgs: ['--mute-audio', '--enable-automation'], executablePath: config.chromeExecutablePath, }), - 60000, - correlationId + 60_000, + correlationId, ); - - const page = context.pages()[0] ?? await context.newPage(); - await resizeBrowserWindow(page, browserWindowSize, correlationId); - await page.setViewportSize(size); - attachBrowserErrorHandlers(context.browser(), context, page, correlationId); - - console.log(`${getCorrelationIdLog(correlationId)} Persistent browser launched successfully!`); - - return page; + let page: Page | undefined; + + try { + page = context.pages()[0] ?? await context.newPage(); + registerBrowserSession(page, 'owned-persistent-context', true, log); + await configurePage(page, timezoneId, correlationId); + + console.log(`${log} Persistent browser launched successfully!`); + return page; + } catch (error) { + const normalizedError = normalizeBrowserSessionError(page, error); + await cleanupFailedBrowserSetup({ + browser: context.browser(), + context, + page, + closeBrowser: false, + closeContext: true, + correlationId, + }); + throw normalizedError; + } } const browser = await launchBrowserWithTimeout( - async () => await chromium.launch({ + () => chromium.launch({ headless: false, - // Don't let Playwright install its own SIGTERM/SIGINT/SIGHUP handlers — they - // close the browser immediately when the node process receives a signal, which - // breaks our graceful shutdown (we want the in-flight recording to finish). - // The app explicitly calls browser.close() when the recording is done. handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false, - args: [ - ...browserArgs, - ...displayArgs, - ], - ignoreDefaultArgs, + args: browserArgs, + ignoreDefaultArgs: ['--mute-audio', '--enable-automation'], executablePath: config.chromeExecutablePath, }), - 60000, - correlationId + 60_000, + correlationId, ); - const context = await browser.newContext({ - ...contextOptions, - ...(config.googleChromeStorageStatePath && botType === 'google' ? { - storageState: config.googleChromeStorageStatePath, - } : {}), - }); - - // Grant permissions so Teams will play audio (Teams requires this unlike Google Meet) - if (botType !== 'google') { - await context.grantPermissions(['microphone', 'camera'], { origin: url }); - } + let context: BrowserContext | undefined; + let page: Page | undefined; - const page = await context.newPage(); + try { + context = await browser.newContext({ + ...contextOptions, + ...(storageStatePath + ? { storageState: storageStatePath } + : {}), + }); - // Attach common error handlers - attachBrowserErrorHandlers(browser, context, page, correlationId); + if (botType === 'microsoft') { + await context.grantPermissions(['microphone', 'camera'], { origin: trustedOrigin }); + } - console.log(`${getCorrelationIdLog(correlationId)} Browser launched successfully!`); + page = await context.newPage(); + registerBrowserSession(page, 'owned-browser', true, log); + await configurePage(page, timezoneId, correlationId); - return page; + console.log(`${log} Browser launched successfully!`); + return page; + } catch (error) { + const normalizedError = normalizeBrowserSessionError(page, error); + await cleanupFailedBrowserSetup({ + browser, + context, + page, + closeBrowser: true, + closeContext: true, + correlationId, + }); + throw normalizedError; + } } export default createBrowserContext; diff --git a/src/lib/diskUploaderPath.test.ts b/src/lib/diskUploaderPath.test.ts new file mode 100644 index 00000000..db337b9a --- /dev/null +++ b/src/lib/diskUploaderPath.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import path from 'node:path'; +import { + assertPathWithinTempFolder, + assertSafeFileComponent, +} from '../middleware/disk-uploader'; + +test('accepts only allowlisted temporary file components', () => { + assert.doesNotThrow(() => assertSafeFileComponent('recording_01-safe', 'test component')); + assert.throws(() => assertSafeFileComponent('../recording', 'test component'), /Invalid test component/); + assert.throws(() => assertSafeFileComponent('recording.mp4', 'test component'), /Invalid test component/); +}); + +test('keeps temporary recording paths inside the application media root', () => { + const root = path.resolve(process.cwd(), 'dist', '_tempvideo'); + const nested = path.join(root, 'tenant', 'recording.mp4'); + + assert.equal(assertPathWithinTempFolder(nested), nested); + assert.throws( + () => assertPathWithinTempFolder(path.resolve(root, '..', 'recording.mp4')), + /escapes its isolated directory/, + ); +}); diff --git a/src/lib/recording.test.ts b/src/lib/recording.test.ts new file mode 100644 index 00000000..b1036d86 --- /dev/null +++ b/src/lib/recording.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { getRecordingMimeTypesForExtension, mp4MimeType, vp8MimeType, vp9MimeType, webmMimeType } from './recording'; + +test('real-time WebM capture prefers VP8 with VP9 compatibility fallbacks', () => { + const { mimeTypes } = getRecordingMimeTypesForExtension('.webm', true); + assert.equal(mimeTypes.find(() => true), vp8MimeType); + assert.equal(mimeTypes.find((mimeType) => mimeType !== vp8MimeType), webmMimeType); + assert.equal(mimeTypes.find((mimeType) => mimeType === vp9MimeType), vp9MimeType); +}); + +test('real-time MP4 capture retains H.264 preference before WebM fallback', () => { + const { mimeTypes } = getRecordingMimeTypesForExtension('.mp4', true); + assert.equal(mimeTypes[0], mp4MimeType); + assert.equal(mimeTypes.find((mimeType) => mimeType !== mp4MimeType), vp8MimeType); +}); diff --git a/src/lib/recording.ts b/src/lib/recording.ts index 66a257f3..38f0bc83 100644 --- a/src/lib/recording.ts +++ b/src/lib/recording.ts @@ -15,19 +15,27 @@ export const webmMimeType = `${webmContentType};codecs=vp9,opus`; export const vp9ContentType: ContentType = 'video/webm'; export const vp9MimeType = `${vp9ContentType};codecs=vp09.00.10.08,opus`; -export const getRecordingMimeTypesForExtension = (extension: string) => { - const webmVp9MimeTypes = [webmMimeType, vp9MimeType]; +// VP8 encodes far faster than VP9 in real time. VP9 under software GL +// (swiftshader) starves the encoder -> dropped frames + a stuttery, very +// low-bitrate stream that looks laggy on playback. VP8 is the classic +// MediaRecorder screen-capture codec. +export const vp8MimeType = `${webmContentType};codecs=vp8,opus`; + +export const getRecordingMimeTypesForExtension = (extension: string, preferVp8 = false) => { + const webmMimeTypes = preferVp8 + ? [vp8MimeType, webmMimeType, vp9MimeType] + : [webmMimeType, vp9MimeType]; if (extension === '.mp4') { return { - mimeTypes: [mp4MimeType, ...webmVp9MimeTypes], + mimeTypes: [mp4MimeType, ...webmMimeTypes], primaryMimeType: mp4MimeType, secondaryMimeType: webmMimeType, }; } return { - mimeTypes: webmVp9MimeTypes, + mimeTypes: webmMimeTypes, primaryMimeType: webmMimeType, secondaryMimeType: vp9MimeType, }; diff --git a/src/middleware/disk-uploader.ts b/src/middleware/disk-uploader.ts index 84795040..5991849f 100644 --- a/src/middleware/disk-uploader.ts +++ b/src/middleware/disk-uploader.ts @@ -17,11 +17,29 @@ import { getStorageProvider } from '../uploader/providers/factory'; import { getTimeString } from '../lib/datetime'; import { notifyRecordingCompleted, RecordingCompletedPayload } from '../services/notificationService'; import { writeWebmDurationMetadata } from '../lib/webmDuration'; +import { encodeFileNameSafebase64 } from '../util/strings'; console.log(' ----- PWD OR CWD ----- ', process.cwd()); const tempFolder = path.join(process.cwd(), 'dist', '_tempvideo'); const execFileAsync = promisify(execFile); +const SAFE_FILE_COMPONENT = /^[A-Za-z0-9_-]+$/; +const SAFE_FILE_EXTENSION = /^\.[A-Za-z0-9]+$/; + +export function assertSafeFileComponent(value: string, label: string): void { + if (!value || !SAFE_FILE_COMPONENT.test(value)) { + throw new Error(`Invalid ${label} for temporary recording path`); + } +} + +export function assertPathWithinTempFolder(filePath: string): string { + const root = path.resolve(tempFolder); + const resolved = path.resolve(filePath); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) { + throw new Error('Temporary recording path escapes its isolated directory'); + } + return resolved; +} function isNoSuchUploadError(err: any, userId: string, logger: Logger): boolean { /** @@ -49,6 +67,7 @@ function isNoSuchUploadError(err: any, userId: string, logger: Logger): boolean export interface IUploader { uploadRecordingToRemoteStorage(options?: { forceUpload?: boolean }): Promise; saveDataToTempFile(data: Buffer): Promise; + useExternalFile(filePath: string, extension: string): Promise; setRecordingDuration(durationSeconds: number): void; } @@ -324,6 +343,50 @@ class DiskUploader implements IUploader { } } + public async useExternalFile(filePath: string, extension: string): Promise { + await this.waitForWritingFlag(); + if (this.queue.length > 0) { + throw new Error('Cannot replace the recording while disk writes are pending'); + } + + const normalizedExtension = extension.startsWith('.') ? extension : `.${extension}`; + const targetPath = DiskUploader.getFilePath( + this._userId, + this._tempFileId, + normalizedExtension + ); + // `filePath` is produced by the internal RTMS recorder; still enforce the + // temporary-media boundary before moving it into the uploader location. + const sourcePath = assertPathWithinTempFolder(filePath); + const resolvedTarget = assertPathWithinTempFolder(targetPath); + + if (sourcePath !== resolvedTarget) { + await fs.promises.unlink(resolvedTarget).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }); + try { + await fs.promises.rename(sourcePath, resolvedTarget); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code !== 'EXDEV') throw error; + await fs.promises.copyFile(sourcePath, resolvedTarget); + await fs.promises.unlink(sourcePath); + } + } + + const stats = await fs.promises.stat(resolvedTarget); + if (!stats.isFile() || stats.size === 0) { + throw new Error('External recording file is empty'); + } + + this.fileExtension = normalizedExtension; + this.contentType = extensionToContentType[normalizedExtension] ?? 'video/mp4'; + this.firstChunkReceivedAt = this.firstChunkReceivedAt ?? Date.now(); + this._logger.info('Using externally recorded media file for upload', { + extension: normalizedExtension, + size: stats.size, + }); + } + public setRecordingDuration(durationSeconds: number): void { if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) { this._logger.warn('Ignoring invalid recording duration from recorder', { durationSeconds }); @@ -340,15 +403,21 @@ class DiskUploader implements IUploader { } private static getFolderPath(userId: string) { - const folderPath = path.join(tempFolder, userId); + const safeUserId = encodeFileNameSafebase64(userId); + assertSafeFileComponent(safeUserId, 'userId'); + const folderPath = path.join(tempFolder, safeUserId); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal return folderPath; } private static getFilePath(userId: string, tempFileId: string, fileExtension: string) { + assertSafeFileComponent(tempFileId, 'temporary file ID'); + if (!SAFE_FILE_EXTENSION.test(fileExtension)) { + throw new Error('Invalid temporary recording file extension'); + } const fileName = `${tempFileId}${fileExtension}`; const folderPath = DiskUploader.getFolderPath(userId); - const filePath = path.join(folderPath, fileName); - return filePath; + const filePath = path.join(folderPath, fileName); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + return assertPathWithinTempFolder(filePath); } private async processRecordingUpload() { diff --git a/src/monitoring/sentry.test.ts b/src/monitoring/sentry.test.ts new file mode 100644 index 00000000..dd79143b --- /dev/null +++ b/src/monitoring/sentry.test.ts @@ -0,0 +1,345 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { WaitingAtLobbyRetryError } from '../error'; +import { + SentryReporter, + classifyMeetingFailureKind, + inferFallbackResult, + inferMeetingFailurePhase, + inferMeetingFailureTransport, + redactSentryEvent, +} from './sentry'; + +interface FakeScope { + level?: string; + tags: Record; + fingerprint?: string[]; + setLevel(level: string): void; + setTag(key: string, value: string): void; + setFingerprint(fingerprint: string[]): void; +} + +interface FakeInitOptions { + dsn?: string; + environment?: string; + release?: string; + tracesSampleRate?: number; + sendDefaultPii?: boolean; + beforeSend?: (event: unknown) => unknown; + integrations?: ( + integrations: Array<{ name: string }> + ) => Array<{ name: string }>; +} + +const createFakeSdk = () => { + let activeScope: FakeScope | undefined; + const state = { + initCalls: [] as FakeInitOptions[], + exceptions: [] as Array<{ error: unknown; level?: string; tags: Record; fingerprint?: string[] }>, + messages: [] as Array<{ message: string; level?: string; tags: Record; fingerprint?: string[] }>, + flushTimeouts: [] as number[], + }; + + const sdk = { + init(options: FakeInitOptions) { + state.initCalls.push(options); + }, + withScope(callback: (scope: FakeScope) => void) { + const scope: FakeScope = { + tags: {}, + setLevel(level) { + this.level = level; + }, + setTag(key, value) { + this.tags[key] = value; + }, + setFingerprint(fingerprint) { + this.fingerprint = fingerprint; + }, + }; + activeScope = scope; + callback(scope); + activeScope = undefined; + }, + captureException(error: unknown) { + state.exceptions.push({ + error, + level: activeScope?.level, + tags: { ...activeScope?.tags }, + fingerprint: activeScope?.fingerprint, + }); + return 'event-id'; + }, + captureMessage(message: string) { + state.messages.push({ + message, + level: activeScope?.level, + tags: { ...activeScope?.tags }, + fingerprint: activeScope?.fingerprint, + }); + return 'event-id'; + }, + async flush(timeout: number) { + state.flushTimeouts.push(timeout); + return true; + }, + }; + + return { sdk, state }; +}; + +test('is a complete no-op when SENTRY_DSN is absent', async () => { + const { sdk, state } = createFakeSdk(); + const reporter = new SentryReporter(sdk as never, {}); + + assert.equal(reporter.initialize(), false); + assert.equal(reporter.captureOperationalError(new Error('failure'), { phase: 'startup' }), false); + assert.equal(await reporter.flush(), true); + assert.equal(state.initCalls.length, 0); + assert.equal(state.exceptions.length, 0); + assert.equal(state.flushTimeouts.length, 0); +}); + +test('initializes Sentry with safe non-tracing defaults from the environment', () => { + const { sdk, state } = createFakeSdk(); + const reporter = new SentryReporter(sdk as never, { + SENTRY_DSN: 'https://public@example.invalid/1', + SENTRY_ENVIRONMENT: 'production', + SENTRY_RELEASE: 'meeting-bot@1.3.6', + }); + + assert.equal(reporter.initialize(), true); + assert.equal(state.initCalls.length, 1); + assert.equal(state.initCalls[0].dsn, 'https://public@example.invalid/1'); + assert.equal(state.initCalls[0].environment, 'production'); + assert.equal(state.initCalls[0].release, 'meeting-bot@1.3.6'); + assert.equal(state.initCalls[0].tracesSampleRate, 0); + assert.equal(state.initCalls[0].sendDefaultPii, false); + assert.equal(typeof state.initCalls[0].beforeSend, 'function'); + const integrations = state.initCalls[0].integrations?.([ + { name: 'Http' }, + { name: 'Console' }, + { name: 'OnUncaughtException' }, + { name: 'OnUnhandledRejection' }, + ]); + assert.deepEqual(integrations?.map(({ name }) => name), ['Http']); +}); + +test('redacts URL queries, credentials, request and page bodies before sending', () => { + const event = { + message: 'Failed https://zoom.us/j/123?pwd=zoom-password&token=url-token and /wc/join/123?foo=private-query token=inline-token', + user: { email: 'person@example.com' }, + request: { + url: 'https://zoom.us/j/123?pwd=zoom-password', + query_string: 'pwd=zoom-password', + data: 'private page', + headers: { + authorization: 'Bearer private-bearer', + 'x-api-key': 'private-api-key', + }, + }, + extra: { + documentBodyText: 'private page text', + clientSecret: 'private-client-secret', + nested: { password: 'private-password' }, + }, + }; + + const redacted = redactSentryEvent(event); + const serialized = JSON.stringify(redacted); + + assert.equal(redacted.request.url, 'https://zoom.us/j/123'); + assert.equal('user' in redacted, false); + assert.equal(redacted.request.query_string, '[REDACTED]'); + assert.equal(redacted.request.data, '[REDACTED]'); + assert.equal(redacted.request.headers.authorization, '[REDACTED]'); + assert.equal(redacted.request.headers['x-api-key'], '[REDACTED]'); + for (const secret of [ + 'zoom-password', + 'url-token', + 'inline-token', + 'private-query', + 'private page', + 'private-bearer', + 'private-api-key', + 'private-client-secret', + 'private-password', + ]) { + assert.equal(serialized.includes(secret), false, `found leaked value: ${secret}`); + } +}); + +test('redacts non-HTTP meeting URLs and inline OAuth credentials', () => { + const event = { + message: 'wss://rtms.zoom.us/connect?signature=private zoommtg://zoom.us/join?pwd=private clientSecret=private oauth_token=private', + }; + const serialized = JSON.stringify(redactSentryEvent(event)); + + assert.equal(serialized.includes('signature=private'), false); + assert.equal(serialized.includes('pwd=private'), false); + assert.equal(serialized.includes('clientSecret=private'), false); + assert.equal(serialized.includes('oauth_token=private'), false); +}); + +test('captures one tagged permanent meeting failure for the same final error', () => { + const { sdk, state } = createFakeSdk(); + const reporter = new SentryReporter(sdk as never, { + SENTRY_DSN: 'https://public@example.invalid/1', + SENTRY_ENVIRONMENT: 'production', + SENTRY_RELEASE: 'meeting-bot@1.3.6', + }); + reporter.initialize(); + + const error = new Error('recording failed'); + const context = { + provider: 'microsoft' as const, + transport: 'browser' as const, + phase: 'recording', + fallbackResult: 'not_attempted', + teamId: 'team-id', + eventId: 'event-id', + botId: 'bot-id', + correlationId: 'correlation-id', + }; + + assert.equal(reporter.reportPermanentMeetingFailure(error, context), true); + assert.equal(reporter.reportPermanentMeetingFailure(error, context), false); + assert.equal(state.exceptions.length, 1); + assert.equal(state.exceptions[0].level, 'error'); + assert.deepEqual(state.exceptions[0].fingerprint, ['microsoft', 'recording_failure']); + assert.deepEqual(state.exceptions[0].tags, { + provider: 'microsoft', + transport: 'browser', + error_type: 'Error', + failure_kind: 'recording_failure', + stage: 'recording', + fallback_result: 'not_attempted', + environment: 'production', + release: 'meeting-bot@1.3.6', + team_id: 'team-id', + event_id: 'event-id', + bot_id: 'bot-id', + correlation_id: 'correlation-id', + }); +}); + +test('reports host rejection and lobby timeout as classified warnings', () => { + const { sdk, state } = createFakeSdk(); + const reporter = new SentryReporter(sdk as never, { + SENTRY_DSN: 'https://public@example.invalid/1', + }); + reporter.initialize(); + + const context = { + provider: 'google' as const, + transport: 'browser' as const, + phase: 'waiting_room', + fallbackResult: 'not_attempted', + teamId: 'team-id', + eventId: 'event-id', + correlationId: 'correlation-id', + }; + const rejected = new WaitingAtLobbyRetryError( + 'Google Meet bot could not enter the meeting', + 'Someone in the call denied your request to join' + ); + const timedOut = new WaitingAtLobbyRetryError( + 'Google Meet bot could not enter the meeting', + 'No one responded to your request to join the call' + ); + + assert.equal(classifyMeetingFailureKind(rejected), 'host_rejected'); + assert.equal(classifyMeetingFailureKind(timedOut), 'lobby_timeout'); + reporter.reportPermanentMeetingFailure(rejected, context); + reporter.reportPermanentMeetingFailure(timedOut, { + ...context, + correlationId: 'second-correlation-id', + }); + + assert.deepEqual(state.exceptions.map(({ level, tags }) => ({ + level, + failureKind: tags.failure_kind, + stage: tags.stage, + })), [ + { level: 'warning', failureKind: 'host_rejected', stage: 'waiting_room' }, + { level: 'warning', failureKind: 'lobby_timeout', stage: 'waiting_room' }, + ]); +}); + +test('keeps technical permanent failures at error level', () => { + const { sdk, state } = createFakeSdk(); + const reporter = new SentryReporter(sdk as never, { + SENTRY_DSN: 'https://public@example.invalid/1', + }); + reporter.initialize(); + + const technicalError = new Error('CDP browser disconnected'); + reporter.reportPermanentMeetingFailure(technicalError, { + provider: 'zoom', + transport: 'browser', + phase: 'recording', + fallbackResult: 'not_attempted', + teamId: 'team-id', + botId: 'bot-id', + correlationId: 'correlation-id', + }); + + assert.equal(state.exceptions[0].level, 'error'); + assert.equal(state.exceptions[0].tags.failure_kind, 'browser_failure'); + assert.equal(state.exceptions[0].tags.team_id, 'team-id'); + assert.equal(state.exceptions[0].tags.bot_id, 'bot-id'); + assert.equal(state.exceptions[0].tags.event_id, 'none'); +}); + +test('classifies an RTMS fallback failure from a wrapped error message', () => { + const error = new Error('Zoom RTMS stream failed after recovery retries'); + assert.equal(inferMeetingFailureTransport(error), 'rtms'); + assert.equal(inferMeetingFailurePhase(error), 'fallback'); + assert.equal(inferFallbackResult(error), 'failed'); +}); + +test('preserves explicit RTMS transport context for upload failures', () => { + const error = Object.assign(new Error('Recording upload failed'), { + transport: 'rtms', + phase: 'upload', + fallbackResult: 'recovered', + }); + + assert.equal(inferMeetingFailureTransport(error), 'rtms'); + assert.equal(inferMeetingFailurePhase(error), 'upload'); + assert.equal(inferFallbackResult(error), 'recovered'); +}); + +test('reports a recovered Zoom RTMS fallback as a warning', () => { + const { sdk, state } = createFakeSdk(); + const reporter = new SentryReporter(sdk as never, { + SENTRY_DSN: 'https://public@example.invalid/1', + }); + reporter.initialize(); + + reporter.reportRecoveredZoomFallback({ + phase: 'fallback', + teamId: 'team-id', + eventId: 'event-id', + correlationId: 'correlation-id', + }); + + assert.equal(state.messages.length, 1); + assert.equal(state.messages[0].level, 'warning'); + assert.equal(state.messages[0].tags.provider, 'zoom'); + assert.equal(state.messages[0].tags.transport, 'rtms'); + assert.equal(state.messages[0].tags.failure_kind, 'automated_bot_blocked'); + assert.equal(state.messages[0].tags.stage, 'fallback'); + assert.equal(state.messages[0].tags.fallback_result, 'recovered'); +}); + +test('caps Sentry flushing at two seconds', async () => { + const { sdk, state } = createFakeSdk(); + const reporter = new SentryReporter(sdk as never, { + SENTRY_DSN: 'https://public@example.invalid/1', + }); + reporter.initialize(); + + assert.equal(await reporter.flush(10_000), true); + assert.deepEqual(state.flushTimeouts, [2_000]); +}); diff --git a/src/monitoring/sentry.ts b/src/monitoring/sentry.ts new file mode 100644 index 00000000..bdcd7f55 --- /dev/null +++ b/src/monitoring/sentry.ts @@ -0,0 +1,495 @@ +import * as Sentry from '@sentry/node'; +import { v5 as uuidv5 } from 'uuid'; + +const REDACTED = '[REDACTED]'; +const MAX_FLUSH_TIMEOUT_MS = 2_000; +const GLOBAL_HANDLER_INTEGRATIONS = new Set([ + 'Console', + 'OnUncaughtException', + 'OnUnhandledRejection', +]); + +const SENSITIVE_KEYS = new Set([ + 'authorization', + 'accountid', + 'bearer', + 'body', + 'clientsecret', + 'clientid', + 'cookie', + 'cookies', + 'documentbody', + 'documentbodytext', + 'dsn', + 'html', + 'idtoken', + 'oauth', + 'pagebody', + 'pagecontent', + 'pagetext', + 'password', + 'passwd', + 'pwd', + 'query', + 'querystring', + 'refreshtoken', + 'requestbody', + 'responsebody', + 'secret', + 'servicekey', + 'signature', + 'setcookie', + 'token', + 'accesstoken', + 'apikey', + 'participantuserid', +]); + +type SentrySdk = Pick< + typeof Sentry, + 'captureException' | 'captureMessage' | 'flush' | 'init' | 'withScope' +>; + +export type MeetingProvider = 'google' | 'microsoft' | 'zoom'; +export type RecordingTransport = 'browser' | 'rtms'; +export type MeetingFailureKind = + | 'automated_bot_blocked' + | 'browser_failure' + | 'host_rejected' + | 'lobby_timeout' + | 'meeting_ended' + | 'recording_failure' + | 'rtms_failure' + | 'sign_in_required' + | 'technical_failure' + | 'unsupported_meeting' + | 'upload_failed'; + +export interface MeetingSentryContext { + provider: MeetingProvider; + transport: RecordingTransport; + phase: string; + fallbackResult: string; + teamId: string; + eventId?: string; + botId?: string; + correlationId: string; +} + +export interface MeetingIdentityContext { + teamId: string; + userId: string; + url: string; + eventId?: string; + botId?: string; +} + +export interface OperationalSentryContext { + phase: string; + provider?: MeetingProvider | 'system'; + transport?: RecordingTransport | 'none'; + teamId?: string; + eventId?: string; + botId?: string; + correlationId?: string; +} + +const normalizedKey = (key: string): string => + key.toLowerCase().replace(/[^a-z0-9]/g, ''); + +const isSensitiveKey = (key: string, parentKey?: string): boolean => { + const normalized = normalizedKey(key); + if (SENSITIVE_KEYS.has(normalized)) return true; + if ( + normalized.endsWith('secret') + || normalized.endsWith('token') + || normalized.endsWith('apikey') + || normalized.endsWith('authorization') + || normalized.endsWith('cookie') + || normalized.endsWith('password') + || normalized.endsWith('signature') + ) return true; + return normalized === 'data' && normalizedKey(parentKey ?? '') === 'request'; +}; + +export const redactSensitiveString = (value: string): string => value + .replace(/\b(?:https?|wss?|zoommtg):\/\/[^\s"'<>]+/gi, (candidate) => { + const queryIndex = candidate.search(/[?#]/); + return queryIndex === -1 ? candidate : candidate.slice(0, queryIndex); + }) + .replace( + /(^|[\s("'`])((?:\/\/|\/)[^\s"'<>?]+)\?[^\s"'<>]*/g, + '$1$2' + ) + .replace( + /\b((?:[a-z0-9-]+\.)+[a-z]{2,}(?:\/[^\s"'<>?]*)?)\?[^\s"'<>]*/gi, + '$1' + ) + .replace(/\bBearer\s+[^\s,;]+/gi, `Bearer ${REDACTED}`) + .replace( + /((?:access[_-]?token|api[_-]?key|authorization|client[_-]?secret|id[_-]?token|oauth|password|passwd|pwd|refresh[_-]?token|secret|token)\s*["']?\s*[:=]\s*["']?)[^&\s,"'`;}]*/gi, + `$1${REDACTED}` + ); + +const redactValue = ( + value: unknown, + seen: WeakSet, + parentKey?: string +): unknown => { + if (typeof value === 'string') return redactSensitiveString(value); + if (value === null || typeof value !== 'object') return value; + if (seen.has(value)) return '[Circular]'; + seen.add(value); + + if (Array.isArray(value)) { + return value.map((item) => redactValue(item, seen, parentKey)); + } + + const redacted: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + redacted[key] = isSensitiveKey(key, parentKey) + ? REDACTED + : redactValue(nestedValue, seen, key); + } + return redacted; +}; + +export const redactSentryEvent = (event: T): T => { + const redacted = redactValue(event, new WeakSet()) as T; + if (redacted && typeof redacted === 'object' && 'user' in redacted) { + delete (redacted as Record).user; + } + return redacted; +}; + +const errorType = (error: unknown): string => { + if (!(error instanceof Error)) return 'Unknown'; + return error.constructor.name || error.name || 'UnknownError'; +}; + +const errorDescriptor = (error: unknown): string => [ + errorType(error), + error instanceof Error ? error.message : '', +].join(' ').toLowerCase(); + +const readErrorTag = (error: unknown, key: string): string | undefined => { + if (!error || typeof error !== 'object') return undefined; + const value = (error as Record)[key]; + return typeof value === 'string' && value.trim() ? value : undefined; +}; + +const FAILURE_KINDS = new Set([ + 'automated_bot_blocked', + 'browser_failure', + 'host_rejected', + 'lobby_timeout', + 'meeting_ended', + 'recording_failure', + 'rtms_failure', + 'sign_in_required', + 'technical_failure', + 'unsupported_meeting', + 'upload_failed', +]); + +export const classifyMeetingFailureKind = (error: unknown): MeetingFailureKind => { + const explicitKind = readErrorTag(error, 'failureKind') + ?? readErrorTag(error, 'failure_kind'); + if (explicitKind && FAILURE_KINDS.has(explicitKind as MeetingFailureKind)) { + return explicitKind as MeetingFailureKind; + } + + const reason = readErrorTag(error, 'reason'); + if (reason && FAILURE_KINDS.has(reason as MeetingFailureKind)) { + return reason as MeetingFailureKind; + } + const descriptor = errorDescriptor(error); + if (reason === 'automated_bot_blocked' || descriptor.includes('automated bot')) { + return 'automated_bot_blocked'; + } + + if (descriptor.includes('waitingatlobby') || descriptor.includes('waiting at lobby')) { + const bodyText = readErrorTag(error, 'documentBodyText') ?? ''; + return /denied|removed|rejected/i.test(bodyText) + ? 'host_rejected' + : 'lobby_timeout'; + } + + if (/host.?rejected|denied access|request (?:was )?denied/.test(descriptor)) { + return 'host_rejected'; + } + if (descriptor.includes('lobby') && descriptor.includes('timeout')) return 'lobby_timeout'; + if (descriptor.includes('sign in') || descriptor.includes('signin')) return 'sign_in_required'; + if (descriptor.includes('upload')) return 'upload_failed'; + if (descriptor.includes('rtms')) return 'rtms_failure'; + if ( + descriptor.includes('browser') + || descriptor.includes('context-closed') + || descriptor.includes('page-crashed') + || descriptor.includes('page-closed') + || descriptor.includes('cdp') + ) return 'browser_failure'; + if (descriptor.includes('meeting ended') || descriptor.includes('meeting_ended')) { + return 'meeting_ended'; + } + if (descriptor.includes('unsupported meeting') || descriptor.includes('unsupportedmeeting')) { + return 'unsupported_meeting'; + } + if (descriptor.includes('recording')) return 'recording_failure'; + return 'technical_failure'; +}; + +const failureLevel = (failureKind: MeetingFailureKind): 'error' | 'warning' => + failureKind === 'host_rejected' || failureKind === 'lobby_timeout' + ? 'warning' + : 'error'; + +export const inferMeetingFailurePhase = (error: unknown): string => { + const explicitPhase = readErrorTag(error, 'phase') ?? readErrorTag(error, 'stage'); + if (explicitPhase) return explicitPhase; + + const type = errorDescriptor(error); + if (type.includes('upload')) return 'upload'; + if (type.includes('lobby') || type.includes('waitingroom')) return 'waiting_room'; + if (type.includes('signin') || type.includes('unsupported') || type.includes('join')) return 'prejoin'; + if (type.includes('browser') || type.includes('context') || type.includes('cdp')) return 'browser'; + if (type.includes('rtms')) return 'fallback'; + return 'recording'; +}; + +export const inferMeetingFailureTransport = (error: unknown): RecordingTransport => { + const transport = readErrorTag(error, 'transport'); + if (transport === 'rtms' || transport === 'browser') return transport; + return errorDescriptor(error).includes('rtms') ? 'rtms' : 'browser'; +}; + +export const inferFallbackResult = (error: unknown): string => { + const fallbackResult = readErrorTag(error, 'fallbackResult') + ?? readErrorTag(error, 'fallback_result'); + if (fallbackResult) return fallbackResult; + return inferMeetingFailureTransport(error) === 'rtms' ? 'failed' : 'not_attempted'; +}; + +export const createSentryCorrelationId = ({ + userId, + eventId, + botId, + url, +}: MeetingIdentityContext): string => { + const entityId = botId ?? eventId; + return uuidv5(`${userId}:${entityId}:${url}`, uuidv5.DNS); +}; + +const normalizedException = (error: unknown): Error => { + if (error instanceof Error) return error; + return new Error(redactSensitiveString(String(error ?? 'Unknown error'))); +}; + +export class SentryReporter { + private enabled = false; + private environment = 'unknown'; + private release = 'unknown'; + private readonly reportedObjectFailures = new WeakMap>(); + private readonly reportedPrimitiveFailures = new Set(); + + constructor( + private readonly sdk: SentrySdk = Sentry, + private readonly env: NodeJS.ProcessEnv = process.env + ) {} + + initialize(): boolean { + const dsn = this.env.SENTRY_DSN?.trim(); + if (!dsn) return false; + + this.environment = this.env.SENTRY_ENVIRONMENT?.trim() + || this.env.NODE_ENV?.trim() + || 'unknown'; + this.release = this.env.SENTRY_RELEASE?.trim() || 'unknown'; + + try { + this.sdk.init({ + dsn, + environment: this.environment, + release: this.release === 'unknown' ? undefined : this.release, + tracesSampleRate: 0, + sendDefaultPii: false, + integrations: (defaultIntegrations) => defaultIntegrations.filter( + (integration) => !GLOBAL_HANDLER_INTEGRATIONS.has(integration.name) + ), + beforeSend: (event) => redactSentryEvent(event), + }); + this.enabled = true; + return true; + } catch (error) { + console.error( + 'Sentry initialization failed; monitoring is disabled.', + redactSensitiveString(normalizedException(error).message) + ); + return false; + } + } + + isEnabled(): boolean { + return this.enabled; + } + + private tags( + context: OperationalSentryContext & { + errorType: string; + failureKind: MeetingFailureKind; + fallbackResult?: string; + } + ): Record { + return { + provider: context.provider ?? 'system', + transport: context.transport ?? 'none', + error_type: context.errorType, + failure_kind: context.failureKind, + stage: context.phase, + fallback_result: context.fallbackResult ?? 'not_applicable', + environment: this.environment, + release: this.release, + team_id: context.teamId ?? 'none', + event_id: context.eventId ?? 'none', + bot_id: context.botId ?? 'none', + correlation_id: context.correlationId ?? 'none', + }; + } + + private capture( + error: unknown, + level: 'error' | 'warning', + tags: Record, + message?: string + ): void { + if (!this.enabled) return; + try { + this.sdk.withScope((scope) => { + scope.setLevel(level); + for (const [key, value] of Object.entries(tags)) scope.setTag(key, value); + scope.setFingerprint([tags.provider, tags.failure_kind]); + if (message) { + this.sdk.captureMessage(message, level); + } else { + this.sdk.captureException(normalizedException(error)); + } + }); + } catch (captureError) { + console.error( + 'Sentry capture failed.', + redactSensitiveString(normalizedException(captureError).message) + ); + } + } + + private failureKey(error: unknown, context: MeetingSentryContext): string { + return [ + context.correlationId, + context.provider, + context.transport, + context.phase, + context.fallbackResult, + errorType(error), + ].join(':'); + } + + private isDuplicatePermanentFailure(error: unknown, context: MeetingSentryContext): boolean { + const key = this.failureKey(error, context); + if (error !== null && (typeof error === 'object' || typeof error === 'function')) { + const previousKeys = this.reportedObjectFailures.get(error as object); + if (previousKeys?.has(key)) return true; + if (previousKeys) { + previousKeys.add(key); + } else { + this.reportedObjectFailures.set(error as object, new Set([key])); + } + return false; + } + + if (this.reportedPrimitiveFailures.has(key)) return true; + if (this.reportedPrimitiveFailures.size >= 1_000) { + this.reportedPrimitiveFailures.clear(); + } + this.reportedPrimitiveFailures.add(key); + return false; + } + + reportPermanentMeetingFailure(error: unknown, context: MeetingSentryContext): boolean { + if (!this.enabled || this.isDuplicatePermanentFailure(error, context)) return false; + const failureKind = classifyMeetingFailureKind(error); + this.capture(error, failureLevel(failureKind), this.tags({ + ...context, + errorType: errorType(error), + failureKind, + })); + return true; + } + + reportRecoveredZoomFallback( + context: Omit, + browserError?: unknown + ): boolean { + if (!this.enabled) return false; + this.capture(browserError, 'warning', this.tags({ + ...context, + provider: 'zoom', + transport: 'rtms', + errorType: errorType(browserError) === 'Unknown' + ? 'ZoomBrowserJoinBlockedError' + : errorType(browserError), + failureKind: 'automated_bot_blocked', + fallbackResult: 'recovered', + }), 'Zoom browser join was blocked; RTMS fallback recovered the recording.'); + return true; + } + + captureOperationalError(error: unknown, context: OperationalSentryContext): boolean { + if (!this.enabled) return false; + this.capture(error, 'error', this.tags({ + ...context, + errorType: errorType(error), + failureKind: classifyMeetingFailureKind(error), + })); + return true; + } + + async flush(timeoutMs = MAX_FLUSH_TIMEOUT_MS): Promise { + if (!this.enabled) return true; + const boundedTimeout = Math.min( + MAX_FLUSH_TIMEOUT_MS, + Math.max(0, Math.floor(timeoutMs)) + ); + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + this.sdk.flush(boundedTimeout), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), boundedTimeout); + }), + ]); + } catch { + return false; + } finally { + if (timeout) clearTimeout(timeout); + } + } +} + +const reporter = new SentryReporter(); + +export const initializeSentry = (): boolean => reporter.initialize(); +export const isSentryEnabled = (): boolean => reporter.isEnabled(); +export const reportPermanentMeetingFailure = ( + error: unknown, + context: MeetingSentryContext +): boolean => reporter.reportPermanentMeetingFailure(error, context); +export const reportRecoveredZoomFallback = ( + context: Omit, + browserError?: unknown +): boolean => reporter.reportRecoveredZoomFallback(context, browserError); +export const captureOperationalError = ( + error: unknown, + context: OperationalSentryContext +): boolean => reporter.captureOperationalError(error, context); +export const flushSentry = (timeoutMs = MAX_FLUSH_TIMEOUT_MS): Promise => + reporter.flush(timeoutMs); diff --git a/src/rtms/RtmsMediaRecorder.test.ts b/src/rtms/RtmsMediaRecorder.test.ts new file mode 100644 index 00000000..5981df63 --- /dev/null +++ b/src/rtms/RtmsMediaRecorder.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Logger } from 'winston'; +import config from '../config'; +import { boundMediaGapMilliseconds, RtmsMediaRecorder } from './RtmsMediaRecorder'; + +test('preserves media gaps longer than 60 seconds up to the recording limit', () => { + const twoMinuteGap = 120_000; + + assert.equal(boundMediaGapMilliseconds(twoMinuteGap), twoMinuteGap); + assert.equal( + boundMediaGapMilliseconds(config.maxRecordingDuration * 60_000 + 1), + config.maxRecordingDuration * 60_000 + ); +}); + +test('keeps a media gap longer than 60 seconds in the recorded timeline', async () => { + const logger = { + info: () => undefined, + warn: () => undefined, + error: () => undefined, + } as unknown as Logger; + const recorder = await RtmsMediaRecorder.create(logger); + + try { + recorder.writeAudio(Buffer.alloc(3_200), 1_000_000); + recorder.writeAudio(Buffer.alloc(3_200), 1_065_000); + + const recording = await recorder.finalize(); + assert.ok(recording.durationSeconds >= 65); + } finally { + await recorder.cleanup(); + } +}); diff --git a/src/rtms/RtmsMediaRecorder.ts b/src/rtms/RtmsMediaRecorder.ts new file mode 100644 index 00000000..48a3c1c9 --- /dev/null +++ b/src/rtms/RtmsMediaRecorder.ts @@ -0,0 +1,332 @@ +import { execFile } from 'child_process'; +import fs, { WriteStream } from 'fs'; +import path from 'path'; +import { promisify } from 'util'; +import { Logger } from 'winston'; +import config from '../config'; + +const execFileAsync = promisify(execFile); +const AUDIO_BYTES_PER_MILLISECOND = 16_000 * 2 / 1000; +const VIDEO_FRAME_DURATION_MILLISECONDS = 40; +const INITIAL_VIDEO_BUFFER_LIMIT_MILLISECONDS = 60_000; +const MAX_PENDING_VIDEO_BYTES = 64 * 1024 * 1024; + +export const boundMediaGapMilliseconds = (milliseconds: number): number => { + const maximum = config.maxRecordingDuration * 60_000; + if (!Number.isFinite(milliseconds) || milliseconds < 0) { + throw new Error('Zoom RTMS media gap is invalid'); + } + if (!Number.isFinite(maximum) || maximum <= 0) { + throw new Error('MAX_RECORDING_DURATION_MINUTES must be a positive number'); + } + return Math.min(milliseconds, maximum); +}; + +interface PendingVideoPacket { + buffer: Buffer; + timestamp: number; +} + +const closeStream = (stream: WriteStream): Promise => new Promise((resolve, reject) => { + stream.once('error', reject); + stream.end(resolve); +}); + +const writeStream = (stream: WriteStream, data: Buffer): Promise => new Promise((resolve, reject) => { + const onError = (error: Error) => { + stream.off('drain', onDrain); + reject(error); + }; + const onDrain = () => { + stream.off('error', onError); + resolve(); + }; + + stream.once('error', onError); + if (stream.write(data)) { + stream.off('error', onError); + resolve(); + } else { + stream.once('drain', onDrain); + } +}); + +export class RtmsMediaRecorder { + private readonly audioPath: string; + private readonly videoPath: string; + private readonly outputPath: string; + private readonly audioStream: WriteStream; + private readonly videoStream: WriteStream; + private writeQueue: Promise = Promise.resolve(); + private writeError?: Error; + private audioBytes = 0; + private videoBytes = 0; + private videoFrames = 0; + private pendingVideoBytes = 0; + private readonly pendingVideoPackets: PendingVideoPacket[] = []; + private expectedAudioTimestamp?: number; + private expectedVideoTimestamp?: number; + private firstAudioTimestamp?: number; + private lastAudioTimestamp?: number; + private timelineStartTimestamp?: number; + + private constructor( + private readonly folderPath: string, + private readonly logger: Logger, + private readonly blackFrame: Buffer + ) { + // `folderPath` is generated by mkdtemp inside the private create() factory. + this.audioPath = path.join(folderPath, 'audio.raw'); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + this.videoPath = path.join(folderPath, 'video.h264'); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + this.outputPath = path.join(folderPath, 'recording.mp4'); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + this.audioStream = fs.createWriteStream(this.audioPath, { highWaterMark: 4 * 1024 * 1024 }); + this.videoStream = fs.createWriteStream(this.videoPath, { highWaterMark: 4 * 1024 * 1024 }); + const rememberWriteError = (error: Error) => { + this.writeError = this.writeError ?? error; + }; + this.audioStream.on('error', rememberWriteError); + this.videoStream.on('error', rememberWriteError); + } + + static async create(logger: Logger): Promise { + const root = path.join(process.cwd(), 'dist', '_tempvideo'); + await fs.promises.mkdir(root, { recursive: true }); + const folderPath = await fs.promises.mkdtemp(path.join(root, 'rtms-')); + const blackFrame = await RtmsMediaRecorder.createBlackFrame(folderPath, logger); + return new RtmsMediaRecorder(folderPath, logger, blackFrame); + } + + writeAudio(buffer: Buffer, timestamp: number): void { + const data = Buffer.from(buffer); + this.enqueue(async () => { + const pendingVideoStart = this.pendingVideoPackets[0]?.timestamp; + if (typeof this.firstAudioTimestamp !== 'number') { + this.timelineStartTimestamp = this.timelineStartTimestamp ?? Math.min( + timestamp, + pendingVideoStart ?? timestamp + ); + } + + const expectedTimestamp = this.expectedAudioTimestamp ?? this.timelineStartTimestamp; + if (typeof expectedTimestamp === 'number' && timestamp > expectedTimestamp + 20) { + const missingMilliseconds = this.capGap(timestamp - expectedTimestamp, 'audio'); + await this.writeSilence(missingMilliseconds); + } + + await writeStream(this.audioStream, data); + this.audioBytes += data.length; + const packetEnd = timestamp + data.length / AUDIO_BYTES_PER_MILLISECOND; + this.firstAudioTimestamp = this.firstAudioTimestamp ?? timestamp; + this.lastAudioTimestamp = Math.max(this.lastAudioTimestamp ?? packetEnd, packetEnd); + this.expectedAudioTimestamp = Math.max(this.expectedAudioTimestamp ?? packetEnd, packetEnd); + await this.flushPendingVideo(); + }); + } + + writeVideo(buffer: Buffer, timestamp: number): void { + const data = Buffer.from(buffer); + this.enqueue(async () => { + if ( + typeof this.firstAudioTimestamp !== 'number' + && typeof this.timelineStartTimestamp !== 'number' + ) { + this.pendingVideoPackets.push({ buffer: data, timestamp }); + this.pendingVideoBytes += data.length; + const bufferedMilliseconds = timestamp - this.pendingVideoPackets[0].timestamp; + if ( + bufferedMilliseconds < INITIAL_VIDEO_BUFFER_LIMIT_MILLISECONDS + && this.pendingVideoBytes < MAX_PENDING_VIDEO_BYTES + ) { + return; + } + + this.timelineStartTimestamp = this.pendingVideoPackets[0].timestamp; + this.logger.warn('Zoom RTMS audio did not arrive before the initial video buffer limit'); + await this.flushPendingVideo(); + return; + } + + await this.writeVideoPacket(data, timestamp); + }); + } + + async finalize(): Promise<{ filePath: string; durationSeconds: number }> { + await this.writeQueue; + if (this.writeError) throw this.writeError; + if (this.pendingVideoPackets.length > 0) { + this.timelineStartTimestamp = this.timelineStartTimestamp + ?? this.pendingVideoPackets[0].timestamp; + await this.flushPendingVideo(); + } + if ( + typeof this.lastAudioTimestamp === 'number' + && typeof this.expectedVideoTimestamp === 'number' + && this.lastAudioTimestamp > this.expectedVideoTimestamp + VIDEO_FRAME_DURATION_MILLISECONDS + ) { + await this.writeBlackFrames( + this.capGap(this.lastAudioTimestamp - this.expectedVideoTimestamp, 'video') + ); + } else if ( + typeof this.lastAudioTimestamp === 'number' + && typeof this.expectedVideoTimestamp === 'number' + && this.expectedVideoTimestamp > this.lastAudioTimestamp + 20 + ) { + await this.writeSilence( + this.capGap(this.expectedVideoTimestamp - this.lastAudioTimestamp, 'audio') + ); + } + + await Promise.all([closeStream(this.audioStream), closeStream(this.videoStream)]); + if (this.audioBytes === 0 && this.videoBytes === 0) { + throw new Error('Zoom RTMS stream ended without audio or video data'); + } + + const args = this.ffmpegArgs(); + this.logger.info('Muxing Zoom RTMS media into MP4', { + audioBytes: this.audioBytes, + videoBytes: this.videoBytes, + }); + await execFileAsync('ffmpeg', args, { maxBuffer: 10 * 1024 * 1024 }); + + const output = await fs.promises.stat(this.outputPath); + if (output.size === 0) throw new Error('Zoom RTMS muxer produced an empty recording'); + + const audioDuration = this.audioBytes / AUDIO_BYTES_PER_MILLISECOND; + const videoDuration = this.videoFrames * VIDEO_FRAME_DURATION_MILLISECONDS; + const durationSeconds = Math.max(1, Math.ceil(Math.max(audioDuration, videoDuration) / 1000)); + return { filePath: this.outputPath, durationSeconds }; + } + + async cleanup(): Promise { + await this.writeQueue; + this.audioStream.destroy(); + this.videoStream.destroy(); + await fs.promises.rm(this.folderPath, { recursive: true, force: true }); + } + + private enqueue(operation: () => Promise): void { + this.writeQueue = this.writeQueue.then(operation).catch((error: Error) => { + this.writeError = this.writeError ?? error; + }); + } + + private capGap(milliseconds: number, media: 'audio' | 'video'): number { + const boundedMilliseconds = boundMediaGapMilliseconds(milliseconds); + if (boundedMilliseconds === milliseconds) return milliseconds; + this.logger.warn('Capping an unexpectedly large Zoom RTMS media gap', { + media, + milliseconds, + cappedAt: boundedMilliseconds, + }); + return boundedMilliseconds; + } + + private async writeSilence(milliseconds: number): Promise { + let remainingBytes = Math.round(milliseconds * AUDIO_BYTES_PER_MILLISECOND); + const silence = Buffer.alloc(64 * 1024); + while (remainingBytes > 0) { + const bytes = Math.min(remainingBytes, silence.length); + await writeStream(this.audioStream, silence.subarray(0, bytes)); + this.audioBytes += bytes; + remainingBytes -= bytes; + } + } + + private async writeBlackFrames(milliseconds: number): Promise { + const frames = Math.floor(milliseconds / VIDEO_FRAME_DURATION_MILLISECONDS); + const batchSize = 100; + const fullBatch = Buffer.concat(Array(batchSize).fill(this.blackFrame)); + let remaining = frames; + while (remaining > 0) { + const currentBatchSize = Math.min(batchSize, remaining); + const data = currentBatchSize === batchSize + ? fullBatch + : Buffer.concat(Array(currentBatchSize).fill(this.blackFrame)); + await writeStream(this.videoStream, data); + this.videoBytes += data.length; + this.videoFrames += currentBatchSize; + remaining -= currentBatchSize; + } + } + + private async flushPendingVideo(): Promise { + if (this.pendingVideoPackets.length === 0) return; + const packets = this.pendingVideoPackets.splice(0); + this.pendingVideoBytes = 0; + for (const packet of packets) { + await this.writeVideoPacket(packet.buffer, packet.timestamp); + } + } + + private async writeVideoPacket(buffer: Buffer, timestamp: number): Promise { + const expectedTimestamp = this.expectedVideoTimestamp ?? this.timelineStartTimestamp; + if ( + typeof expectedTimestamp === 'number' + && timestamp > expectedTimestamp + VIDEO_FRAME_DURATION_MILLISECONDS * 2 + ) { + await this.writeBlackFrames(this.capGap(timestamp - expectedTimestamp, 'video')); + } + await writeStream(this.videoStream, buffer); + this.videoBytes += buffer.length; + this.videoFrames += 1; + this.expectedVideoTimestamp = Math.max( + this.expectedVideoTimestamp ?? 0, + timestamp + VIDEO_FRAME_DURATION_MILLISECONDS + ); + } + + private static async createBlackFrame( + folderPath: string, + logger: Logger + ): Promise { + const filePath = path.join(folderPath, 'black-frame.h264'); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + try { + await execFileAsync('ffmpeg', [ + '-y', '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'color=c=black:s=1280x720:r=25', + '-frames:v', '1', '-c:v', 'libx264', '-profile:v', 'baseline', + '-preset', 'ultrafast', '-tune', 'zerolatency', '-f', 'h264', filePath, + ], { maxBuffer: 1024 * 1024 }); + const frame = await fs.promises.readFile(filePath); + await fs.promises.unlink(filePath); + return frame; + } catch (error) { + await fs.promises.unlink(filePath).catch(() => undefined); + logger.error('Unable to create Zoom RTMS video gap frame', { error }); + throw new Error('Unable to initialize Zoom RTMS media muxing'); + } + } + + private ffmpegArgs(): string[] { + const common = ['-y', '-hide_banner', '-loglevel', 'error']; + const output = ['-movflags', '+faststart', this.outputPath]; + + if (this.audioBytes > 0 && this.videoBytes > 0) { + return [ + ...common, + '-f', 's16le', '-ar', '16000', '-ac', '1', '-i', this.audioPath, + '-framerate', '25', '-f', 'h264', '-i', this.videoPath, + '-map', '1:v:0', '-map', '0:a:0', + '-c:v', 'copy', '-c:a', 'aac', + ...output, + ]; + } + + if (this.videoBytes > 0) { + return [ + ...common, + '-framerate', '25', '-f', 'h264', '-i', this.videoPath, + '-c:v', 'copy', + ...output, + ]; + } + + return [ + ...common, + '-f', 's16le', '-ar', '16000', '-ac', '1', '-i', this.audioPath, + '-f', 'lavfi', '-i', 'color=c=black:s=1280x720:r=25', + '-shortest', '-c:v', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', + ...output, + ]; + } +} diff --git a/src/rtms/ZoomRtmsApi.test.ts b/src/rtms/ZoomRtmsApi.test.ts new file mode 100644 index 00000000..35133749 --- /dev/null +++ b/src/rtms/ZoomRtmsApi.test.ts @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import axios from 'axios'; +import { KnownError } from '../error'; +import { ZoomRtmsApi } from './ZoomRtmsApi'; +import { ZoomRtmsApiCredentials } from './types'; + +const originalPatch = axios.patch; +const originalPost = axios.post; + +const legacyCredentials = (): ZoomRtmsApiCredentials => ({ + source: 'legacy', + customerId: 'legacy', + rtmsClientId: 'rtms-client', + oauthAccessToken: 'access-token', +}); + +const customerCredentials = ( + customerId: string, + clientId = `${customerId}-client` +): ZoomRtmsApiCredentials => ({ + source: 'customer', + customerId, + rtmsClientId: 'rtms-client', + participantUserId: `${customerId}-operator`, + oauthAccountId: `${customerId}-account`, + oauthClientId: clientId, + oauthClientSecret: `${customerId}-secret`, +}); + +test.beforeEach(() => { + ZoomRtmsApi.clearTokenCache(); +}); + +test.afterEach(() => { + axios.patch = originalPatch; + axios.post = originalPost; +}); + +const zoomError = (status: number, code?: number) => ({ + isAxiosError: true, + response: { + status, + data: { code, message: 'Zoom API error' }, + }, +}); + +test('retries transient start failures with bounded exponential backoff', async () => { + let now = 0; + let attempts = 0; + const waits: number[] = []; + const requestTimeouts: number[] = []; + const failures = [zoomError(400, 3000), zoomError(429), zoomError(503)]; + axios.patch = (async (_url, _body, requestConfig) => { + requestTimeouts.push(requestConfig?.timeout ?? 0); + const failure = failures[attempts++]; + if (failure) throw failure; + }) as typeof axios.patch; + + const api = new ZoomRtmsApi(legacyCredentials(), async (milliseconds) => { + waits.push(milliseconds); + now += milliseconds; + }, () => now); + + await api.start('123456789', 8_000); + + assert.equal(attempts, 4); + assert.deepEqual(waits, [1_000, 2_000, 4_000]); + assert.deepEqual(requestTimeouts, [8_000, 7_000, 5_000, 1_000]); +}); + +test('stops retrying when the caller timeout is reached', async () => { + let now = 0; + let attempts = 0; + const waits: number[] = []; + axios.patch = (async () => { + attempts += 1; + throw zoomError(500); + }) as typeof axios.patch; + + const api = new ZoomRtmsApi(legacyCredentials(), async (milliseconds) => { + waits.push(milliseconds); + now += milliseconds; + }, () => now); + + await assert.rejects(api.start('123456789', 2_500), KnownError); + assert.equal(attempts, 2); + assert.deepEqual(waits, [1_000, 1_500]); +}); + +test('does not retry fatal start failures or stop failures', async () => { + let attempts = 0; + let waits = 0; + axios.patch = (async () => { + attempts += 1; + throw zoomError(attempts === 1 ? 403 : 500); + }) as typeof axios.patch; + + const api = new ZoomRtmsApi(legacyCredentials(), async () => { + waits += 1; + }); + + await assert.rejects(api.start('123456789', 10_000), KnownError); + await assert.rejects(api.stop('123456789'), KnownError); + assert.equal(attempts, 2); + assert.equal(waits, 0); +}); + +test('waits for external authorization after a customer-scoped 403', async () => { + axios.patch = (async () => { + throw zoomError(403, 2308); + }) as typeof axios.patch; + + const result = await new ZoomRtmsApi({ + ...customerCredentials('team-a'), + oauthAccessToken: 'customer-access-token', + }).start( + '123456789', + 10_000 + ); + assert.deepEqual(result, { + status: 'awaiting_external_authorization', + httpStatus: 403, + }); + + await assert.rejects( + new ZoomRtmsApi(legacyCredentials()).start('123456789', 10_000), + KnownError + ); +}); + +test('keeps unsupported or disabled RTMS 403 responses terminal', async () => { + axios.patch = (async () => { + throw zoomError(403, 13273); + }) as typeof axios.patch; + + await assert.rejects( + new ZoomRtmsApi({ + ...customerCredentials('team-a'), + oauthAccessToken: 'customer-access-token', + }).start('123456789', 10_000), + KnownError + ); +}); + +test('caches S2S OAuth tokens independently by credential identity', async () => { + const oauthClients: string[] = []; + const authorizationHeaders: string[] = []; + axios.post = (async (_url, _body, requestConfig) => { + const clientId = requestConfig?.auth?.username ?? ''; + oauthClients.push(clientId); + return { data: { access_token: `token-for-${clientId}`, expires_in: 3600 } }; + }) as typeof axios.post; + axios.patch = (async (_url, _body, requestConfig) => { + authorizationHeaders.push(String(requestConfig?.headers?.Authorization)); + }) as typeof axios.patch; + + const teamA = customerCredentials('team-a'); + await new ZoomRtmsApi(teamA).start('123456789'); + await new ZoomRtmsApi(teamA).start('123456789'); + await new ZoomRtmsApi(customerCredentials('team-b')).start('123456789'); + + assert.deepEqual(oauthClients, ['team-a-client', 'team-b-client']); + assert.deepEqual(authorizationHeaders, [ + 'Bearer token-for-team-a-client', + 'Bearer token-for-team-a-client', + 'Bearer token-for-team-b-client', + ]); +}); diff --git a/src/rtms/ZoomRtmsApi.ts b/src/rtms/ZoomRtmsApi.ts new file mode 100644 index 00000000..a33e32be --- /dev/null +++ b/src/rtms/ZoomRtmsApi.ts @@ -0,0 +1,205 @@ +import axios, { AxiosError } from 'axios'; +import crypto from 'crypto'; +import { KnownError } from '../error'; +import { ZoomRtmsApiCredentials, ZoomRtmsStartResult } from './types'; + +type RtmsAction = 'start' | 'stop'; + +const REQUEST_TIMEOUT_MS = 15_000; +const START_RETRY_INITIAL_DELAY_MS = 1_000; +const START_RETRY_MAX_DELAY_MS = 10_000; + +interface CachedToken { + value: string; + expiresAt: number; +} + +interface ZoomApiErrorBody { + code?: number; + message?: string; + reason?: string; +} + +const asAxiosError = (error: unknown): AxiosError | undefined => + axios.isAxiosError(error) ? error : undefined; + +export class ZoomRtmsApi { + private static readonly tokenCache = new Map(); + + constructor( + private readonly credentials: ZoomRtmsApiCredentials, + private readonly wait: (milliseconds: number) => Promise = + (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + private readonly now: () => number = Date.now + ) {} + + static clearTokenCache(): void { + this.tokenCache.clear(); + } + + async start(meetingId: string, retryTimeoutMs = 0): Promise { + const retryWindowMs = Number.isFinite(retryTimeoutMs) + ? Math.max(0, retryTimeoutMs) + : 0; + const deadline = this.now() + retryWindowMs; + let retryDelayMs = START_RETRY_INITIAL_DELAY_MS; + + while (true) { + try { + const remainingMs = deadline - this.now(); + const requestTimeoutMs = retryWindowMs > 0 + ? Math.max(1, Math.min(REQUEST_TIMEOUT_MS, remainingMs)) + : REQUEST_TIMEOUT_MS; + await this.sendStatusRequest(meetingId, 'start', requestTimeoutMs); + return { status: 'requested' }; + } catch (error: unknown) { + if (this.isAwaitingExternalAuthorization(error)) { + return { status: 'awaiting_external_authorization', httpStatus: 403 }; + } + const remainingMs = deadline - this.now(); + if (!this.isRetryableStartError(error) || remainingMs <= 0) { + throw this.toKnownError(error, 'start'); + } + + await this.wait(Math.min(retryDelayMs, remainingMs)); + if (this.now() >= deadline) { + throw this.toKnownError(error, 'start'); + } + retryDelayMs = Math.min(retryDelayMs * 2, START_RETRY_MAX_DELAY_MS); + } + } + } + + async stop(meetingId: string): Promise { + try { + await this.sendStatusRequest(meetingId, 'stop', REQUEST_TIMEOUT_MS); + } catch (error: unknown) { + throw this.toKnownError(error, 'stop'); + } + } + + private async sendStatusRequest( + meetingId: string, + action: RtmsAction, + timeout: number + ): Promise { + const clientId = this.credentials.rtmsClientId; + if (!clientId) { + throw new KnownError('ZOOM_RTMS_CLIENT_ID is required for RTMS', false, 0); + } + + const accessToken = await this.getAccessToken(); + const settings: { client_id: string; participant_user_id?: string } = { + client_id: clientId, + }; + if (this.credentials.participantUserId) { + settings.participant_user_id = this.credentials.participantUserId; + } + + await axios.patch( + `https://api.zoom.us/v2/live_meetings/${encodeURIComponent(meetingId)}/rtms_app/status`, + { action, settings }, + { + headers: { Authorization: `Bearer ${accessToken}` }, + timeout, + } + ); + } + + private isRetryableStartError(error: unknown): boolean { + const axiosError = asAxiosError(error); + if (!axiosError) return false; + const status = Number(axiosError.response?.status); + const code = Number(axiosError.response?.data?.code); + return !axiosError.response + || code === 3000 + || status === 429 + || (status >= 500 && status < 600); + } + + private isAwaitingExternalAuthorization(error: unknown): boolean { + const axiosError = asAxiosError(error); + const code = Number(axiosError?.response?.data?.code); + return this.credentials.source === 'customer' + && axiosError?.response?.status === 403 + && (code === 2308 || code === 2309); + } + + private toKnownError(error: unknown, action: RtmsAction): KnownError { + if (error instanceof KnownError) return error; + + const axiosError = asAxiosError(error); + const status = axiosError?.response?.status; + const message = axiosError?.response?.data?.message + || (error instanceof Error ? error.message : undefined) + || 'Unknown Zoom API error'; + return new KnownError( + `Zoom RTMS ${action} failed${status ? ` (${status})` : ''}: ${message}`, + false, + 0 + ); + } + + private async getAccessToken(): Promise { + if (this.credentials.oauthAccessToken) { + return this.credentials.oauthAccessToken; + } + + const cacheKey = this.tokenCacheKey(); + const cachedToken = ZoomRtmsApi.tokenCache.get(cacheKey); + if (cachedToken && cachedToken.expiresAt > this.now()) { + return cachedToken.value; + } + + const { oauthAccountId, oauthClientId, oauthClientSecret } = this.credentials; + if (!oauthAccountId || !oauthClientId || !oauthClientSecret) { + throw new KnownError( + 'Configure ZOOM_RTMS_OAUTH_ACCESS_TOKEN or the ZOOM_RTMS_OAUTH_ACCOUNT_ID/CLIENT_ID/CLIENT_SECRET settings', + false, + 0 + ); + } + + try { + const response = await axios.post<{ access_token: string; expires_in?: number }>( + 'https://zoom.us/oauth/token', + undefined, + { + auth: { username: oauthClientId, password: oauthClientSecret }, + params: { + grant_type: 'account_credentials', + account_id: oauthAccountId, + }, + timeout: 15_000, + } + ); + const expiresIn = Math.max(60, response.data.expires_in ?? 3600); + const cachedToken = { + value: response.data.access_token, + expiresAt: this.now() + (expiresIn - 30) * 1000, + }; + ZoomRtmsApi.tokenCache.set(cacheKey, cachedToken); + return cachedToken.value; + } catch (error: unknown) { + const axiosError = asAxiosError(error); + const status = axiosError?.response?.status; + const message = axiosError?.response?.data?.reason + || (error instanceof Error ? error.message : undefined) + || 'Unknown OAuth error'; + throw new KnownError( + `Unable to obtain Zoom RTMS OAuth token${status ? ` (${status})` : ''}: ${message}`, + false, + 0 + ); + } + } + + private tokenCacheKey(): string { + const identity = [ + this.credentials.oauthAccountId, + this.credentials.oauthClientId, + this.credentials.oauthClientSecret, + ].join('\0'); + return crypto.createHash('sha256').update(identity).digest('hex'); + } +} diff --git a/src/rtms/ZoomRtmsCredentials.test.ts b/src/rtms/ZoomRtmsCredentials.test.ts new file mode 100644 index 00000000..1f72b754 --- /dev/null +++ b/src/rtms/ZoomRtmsCredentials.test.ts @@ -0,0 +1,342 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import config, { + deriveZoomRtmsTransportStrategy, + parseZoomRtmsCustomerCredentials, + ZoomRtmsCustomerCredentials, +} from '../config'; +import { ZoomRtmsCredentialsMissingError } from '../error'; +import { + resolveZoomRtmsCredentials, + ZoomRtmsCredentialConfigurationError, +} from './ZoomRtmsCredentials'; + +const original = { + transport: config.zoomRecordingTransport, + rtmsClientId: config.zoomRtms.clientId, + rtmsClientSecret: config.zoomRtms.clientSecret, + rtmsWebhookSecret: config.zoomRtms.webhookSecret, + oauthAccessToken: config.zoomRtms.oauthAccessToken, + oauthAccountId: config.zoomRtms.oauthAccountId, + oauthClientId: config.zoomRtms.oauthClientId, + oauthClientSecret: config.zoomRtms.oauthClientSecret, + participantUserId: config.zoomRtms.participantUserId, + globalTeamId: config.zoomRtms.globalTeamId, + customerCredentials: config.zoomRtms.customerCredentials, + customerCredentialErrors: config.zoomRtms.customerCredentialErrors, + customerCredentialsError: config.zoomRtms.customerCredentialsError, +}; + +const customer = (enabled = true): ZoomRtmsCustomerCredentials => ({ + enabled, + accountId: 'account-a', + clientId: 'oauth-client-a', + clientSecret: 'oauth-secret-a', + participantUserId: 'operator-a', +}); + +test.beforeEach(() => { + config.zoomRecordingTransport = 'browser'; + config.zoomRtms.clientId = 'rtms-client'; + config.zoomRtms.clientSecret = 'rtms-secret'; + config.zoomRtms.webhookSecret = 'webhook-secret'; + config.zoomRtms.oauthAccessToken = undefined; + config.zoomRtms.oauthAccountId = undefined; + config.zoomRtms.oauthClientId = undefined; + config.zoomRtms.oauthClientSecret = undefined; + config.zoomRtms.participantUserId = undefined; + config.zoomRtms.globalTeamId = undefined; + config.zoomRtms.customerCredentials = Object.create(null); + config.zoomRtms.customerCredentialErrors = Object.create(null); + config.zoomRtms.customerCredentialsError = undefined; +}); + +test.after(() => { + config.zoomRecordingTransport = original.transport; + config.zoomRtms.clientId = original.rtmsClientId; + config.zoomRtms.clientSecret = original.rtmsClientSecret; + config.zoomRtms.webhookSecret = original.rtmsWebhookSecret; + config.zoomRtms.oauthAccessToken = original.oauthAccessToken; + config.zoomRtms.oauthAccountId = original.oauthAccountId; + config.zoomRtms.oauthClientId = original.oauthClientId; + config.zoomRtms.oauthClientSecret = original.oauthClientSecret; + config.zoomRtms.participantUserId = original.participantUserId; + config.zoomRtms.globalTeamId = original.globalTeamId; + config.zoomRtms.customerCredentials = original.customerCredentials; + config.zoomRtms.customerCredentialErrors = original.customerCredentialErrors; + config.zoomRtms.customerCredentialsError = original.customerCredentialsError; +}); + +test('parses customer credential JSON without exposing secret values in errors', () => { + const parsed = parseZoomRtmsCustomerCredentials(JSON.stringify({ + 'team-a': customer(), + 'team-b': { enabled: true, clientSecret: 'must-not-appear' }, + })); + + assert.deepEqual({ ...parsed.credentials['team-a'] }, customer()); + assert.match(parsed.entryErrors['team-b'], /accountId/); + assert.equal(parsed.entryErrors['team-b'].includes('must-not-appear'), false); + + const malformed = parseZoomRtmsCustomerCredentials('{secret-value'); + assert.match(malformed.error ?? '', /valid JSON/); + assert.equal(malformed.error?.includes('secret-value'), false); +}); + +test('parses complete dedicated RTMS app credentials', () => { + const dedicated = { + ...customer(), + rtmsApp: { + webhookId: 'customer-app-a', + clientId: 'dedicated-rtms-client', + clientSecret: 'dedicated-rtms-secret', + webhookSecret: 'dedicated-webhook-secret', + }, + }; + const parsed = parseZoomRtmsCustomerCredentials(JSON.stringify({ + 'team-a': dedicated, + })); + + assert.deepEqual({ ...parsed.credentials['team-a'] }, dedicated); + assert.deepEqual({ ...parsed.entryErrors }, {}); +}); + +test('enforces the configured customer credential mode', () => { + const dedicated = { + ...customer(), + rtmsApp: { + webhookId: 'customer-app-a', + clientId: 'dedicated-rtms-client', + clientSecret: 'dedicated-rtms-secret', + webhookSecret: 'dedicated-webhook-secret', + }, + }; + + const dedicatedOnly = parseZoomRtmsCustomerCredentials(JSON.stringify({ + shared: customer(), + dedicated, + }), 'dedicated_customer'); + assert.equal(dedicatedOnly.credentials.shared, undefined); + assert.deepEqual({ ...dedicatedOnly.credentials.dedicated }, dedicated); + assert.match(dedicatedOnly.entryErrors.shared, /rtmsApp is required/); + + const sharedOnly = parseZoomRtmsCustomerCredentials(JSON.stringify({ + shared: customer(), + dedicated, + }), 'shared_customer'); + assert.deepEqual({ ...sharedOnly.credentials.shared }, customer()); + assert.equal(sharedOnly.credentials.dedicated, undefined); + assert.match(sharedOnly.entryErrors.dedicated, /rtmsApp is not allowed/); +}); + +test('rejects partial, unsafe, and duplicate dedicated app settings without leaking secrets', () => { + const secret = 'must-never-appear'; + const parsed = parseZoomRtmsCustomerCredentials(JSON.stringify({ + partial: { + ...customer(), + rtmsApp: { + webhookId: 'partial-app', + clientId: 'dedicated-client', + clientSecret: secret, + }, + }, + unsafe: { + ...customer(), + rtmsApp: { + webhookId: '../unsafe', + clientId: 'dedicated-client', + clientSecret: secret, + webhookSecret: secret, + }, + }, + duplicateA: { + ...customer(), + rtmsApp: { + webhookId: 'duplicate-app', + clientId: 'dedicated-client-a', + clientSecret: secret, + webhookSecret: secret, + }, + }, + duplicateB: { + ...customer(), + rtmsApp: { + webhookId: 'duplicate-app', + clientId: 'dedicated-client-b', + clientSecret: secret, + webhookSecret: secret, + }, + }, + })); + + assert.equal(parsed.credentials.partial, undefined); + assert.equal(parsed.credentials.unsafe, undefined); + assert.equal(parsed.credentials.duplicateA, undefined); + assert.equal(parsed.credentials.duplicateB, undefined); + assert.match(parsed.entryErrors.partial, /rtmsApp\.webhookSecret/); + assert.match(parsed.entryErrors.unsafe, /rtmsApp\.webhookId/); + assert.match(parsed.entryErrors.duplicateA, /unique/); + assert.match(parsed.entryErrors.duplicateB, /unique/); + assert.equal(JSON.stringify(parsed.entryErrors).includes(secret), false); +}); + +test('derives browser-only, fallback, and forced RTMS strategies', () => { + assert.equal(deriveZoomRtmsTransportStrategy('browser', false), 'browser_only'); + assert.equal(deriveZoomRtmsTransportStrategy('browser', true), 'browser_then_rtms'); + assert.equal(deriveZoomRtmsTransportStrategy('rtms', false), 'rtms_only'); + assert.equal(deriveZoomRtmsTransportStrategy('rtms', true), 'rtms_only'); +}); + +test('selects enabled team credentials for browser fallback', () => { + config.zoomRtms.customerCredentials['team-a'] = customer(); + + const selected = resolveZoomRtmsCredentials('team-a'); + assert.equal(selected.api.source, 'customer'); + assert.equal(selected.api.oauthAccountId, 'account-a'); + assert.equal(selected.api.oauthClientId, 'oauth-client-a'); + assert.equal(selected.credentialMode, 'shared_customer'); + assert.equal(selected.app.appId, 'global'); + assert.equal(selected.app.clientId, 'rtms-client'); + assert.equal(selected.eventScope.customerId, 'team-a'); + assert.equal(selected.eventScope.appId, 'global'); + assert.equal(selected.eventScope.operatorId, 'operator-a'); +}); + +test('selects dedicated RTMS app and customer OAuth credentials together', () => { + config.zoomRtms.customerCredentials['team-a'] = { + ...customer(), + rtmsApp: { + webhookId: 'customer-app-a', + clientId: 'dedicated-rtms-client', + clientSecret: 'dedicated-rtms-secret', + webhookSecret: 'dedicated-webhook-secret', + }, + }; + + const selected = resolveZoomRtmsCredentials('team-a'); + assert.equal(selected.credentialMode, 'dedicated_customer'); + assert.equal(selected.app.appId, 'customer-app-a'); + assert.equal(selected.app.clientId, 'dedicated-rtms-client'); + assert.equal(selected.app.clientSecret, 'dedicated-rtms-secret'); + assert.equal(selected.app.webhookSecret, 'dedicated-webhook-secret'); + assert.equal(selected.api.rtmsClientId, 'dedicated-rtms-client'); + assert.equal(selected.api.oauthClientId, 'oauth-client-a'); + assert.equal(selected.eventScope.appId, 'customer-app-a'); +}); + +test('dedicated customer apps do not depend on global RTMS app credentials', () => { + config.zoomRtms.clientId = undefined; + config.zoomRtms.clientSecret = undefined; + config.zoomRtms.webhookSecret = undefined; + config.zoomRtms.customerCredentials['dedicated-team'] = { + ...customer(), + rtmsApp: { + webhookId: 'dedicated-app', + clientId: 'dedicated-client', + clientSecret: 'dedicated-secret', + webhookSecret: 'dedicated-webhook-secret', + }, + }; + config.zoomRtms.customerCredentials['shared-team'] = customer(); + + assert.equal( + resolveZoomRtmsCredentials('dedicated-team').credentialMode, + 'dedicated_customer' + ); + assert.throws( + () => resolveZoomRtmsCredentials('shared-team'), + (error: unknown) => error instanceof ZoomRtmsCredentialConfigurationError + && error.reason === 'invalid_global_configuration' + ); +}); + +test('fails typed for missing, disabled, and invalid fallback credentials', () => { + assert.throws( + () => resolveZoomRtmsCredentials('missing-team'), + ZoomRtmsCredentialsMissingError + ); + + config.zoomRtms.customerCredentials['disabled-team'] = customer(false); + assert.throws( + () => resolveZoomRtmsCredentials('disabled-team'), + (error: unknown) => error instanceof ZoomRtmsCredentialConfigurationError + && error.reason === 'disabled' + ); + + config.zoomRtms.customerCredentialErrors['invalid-team'] = 'invalid fields'; + assert.throws( + () => resolveZoomRtmsCredentials('invalid-team'), + (error: unknown) => error instanceof ZoomRtmsCredentialConfigurationError + && error.reason === 'invalid_customer_configuration' + ); +}); + +test('uses global OAuth only for the explicitly configured internal team', () => { + config.zoomRtms.globalTeamId = 'internal-team'; + config.zoomRtms.oauthAccessToken = 'legacy-access-token'; + config.zoomRtms.participantUserId = 'legacy-operator'; + + const selected = resolveZoomRtmsCredentials('internal-team'); + assert.equal(selected.api.source, 'legacy'); + assert.equal(selected.credentialMode, 'internal'); + assert.equal(selected.app.appId, 'global'); + assert.equal(selected.api.oauthAccessToken, 'legacy-access-token'); + assert.equal(selected.eventScope.appId, 'global'); + assert.equal(selected.eventScope.customerId, 'internal-team'); + assert.equal(selected.eventScope.operatorId, 'legacy-operator'); + + assert.throws( + () => resolveZoomRtmsCredentials('team-without-mapping'), + ZoomRtmsCredentialsMissingError + ); +}); + +test('never falls back to global OAuth for disabled or invalid customer credentials', () => { + config.zoomRtms.globalTeamId = 'internal-team'; + config.zoomRtms.oauthAccessToken = 'legacy-access-token'; + config.zoomRtms.customerCredentials['internal-team'] = customer(false); + + assert.throws( + () => resolveZoomRtmsCredentials('internal-team'), + (error: unknown) => error instanceof ZoomRtmsCredentialConfigurationError + && error.reason === 'disabled' + ); + + delete config.zoomRtms.customerCredentials['internal-team']; + config.zoomRtms.customerCredentialErrors['internal-team'] = 'invalid fields'; + assert.throws( + () => resolveZoomRtmsCredentials('internal-team'), + (error: unknown) => error instanceof ZoomRtmsCredentialConfigurationError + && error.reason === 'invalid_customer_configuration' + ); + + config.zoomRtms.customerCredentialErrors = Object.create(null); + config.zoomRtms.customerCredentialsError = 'invalid JSON'; + assert.throws( + () => resolveZoomRtmsCredentials('internal-team'), + (error: unknown) => error instanceof ZoomRtmsCredentialConfigurationError + && error.reason === 'invalid_customer_configuration' + ); +}); + +test('supports internal and customer credentials in the same deployment', () => { + config.zoomRtms.globalTeamId = 'internal-team'; + config.zoomRtms.oauthAccessToken = 'legacy-access-token'; + config.zoomRtms.customerCredentials['customer-team'] = customer(); + + assert.equal(resolveZoomRtmsCredentials('internal-team').api.source, 'legacy'); + assert.equal(resolveZoomRtmsCredentials('customer-team').api.source, 'customer'); + assert.throws( + () => resolveZoomRtmsCredentials('unknown-team'), + ZoomRtmsCredentialsMissingError + ); +}); + +test('prefers an enabled exact customer mapping over global credentials', () => { + config.zoomRtms.globalTeamId = 'shared-team'; + config.zoomRtms.oauthAccessToken = 'legacy-access-token'; + config.zoomRtms.customerCredentials['shared-team'] = customer(); + + const selected = resolveZoomRtmsCredentials('shared-team'); + assert.equal(selected.api.source, 'customer'); + assert.equal(selected.api.oauthAccountId, 'account-a'); +}); diff --git a/src/rtms/ZoomRtmsCredentials.ts b/src/rtms/ZoomRtmsCredentials.ts new file mode 100644 index 00000000..a501e1bb --- /dev/null +++ b/src/rtms/ZoomRtmsCredentials.ts @@ -0,0 +1,155 @@ +import config from '../config'; +import { KnownError, ZoomRtmsCredentialsMissingError } from '../error'; +import { + ZoomRtmsApiCredentials, + ZoomRtmsAppCredentials, + ZoomRtmsCredentialMode, + ZoomRtmsEventScope, +} from './types'; + +export type ZoomRtmsCredentialErrorReason = + | 'disabled' + | 'invalid_customer_configuration' + | 'invalid_global_configuration'; + +export class ZoomRtmsCredentialConfigurationError extends KnownError { + constructor( + public readonly reason: ZoomRtmsCredentialErrorReason, + public readonly teamId: string, + message: string + ) { + super(message, false, 0); + this.name = 'ZoomRtmsCredentialConfigurationError'; + } +} + +export interface ResolvedZoomRtmsCredentials { + credentialMode: ZoomRtmsCredentialMode; + app: ZoomRtmsAppCredentials; + api: ZoomRtmsApiCredentials; + eventScope: ZoomRtmsEventScope; +} + +const globalAppCredentials = (teamId: string): ZoomRtmsAppCredentials => { + const clientId = config.zoomRtms.clientId; + const clientSecret = config.zoomRtms.clientSecret; + const webhookSecret = config.zoomRtms.webhookSecret; + if (!clientId || !clientSecret || !webhookSecret) { + throw new ZoomRtmsCredentialConfigurationError( + 'invalid_global_configuration', + teamId, + 'ZOOM_RTMS_CLIENT_ID, ZOOM_RTMS_CLIENT_SECRET and ZOOM_RTMS_WEBHOOK_SECRET are required for the global RTMS app' + ); + } + + return { + appId: 'global', + clientId, + clientSecret, + webhookSecret, + }; +}; + +const globalCredentials = (teamId: string): ResolvedZoomRtmsCredentials => { + const app = globalAppCredentials(teamId); + + const hasAccessToken = Boolean(config.zoomRtms.oauthAccessToken); + const hasServerCredentials = Boolean( + config.zoomRtms.oauthAccountId + && config.zoomRtms.oauthClientId + && config.zoomRtms.oauthClientSecret + ); + if (!hasAccessToken && !hasServerCredentials) { + throw new ZoomRtmsCredentialConfigurationError( + 'invalid_global_configuration', + teamId, + 'Configure ZOOM_RTMS_OAUTH_ACCESS_TOKEN or the global Zoom RTMS OAuth account credentials' + ); + } + + return { + credentialMode: 'internal', + app, + api: { + source: 'legacy', + customerId: teamId, + rtmsClientId: app.clientId, + participantUserId: config.zoomRtms.participantUserId, + oauthAccessToken: config.zoomRtms.oauthAccessToken, + oauthAccountId: config.zoomRtms.oauthAccountId, + oauthClientId: config.zoomRtms.oauthClientId, + oauthClientSecret: config.zoomRtms.oauthClientSecret, + }, + eventScope: { + appId: app.appId, + customerId: teamId, + operatorId: config.zoomRtms.participantUserId, + }, + }; +}; + +export const resolveZoomRtmsCredentials = ( + teamId: string +): ResolvedZoomRtmsCredentials => { + const customer = config.zoomRtms.customerCredentials[teamId]; + if (customer?.enabled) { + const app: ZoomRtmsAppCredentials = customer.rtmsApp + ? { + appId: customer.rtmsApp.webhookId, + clientId: customer.rtmsApp.clientId, + clientSecret: customer.rtmsApp.clientSecret, + webhookSecret: customer.rtmsApp.webhookSecret, + } + : globalAppCredentials(teamId); + + return { + credentialMode: customer.rtmsApp ? 'dedicated_customer' : 'shared_customer', + app, + api: { + source: 'customer', + customerId: teamId, + rtmsClientId: app.clientId, + participantUserId: customer.participantUserId, + oauthAccountId: customer.accountId, + oauthClientId: customer.clientId, + oauthClientSecret: customer.clientSecret, + }, + eventScope: { + appId: app.appId, + customerId: teamId, + operatorId: customer.participantUserId, + }, + }; + } + + if (config.zoomRtms.customerCredentialsError) { + throw new ZoomRtmsCredentialConfigurationError( + 'invalid_customer_configuration', + teamId, + config.zoomRtms.customerCredentialsError + ); + } + + const entryError = config.zoomRtms.customerCredentialErrors[teamId]; + if (entryError) { + throw new ZoomRtmsCredentialConfigurationError( + 'invalid_customer_configuration', + teamId, + `Zoom RTMS credentials for team ${teamId} are invalid: ${entryError}` + ); + } + + if (customer && !customer.enabled) { + throw new ZoomRtmsCredentialConfigurationError( + 'disabled', + teamId, + `Zoom RTMS fallback is disabled for team ${teamId}` + ); + } + + if (config.zoomRtms.globalTeamId === teamId) { + return globalCredentials(teamId); + } + + throw new ZoomRtmsCredentialsMissingError(teamId); +}; diff --git a/src/rtms/ZoomRtmsEventStore.test.ts b/src/rtms/ZoomRtmsEventStore.test.ts new file mode 100644 index 00000000..abe32403 --- /dev/null +++ b/src/rtms/ZoomRtmsEventStore.test.ts @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import config from '../config'; +import { ZoomRtmsEventStore } from './ZoomRtmsEventStore'; +import { ZoomRtmsEventScope, ZoomRtmsWebhookEvent } from './types'; + +const originalRedisEnabled = config.isRedisEnabled; + +test.beforeEach(() => { + config.isRedisEnabled = false; +}); + +test.afterEach(() => { + config.isRedisEnabled = originalRedisEnabled; +}); + +const scope = ( + customerId: string, + operatorId: string, + appId = 'global' +): ZoomRtmsEventScope => ({ + appId, + customerId, + operatorId, +}); + +const startEvent = ( + eventTs: number, + operatorId = 'operator-id', + streamId = 'stream-id' +): ZoomRtmsWebhookEvent => ({ + event: 'meeting.rtms_started', + event_ts: eventTs, + payload: { + meeting_uuid: 'meeting-uuid', + meeting_id: '12345678901', + operator_id: operatorId, + rtms_stream_id: streamId, + server_urls: 'wss://rtms.zoom.us', + }, +}); + +test('routes initial and recovery start events to their respective queues', async () => { + const store = new ZoomRtmsEventStore(); + const eventScope = scope('team-a', 'operator-id'); + await store.reserveMeeting('12345678901', 'owner-a', 60, eventScope); + const initial = startEvent(1); + + assert.equal(await store.publish(initial), true); + assert.equal(await store.publish(initial), false); + assert.deepEqual( + await store.waitForMeetingStart('12345678901', eventScope, 1), + initial + ); + + await store.markStreamActive('stream-id', eventScope); + const recovery = startEvent(2); + assert.equal(await store.publish(recovery), true); + assert.deepEqual( + await store.waitForStreamEvent('stream-id', eventScope, 1), + recovery + ); +}); + +test('releases meeting reservations only for their owner', async () => { + const store = new ZoomRtmsEventStore(); + const eventScope = scope('team-a', 'operator-id'); + + assert.equal(await store.reserveMeeting('12345678901', 'owner-a', 60, eventScope), true); + assert.equal(await store.reserveMeeting('12345678901', 'owner-b', 60, eventScope), false); + + await store.releaseMeeting('12345678901', 'owner-b', eventScope); + assert.equal(await store.reserveMeeting('12345678901', 'owner-b', 60, eventScope), false); + + await store.releaseMeeting('12345678901', 'owner-a', eventScope); + assert.equal(await store.reserveMeeting('12345678901', 'owner-b', 60, eventScope), true); +}); + +test('isolates initial events for parallel tenants by operator and customer', async () => { + const store = new ZoomRtmsEventStore(); + const teamA = scope('team-a', 'operator-a'); + const teamB = scope('team-b', 'operator-b'); + await store.reserveMeeting('12345678901', 'owner-a', 60, teamA); + await store.reserveMeeting('12345678901', 'owner-b', 60, teamB); + + const eventA = startEvent(10, 'operator-a', 'stream-a'); + const eventB = startEvent(11, 'operator-b', 'stream-b'); + await store.publish(eventA); + await store.publish(eventB); + + assert.deepEqual( + await store.waitForMeetingStart('12345678901', teamA, 1), + eventA + ); + assert.deepEqual( + await store.waitForMeetingStart('12345678901', teamB, 1), + eventB + ); +}); + +test('allows only one customer reservation for the same meeting and Zoom operator', async () => { + const store = new ZoomRtmsEventStore(); + const teamA = scope('team-a', 'shared-operator'); + const teamB = scope('team-b', 'shared-operator'); + + assert.equal(await store.reserveMeeting('12345678901', 'owner-a', 60, teamA), true); + assert.equal(await store.reserveMeeting('12345678901', 'owner-b', 60, teamB), false); + + const event = startEvent(12, 'shared-operator', 'shared-stream'); + await store.publish(event); + assert.deepEqual(await store.waitForMeetingStart('12345678901', teamA, 1), event); +}); + +test('isolates identical events and reservations across dedicated apps', async () => { + const store = new ZoomRtmsEventStore(); + const teamA = scope('team-a', 'shared-operator', 'app-a'); + const teamB = scope('team-b', 'shared-operator', 'app-b'); + + assert.equal(await store.reserveMeeting('12345678901', 'owner-a', 60, teamA), true); + assert.equal(await store.reserveMeeting('12345678901', 'owner-b', 60, teamB), true); + + const event = startEvent(13, 'shared-operator', 'shared-stream'); + assert.equal(await store.publish(event, 'app-a'), true); + assert.equal(await store.publish(event, 'app-b'), true); + assert.deepEqual(await store.waitForMeetingStart('12345678901', teamA, 1), event); + assert.deepEqual(await store.waitForMeetingStart('12345678901', teamB, 1), event); +}); + +test('moves early stream stop events into the owning customer queue', async () => { + const store = new ZoomRtmsEventStore(); + const eventScope = scope('team-a', 'operator-id'); + const stopped: ZoomRtmsWebhookEvent = { + event: 'meeting.rtms_stopped', + event_ts: 12, + payload: { + meeting_uuid: 'meeting-uuid', + rtms_stream_id: 'stream-id', + stop_reason: 6, + }, + }; + + await store.publish(stopped); + await store.markStreamActive('stream-id', eventScope); + assert.deepEqual( + await store.waitForStreamEvent('stream-id', eventScope, 1), + stopped + ); +}); + +test('prevents another customer from taking ownership of an active stream', async () => { + const store = new ZoomRtmsEventStore(); + await store.markStreamActive('stream-id', scope('team-a', 'operator-a')); + + await assert.rejects( + store.markStreamActive('stream-id', scope('team-b', 'operator-b')), + /already owned by another customer/ + ); + + await store.markStreamActive('stream-id', scope('team-b', 'operator-b', 'dedicated-app')); +}); + +test('keeps pending stream events isolated by app', async () => { + const store = new ZoomRtmsEventStore(); + const teamA = scope('team-a', 'operator-id', 'app-a'); + const teamB = scope('team-b', 'operator-id', 'app-b'); + const stopped: ZoomRtmsWebhookEvent = { + event: 'meeting.rtms_stopped', + event_ts: 14, + payload: { + meeting_uuid: 'meeting-uuid', + rtms_stream_id: 'shared-stream-id', + stop_reason: 6, + }, + }; + + await store.publish(stopped, 'app-a'); + await store.markStreamActive('shared-stream-id', teamB); + await store.markStreamActive('shared-stream-id', teamA); + assert.deepEqual( + await store.waitForStreamEvent('shared-stream-id', teamA, 1), + stopped + ); +}); diff --git a/src/rtms/ZoomRtmsEventStore.ts b/src/rtms/ZoomRtmsEventStore.ts new file mode 100644 index 00000000..2c1edc5b --- /dev/null +++ b/src/rtms/ZoomRtmsEventStore.ts @@ -0,0 +1,459 @@ +import crypto from 'crypto'; +import { createClient } from 'redis'; +import config from '../config'; +import { normalizeZoomMeetingId } from './utils'; +import { ZoomRtmsEventScope, ZoomRtmsWebhookEvent } from './types'; + +type RedisClient = ReturnType; +type LocalWaiter = (event: ZoomRtmsWebhookEvent | null) => void; + +const PREFIX = 'meeting-bot:zoom:rtms'; + +export class ZoomRtmsEventStore { + private commandClient?: RedisClient; + private blockingClient?: RedisClient; + private connectPromise?: Promise; + private readonly localQueues = new Map(); + private readonly localWaiters = new Map(); + private readonly localActiveStreams = new Map(); + private readonly localDedupe = new Set(); + private readonly localReservations = new Map(); + private readonly localRoutes = new Map>(); + + private digest(value: string): string { + return crypto.createHash('sha256').update(value).digest('hex'); + } + + private operatorId(scope: ZoomRtmsEventScope): string { + return scope.operatorId || '*'; + } + + private appId(scope: ZoomRtmsEventScope): string { + return scope.appId || 'global'; + } + + private appKeySegment(appId: string): string { + return appId === 'global' ? '' : `:app:${this.digest(appId)}`; + } + + private operatorRoutesKey(meetingId: string, appId: string, operatorId: string): string { + return `${PREFIX}:meeting:${normalizeZoomMeetingId(meetingId)}${this.appKeySegment(appId)}:operator:${this.digest(operatorId)}:routes`; + } + + private meetingQueue( + meetingId: string, + appId: string, + operatorId: string, + customerDigest: string + ): string { + return `${PREFIX}:meeting:${normalizeZoomMeetingId(meetingId)}${this.appKeySegment(appId)}:operator:${this.digest(operatorId)}:customer:${customerDigest}:events`; + } + + private streamQueue(streamId: string, appId: string, customerDigest: string): string { + return `${PREFIX}:stream:${streamId}${this.appKeySegment(appId)}:customer:${customerDigest}:events`; + } + + private pendingStreamQueue(streamId: string, appId: string): string { + return `${PREFIX}:stream:${streamId}${this.appKeySegment(appId)}:pending-events`; + } + + private activeStreamKey(streamId: string, appId: string): string { + return `${PREFIX}:stream:${streamId}${this.appKeySegment(appId)}:active`; + } + + private reservationKey(meetingId: string, scope: ZoomRtmsEventScope): string { + return `${PREFIX}:meeting:${normalizeZoomMeetingId(meetingId)}${this.appKeySegment(this.appId(scope))}:operator:${this.digest(this.operatorId(scope))}:owner`; + } + + private dedupeKey(event: ZoomRtmsWebhookEvent, appId: string): string { + const identity = [ + ...(appId === 'global' ? [] : [appId]), + event.event, + event.event_ts ?? '', + event.payload.meeting_uuid, + event.payload.rtms_stream_id, + event.payload.server_urls ?? '', + event.payload.stop_reason ?? '', + ].join(':'); + const digest = crypto + .createHash('sha256') + .update(identity) + .digest('hex'); + return `${PREFIX}:event:${digest}`; + } + + private async connect(): Promise { + if (!config.isRedisEnabled) return; + if (this.commandClient?.isReady && this.blockingClient?.isReady) return; + + if (!this.connectPromise) { + this.commandClient = createClient({ url: config.redisUri, name: 'zoom-rtms-events' }); + this.blockingClient = createClient({ url: config.redisUri, name: 'zoom-rtms-blocking' }); + this.commandClient.on('error', (error) => console.error('Zoom RTMS Redis error', error)); + this.blockingClient.on('error', (error) => console.error('Zoom RTMS blocking Redis error', error)); + this.connectPromise = Promise.all([ + this.commandClient.connect(), + this.blockingClient.connect(), + ]).then(() => undefined).catch(async (error) => { + const openClients = [this.commandClient, this.blockingClient] + .filter((client): client is RedisClient => Boolean(client?.isOpen)); + await Promise.all(openClients.map((client) => client.disconnect())); + this.commandClient = undefined; + this.blockingClient = undefined; + this.connectPromise = undefined; + throw error; + }); + } + + await this.connectPromise; + } + + private getCommandClient(): RedisClient { + if (!this.commandClient) throw new Error('Zoom RTMS Redis client is unavailable'); + return this.commandClient; + } + + private getBlockingClient(): RedisClient { + if (!this.blockingClient) throw new Error('Zoom RTMS blocking Redis client is unavailable'); + return this.blockingClient; + } + + async publish(event: ZoomRtmsWebhookEvent, appId = 'global'): Promise { + const streamId = event.payload.rtms_stream_id; + if (!streamId) throw new Error('RTMS webhook is missing rtms_stream_id'); + + if (!config.isRedisEnabled) { + return this.publishLocally(event, appId); + } + + await this.connect(); + const client = this.getCommandClient(); + const ttl = config.zoomRtms.eventTtlSeconds; + let queueKeys: string[]; + if (event.event === 'meeting.rtms_started') { + const meetingId = normalizeZoomMeetingId(event.payload.meeting_id ?? ''); + const activeCustomer = await client.get(this.activeStreamKey(streamId, appId)); + queueKeys = activeCustomer + ? [this.streamQueue(streamId, appId, activeCustomer)] + : await this.meetingQueuesForStart( + meetingId, + String(event.payload.operator_id ?? ''), + appId + ); + } else { + const activeCustomer = await client.get(this.activeStreamKey(streamId, appId)); + queueKeys = [activeCustomer + ? this.streamQueue(streamId, appId, activeCustomer) + : this.pendingStreamQueue(streamId, appId)]; + } + + if (queueKeys.length === 0) return true; + + const published = await client.sendCommand([ + 'EVAL', + 'if redis.call("SET", KEYS[1], "1", "EX", ARGV[2], "NX") then for i = 2, #KEYS do redis.call("RPUSH", KEYS[i], ARGV[1]); redis.call("EXPIRE", KEYS[i], ARGV[2]); end; return 1; end; return 0;', + String(queueKeys.length + 1), + this.dedupeKey(event, appId), + ...queueKeys, + JSON.stringify(event), + String(ttl), + ]); + return Number(published) === 1; + } + + async waitForMeetingStart( + meetingId: string, + scope: ZoomRtmsEventScope, + timeoutSeconds: number + ): Promise { + return this.waitFor( + this.meetingQueue( + meetingId, + this.appId(scope), + this.operatorId(scope), + this.digest(scope.customerId) + ), + timeoutSeconds + ); + } + + async waitForStreamEvent( + streamId: string, + scope: ZoomRtmsEventScope, + timeoutSeconds: number + ): Promise { + return this.waitFor( + this.streamQueue(streamId, this.appId(scope), this.digest(scope.customerId)), + timeoutSeconds + ); + } + + async markStreamActive(streamId: string, scope: ZoomRtmsEventScope): Promise { + const customerDigest = this.digest(scope.customerId); + const appId = this.appId(scope); + if (!config.isRedisEnabled) { + const activeCustomer = this.localActiveStreams.get(this.activeStreamKey(streamId, appId)); + if (activeCustomer && activeCustomer !== customerDigest) { + throw new Error('Zoom RTMS stream is already owned by another customer'); + } + this.localActiveStreams.set(this.activeStreamKey(streamId, appId), customerDigest); + const pendingQueue = this.pendingStreamQueue(streamId, appId); + const targetQueue = this.streamQueue(streamId, appId, customerDigest); + const pendingEvents = this.localQueues.get(pendingQueue) ?? []; + this.localQueues.delete(pendingQueue); + pendingEvents.forEach((event) => this.enqueueLocal(targetQueue, event)); + return; + } + + await this.connect(); + const activeTtl = Math.max( + config.zoomRtms.eventTtlSeconds, + config.maxRecordingDuration * 60 + 600 + ); + const claimed = await this.getCommandClient().sendCommand([ + 'EVAL', + 'local owner = redis.call("GET", KEYS[1]); if owner and owner ~= ARGV[1] then return -1; end; redis.call("SET", KEYS[1], ARGV[1], "EX", ARGV[2]); local events = redis.call("LRANGE", KEYS[2], 0, -1); for _, event in ipairs(events) do redis.call("RPUSH", KEYS[3], event); end; if #events > 0 then redis.call("EXPIRE", KEYS[3], ARGV[3]); end; redis.call("DEL", KEYS[2]); return #events;', + '3', + this.activeStreamKey(streamId, appId), + this.pendingStreamQueue(streamId, appId), + this.streamQueue(streamId, appId, customerDigest), + customerDigest, + String(activeTtl), + String(config.zoomRtms.eventTtlSeconds), + ]); + if (Number(claimed) === -1) { + throw new Error('Zoom RTMS stream is already owned by another customer'); + } + } + + async markStreamInactive(streamId: string, scope?: ZoomRtmsEventScope): Promise { + const expectedCustomer = scope ? this.digest(scope.customerId) : undefined; + const appId = scope ? this.appId(scope) : 'global'; + if (!config.isRedisEnabled) { + const activeStreamKey = this.activeStreamKey(streamId, appId); + if (!expectedCustomer || this.localActiveStreams.get(activeStreamKey) === expectedCustomer) { + this.localActiveStreams.delete(activeStreamKey); + } + return; + } + + await this.connect(); + if (!expectedCustomer) { + await this.getCommandClient().del(this.activeStreamKey(streamId, appId)); + return; + } + await this.getCommandClient().sendCommand([ + 'EVAL', + 'if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]); end; return 0;', + '1', + this.activeStreamKey(streamId, appId), + expectedCustomer, + ]); + } + + async reserveMeeting( + meetingId: string, + ownerId: string, + ttlSeconds: number, + scope: ZoomRtmsEventScope + ): Promise { + const key = this.reservationKey(meetingId, scope); + const routesKey = this.operatorRoutesKey( + meetingId, + this.appId(scope), + this.operatorId(scope) + ); + const customerDigest = this.digest(scope.customerId); + if (!config.isRedisEnabled) { + if (this.localReservations.has(key)) return false; + this.localReservations.set(key, ownerId); + const routes = this.localRoutes.get(routesKey) ?? new Set(); + routes.add(customerDigest); + this.localRoutes.set(routesKey, routes); + return true; + } + + await this.connect(); + const ttl = String(Math.max(1, Math.ceil(ttlSeconds))); + const result = await this.getCommandClient().sendCommand([ + 'EVAL', + 'if redis.call("SET", KEYS[1], ARGV[1], "EX", ARGV[3], "NX") then redis.call("SADD", KEYS[2], ARGV[2]); redis.call("EXPIRE", KEYS[2], ARGV[3]); return 1; end; return 0;', + '2', + key, + routesKey, + ownerId, + customerDigest, + ttl, + ]); + return Number(result) === 1; + } + + async releaseMeeting( + meetingId: string, + ownerId: string, + scope: ZoomRtmsEventScope + ): Promise { + const key = this.reservationKey(meetingId, scope); + const routesKey = this.operatorRoutesKey( + meetingId, + this.appId(scope), + this.operatorId(scope) + ); + const customerDigest = this.digest(scope.customerId); + if (!config.isRedisEnabled) { + if (this.localReservations.get(key) === ownerId) { + this.localReservations.delete(key); + const routes = this.localRoutes.get(routesKey); + routes?.delete(customerDigest); + if (routes?.size === 0) this.localRoutes.delete(routesKey); + } + return; + } + + await this.connect(); + await this.getCommandClient().sendCommand([ + 'EVAL', + 'if redis.call("GET", KEYS[1]) == ARGV[1] then redis.call("DEL", KEYS[1]); redis.call("SREM", KEYS[2], ARGV[2]); if redis.call("SCARD", KEYS[2]) == 0 then redis.call("DEL", KEYS[2]); end; return 1; end; return 0;', + '2', + key, + routesKey, + ownerId, + customerDigest, + ]); + } + + async close(): Promise { + const clients = [this.commandClient, this.blockingClient] + .filter((client): client is RedisClient => Boolean(client?.isOpen)); + await Promise.all(clients.map((client) => client.quit())); + this.commandClient = undefined; + this.blockingClient = undefined; + this.connectPromise = undefined; + } + + private async waitFor( + queueKey: string, + timeoutSeconds: number + ): Promise { + if (!config.isRedisEnabled) { + return this.waitForLocal(queueKey, timeoutSeconds); + } + + await this.connect(); + const result = await this.getBlockingClient().blPop( + queueKey, + Math.max(1, Math.ceil(timeoutSeconds)) + ); + return result ? JSON.parse(result.element) as ZoomRtmsWebhookEvent : null; + } + + private async meetingQueuesForStart( + meetingId: string, + operatorId: string, + appId: string + ): Promise { + const operatorIds = operatorId ? [operatorId, '*'] : ['*']; + const customerRoutes = await Promise.all(operatorIds.map(async (routeOperatorId) => ({ + operatorId: routeOperatorId, + customerDigests: await this.getCommandClient().sMembers( + this.operatorRoutesKey(meetingId, appId, routeOperatorId) + ), + }))); + return customerRoutes.flatMap(({ operatorId: routeOperatorId, customerDigests }) => + customerDigests.map((customerDigest) => + this.meetingQueue(meetingId, appId, routeOperatorId, customerDigest) + ) + ); + } + + private localMeetingQueuesForStart( + meetingId: string, + operatorId: string, + appId: string + ): string[] { + const operatorIds = operatorId ? [operatorId, '*'] : ['*']; + return operatorIds.flatMap((routeOperatorId) => + Array.from( + this.localRoutes.get(this.operatorRoutesKey(meetingId, appId, routeOperatorId)) ?? [] + ).map((customerDigest) => + this.meetingQueue(meetingId, appId, routeOperatorId, customerDigest) + ) + ); + } + + private publishLocally(event: ZoomRtmsWebhookEvent, appId = 'global'): boolean { + const streamId = event.payload.rtms_stream_id; + let queueKeys: string[]; + if (event.event === 'meeting.rtms_started') { + const meetingId = normalizeZoomMeetingId(event.payload.meeting_id ?? ''); + const activeCustomer = this.localActiveStreams.get(this.activeStreamKey(streamId, appId)); + queueKeys = activeCustomer + ? [this.streamQueue(streamId, appId, activeCustomer)] + : this.localMeetingQueuesForStart( + meetingId, + String(event.payload.operator_id ?? ''), + appId + ); + } else { + const activeCustomer = this.localActiveStreams.get(this.activeStreamKey(streamId, appId)); + queueKeys = [activeCustomer + ? this.streamQueue(streamId, appId, activeCustomer) + : this.pendingStreamQueue(streamId, appId)]; + } + + if (queueKeys.length === 0) return true; + + const dedupeKey = this.dedupeKey(event, appId); + if (this.localDedupe.has(dedupeKey)) return false; + this.localDedupe.add(dedupeKey); + const timer = setTimeout( + () => this.localDedupe.delete(dedupeKey), + config.zoomRtms.eventTtlSeconds * 1000 + ); + timer.unref(); + + queueKeys.forEach((queueKey) => this.enqueueLocal(queueKey, event)); + return true; + } + + private enqueueLocal(queueKey: string, event: ZoomRtmsWebhookEvent): void { + const waiter = this.localWaiters.get(queueKey)?.shift(); + if (waiter) { + waiter(event); + } else { + const queue = this.localQueues.get(queueKey) ?? []; + queue.push(event); + this.localQueues.set(queueKey, queue); + } + } + + private waitForLocal( + queueKey: string, + timeoutSeconds: number + ): Promise { + const queued = this.localQueues.get(queueKey)?.shift(); + if (queued) return Promise.resolve(queued); + + return new Promise((resolve) => { + const waiters = this.localWaiters.get(queueKey) ?? []; + let settled = false; + const finish = (event: ZoomRtmsWebhookEvent | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(event); + }; + waiters.push(finish); + this.localWaiters.set(queueKey, waiters); + + const timer = setTimeout(() => { + const current = this.localWaiters.get(queueKey) ?? []; + const index = current.indexOf(finish); + if (index >= 0) current.splice(index, 1); + finish(null); + }, Math.max(1, timeoutSeconds) * 1000); + }); + } +} + +export const zoomRtmsEventStore = new ZoomRtmsEventStore(); diff --git a/src/rtms/ZoomRtmsSdkClient.test.ts b/src/rtms/ZoomRtmsSdkClient.test.ts new file mode 100644 index 00000000..46a3483d --- /dev/null +++ b/src/rtms/ZoomRtmsSdkClient.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildZoomRtmsSdkJoinParams } from './ZoomRtmsSdkClient'; +import { ZoomRtmsAppCredentials, ZoomRtmsPayload } from './types'; + +test('builds SDK join parameters from the resolved RTMS app', () => { + const app: ZoomRtmsAppCredentials = { + appId: 'customer-app', + clientId: 'customer-client', + clientSecret: 'customer-secret', + webhookSecret: 'customer-webhook-secret', + }; + const payload: ZoomRtmsPayload = { + meeting_uuid: 'meeting-uuid', + rtms_stream_id: 'stream-id', + server_urls: 'wss://rtms.example.test', + }; + + assert.deepEqual(buildZoomRtmsSdkJoinParams(payload, app, 12_000), { + meeting_uuid: 'meeting-uuid', + rtms_stream_id: 'stream-id', + server_urls: 'wss://rtms.example.test', + client: 'customer-client', + secret: 'customer-secret', + timeout: 12_000, + pollInterval: 10, + is_verify_cert: 1, + }); +}); diff --git a/src/rtms/ZoomRtmsSdkClient.ts b/src/rtms/ZoomRtmsSdkClient.ts new file mode 100644 index 00000000..e0710d0b --- /dev/null +++ b/src/rtms/ZoomRtmsSdkClient.ts @@ -0,0 +1,236 @@ +import { Logger } from 'winston'; +import { createRequire } from 'module'; +import { KnownError } from '../error'; +import { ZoomRtmsAppCredentials, ZoomRtmsPayload } from './types'; +import { RtmsMediaRecorder } from './RtmsMediaRecorder'; + +interface RtmsClient { + setAudioParams(params: Record): boolean; + setVideoParams(params: Record): boolean; + onJoinConfirm(callback: (reason: number) => void): boolean; + onLeave(callback: (reason: number) => void): boolean; + onAudioData(callback: (buffer: Buffer, size: number, timestamp: number) => void): boolean; + onVideoData(callback: (buffer: Buffer, size: number, timestamp: number) => void): boolean; + onMediaConnectionInterrupted(callback: (timestamp: number) => void): boolean; + join(params: Record): boolean; + leave(): boolean; +} + +interface RtmsSdk { + Client: new () => RtmsClient; + AudioContentType: { RAW_AUDIO: number }; + AudioCodec: { L16: number }; + AudioSampleRate: { SR_16K: number }; + AudioChannel: { MONO: number }; + AudioDataOption: { AUDIO_MIXED_STREAM: number }; + VideoContentType: { RAW_VIDEO: number }; + VideoCodec: { H264: number }; + VideoResolution: { HD: number }; + VideoDataOption: { VIDEO_SINGLE_ACTIVE_STREAM: number }; +} + +export type ZoomRtmsSdkConnectionIssue = + | { type: 'media_interrupted'; timestamp: number } + | { type: 'left'; reason: number }; + +interface ActiveClient { + client: RtmsClient; + suppressIssues: boolean; +} + +const isSupportedPlatform = (): boolean => + (process.platform === 'linux' && process.arch === 'x64') + || (process.platform === 'darwin' && process.arch === 'arm64'); + +const requireModule = createRequire(__filename); + +const loadSdk = (): RtmsSdk => { + if (!isSupportedPlatform()) { + throw new KnownError( + `Zoom RTMS SDK is not supported on ${process.platform}-${process.arch}`, + false, + 0 + ); + } + + try { + const module = requireModule('@zoom/rtms'); + const sdk = module.default as RtmsSdk; + if (!sdk || typeof sdk.Client !== 'function') throw new Error('Client export is missing'); + return sdk; + } catch (error: unknown) { + throw new KnownError( + `Unable to load Zoom RTMS SDK: ${error instanceof Error ? error.message : String(error)}`, + false, + 0 + ); + } +}; + +export const assertZoomRtmsSdkAvailable = (): void => { + loadSdk(); +}; + +export const buildZoomRtmsSdkJoinParams = ( + payload: ZoomRtmsPayload, + app: ZoomRtmsAppCredentials, + timeoutMs: number +): Record => ({ + meeting_uuid: payload.meeting_uuid, + rtms_stream_id: payload.rtms_stream_id, + server_urls: payload.server_urls, + client: app.clientId, + secret: app.clientSecret, + timeout: timeoutMs, + pollInterval: 10, + is_verify_cert: 1, +}); + +export class ZoomRtmsSdkClient { + private activeClient?: ActiveClient; + private connectionIssue?: ZoomRtmsSdkConnectionIssue; + + constructor( + private readonly recorder: RtmsMediaRecorder, + private readonly logger: Logger, + private readonly app: ZoomRtmsAppCredentials + ) {} + + async connect(payload: ZoomRtmsPayload, timeoutMs = 30_000): Promise { + await this.close(); + this.connectionIssue = undefined; + + if (!this.app.clientId || !this.app.clientSecret) { + throw new KnownError( + 'Zoom RTMS app client ID and client secret are required', + false, + 0 + ); + } + if (!payload.meeting_uuid || !payload.rtms_stream_id || !payload.server_urls) { + throw new KnownError('Zoom RTMS start event is missing connection details', false, 0); + } + + const sdk = loadSdk(); + const client = new sdk.Client(); + const activeClient: ActiveClient = { client, suppressIssues: false }; + this.activeClient = activeClient; + const boundedTimeoutMs = Math.max(1_000, Math.min(30_000, timeoutMs)); + + const audioConfigured = client.setAudioParams({ + contentType: sdk.AudioContentType.RAW_AUDIO, + codec: sdk.AudioCodec.L16, + sampleRate: sdk.AudioSampleRate.SR_16K, + channel: sdk.AudioChannel.MONO, + dataOpt: sdk.AudioDataOption.AUDIO_MIXED_STREAM, + duration: 100, + frameSize: 1600, + }); + const videoConfigured = client.setVideoParams({ + contentType: sdk.VideoContentType.RAW_VIDEO, + codec: sdk.VideoCodec.H264, + resolution: sdk.VideoResolution.HD, + dataOpt: sdk.VideoDataOption.VIDEO_SINGLE_ACTIVE_STREAM, + fps: 25, + }); + if (!audioConfigured || !videoConfigured) { + await this.close(); + throw new KnownError('Unable to configure Zoom RTMS audio/video streams', false, 0); + } + + const audioCallbackSet = client.onAudioData((buffer, size, timestamp) => { + const data = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer); + this.recorder.writeAudio(data.subarray(0, size), timestamp); + }); + const videoCallbackSet = client.onVideoData((buffer, size, timestamp) => { + const data = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer); + this.recorder.writeVideo(data.subarray(0, size), timestamp); + }); + if (!audioCallbackSet || !videoCallbackSet) { + await this.close(); + throw new KnownError('Unable to register Zoom RTMS media callbacks', false, 0); + } + + try { + await new Promise((resolve, reject) => { + let settled = false; + let joined = false; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + error ? reject(error) : resolve(); + }; + const timeout = setTimeout( + () => finish(new Error('Timed out connecting to the Zoom RTMS stream')), + boundedTimeoutMs + ); + + const joinCallbackSet = client.onJoinConfirm((reason) => { + if (reason === 0) { + joined = true; + finish(); + } else { + finish(new Error(`Zoom RTMS SDK rejected the stream connection (${reason})`)); + } + }); + const leaveCallbackSet = client.onLeave((reason) => { + this.logger.info('Zoom RTMS SDK left the stream', { reason }); + if (!joined) { + finish(new Error(`Zoom RTMS SDK left before connecting (${reason})`)); + } else if ( + !activeClient.suppressIssues + && this.activeClient === activeClient + && !this.connectionIssue + ) { + this.connectionIssue = { type: 'left', reason }; + } + }); + const interruptionCallbackSet = client.onMediaConnectionInterrupted((timestamp) => { + this.logger.warn('Zoom RTMS media connection interrupted', { timestamp }); + if (!joined) { + finish(new Error('Zoom RTMS media connection was interrupted while connecting')); + } else if ( + !activeClient.suppressIssues + && this.activeClient === activeClient + && !this.connectionIssue + ) { + this.connectionIssue = { type: 'media_interrupted', timestamp }; + } + }); + + if (!joinCallbackSet || !leaveCallbackSet || !interruptionCallbackSet) { + finish(new Error('Unable to register Zoom RTMS connection callbacks')); + return; + } + + const joining = client.join( + buildZoomRtmsSdkJoinParams(payload, this.app, boundedTimeoutMs) + ); + if (!joining) finish(new Error('Zoom RTMS SDK could not start the stream connection')); + }); + } catch (error) { + await this.close(); + throw error; + } + } + + takeConnectionIssue(): ZoomRtmsSdkConnectionIssue | undefined { + const issue = this.connectionIssue; + this.connectionIssue = undefined; + return issue; + } + + async close(): Promise { + const activeClient = this.activeClient; + this.activeClient = undefined; + this.connectionIssue = undefined; + if (!activeClient) return; + activeClient.suppressIssues = true; + try { + activeClient.client.leave(); + } catch (error) { + this.logger.warn('Unable to close Zoom RTMS SDK client cleanly', { error }); + } + } +} diff --git a/src/rtms/ZoomRtmsTransport.test.ts b/src/rtms/ZoomRtmsTransport.test.ts new file mode 100644 index 00000000..c631698e --- /dev/null +++ b/src/rtms/ZoomRtmsTransport.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + isTerminalSdkLeaveReason, + isTransientStopReason, + shouldRestartStoppedStream, +} from './ZoomRtmsTransport'; + +test('treats non-retryable Zoom SDK stop reasons as terminal', () => { + for (const reason of [1, 2, 3, 4, 5, 6, 7, 8, 9, 20, 21, 22, 23, 25, 26]) { + assert.equal(isTerminalSdkLeaveReason(reason), true, `reason ${reason}`); + } +}); + +test('keeps transient and unknown Zoom SDK leave reasons reconnectable', () => { + for (const reason of [0, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 24, 27, 960]) { + assert.equal(isTerminalSdkLeaveReason(reason), false, `reason ${reason}`); + } +}); + +test('restarts the complete RTMS stream for every retryable Zoom stop reason', () => { + for (const reason of [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 24]) { + assert.equal(isTransientStopReason(reason), true, `reason ${reason}`); + } + + for (const reason of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 20, 21, 22, 23, 25, 26, 27, 960]) { + assert.equal(isTransientStopReason(reason), false, `reason ${reason}`); + } +}); + +test('restarts stopped streams for transient and unknown reasons only', () => { + for (const reason of [0, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 24, 27, 960]) { + assert.equal(shouldRestartStoppedStream(reason), true, `reason ${reason}`); + } + + for (const reason of [1, 2, 3, 4, 5, 6, 7, 8, 9, 20, 21, 22, 23, 25, 26]) { + assert.equal(shouldRestartStoppedStream(reason), false, `reason ${reason}`); + } + assert.equal(shouldRestartStoppedStream(undefined), false); +}); diff --git a/src/rtms/ZoomRtmsTransport.ts b/src/rtms/ZoomRtmsTransport.ts new file mode 100644 index 00000000..5861e62a --- /dev/null +++ b/src/rtms/ZoomRtmsTransport.ts @@ -0,0 +1,529 @@ +import { Logger } from 'winston'; +import { randomUUID } from 'crypto'; +import { JoinParams } from '../bots/AbstractMeetBot'; +import config, { NODE_ENV } from '../config'; +import { KnownError } from '../error'; +import { BotStatus } from '../types'; +import { RtmsMediaRecorder } from './RtmsMediaRecorder'; +import { ZoomRtmsApi } from './ZoomRtmsApi'; +import { resolveZoomRtmsCredentials } from './ZoomRtmsCredentials'; +import { zoomRtmsEventStore } from './ZoomRtmsEventStore'; +import { + assertZoomRtmsSdkAvailable, + ZoomRtmsSdkClient, + ZoomRtmsSdkConnectionIssue, +} from './ZoomRtmsSdkClient'; +import { + ZoomRtmsEventScope, + ZoomRtmsPayload, + ZoomRtmsStopReason, + ZoomRtmsWebhookEvent, +} from './types'; +import { extractZoomMeetingId } from './utils'; + +const START_EVENT_CLOCK_SKEW_MS = 5_000; +const SIGNAL_RECONNECT_WINDOW_MS = 60_000; +const MEDIA_RECONNECT_WINDOW_MS = 30_000; +const STREAM_EVENT_POLL_SECONDS = 1; +const RECONNECT_DELAYS_MS = [3_000, 6_000, 12_000, 24_000, 30_000]; +const MAX_STREAM_RESTARTS = RECONNECT_DELAYS_MS.length; +const FIRST_KNOWN_STOP_REASON = 1; +const LAST_KNOWN_STOP_REASON = 26; + +export const isTransientStopReason = (reason?: number): reason is number => + typeof reason === 'number' && ((reason >= 10 && reason <= 19) || reason === 24); + +export const isTerminalSdkLeaveReason = (reason: number): boolean => + reason >= FIRST_KNOWN_STOP_REASON + && reason <= LAST_KNOWN_STOP_REASON + && !isTransientStopReason(reason); + +export const shouldRestartStoppedStream = (reason?: number): reason is number => + typeof reason === 'number' + && reason !== ZoomRtmsStopReason.StreamRevoked + && !isTerminalSdkLeaveReason(reason); + +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +const sleep = (milliseconds: number): Promise => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +export class ZoomRtmsTransport { + constructor(private readonly logger: Logger) {} + + async record( + params: JoinParams, + pushState: (state: BotStatus) => void + ): Promise { + const meetingId = extractZoomMeetingId(params.url); + const credentials = resolveZoomRtmsCredentials(params.teamId); + const eventScope = credentials.eventScope; + const api = new ZoomRtmsApi(credentials.api); + let recorder: RtmsMediaRecorder | undefined; + let client: ZoomRtmsSdkClient | undefined; + let streamId: string | undefined; + let lastStartPayload: ZoomRtmsPayload | undefined; + let terminalStopReceived = false; + let rtmsRequested = false; + let streamRestartAttempts = 0; + const reservationOwnerId = randomUUID(); + let meetingReserved = false; + + try { + if (!config.isRedisEnabled && (NODE_ENV === 'production' || NODE_ENV === 'staging')) { + throw new KnownError( + 'Zoom RTMS requires REDIS_CONSUMER_ENABLED=true in multi-replica environments', + false, + 0 + ); + } + assertZoomRtmsSdkAvailable(); + recorder = await RtmsMediaRecorder.create(this.logger); + + const reservationTtlSeconds = + config.joinWaitTime * 60 + config.maxRecordingDuration * 60 + 600; + meetingReserved = await zoomRtmsEventStore.reserveMeeting( + meetingId, + reservationOwnerId, + reservationTtlSeconds, + eventScope + ); + if (!meetingReserved) { + throw new KnownError( + 'A Zoom RTMS recording is already active for this meeting and operator', + false, + 0 + ); + } + + this.logger.info('Requesting Zoom RTMS stream', { + meetingId, + credentialMode: credentials.credentialMode, + }); + const startRequestedAt = Date.now(); + const initialJoinDeadline = startRequestedAt + config.joinWaitTime * 60 * 1000; + const startResult = await api.start( + meetingId, + Math.max(0, initialJoinDeadline - Date.now()) + ); + rtmsRequested = startResult.status === 'requested'; + if (startResult.status === 'awaiting_external_authorization') { + this.logger.info('Waiting for external Zoom RTMS authorization', { meetingId }); + } + const initialEventWaitMs = initialJoinDeadline - Date.now(); + if (initialEventWaitMs <= 0) { + throw new KnownError('Timed out requesting the Zoom RTMS stream', false, 0); + } + + const startEvent = await this.waitForFreshStart( + meetingId, + startRequestedAt, + eventScope, + Math.ceil(initialEventWaitMs / 1000) + ); + if (!startEvent) { + throw new KnownError('Timed out waiting for a fresh Zoom RTMS start event', false, 0); + } + + streamId = startEvent.payload.rtms_stream_id; + lastStartPayload = startEvent.payload; + rtmsRequested = true; + await zoomRtmsEventStore.markStreamActive(streamId, eventScope); + client = new ZoomRtmsSdkClient(recorder, this.logger, credentials.app); + await this.connectWithBackoff( + client, + lastStartPayload, + 'initial RTMS connection', + this.eventDeadline(startEvent, SIGNAL_RECONNECT_WINDOW_MS), + true + ); + + pushState('joined'); + this.logger.info('Zoom RTMS recording started', { meetingId, streamId }); + + const recordingDeadline = Date.now() + config.maxRecordingDuration * 60 * 1000; + const activeClient = client; + const restartTerminatedStream = async (stopReason: number): Promise => { + rtmsRequested = false; + if (streamRestartAttempts >= MAX_STREAM_RESTARTS) { + throw new Error( + `Zoom RTMS stream restart limit reached after stop reason ${stopReason}` + ); + } + streamRestartAttempts += 1; + + const stoppedStreamId = streamId; + if (!stoppedStreamId) throw new Error('Zoom RTMS stream ID is unavailable'); + this.logger.warn('Restarting terminated Zoom RTMS stream', { + meetingId, + streamId: stoppedStreamId, + stopReason, + attempt: streamRestartAttempts, + maxAttempts: MAX_STREAM_RESTARTS, + }); + await activeClient.close(); + await zoomRtmsEventStore.markStreamInactive(stoppedStreamId, eventScope); + + const restartRequestedAt = Date.now(); + const restartJoinDeadline = Math.min( + recordingDeadline, + restartRequestedAt + config.joinWaitTime * 60 * 1000 + ); + const restartResult = await api.start( + meetingId, + Math.max(0, restartJoinDeadline - Date.now()) + ); + rtmsRequested = restartResult.status === 'requested'; + if (restartResult.status === 'awaiting_external_authorization') { + this.logger.info('Waiting for external Zoom RTMS restart authorization', { + meetingId, + }); + } + const restartEventWaitMs = restartJoinDeadline - Date.now(); + if (restartEventWaitMs <= 0) { + throw new Error('Timed out requesting the Zoom RTMS stream restart'); + } + const restartedEvent = await this.waitForFreshStart( + meetingId, + restartRequestedAt, + eventScope, + Math.ceil(restartEventWaitMs / 1000) + ); + if (!restartedEvent) { + throw new Error('Timed out waiting for Zoom RTMS to restart'); + } + + streamId = restartedEvent.payload.rtms_stream_id; + lastStartPayload = restartedEvent.payload; + rtmsRequested = true; + await zoomRtmsEventStore.markStreamActive(streamId, eventScope); + await this.connectWithBackoff( + activeClient, + lastStartPayload, + 'restarted RTMS stream', + Math.min( + recordingDeadline, + this.eventDeadline(restartedEvent, SIGNAL_RECONNECT_WINDOW_MS) + ), + true + ); + this.logger.info('Zoom RTMS stream restarted', { meetingId, streamId }); + }; + + while (!terminalStopReceived) { + const remainingSeconds = Math.ceil((recordingDeadline - Date.now()) / 1000); + if (remainingSeconds <= 0) break; + + const event = await zoomRtmsEventStore.waitForStreamEvent( + streamId, + eventScope, + Math.min(STREAM_EVENT_POLL_SECONDS, remainingSeconds) + ); + + if (event?.event === 'meeting.rtms_stopped') { + rtmsRequested = false; + if (typeof event.payload.stop_reason !== 'number') { + throw new KnownError('Zoom RTMS stop event is missing its reason', false, 0); + } + if (event.payload.stop_reason === ZoomRtmsStopReason.StreamRevoked) { + throw new KnownError( + 'Zoom RTMS consent was revoked; the recording was discarded', + false, + 0 + ); + } + + if (shouldRestartStoppedStream(event.payload.stop_reason)) { + await restartTerminatedStream(event.payload.stop_reason); + continue; + } + + terminalStopReceived = true; + continue; + } + + if (event?.event === 'meeting.rtms_started') { + lastStartPayload = event.payload; + this.logger.warn('Zoom RTMS server changed; reconnecting immediately', { + meetingId, + streamId, + }); + await this.connectWithBackoff( + client, + lastStartPayload, + 'RTMS server failover', + Math.min( + recordingDeadline, + this.eventDeadline(event, SIGNAL_RECONNECT_WINDOW_MS) + ), + true + ); + continue; + } + + if (event?.event === 'meeting.rtms_interrupted') { + const reconnectPayload = this.mergeInterruptedPayload(lastStartPayload, event); + lastStartPayload = reconnectPayload; + this.logger.warn('Zoom RTMS signaling interrupted; reconnecting', { + meetingId, + streamId, + }); + await this.connectWithBackoff( + client, + reconnectPayload, + 'interrupted RTMS signaling connection', + Math.min( + recordingDeadline, + this.eventDeadline(event, SIGNAL_RECONNECT_WINDOW_MS) + ), + false + ); + continue; + } + + const connectionIssue = client.takeConnectionIssue(); + if (connectionIssue) { + if ( + connectionIssue.type === 'left' + && connectionIssue.reason === ZoomRtmsStopReason.StreamRevoked + ) { + throw new KnownError( + 'Zoom RTMS consent was revoked; the recording was discarded', + false, + 0 + ); + } + + if ( + connectionIssue.type === 'left' + && isTerminalSdkLeaveReason(connectionIssue.reason) + ) { + terminalStopReceived = true; + rtmsRequested = false; + this.logger.info('Zoom RTMS stream ended; finalizing recording', { + meetingId, + streamId, + stopReason: connectionIssue.reason, + }); + continue; + } + + if ( + connectionIssue.type === 'left' + && isTransientStopReason(connectionIssue.reason) + ) { + await restartTerminatedStream(connectionIssue.reason); + continue; + } + + if (!lastStartPayload) throw new Error('Zoom RTMS reconnect details are unavailable'); + const reconnectWindow = connectionIssue.type === 'media_interrupted' + ? MEDIA_RECONNECT_WINDOW_MS + : SIGNAL_RECONNECT_WINDOW_MS; + this.logger.warn('Zoom RTMS SDK connection lost; reconnecting the full stream', { + meetingId, + streamId, + issue: connectionIssue, + }); + await this.reconnectAfterSdkIssue( + client, + lastStartPayload, + connectionIssue, + Math.min(recordingDeadline, Date.now() + reconnectWindow) + ); + } + } + + if (!terminalStopReceived) { + this.logger.info('Maximum Zoom RTMS recording duration reached; stopping stream', { + meetingId, + streamId, + }); + await api.stop(meetingId); + rtmsRequested = false; + } + + await client.close(); + const recording = await recorder.finalize(); + params.uploader.setRecordingDuration(recording.durationSeconds); + await params.uploader.useExternalFile(recording.filePath, '.mp4'); + pushState('finished'); + this.logger.info('Zoom RTMS recording finalized', { + meetingId, + streamId, + durationSeconds: recording.durationSeconds, + }); + } catch (error: unknown) { + if (error instanceof KnownError) throw error; + throw new KnownError( + `Zoom RTMS recording failed: ${errorMessage(error)}`, + false, + 0 + ); + } finally { + await client?.close(); + if (streamId) { + await zoomRtmsEventStore.markStreamInactive(streamId, eventScope).catch((error) => { + this.logger.warn('Unable to release Zoom RTMS stream ownership', { error, streamId }); + }); + } + if (rtmsRequested && !terminalStopReceived) { + await api.stop(meetingId).catch((error) => { + this.logger.warn('Unable to stop Zoom RTMS during cleanup', { error, meetingId }); + }); + } + await recorder?.cleanup().catch((error) => { + this.logger.warn('Unable to remove Zoom RTMS temporary files', { error }); + }); + if (meetingReserved) { + await zoomRtmsEventStore.releaseMeeting( + meetingId, + reservationOwnerId, + eventScope + ).catch((error) => { + this.logger.warn('Unable to release Zoom RTMS meeting reservation', { + error, + meetingId, + }); + }); + } + } + } + + private async waitForFreshStart( + meetingId: string, + requestedAt: number, + eventScope: ZoomRtmsEventScope, + timeoutSeconds: number + ): Promise { + const deadline = Date.now() + Math.max(1, timeoutSeconds) * 1000; + + while (Date.now() < deadline) { + const event = await zoomRtmsEventStore.waitForMeetingStart( + meetingId, + eventScope, + Math.max(1, Math.ceil((deadline - Date.now()) / 1000)) + ); + if (!event) return null; + + const eventTimestamp = event.event_ts; + if ( + event.event !== 'meeting.rtms_started' + || typeof eventTimestamp !== 'number' + || eventTimestamp < requestedAt - START_EVENT_CLOCK_SKEW_MS + ) { + this.logger.warn('Ignoring stale Zoom RTMS start event', { + meetingId, + requestedAt, + eventTimestamp, + }); + continue; + } + + const expectedOperatorId = eventScope.operatorId; + if ( + expectedOperatorId + && String(event.payload.operator_id ?? '') !== expectedOperatorId + ) { + this.logger.warn('Ignoring Zoom RTMS start event for another operator', { + meetingId, + expectedOperatorId, + operatorId: event.payload.operator_id, + }); + continue; + } + + return event; + } + + return null; + } + + private mergeInterruptedPayload( + lastStartPayload: ZoomRtmsPayload | undefined, + event: ZoomRtmsWebhookEvent + ): ZoomRtmsPayload { + if (!lastStartPayload) throw new Error('Zoom RTMS reconnect details are unavailable'); + return { + ...lastStartPayload, + ...event.payload, + server_urls: event.payload.server_urls ?? lastStartPayload.server_urls, + }; + } + + private eventDeadline(event: ZoomRtmsWebhookEvent, windowMs: number): number { + const eventTimestamp = typeof event.event_ts === 'number' ? event.event_ts : Date.now(); + return eventTimestamp + windowMs; + } + + private async reconnectAfterSdkIssue( + client: ZoomRtmsSdkClient, + payload: ZoomRtmsPayload, + issue: ZoomRtmsSdkConnectionIssue, + deadline: number + ): Promise { + const description = issue.type === 'media_interrupted' + ? 'interrupted RTMS media connection' + : `RTMS SDK leave (${issue.reason})`; + await this.connectWithBackoff(client, payload, description, deadline, false); + } + + private async connectWithBackoff( + client: ZoomRtmsSdkClient, + payload: ZoomRtmsPayload, + description: string, + deadline: number, + immediate: boolean + ): Promise { + let lastError: unknown; + let attempt = 0; + + if (immediate) { + attempt += 1; + try { + await this.connectBeforeDeadline(client, payload, deadline); + return; + } catch (error) { + lastError = error; + this.logger.warn(`Zoom ${description} attempt failed`, { + attempt, + error: errorMessage(error), + }); + } + } + + for (const delayMs of RECONNECT_DELAYS_MS) { + if (Date.now() + delayMs >= deadline) break; + await sleep(delayMs); + attempt += 1; + try { + await this.connectBeforeDeadline(client, payload, deadline); + return; + } catch (error) { + lastError = error; + this.logger.warn(`Zoom ${description} attempt failed`, { + attempt, + delayMs, + error: errorMessage(error), + }); + } + } + + throw new Error( + `Unable to restore ${description} within Zoom's reconnect window after ${attempt} attempts${ + lastError ? `: ${errorMessage(lastError)}` : '' + }` + ); + } + + private async connectBeforeDeadline( + client: ZoomRtmsSdkClient, + payload: ZoomRtmsPayload, + deadline: number + ): Promise { + const remainingMs = deadline - Date.now(); + if (remainingMs < 1_000) throw new Error('Zoom RTMS reconnect window expired'); + await client.connect(payload, Math.min(30_000, remainingMs)); + } +} diff --git a/src/rtms/types.ts b/src/rtms/types.ts new file mode 100644 index 00000000..93a1b3e5 --- /dev/null +++ b/src/rtms/types.ts @@ -0,0 +1,64 @@ +export type ZoomRtmsEventName = + | 'meeting.rtms_started' + | 'meeting.rtms_stopped' + | 'meeting.rtms_interrupted'; + +export enum ZoomRtmsStopReason { + MeetingEnded = 6, + StreamRevoked = 8, +} + +export interface ZoomRtmsPayload { + meeting_uuid: string; + meeting_id?: string | number; + operator_id?: string | number; + rtms_stream_id: string; + server_urls?: string; + stop_reason?: number; + [key: string]: unknown; +} + +export interface ZoomRtmsWebhookEvent { + event: ZoomRtmsEventName; + event_ts: number; + payload: ZoomRtmsPayload; +} + +export interface ZoomWebhookBody { + event?: string; + event_ts?: number; + payload?: Record; +} + +export type ZoomRtmsCredentialMode = + | 'internal' + | 'shared_customer' + | 'dedicated_customer'; + +export interface ZoomRtmsAppCredentials { + appId: string; + clientId: string; + clientSecret: string; + webhookSecret: string; +} + +export interface ZoomRtmsApiCredentials { + source: 'customer' | 'legacy'; + customerId: string; + rtmsClientId: string; + participantUserId?: string; + oauthAccessToken?: string; + oauthAccountId?: string; + oauthClientId?: string; + oauthClientSecret?: string; +} + +export interface ZoomRtmsEventScope { + appId: string; + customerId: string; + operatorId?: string; +} + +export type ZoomRtmsStartResult = + | { status: 'requested' } + | { status: 'awaiting_external_authorization'; httpStatus: 403 }; diff --git a/src/rtms/utils.test.ts b/src/rtms/utils.test.ts new file mode 100644 index 00000000..d6c8305d --- /dev/null +++ b/src/rtms/utils.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildZoomWebhookSignature, + extractZoomMeetingId, + verifyZoomWebhookSignature, +} from './utils'; + +test('extracts a meeting ID from Zoom join URLs', () => { + assert.equal( + extractZoomMeetingId('https://us05web.zoom.us/j/12345678901?pwd=secret'), + '12345678901' + ); + assert.equal( + extractZoomMeetingId('https://zoom.us/wc/join/123456789'), + '123456789' + ); +}); + +test('rejects non-Zoom meeting URLs', () => { + assert.throws(() => extractZoomMeetingId('https://example.com/j/123456789')); +}); + +test('verifies signed Zoom webhooks within the timestamp tolerance', () => { + const body = Buffer.from('{"event":"meeting.rtms_started"}'); + const timestamp = '1720000000'; + const secret = 'webhook-secret'; + const signature = buildZoomWebhookSignature(body, timestamp, secret); + + assert.equal(verifyZoomWebhookSignature({ + rawBody: body, + timestamp, + signature, + secret, + now: 1_720_000_100_000, + }), true); + assert.equal(verifyZoomWebhookSignature({ + rawBody: body, + timestamp, + signature, + secret, + now: 1_720_001_000_000, + }), false); +}); diff --git a/src/rtms/utils.ts b/src/rtms/utils.ts new file mode 100644 index 00000000..5468fef2 --- /dev/null +++ b/src/rtms/utils.ts @@ -0,0 +1,71 @@ +import crypto from 'crypto'; + +const ZOOM_HOST_SUFFIXES = ['.zoom.us', '.zoom.com', '.zoomgov.com']; + +export const normalizeZoomMeetingId = (value: string | number): string => { + const meetingId = String(value).replace(/\D/g, ''); + if (!meetingId) { + throw new Error('Zoom meeting ID is missing or invalid'); + } + return meetingId; +}; + +export const extractZoomMeetingId = (meetingUrl: string): string => { + const url = new URL(meetingUrl); + const hostname = url.hostname.toLowerCase(); + const isZoomHost = hostname === 'zoom.us' + || hostname === 'zoom.com' + || hostname === 'zoomgov.com' + || ZOOM_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix)); + + if (!isZoomHost) { + throw new Error('RTMS requires a Zoom meeting URL'); + } + + const pathMatch = url.pathname.match(/\/(?:j|wc\/join)\/(\d+)/i); + const queryMeetingId = url.searchParams.get('confno'); + return normalizeZoomMeetingId(pathMatch?.[1] ?? queryMeetingId ?? ''); +}; + +export const buildZoomWebhookSignature = ( + rawBody: Buffer | string, + timestamp: string, + secret: string +): string => { + const body = Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : rawBody; + const message = `v0:${timestamp}:${body}`; + return `v0=${crypto.createHmac('sha256', secret).update(message).digest('hex')}`; +}; + +export const verifyZoomWebhookSignature = ({ + rawBody, + timestamp, + signature, + secret, + now = Date.now(), + toleranceSeconds = 300, +}: { + rawBody?: Buffer; + timestamp?: string; + signature?: string; + secret?: string; + now?: number; + toleranceSeconds?: number; +}): boolean => { + if (!rawBody || !timestamp || !signature || !secret) return false; + + const timestampSeconds = Number(timestamp); + if (!Number.isFinite(timestampSeconds)) return false; + + const ageSeconds = Math.abs(Math.floor(now / 1000) - timestampSeconds); + if (ageSeconds > toleranceSeconds) return false; + + const expected = Buffer.from(buildZoomWebhookSignature(rawBody, timestamp, secret)); + const actual = Buffer.from(signature); + return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); +}; + +export const buildZoomUrlValidationResponse = (plainToken: string, secret: string) => ({ + plainToken, + encryptedToken: crypto.createHmac('sha256', secret).update(plainToken).digest('hex'), +}); diff --git a/src/rtms/webhook.test.ts b/src/rtms/webhook.test.ts new file mode 100644 index 00000000..a35051d6 --- /dev/null +++ b/src/rtms/webhook.test.ts @@ -0,0 +1,261 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Request, Response } from 'express'; +import config from '../config'; +import { zoomRtmsEventStore } from './ZoomRtmsEventStore'; +import { buildZoomWebhookSignature, buildZoomUrlValidationResponse } from './utils'; +import { handleZoomRtmsWebhook } from './webhook'; + +interface TestResponse { + statusCode: number; + body?: unknown; + status(code: number): TestResponse; + json(body: unknown): TestResponse; + sendStatus(code: number): TestResponse; +} + +const createResponse = (): TestResponse => ({ + statusCode: 200, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + sendStatus(code) { + this.statusCode = code; + return this; + }, +}); + +const send = async ({ + body, + timestamp, + signature, + webhookId, +}: { + body: Record; + timestamp?: string; + signature?: string; + webhookId?: string; +}): Promise => { + const rawBody = Buffer.from(JSON.stringify(body)); + const req = { + body, + rawBody, + headers: { + 'x-zm-request-timestamp': timestamp, + 'x-zm-signature': signature, + }, + params: webhookId ? { webhookId } : {}, + } as unknown as Request; + const res = createResponse(); + await handleZoomRtmsWebhook(req, res as unknown as Response); + return res; +}; + +test('validates Zoom challenges and signed RTMS events', async () => { + const originalSecret = config.zoomRtms.webhookSecret; + const originalCustomerCredentials = config.zoomRtms.customerCredentials; + const originalRedisEnabled = config.isRedisEnabled; + config.zoomRtms.webhookSecret = 'webhook-secret'; + config.zoomRtms.customerCredentials = { + ...originalCustomerCredentials, + 'dedicated-team': { + enabled: true, + accountId: 'account-id', + clientId: 'oauth-client-id', + clientSecret: 'oauth-client-secret', + participantUserId: 'operator-id', + rtmsApp: { + webhookId: 'dedicated-app-1234', + clientId: 'rtms-client-id', + clientSecret: 'rtms-client-secret', + webhookSecret: 'dedicated-webhook-secret', + }, + }, + 'disabled-team': { + enabled: false, + accountId: 'disabled-account-id', + clientId: 'disabled-oauth-client-id', + clientSecret: 'disabled-oauth-client-secret', + participantUserId: 'disabled-operator-id', + rtmsApp: { + webhookId: 'disabled-app-1234', + clientId: 'disabled-rtms-client-id', + clientSecret: 'disabled-rtms-client-secret', + webhookSecret: 'disabled-webhook-secret', + }, + }, + }; + config.isRedisEnabled = false; + const eventScope = { appId: 'global', customerId: 'team-a', operatorId: 'operator-id' }; + const dedicatedScope = { + appId: 'dedicated-app-1234', + customerId: 'dedicated-team', + operatorId: 'operator-id', + }; + + try { + await zoomRtmsEventStore.reserveMeeting( + '12345678901', + 'webhook-test-owner', + 60, + eventScope + ); + await zoomRtmsEventStore.reserveMeeting( + '12345678901', + 'dedicated-webhook-test-owner', + 60, + dedicatedScope + ); + const challenge = await send({ + body: { + event: 'endpoint.url_validation', + payload: { plainToken: 'plain-token' }, + }, + }); + assert.equal(challenge.statusCode, 200); + assert.deepEqual( + challenge.body, + buildZoomUrlValidationResponse('plain-token', 'webhook-secret') + ); + + const timestamp = String(Math.floor(Date.now() / 1000)); + const body = { + event: 'meeting.rtms_started', + event_ts: Date.now(), + payload: { + meeting_uuid: 'meeting-uuid', + meeting_id: '12345678901', + operator_id: 'operator-id', + rtms_stream_id: 'stream-id', + server_urls: 'wss://rtms.zoom.us', + }, + }; + const signed = await send({ + body, + timestamp, + signature: buildZoomWebhookSignature( + JSON.stringify(body), + timestamp, + 'webhook-secret' + ), + }); + assert.equal(signed.statusCode, 204); + assert.equal( + ( + await zoomRtmsEventStore.waitForMeetingStart( + '12345678901', + eventScope, + 1 + ) + )?.payload.rtms_stream_id, + 'stream-id' + ); + + const rejected = await send({ + body, + timestamp, + signature: 'v0=invalid', + }); + assert.equal(rejected.statusCode, 401); + + const dedicatedChallenge = await send({ + webhookId: 'dedicated-app-1234', + body: { + event: 'endpoint.url_validation', + payload: { plainToken: 'dedicated-plain-token' }, + }, + }); + assert.deepEqual( + dedicatedChallenge.body, + buildZoomUrlValidationResponse( + 'dedicated-plain-token', + 'dedicated-webhook-secret' + ) + ); + + const dedicatedBody = { + ...body, + event_ts: Date.now() + 1, + payload: { + ...body.payload, + rtms_stream_id: 'dedicated-stream-id', + }, + }; + const dedicatedSigned = await send({ + webhookId: 'dedicated-app-1234', + body: dedicatedBody, + timestamp, + signature: buildZoomWebhookSignature( + JSON.stringify(dedicatedBody), + timestamp, + 'dedicated-webhook-secret' + ), + }); + assert.equal(dedicatedSigned.statusCode, 204); + assert.equal( + ( + await zoomRtmsEventStore.waitForMeetingStart( + '12345678901', + dedicatedScope, + 1 + ) + )?.payload.rtms_stream_id, + 'dedicated-stream-id' + ); + + const wrongAppSecret = await send({ + webhookId: 'dedicated-app-1234', + body: dedicatedBody, + timestamp, + signature: buildZoomWebhookSignature( + JSON.stringify(dedicatedBody), + timestamp, + 'webhook-secret' + ), + }); + assert.equal(wrongAppSecret.statusCode, 401); + + const unknownApp = await send({ + webhookId: 'unknown-dedicated-app', + body: dedicatedBody, + timestamp, + signature: buildZoomWebhookSignature( + JSON.stringify(dedicatedBody), + timestamp, + 'dedicated-webhook-secret' + ), + }); + assert.equal(unknownApp.statusCode, 404); + + const disabledApp = await send({ + webhookId: 'disabled-app-1234', + body: dedicatedBody, + timestamp, + signature: buildZoomWebhookSignature( + JSON.stringify(dedicatedBody), + timestamp, + 'disabled-webhook-secret' + ), + }); + assert.equal(disabledApp.statusCode, 404); + } finally { + await zoomRtmsEventStore.releaseMeeting( + '12345678901', + 'webhook-test-owner', + eventScope + ); + await zoomRtmsEventStore.releaseMeeting( + '12345678901', + 'dedicated-webhook-test-owner', + dedicatedScope + ); + config.zoomRtms.webhookSecret = originalSecret; + config.zoomRtms.customerCredentials = originalCustomerCredentials; + config.isRedisEnabled = originalRedisEnabled; + } +}); diff --git a/src/rtms/webhook.ts b/src/rtms/webhook.ts new file mode 100644 index 00000000..01327796 --- /dev/null +++ b/src/rtms/webhook.ts @@ -0,0 +1,123 @@ +import express, { Request, Response } from 'express'; +import config from '../config'; +import { zoomRtmsEventStore } from './ZoomRtmsEventStore'; +import { ZoomRtmsEventName, ZoomRtmsWebhookEvent, ZoomWebhookBody } from './types'; +import { + buildZoomUrlValidationResponse, + verifyZoomWebhookSignature, +} from './utils'; + +interface RawBodyRequest extends Request { + rawBody?: Buffer; +} + +const RTMS_EVENTS = new Set([ + 'meeting.rtms_started', + 'meeting.rtms_stopped', + 'meeting.rtms_interrupted', +]); + +export const captureRawBody = (req: Request, _res: unknown, buffer: Buffer): void => { + (req as RawBodyRequest).rawBody = Buffer.from(buffer); +}; + +const headerValue = (value: string | string[] | undefined): string | undefined => + Array.isArray(value) ? value[0] : value; + +const router = express.Router(); + +const resolveWebhook = ( + webhookId?: string +): { appId: string; secret?: string } | undefined => { + if (typeof webhookId === 'undefined') { + return { appId: 'global', secret: config.zoomRtms.webhookSecret }; + } + + const credentials = Object.values(config.zoomRtms.customerCredentials) + .find((customer) => + customer.enabled + && customer.rtmsApp?.webhookId === webhookId + ); + return credentials?.rtmsApp + ? { appId: webhookId, secret: credentials.rtmsApp.webhookSecret } + : undefined; +}; + +export const handleZoomRtmsWebhook = async (req: RawBodyRequest, res: Response) => { + const body = req.body as ZoomWebhookBody; + const webhook = resolveWebhook(req.params?.webhookId); + if (!webhook) { + return res.status(404).json({ error: 'Zoom RTMS webhook is not configured' }); + } + const { appId, secret } = webhook; + + if (body.event === 'endpoint.url_validation') { + const plainToken = body.payload?.plainToken; + if (typeof plainToken !== 'string' || !secret) { + return res.status(503).json({ error: 'Zoom RTMS webhook is not configured' }); + } + return res.json(buildZoomUrlValidationResponse(plainToken, secret)); + } + + const verified = verifyZoomWebhookSignature({ + rawBody: req.rawBody, + timestamp: headerValue(req.headers['x-zm-request-timestamp']), + signature: headerValue(req.headers['x-zm-signature']), + secret, + }); + if (!verified) { + return res.status(401).json({ error: 'Invalid Zoom webhook signature' }); + } + + if (!body.event || !RTMS_EVENTS.has(body.event as ZoomRtmsEventName)) { + return res.sendStatus(204); + } + + const payload = body.payload; + if ( + typeof body.event_ts !== 'number' + || !Number.isFinite(body.event_ts) + || !payload + || typeof payload.meeting_uuid !== 'string' + || typeof payload.rtms_stream_id !== 'string' + ) { + return res.status(400).json({ error: 'Invalid Zoom RTMS webhook payload' }); + } + + if ( + body.event === 'meeting.rtms_started' + && ( + !['string', 'number'].includes(typeof payload.meeting_id) + || typeof payload.operator_id !== 'string' + || typeof payload.server_urls !== 'string' + ) + ) { + return res.status(400).json({ error: 'Invalid Zoom RTMS start event' }); + } + + if ( + body.event === 'meeting.rtms_stopped' + && (typeof payload.stop_reason !== 'number' || !Number.isFinite(payload.stop_reason)) + ) { + return res.status(400).json({ error: 'Invalid Zoom RTMS stop event' }); + } + + const event: ZoomRtmsWebhookEvent = { + event: body.event as ZoomRtmsEventName, + event_ts: body.event_ts, + payload: payload as ZoomRtmsWebhookEvent['payload'], + }; + + try { + await zoomRtmsEventStore.publish(event, appId); + return res.sendStatus(204); + } catch (error) { + console.error('Unable to persist Zoom RTMS webhook event', error); + return res.status(503).json({ error: 'Zoom RTMS event queue unavailable' }); + } +}; + +router.post('/', handleZoomRtmsWebhook); +router.post('/apps/:webhookId', handleZoomRtmsWebhook); + +export default router; diff --git a/src/services/botService.ts b/src/services/botService.ts index f7cbecd2..abfb078a 100644 --- a/src/services/botService.ts +++ b/src/services/botService.ts @@ -3,6 +3,14 @@ import { BotStatus, IVFSResponse, LogCategory, LogSubCategory } from '../types'; import config from '../config'; import { Logger } from 'winston'; +export const hasUsableLegacyCoreToken = (token: string): boolean => { + const normalized = token.trim().toLowerCase(); + return normalized.length > 0 && + normalized !== 'your-auth-token' && + normalized !== 'placeholder' && + normalized !== 'unused'; +}; + export const patchBotStatus = async ({ eventId, botId, @@ -16,6 +24,10 @@ export const patchBotStatus = async ({ provider: 'google' | 'microsoft' | 'zoom', status: BotStatus[], }, logger: Logger) => { + if (!hasUsableLegacyCoreToken(token)) { + logger.debug('Skipping legacy Core bot status callback because no user bearer token was supplied'); + return true; + } try { const apiV2 = createApiV2(token, config.serviceKey); const response = await apiV2.patch< @@ -59,6 +71,10 @@ export const addBotLog = async ({ category: LogCategory, subCategory: LogSubCategory, }, logger: Logger) => { + if (!hasUsableLegacyCoreToken(token)) { + logger.debug('Skipping legacy Core bot log callback because no user bearer token was supplied'); + return true; + } try { const apiV2 = createApiV2(token, config.serviceKey); const response = await apiV2.patch< diff --git a/src/tasks/RecordingTask.ts b/src/tasks/RecordingTask.ts index 0e3c1e0b..5619f137 100644 --- a/src/tasks/RecordingTask.ts +++ b/src/tasks/RecordingTask.ts @@ -30,12 +30,14 @@ export class RecordingTask extends Task { } protected async execute(): Promise { - const { mimeTypes } = getRecordingMimeTypesForExtension(config.uploaderFileExtension); + // Zoom records via real-time tab capture, often under software GL (swiftshader). + // VP9 can't keep up there and drops frames -> laggy playback, so prefer VP8. + const { mimeTypes } = getRecordingMimeTypesForExtension(config.uploaderFileExtension, true); const loneParticipantExitDelayMs = config.loneParticipantExitDelaySeconds * 1000; await this.page.evaluate( - async ({ teamId, duration, inactivityLimit, loneParticipantExitDelayMs, userId, slightlySecretId, activateInactivityDetectionAfter, activateInactivityDetectionAfterMinutes, mimeTypes }: - { teamId: string, duration: number, inactivityLimit: number, loneParticipantExitDelayMs: number, userId: string, slightlySecretId: string, activateInactivityDetectionAfter: string, activateInactivityDetectionAfterMinutes: number, mimeTypes: string[] }) => { + async ({ teamId, duration, inactivityLimit, loneParticipantExitDelayMs, userId, slightlySecretId, activateInactivityDetectionAfter, activateInactivityDetectionAfterMinutes, mimeTypes, videoBitsPerSecond }: + { teamId: string, duration: number, inactivityLimit: number, loneParticipantExitDelayMs: number, userId: string, slightlySecretId: string, activateInactivityDetectionAfter: string, activateInactivityDetectionAfterMinutes: number, mimeTypes: string[], videoBitsPerSecond: number }) => { let timeoutId: NodeJS.Timeout; let inactivitySilenceDetectionTimeout: NodeJS.Timeout; @@ -84,7 +86,7 @@ export class RecordingTask extends Task { } console.log(`Media Recorder will use ${selectedMimeType} codecs...`); - const mediaRecorder = new MediaRecorder(stream, { mimeType: selectedMimeType }); + const mediaRecorder = new MediaRecorder(stream, { mimeType: selectedMimeType, videoBitsPerSecond }); console.log(`Media Recorder actual mime type: ${mediaRecorder.mimeType}`); let chunkUploadChain: Promise = Promise.resolve(); let isStoppingRecording = false; @@ -182,6 +184,29 @@ export class RecordingTask extends Task { loneTest = setInterval(() => { try { + const terminalText = (dom.body?.innerText ?? '').toLowerCase(); + const participantWasRemoved = [ + 'you have been removed from the meeting', + 'you have been removed by the host', + 'the host has removed you from this meeting', + ].some((text) => terminalText.includes(text)); + const meetingHasEnded = [ + 'this meeting has been ended by host', + 'this meeting has ended', + 'meeting has been ended', + ].some((text) => terminalText.includes(text)); + + if (participantWasRemoved || meetingHasEnded) { + console.log('Detected terminal Zoom meeting state; finalizing recording.', { + userId, + reason: participantWasRemoved ? 'participant_removed' : 'meeting_ended', + }); + clearInterval(loneTest); + monitor = false; + void stopTheRecording(); + return; + } + // Detect and click blocking "OK" buttons const okButton = Array.from(dom.querySelectorAll('button')) .filter((el) => el?.innerText?.trim()?.match(/^OK/i)); @@ -308,16 +333,17 @@ export class RecordingTask extends Task { // Start the recording await startRecording(); }, - { +{ teamId: this.teamId, duration: this.duration, - inactivityLimit: this.inactivityLimit, + inactivityLimit: this.inactivityLimit, loneParticipantExitDelayMs, - userId: this.userId, + userId: this.userId, slightlySecretId: this.slightlySecretId, activateInactivityDetectionAfterMinutes: config.activateInactivityDetectionAfter, activateInactivityDetectionAfter: new Date(new Date().getTime() + config.activateInactivityDetectionAfter * 60 * 1000).toISOString(), - mimeTypes + mimeTypes, + videoBitsPerSecond: config.recordingVideoBitsPerSecond, } ); } diff --git a/src/util/logger.ts b/src/util/logger.ts index 4735eddb..4ecc2de3 100644 --- a/src/util/logger.ts +++ b/src/util/logger.ts @@ -1,9 +1,48 @@ import { ConsoleMessage } from 'playwright'; import { createLogger, format, transports, Logger } from 'winston'; import { v5 as uuidv5, v4 } from 'uuid'; +import { redactSensitiveString } from '../monitoring/sentry'; const NAMESPACE = uuidv5.DNS; +const SENSITIVE_KEY = /(accountid|authorization|bearer|clientid|cookie|oauth|participantuserid|password|secret|token|bodytext|documentbodytext|rawbody)/i; +const URL_KEY = /url/i; + +export const sanitizeUrl = (value: string): string => { + try { + const parsed = new URL(value); + parsed.search = ''; + parsed.hash = ''; + return parsed.toString(); + } catch { + return value.replace(/([?&](?:pwd|password|token|code|key|secret)=)[^&#\s]*/gi, '$1[REDACTED]'); + } +}; + +const sanitizeString = (value: string): string => + redactSensitiveString(value).replace( + /\b(?:https?|wss?|zoommtg):\/\/[^\s"'<>]+/gi, + (match) => sanitizeUrl(match) + ); + +export const redactSensitive = (value: unknown, key = '', seen = new WeakSet()): unknown => { + if (SENSITIVE_KEY.test(key)) return '[REDACTED]'; + if (typeof value === 'string') return URL_KEY.test(key) ? sanitizeUrl(value) : sanitizeString(value); + if (value === null || typeof value !== 'object') return value; + if (value instanceof Error) { + return { name: value.name, message: sanitizeString(value.message) }; + } + if (seen.has(value)) return '[Circular]'; + seen.add(value); + if (Array.isArray(value)) return value.map((entry) => redactSensitive(entry, key, seen)); + return Object.fromEntries( + Object.entries(value).map(([entryKey, entryValue]) => [ + entryKey, + redactSensitive(entryValue, entryKey, seen), + ]) + ); +}; + export function loggerFactory(correlationId: string, botType?: string): Logger { return createLogger({ format: format.combine( @@ -16,9 +55,10 @@ export function loggerFactory(correlationId: string, botType?: string): Logger { return info; })(), format.printf(({ timestamp, level, message, correlationId, botType, ...meta }) => { - const metaStr = Object.keys(meta).length ? JSON.stringify(meta) : ''; + const redactedMeta = redactSensitive(meta); + const metaStr = Object.keys(meta).length ? JSON.stringify(redactedMeta) : ''; const botTypeStr = botType ? ` [botType: ${botType}]` : ''; - return `[${timestamp}] [${level}] [correlationId: ${correlationId}]${botTypeStr} ${message} ${metaStr}`; + return `[${timestamp}] [${level}] [correlationId: ${correlationId}]${botTypeStr} ${sanitizeString(String(message))} ${metaStr}`; }), ), transports: [new transports.Console()], @@ -27,23 +67,23 @@ export function loggerFactory(correlationId: string, botType?: string): Logger { export const browserLogCaptureCallback = async (logger: Logger, msg: ConsoleMessage) => { try { - const values: unknown[] = []; - for (const arg of msg?.args()) { - values.push(await arg?.jsonValue()); - } + const metadata = { + browserConsoleType: msg.type(), + source: sanitizeUrl(msg.location().url || 'unknown'), + }; switch (msg?.type()) { case 'error': - logger.error(`[Playwright chrome logger] ${msg.text()}`, ...values); + logger.error('[Playwright browser console error]', metadata); break; case 'warning': - logger.warn(`[Playwright chrome logger] ${msg.text()}`, ...values); + logger.warn('[Playwright browser console warning]', metadata); break; case 'info': case 'log': - logger.info(`[Playwright chrome logger] ${msg.text()}`, ...values); + logger.info('[Playwright browser console message]', metadata); break; default: - logger.info(`[Playwright chrome logger] ${msg.text()}`, ...values); + logger.info('[Playwright browser console event]', metadata); break; } } catch(err) { @@ -68,12 +108,12 @@ export const createCorrelationId = ({ const entityId = botId ?? eventId; const name = `${userId}:${entityId}:${url}`; const id = uuidv5(name, NAMESPACE); - console.log(`[correlationId:${id}]`, { + console.log('[correlationId:%s]', id, { correlationId: id, userId, eventId, botId, - url, + url: sanitizeUrl(url), teamId, method: 'v5' }); @@ -81,12 +121,12 @@ export const createCorrelationId = ({ } catch(err) { console.error('Unable to create deterministic correlationId', { userId, teamId, err }); const id = v4(); - console.log(`[correlationId:${id}]`, { + console.log('[correlationId:%s]', id, { correlationId: id, userId, eventId, botId, - url, + url: sanitizeUrl(url), teamId, method: 'v4' });