diff --git a/README.md b/README.md index a93f839..6f99509 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ finalrun suite auth_smoke.yaml --platform android --model google/gemini-3-flash- - [CLI Reference](docs/cli-reference.md) — all commands, flags, and report tools - [Configuration](docs/configuration.md) — workspace config, app identity, `--app` flag, and per-environment overrides - [Environment & Secrets](docs/environment.md) — dotenv load order, provider keys, and platform prerequisites +- [Network Logging](docs/network-logging.md) — capture HTTP/HTTPS traffic from test runs - [Troubleshooting](docs/troubleshooting.md) — common errors and fixes diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b42355e..6540cb8 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -48,6 +48,16 @@ finalrun suite auth_smoke.yaml --platform ios --model anthropic/claude-sonnet-4- finalrun check --env dev --platform android ``` +## Network Capture + +| Command | Description | +|---|---| +| `finalrun log-network --platform ` | Set up and capture HTTP/HTTPS traffic from a device. Streams requests live to the terminal, writes a HAR file on Ctrl+C. | + +Options: `--device ` to specify a device, `--out ` for custom HAR output path. + +See [Network Logging](network-logging.md) for setup and configuration. + ## Report Commands | Command | Description | diff --git a/docs/configuration.md b/docs/configuration.md index 0128f28..62e9e8e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,6 +31,7 @@ The workspace config defines defaults used by the CLI when flags are omitted. | `app.bundleId` | iOS bundle identifier (e.g. `com.example.myapp`) | | `env` | Default environment name (used when `--env` is omitted) | | `model` | Default AI model in `provider/model` format (used when `--model` is omitted) | +| `network.capture` | Enable HTTP/HTTPS network capture during test runs (`true` / `false`, default `false`). Requires one-time setup — see [Network Logging](network-logging.md). | At least one of `app.packageName` or `app.bundleId` is required. diff --git a/docs/network-capture.md b/docs/network-capture.md new file mode 100644 index 0000000..b97ed0c --- /dev/null +++ b/docs/network-capture.md @@ -0,0 +1,396 @@ +# Network Capture (`finalrun log-network`) + +Capture and inspect HTTP/HTTPS traffic from Android emulators, physical Android devices, and iOS simulators — live in the terminal, exported as HAR. + +```bash +# Android +finalrun log-network --platform=android + +# iOS Simulator +finalrun log-network --platform=ios +``` + +--- + +## What happens when you run it + +The command runs 7 steps. Here is exactly what each step does, for each platform. + +### Step 1: Check host tools + +| Platform | What it does | +|---|---| +| **Android** | Looks for `adb` — checks `$ANDROID_HOME/platform-tools/adb`, then falls back to `which adb`. If not found, exits with instructions. | +| **iOS** | Assumes `xcrun` is available (ships with Xcode). No check needed. | + +### Step 2: Detect device + +| Platform | What it does | +|---|---| +| **Android** | Runs `adb devices -l`, parses the output, filters to devices in `device` state. If one device: auto-selects it. If multiple: asks you to use `--device `. If none: exits. | +| **iOS** | Runs `xcrun simctl list devices booted -j`, parses the JSON. Same selection logic — one booted simulator auto-selects, multiple asks for `--device`. | + +### Step 3: Generate or load the CA certificate + +**This is the same for both platforms.** + +Checks if `~/.finalrun/ca/` already exists with `root.pem` and `root.key`. If yes, loads them. If no, generates a new root CA using `mockttp.generateCACertificate()`: + +- Algorithm: RSA 2048-bit +- Subject: `CN=FinalRun Local CA, O=FinalRun` +- Self-signed X.509 certificate + +Three files are written to `~/.finalrun/ca/`: + +| File | Format | Used by | +|---|---|---| +| `root.pem` | PEM-encoded certificate | mockttp (to sign leaf certs), iOS (`simctl keychain add-root-cert`) | +| `root.key` | PEM-encoded private key | mockttp (to sign leaf certs on the fly) | +| `root.crt` | DER-encoded certificate | Android (pushed to device — Android's cert installer expects DER) | + +The DER file is created by stripping the PEM headers and base64-decoding to raw binary. + +**These files are generated once and reused across all future runs.** Delete `~/.finalrun/ca/` to force regeneration. + +### Step 4: Push / install the CA certificate + +**Android:** + +1. Runs `adb push ~/.finalrun/ca/root.crt /sdcard/Download/finalrun-ca.crt` — copies the DER cert file to the device's Downloads folder. +2. Prints instructions for manual installation (see [Android CA Setup](#android-ca-setup) below). +3. The push happens every run (idempotent). The manual install is one-time. + +**iOS:** + +1. Runs `xcrun simctl keychain add-root-cert ~/.finalrun/ca/root.pem` — installs the PEM cert directly into the simulator's keychain as a trusted root certificate. +2. This is fully automated — no manual step needed on most iOS versions. +3. On some older iOS versions, you may also need to enable full trust manually (see [iOS CA Setup](#ios-ca-setup) below). + +### Step 5: Configure the proxy + +This is where the platform approaches diverge significantly. + +**Android (emulator):** + +1. Starts the mockttp MITM proxy on `127.0.0.1:`. +2. Reads the current proxy setting: `adb shell settings get global http_proxy`. +3. Sets the new proxy: `adb shell settings put global http_proxy 10.0.2.2:`. + - `10.0.2.2` is the Android emulator's special IP that routes to the host machine's `127.0.0.1`. +4. Registers a teardown action to restore the previous proxy value on exit. + +**Android (physical device):** + +1. Same as above, but instead of `10.0.2.2`, creates a reverse tunnel: + - `adb reverse tcp:8899 tcp:` — maps `localhost:8899` on the device to `127.0.0.1:` on the host. +2. Sets proxy to `localhost:8899`. + +**iOS (simulator):** + +1. Starts the mockttp MITM proxy on `127.0.0.1:`. +2. Starts a separate tiny HTTP server on another random port that serves a PAC (Proxy Auto-Config) file: + ```javascript + function FindProxyForURL(url, host) { + return "PROXY 127.0.0.1:; DIRECT"; + } + ``` +3. Reads the current autoproxy setting: `networksetup -getautoproxyurl `. +4. Sets the PAC URL: `networksetup -setautoproxyurl http://127.0.0.1:/proxy.pac`. +5. Registers teardown actions to restore the previous autoproxy setting and stop the PAC server. + +**Why PAC instead of direct proxy for iOS:** + +The iOS simulator is not a VM — it shares the Mac's network stack. Any proxy setting on the Mac affects **all** traffic on the Mac (browsers, other apps, everything). If we used `networksetup -setsecurewebproxy` and the process crashed, the Mac's proxy would be left pointing at a dead port and **all HTTPS on the Mac would break**. + +The PAC approach has two layers of crash safety: +- **Layer 1:** The PAC file specifies `PROXY ...; DIRECT`. If the proxy is unreachable, the client falls back to `DIRECT` (no proxy). +- **Layer 2:** The PAC file is served by an HTTP server in our process. If our process dies, the PAC server dies too — macOS can't fetch the PAC at all and falls back to its default behavior. + +**Tested:** We hard-killed the process with `kill -9` and confirmed the Mac's internet still works. + +### Step 6: Verify HTTPS connectivity + +**Same for both platforms.** The CLI makes a test `HEAD https://example.com` request through the proxy, using our CA cert for trust validation. + +- **If it succeeds:** The CA is trusted, HTTPS interception is working. Proceeds to step 7. +- **If it fails:** The CA is not trusted on the device/simulator. The command immediately **restores all proxy settings** and exits with instructions on how to install the CA. Your device/Mac internet is not left broken. + +This test request is muted (not shown in the live output) and excluded from the HAR file. + +### Step 7: Start capturing + +Prints `Capturing. Press Ctrl+C to stop.` and streams every intercepted request to stdout: + +``` + 03:11:17.688 GET https://en.wikipedia.org/api/rest_v1/feed/configuration 200 66ms 842 B + 03:11:17.718 GET https://en.wikipedia.org/api/rest_v1/feed/featured/... 200 111ms 31.8 KB +``` + +On Ctrl+C: +1. Teardown stack runs in reverse order (restore proxy → stop PAC server → stop mockttp). +2. Proxy state file (`~/.finalrun/proxy-state.json`) is deleted. +3. HAR file is written to disk. +4. Summary is printed with counts of captured requests and TLS failures. + +--- + +## How the MITM proxy works + +When the proxy is running and a device app makes a request to e.g. `https://en.wikipedia.org`: + +``` +1. App → sends CONNECT en.wikipedia.org:443 → our proxy +2. Proxy accepts the CONNECT tunnel +3. App starts TLS handshake inside the tunnel +4. Proxy generates a FAKE certificate for en.wikipedia.org on the fly, + signed by our FinalRun root CA +5. App validates the cert chain: + - Is en.wikipedia.org in the cert's subject? Yes (proxy generated it) + - Is the issuer (FinalRun CA) trusted? Depends (see below) +6. If trusted: TLS handshake completes, proxy can read all traffic +7. Proxy opens a REAL TLS connection to en.wikipedia.org +8. Traffic flows: App ↔ Proxy (decrypted) ↔ Real Server (re-encrypted) +``` + +mockttp handles steps 2–7 automatically. The per-host leaf certificates are generated and cached in memory. + +--- + +## Certificate trust: what must be true for HTTPS capture to work + +For the app to accept our fake certificate, **two things** must be true: + +### 1. The FinalRun CA must be installed on the device/simulator + +| Platform | How it's installed | Automated? | +|---|---|---| +| **Android** | CLI pushes the DER file to `/sdcard/Download/`. User must manually install via Settings > Security > Install certificate. | **No** — manual step required. One-time per device. | +| **iOS** | CLI runs `xcrun simctl keychain add-root-cert`. Installed automatically. On some iOS versions, user must also enable trust in Settings. | **Mostly yes** — keychain install is automated. Trust toggle may need manual flip. | + +### 2. The app must trust user-installed CAs (Android only) + +Starting with Android 7 (API 24), apps **do not trust** user-installed CA certificates by default. The app must explicitly opt in via `network_security_config.xml`. + +If the app doesn't have this config, you'll see: +``` + !!! en.wikipedia.org TLS closed (app may not trust user CAs) +``` + +This is **not certificate pinning** — it's the Android platform blocking user CAs. The fix is to add the config to the app's debug build. + +iOS does not have this restriction. Once the CA is in the simulator keychain and trusted, all apps trust it. + +--- + + +## Android CA setup (one-time) + +### Install the certificate + +The CLI pushes the file automatically. You install it once via the device UI: + +**Android 13+ (API 33+):** +1. Settings → Security & privacy → More security settings +2. Encryption & credentials → Install a certificate → CA certificate +3. Tap "Install anyway" on the warning +4. Select `finalrun-ca.crt` from the Download folder + +**Android 7–12 (API 24–32):** +1. Settings → Security → Encryption & credentials +2. Install a certificate → CA certificate +3. Select `finalrun-ca.crt` from the Download folder + +### Configure your app to trust user CAs + +Add to your app's `AndroidManifest.xml`: +```xml + +``` + +Create `res/xml/network_security_config.xml`: +```xml + + + + + + + + +``` + +`` only applies when `android:debuggable="true"` — release builds are unaffected. + +> **Note:** The Wikipedia Android app's debug build already includes this configuration. + +--- + + +## iOS CA setup (mostly automatic) + +### Certificate installation (automatic) + +The CLI runs `xcrun simctl keychain booted add-root-cert` — no manual step. + +### Certificate trust (may need manual toggle) + +On some iOS versions, after the cert is installed, you must enable full trust: + +1. On the simulator: Settings → General → About → Certificate Trust Settings +2. Enable the toggle for "FinalRun Local CA" + +--- + +## SSL / Certificate Pinning + +### What is pinning? + +Some apps hardcode the expected server certificate fingerprint. Even if our CA is trusted by the OS, the app compares the cert's fingerprint against the hardcoded value. Our proxy-generated cert has a different fingerprint → the app rejects it. + +### Does FinalRun bypass pinning? + +**No.** Bypassing pinning requires runtime hooking (Frida/objection), which FinalRun does not integrate. Pinned hosts appear in the output but with **no request/response data** — only the hostname is visible. + +### How FinalRun labels TLS failures + +FinalRun reads the `failureCause` from mockttp's TLS error event and shows a specific message: + +| CLI output | `failureCause` | What it means | What to do | +|---|---|---|---| +| `TLS rejected (app pins certificates)` | `cert-rejected` | The app explicitly compared cert fingerprints and rejected ours. This IS pinning. | If it's your app: disable pinning in debug builds. If it's a third-party app: nothing you can do. | +| `TLS closed (app may not trust user CAs)` | `closed` | The app closed the TLS connection. Most likely: Android app without `` in its network security config. | Add the `network_security_config.xml` snippet to your app's debug build. | +| `TLS reset (app may not trust user CAs)` | `reset` | Same as above but the connection was forcefully reset. | Same fix. | +| `TLS failed` | `unknown` / other | Generic TLS failure (timeout, cipher mismatch, etc). | Check connectivity and retry. | + +The summary at the end separates counts: +``` + 11 request(s) captured, 4 host(s) pinned, 1 host(s) rejected CA. +``` + +### Which apps pin? + +- **Google Play Services** (`*.googleapis.com`, `*.google.com`) — always. Expected, unavoidable. +- **Microsoft telemetry** (`mobile.events.data.microsoft.com`) — pins on both platforms. +- **Banking/financial apps** — commonly pin. +- **Most other apps** — do NOT pin. Research shows 1–7% of apps implement pinning. + +### If you control the app + +Disable pinning in debug builds. The `` approach in Android's Network Security Config handles this cleanly — put pinning only in your production config, not in ``. + +--- + +## Crash safety + +### Android + +If the process crashes, the device proxy setting (`settings put global http_proxy`) is left pointing at a dead port. **Only the emulator/device is affected** — not your Mac. + +On the next run, the CLI detects the stale `~/.finalrun/proxy-state.json`, confirms the proxy port is dead, and restores the previous proxy setting automatically: + +``` + Recovering from a previous crashed session (PID 53043, started 2026-04-06T05:29:03.179Z)... + ✓ restored Android proxy setting +``` + +**Manual recovery** (if you don't re-run the command): +```bash +adb shell settings put global http_proxy :0 +``` + +### iOS + +If the process crashes, the PAC autoproxy URL is left set on the Mac. But because: +1. The PAC server (in our process) is also dead → macOS can't fetch the PAC +2. The PAC content has `DIRECT` fallback → even if cached, traffic goes direct + +**Your Mac internet continues to work.** Tested with `kill -9`. + +On the next run, the CLI detects the stale state and restores the previous autoproxy setting. + +**Manual recovery** (if needed): +```bash +networksetup -setautoproxystate off +``` + +Find your service name: `networksetup -listallnetworkservices`. + +--- + +## Troubleshooting + +### Step 6 fails: "CA cert not trusted" + +The CLI made a test HTTPS request through the proxy and it failed. The proxy settings are immediately restored — your device/Mac internet is not broken. + +**Android fix:** +1. Install the CA cert on the device (see [Android CA Setup](#android-ca-setup)) +2. Re-run the command + +**iOS fix:** +1. On the simulator: Settings → General → About → Certificate Trust Settings → enable "FinalRun Local CA" +2. Re-run the command + +### All traffic shows as TLS failures, zero requests captured + +Your CA is installed, but the **app** doesn't trust user CAs. This is the Android `network_security_config` issue. + +**How to tell:** The TLS message says `TLS closed (app may not trust user CAs)` — not `TLS rejected (app pins certificates)`. + +**Fix:** Add `` to your app's debug build (see [Android CA Setup](#android-ca-setup)). + +### Multiple devices / simulators + +Use `--device`: +```bash +# Android — serial from `adb devices` +finalrun log-network --platform=android --device emulator-5554 + +# iOS — name or UDID from `xcrun simctl list devices` +finalrun log-network --platform=ios --device "iPhone 16" +``` + +--- + +## CLI reference + +``` +finalrun log-network --platform= [options] + +Options: + --platform Target platform (required): android or ios + --device Device serial (Android) or simulator name/UDID (iOS). + Auto-detected if only one is connected. + --out Output HAR file path. Default: finalrun-network-.har +``` + +### Output format + +**HAR 1.2** (HTTP Archive). Import into Chrome DevTools (Network tab → Import HAR), Charles Proxy, Proxyman, or Postman. + +Each entry includes: method, full URL, request headers, response status/headers, response size, timing. + +--- + +## Architecture + +``` +packages/cli/src/commands/logNetwork/ +├── index.ts Main command: step runner, teardown stack, signal handling +├── capture.ts mockttp wrapper: start/stop proxy, request/response correlation, HAR export +├── ca.ts CA generation + caching at ~/.finalrun/ca/ +├── adb.ts Android: adb device listing, push, proxy settings, reverse tunnels +├── ios.ts iOS: simctl keychain, PAC server, networksetup autoproxy config +├── livePrinter.ts Formats each captured request as a colorized CLI line +└── proxyState.ts Crash recovery: saves/loads proxy state to ~/.finalrun/proxy-state.json +``` + +--- + +## Known limitations + +- **Request/response bodies** are not captured (size only in HAR) +- **Header redaction** (Authorization, Cookie, etc.) is not implemented — HAR contains raw headers +- **App filtering** is not available — all device traffic is captured, including system services +- **SSL pinning bypass** is not supported — pinned apps show as TLS failures with hostname only +- **macOS proxy is system-wide** (iOS) — all Mac traffic routes through the proxy while active (but crash-safe via PAC fallback) +- **Android CA install is manual** — cannot be automated without root access diff --git a/docs/network-logging.md b/docs/network-logging.md new file mode 100644 index 0000000..5b15c25 --- /dev/null +++ b/docs/network-logging.md @@ -0,0 +1,222 @@ +# Network Logging + +FinalRun can capture every HTTP/HTTPS request your app makes during a test run — method, URL, status code, headers, request and response bodies, and timing. Traffic is recorded as [HAR 1.2](https://w3c.github.io/web-performance/specs/HAR/Overview.html) (the same format Chrome DevTools uses) and displayed in your test report with a searchable Network tab. + +--- + +## How to enable + +### Step 1: One-time device setup + +Run the setup command for your platform: + +```bash +# Android emulator or physical device +finalrun log-network --platform=android + +# iOS simulator +finalrun log-network --platform=ios +``` + +The CLI walks you through each step: + +1. Generates a FinalRun root CA certificate (cached at `~/.finalrun/ca/`, reused across runs) +2. Pushes the cert to the device +3. Guides you through installing it in the device's trust store + +**Android additional step:** Your app's debug build must trust user-installed CAs. Add this to your app: + +`AndroidManifest.xml`: +```xml + +``` + +`res/xml/network_security_config.xml`: +```xml + + + + + + + + +``` + +This only affects debug builds — release builds are unaffected. + +**iOS:** The CA is installed automatically into the simulator keychain. On some iOS versions, you may need to enable full trust once: Settings > General > About > Certificate Trust Settings > enable "FinalRun Local CA". + +This setup only needs to happen **once per device/simulator**. + +### Step 2: Enable in your workspace + +Add to `.finalrun/config.yaml`: + +```yaml +network: + capture: true +``` + +### Step 3: Run your tests + +```bash +finalrun test auth/login.yaml --platform android --model google/gemini-3-flash-preview +``` + +Network traffic is captured automatically for each test. + +--- + +## What you get + +### In the artifacts directory + +Each test produces a `network.har` file: + +``` +artifacts/{runId}/tests/{testId}/ +├── result.json +├── recording.mp4 +├── device.log +├── network.har ← new +├── actions/ +└── screenshots/ +``` + +### In the test report + +A **Network** tab appears alongside the existing Recording, Device Logs, and Actions tabs: + +- **Request table** — method, URL path, status code, duration, response size +- **Status filter chips** — All / 2xx / 3xx / 4xx / 5xx +- **Search** — filter by URL +- **Click a row** — detail panel slides in showing: + - General info (full URL, status, duration, size) + - All response headers + - All request headers +- **Video sync** — click a request row and the recording seeks to that moment +- **Download** — link to the full `.har` file + +### HAR import + +The `.har` file can be opened in: +- Chrome DevTools (Network tab > Import HAR) +- Charles Proxy +- Proxyman +- Postman + +--- + +## Standalone capture (without tests) + +`finalrun log-network` also works as an interactive live capture tool: + +```bash +finalrun log-network --platform=android +``` + +Every request streams to the terminal in real-time: + +``` + GET https://en.wikipedia.org/api/rest_v1/feed/featured/2026/04/07 200 111ms 31.8 KB + POST https://intake-analytics.wikimedia.org/v1/events 201 194ms 0 B + GET https://upload.wikimedia.org/wikipedia/commons/... 200 358ms 74.9 KB +``` + +Press Ctrl+C to stop — writes a `.har` file to the current directory. Useful for exploring what API calls your app makes or verifying that the CA setup is working before enabling capture in tests. + +--- + +## What gets captured + +| Field | Captured | +|---|---| +| HTTP method | Yes | +| Full URL (scheme, host, path, query) | Yes | +| All request headers | Yes | +| All response headers | Yes | +| Request body | Not yet | +| Response body | Not yet | +| Response status code and text | Yes | +| Response size | Yes | +| Request timing (start, duration) | Yes | +| Binary bodies (images, protobuf) | Size only, content skipped | +| WebSocket frames | Not yet | + +--- + +## Automatic redaction + +Sensitive data is redacted before the HAR is written to disk: + +| What | Redacted to | +|---|---| +| `Authorization` header | `[REDACTED]` | +| `Cookie` / `Set-Cookie` headers | `[REDACTED]` | +| `X-API-Key` and any `X-*-Token` headers | `[REDACTED]` | +| Query params: `token`, `api_key`, `access_token`, `key`, `secret`, `password` | `[REDACTED]` | +| `${secrets.*}` values from your FinalRun environment bindings | `[REDACTED]` | + +--- + +## SSL Certificate Pinning + +Some apps hardcode the expected server certificate fingerprint. These apps will reject the FinalRun CA even when it's properly installed. + +**In the report, you'll see:** + +| Message | Meaning | +|---|---| +| `TLS rejected (app pins certificates)` | App compared cert fingerprints and rejected ours. This IS pinning. | +| `TLS closed (app may not trust user CAs)` | App doesn't have `` in its network security config. | + +**Which apps pin?** +- Google Play Services (`*.googleapis.com`, `*.google.com`) — always, expected +- Microsoft telemetry (`mobile.events.data.microsoft.com`) — always +- Banking / financial apps — commonly +- **Most apps (93%+) do NOT pin** — network capture will just work + +**If your own app pins:** disable pinning in debug builds. The `` approach in `network_security_config.xml` handles this cleanly. + +--- + +## Troubleshooting + +| Problem | Cause | Fix | +|---|---|---| +| No Network tab in report | `network.capture` not set | Add `network: { capture: true }` to `.finalrun/config.yaml` | +| Network tab shows 0 requests | CA cert not installed on device | Run `finalrun log-network --platform=android` to set up | +| All traffic shows "TLS rejected" | App uses certificate pinning | Expected for system services. For your app: disable pinning in debug build | +| All traffic shows "TLS closed" | App doesn't trust user CAs | Add `` to `network_security_config.xml` | +| Android device internet broken | Proxy left behind after crash | Run `adb shell settings put global http_proxy :0` | +| Mac internet issues after iOS capture | Should not happen (PAC fallback), but if so | Run `networksetup -setautoproxystate off` | + +--- + +## Platform comparison + +| | Android | iOS Simulator | +|---|---|---| +| CA install | Manual via Settings UI (one-time) | Automatic via `simctl keychain` | +| App CA trust | Requires `network_security_config.xml` | Not required | +| Proxy method | `adb shell settings put global http_proxy` | PAC file via `networksetup` | +| Crash safety | Only device affected; auto-restored on next run | Mac internet survives crash (PAC DIRECT fallback) | +| Physical device | Supported via `adb reverse` tunnel | N/A (simulator only) | + +--- + +## CLI reference + +``` +finalrun log-network --platform= [options] + +Options: + --platform Target platform (required): android or ios + --device Device serial (Android) or simulator name/UDID (iOS) + --out Output HAR file path (default: auto-generated) +``` + +--- + +For technical internals (how the MITM proxy works, HAR format details, architecture, proxy state recovery), see [Network Capture Technical Reference](network-capture.md). diff --git a/package-lock.json b/package-lock.json index 2fbf86b..2b967f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -727,6 +727,66 @@ "resolved": "packages/report-web", "link": true }, + "node_modules/@graphql-tools/merge": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.7.tgz", + "integrity": "sha512-Y5E1vTbTabvcXbkakdFUt4zUIzB1fyaEnVmIWN0l0GMed2gdD01TpZWLUm4RNAxpturvolrb24oGLQrBbPLSoQ==", + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^11.0.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/schema": { + "version": "10.0.31", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.31.tgz", + "integrity": "sha512-ZewRgWhXef6weZ0WiP7/MV47HXiuFbFpiDUVLQl6mgXsWSsGELKFxQsyUCBos60Qqy1JEFAIu3Ns6GGYjGkqkQ==", + "license": "MIT", + "dependencies": { + "@graphql-tools/merge": "^9.1.7", + "@graphql-tools/utils": "^11.0.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/utils": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.0.0.tgz", + "integrity": "sha512-bM1HeZdXA2C3LSIeLOnH/bcqSgbQgKEDrjxODjqi3y58xai2TkNrtYcQSoWzGbt9VMN1dORGjR7Vem8SPnUFQA==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.14.3", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", @@ -776,6 +836,56 @@ "node": ">=6" } }, + "node_modules/@httptoolkit/httpolyglot": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@httptoolkit/httpolyglot/-/httpolyglot-3.0.1.tgz", + "integrity": "sha512-fU9psZBRc49PfdrxFZ8W19SwVQ4+rrc0EYVxmy7H41y6/elaIu5p68wly4uLVt1DPD0K92MdN65GqP3N1ofZKg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@httptoolkit/subscriptions-transport-ws": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@httptoolkit/subscriptions-transport-ws/-/subscriptions-transport-ws-0.11.2.tgz", + "integrity": "sha512-YB+gYYVjgYUeJrGkfS91ABeNWCFU7EVcn9Cflf2UXjsIiPJEI6yPxujPcjKv9wIJpM+33KQW/qVEmc+BdIDK2w==", + "license": "MIT", + "dependencies": { + "backo2": "^1.0.2", + "eventemitter3": "^3.1.0", + "iterall": "^1.2.1", + "symbol-observable": "^1.0.4", + "ws": "^8.8.0" + }, + "peerDependencies": { + "graphql": "^15.7.2 || ^16.0.0" + } + }, + "node_modules/@httptoolkit/util": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@httptoolkit/util/-/util-0.1.9.tgz", + "integrity": "sha512-MRRf7UAHcb/bXfytFDmuCo+XRH6HqxXpQpTMT8KUd95YCLDiofPOBE+2SV3M6rG4YYeEf3WxNzQiidn3qo1akQ==", + "license": "Apache-2.0" + }, + "node_modules/@httptoolkit/websocket-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@httptoolkit/websocket-stream/-/websocket-stream-6.0.1.tgz", + "integrity": "sha512-A0NOZI+Glp3Xgcz6Na7i7o09+/+xm2m0UCU8gdtM2nIv6/cjLmhMZMqehSpTlgbx9omtLmV8LVqOskPEyWnmZQ==", + "license": "BSD-2-Clause", + "dependencies": { + "@types/ws": "*", + "duplexify": "^3.5.1", + "inherits": "^2.0.1", + "isomorphic-ws": "^4.0.1", + "readable-stream": "^2.3.3", + "safe-buffer": "^5.1.2", + "ws": "*", + "xtend": "^4.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1482,6 +1592,154 @@ "node": ">=8.0.0" } }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz", + "integrity": "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-attr": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.1.tgz", + "integrity": "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz", + "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.1.tgz", + "integrity": "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.1.tgz", + "integrity": "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.1.tgz", + "integrity": "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pfx": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-attr": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz", + "integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz", + "integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.1.tgz", + "integrity": "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1561,6 +1819,21 @@ "tslib": "^2.8.0" } }, + "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==", + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1611,6 +1884,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", @@ -1850,6 +2132,18 @@ "node": ">= 20" } }, + "node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/abbrev": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", @@ -1860,6 +2154,19 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1887,7 +2194,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -1955,6 +2261,47 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/backo2": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", + "integrity": "sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1965,6 +2312,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.12", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", @@ -1977,6 +2333,39 @@ "node": ">=6.0.0" } }, + "node_modules/basic-ftp": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -1990,6 +2379,62 @@ "node": "18 || 20 || >=22" } }, + "node_modules/brotli-wasm": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/brotli-wasm/-/brotli-wasm-3.0.1.tgz", + "integrity": "sha512-U3K72/JAi3jITpdhZBqzSUq+DUY697tLxOuFXB+FpAE/Ug+5C3VZrv4uA674EUZHxNAuQ9wETXNqQkxZD6oL4A==", + "license": "Apache-2.0", + "engines": { + "node": ">=v18.0.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacheable-lookup": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-6.1.0.tgz", + "integrity": "sha512-KJ/Dmo1lDDhmW2XDPMo+9oiy/CeqosPguPCrgcVzKyZrL6pM1gU2GmPY/xo6OQPTUaA/c0kwHuywB4E6nmT9ww==", + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "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/caniuse-lite": { "version": "1.0.30001782", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", @@ -2160,6 +2605,45 @@ "node": ">=18" } }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "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" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", @@ -2170,42 +2654,125 @@ "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "engines": { + "node": ">=18" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "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": ">= 8" + "node": ">= 0.6" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "node_modules/cookie": { + "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" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "object-assign": "^4", + "vary": "^1" }, "engines": { - "node": ">=6.0" + "node": ">= 0.10" }, - "peerDependenciesMeta": { - "supports-color": { + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-inspect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", + "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "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.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { "optional": true } } @@ -2217,6 +2784,40 @@ "dev": true, "license": "MIT" }, + "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==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/depd": { + "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" + } + }, + "node_modules/destroyable-server": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/destroyable-server/-/destroyable-server-1.1.1.tgz", + "integrity": "sha512-7tjgU/99QVuYSqkMXr6XdQSvXj+8TjC9NiRVWNSyGytxklZ88m+qcSvWTJ3VysE3I9wurph7dTciLEEj8aUlaQ==", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2262,12 +2863,92 @@ "node": ">=0.10" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "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==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", @@ -2319,6 +3000,12 @@ "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==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2332,6 +3019,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "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" + } + }, "node_modules/eslint": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", @@ -2454,6 +3162,19 @@ "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==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -2484,7 +3205,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -2494,12 +3214,26 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } }, + "node_modules/etag": { + "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" + } + }, + "node_modules/eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", + "license": "MIT" + }, "node_modules/eventsource-parser": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", @@ -2509,6 +3243,70 @@ "node": ">=18.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2516,6 +3314,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-json-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2561,6 +3365,69 @@ "node": ">=16.0.0" } }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "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" + } + }, + "node_modules/finalhandler/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==", + "license": "MIT", + "engines": { + "node": ">= 0.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==", + "license": "MIT" + }, + "node_modules/finalhandler/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2599,6 +3466,24 @@ "dev": true, "license": "ISC" }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2614,6 +3499,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2635,6 +3529,55 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-port": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", + "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.13.7", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", @@ -2648,6 +3591,20 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2674,6 +3631,69 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-http": { + "version": "1.22.4", + "resolved": "https://registry.npmjs.org/graphql-http/-/graphql-http-1.22.4.tgz", + "integrity": "sha512-OC3ucK988teMf+Ak/O+ZJ0N2ukcgrEurypp8ePyJFWq83VzwRAmHxxr+XxrMpxO/FIwI4a7m/Fzv3tWGJv0wPA==", + "license": "MIT", + "workspaces": [ + "implementations/**/*" + ], + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "graphql": ">=0.11 <=16" + } + }, + "node_modules/graphql-subscriptions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-2.0.0.tgz", + "integrity": "sha512-s6k2b8mmt9gF9pEfkxsaO1lTxaySfKoEJzEfmwguBbQ//Oq23hIXCfR1hm4kdh5hnR20RdwB+s3BCb+0duHSZA==", + "license": "MIT", + "dependencies": { + "iterall": "^1.3.0" + }, + "peerDependencies": { + "graphql": "^15.7.2 || ^16.0.0" + } + }, + "node_modules/graphql-tag": { + "version": "2.12.6", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", + "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, "node_modules/grpc-tools": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/grpc-tools/-/grpc-tools-1.13.1.tgz", @@ -2688,11 +3708,94 @@ "grpc_tools_node_protoc_plugin": "bin/protoc_plugin.js" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-encoding": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/http-encoding/-/http-encoding-2.2.0.tgz", + "integrity": "sha512-sotL89NNHWS7TjaoswmKkNKf57+nAYCg3fbGCclpNLriVGkOSgrAWQ9DLqahnwacY+gednJk+HFdZrpKw/Ff6w==", + "license": "Apache-2.0", + "dependencies": { + "brotli-wasm": "^3.0.0", + "pify": "^5.0.0", + "zstd-codec": "^0.1.5" + }, + "engines": { + "node": ">=v18.0.0" + } + }, + "node_modules/http-errors": { + "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.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -2702,6 +3805,22 @@ "node": ">= 14" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2722,6 +3841,30 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2766,6 +3909,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-unicode-supported": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", @@ -2778,6 +3927,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2785,6 +3940,21 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic-ws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", + "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/iterall": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz", + "integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==", + "license": "MIT" + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2852,6 +4022,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -2892,6 +4068,79 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/milliparsec": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/milliparsec/-/milliparsec-5.1.1.tgz", + "integrity": "sha512-jkEDaSWZp4/Q3vprqdqukBqUEyNNqC1pwTjZ5cp9YkaR1wv5fvTCd8VFsecbw7i8DNBGjzhJ83MDoPZlcTaPQg==", + "license": "MIT", + "engines": { + "node": ">=18.13 || >=19.20 || >=20" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -2943,11 +4192,65 @@ "node": ">= 18" } }, + "node_modules/mockttp": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/mockttp/-/mockttp-4.3.0.tgz", + "integrity": "sha512-zh8v6qZfqyTZan/SlJJcb9cDSgwIGLmGoHeG+bGm+qldJ7B5OYbwxAImd62+hXFp+X3nPUJPkvA6cqxe4pEp7w==", + "license": "Apache-2.0", + "dependencies": { + "@graphql-tools/schema": "^10.0.31", + "@graphql-tools/utils": "^11.0.0", + "@httptoolkit/httpolyglot": "^3.0.1", + "@httptoolkit/subscriptions-transport-ws": "^0.11.2", + "@httptoolkit/util": "^0.1.7", + "@httptoolkit/websocket-stream": "^6.0.1", + "@peculiar/asn1-pkcs8": "^2.5.0", + "@peculiar/asn1-schema": "^2.3.15", + "@peculiar/asn1-x509": "^2.3.15", + "@peculiar/x509": "^1.12.3", + "@types/cors": "^2.8.6", + "@types/node": "*", + "async-mutex": "^0.5.0", + "base64-arraybuffer": "^1.0.2", + "cacheable-lookup": "^6.0.0", + "common-tags": "^1.8.0", + "connect": "^3.7.0", + "cors": "^2.8.4", + "destroyable-server": "^1.1.1", + "express": "^5.2.1", + "fast-json-patch": "^3.1.1", + "get-port": "^7.2.0", + "graphql": "^14.0.2 || ^15.5 || ^16.0.0", + "graphql-http": "^1.22.0", + "graphql-subscriptions": "^2.0.0", + "graphql-tag": "^2.12.6", + "http-encoding": "^2.0.1", + "http2-wrapper": "^2.2.1", + "https-proxy-agent": "^7.0.6", + "isomorphic-ws": "^4.0.1", + "lodash": "^4.16.4", + "lru-cache": "^11.2.7", + "milliparsec": "^5.1.1", + "native-duplexpair": "^1.0.0", + "pac-proxy-agent": "^7.0.0", + "parse-multipart-data": "^1.4.0", + "read-tls-client-hello": "^2.0.0", + "semver": "^7.5.3", + "socks-proxy-agent": "^8.0.5", + "urlpattern-polyfill": "^10.1.0", + "ws": "^8.20.0" + }, + "bin": { + "mockttp": "dist/admin/admin-bin.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, "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==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2968,6 +4271,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/native-duplexpair": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", + "integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2975,6 +4284,24 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/next": { "version": "16.2.1", "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz", @@ -3065,6 +4392,48 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "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" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "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" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -3153,6 +4522,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "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==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/parse-multipart-data": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/parse-multipart-data/-/parse-multipart-data-1.5.0.tgz", + "integrity": "sha512-ck5zaMF0ydjGfejNMnlo5YU2oJ+pT+80Jb1y4ybanT27j+zbVP/jkYmCrUGsEln0Ox/hZmuvgy8Ra7AxbXP2Mw==", + "license": "MIT" + }, + "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" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3173,6 +4589,16 @@ "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3186,10 +4612,22 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", + "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==", + "license": "MIT", + "engines": { + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/postcss": { @@ -3246,6 +4684,12 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/protobufjs": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", @@ -3270,6 +4714,19 @@ "node": ">=12.0.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3280,6 +4737,75 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -3301,6 +4827,44 @@ "react": "^19.2.4" } }, + "node_modules/read-tls-client-hello": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-tls-client-hello/-/read-tls-client-hello-2.0.0.tgz", + "integrity": "sha512-zPrnTO77iw1KUjg5QlDzxSRg69SCeWqMI5dY2tEZJc2mDL7RmmpG+RF2Qw9p4JlZ2PUAuJWLFB+ti8o9mTiO6A==", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3310,6 +4874,12 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "license": "MIT" + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -3336,6 +4906,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3346,7 +4958,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3355,6 +4966,57 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -3423,6 +5085,78 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -3435,6 +5169,54 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "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==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3444,6 +5226,15 @@ "node": ">=0.10.0" } }, + "node_modules/statuses": { + "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" + } + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -3456,6 +5247,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -3511,6 +5323,15 @@ } } }, + "node_modules/symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/tar": { "version": "7.5.13", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", @@ -3545,6 +5366,15 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/toidentifier": { + "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" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -3627,6 +5457,24 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3640,6 +5488,20 @@ "node": ">= 0.8.0" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typescript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", @@ -3685,6 +5547,15 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, + "node_modules/unpipe": { + "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" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3695,6 +5566,27 @@ "punycode": "^2.1.0" } }, + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -3708,6 +5600,15 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -3810,6 +5711,42 @@ "node": ">=8" } }, + "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==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "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/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -3934,6 +5871,12 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zstd-codec": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/zstd-codec/-/zstd-codec-0.1.5.tgz", + "integrity": "sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g==", + "license": "MIT" + }, "packages/cli": { "name": "@finalrun/finalrun-agent", "version": "0.1.3", @@ -3971,6 +5914,7 @@ "chalk": "^5.4.0", "commander": "^13.1.0", "dotenv": "^16.4.0", + "mockttp": "^4.3.0", "ora": "^8.2.0", "protobufjs": "^7.5.4", "uuid": "^11.1.0", diff --git a/packages/cli/bin/finalrun.ts b/packages/cli/bin/finalrun.ts index 7bd55c8..9b9d3e6 100644 --- a/packages/cli/bin/finalrun.ts +++ b/packages/cli/bin/finalrun.ts @@ -29,6 +29,7 @@ import { resolveWorkspaceForCommand, } from '../src/workspace.js'; import { WorkspaceSelectionCancelledError } from '../src/workspacePicker.js'; +import { runLogNetworkCommand } from '../src/commands/logNetwork/index.js'; // ============================================================================ // CLI definition @@ -237,6 +238,16 @@ program }); }); +program + .command('log-network') + .description('Capture and display HTTP(S) network traffic from a connected device') + .requiredOption('--platform ', 'Target platform (android)') + .option('--device ', 'Device serial (auto-detected if only one)') + .option('--out ', 'Output HAR file path (default: auto-generated)') + .action(async (options: { platform: string; device?: string; out?: string }) => { + await runLogNetworkCommand(options); + }); + program.parse(); interface CommonCommandOptions { @@ -332,6 +343,7 @@ async function runTestCommand(params: { maxIterations: parseInt(params.options.maxIterations, 10) || 110, debug, invokedCommand: params.invokedCommand, + networkCapture: workspaceConfig.network?.capture === true, }); const runUrl = reportServer diff --git a/packages/cli/package.json b/packages/cli/package.json index c642bcd..47d70d4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -84,6 +84,7 @@ "chalk": "^5.4.0", "commander": "^13.1.0", "dotenv": "^16.4.0", + "mockttp": "^4.3.0", "ora": "^8.2.0", "protobufjs": "^7.5.4", "uuid": "^11.1.0", diff --git a/packages/cli/src/commands/logNetwork/adb.ts b/packages/cli/src/commands/logNetwork/adb.ts new file mode 100644 index 0000000..64356b8 --- /dev/null +++ b/packages/cli/src/commands/logNetwork/adb.ts @@ -0,0 +1,160 @@ +// Minimal adb wrapper for the log-network command. Self-contained so this +// feature can iterate without touching the shared AdbClient yet. + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import fs from 'node:fs'; +import path from 'node:path'; + +const execFileAsync = promisify(execFile); + +export interface AdbDevice { + serial: string; + state: string; + properties: Record; +} + +export interface AdbResult { + stdout: string; + stderr: string; +} + +export class AdbError extends Error { + constructor( + message: string, + public readonly stderr?: string, + ) { + super(message); + this.name = 'AdbError'; + } +} + +export function resolveAdbPath(): string | null { + const home = process.env['ANDROID_HOME'] ?? process.env['ANDROID_SDK_ROOT']; + if (home) { + const candidate = path.join(home, 'platform-tools', 'adb'); + if (fs.existsSync(candidate)) { + return candidate; + } + } + // Fallback: hope it's on PATH. + try { + // We rely on execFile('adb', ...) working if PATH resolves it. + // A richer resolve path can be added later. + return 'adb'; + } catch { + return null; + } +} + +async function run(adbPath: string, args: string[]): Promise { + try { + const { stdout, stderr } = await execFileAsync(adbPath, args, { + maxBuffer: 8 * 1024 * 1024, + }); + return { stdout: String(stdout), stderr: String(stderr) }; + } catch (error) { + const err = error as NodeJS.ErrnoException & { stderr?: string; stdout?: string }; + throw new AdbError( + `adb ${args.join(' ')} failed: ${err.message}`, + typeof err.stderr === 'string' ? err.stderr : undefined, + ); + } +} + +export async function listDevices(adbPath: string): Promise { + const { stdout } = await run(adbPath, ['devices', '-l']); + const lines = stdout + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith('List of devices')); + + const devices: AdbDevice[] = []; + for (const line of lines) { + const parts = line.split(/\s+/); + const serial = parts[0]; + const state = parts[1]; + if (!serial || !state) continue; + const properties: Record = {}; + for (let i = 2; i < parts.length; i++) { + const kv = parts[i]; + if (!kv) continue; + const eq = kv.indexOf(':'); + if (eq > 0) { + properties[kv.slice(0, eq)] = kv.slice(eq + 1); + } + } + devices.push({ serial, state, properties }); + } + return devices; +} + +export async function shell(adbPath: string, serial: string, command: string): Promise { + const { stdout } = await run(adbPath, ['-s', serial, 'shell', command]); + return stdout.trimEnd(); +} + +export async function push( + adbPath: string, + serial: string, + src: string, + dest: string, +): Promise { + await run(adbPath, ['-s', serial, 'push', src, dest]); +} + +export async function reverse( + adbPath: string, + serial: string, + hostPort: number, + devicePort: number, +): Promise { + // tcp:devicePort on device -> tcp:hostPort on host (reverse tunnel). + await run(adbPath, [ + '-s', + serial, + 'reverse', + `tcp:${devicePort}`, + `tcp:${hostPort}`, + ]); +} + +export async function removeReverse( + adbPath: string, + serial: string, + devicePort: number, +): Promise { + try { + await run(adbPath, ['-s', serial, 'reverse', '--remove', `tcp:${devicePort}`]); + } catch { + // best effort + } +} + +export async function getGlobalProxy(adbPath: string, serial: string): Promise { + const out = await shell(adbPath, serial, 'settings get global http_proxy'); + const trimmed = out.trim(); + if (!trimmed || trimmed === 'null') return null; + return trimmed; +} + +export async function setGlobalProxy( + adbPath: string, + serial: string, + hostPort: string, +): Promise { + await shell(adbPath, serial, `settings put global http_proxy ${hostPort}`); +} + +export async function clearGlobalProxy(adbPath: string, serial: string): Promise { + // `:0` is the documented way to clear; `delete` also works on most images. + await shell(adbPath, serial, 'settings put global http_proxy :0'); +} + +export async function getProp(adbPath: string, serial: string, key: string): Promise { + return await shell(adbPath, serial, `getprop ${key}`); +} + +export function isEmulator(serial: string): boolean { + return serial.startsWith('emulator-'); +} diff --git a/packages/cli/src/commands/logNetwork/ca.ts b/packages/cli/src/commands/logNetwork/ca.ts new file mode 100644 index 0000000..5a61846 --- /dev/null +++ b/packages/cli/src/commands/logNetwork/ca.ts @@ -0,0 +1,87 @@ +// FinalRun root CA — generate once per host and cache under ~/.finalrun/ca/. +// Used by the mockttp proxy to sign per-host leaf certs on the fly. + +import { promises as fsp } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { generateCACertificate } from 'mockttp'; + +export interface FinalRunCAFiles { + /** Directory holding the CA files. */ + dir: string; + /** PEM-encoded certificate (used by mockttp + pushed to the device). */ + certPath: string; + /** PEM-encoded private key (used by mockttp to sign leaves). */ + keyPath: string; + /** DER-encoded certificate (what Android's Settings UI expects). */ + certDerPath: string; +} + +export interface LoadedCA { + cert: string; + key: string; + files: FinalRunCAFiles; + generated: boolean; +} + +function defaultCaDir(): string { + return path.join(os.homedir(), '.finalrun', 'ca'); +} + +function caFiles(dir: string): FinalRunCAFiles { + return { + dir, + certPath: path.join(dir, 'root.pem'), + keyPath: path.join(dir, 'root.key'), + certDerPath: path.join(dir, 'root.crt'), + }; +} + +async function fileExists(p: string): Promise { + try { + await fsp.access(p); + return true; + } catch { + return false; + } +} + +function pemToDer(pem: string): Buffer { + const body = pem + .replace(/-----BEGIN CERTIFICATE-----/, '') + .replace(/-----END CERTIFICATE-----/, '') + .replace(/\s+/g, ''); + return Buffer.from(body, 'base64'); +} + +/** + * Load the FinalRun CA from disk, generating it if absent. + * Returns the PEM-encoded cert + key usable directly by mockttp. + */ +export async function loadOrGenerateCA(dir: string = defaultCaDir()): Promise { + const files = caFiles(dir); + await fsp.mkdir(files.dir, { recursive: true }); + + if ((await fileExists(files.certPath)) && (await fileExists(files.keyPath))) { + const cert = await fsp.readFile(files.certPath, 'utf8'); + const key = await fsp.readFile(files.keyPath, 'utf8'); + if (!(await fileExists(files.certDerPath))) { + await fsp.writeFile(files.certDerPath, pemToDer(cert)); + } + return { cert, key, files, generated: false }; + } + + const { cert, key } = await generateCACertificate({ + subject: { + commonName: 'FinalRun Local CA', + organizationName: 'FinalRun', + }, + bits: 2048, + }); + + await fsp.writeFile(files.certPath, cert, { mode: 0o600 }); + await fsp.writeFile(files.keyPath, key, { mode: 0o600 }); + await fsp.writeFile(files.certDerPath, pemToDer(cert)); + + return { cert, key, files, generated: true }; +} diff --git a/packages/cli/src/commands/logNetwork/capture.ts b/packages/cli/src/commands/logNetwork/capture.ts new file mode 100644 index 0000000..66b59c9 --- /dev/null +++ b/packages/cli/src/commands/logNetwork/capture.ts @@ -0,0 +1,172 @@ +// Standalone network capture for `finalrun log-network` command. +// Uses mockttp directly for interactive live-streaming capture. +// +// The test pipeline uses NetworkCaptureManager in @finalrun/device-node +// instead — same mockttp patterns but with session/test lifecycle. + +import * as mockttp from 'mockttp'; + +export interface CapturedEntry { + startedAt: Date; + completedAt: Date; + method: string; + url: string; + statusCode: number; + statusMessage: string; + requestHeaders: Record; + responseHeaders: Record; + requestBodySize: number; + responseBodySize: number; + durationMs: number; + responseSize: number; +} + +export interface TlsError { + hostname: string; + remoteIpAddress?: string; + failureCause?: string; +} + +export type OnEntry = (entry: CapturedEntry) => void; +export type OnTlsError = (err: TlsError) => void; + +export interface NetworkCaptureCallbacks { + onEntry: OnEntry; + onTlsError?: OnTlsError; +} + +export class NetworkCapture { + private _server: mockttp.Mockttp | null = null; + private _entries: CapturedEntry[] = []; + private _tlsErrors: TlsError[] = []; + private _port = 0; + private _pendingRequests = new Map; bodySize: number }>(); + + get port(): number { return this._port; } + get entries(): readonly CapturedEntry[] { return this._entries; } + get tlsErrors(): readonly TlsError[] { return this._tlsErrors; } + + async start(cert: string, key: string, callbacks: NetworkCaptureCallbacks): Promise { + this._server = mockttp.getLocal({ https: { cert, key } }); + await this._server.forAnyRequest().thenPassThrough(); + await this._server.start(); + this._port = this._server.port; + + await this._server.on('request', (req) => { + this._pendingRequests.set(req.id, { + startedAt: new Date(), + method: req.method, + url: req.url, + headers: flattenHeaders(req.headers), + bodySize: req.body?.buffer?.byteLength ?? 0, + }); + }); + + await this._server.on('response', (response) => { + const completedAt = new Date(); + const pending = this._pendingRequests.get(response.id); + this._pendingRequests.delete(response.id); + + const respSize = response.body?.buffer?.byteLength ?? 0; + + const entry: CapturedEntry = { + startedAt: pending?.startedAt ?? completedAt, + completedAt, + method: pending?.method ?? '???', + url: pending?.url ?? '', + statusCode: response.statusCode, + statusMessage: response.statusMessage ?? '', + requestHeaders: pending?.headers ?? {}, + responseHeaders: flattenHeaders(response.headers), + requestBodySize: pending?.bodySize ?? 0, + responseBodySize: respSize, + durationMs: completedAt.getTime() - (pending?.startedAt ?? completedAt).getTime(), + responseSize: respSize, + }; + this._entries.push(entry); + callbacks.onEntry(entry); + }); + + await this._server.on('tls-client-error', (failure) => { + const f = failure as unknown as { + remoteIpAddress?: string; + failureCause?: string; + tlsMetadata?: { sniHostname?: string }; + }; + const tlsErr: TlsError = { + hostname: f.tlsMetadata?.sniHostname ?? 'unknown', + remoteIpAddress: f.remoteIpAddress, + failureCause: f.failureCause, + }; + this._tlsErrors.push(tlsErr); + callbacks.onTlsError?.(tlsErr); + }); + + return this._port; + } + + async stop(): Promise { + if (this._server) { + await this._server.stop(); + this._server = null; + } + } + + toHar(): object { + return { + log: { + version: '1.2', + creator: { name: 'FinalRun', version: '0.1.0' }, + entries: this._entries.map((e) => ({ + startedDateTime: e.startedAt.toISOString(), + time: e.durationMs, + request: { + method: e.method, + url: e.url, + httpVersion: 'HTTP/1.1', + headers: headersToHar(e.requestHeaders), + queryString: parseQueryString(e.url), + bodySize: e.requestBodySize, + headersSize: -1, + }, + response: { + status: e.statusCode, + statusText: e.statusMessage, + httpVersion: 'HTTP/1.1', + headers: headersToHar(e.responseHeaders), + content: { + size: e.responseSize, + mimeType: e.responseHeaders['content-type'] ?? 'application/octet-stream', + }, + bodySize: e.responseSize, + headersSize: -1, + redirectURL: '', + }, + cache: {}, + timings: { send: 0, wait: e.durationMs, receive: 0 }, + })), + }, + }; + } +} + +function flattenHeaders(headers: Record): Record { + const flat: Record = {}; + for (const [k, v] of Object.entries(headers)) { + if (v === undefined) continue; + flat[k.toLowerCase()] = Array.isArray(v) ? v.join(', ') : v; + } + return flat; +} + +function headersToHar(headers: Record): Array<{ name: string; value: string }> { + return Object.entries(headers).map(([name, value]) => ({ name, value })); +} + +function parseQueryString(url: string): Array<{ name: string; value: string }> { + try { + return [...new URL(url).searchParams].map(([name, value]) => ({ name, value })); + } catch { + return []; + } +} diff --git a/packages/cli/src/commands/logNetwork/colors.ts b/packages/cli/src/commands/logNetwork/colors.ts new file mode 100644 index 0000000..9d2a00b --- /dev/null +++ b/packages/cli/src/commands/logNetwork/colors.ts @@ -0,0 +1,16 @@ +// ANSI color helpers — avoids ESM-only chalk dependency in CommonJS CLI package. + +const ESC = '\x1b['; +const RESET = `${ESC}0m`; + +export const colors = { + green: (s: string) => `${ESC}32m${s}${RESET}`, + red: (s: string) => `${ESC}31m${s}${RESET}`, + yellow: (s: string) => `${ESC}33m${s}${RESET}`, + cyan: (s: string) => `${ESC}36m${s}${RESET}`, + magenta: (s: string) => `${ESC}35m${s}${RESET}`, + blue: (s: string) => `${ESC}34m${s}${RESET}`, + dim: (s: string) => `${ESC}2m${s}${RESET}`, + bold: (s: string) => `${ESC}1m${s}${RESET}`, + white: (s: string) => s, +}; diff --git a/packages/cli/src/commands/logNetwork/index.ts b/packages/cli/src/commands/logNetwork/index.ts new file mode 100644 index 0000000..15d4bf0 --- /dev/null +++ b/packages/cli/src/commands/logNetwork/index.ts @@ -0,0 +1,590 @@ +// `finalrun log-network` — guided network capture for Android and iOS. +// Walks through setup steps, starts an HTTPS-intercepting proxy, streams +// captured traffic live, writes a HAR file on Ctrl+C. + +import { promises as fsp } from 'node:fs'; +import * as http from 'node:http'; +import * as https from 'node:https'; +import path from 'node:path'; +import { colors as chalk } from './colors.js'; +import { loadOrGenerateCA } from './ca.js'; +import { + resolveAdbPath, + listDevices, + push, + getGlobalProxy, + setGlobalProxy, + clearGlobalProxy, + reverse, + removeReverse, + isEmulator, + getProp, + type AdbDevice, +} from './adb.js'; +import { + listBootedSimulators, + installCACert, + findActiveNetworkService, + startPacServer, + getAutoproxyUrl, + setAutoproxyUrl, + restoreAutoproxy, + type IOSSimulator, +} from './ios.js'; +import { NetworkCapture, type CapturedEntry } from './capture.js'; +import { printEntry, printTlsError } from './livePrinter.js'; +import { + saveProxyState, + clearProxyState, + loadProxyState, + type SavedProxyState, +} from './proxyState.js'; + +export interface LogNetworkOptions { + platform: string; + device?: string; + out?: string; +} + +type TeardownFn = () => Promise; + +// ============================================================================ +// Shared helpers +// ============================================================================ + +function createStepPrinter(stepTotal: number) { + let step = 0; + return (label: string, status: 'pass' | 'fail' | 'info', detail?: string) => { + step++; + const icon = + status === 'pass' ? chalk.green('\u2713') : status === 'fail' ? chalk.red('\u2717') : chalk.blue('i'); + const suffix = detail ? ` ${chalk.dim(detail)}` : ''; + console.log(` [${step}/${stepTotal}] ${label.padEnd(50)} ${icon}${suffix}`); + }; +} + +async function runCleanup(teardownStack: Array<{ label: string; fn: TeardownFn }>): Promise { + console.log('\n Cleaning up...'); + for (let i = teardownStack.length - 1; i >= 0; i--) { + const { label, fn } = teardownStack[i]!; + try { + await fn(); + console.log(` ${chalk.green('\u2713')} ${label}`); + } catch (err) { + console.log(` ${chalk.red('\u2717')} ${label}: ${(err as Error).message}`); + } + } + teardownStack.length = 0; +} + +async function waitForCtrlC(): Promise { + await new Promise((resolve) => { + const onSignal = () => { + process.removeListener('SIGINT', onSignal); + process.removeListener('SIGTERM', onSignal); + resolve(); + }; + process.on('SIGINT', onSignal); + process.on('SIGTERM', onSignal); + }); +} + +async function writeHarAndSummary( + capture: NetworkCapture, + tlsHostCauses: Map, + outPath: string | undefined, +): Promise { + const harPath = outPath ?? `finalrun-network-${timestamp()}.har`; + const har = capture.toHar(); + await fsp.writeFile(harPath, JSON.stringify(har, null, 2), 'utf8'); + + const decoded = capture.entries.length; + const parts: string[] = [`${decoded} request(s) captured`]; + + // Categorize TLS failures. + let pinned = 0; + let caRejected = 0; + let otherTls = 0; + for (const cause of tlsHostCauses.values()) { + if (cause === 'cert-rejected') pinned++; + else if (cause === 'closed' || cause === 'reset') caRejected++; + else otherTls++; + } + if (pinned > 0) parts.push(`${pinned} host(s) pinned`); + if (caRejected > 0) parts.push(`${caRejected} host(s) rejected CA`); + if (otherTls > 0) parts.push(`${otherTls} host(s) TLS failed`); + + console.log(`\n ${parts.join(', ')}.`); + console.log(` Wrote ${chalk.bold(path.resolve(harPath))}\n`); +} + +function timestamp(): string { + return new Date().toISOString().replace(/[:.]/g, '-').replace('Z', ''); +} + +/** + * Make a test HTTPS request through the proxy to verify the CA is trusted. + * Returns true if the request succeeds (CA is working). + */ +/** + * Check if a proxy is actually responding on a port (not just a stale socket). + */ +async function isProxyResponding(port: number): Promise { + return new Promise((resolve) => { + const req = http.request( + { hostname: '127.0.0.1', port, path: 'http://example.com/', method: 'HEAD', timeout: 2000 }, + (res) => { res.resume(); resolve(true); }, + ); + req.on('error', () => resolve(false)); + req.on('timeout', () => { req.destroy(); resolve(false); }); + req.end(); + }); +} + +/** + * Make a test HTTPS request through the proxy to verify the CA is trusted. + * Temporarily mutes the live printer so the test request doesn't appear. + */ +async function testProxyConnectivity( + proxyPort: number, + ca: { cert: string }, + capture: NetworkCapture, +): Promise { + const countBefore = capture.entries.length; + const result = await new Promise((resolve) => { + const req = https.request( + { + hostname: 'example.com', + port: 443, + path: '/', + method: 'HEAD', + agent: new https.Agent({ + host: '127.0.0.1', + port: proxyPort, + ca: ca.cert, + } as https.AgentOptions), + timeout: 5000, + }, + (res) => { + res.resume(); + resolve(res.statusCode !== undefined); + }, + ); + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + req.end(); + }); + // Remove the test request from captured entries so it doesn't pollute the HAR. + if (capture.entries.length > countBefore) { + (capture.entries as CapturedEntry[]).splice(countBefore); + } + return result; +} + +// ============================================================================ +// Stale proxy recovery +// ============================================================================ + +async function recoverStaleProxy(): Promise { + const state = await loadProxyState(); + if (!state) return; + + // Check if a live proxy is actually running on the saved port by making + // a real HTTP request through it. A stale port (TIME_WAIT / lingering socket) + // won't respond to an HTTP request. + const proxyAlive = await isProxyResponding(state.proxyPort); + if (proxyAlive) { + return; // Another instance is actively running — leave it alone. + } + + // The previous process is dead but left proxy settings behind. + console.log(chalk.yellow(`\n Recovering from a previous crashed session (PID ${state.pid}, started ${state.startedAt})...`)); + + if (state.platform === 'android' && state.deviceSerial) { + try { + const adbPath = resolveAdbPath(); + if (adbPath) { + if (state.previousAndroidProxy) { + await setGlobalProxy(adbPath, state.deviceSerial, state.previousAndroidProxy); + } else { + await clearGlobalProxy(adbPath, state.deviceSerial); + } + console.log(chalk.green(' \u2713 restored Android proxy setting')); + } + } catch (err) { + console.log(chalk.red(` \u2717 failed to restore Android proxy: ${(err as Error).message}`)); + } + } + + if (state.platform === 'ios' && state.networkService) { + try { + await restoreAutoproxy( + state.networkService, + state.previousAutoproxy ?? { enabled: false, url: '' }, + ); + console.log(chalk.green(` \u2713 restored macOS proxy on "${state.networkService}"`)); + } catch (err) { + console.log(chalk.red(` \u2717 failed to restore macOS proxy: ${(err as Error).message}`)); + } + } + + await clearProxyState(); + console.log(''); +} + +// ============================================================================ +// Main entry +// ============================================================================ + +export async function runLogNetworkCommand(options: LogNetworkOptions): Promise { + const platform = options.platform.toLowerCase(); + if (platform !== 'android' && platform !== 'ios') { + console.log(chalk.red('--platform must be "android" or "ios".')); + process.exit(1); + } + + // Recover from a previous crash before doing anything else. + await recoverStaleProxy(); + + const teardownStack: Array<{ label: string; fn: TeardownFn }> = []; + + try { + if (platform === 'android') { + await runAndroidCapture(options, teardownStack); + } else { + await runIOSCapture(options, teardownStack); + } + } catch (err) { + console.log(`\n ${chalk.red('Error:')} ${(err as Error).message}`); + await runCleanup(teardownStack); + await clearProxyState(); + process.exit(1); + } +} + +// ============================================================================ +// Android capture flow +// ============================================================================ + +const PROXY_PORT_ON_DEVICE = 8899; + +async function runAndroidCapture( + options: LogNetworkOptions, + teardownStack: Array<{ label: string; fn: TeardownFn }>, +): Promise { + const printStep = createStepPrinter(7); + + // ── Step 1: Host tools ──────────────────────────────────────────────── + const adbPath = resolveAdbPath(); + if (!adbPath) { + printStep('Checking host tools (adb)', 'fail'); + console.log(' adb not found. Install the Android SDK platform-tools and add to PATH.'); + process.exit(1); + } + printStep('Checking host tools (adb)', 'pass'); + + // ── Step 2: Detect device ───────────────────────────────────────────── + const devices = (await listDevices(adbPath)).filter((d) => d.state === 'device'); + let device: AdbDevice; + if (options.device) { + const match = devices.find((d) => d.serial === options.device); + if (!match) { + printStep('Detecting device', 'fail'); + console.log(` Device "${options.device}" not found. Available: ${devices.map((d) => d.serial).join(', ') || 'none'}`); + process.exit(1); + } + device = match; + } else if (devices.length === 1) { + device = devices[0]!; + } else if (devices.length === 0) { + printStep('Detecting device', 'fail'); + console.log(' No Android devices/emulators connected. Start one and try again.'); + process.exit(1); + return; + } else { + printStep('Detecting device', 'fail'); + console.log(` Multiple devices found: ${devices.map((d) => d.serial).join(', ')}`); + console.log(' Use --device to specify one.'); + process.exit(1); + return; + } + const model = device.properties['model'] ?? device.serial; + printStep('Detecting device', 'pass', `${device.serial} (${model})`); + + // ── Step 3: Generate/load CA cert ───────────────────────────────────── + const ca = await loadOrGenerateCA(); + printStep( + 'Generating/loading FinalRun CA cert', + 'pass', + ca.generated ? `created ${ca.files.certPath}` : ca.files.certPath, + ); + + // ── Step 4: Push CA cert to device ──────────────────────────────────── + const deviceCertDest = '/sdcard/Download/finalrun-ca.crt'; + await push(adbPath, device.serial, ca.files.certDerPath, deviceCertDest); + printStep('CA cert pushed to device', 'pass', deviceCertDest); + + const apiLevel = parseInt(await getProp(adbPath, device.serial, 'ro.build.version.sdk'), 10); + console.log(''); + console.log(chalk.yellow(' One-time setup (skip if already done):')); + console.log(chalk.dim(' 1. Install the CA cert on the device:')); + if (apiLevel >= 33) { + console.log(chalk.dim(' Settings > Security & privacy > More security settings')); + console.log(chalk.dim(' > Encryption & credentials > Install a certificate > CA certificate')); + console.log(chalk.dim(` > Select "${deviceCertDest.split('/').pop()}"`)); + } else { + console.log(chalk.dim(' Settings > Security > Encryption & credentials')); + console.log(chalk.dim(' > Install a certificate > CA certificate')); + console.log(chalk.dim(` > Browse to Download/ and select "${deviceCertDest.split('/').pop()}"`)); + } + if (apiLevel >= 24) { + console.log(chalk.dim(' 2. Your app must trust user CAs (debug builds). In res/xml/network_security_config.xml:')); + console.log(chalk.dim(' ')); + console.log(chalk.dim(' ')); + console.log(chalk.dim(' ')); + } + console.log(''); + + // ── Step 5: Configure device proxy ──────────────────────────────────── + const capture = new NetworkCapture(); + const tlsHostCauses = new Map(); + + let muted = false; + const proxyPort = await capture.start(ca.cert, ca.key, { + onEntry: (entry) => { if (!muted) printEntry(entry); }, + onTlsError: (err) => { + if (!muted && !tlsHostCauses.has(err.hostname)) { + tlsHostCauses.set(err.hostname, err.failureCause ?? 'unknown'); + printTlsError(err); + } + }, + }); + + teardownStack.push({ + label: 'stopped capture proxy', + fn: () => capture.stop(), + }); + + const previousProxy = await getGlobalProxy(adbPath, device.serial); + + let proxyTarget: string; + if (isEmulator(device.serial)) { + proxyTarget = `10.0.2.2:${proxyPort}`; + } else { + await reverse(adbPath, device.serial, proxyPort, PROXY_PORT_ON_DEVICE); + teardownStack.push({ + label: 'removed adb reverse forward', + fn: () => removeReverse(adbPath, device.serial, PROXY_PORT_ON_DEVICE), + }); + proxyTarget = `localhost:${PROXY_PORT_ON_DEVICE}`; + } + + await setGlobalProxy(adbPath, device.serial, proxyTarget); + teardownStack.push({ + label: 'restored device proxy setting', + fn: async () => { + if (previousProxy) { + await setGlobalProxy(adbPath, device.serial, previousProxy); + } else { + await clearGlobalProxy(adbPath, device.serial); + } + }, + }); + + // Persist proxy state for crash recovery. + await saveProxyState({ + platform: 'android', + pid: process.pid, + ppid: process.ppid, + proxyPort, + startedAt: new Date().toISOString(), + deviceSerial: device.serial, + previousAndroidProxy: previousProxy, + }); + + printStep('Configuring device proxy', 'pass', proxyTarget); + + // ── Step 6: Verify HTTPS connectivity ───────────────────────────────── + muted = true; + const connected = await testProxyConnectivity(proxyPort, ca, capture); + muted = false; + if (connected) { + printStep('Verifying HTTPS capture', 'pass', 'test request succeeded'); + } else { + printStep('Verifying HTTPS capture', 'fail', 'CA cert not trusted on device'); + console.log(''); + console.log(chalk.red(' The device does not trust the FinalRun CA certificate.')); + console.log(chalk.red(' HTTPS traffic will fail while the proxy is active.')); + console.log(''); + console.log(chalk.yellow(' To fix: install the CA cert now (see step 4 instructions above),')); + console.log(chalk.yellow(' then re-run this command.')); + console.log(''); + await runCleanup(teardownStack); + await clearProxyState(); + process.exit(1); + } + + // ── Step 7: Start capture ───────────────────────────────────────────── + printStep('Starting capture proxy', 'pass', `listening on 127.0.0.1:${proxyPort}`); + + console.log( + `\n ${chalk.green('Capturing.')} Press ${chalk.bold('Ctrl+C')} to stop.\n`, + ); + + await waitForCtrlC(); + await runCleanup(teardownStack); + await clearProxyState(); + await writeHarAndSummary(capture, tlsHostCauses, options.out); +} + +// ============================================================================ +// iOS capture flow +// ============================================================================ + +async function runIOSCapture( + options: LogNetworkOptions, + teardownStack: Array<{ label: string; fn: TeardownFn }>, +): Promise { + const printStep = createStepPrinter(7); + + // ── Step 1: Host tools ──────────────────────────────────────────────── + printStep('Checking host tools (xcrun)', 'pass'); + + // ── Step 2: Detect simulator ────────────────────────────────────────── + const sims = await listBootedSimulators(); + let sim: IOSSimulator; + if (options.device) { + const match = sims.find((s) => s.udid === options.device || s.name === options.device); + if (!match) { + printStep('Detecting simulator', 'fail'); + console.log(` Simulator "${options.device}" not found. Booted: ${sims.map((s) => `${s.name} (${s.udid})`).join(', ') || 'none'}`); + process.exit(1); + } + sim = match; + } else if (sims.length === 1) { + sim = sims[0]!; + } else if (sims.length === 0) { + printStep('Detecting simulator', 'fail'); + console.log(' No booted iOS simulators found. Start one and try again.'); + process.exit(1); + return; + } else { + printStep('Detecting simulator', 'fail'); + console.log(` Multiple simulators booted: ${sims.map((s) => s.name).join(', ')}`); + console.log(' Use --device to specify one.'); + process.exit(1); + return; + } + printStep('Detecting simulator', 'pass', `${sim.name} (${sim.udid.slice(0, 8)}…)`); + + // ── Step 3: Generate/load CA cert ───────────────────────────────────── + const ca = await loadOrGenerateCA(); + printStep( + 'Generating/loading FinalRun CA cert', + 'pass', + ca.generated ? `created ${ca.files.certPath}` : ca.files.certPath, + ); + + // ── Step 4: Install CA cert in simulator keychain ───────────────────── + await installCACert(ca.files.certPath, sim.udid); + printStep('CA cert installed in simulator', 'pass', 'added to keychain as trusted root'); + + console.log(''); + console.log(chalk.yellow(' One-time setup (skip if already done):')); + console.log(chalk.dim(' On the simulator, enable full trust for the CA cert:')); + console.log(chalk.dim(' Settings > General > About > Certificate Trust Settings')); + console.log(chalk.dim(' > Enable "FinalRun Local CA"')); + console.log(''); + + // ── Step 5: Configure macOS proxy ───────────────────────────────────── + const networkService = await findActiveNetworkService(); + if (!networkService) { + printStep('Configuring macOS proxy', 'fail'); + console.log(' No active network service found.'); + process.exit(1); + return; + } + + const capture = new NetworkCapture(); + const tlsHostCauses = new Map(); + + let muted = false; + const proxyPort = await capture.start(ca.cert, ca.key, { + onEntry: (entry) => { if (!muted) printEntry(entry); }, + onTlsError: (err) => { + if (!muted && !tlsHostCauses.has(err.hostname)) { + tlsHostCauses.set(err.hostname, err.failureCause ?? 'unknown'); + printTlsError(err); + } + }, + }); + + teardownStack.push({ + label: 'stopped capture proxy', + fn: () => capture.stop(), + }); + + // Start a PAC server with DIRECT fallback — if our process crashes, the + // PAC server dies too → macOS can't fetch the PAC → falls back to direct. + // Double safety: the PAC itself also specifies "PROXY ...; DIRECT". + const pacServer = await startPacServer(proxyPort); + teardownStack.push({ + label: 'stopped PAC server', + fn: () => pacServer.stop(), + }); + + const prevAutoproxy = await getAutoproxyUrl(networkService); + + await setAutoproxyUrl(networkService, pacServer.url); + teardownStack.push({ + label: `restored macOS proxy on "${networkService}"`, + fn: () => restoreAutoproxy(networkService, prevAutoproxy), + }); + + // Persist proxy state for crash recovery. + await saveProxyState({ + platform: 'ios', + pid: process.pid, + ppid: process.ppid, + proxyPort, + startedAt: new Date().toISOString(), + networkService, + previousAutoproxy: prevAutoproxy, + }); + + printStep('Configuring macOS proxy', 'pass', `${networkService} → PAC with DIRECT fallback`); + + // ── Step 6: Verify HTTPS connectivity ───────────────────────────────── + muted = true; + const connected = await testProxyConnectivity(proxyPort, ca, capture); + muted = false; + if (connected) { + printStep('Verifying HTTPS capture', 'pass', 'test request succeeded'); + } else { + printStep('Verifying HTTPS capture', 'fail', 'CA cert not trusted'); + console.log(''); + console.log(chalk.red(' HTTPS verification failed. The CA cert may not be fully trusted.')); + console.log(chalk.yellow(' On the simulator: Settings > General > About > Certificate Trust Settings')); + console.log(chalk.yellow(' Enable the toggle for "FinalRun Local CA", then re-run.')); + console.log(''); + await runCleanup(teardownStack); + await clearProxyState(); + process.exit(1); + } + + // ── Step 7: Start capture ───────────────────────────────────────────── + printStep('Starting capture proxy', 'pass', `listening on 127.0.0.1:${proxyPort}`); + + console.log( + `\n ${chalk.green('Capturing.')} Press ${chalk.bold('Ctrl+C')} to stop.\n`, + ); + console.log(chalk.dim(' Note: macOS proxy affects all Mac traffic while active. If the process crashes, traffic falls back to direct (no broken internet).\n')); + + await waitForCtrlC(); + await runCleanup(teardownStack); + await clearProxyState(); + await writeHarAndSummary(capture, tlsHostCauses, options.out); +} diff --git a/packages/cli/src/commands/logNetwork/ios.ts b/packages/cli/src/commands/logNetwork/ios.ts new file mode 100644 index 0000000..36dafd3 --- /dev/null +++ b/packages/cli/src/commands/logNetwork/ios.ts @@ -0,0 +1,148 @@ +// iOS simulator helpers for the log-network command. + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { promises as fsp } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +const execFileAsync = promisify(execFile); + +export interface IOSSimulator { + udid: string; + name: string; + state: string; + runtime: string; +} + +async function run(cmd: string, args: string[]): Promise { + const { stdout } = await execFileAsync(cmd, args, { maxBuffer: 8 * 1024 * 1024 }); + return String(stdout).trim(); +} + +export async function listBootedSimulators(): Promise { + const json = await run('xcrun', ['simctl', 'list', 'devices', 'booted', '-j']); + const data = JSON.parse(json); + const sims: IOSSimulator[] = []; + for (const [runtime, devices] of Object.entries(data.devices as Record>)) { + for (const d of devices) { + if (d.state === 'Booted') { + sims.push({ udid: d.udid, name: d.name, state: d.state, runtime }); + } + } + } + return sims; +} + +export async function installCACert(certPath: string, udid?: string): Promise { + const target = udid ?? 'booted'; + await run('xcrun', ['simctl', 'keychain', target, 'add-root-cert', certPath]); +} + +export async function openApp(bundleId: string, udid?: string): Promise { + const target = udid ?? 'booted'; + await run('xcrun', ['simctl', 'launch', target, bundleId]); +} + +export async function terminateApp(bundleId: string, udid?: string): Promise { + const target = udid ?? 'booted'; + try { + await run('xcrun', ['simctl', 'terminate', target, bundleId]); + } catch { + // App might not be running. + } +} + +// ── PAC-based proxy (crash-safe: falls back to DIRECT if proxy dies) ──────── + +/** + * Start a tiny HTTP server that serves a PAC file pointing to our proxy. + * If the process dies, this server dies too → PAC URL becomes unreachable → + * macOS can't fetch the PAC → falls back to direct connections. No broken internet. + * + * The PAC itself also has a DIRECT fallback: "PROXY ...; DIRECT" — double safety. + */ +export async function startPacServer(proxyPort: number): Promise<{ url: string; stop: () => Promise }> { + const pacContent = [ + 'function FindProxyForURL(url, host) {', + ` return "PROXY 127.0.0.1:${proxyPort}; DIRECT";`, + '}', + ].join('\n'); + + const { createServer } = await import('node:http'); + + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + if (req.url === '/proxy.pac' || req.url === '/') { + res.writeHead(200, { 'Content-Type': 'application/x-ns-proxy-autoconfig' }); + res.end(pacContent); + } else { + res.writeHead(404); + res.end(); + } + }); + + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + const url = `http://127.0.0.1:${port}/proxy.pac`; + resolve({ + url, + stop: () => new Promise((res) => server.close(() => res())), + }); + }); + }); +} + +// ── macOS auto-proxy config (uses the PAC file) ───────────────────────────── + +export interface AutoproxyState { + enabled: boolean; + url: string; +} + +export async function getAutoproxyUrl(service: string): Promise { + const output = await run('networksetup', ['-getautoproxyurl', service]); + const enabled = /Enabled:\s*Yes/i.test(output); + const urlMatch = output.match(/URL:\s*(\S+)/); + return { + enabled, + url: urlMatch?.[1] ?? '', + }; +} + +export async function setAutoproxyUrl(service: string, pacUrl: string): Promise { + await run('networksetup', ['-setautoproxyurl', service, pacUrl]); + await run('networksetup', ['-setautoproxystate', service, 'on']); +} + +export async function restoreAutoproxy(service: string, previous: AutoproxyState): Promise { + if (previous.enabled && previous.url) { + await run('networksetup', ['-setautoproxyurl', service, previous.url]); + await run('networksetup', ['-setautoproxystate', service, 'on']); + } else { + await run('networksetup', ['-setautoproxystate', service, 'off']); + } +} + +export async function findActiveNetworkService(): Promise { + const output = await run('networksetup', ['-listallnetworkservices']); + const services = output + .split('\n') + .filter((line) => !line.startsWith('*') && !line.startsWith('An asterisk')) + .map((s) => s.trim()) + .filter(Boolean); + + for (const svc of services) { + try { + const info = await run('networksetup', ['-getinfo', svc]); + if (info.includes('IP address') && !info.includes('IP address: none')) { + return svc; + } + } catch { + continue; + } + } + return services[0] ?? null; +} diff --git a/packages/cli/src/commands/logNetwork/livePrinter.ts b/packages/cli/src/commands/logNetwork/livePrinter.ts new file mode 100644 index 0000000..97efe27 --- /dev/null +++ b/packages/cli/src/commands/logNetwork/livePrinter.ts @@ -0,0 +1,86 @@ +// Formats captured request entries as compact, colorized CLI lines. + +import { colors as chalk } from './colors.js'; +import type { CapturedEntry, TlsError } from './capture.js'; + +const METHOD_WIDTH = 7; +const STATUS_WIDTH = 5; +const DURATION_WIDTH = 8; +const SIZE_WIDTH = 10; +const TIME_WIDTH = 12; // HH:MM:SS.mmm + +export function printEntry(entry: CapturedEntry, stream: NodeJS.WritableStream = process.stdout): void { + const time = formatTime(entry.completedAt); + const method = entry.method.toUpperCase().padEnd(METHOD_WIDTH); + const status = String(entry.statusCode).padStart(STATUS_WIDTH); + const duration = formatDuration(entry.durationMs).padStart(DURATION_WIDTH); + const size = formatSize(entry.responseSize).padStart(SIZE_WIDTH); + + // Reserve space for metadata columns + spaces. + const cols = (process.stdout as { columns?: number }).columns ?? 120; + const fixedWidth = TIME_WIDTH + 2 + METHOD_WIDTH + 1 + STATUS_WIDTH + 1 + DURATION_WIDTH + 1 + SIZE_WIDTH; + const urlWidth = Math.max(20, cols - fixedWidth - 4); + const url = truncate(entry.url, urlWidth); + + const statusColor = colorForStatus(entry.statusCode); + + stream.write( + ` ${chalk.dim(time)} ${chalk.bold(method)} ${url.padEnd(urlWidth)} ${statusColor(status)} ${chalk.dim(duration)} ${chalk.dim(size)}\n`, + ); +} + +export function printTlsError(err: TlsError, stream: NodeJS.WritableStream = process.stdout): void { + const time = formatTime(new Date()); + const method = '!!!'.padEnd(METHOD_WIDTH); + const host = err.hostname || 'unknown'; + const reason = tlsFailureMessage(err.failureCause); + stream.write( + ` ${chalk.dim(time)} ${chalk.magenta(method)} ${chalk.magenta(host)} ${chalk.magenta(reason)}\n`, + ); +} + +function tlsFailureMessage(cause?: string): string { + switch (cause) { + case 'cert-rejected': + return 'TLS rejected (app pins certificates)'; + case 'closed': + return 'TLS closed (app may not trust user CAs)'; + case 'reset': + return 'TLS reset (app may not trust user CAs)'; + default: + return 'TLS failed'; + } +} + +function formatTime(d: Date): string { + const h = String(d.getHours()).padStart(2, '0'); + const m = String(d.getMinutes()).padStart(2, '0'); + const s = String(d.getSeconds()).padStart(2, '0'); + const ms = String(d.getMilliseconds()).padStart(3, '0'); + return `${h}:${m}:${s}.${ms}`; +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function formatSize(bytes: number): string { + if (bytes < 0) return '?'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function truncate(s: string, maxLen: number): string { + if (s.length <= maxLen) return s; + return s.slice(0, maxLen - 1) + '\u2026'; +} + +function colorForStatus(code: number): (text: string) => string { + if (code >= 500) return chalk.red; + if (code >= 400) return chalk.yellow; + if (code >= 300) return chalk.cyan; + if (code >= 200) return chalk.green; + return chalk.white; +} diff --git a/packages/cli/src/commands/logNetwork/proxyState.ts b/packages/cli/src/commands/logNetwork/proxyState.ts new file mode 100644 index 0000000..ae1a3fb --- /dev/null +++ b/packages/cli/src/commands/logNetwork/proxyState.ts @@ -0,0 +1,88 @@ +// Persists proxy state to disk so we can recover from crashes. +// If the process is SIGKILL'd, the next run detects the stale state +// and restores the original proxy settings before proceeding. + +import { promises as fsp } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import * as net from 'node:net'; + +const STATE_DIR = path.join(os.homedir(), '.finalrun'); +const STATE_FILE = path.join(STATE_DIR, 'proxy-state.json'); + +export interface SavedProxyState { + platform: 'android' | 'ios'; + pid: number; + ppid: number; + proxyPort: number; + startedAt: string; + + // Android-specific + deviceSerial?: string; + previousAndroidProxy?: string | null; + + // iOS-specific + networkService?: string; + previousAutoproxy?: { enabled: boolean; url: string }; +} + +export async function saveProxyState(state: SavedProxyState): Promise { + await fsp.mkdir(STATE_DIR, { recursive: true }); + await fsp.writeFile(STATE_FILE, JSON.stringify(state, null, 2), 'utf8'); +} + +export async function clearProxyState(): Promise { + try { + await fsp.unlink(STATE_FILE); + } catch { + // File might not exist. + } +} + +export async function loadProxyState(): Promise { + try { + const raw = await fsp.readFile(STATE_FILE, 'utf8'); + return JSON.parse(raw) as SavedProxyState; + } catch { + return null; + } +} + +/** + * Check if the process that wrote the state file is still running. + * Also checks the parent PID since tsx runs Node as a child. + */ +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Check if any process in the given PID list is alive. + */ +export function isAnyProcessAlive(pids: number[]): boolean { + return pids.some((pid) => isProcessAlive(pid)); +} + +/** + * Check if anything is listening on a given localhost port. + */ +export async function isPortListening(port: number): Promise { + return new Promise((resolve) => { + const socket = net.createConnection({ host: '127.0.0.1', port }, () => { + socket.destroy(); + resolve(true); + }); + socket.on('error', () => { + resolve(false); + }); + socket.setTimeout(1000, () => { + socket.destroy(); + resolve(false); + }); + }); +} diff --git a/packages/cli/src/reportServer.ts b/packages/cli/src/reportServer.ts index 880f72b..d55aa6f 100644 --- a/packages/cli/src/reportServer.ts +++ b/packages/cli/src/reportServer.ts @@ -26,6 +26,7 @@ const CONTENT_TYPES: Record = { '.html': 'text/html; charset=utf-8', '.json': 'application/json; charset=utf-8', '.log': 'text/plain; charset=utf-8', + '.har': 'application/json; charset=utf-8', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', @@ -302,6 +303,28 @@ export async function buildReportRunManifestViewModel( } return await cached; }; + const readNetworkHar = async (networkLogPath: string): Promise<{ entries: unknown[]; tlsErrors: unknown[] } | undefined> => { + const content = await readRunArtifactText(artifactsDir, runId, networkLogPath); + if (!content) return undefined; + try { + const har = JSON.parse(content); + const entries = (har?.log?.entries ?? []).map((e: Record) => ({ + startedDateTime: e['startedDateTime'], + method: (e['request'] as Record)?.['method'], + url: (e['request'] as Record)?.['url'], + status: (e['response'] as Record)?.['status'], + statusText: (e['response'] as Record)?.['statusText'], + time: e['time'], + responseSize: ((e['response'] as Record)?.['content'] as Record)?.['size'] ?? (e['response'] as Record)?.['bodySize'] ?? 0, + requestHeaders: (e['request'] as Record)?.['headers'], + responseHeaders: (e['response'] as Record)?.['headers'], + })); + const tlsErrors = har?.log?._tlsErrors ?? []; + return { entries, tlsErrors }; + } catch { + return undefined; + } + }; const readDeviceLogTail = async (deviceLogPath: string): Promise => { const content = await readRunArtifactText(artifactsDir, runId, deviceLogPath); if (!content) { @@ -337,7 +360,7 @@ export async function buildReportRunManifestViewModel( ), }, tests: await Promise.all( - manifest.tests.map(async (test) => await toTestViewModel(runId, test, readSnapshotYamlText, readDeviceLogTail)), + manifest.tests.map(async (test) => await toTestViewModel(runId, test, readSnapshotYamlText, readDeviceLogTail, readNetworkHar)), ), paths: { ...manifest.paths, @@ -375,7 +398,9 @@ async function toTestViewModel( test: TestResult, readSnapshotYamlText: (snapshotYamlPath: string) => Promise, readDeviceLogTail: (deviceLogPath: string) => Promise, + readNetworkHar: (networkLogPath: string) => Promise<{ entries: unknown[]; tlsErrors: unknown[] } | undefined>, ): Promise { + const networkHar = test.networkLogFile ? await readNetworkHar(test.networkLogFile) : undefined; return { ...test, snapshotYamlPath: test.snapshotYamlPath @@ -393,6 +418,11 @@ async function toTestViewModel( deviceLogTailText: test.deviceLogFile ? await readDeviceLogTail(test.deviceLogFile) : undefined, + networkLogFile: test.networkLogFile + ? buildRunScopedArtifactPath(runId, test.networkLogFile) + : undefined, + networkLogEntries: networkHar?.entries as ReportManifestTestRecord['networkLogEntries'], + networkTlsErrors: networkHar?.tlsErrors as ReportManifestTestRecord['networkTlsErrors'], previewScreenshotPath: test.previewScreenshotPath ? buildRunScopedArtifactPath(runId, test.previewScreenshotPath) : undefined, diff --git a/packages/cli/src/reportTemplate.ts b/packages/cli/src/reportTemplate.ts index 6b969f5..bb124f3 100644 --- a/packages/cli/src/reportTemplate.ts +++ b/packages/cli/src/reportTemplate.ts @@ -21,9 +21,29 @@ export interface ReportManifestSelectedTestRecord extends TestDefinition { snapshotYamlText?: string; } +export interface NetworkLogEntry { + startedDateTime: string; + method: string; + url: string; + status: number; + statusText: string; + time: number; + responseSize: number; + requestHeaders?: Array<{ name: string; value: string }>; + responseHeaders?: Array<{ name: string; value: string }>; +} + +export interface NetworkTlsErrorEntry { + hostname: string; + cause: string; + timestamp: string; +} + export interface ReportManifestTestRecord extends TestResult { snapshotYamlText?: string; deviceLogTailText?: string; + networkLogEntries?: NetworkLogEntry[]; + networkTlsErrors?: NetworkTlsErrorEntry[]; } export interface ReportRunManifest extends Omit { @@ -676,6 +696,48 @@ export function renderHtmlReport(manifest: ReportRunManifest): string { background: rgba(67, 24, 255, 0.04); } + /* ── Network Log Tab ──────────────────────────────────────────────── */ + .network-log-inline { display: flex; flex-direction: column; height: 100%; } + .network-log-toolbar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border-light); flex-shrink: 0; } + .network-log-search { flex: 1; padding: 6px 10px; font-size: 12px; border: 1px solid var(--border-light); border-radius: 6px; background: var(--surface); color: var(--text-primary); } + .network-log-search:focus { outline: 2px solid var(--accent); } + .network-log-match-count { font-size: 11px; color: var(--text-muted); min-width: 40px; } + .network-log-filters { display: flex; gap: 4px; } + .net-filter-chip { font-size: 11px; padding: 3px 8px; border-radius: 4px; border: 1px solid var(--border-light); background: transparent; color: var(--text-secondary); cursor: pointer; } + .net-filter-chip.is-active { background: var(--accent); color: #fff; border-color: var(--accent); } + .network-log-table-wrap { display: flex; flex: 1; overflow: hidden; } + .network-log-table { flex: 1; overflow-y: auto; font-size: 12px; font-family: var(--font-mono); } + .network-log-row { display: flex; align-items: center; gap: 8px; padding: 5px 12px; border-bottom: 1px solid var(--border-light); cursor: pointer; } + .network-log-row:hover { background: rgba(67, 24, 255, 0.04); } + .network-log-row.is-active { background: rgba(67, 24, 255, 0.08); } + .network-log-row.is-selected { background: rgba(67, 24, 255, 0.12); border-left: 3px solid var(--accent); padding-left: 9px; } + .network-log-row.is-hidden { display: none; } + .net-method { width: 50px; font-weight: 600; flex-shrink: 0; } + .net-url { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary); } + .net-status { width: 40px; text-align: right; flex-shrink: 0; font-weight: 600; } + .net-time { width: 55px; text-align: right; flex-shrink: 0; color: var(--text-muted); } + .net-size { width: 55px; text-align: right; flex-shrink: 0; color: var(--text-muted); } + .network-log-row.status-2xx .net-status { color: #05cd99; } + .network-log-row.status-3xx .net-status { color: #3b82f6; } + .network-log-row.status-4xx .net-status { color: #e8a735; } + .network-log-row.status-5xx .net-status { color: #ee5d50; } + .network-log-row.tls-error { opacity: 0.6; } + .network-log-row.tls-error .net-method { color: #d946ef; } + .network-log-row.tls-error .net-status { color: #d946ef; font-weight: normal; font-size: 11px; width: auto; } + .network-log-detail { width: 45%; border-left: 1px solid var(--border-light); overflow-y: auto; display: flex; flex-direction: column; } + .network-log-detail-header { padding: 10px 12px; font-size: 12px; font-weight: 600; border-bottom: 1px solid var(--border-light); word-break: break-all; } + .network-log-detail-tabs { display: flex; border-bottom: 1px solid var(--border-light); } + .net-detail-tab { padding: 6px 14px; font-size: 11px; border: none; background: none; cursor: pointer; color: var(--text-secondary); border-bottom: 2px solid transparent; } + .net-detail-tab.is-active { color: var(--accent); border-bottom-color: var(--accent); } + .network-log-detail-body { flex: 1; overflow-y: auto; padding: 10px 12px; font-size: 12px; font-family: var(--font-mono); white-space: pre-wrap; word-break: break-all; } + .network-log-detail-body .header-section { margin-bottom: 12px; } + .network-log-detail-body .header-section-title { font-weight: 600; color: var(--text-muted); margin-bottom: 4px; font-size: 11px; text-transform: uppercase; } + .network-log-detail-body .header-row { display: flex; gap: 8px; padding: 2px 0; } + .network-log-detail-body .header-name { color: var(--accent); min-width: 120px; flex-shrink: 0; } + .network-log-detail-body .header-value { color: var(--text-primary); word-break: break-all; } + .network-log-download { display: block; padding: 10px 16px; border-top: 1px solid var(--border-light); font-size: 12px; color: var(--accent); text-decoration: none; flex-shrink: 0; } + .network-log-download:hover { background: rgba(67, 24, 255, 0.04); } + .analysis-card.success { background: rgba(5, 205, 153, 0.08); border-color: rgba(5, 205, 153, 0.22); @@ -1415,6 +1477,169 @@ export function renderHtmlReport(manifest: ReportRunManifest): string { } }); + // ── Network Log JS ───────────────────────────────────────────────── + function handleNetFilter(chip) { + var netInline = chip.closest('.network-log-inline'); + if (!netInline) return; + var status = chip.dataset.netStatus; + if (status === 'all') { + var chips = netInline.querySelectorAll('.net-filter-chip'); + for (var i = 0; i < chips.length; i++) chips[i].classList.toggle('is-active', chips[i].dataset.netStatus === 'all'); + } else { + chip.classList.toggle('is-active'); + var allChip = netInline.querySelector('.net-filter-chip[data-net-status="all"]'); + var anyActive = false; + var levelChips = netInline.querySelectorAll('.net-filter-chip:not([data-net-status="all"])'); + for (var j = 0; j < levelChips.length; j++) { if (levelChips[j].classList.contains('is-active')) anyActive = true; } + if (allChip) allChip.classList.toggle('is-active', !anyActive); + } + applyNetVisibility(netInline); + } + + function applyNetVisibility(netInline) { + var searchText = (netInline.querySelector('.network-log-search') || {}).value || ''; + var activeChips = netInline.querySelectorAll('.net-filter-chip.is-active'); + var activeStatuses = []; + var allActive = false; + for (var c = 0; c < activeChips.length; c++) { + if (activeChips[c].dataset.netStatus === 'all') allActive = true; + else activeStatuses.push(activeChips[c].dataset.netStatus); + } + var rows = netInline.querySelectorAll('.network-log-row'); + var visible = 0; + for (var i = 0; i < rows.length; i++) { + var row = rows[i]; + var url = (row.querySelector('.net-url') || {}).textContent || ''; + var statusCode = row.dataset.netStatusCode || ''; + var matchesSearch = !searchText || url.toLowerCase().indexOf(searchText.toLowerCase()) !== -1; + var matchesFilter = allActive || activeStatuses.length === 0 || activeStatuses.some(function(s) { return statusCode.charAt(0) === s; }); + var show = matchesSearch && (matchesFilter || row.classList.contains('tls-error')); + row.classList.toggle('is-hidden', !show); + if (show) visible++; + } + var countEl = netInline.querySelector('.network-log-match-count'); + if (countEl) countEl.textContent = visible + ' / ' + rows.length; + } + + function handleNetRowClick(row) { + if (row.classList.contains('tls-error')) return; + var netInline = row.closest('.network-log-inline'); + if (!netInline) return; + var detail = netInline.querySelector('.network-log-detail'); + if (!detail) return; + var wasSelected = row.classList.contains('is-selected'); + var allRows = netInline.querySelectorAll('.network-log-row.is-selected'); + for (var i = 0; i < allRows.length; i++) allRows[i].classList.remove('is-selected'); + if (wasSelected) { + detail.style.display = 'none'; + return; + } + row.classList.add('is-selected'); + detail.style.display = ''; + var entry = JSON.parse(row.dataset.entry || '{}'); + renderNetDetail(detail, entry); + // Video sync: seek to request timestamp + var container = netInline.closest('[data-test-panel]'); + if (container) { + var video = container.querySelector('[data-role="recording-video"]'); + var recStarted = netInline.dataset.recordingStarted; + if (video && recStarted && row.dataset.networkTs) { + var offset = (new Date(row.dataset.networkTs).getTime() - new Date(recStarted).getTime()) / 1000; + if (isFinite(offset) && offset >= 0) video.currentTime = offset; + } + } + } + + function renderNetDetail(detail, entry) { + var header = detail.querySelector('.network-log-detail-header'); + if (header) header.textContent = entry.method + ' ' + entry.url; + // Default to Headers tab + var tabs = detail.querySelectorAll('.net-detail-tab'); + for (var t = 0; t < tabs.length; t++) tabs[t].classList.toggle('is-active', tabs[t].dataset.detailTab === 'headers'); + renderNetDetailContent(detail, entry, 'headers'); + } + + function switchNetDetailTab(tab) { + var detail = tab.closest('.network-log-detail'); + if (!detail) return; + var tabs = detail.querySelectorAll('.net-detail-tab'); + for (var i = 0; i < tabs.length; i++) tabs[i].classList.remove('is-active'); + tab.classList.add('is-active'); + var row = detail.closest('.network-log-table-wrap').querySelector('.network-log-row.is-selected'); + var entry = row ? JSON.parse(row.dataset.entry || '{}') : {}; + renderNetDetailContent(detail, entry, tab.dataset.detailTab); + } + + function renderNetDetailContent(detail, entry, tab) { + var body = detail.querySelector('.network-log-detail-body'); + if (!body) return; + body.innerHTML = renderHeaderSection('General', [{ name: 'URL', value: entry.url || '' }, { name: 'Status', value: (entry.status || '') + ' ' + (entry.statusText || '') }, { name: 'Duration', value: (entry.time || 0) + 'ms' }, { name: 'Size', value: formatNetBytes(entry.responseSize || 0) }]) + renderHeaderSection('Response Headers', entry.responseHeaders) + renderHeaderSection('Request Headers', entry.requestHeaders); + } + + function renderHeaderSection(title, headers) { + if (!headers || headers.length === 0) return ''; + var html = '
' + escapeHtmlJS(title) + '
'; + for (var i = 0; i < headers.length; i++) { + html += '
' + escapeHtmlJS(headers[i].name) + '' + escapeHtmlJS(headers[i].value) + '
'; + } + return html + '
'; + } + + function formatNetBytes(b) { + if (!b || b <= 0) return '0 B'; + if (b < 1024) return b + ' B'; + if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'; + return (b / 1048576).toFixed(1) + ' MB'; + } + + function escapeHtmlJS(s) { + if (!s) return ''; + return s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + } + + // Network search input handler + document.addEventListener('input', function(e) { + if (e.target && e.target.classList && e.target.classList.contains('network-log-search')) { + var netInline = e.target.closest('.network-log-inline'); + if (netInline) applyNetVisibility(netInline); + } + }); + + // Initialize network log counts + var netInlines = document.querySelectorAll('.network-log-inline'); + for (var ni = 0; ni < netInlines.length; ni++) { + var countEl = netInlines[ni].querySelector('.network-log-match-count'); + var total = netInlines[ni].querySelectorAll('.network-log-row').length; + if (countEl) countEl.textContent = total + ' requests'; + } + + // Network log video sync on playback + function syncNetworkLogToVideo(container) { + var netInline = container.querySelector('.network-log-inline'); + if (!netInline) return; + var video = container.querySelector('[data-role="recording-video"]'); + if (!video) return; + var recStarted = netInline.dataset.recordingStarted; + if (!recStarted) return; + var recStartMs = new Date(recStarted).getTime(); + var rows = netInline.querySelectorAll('.network-log-row[data-network-ts]:not(.is-hidden):not(.tls-error)'); + if (rows.length === 0) return; + var currentMs = recStartMs + video.currentTime * 1000; + var bestRow = null; + var bestDist = Infinity; + for (var i = 0; i < rows.length; i++) { + var ts = new Date(rows[i].dataset.networkTs).getTime(); + var dist = Math.abs(ts - currentMs); + if (dist < bestDist) { bestDist = dist; bestRow = rows[i]; } + } + var prev = netInline.querySelectorAll('.network-log-row.is-active'); + for (var p = 0; p < prev.length; p++) prev[p].classList.remove('is-active'); + if (bestRow && bestDist < 5000) { + bestRow.classList.add('is-active'); + bestRow.scrollIntoView({ block: 'nearest' }); + } + } + function syncRecordingShell(container, video) { const shell = container.querySelector('.recording-shell'); if (!shell) { @@ -1507,6 +1732,7 @@ export function renderHtmlReport(manifest: ReportRunManifest): string { if (now - lastLogHighlightTime < 500) return; lastLogHighlightTime = now; highlightNearestLogLine(container, video.currentTime); + syncNetworkLogToVideo(container); }); video.dataset.seekbarBound = '1'; @@ -1914,6 +2140,9 @@ function renderSpecDetailSection( ${test?.deviceLogFile ? '' : ''} + ${test?.networkLogEntries && test.networkLogEntries.length > 0 + ? `` + : ''}
@@ -1939,12 +2168,99 @@ function renderSpecDetailSection(
` : ''} + ${test?.networkLogEntries && test.networkLogEntries.length > 0 + ? `
+
+
+ + +
+ + + + + +
+
+
+
+ ${renderNetworkLogRows(test.networkLogEntries, test.networkTlsErrors)} +
+ +
+ ${test.networkLogFile ? `Download network.har` : ''} +
+
` + : ''} `; } +function renderNetworkLogRows(entries: NonNullable, tlsErrors?: ReportManifestTestRecord['networkTlsErrors']): string { + let html = ''; + for (const entry of entries) { + const statusClass = entry.status >= 500 ? 'status-5xx' : entry.status >= 400 ? 'status-4xx' : entry.status >= 300 ? 'status-3xx' : 'status-2xx'; + const duration = entry.time < 1000 ? `${Math.round(entry.time)}ms` : `${(entry.time / 1000).toFixed(1)}s`; + const size = formatNetSize(entry.responseSize); + const urlPath = extractUrlPath(entry.url); + const entryJson = escapeHtml(JSON.stringify({ + url: entry.url, + method: entry.method, + status: entry.status, + statusText: entry.statusText, + time: entry.time, + responseSize: entry.responseSize, + requestHeaders: entry.requestHeaders, + responseHeaders: entry.responseHeaders, + })); + html += `
+ ${escapeHtml(entry.method)} + ${escapeHtml(urlPath)} + ${entry.status} + ${duration} + ${size} +
`; + } + if (tlsErrors && tlsErrors.length > 0) { + for (const err of tlsErrors) { + const reason = err.cause === 'cert-rejected' ? 'TLS rejected (pinning)' : err.cause === 'closed' || err.cause === 'reset' ? 'TLS closed (CA not trusted)' : 'TLS failed'; + html += `
+ !!! + ${escapeHtml(err.hostname)} + ${escapeHtml(reason)} + + +
`; + } + } + return html; +} + +function formatNetSize(bytes: number): string { + if (!bytes || bytes <= 0) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function extractUrlPath(url: string): string { + try { + const u = new URL(url); + const path = u.pathname + u.search; + return path.length > 80 ? path.slice(0, 79) + '\u2026' : path; + } catch { + return url.length > 80 ? url.slice(0, 79) + '\u2026' : url; + } +} + function renderSpecTestSection( snapshotYamlPath: string | undefined, snapshotYamlText: string | undefined, diff --git a/packages/cli/src/reportWriter.ts b/packages/cli/src/reportWriter.ts index e019cbb..79d5a91 100644 --- a/packages/cli/src/reportWriter.ts +++ b/packages/cli/src/reportWriter.ts @@ -24,7 +24,7 @@ import { redactResolvedValue, } from '@finalrun/common'; import type { TestExecutionResult, AgentActionResult } from '@finalrun/goal-executor'; -import type { DeviceLogCaptureResult } from '@finalrun/common'; +import type { DeviceLogCaptureResult, NetworkLogCaptureResult } from '@finalrun/common'; import type { LoadedEnvironmentConfig } from './testLoader.js'; interface TestSnapshotState { @@ -328,6 +328,10 @@ export class ReportWriter { const deviceLogStartedAt = result.deviceLog?.startedAt; const deviceLogCompletedAt = result.deviceLog?.completedAt; + const networkLogRelative = await this._copyNetworkLogArtifact(test.testId!, result.networkLog, bindings); + const networkLogStartedAt = result.networkLog?.startedAt; + const networkLogCompletedAt = result.networkLog?.completedAt; + const steps: AgentAction[] = []; for (const [index, step] of result.steps.entries()) { const stepNumber = index + 1; @@ -379,6 +383,9 @@ export class ReportWriter { deviceLogFile: deviceLogRelative, deviceLogStartedAt, deviceLogCompletedAt, + networkLogFile: networkLogRelative, + networkLogStartedAt, + networkLogCompletedAt, steps, }; @@ -744,6 +751,48 @@ export class ReportWriter { return logRelative; } + private async _copyNetworkLogArtifact( + testId: string, + networkLog: NetworkLogCaptureResult | undefined, + bindings: RuntimeBindings, + ): Promise { + if (!networkLog?.filePath) { + return undefined; + } + + const harRelative = path.posix.join('tests', testId, 'network.har'); + const sourcePath = path.resolve(networkLog.filePath); + const targetPath = path.resolve(path.join(this._runDir, harRelative)); + + try { + await fsp.access(sourcePath); + } catch { + Logger.w(`Network HAR file not found for report copy: ${networkLog.filePath}`); + return undefined; + } + + await fsp.copyFile(sourcePath, targetPath); + + // Redact sensitive headers and secret values in the HAR. + try { + const raw = await fsp.readFile(targetPath, 'utf-8'); + const har = JSON.parse(raw); + if (har?.log?.entries) { + for (const entry of har.log.entries) { + redactHarHeaders(entry.request?.headers); + redactHarHeaders(entry.response?.headers); + redactHarQueryParams(entry.request?.queryString); + } + } + await fsp.writeFile(targetPath, JSON.stringify(har, null, 2), 'utf-8'); + } catch (error) { + Logger.w(`Failed to redact network HAR file: ${this._formatError(error)}`); + // Keep the unredacted copy rather than deleting — better to have data. + } + + return harRelative; + } + private _formatError(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -937,3 +986,51 @@ function redactTiming( })), }; } + +// ── HAR redaction helpers ──────────────────────────────────────────────────── + +const REDACTED_HEADER_NAMES = new Set([ + 'authorization', + 'cookie', + 'set-cookie', + 'x-api-key', +]); + +const REDACTED_TOKEN_SUFFIXES = ['-token', '-key', '-secret']; + +function shouldRedactHeader(name: string): boolean { + const lower = name.toLowerCase(); + if (REDACTED_HEADER_NAMES.has(lower)) return true; + for (const suffix of REDACTED_TOKEN_SUFFIXES) { + if (lower.endsWith(suffix)) return true; + } + return false; +} + +function redactHarHeaders(headers: Array<{ name: string; value: string }> | undefined): void { + if (!headers) return; + for (const header of headers) { + if (shouldRedactHeader(header.name)) { + header.value = '[REDACTED]'; + } + } +} + +const REDACTED_QUERY_PARAM_NAMES = new Set([ + 'token', + 'api_key', + 'apikey', + 'access_token', + 'key', + 'secret', + 'password', +]); + +function redactHarQueryParams(queryString: Array<{ name: string; value: string }> | undefined): void { + if (!queryString) return; + for (const param of queryString) { + if (REDACTED_QUERY_PARAM_NAMES.has(param.name.toLowerCase())) { + param.value = '[REDACTED]'; + } + } +} diff --git a/packages/cli/src/sessionRunner.ts b/packages/cli/src/sessionRunner.ts index ccccb04..69ddcc2 100644 --- a/packages/cli/src/sessionRunner.ts +++ b/packages/cli/src/sessionRunner.ts @@ -20,7 +20,7 @@ import { type TestRecordingResult, } from '@finalrun/goal-executor'; import type { TestExecutionResult } from '@finalrun/goal-executor'; -import type { DeviceLogCaptureResult } from '@finalrun/common'; +import type { DeviceLogCaptureResult, NetworkLogCaptureResult } from '@finalrun/common'; import type { ResolvedAppConfig } from './appConfig.js'; import { CliFilePathUtil } from './filePathUtil.js'; import { @@ -77,6 +77,10 @@ export interface TestSessionConfig { testId: string; keepPartialOnFailure?: boolean; }; + networkCapture?: { + runId: string; + testId: string; + }; } export interface GoalSessionConfig { @@ -304,6 +308,13 @@ export async function executeTestOnSession( keepPartialOnFailure: boolean; } | undefined; + let activeNetworkCapture: + | { + runId: string; + testId: string; + startedAt: string; + } + | undefined; try { const aiAgent = dependencies.createAiAgent({ @@ -409,6 +420,33 @@ export async function executeTestOnSession( } } + if (config.networkCapture) { + try { + const netResponse = await session.device.startNetworkCapture({ + runId: config.networkCapture.runId, + testId: config.networkCapture.testId, + }); + if (netResponse.success) { + activeNetworkCapture = { + runId: config.networkCapture.runId, + testId: config.networkCapture.testId, + startedAt: + typeof netResponse.data?.['startedAt'] === 'string' + ? (netResponse.data['startedAt'] as string) + : new Date().toISOString(), + }; + Logger.d(`Network capture marker set for test ${config.networkCapture.testId}`); + } else { + Logger.w( + `Unable to start network capture for test ${config.networkCapture.testId}: ` + + `${netResponse.message ?? 'unknown error'}`, + ); + } + } catch (error) { + Logger.w('Failed to start network capture:', error); + } + } + // Execute! let result = await executor.executeGoal((event) => renderer.onProgress(event)); @@ -468,6 +506,43 @@ export async function executeTestOnSession( activeRecording = undefined; } + let networkLog: NetworkLogCaptureResult | undefined; + if (activeNetworkCapture) { + try { + const stopNetResponse = await session.device.stopNetworkCapture( + activeNetworkCapture.runId, + activeNetworkCapture.testId, + ); + if (stopNetResponse.success) { + const filePath = stopNetResponse.data?.['filePath']; + if (typeof filePath === 'string') { + networkLog = { + filePath, + startedAt: + typeof stopNetResponse.data?.['startedAt'] === 'string' + ? (stopNetResponse.data['startedAt'] as string) + : activeNetworkCapture.startedAt, + completedAt: + typeof stopNetResponse.data?.['completedAt'] === 'string' + ? (stopNetResponse.data['completedAt'] as string) + : new Date().toISOString(), + }; + } else { + Logger.w(`Network capture stopped for test ${activeNetworkCapture.testId} but no file path returned.`); + } + activeNetworkCapture = undefined; + } else { + Logger.w( + `Unable to stop network capture for test ${activeNetworkCapture.testId}: ` + + `${stopNetResponse.message ?? 'unknown error'}`, + ); + activeNetworkCapture = undefined; + } + } catch (error) { + Logger.w('Failed to stop network capture:', error); + } + } + let deviceLog: DeviceLogCaptureResult | undefined; if (activeLogCapture) { try { @@ -516,11 +591,12 @@ export async function executeTestOnSession( } } - const finalResult = recording - ? { ...result, recording, ...(deviceLog ? { deviceLog } : {}) } - : deviceLog - ? { ...result, deviceLog } - : result; + const finalResult = { + ...result, + ...(recording ? { recording } : {}), + ...(deviceLog ? { deviceLog } : {}), + ...(networkLog ? { networkLog } : {}), + }; // Print summary renderer.printSummary(finalResult); @@ -549,6 +625,13 @@ export async function executeTestOnSession( Logger.w('Failed to abort active log capture during cleanup:', error); } } + if (activeNetworkCapture) { + try { + await session.device.abortNetworkCapture(activeNetworkCapture.runId); + } catch (error) { + Logger.w('Failed to abort active network capture during cleanup:', error); + } + } renderer.destroy(); } } diff --git a/packages/cli/src/testRunner.ts b/packages/cli/src/testRunner.ts index 1e79d6d..8de8eb5 100644 --- a/packages/cli/src/testRunner.ts +++ b/packages/cli/src/testRunner.ts @@ -38,6 +38,12 @@ import { resolveWorkspace, type FinalRunWorkspace, } from './workspace.js'; +import { loadOrGenerateCA } from './commands/logNetwork/ca.js'; +import { + AndroidNetworkProxySetup, + IOSNetworkProxySetup, + type NetworkProxySetup, +} from '@finalrun/device-node'; export interface TestRunnerOptions extends CheckRunnerOptions { apiKey: string; @@ -46,6 +52,7 @@ export interface TestRunnerOptions extends CheckRunnerOptions { maxIterations?: number; debug?: boolean; invokedCommand?: 'test' | 'suite'; + networkCapture?: boolean; } export interface TestRunnerResult { @@ -230,6 +237,41 @@ export async function runTests(options: TestRunnerOptions): Promise; + return { + capture: typeof obj['capture'] === 'boolean' ? obj['capture'] : undefined, }; } diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 4f43900..a5d36ac 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -36,6 +36,7 @@ export type { SpanTiming, } from './models/Trace.js'; export type { DeviceLogCaptureResult } from './models/DeviceLog.js'; +export type { NetworkLogCaptureResult } from './models/NetworkLog.js'; export type { PlannerThought, ActionPayload, diff --git a/packages/common/src/interfaces/DeviceAgent.ts b/packages/common/src/interfaces/DeviceAgent.ts index 8337969..55a11ba 100644 --- a/packages/common/src/interfaces/DeviceAgent.ts +++ b/packages/common/src/interfaces/DeviceAgent.ts @@ -58,6 +58,17 @@ export interface DeviceAgent { logCaptureCleanUp(): Promise; abortLogCapture(runId: string, keepOutput?: boolean): Promise; + // Network capture methods (session-scoped proxy + per-test entries) + startNetworkSession(params: { cert: string; key: string }): Promise; + stopNetworkSession(): Promise; + startNetworkCapture(request: { runId: string; testId: string }): Promise; + stopNetworkCapture(runId: string, testId: string): Promise; + networkCaptureCleanUp(): Promise; + abortNetworkCapture(runId: string): Promise; + getNetworkProxyPort(): number; + getNetworkEntryCount(): number; + getNetworkTlsErrorCount(): number; + // Dart: void uninstallDriver() uninstallDriver(): void; } diff --git a/packages/common/src/models/NetworkLog.ts b/packages/common/src/models/NetworkLog.ts new file mode 100644 index 0000000..75798fe --- /dev/null +++ b/packages/common/src/models/NetworkLog.ts @@ -0,0 +1,5 @@ +export interface NetworkLogCaptureResult { + filePath: string; + startedAt: string; + completedAt: string; +} diff --git a/packages/common/src/models/TestResult.ts b/packages/common/src/models/TestResult.ts index 7962dd6..d475a6f 100644 --- a/packages/common/src/models/TestResult.ts +++ b/packages/common/src/models/TestResult.ts @@ -61,6 +61,9 @@ export interface TestResult { deviceLogFile?: string; deviceLogStartedAt?: string; deviceLogCompletedAt?: string; + networkLogFile?: string; + networkLogStartedAt?: string; + networkLogCompletedAt?: string; steps: AgentAction[]; workspaceSourcePath?: string; snapshotYamlPath?: string; diff --git a/packages/device-node/src/device/Device.ts b/packages/device-node/src/device/Device.ts index 849d689..f284405 100644 --- a/packages/device-node/src/device/Device.ts +++ b/packages/device-node/src/device/Device.ts @@ -38,6 +38,10 @@ import { defaultLogCaptureManager, type DeviceLogCaptureController, } from './LogCaptureManager.js'; +import { + NetworkCaptureManager, + type DeviceNetworkCaptureController, +} from './NetworkCaptureManager.js'; import type { DeviceRuntime, DeviceScreenshotAndHierarchy, @@ -56,17 +60,20 @@ export class Device implements DeviceAgent { private _disconnectionCallback: ((deviceUUID: string, reason: string) => void) | null = null; private _recordingController: DeviceRecordingController; private _logCaptureController: DeviceLogCaptureController; + private _networkCaptureController: DeviceNetworkCaptureController; constructor(params: { deviceInfo: DeviceInfo; runtime: DeviceRuntime; recordingController?: DeviceRecordingController; logCaptureController?: DeviceLogCaptureController; + networkCaptureController?: DeviceNetworkCaptureController; }) { this._deviceInfo = params.deviceInfo; this._runtime = params.runtime; this._recordingController = params.recordingController ?? defaultRecordingManager; this._logCaptureController = params.logCaptureController ?? defaultLogCaptureManager; + this._networkCaptureController = params.networkCaptureController ?? new NetworkCaptureManager(); } async setUp(_options?: { reuseAddress?: boolean }): Promise { @@ -193,6 +200,11 @@ export class Device implements DeviceAgent { } catch (error) { Logger.w('Failed to clean up log capture resources:', error); } + try { + await this.networkCaptureCleanUp(); + } catch (error) { + Logger.w('Failed to clean up network capture resources:', error); + } await this._runtime.close(); } @@ -327,6 +339,44 @@ export class Device implements DeviceAgent { }); } + // ── Network capture ────────────────────────────────────────────────── + + async startNetworkSession(params: { cert: string; key: string }): Promise { + return await this._networkCaptureController.startSession(params); + } + + async stopNetworkSession(): Promise { + await this._networkCaptureController.stopSession(); + } + + async startNetworkCapture(request: { runId: string; testId: string }): Promise { + return await this._networkCaptureController.startTestCapture(request); + } + + async stopNetworkCapture(runId: string, testId: string): Promise { + return await this._networkCaptureController.stopTestCapture(runId, testId); + } + + async networkCaptureCleanUp(): Promise { + await this._networkCaptureController.stopSession(); + } + + async abortNetworkCapture(runId: string): Promise { + await this._networkCaptureController.abortTestCapture(runId); + } + + getNetworkProxyPort(): number { + return this._networkCaptureController.proxyPort; + } + + getNetworkEntryCount(): number { + return this._networkCaptureController.entryCount; + } + + getNetworkTlsErrorCount(): number { + return this._networkCaptureController.tlsErrorCount; + } + uninstallDriver(): void { Logger.d(`Uninstall driver for device: ${this._deviceInfo.deviceUUID}`); } diff --git a/packages/device-node/src/device/NetworkCaptureManager.ts b/packages/device-node/src/device/NetworkCaptureManager.ts new file mode 100644 index 0000000..712b17d --- /dev/null +++ b/packages/device-node/src/device/NetworkCaptureManager.ts @@ -0,0 +1,377 @@ +// Network capture manager: session-scoped HTTPS proxy with per-test entry slicing. +// +// Session lifecycle (once per run): +// startSession() → starts mockttp MITM proxy, subscribes to request/response events +// stopSession() → stops proxy, clears all state +// +// Per-test lifecycle (called for each test): +// startTestCapture(runId, testId) → records the current entry count as a marker +// stopTestCapture(runId, testId) → slices entries since marker, writes HAR to /tmp/ +// +// This follows the same manager pattern as LogCaptureManager but with a +// session-scoped proxy instead of per-test child processes. + +import * as fsp from 'node:fs/promises'; +import * as os from 'node:os'; +import path from 'node:path'; +import { DeviceNodeResponse, Logger } from '@finalrun/common'; + +// mockttp is an optional dependency (only in the CLI package). +// Dynamic import so device-node doesn't hard-depend on it. +type Mockttp = import('mockttp').Mockttp; + +export interface NetworkCaptureSessionParams { + cert: string; + key: string; +} + +export interface NetworkCaptureTestParams { + runId: string; + testId: string; +} + +export interface NetworkCapturedEntry { + startedAt: Date; + completedAt: Date; + method: string; + url: string; + statusCode: number; + statusMessage: string; + requestHeaders: Record; + responseHeaders: Record; + requestBodySize: number; + responseBodySize: number; + durationMs: number; +} + +export interface NetworkTlsError { + hostname: string; + failureCause: string; + timestamp: Date; +} + +export interface DeviceNetworkCaptureController { + startSession(params: NetworkCaptureSessionParams): Promise; + stopSession(): Promise; + startTestCapture(params: NetworkCaptureTestParams): Promise; + stopTestCapture(runId: string, testId: string): Promise; + abortTestCapture(runId: string): Promise; + get proxyPort(): number; + get entryCount(): number; + get tlsErrorCount(): number; +} + +const MAP_KEY_DELIMITER = '###'; +const TEMP_DIR = path.join(os.tmpdir(), 'finalrun-network'); +export class NetworkCaptureManager implements DeviceNetworkCaptureController { + private _server: Mockttp | null = null; + private _entries: NetworkCapturedEntry[] = []; + private _tlsErrors: NetworkTlsError[] = []; + private _proxyPort = 0; + + // Per-test tracking: maps runId###testId → start index in _entries array. + private readonly _testStartIndexMap = new Map(); + private readonly _testTlsStartIndexMap = new Map(); + private readonly _stoppedTests = new Set(); + + // Pending requests (waiting for response). + private readonly _pendingRequests = new Map< + string, + { startedAt: Date; method: string; url: string; headers: Record; bodySize: number } + >(); + + get proxyPort(): number { + return this._proxyPort; + } + + get entryCount(): number { + return this._entries.length; + } + + get tlsErrorCount(): number { + return this._tlsErrors.length; + } + + async startSession(params: NetworkCaptureSessionParams): Promise { + if (this._server) { + return new DeviceNodeResponse({ + success: false, + message: 'Network capture session already active', + }); + } + + try { + const mockttp = await import('mockttp'); + this._server = mockttp.getLocal({ + https: { cert: params.cert, key: params.key }, + }); + + await this._server.forAnyRequest().thenPassThrough(); + await this._server.start(); + this._proxyPort = this._server.port; + + // Subscribe to request events — store request metadata (no body). + await this._server.on('request', (req) => { + this._pendingRequests.set(req.id, { + startedAt: new Date(), + method: req.method, + url: req.url, + headers: flattenHeaders(req.headers), + bodySize: req.body?.buffer?.byteLength ?? 0, + }); + }); + + // Subscribe to response events — correlate with request, store entry. + await this._server.on('response', (response) => { + const completedAt = new Date(); + const pending = this._pendingRequests.get(response.id); + this._pendingRequests.delete(response.id); + + const entry: NetworkCapturedEntry = { + startedAt: pending?.startedAt ?? completedAt, + completedAt, + method: pending?.method ?? '???', + url: pending?.url ?? '', + statusCode: response.statusCode, + statusMessage: response.statusMessage ?? '', + requestHeaders: pending?.headers ?? {}, + responseHeaders: flattenHeaders(response.headers), + requestBodySize: pending?.bodySize ?? 0, + responseBodySize: response.body?.buffer?.byteLength ?? 0, + durationMs: completedAt.getTime() - (pending?.startedAt ?? completedAt).getTime(), + }; + this._entries.push(entry); + }); + + // Subscribe to TLS errors. + await this._server.on('tls-client-error', (failure) => { + const f = failure as unknown as { + failureCause?: string; + tlsMetadata?: { sniHostname?: string }; + }; + this._tlsErrors.push({ + hostname: f.tlsMetadata?.sniHostname ?? 'unknown', + failureCause: f.failureCause ?? 'unknown', + timestamp: new Date(), + }); + }); + + Logger.d(`Network capture proxy started on port ${this._proxyPort}`); + return new DeviceNodeResponse({ + success: true, + message: `Network capture proxy started on port ${this._proxyPort}`, + data: { proxyPort: this._proxyPort }, + }); + } catch (error) { + Logger.e('Failed to start network capture session', error); + return new DeviceNodeResponse({ + success: false, + message: `Failed to start network capture: ${formatError(error)}`, + }); + } + } + + async stopSession(): Promise { + if (this._server) { + try { + await this._server.stop(); + } catch (error) { + Logger.w('Failed to stop network capture proxy cleanly', error); + } + this._server = null; + } + this._entries = []; + this._tlsErrors = []; + this._pendingRequests.clear(); + this._testStartIndexMap.clear(); + this._testTlsStartIndexMap.clear(); + this._stoppedTests.clear(); + this._proxyPort = 0; + } + + async startTestCapture(params: NetworkCaptureTestParams): Promise { + const mapKey = this._mapKey(params.runId, params.testId); + + if (this._testStartIndexMap.has(mapKey)) { + return new DeviceNodeResponse({ + success: false, + message: 'Network capture already in progress for this test', + }); + } + + // Record the current position — entries from here onward belong to this test. + this._testStartIndexMap.set(mapKey, this._entries.length); + this._testTlsStartIndexMap.set(mapKey, this._tlsErrors.length); + this._stoppedTests.delete(mapKey); + + return new DeviceNodeResponse({ + success: true, + message: `Network capture started for test: ${params.testId}`, + data: { startedAt: new Date().toISOString() }, + }); + } + + async stopTestCapture(runId: string, testId: string): Promise { + const mapKey = this._mapKey(runId, testId); + + if (this._stoppedTests.has(mapKey)) { + return new DeviceNodeResponse({ + success: true, + message: 'Network capture already stopped for this test', + }); + } + + const startIndex = this._testStartIndexMap.get(mapKey); + if (startIndex === undefined) { + return new DeviceNodeResponse({ + success: false, + message: 'No active network capture for this test', + }); + } + + const tlsStartIndex = this._testTlsStartIndexMap.get(mapKey) ?? 0; + const completedAt = new Date(); + + // Slice entries and TLS errors for this test. + const testEntries = this._entries.slice(startIndex); + const testTlsErrors = this._tlsErrors.slice(tlsStartIndex); + const startedAt = testEntries.length > 0 + ? testEntries[0]!.startedAt + : (this._testStartIndexMap.get(mapKey) !== undefined ? new Date() : completedAt); + + // Write HAR to temp file. + try { + await fsp.mkdir(TEMP_DIR, { recursive: true }); + const sanitizedRunId = sanitizeForFilename(runId); + const sanitizedTestId = sanitizeForFilename(testId); + const filePath = path.join(TEMP_DIR, `${sanitizedRunId}_${sanitizedTestId}.har`); + + const har = buildHar(testEntries, testTlsErrors); + await fsp.writeFile(filePath, JSON.stringify(har, null, 2), 'utf8'); + + this._testStartIndexMap.delete(mapKey); + this._testTlsStartIndexMap.delete(mapKey); + this._stoppedTests.add(mapKey); + + return new DeviceNodeResponse({ + success: true, + message: `Network capture stopped for test: ${testId} (${testEntries.length} requests)`, + data: { + filePath, + startedAt: startedAt.toISOString(), + completedAt: completedAt.toISOString(), + requestCount: testEntries.length, + tlsErrorCount: testTlsErrors.length, + }, + }); + } catch (error) { + Logger.e(`Failed to write network HAR for test: ${testId}`, error); + return new DeviceNodeResponse({ + success: false, + message: `Failed to write network capture: ${formatError(error)}`, + }); + } + } + + async abortTestCapture(runId: string): Promise { + const keysToAbort = [...this._testStartIndexMap.keys()].filter((k) => + k.startsWith(`${runId}${MAP_KEY_DELIMITER}`), + ); + for (const key of keysToAbort) { + this._testStartIndexMap.delete(key); + this._testTlsStartIndexMap.delete(key); + this._stoppedTests.add(key); + } + } + + private _mapKey(runId: string, testId: string): string { + return `${runId}${MAP_KEY_DELIMITER}${testId}`; + } +} + +// ── HAR builder ───────────────────────────────────────────────────────────── + +function buildHar(entries: NetworkCapturedEntry[], tlsErrors: NetworkTlsError[]): object { + return { + log: { + version: '1.2', + creator: { name: 'FinalRun', version: '0.1.0' }, + entries: entries.map((e) => ({ + startedDateTime: e.startedAt.toISOString(), + time: e.durationMs, + request: { + method: e.method, + url: e.url, + httpVersion: 'HTTP/1.1', + headers: headersToHar(e.requestHeaders), + queryString: parseQueryString(e.url), + bodySize: e.requestBodySize, + headersSize: -1, + }, + response: { + status: e.statusCode, + statusText: e.statusMessage, + httpVersion: 'HTTP/1.1', + headers: headersToHar(e.responseHeaders), + content: { + size: e.responseBodySize, + mimeType: e.responseHeaders['content-type'] ?? 'application/octet-stream', + }, + bodySize: e.responseBodySize, + headersSize: -1, + redirectURL: '', + }, + cache: {}, + timings: { + send: 0, + wait: e.durationMs, + receive: 0, + }, + })), + ...(tlsErrors.length > 0 + ? { + _tlsErrors: tlsErrors.map((e) => ({ + hostname: e.hostname, + cause: e.failureCause, + timestamp: e.timestamp.toISOString(), + })), + } + : {}), + }, + }; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function flattenHeaders(headers: Record): Record { + const flat: Record = {}; + for (const [k, v] of Object.entries(headers)) { + if (v === undefined) continue; + flat[k.toLowerCase()] = Array.isArray(v) ? v.join(', ') : v; + } + return flat; +} + +function headersToHar(headers: Record): Array<{ name: string; value: string }> { + return Object.entries(headers).map(([name, value]) => ({ name, value })); +} + +function parseQueryString(url: string): Array<{ name: string; value: string }> { + try { + const u = new URL(url); + return [...u.searchParams].map(([name, value]) => ({ name, value })); + } catch { + return []; + } +} + +function sanitizeForFilename(value: string): string { + return value + .replaceAll(/[/\\:*?"<>|]/g, '_') + .replaceAll(/\s+/g, '_') + .replaceAll(/_+/g, '_'); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/device-node/src/device/NetworkCaptureSetup.ts b/packages/device-node/src/device/NetworkCaptureSetup.ts new file mode 100644 index 0000000..dd1e519 --- /dev/null +++ b/packages/device-node/src/device/NetworkCaptureSetup.ts @@ -0,0 +1,270 @@ +// Platform-specific proxy configuration for network capture. +// Configures the device/simulator to route traffic through our proxy, +// verifies the CA cert is trusted, and restores settings on cleanup. + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as path from 'node:path'; +import { Logger } from '@finalrun/common'; + +const execFileAsync = promisify(execFile); + +// ── Interface ──────────────────────────────────────────────────────────────── + +export interface NetworkProxySetup { + /** Configure the device/host to route traffic through the proxy. */ + configureProxy(proxyPort: number): Promise; + /** Restore original proxy settings. */ + restoreProxy(): Promise; +} + +// ── Android ────────────────────────────────────────────────────────────────── + +const ANDROID_REVERSE_PORT = 8899; + +export class AndroidNetworkProxySetup implements NetworkProxySetup { + private _previousProxy: string | null = null; + private _reverseActive = false; + + constructor( + private readonly _adbPath: string, + private readonly _deviceSerial: string, + ) {} + + get isEmulator(): boolean { + return this._deviceSerial.startsWith('emulator-'); + } + + async configureProxy(proxyPort: number): Promise { + // Save previous proxy. + this._previousProxy = await this._getGlobalProxy(); + + // CA cert is NOT pushed here — that's the job of `finalrun log-network` + // (the setup tool). Pushing during a test run can cause + // CertPathValidatorException if the app is already running. + + // Set proxy target. + let proxyTarget: string; + if (this.isEmulator) { + proxyTarget = `10.0.2.2:${proxyPort}`; + } else { + // Physical device: reverse tunnel. + await this._adb('reverse', `tcp:${ANDROID_REVERSE_PORT}`, `tcp:${proxyPort}`); + this._reverseActive = true; + proxyTarget = `localhost:${ANDROID_REVERSE_PORT}`; + } + + await this._setGlobalProxy(proxyTarget); + Logger.d(`Android proxy set to ${proxyTarget}`); + } + + async restoreProxy(): Promise { + try { + if (this._previousProxy) { + await this._setGlobalProxy(this._previousProxy); + } else { + await this._clearGlobalProxy(); + } + } catch (error) { + Logger.w('Failed to restore Android proxy', error); + } + + if (this._reverseActive) { + try { + await this._adb('reverse', '--remove', `tcp:${ANDROID_REVERSE_PORT}`); + } catch { + // best effort + } + this._reverseActive = false; + } + } + + private async _getGlobalProxy(): Promise { + const out = await this._shell('settings get global http_proxy'); + const trimmed = out.trim(); + return (!trimmed || trimmed === 'null') ? null : trimmed; + } + + private async _setGlobalProxy(value: string): Promise { + await this._shell(`settings put global http_proxy ${value}`); + } + + private async _clearGlobalProxy(): Promise { + await this._shell('settings put global http_proxy :0'); + } + + private async _shell(command: string): Promise { + const { stdout } = await execFileAsync(this._adbPath, ['-s', this._deviceSerial, 'shell', command]); + return String(stdout).trimEnd(); + } + + private async _adb(...args: string[]): Promise { + const { stdout } = await execFileAsync(this._adbPath, ['-s', this._deviceSerial, ...args]); + return String(stdout).trimEnd(); + } +} + +// ── iOS ────────────────────────────────────────────────────────────────────── + +export class IOSNetworkProxySetup implements NetworkProxySetup { + private _networkService: string | null = null; + private _previousAutoproxy: { enabled: boolean; url: string } | null = null; + private _pacServer: { url: string; stop: () => Promise } | null = null; + + constructor(private readonly _simulatorUdid: string) {} + + async configureProxy(proxyPort: number): Promise { + // Install CA into simulator keychain. + const caCertPath = path.join(process.env['HOME'] ?? '/tmp', '.finalrun', 'ca', 'root.pem'); + if (fs.existsSync(caCertPath)) { + await this._xcrun('simctl', 'keychain', this._simulatorUdid, 'add-root-cert', caCertPath); + } + + // Find active network service. + this._networkService = await findActiveNetworkService(); + if (!this._networkService) { + throw new Error('No active macOS network service found'); + } + + // Save previous autoproxy state. + this._previousAutoproxy = await getAutoproxyUrl(this._networkService); + + // Start PAC server with DIRECT fallback (crash-safe). + this._pacServer = await startPacServer(proxyPort); + + // Set autoproxy URL. + await setAutoproxyUrl(this._networkService, this._pacServer.url); + Logger.d(`iOS proxy set via PAC on ${this._networkService}: ${this._pacServer.url}`); + } + + async restoreProxy(): Promise { + if (this._networkService && this._previousAutoproxy) { + try { + await restoreAutoproxy(this._networkService, this._previousAutoproxy); + } catch (error) { + Logger.w('Failed to restore macOS autoproxy', error); + } + } + + if (this._pacServer) { + try { + await this._pacServer.stop(); + } catch { + // best effort + } + this._pacServer = null; + } + } + + private async _xcrun(...args: string[]): Promise { + const { stdout } = await execFileAsync('xcrun', args, { maxBuffer: 8 * 1024 * 1024 }); + return String(stdout).trim(); + } +} + +// ── Shared: PAC server + networksetup helpers ──────────────────────────────── + +async function startPacServer(proxyPort: number): Promise<{ url: string; stop: () => Promise }> { + const pacContent = [ + 'function FindProxyForURL(url, host) {', + ` return "PROXY 127.0.0.1:${proxyPort}; DIRECT";`, + '}', + ].join('\n'); + + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + if (req.url === '/proxy.pac' || req.url === '/') { + res.writeHead(200, { 'Content-Type': 'application/x-ns-proxy-autoconfig' }); + res.end(pacContent); + } else { + res.writeHead(404); + res.end(); + } + }); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + resolve({ + url: `http://127.0.0.1:${port}/proxy.pac`, + stop: () => new Promise((r) => server.close(() => r())), + }); + }); + }); +} + +async function run(cmd: string, args: string[]): Promise { + const { stdout } = await execFileAsync(cmd, args, { maxBuffer: 8 * 1024 * 1024 }); + return String(stdout).trim(); +} + +async function getAutoproxyUrl(service: string): Promise<{ enabled: boolean; url: string }> { + const output = await run('networksetup', ['-getautoproxyurl', service]); + return { + enabled: /Enabled:\s*Yes/i.test(output), + url: output.match(/URL:\s*(\S+)/)?.[1] ?? '', + }; +} + +async function setAutoproxyUrl(service: string, pacUrl: string): Promise { + await run('networksetup', ['-setautoproxyurl', service, pacUrl]); + await run('networksetup', ['-setautoproxystate', service, 'on']); +} + +async function restoreAutoproxy(service: string, previous: { enabled: boolean; url: string }): Promise { + if (previous.enabled && previous.url) { + await run('networksetup', ['-setautoproxyurl', service, previous.url]); + await run('networksetup', ['-setautoproxystate', service, 'on']); + } else { + await run('networksetup', ['-setautoproxystate', service, 'off']); + } +} + +async function findActiveNetworkService(): Promise { + const output = await run('networksetup', ['-listallnetworkservices']); + const services = output + .split('\n') + .filter((l) => !l.startsWith('*') && !l.startsWith('An asterisk')) + .map((s) => s.trim()) + .filter(Boolean); + + for (const svc of services) { + try { + const info = await run('networksetup', ['-getinfo', svc]); + if (info.includes('IP address') && !info.includes('IP address: none')) { + return svc; + } + } catch { + continue; + } + } + return services[0] ?? null; +} + +// ── Shared: traffic-based CA verification ──────────────────────────────────── + +/** + * Wait briefly for background traffic, then check if the proxy saw any + * successful requests vs TLS errors. This tests the DEVICE's trust of the + * CA, not the host's. + * + * Returns 'verified' if successful entries exist, 'untrusted' if only TLS + * errors, 'unknown' if no traffic observed (app might not have made requests). + */ +export async function checkProxyTraffic( + getEntryCount: () => number, + getTlsErrorCount: () => number, + waitMs: number = 3000, +): Promise<'verified' | 'untrusted' | 'unknown'> { + return new Promise((resolve) => { + setTimeout(() => { + const entries = getEntryCount(); + const tlsErrors = getTlsErrorCount(); + if (entries > 0) resolve('verified'); + else if (tlsErrors > 0) resolve('untrusted'); + else resolve('unknown'); + }, waitMs); + }); +} diff --git a/packages/device-node/src/index.ts b/packages/device-node/src/index.ts index 67e517b..d81ccd0 100644 --- a/packages/device-node/src/index.ts +++ b/packages/device-node/src/index.ts @@ -24,6 +24,20 @@ export type { export type { LogCaptureProvider } from './device/LogCaptureProvider.js'; export { AndroidLogcatProvider } from './device/AndroidLogcatProvider.js'; export { IOSLogProvider } from './device/IOSLogProvider.js'; +export { NetworkCaptureManager } from './device/NetworkCaptureManager.js'; +export type { + DeviceNetworkCaptureController, + NetworkCaptureSessionParams, + NetworkCaptureTestParams, + NetworkCapturedEntry, + NetworkTlsError, +} from './device/NetworkCaptureManager.js'; +export { + AndroidNetworkProxySetup, + IOSNetworkProxySetup, + checkProxyTraffic, +} from './device/NetworkCaptureSetup.js'; +export type { NetworkProxySetup } from './device/NetworkCaptureSetup.js'; export type { DeviceRecordingController, RecordingSessionStartParams, diff --git a/packages/goal-executor/src/TestExecutor.ts b/packages/goal-executor/src/TestExecutor.ts index 4e9a4f0..a5cdfc2 100644 --- a/packages/goal-executor/src/TestExecutor.ts +++ b/packages/goal-executor/src/TestExecutor.ts @@ -98,6 +98,7 @@ export interface TestExecutionResult { completedAt: string; recording?: TestRecordingResult; deviceLog?: import('@finalrun/common').DeviceLogCaptureResult; + networkLog?: import('@finalrun/common').NetworkLogCaptureResult; steps: AgentActionResult[]; totalIterations: number; }