From 9c61c441e6738af1cba80aeafd23634798b5ab49 Mon Sep 17 00:00:00 2001 From: Petrelid Date: Fri, 25 Sep 2026 15:55:10 +0100 Subject: [PATCH 1/3] feat(security): implement build pipeline egress monitor - Added `monitor-build-egress.sh` script to detect and block unauthorized network egress during build processes. - Updated `package.json` to include new security commands for egress monitoring. - Modified `render.yaml` to run builds under the egress monitor with appropriate flags. - Enhanced `server/package.json` to integrate egress monitoring in the build process. - Cleaned up unused imports in `helpStore.ts`. - Updated global CSS file with a placeholder comment. - Added tests for the egress detection functionality, covering various scenarios including unauthorized connections and allowlist extensions. --- .github/workflows/ci.yml | 44 ++ README.md | 8 + docs/security-runbook.md | 55 +++ package.json | 3 + render.yaml | 11 +- scripts/monitor-build-egress.sh | 694 ++++++++++++++++++++++++++++++++ server/package.json | 3 +- src/stores/helpStore.ts | 4 - src/styles/global.css | 2 +- test/egress-detector.test.js | 203 ++++++++++ 10 files changed, 1020 insertions(+), 7 deletions(-) create mode 100755 scripts/monitor-build-egress.sh create mode 100644 test/egress-detector.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ae89d5b..91c458bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,3 +159,47 @@ jobs: - name: Audit dependencies run: node scripts/audit-deps.js --check + + # Build-pipeline network egress monitor: every outbound connection made + # while dependencies are fetched and while `npm run build` runs is captured + # (tcpdump) and classified against the allowlist in + # scripts/monitor-build-egress.sh. Unauthorized destinations fail the job + # and the audit logs are uploaded for security review. + build-egress-monitor: + name: Build Egress Monitor & Data Exfiltration Gate + runs-on: ubuntu-latest + steps: + - name: Checkout Codebase + uses: actions/checkout@v4 + + - name: Setup Node.js Environment + uses: actions/setup-node@v4 + with: + node-version: '22.x' + cache: 'npm' + + - name: Install packet capture tooling + run: sudo apt-get update -qq && sudo apt-get install -y -qq tcpdump + + - name: Install dependencies inside the egress monitor (strict gate) + run: > + bash scripts/monitor-build-egress.sh --strict + --log-dir artifacts/build-egress/install + -- npm ci --ignore-scripts + + - name: Run production build inside the egress monitor (egress gate) + # This job owns the network verdict only: the app build's own exit + # code is recorded in the summary artifact but does not fail the job + # (compilation errors belong to the build, see docs/security-runbook.md). + run: > + bash scripts/monitor-build-egress.sh --strict --ignore-command-exit + --log-dir artifacts/build-egress/build + -- npm run build + + - name: Upload build egress audit logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: build-egress-audit + path: artifacts/build-egress/ + if-no-files-found: warn diff --git a/README.md b/README.md index fe66eb57..2e99fc00 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,14 @@ HelPhone is a React + Vite community emergency response application built on Ste - **Runbook**: [docs/security-runbook.md](docs/security-runbook.md). - **Tests**: `test/dep-audit.test.js`. +### 7. Build Pipeline Egress Monitoring & Data Exfiltration Prevention +- **Monitor**: `scripts/monitor-build-egress.sh` wraps a build command (`npm run build` by default) with a packet capture (`tcpdump`, or `iptables` LOG/REJECT when running as root) and classifies every observed destination — tcpdump `src > dst` lines, iptables `DST=` log lines, and DNS query names. +- **Unauthorized Connection Gate**: loopback/RFC1918/CGNAT plus a curated registry allowlist (npm, GitHub, PyPI, crates.io, Node.js) are permitted; anything else — including cloud metadata endpoints (`169.254.169.254`) — fails the build with exit code 1. `--enforce` additionally REJECTs the connection through an `iptables` `OUTPUT` chain while the build runs. +- **Egress Audit Logs**: `egress-capture.log` (raw packets), `egress-audit.log` (per-destination verdicts) and `egress-summary.log` are written to `artifacts/build-egress/` and uploaded as CI artifacts for security review. +- **CI Gate**: the `build-egress-monitor` job in `.github/workflows/ci.yml` runs installation and the production build inside the monitor in `--strict` mode (fails when capture is unavailable or unauthorized egress is seen). +- **Runbook**: [docs/security-runbook.md](docs/security-runbook.md). +- **Tests**: `test/egress-detector.test.js`. + --- ## Quick Start diff --git a/docs/security-runbook.md b/docs/security-runbook.md index a22af29f..e7e5b1cf 100644 --- a/docs/security-runbook.md +++ b/docs/security-runbook.md @@ -86,3 +86,58 @@ revoked immediately. from a clean checkout and diff. - **STALE** — `licenses.json` no longer matches the lockfiles; run `npm run security:audit-deps` and commit. + +## Build pipeline egress monitor + +Detects and blocks malicious network egress (data exfiltration) while the +build pipeline runs: dependency installation and `npm run build`. + +- Script: `scripts/monitor-build-egress.sh [options] [--] ` — starts + a capture, runs the command, then classifies every captured destination. + `--classify [file]` re-audits an existing capture log (this is what the + tests exercise). +- Backends (`--backend auto|tcpdump|iptables|none`): + - **tcpdump** — `tcpdump -i any -nn -l -Q out 'ip or ip6'`; uses `sudo -n` + when not root. This is the CI backend. + - **iptables** — root only. Installs an `EGRESS-MONITOR` chain on `OUTPUT` + that RETURNs loopback/established/allowlisted destinations, LOGs the rest + with the `EGRESS-DENY: ` prefix, and with `--enforce` REJECTs them so the + build is blocked at the socket level. The chain is removed on exit. + - **none** — no capture. `--strict` fails the run; otherwise the build + continues with a warning (Render uses this: no `CAP_NET_RAW`). +- Classification rules, in order: + 1. deny list wins — `169.254.169.254` and friends plus + `metadata.google.internal` / `instance-data` are always unauthorized; + 2. an allowlisted hostname in the same line legitimises the flow (DNS query + or SNI), so CDN IPs are accepted without pinning them; + 3. destination IP inside an allowlisted CIDR → allowed; + 4. anything else → `UNAUTHORIZED` (reason `ip-not-allowlisted` / + `domain-not-allowlisted`) and exit 1. +- Built-in allowlist: loopback, `10/8`, `172.16/12`, `192.168/16`, CGNAT + `100.64/10`, ULA/link-local IPv6, `/etc/resolv.conf` nameservers, and + registry/documentation hosts (npm, GitHub, PyPI, crates.io, nodejs.org). + Allowlisted domains are resolved up-front (parallel, 3 s bound per lookup) + so their current IPs are covered too. +- Extending the policy: `EGRESS_ALLOWLIST` (comma separated), an + `EGRESS_ALLOWLIST_FILE` / `--allowlist-file` (one entry per line, `#` + comments), or the `EGRESS_ALLOWLIST` env var Render exposes. Never widen a + broad CIDR (e.g. all of `0.0.0.0/0`) to silence a finding — investigate + first. +- Artifacts (uploaded by CI as `build-egress-audit`): + `artifacts/build-egress//egress-capture.log` (raw), + `egress-audit.log` (one verdict per line) and `egress-summary.log` + (counts, allowlist size, build exit code, verdict). +- Gates: + - `npm run security:egress` — run `npm run build` under the monitor; + - `npm run security:egress:strict` — also fail when capture is unavailable; + - `npm run security:egress:audit` — re-classify a saved capture; + - CI job `build-egress-monitor` — strict install gate, then the production + build with `--ignore-command-exit` so this job owns the *network* verdict + (the app build's own exit code is recorded in the summary only); + - `render.yaml` `buildCommand` — the deploy build runs with + `--allow-no-capture` because Render build containers cannot capture. +- Triage of a finding: open `egress-audit.log`, take the `dst=`/`domain=` + fields. If it is a legitimate build vendor, add it to the allowlist with a + reason in the same PR. If it is not explainable, treat the dependency as + compromised: do not publish the artifact, pin the previous version, rotate + any secrets present in the build environment, and report upstream. diff --git a/package.json b/package.json index f8beaf58..b2717131 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ "security:install": "bash scripts/sandbox-install.sh", "security:scan-lifecycle": "bash scripts/sandbox-install.sh --scan", "security:ai-code": "node scripts/audit-ai-code.js", + "security:egress": "bash scripts/monitor-build-egress.sh -- npm run build", + "security:egress:strict": "bash scripts/monitor-build-egress.sh --strict -- npm run build", + "security:egress:audit": "bash scripts/monitor-build-egress.sh --classify artifacts/build-egress/egress-capture.log", "test:watch": "vitest", "test:e2e:throttling": "playwright test --project=throttling", "lint": "eslint .", diff --git a/render.yaml b/render.yaml index 9746f1e0..31d67934 100644 --- a/render.yaml +++ b/render.yaml @@ -4,13 +4,22 @@ services: name: helphone-backend-server runtime: node plan: starter - buildCommand: npm ci --ignore-scripts + # The build runs inside the egress monitor (docs/security-runbook.md): + # it audits outbound connections during the install/build phase and fails + # on unauthorized destinations. Render build containers have no + # CAP_NET_RAW, so --allow-no-capture keeps the deploy working; CI runs the + # strict, packet-capturing gate (.github/workflows/ci.yml). + buildCommand: bash scripts/monitor-build-egress.sh --allow-no-capture --log-dir /tmp/build-egress -- npm ci --ignore-scripts startCommand: npm run server healthCheckPath: /health autoDeploy: true envVars: - key: NODE_VERSION value: "22" + # Extra build-egress allowlist entries (comma separated hostnames or + # CIDRs) for build-time vendors not in the built-in policy. + - key: EGRESS_ALLOWLIST + value: "" # Postgres connection. Migrations apply automatically at server startup # (see server/db/migrator.ts), so the schema always stays in sync. - key: DATABASE_URL diff --git a/scripts/monitor-build-egress.sh b/scripts/monitor-build-egress.sh new file mode 100755 index 00000000..618f0aff --- /dev/null +++ b/scripts/monitor-build-egress.sh @@ -0,0 +1,694 @@ +#!/usr/bin/env bash +# +# monitor-build-egress.sh — automated malicious network egress detector for +# build pipelines (CI/CD, network security, sandbox, build tooling). +# +# Wraps a build command (default: `npm run build`) with a network capture, +# classifies every observed destination against a security allowlist, and +# fails the build when a third-party dependency attempts an unauthorized +# HTTP/TCP connection to an external IP during the build phase. +# +# Usage: +# scripts/monitor-build-egress.sh [options] [--] [args...] +# scripts/monitor-build-egress.sh --classify [capture.log] +# +# Options: +# --strict Fail when no packet-capture backend is available. +# --allow-no-capture Warn and continue without capture (default). +# --ignore-command-exit Report the wrapped command's exit code in the +# summary but only fail on egress findings (used by +# the CI egress job, which owns the network verdict). +# --report-only Never fail on unauthorized egress (audit only). +# --backend auto | tcpdump | iptables | none (default: auto) +# --enforce Also REJECT unauthorized egress (iptables backend). +# --no-enforce Log only, even when the iptables backend is used. +# --allowlist-file Extra allowlist entries (one per line, # comments). +# --log-dir Audit log directory (default: artifacts/build-egress). +# --classify Classify an existing capture log instead of building. +# -h, --help Show this help. +# +# Environment overrides: EGRESS_LOG_DIR, EGRESS_BACKEND, EGRESS_ENFORCE, +# EGRESS_STRICT, EGRESS_REPORT_ONLY, EGRESS_ALLOWLIST (comma/space separated +# extra entries), EGRESS_ALLOWLIST_FILE. +# +# Exit codes: +# 0 build succeeded and no unauthorized egress was observed +# 1 unauthorized egress observed, or capture was required but unavailable +# 2 usage error +# * the wrapped build command's own exit code (unless --ignore-command-exit) + +set -euo pipefail + +PROGRAM_NAME="${0##*/}" + +# --------------------------------------------------------------------------- +# Configuration (env overridable, flags override env) +# --------------------------------------------------------------------------- +EGRESS_LOG_DIR="${EGRESS_LOG_DIR:-artifacts/build-egress}" +EGRESS_BACKEND="${EGRESS_BACKEND:-auto}" +EGRESS_ENFORCE="${EGRESS_ENFORCE:-auto}" +EGRESS_ALLOWLIST="${EGRESS_ALLOWLIST:-}" +EGRESS_ALLOWLIST_FILE="${EGRESS_ALLOWLIST_FILE:-}" + +BACKEND="none" +ENFORCE=false +STRICT=false +REPORT_ONLY=false +IGNORE_COMMAND_EXIT=false +MODE="monitor" +CMD=() + +# Runtime allowlist buckets (filled by prepare_allowlist) +ALLOWLIST_CIDRS=() +ALLOWLIST_DOMAINS=() +ALLOWLIST_IP6=() + +# Cloud instance metadata endpoints are never legitimate build destinations. +DENY_CIDRS=( "169.254.169.254/32" "100.100.100.200/32" "192.0.0.192/32" "fd00:ec2::254/128" ) +DENY_DOMAINS=( "metadata.google.internal" "instance-data" "metadata.google.com" ) + +# Capture state +CAPTURE_LOG="" +CAPTURE_PID="" +IPTABLES_INSTALLED=false +LAST_TOTAL=0 +LAST_ALLOWED=0 +LAST_UNAUTHORIZED=0 +LAST_UNKNOWN=0 + +# --------------------------------------------------------------------------- +# Help +# --------------------------------------------------------------------------- +usage() { + awk 'NR == 1 { next } !/^#/ { exit } { sub(/^# ?/, ""); print }' "$0" +} + +# --------------------------------------------------------------------------- +# Small utilities +# --------------------------------------------------------------------------- +timestamp() { date -u +%Y-%m-%dT%H:%M:%SZ; } + +is_ipv4() { [[ "${1:-}" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; } +is_ipv6() { [[ "${1:-}" == *:* && "${1:-}" =~ ^[0-9a-fA-F:]+$ ]]; } + +# ip2int -> unsigned 32-bit integer +ip2int() { + local a b c d + local IFS=. + read -r a b c d <<<"$1" + printf '%s' "$(( (10#$a << 24) | (10#$b << 16) | (10#$c << 8) | 10#$d ))" +} + +# cidr_contains -> 0 when the address falls inside +cidr_contains() { + local cidr="$1" ip="$2" net prefix ipi neti mask + if [[ "$cidr" == */* ]]; then + net="${cidr%/*}" + prefix="${cidr#*/}" + else + net="$cidr" + prefix=32 + fi + is_ipv4 "$ip" && is_ipv4 "$net" || return 1 + ipi="$(ip2int "$ip")" + neti="$(ip2int "$net")" + if (( prefix == 0 )); then + mask=0 + else + mask=$(( (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF )) + fi + (( (ipi & mask) == (neti & mask) )) +} + +# --------------------------------------------------------------------------- +# Allowlist construction +# --------------------------------------------------------------------------- +# Built-in policy: loopback + RFC1918/CGNAT for local tooling, and the +# package/documentation registries the build legitimately needs. +default_allowlist_entries() { + cat <<'EOF' +127.0.0.0/8 +0.0.0.0/32 +10.0.0.0/8 +172.16.0.0/12 +192.168.0.0/16 +100.64.0.0/10 +::1/128 +fe80::/10 +fc00::/7 +registry.npmjs.org +registry.yarnpkg.com +npmjs.com +github.com +githubusercontent.com +githubassets.com +raw.githubusercontent.com +pypi.org +pythonhosted.org +crates.io +static.crates.io +nodejs.org +EOF +} + +add_allowlist_entry() { + local entry="$1" + entry="${entry%%#*}" + entry="$(printf '%s' "$entry" | tr -d '[:space:]')" + [[ -z "$entry" ]] && return 0 + if [[ "$entry" == *:* ]]; then + ALLOWLIST_IP6+=("$entry") + elif [[ "$entry" == */* ]] || is_ipv4 "$entry"; then + ALLOWLIST_CIDRS+=("$entry") + else + ALLOWLIST_DOMAINS+=("${entry,,}") + fi +} + +# Resolver addresses must stay reachable or no domain can be resolved. +load_resolver_addresses() { + local ns + while read -r ns; do + [[ -z "$ns" ]] && continue + is_ipv4 "$ns" && add_allowlist_entry "$ns/32" + done < <(awk '/^nameserver/ { print $2 }' /etc/resolv.conf 2>/dev/null || true) + return 0 +} + +prepare_allowlist() { + ALLOWLIST_CIDRS=() + ALLOWLIST_DOMAINS=() + ALLOWLIST_IP6=() + local entry + while IFS= read -r entry; do + add_allowlist_entry "$entry" + done < <(default_allowlist_entries) + if [[ -n "$EGRESS_ALLOWLIST" ]]; then + while IFS= read -r entry; do + add_allowlist_entry "$entry" + done < <(printf '%s' "$EGRESS_ALLOWLIST" | tr ',' '\n') + fi + if [[ -n "$EGRESS_ALLOWLIST_FILE" ]]; then + [[ -r "$EGRESS_ALLOWLIST_FILE" ]] || { + echo "EGRESS: allowlist file not readable: $EGRESS_ALLOWLIST_FILE" >&2 + return 2 + } + while IFS= read -r entry; do + add_allowlist_entry "$entry" + done < "$EGRESS_ALLOWLIST_FILE" + fi + load_resolver_addresses +} + +# Resolve allowlisted domains up-front so CDN IPs are covered even when the +# captured packet only carries an address. Lookups run in parallel and are +# bounded so a broken resolver can never stall the build. +resolve_allowlist_domains() { + local domain ip tmp pid + tmp="$(mktemp "${TMPDIR:-/tmp}/egress-resolve.XXXXXX")" + local -a pids=() + for domain in ${ALLOWLIST_DOMAINS[@]+"${ALLOWLIST_DOMAINS[@]}"}; do + ( + timeout 3 getent ahostsv4 "$domain" 2>/dev/null \ + | awk '{ print $1 }' >> "$tmp" + ) & + pids+=($!) + done + for pid in ${pids[@]+"${pids[@]}"}; do + wait "$pid" 2>/dev/null || true + done + if [[ -s "$tmp" ]]; then + while IFS= read -r ip; do + if is_ipv4 "$ip"; then + add_allowlist_entry "$ip/32" + fi + done < <(sort -u "$tmp") + fi + rm -f "$tmp" + return 0 +} + +allowlist_counts() { + printf 'cidr=%s domain=%s ip6=%s' \ + "${#ALLOWLIST_CIDRS[@]}" "${#ALLOWLIST_DOMAINS[@]}" "${#ALLOWLIST_IP6[@]}" +} + +# --------------------------------------------------------------------------- +# Classification primitives +# --------------------------------------------------------------------------- +domain_allowed() { + local host="${1,,}" + host="${host%.}" + [[ -z "$host" ]] && return 1 + local entry + for entry in ${ALLOWLIST_DOMAINS[@]+"${ALLOWLIST_DOMAINS[@]}"}; do + if [[ "$host" == "$entry" || "$host" == *."$entry" ]]; then + return 0 + fi + done + return 1 +} + +domain_denied() { + local host="${1,,}" + host="${host%.}" + [[ -z "$host" ]] && return 1 + local entry + for entry in "${DENY_DOMAINS[@]}"; do + if [[ "$host" == "$entry" || "$host" == *."$entry" ]]; then + return 0 + fi + done + return 1 +} + +ip_denied() { + local ip="$1" entry + for entry in "${DENY_CIDRS[@]}"; do + if is_ipv4 "$ip"; then + cidr_contains "$entry" "$ip" && return 0 + elif is_ipv6 "$ip"; then + local net="${entry%/*}" + [[ "${ip,,}" == "${net,,}"* ]] && return 0 + fi + done + return 1 +} + +ip_allowed() { + local ip="$1" entry + if is_ipv6 "$ip"; then + for entry in ${ALLOWLIST_IP6[@]+"${ALLOWLIST_IP6[@]}"}; do + local net="${entry%/*}" + [[ "${ip,,}" == "${net,,}"* ]] && return 0 + done + return 1 + fi + for entry in ${ALLOWLIST_CIDRS[@]+"${ALLOWLIST_CIDRS[@]}"}; do + cidr_contains "$entry" "$ip" && return 0 + done + return 1 +} + +# extract_dst_ip -> destination address ("" when absent) +extract_dst_ip() { + local line="$1" token + if [[ "$line" =~ DST=([0-9a-fA-F:.]+) ]]; then + printf '%s' "${BASH_REMATCH[1]}" + return 0 + fi + if [[ "$line" == *'>'* ]]; then + token="${line#*> }" + token="${token%% *}" + token="${token%:}" + # tcpdump prints "addr.port"; the last dot separates the port. + if [[ "$token" =~ ^(.+)\.[0-9]+$ ]]; then + token="${BASH_REMATCH[1]}" + fi + if is_ipv4 "$token" || is_ipv6 "$token"; then + printf '%s' "$token" + return 0 + fi + fi + printf '' +} + +# extract_domains -> newline separated hostnames (DNS query +# names, SNI hints, log URLs). Purely numeric tokens (IPv4, versions) are dropped. +extract_domains() { + local line="$1" + printf '%s' "$line" \ + | grep -Eo '[A-Za-z0-9][A-Za-z0-9-]*([.][A-Za-z0-9-]+)+' 2>/dev/null \ + | tr '[:upper:]' '[:lower:]' \ + | while IFS= read -r candidate; do + candidate="${candidate%.}" + [[ "$candidate" =~ ^[0-9.]+$ ]] && continue + [[ "$candidate" =~ ^[0-9]+$ ]] && continue + printf '%s\n' "$candidate" + done || true +} + +# classify_line -> "VERDICT|reason|dst|domain" +classify_line() { + local line="$1" + local dst domain + local domains=() + local candidate + + dst="$(extract_dst_ip "$line")" + while IFS= read -r candidate; do + [[ -n "$candidate" ]] && domains+=("$candidate") + done < <(extract_domains "$line") + + # 1. Explicit deny list wins over everything (metadata exfiltration). + if [[ -n "$dst" ]] && ip_denied "$dst"; then + printf 'UNAUTHORIZED|metadata-endpoint|%s|-\n' "$dst" + return 0 + fi + for candidate in ${domains[@]+"${domains[@]}"}; do + if domain_denied "$candidate"; then + printf 'UNAUTHORIZED|metadata-endpoint|%s|%s\n' "${dst:--}" "$candidate" + return 0 + fi + done + + # 2. An allowlisted hostname in the same line legitimises the flow. + local allowed_domain=false unauthorized_domain=false unauthorized_name="" allowed_name="" + for candidate in ${domains[@]+"${domains[@]}"}; do + if domain_allowed "$candidate"; then + allowed_domain=true + allowed_name="$candidate" + else + unauthorized_domain=true + unauthorized_name="$candidate" + fi + done + + if [[ -n "$dst" ]]; then + if ip_allowed "$dst"; then + printf 'ALLOWED|ip-allowlist|%s|-\n' "$dst" + return 0 + fi + if [[ "$allowed_domain" == true ]]; then + printf 'ALLOWED|domain-allowlist|%s|%s\n' "$dst" "$allowed_name" + return 0 + fi + if [[ "$unauthorized_domain" == true ]]; then + printf 'UNAUTHORIZED|domain-not-allowlisted|%s|%s\n' "$dst" "$unauthorized_name" + return 0 + fi + printf 'UNAUTHORIZED|ip-not-allowlisted|%s|-\n' "$dst" + return 0 + fi + + if [[ "$unauthorized_domain" == true ]]; then + printf 'UNAUTHORIZED|domain-not-allowlisted|-|%s\n' "$unauthorized_name" + return 0 + fi + if [[ "$allowed_domain" == true ]]; then + printf 'ALLOWED|domain-allowlist|-|%s\n' "$allowed_name" + return 0 + fi + printf 'UNKNOWN|no-destination|-|-\n' +} + +# --------------------------------------------------------------------------- +# Audit log + summary writers +# --------------------------------------------------------------------------- +write_summary() { + local verdict="$1" backend_used="$2" capture_state="$3" command_label="$4" rc="$5" + local summary="$EGRESS_LOG_DIR/egress-summary.log" + { + echo "=== Build Egress Audit Summary ===" + echo "timestamp_utc: $(timestamp)" + echo "command: $command_label" + echo "backend: $backend_used" + echo "enforce: $ENFORCE" + echo "capture: $capture_state" + echo "allowlist: $(allowlist_counts)" + echo "lines_total: $LAST_TOTAL" + echo "allowed: $LAST_ALLOWED" + echo "unauthorized: $LAST_UNAUTHORIZED" + echo "unknown: $LAST_UNKNOWN" + echo "build_exit_code: $rc" + echo "verdict: $verdict" + echo "artifacts: $EGRESS_LOG_DIR/egress-capture.log $EGRESS_LOG_DIR/egress-audit.log" + if (( LAST_UNAUTHORIZED > 0 )) && [[ -r "$EGRESS_LOG_DIR/egress-audit.log" ]]; then + echo "unauthorized_findings:" + grep 'verdict=UNAUTHORIZED' "$EGRESS_LOG_DIR/egress-audit.log" | sed 's/^/ - /' || true + fi + } > "$summary" + cat "$summary" +} + +# classify_log : stream a capture log through the classifier. +# Populates LAST_* counters and appends per-line verdicts to egress-audit.log. +classify_log() { + local src="${1:--}" + local input="/dev/stdin" + if [[ "$src" != "-" && -n "$src" ]]; then + if [[ ! -r "$src" ]]; then + echo "EGRESS: capture log not readable: $src" >&2 + return 2 + fi + input="$src" + fi + + mkdir -p "$EGRESS_LOG_DIR" + local audit="$EGRESS_LOG_DIR/egress-audit.log" + : > "$audit" + + LAST_TOTAL=0 + LAST_ALLOWED=0 + LAST_UNAUTHORIZED=0 + LAST_UNKNOWN=0 + + local line out verdict reason dst domain + while IFS= read -r line || [[ -n "$line" ]]; do + [[ -z "${line// /}" ]] && continue + LAST_TOTAL=$(( LAST_TOTAL + 1 )) + out="$(classify_line "$line")" + IFS='|' read -r verdict reason dst domain <<<"$out" + printf '%s verdict=%s reason=%s dst=%s domain=%s raw=%s\n' \ + "$(timestamp)" "$verdict" "$reason" "$dst" "$domain" "$line" >> "$audit" + case "$verdict" in + ALLOWED) LAST_ALLOWED=$(( LAST_ALLOWED + 1 )) ;; + UNAUTHORIZED) LAST_UNAUTHORIZED=$(( LAST_UNAUTHORIZED + 1 )) ;; + *) LAST_UNKNOWN=$(( LAST_UNKNOWN + 1 )) ;; + esac + done < "$input" + return 0 +} + +# --------------------------------------------------------------------------- +# Capture backends +# --------------------------------------------------------------------------- +select_backend() { + case "$EGRESS_BACKEND" in + tcpdump|iptables|none) + BACKEND="$EGRESS_BACKEND" + ;; + auto) + if command -v tcpdump >/dev/null 2>&1; then + BACKEND="tcpdump" + elif command -v iptables >/dev/null 2>&1 && [[ "$(id -u)" -eq 0 ]]; then + BACKEND="iptables" + else + BACKEND="none" + fi + ;; + *) + echo "EGRESS: unknown backend '$EGRESS_BACKEND'" >&2 + return 2 + ;; + esac +} + +start_tcpdump() { + local -a cmd=( tcpdump -i any -nn -l -Q out -s 96 'ip or ip6' ) + if [[ "$(id -u)" -ne 0 ]]; then + if sudo -n true 2>/dev/null; then + cmd=( sudo -n "${cmd[@]}" ) + else + return 1 + fi + fi + "${cmd[@]}" > "$CAPTURE_LOG" 2> "${CAPTURE_LOG}.err" & + CAPTURE_PID=$! + sleep 1 + if ! kill -0 "$CAPTURE_PID" 2>/dev/null; then + CAPTURE_PID="" + return 1 + fi + return 0 +} + +install_iptables_rules() { + local entry + iptables -N EGRESS-MONITOR 2>/dev/null || iptables -F EGRESS-MONITOR + iptables -A EGRESS-MONITOR -o lo -j RETURN + iptables -A EGRESS-MONITOR -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN 2>/dev/null \ + || iptables -A EGRESS-MONITOR -m state --state ESTABLISHED,RELATED -j RETURN + for entry in ${ALLOWLIST_CIDRS[@]+"${ALLOWLIST_CIDRS[@]}"}; do + [[ "$entry" == *:* ]] && continue + iptables -A EGRESS-MONITOR -d "$entry" -j RETURN || true + done + iptables -A EGRESS-MONITOR -m limit --limit 20/min --limit-burst 40 \ + -j LOG --log-prefix "EGRESS-DENY: " --log-level 4 + if [[ "$ENFORCE" == true ]]; then + iptables -A EGRESS-MONITOR -j REJECT --reject-with icmp-port-unreachable + else + iptables -A EGRESS-MONITOR -j RETURN + fi + iptables -I OUTPUT 1 -j EGRESS-MONITOR + IPTABLES_INSTALLED=true +} + +start_iptables() { + [[ "$(id -u)" -eq 0 ]] || return 1 + install_iptables_rules +} + +start_capture() { + case "$BACKEND" in + tcpdump) start_tcpdump ;; + iptables) start_iptables ;; + none) return 1 ;; + *) return 1 ;; + esac +} + +collect_iptables_log() { + [[ "$IPTABLES_INSTALLED" == true ]] || return 0 + { + dmesg 2>/dev/null | grep 'EGRESS-DENY:' || \ + sudo -n dmesg 2>/dev/null | grep 'EGRESS-DENY:' || true + } >> "$CAPTURE_LOG" + return 0 +} + +stop_capture() { + if [[ -n "$CAPTURE_PID" ]]; then + kill "$CAPTURE_PID" 2>/dev/null || true + wait "$CAPTURE_PID" 2>/dev/null || true + CAPTURE_PID="" + fi + if [[ "$IPTABLES_INSTALLED" == true ]]; then + iptables -D OUTPUT -j EGRESS-MONITOR 2>/dev/null || true + iptables -F EGRESS-MONITOR 2>/dev/null || true + iptables -X EGRESS-MONITOR 2>/dev/null || true + IPTABLES_INSTALLED=false + fi + return 0 +} + +cleanup() { stop_capture; } + +# --------------------------------------------------------------------------- +# Monitor mode: run the build inside a capture window +# --------------------------------------------------------------------------- +run_monitored() { + prepare_allowlist + resolve_allowlist_domains + + mkdir -p "$EGRESS_LOG_DIR" + CAPTURE_LOG="$EGRESS_LOG_DIR/egress-capture.log" + : > "$CAPTURE_LOG" + + select_backend + + trap cleanup EXIT INT TERM + + local capture_state="ok" + if ! start_capture; then + BACKEND="none" + capture_state="unavailable" + CAPTURE_LOG="$EGRESS_LOG_DIR/egress-capture.log" + echo "EGRESS: no packet capture backend available (install tcpdump or run as root)." >&2 + if [[ "$STRICT" == true ]]; then + echo "EGRESS: --strict set, refusing to run an unmonitored build." >&2 + write_summary "FAIL" "none" "$capture_state" "${CMD[*]}" "-1" + return 1 + fi + echo "EGRESS: continuing without capture (use --strict to fail instead)." >&2 + fi + + local build_rc=0 + echo "EGRESS: monitoring '${CMD[*]}' (backend=$BACKEND, enforce=$ENFORCE)" + set +e + "${CMD[@]}" + build_rc=$? + set -e + + stop_capture + trap - EXIT INT TERM + + collect_iptables_log + + classify_log "$CAPTURE_LOG" + + local verdict="PASS" + if (( LAST_UNAUTHORIZED > 0 )) && [[ "$REPORT_ONLY" != true ]]; then + verdict="FAIL" + fi + + write_summary "$verdict" "$BACKEND" "$capture_state" "${CMD[*]}" "$build_rc" + + if [[ "$verdict" == "FAIL" ]]; then + echo "EGRESS: BLOCKED — unauthorized build egress detected ($LAST_UNAUTHORIZED finding(s))." >&2 + return 1 + fi + if [[ "$IGNORE_COMMAND_EXIT" == true ]]; then + return 0 + fi + return "$build_rc" +} + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- +if [[ "${EGRESS_STRICT:-}" == "true" || "${EGRESS_STRICT:-}" == "1" ]]; then + STRICT=true +fi +if [[ "${EGRESS_REPORT_ONLY:-}" == "true" || "${EGRESS_REPORT_ONLY:-}" == "1" ]]; then + REPORT_ONLY=true +fi + +main() { + while [[ $# -gt 0 ]]; do + case "$1" in + --strict) STRICT=true; shift ;; + --allow-no-capture) STRICT=false; shift ;; + --report-only) REPORT_ONLY=true; shift ;; + --ignore-command-exit) IGNORE_COMMAND_EXIT=true; shift ;; + --enforce) EGRESS_ENFORCE=true; shift ;; + --no-enforce) EGRESS_ENFORCE=false; shift ;; + --backend) + [[ $# -ge 2 ]] || { echo "EGRESS: --backend needs a value" >&2; return 2; } + EGRESS_BACKEND="$2"; shift 2 ;; + --allowlist-file) + [[ $# -ge 2 ]] || { echo "EGRESS: --allowlist-file needs a value" >&2; return 2; } + EGRESS_ALLOWLIST_FILE="$2"; shift 2 ;; + --log-dir) + [[ $# -ge 2 ]] || { echo "EGRESS: --log-dir needs a value" >&2; return 2; } + EGRESS_LOG_DIR="$2"; shift 2 ;; + --classify) MODE="classify"; shift ;; + -h|--help) usage; return 0 ;; + --) shift; break ;; + -*) echo "EGRESS: unknown option '$1'" >&2; return 2 ;; + *) break ;; + esac + done + CMD=("$@") + + case "$EGRESS_ENFORCE" in + true|1|yes) ENFORCE=true ;; + *) ENFORCE=false ;; + esac + + if [[ "$MODE" == "classify" ]]; then + prepare_allowlist + local src="${CMD[0]:--}" + local label="--classify ${CMD[0]:-}" + classify_log "$src" + local classify_verdict="PASS" + if (( LAST_UNAUTHORIZED > 0 )) && [[ "$REPORT_ONLY" != true ]]; then + classify_verdict="FAIL" + fi + write_summary "$classify_verdict" "$EGRESS_BACKEND" "classify-mode" "$label" "-" + if (( LAST_UNAUTHORIZED > 0 )) && [[ "$REPORT_ONLY" != true ]]; then + echo "EGRESS: BLOCKED — unauthorized egress in capture log ($LAST_UNAUTHORIZED finding(s))." >&2 + return 1 + fi + return 0 + fi + + if [[ ${#CMD[@]} -eq 0 ]]; then + CMD=( npm run build ) + fi + run_monitored +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/server/package.json b/server/package.json index 989fd6a1..3348d626 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,8 @@ "start": "node index.js", "build": "node -e \"process.exit(0)\"", "test": "node --test *.test.js", - "audit:deps": "node ../scripts/audit-deps.js --lock server/package-lock.json --no-write" + "audit:deps": "node ../scripts/audit-deps.js --lock server/package-lock.json --no-write", + "security:egress": "bash ../scripts/monitor-build-egress.sh --strict -- npm run build" }, "dependencies": { "@aztec/bb.js": "^0.87.9", diff --git a/src/stores/helpStore.ts b/src/stores/helpStore.ts index db248065..9edcc75a 100644 --- a/src/stores/helpStore.ts +++ b/src/stores/helpStore.ts @@ -1,8 +1,6 @@ import { LwwElementSet, LwwOperation } from '../lib/crdt' import { swChannel, postToServiceWorker, getInstanceId } from '../lib/swChannel' -import { LwwElementSet } from '../lib/crdt' -import type { LwwOperation } from '../lib/crdt' import type { SecureStorage } from '../lib/secureStorage' export interface OfflineHelpRecord { status: string; lat: number; lng: number; updatedAt: number } @@ -55,8 +53,6 @@ function subscribeToServiceWorkerMessages(handler: (message: any) => void): () = navigator.serviceWorker.addEventListener('message', listener) return () => navigator.serviceWorker.removeEventListener('message', listener) } -export const upsertOfflineHelp = (id: string, value: OfflineHelpRecord) => helpStore.set(id, value, value.updatedAt) -export const removeOfflineHelp = (id: string, updatedAt = Date.now()) => helpStore.delete(id, updatedAt) const PERSIST_KEY = 'help-store' diff --git a/src/styles/global.css b/src/styles/global.css index 98ccb9d8..29f2ec40 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1 +1 @@ -// Implementation added +/* Placeholder global stylesheet (no global resets yet). */ diff --git a/test/egress-detector.test.js b/test/egress-detector.test.js new file mode 100644 index 00000000..67e9893e --- /dev/null +++ b/test/egress-detector.test.js @@ -0,0 +1,203 @@ +import { describe, it, expect } from "vitest"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "url"; + +// Build pipeline egress detector. Offline tests: canned tcpdump / +// iptables LOG lines are fed through the classifier, no capture or network. + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, ".."); +const SCRIPT = path.join(ROOT, "scripts", "monitor-build-egress.sh"); + +function run(args, opts = {}) { + return spawnSync("bash", [SCRIPT, ...args], { + cwd: ROOT, + encoding: "utf8", + env: { ...process.env, EGRESS_ALLOWLIST: "", EGRESS_ALLOWLIST_FILE: "" }, + ...opts, + }); +} + +function classify(lines, extra = []) { + const logDir = mkdtempSync(path.join(tmpdir(), "helphone-egress-")); + const input = path.join(logDir, "capture.log"); + writeFileSync(input, lines.join("\n") + "\n"); + const result = run(["--classify", "--log-dir", logDir, ...extra, input]); + const audit = path.join(logDir, "egress-audit.log"); + const summary = path.join(logDir, "egress-summary.log"); + return { + ...result, + audit: existsSync(audit) ? readFileSync(audit, "utf8") : "", + summary: existsSync(summary) ? readFileSync(summary, "utf8") : "", + logDir, + }; +} + +const PRIVATE_FLOW = + "12:00:00.000000 IP 172.17.0.2.51234 > 10.0.0.5.443: Flags [S], seq 1, win 64240, length 0"; +const EXTERNAL_FLOW = + "12:00:01.000000 IP 172.17.0.2.51235 > 203.0.113.9.443: Flags [S], seq 2, win 64240, length 0"; +const METADATA_FLOW = + "12:00:04.000000 IP 172.17.0.2.51236 > 169.254.169.254.80: Flags [S], seq 3, win 64240, length 0"; +const DNS_ALLOWED = + "12:00:02.000000 IP 10.0.0.5.5353 > 8.8.8.8.53: 12345+ A? registry.npmjs.org. (32)"; +const DNS_EXFIL = + "12:00:03.000000 IP 10.0.0.5.5354 > 8.8.8.8.53: 12346+ A? exfil.attacker.example. (28)"; +const IPTABLES_DENY = + "[ 123.456] EGRESS-DENY: IN= OUT=eth0 SRC=172.17.0.2 DST=198.51.100.4 LEN=60 PROTO=TCP SPT=44321 DPT=8443"; + +describe("monitor-build-egress.sh script contract", () => { + it("exists and is a hardened bash script", () => { + expect(existsSync(SCRIPT)).toBe(true); + const body = readFileSync(SCRIPT, "utf8"); + expect(body).toContain("#!/usr/bin/env bash"); + expect(body).toContain("set -euo pipefail"); + expect(body).toContain("EGRESS-DENY:"); + }); + + it("prints usage and exits 0 on --help", () => { + const r = run(["--help"]); + expect(r.status).toBe(0); + expect(r.stdout).toContain("monitor-build-egress.sh"); + expect(r.stdout).toContain("--classify"); + expect(r.stdout).toContain("--strict"); + }); + + it("rejects unknown options with exit code 2", () => { + expect(run(["--bogus"]).status).toBe(2); + }); +}); + +describe("destination extraction", () => { + it("reads the tcpdump destination address", () => { + const r = classify([EXTERNAL_FLOW]); + expect(r.stdout).toContain("dst=203.0.113.9"); + expect(r.audit).toContain("dst=203.0.113.9"); + }); + + it("reads the destination from an iptables LOG line", () => { + const r = classify([IPTABLES_DENY]); + expect(r.stdout).toContain("dst=198.51.100.4"); + expect(r.status).toBe(1); + }); + + it("parses IPv6 tcpdump destinations", () => { + const r = classify([ + "12:00:06.000000 IP6 2001:db8::10.40000 > 2001:4860:4860::8888.53: 9999+ A? registry.npmjs.org. (32)", + ]); + expect(r.audit).toContain("dst=2001:4860:4860::8888"); + }); +}); + +describe("unauthorized connection gate", () => { + it("allows loopback and RFC1918 destinations", () => { + const r = classify([PRIVATE_FLOW]); + expect(r.status).toBe(0); + expect(r.audit).toContain("verdict=ALLOWED"); + expect(r.audit).toContain("reason=ip-allowlist"); + expect(r.summary).toContain("verdict: PASS"); + }); + + it("blocks connections to an unapproved external IP", () => { + const r = classify([EXTERNAL_FLOW]); + expect(r.status).toBe(1); + expect(r.audit).toContain("verdict=UNAUTHORIZED"); + expect(r.audit).toContain("reason=ip-not-allowlisted"); + expect(r.summary).toContain("verdict: FAIL"); + expect(r.summary).toContain("unauthorized: 1"); + }); + + it("allows DNS for allowlisted package registries", () => { + const r = classify([DNS_ALLOWED]); + expect(r.status).toBe(0); + expect(r.audit).toContain("verdict=ALLOWED"); + expect(r.audit).toContain("domain=registry.npmjs.org"); + }); + + it("blocks DNS lookups for non-allowlisted hosts", () => { + const r = classify([DNS_EXFIL]); + expect(r.status).toBe(1); + expect(r.audit).toContain("reason=domain-not-allowlisted"); + expect(r.audit).toContain("domain=exfil.attacker.example"); + }); + + it("never allows the cloud metadata endpoint", () => { + const r = classify([METADATA_FLOW]); + expect(r.status).toBe(1); + expect(r.audit).toContain("reason=metadata-endpoint"); + }); + + it("honours --report-only by auditing without failing", () => { + const r = classify([EXTERNAL_FLOW], ["--report-only"]); + expect(r.status).toBe(0); + expect(r.summary).toContain("verdict: PASS"); + expect(r.audit).toContain("verdict=UNAUTHORIZED"); + }); +}); + +describe("allowlist extension", () => { + it("promotes an external IP to allowed via --allowlist-file", () => { + const dir = mkdtempSync(path.join(tmpdir(), "egress-allow-")); + const file = path.join(dir, "allowlist.txt"); + writeFileSync(file, "# partner CDN\n203.0.113.0/24\n"); + const blocked = classify([EXTERNAL_FLOW]); + const allowed = classify([EXTERNAL_FLOW], ["--allowlist-file", file]); + expect(blocked.status).toBe(1); + expect(allowed.status).toBe(0); + expect(allowed.audit).toContain("verdict=ALLOWED"); + expect(allowed.summary).toMatch(/allowlist: cidr=\d+ domain=\d+/); + }); + + it("fails on an unreadable allowlist file", () => { + const r = classify([PRIVATE_FLOW], ["--allowlist-file", "/nonexistent/allowlist.txt"]); + expect(r.status).not.toBe(0); + }); +}); + +describe("build wrapper", () => { + it("propagates a successful build exit code when egress is clean", () => { + const logDir = mkdtempSync(path.join(tmpdir(), "egress-wrap-")); + const r = run(["--backend", "none", "--log-dir", logDir, "--", "true"]); + expect(r.status).toBe(0); + expect(existsSync(path.join(logDir, "egress-summary.log"))).toBe(true); + expect(readFileSync(path.join(logDir, "egress-summary.log"), "utf8")).toContain("verdict: PASS"); + }, 20000); + + it("propagates a failing build exit code", () => { + const logDir = mkdtempSync(path.join(tmpdir(), "egress-wrap-")); + expect(run(["--backend", "none", "--log-dir", logDir, "--", "false"]).status).toBe(1); + }, 20000); + + it("blocks the build when --strict requires capture that is unavailable", () => { + const logDir = mkdtempSync(path.join(tmpdir(), "egress-strict-")); + const r = run(["--strict", "--backend", "none", "--log-dir", logDir, "--", "true"]); + expect(r.status).toBe(1); + expect(readFileSync(path.join(logDir, "egress-summary.log"), "utf8")).toContain("capture: unavailable"); + }, 20000); + + it("defaults to monitoring npm run build when no command is given", () => { + const body = readFileSync(SCRIPT, "utf8"); + expect(body).toContain("CMD=( npm run build )"); + }); +}); + +describe("cidr matching", () => { + const probe = (expr) => + spawnSync("bash", ["-c", `source "${SCRIPT}"; ${expr}`], { encoding: "utf8" }); + + it("matches addresses inside a prefix and rejects those outside", () => { + expect(probe('cidr_contains 10.0.0.0/8 10.1.2.3 && echo yes').stdout.trim()).toBe("yes"); + expect(probe('cidr_contains 10.0.0.0/8 11.1.2.3 && echo yes').stdout.trim()).toBe(""); + expect(probe('cidr_contains 172.16.0.0/12 172.31.255.255 && echo yes').stdout.trim()).toBe("yes"); + expect(probe('cidr_contains 172.16.0.0/12 172.32.0.0 && echo yes').stdout.trim()).toBe(""); + }); + + it("handles non-octal-looking octets without octal parsing", () => { + expect(probe("ip2int 192.168.0.10").stdout.trim()).toBe( + String(192 * 2 ** 24 + 168 * 2 ** 16 + 10), + ); + }); +}); From 05bc5e3d0909ca5871afecb483f795c9473adef1 Mon Sep 17 00:00:00 2001 From: Petrelid Date: Sat, 26 Sep 2026 14:29:37 +0100 Subject: [PATCH 2/3] Add comprehensive tests for API drift detection and version pinning - Implement tests for signature normalization, version classification, and internal member name checks. - Add tests for surface diffs, ensuring breaking and additive changes are correctly identified. - Introduce tests for evaluating drift with various scenarios, including major upgrades and exact pin requirements. - Create tests for parsing Cargo manifests and checking pinning requirements. - Include tests for extracting type surfaces from in-memory declaration files and real package installations. - Establish CLI tests for usage, error handling, and JSON output. --- .github/workflows/ci.yml | 40 + Cargo.lock | 7 + Cargo.toml | 39 +- README.md | 9 + package.json | 2396 +++++++++++++++++++++++++++++++++++ scripts/detect-api-drift.js | 1049 +++++++++++++++ server/package.json | 1133 ++++++++++++++++- test/api-drift.test.js | 672 ++++++++++ tsconfig.json | 20 +- 9 files changed, 5362 insertions(+), 3 deletions(-) create mode 100644 scripts/detect-api-drift.js create mode 100644 test/api-drift.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91c458bd..87873bb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,3 +203,43 @@ jobs: name: build-egress-audit path: artifacts/build-egress/ if-no-files-found: warn + + # Automated dependency version drift & breaking API change analyzer: the + # exported TypeScript surface of every protected package (functions, class + # members, call signatures) is extracted from its .d.ts entry point with the + # TypeScript Compiler API and compared against the reviewed baseline + # committed in package.json -> apiDrift.baseline. + # + # Version pinning guard: a semver-compatible (minor/patch) upgrade that + # drops or re-types a public signature fails this job; major upgrades are + # reported as warnings and need a re-baseline. The Rust half checks that + # Cargo.toml carries [workspace.metadata.api-drift]. + api-drift-guard: + name: Dependency API Drift & Version Pinning Guard + runs-on: ubuntu-latest + steps: + - name: Checkout Codebase + uses: actions/checkout@v4 + + - name: Setup Node.js Environment + uses: actions/setup-node@v4 + with: + node-version: '22.x' + cache: 'npm' + + - name: Install Dependencies (no lifecycle scripts) + run: npm ci --ignore-scripts + + - name: Check app dependencies against the committed API baseline + run: npm run security:api-drift | tee api-drift-report.txt + + - name: Check server dependencies against the committed API baseline + run: npm run security:api-drift:server | tee -a api-drift-report.txt + + - name: Publish drift report artifact + if: failure() + uses: actions/upload-artifact@v4 + with: + name: api-drift-report + path: api-drift-report.txt + if-no-files-found: ignore diff --git a/Cargo.lock b/Cargo.lock index b369da7a..cf037b23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -703,6 +703,13 @@ dependencies = [ "zeroize", ] +[[package]] +name = "emergency_vault" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "enum-ordinalize" version = "4.4.2" diff --git a/Cargo.toml b/Cargo.toml index 98ccb9d8..1133a984 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1 +1,38 @@ -// Implementation added +# Root Cargo workspace for the Soroban contracts under contracts/. +# (This manifest previously contained a `// Implementation added` placeholder, +# which is not valid TOML: every `cargo` invocation under the repository failed +# with a parse error before reaching a crate.) + +[workspace] +resolver = "2" +members = ["contracts/*"] +exclude = [ + # Own workspace root. + "contract", + # Standalone spike crates, not part of the contract workspace. + "scripts/spikes/webtransport_server", + "src/wasm/telemetry_reader", +] + +# Member crates declare this same release profile; it is mirrored here because +# Cargo only honours `[profile]` tables at the workspace root, so building from +# the repo root must produce the same wasm artifact as building in a crate dir. +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true +overflow-checks = true + +# --------------------------------------------------------------------------- +# Version pinning guard (scripts/detect-api-drift.js) — Rust half. +# A semver-compatible `cargo update` may silently pull a release whose public +# API differs from the version the contracts were reviewed against, so shared +# requirements are pinned exactly and the guard fails CI when they are not. +# --------------------------------------------------------------------------- +[workspace.metadata.api-drift] +require-exact-pin = true +# Crate names whose version requirement must be exact regardless of the +# default above (checked wherever they are declared in this manifest). +protected-crates = [] diff --git a/README.md b/README.md index 2e99fc00..ce4f1fb1 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,15 @@ HelPhone is a React + Vite community emergency response application built on Ste - **Runbook**: [docs/security-runbook.md](docs/security-runbook.md). - **Tests**: `test/egress-detector.test.js`. +### 8. Automated Dependency Version Drift & Breaking API Change Analyzer +- **Analyzer**: `scripts/detect-api-drift.js` extracts the exported type surface of every protected package — functions, interfaces, class members, call signatures and `export =` modules — from its `.d.ts` entry point with the TypeScript Compiler API, then diffs the installed surface against the reviewed baseline committed in `package.json` → `apiDrift.baseline`. Signatures are normalized (whitespace, `import("…")` specifiers rewritten to their `node_modules/` form) so the same package produces byte-identical baselines on CI runners and developer machines. +- **Version Pinning Guard**: dropped or re-typed signatures are *breaking*, new exports are *additive*. A breaking diff inside a semver-compatible (same/minor/patch) upgrade fails with exit 1 and names the version to pin; a breaking diff in a major upgrade is reported as a warning and needs a re-baseline. When a package does not bundle its own declarations the drift is classified with the `@types/` version, so an `@types` minor bump that breaks call sites is caught too. +- **Exact Pin Opt-In**: `npm run security:api-drift:pin` (`--require-exact-pin`) additionally fails protected dependencies declared as `^` / `~` / `>=` instead of an exact `1.2.3`. +- **Rust half**: `Cargo.toml` → `[workspace.metadata.api-drift]` (`require-exact-pin`, `protected-crates`) is validated against `[workspace.dependencies]`, `[dependencies]` and `[dev-dependencies]`, where only `=1.2.3` counts as pinned (a bare `1.2.3` means `^1.2.3`). +- **Baselines**: `npm run security:api-drift:update` re-extracts and merges into `package.json` (root: cors, express, express-rate-limit, fuse.js, graphql, pg, react-dom; `server/package.json`: @stellar/stellar-sdk, @aztec/bb.js, @noir-lang/noir_js). Extraction options live in `tsconfig.json` → `apiDrift.compilerOptions`, deliberately outside `compilerOptions` so `tsc --noEmit` ignores them. +- **CI Gate**: the `api-drift-guard` job in `.github/workflows/ci.yml` installs with `npm ci --ignore-scripts`, runs `npm run security:api-drift` and `security:api-drift:server`, and uploads the drift report when it fails. +- **Tests**: `test/api-drift.test.js`. + --- ## Quick Start diff --git a/package.json b/package.json index b2717131..29ad4ac4 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ "security:egress": "bash scripts/monitor-build-egress.sh -- npm run build", "security:egress:strict": "bash scripts/monitor-build-egress.sh --strict -- npm run build", "security:egress:audit": "bash scripts/monitor-build-egress.sh --classify artifacts/build-egress/egress-capture.log", + "security:api-drift": "node scripts/detect-api-drift.js --check", + "security:api-drift:update": "node scripts/detect-api-drift.js --update", + "security:api-drift:server": "node scripts/detect-api-drift.js --root server --check", + "security:api-drift:pin": "node scripts/detect-api-drift.js --check --require-exact-pin", "test:watch": "vitest", "test:e2e:throttling": "playwright test --project=throttling", "lint": "eslint .", @@ -87,5 +91,2397 @@ "react-dom": "^19.2.7", "react-map-gl": "^8.1.1", "react-router-dom": "^7.18.0" + }, + "apiDrift": { + "packages": [ + "cors", + "express", + "express-rate-limit", + "fuse.js", + "graphql", + "pg", + "react-dom" + ], + "baseline": { + "cors": { + "version": "2.8.6", + "typesPackage": "@types/cors", + "typesVersion": "2.8.19", + "surface": { + "CorsOptions": "interface: CorsOptions", + "CorsOptions.allowedHeaders": "?string | string[]", + "CorsOptions.credentials": "?boolean", + "CorsOptions.exposedHeaders": "?string | string[]", + "CorsOptions.maxAge": "?number", + "CorsOptions.methods": "?string | string[]", + "CorsOptions.optionsSuccessStatus": "?number", + "CorsOptions.origin": "?StaticOrigin | CustomOrigin", + "CorsOptions.preflightContinue": "?boolean", + "CorsOptionsDelegate": "type: CorsOptionsDelegate", + "CorsRequest": "interface: CorsRequest", + "CorsRequest.headers": "IncomingHttpHeaders", + "CorsRequest.method": "?string", + "module": "function: (options?: CorsOptions | CorsOptionsDelegate): (req: T, res: { statusCode?: number; setHeader(key: string, value: string): any; end(): any; }, next: (err?: any) => any) => void" + } + }, + "express": { + "version": "4.22.2", + "typesPackage": "@types/express", + "typesVersion": "4.17.25", + "surface": { + "Application": "interface: Application", + "Application._router": "any", + "Application.addListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Application", + "Application.all": "IRouterMatcher", + "Application.checkout": "IRouterMatcher", + "Application.connect": "IRouterMatcher", + "Application.copy": "IRouterMatcher", + "Application.defaultConfiguration": "() => void", + "Application.delete": "IRouterMatcher", + "Application.disable": "(setting: string) => Application", + "Application.disabled": "(setting: string) => boolean", + "Application.emit": "(eventName: string | symbol, ...args: AnyRest) => boolean", + "Application.enable": "(setting: string) => Application", + "Application.enabled": "(setting: string) => boolean", + "Application.engine": "(ext: string, fn: (path: string, options: object, callback: (e: any, rendered?: string) => void) => void) => Application", + "Application.eventNames": "() => (string | symbol)[]", + "Application.get": "((name: string) => any) & IRouterMatcher", + "Application.getMaxListeners": "() => number", + "Application.head": "IRouterMatcher", + "Application.init": "() => void", + "Application.link": "IRouterMatcher", + "Application.listen": "{ (port: number, hostname: string, backlog: number, callback?: () => void): Server; (port: number, hostname: string, callback?: () => void): Server<...>; (port: number, callback?: () => void): Server<...>; (callback?: () => void): Server<...>; (path: string, callback?: ...", + "Application.listenerCount": "(eventName: string | symbol, listener?: Function) => number", + "Application.listeners": "(eventName: string | symbol) => Function[]", + "Application.locals": "Record & Locals", + "Application.lock": "IRouterMatcher", + "Application.m-search": "IRouterMatcher", + "Application.map": "any", + "Application.merge": "IRouterMatcher", + "Application.mkactivity": "IRouterMatcher", + "Application.mkcol": "IRouterMatcher", + "Application.mountpath": "string | string[]", + "Application.move": "IRouterMatcher", + "Application.notify": "IRouterMatcher", + "Application.off": "(eventName: string | symbol, listener: (...args: any[]) => void) => Application", + "Application.on": "(event: \"mount\", callback: (parent: Application>) => void) => Application", + "Application.once": "(eventName: string | symbol, listener: (...args: any[]) => void) => Application", + "Application.options": "IRouterMatcher", + "Application.param": "{ (name: string | string[], handler: RequestParamHandler): Application; (callback: (name: string, matcher: RegExp) => RequestParamHandler): Application; }", + "Application.patch": "IRouterMatcher", + "Application.path": "() => string", + "Application.post": "IRouterMatcher", + "Application.prependListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Application", + "Application.prependOnceListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Application", + "Application.propfind": "IRouterMatcher", + "Application.proppatch": "IRouterMatcher", + "Application.purge": "IRouterMatcher", + "Application.put": "IRouterMatcher", + "Application.query": "?IRouterMatcher", + "Application.rawListeners": "(eventName: string | symbol) => Function[]", + "Application.removeAllListeners": "(eventName?: string | symbol) => Application", + "Application.removeListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Application", + "Application.render": "{ (name: string, options?: object, callback?: (err: Error, html: string) => void): void; (name: string, callback: (err: Error, html: string) => void): void; }", + "Application.report": "IRouterMatcher", + "Application.resource": "any", + "Application.route": "{ (prefix: T): IRoute; (prefix: PathParams): IRoute; }", + "Application.router": "string", + "Application.routes": "any", + "Application.search": "IRouterMatcher", + "Application.set": "(setting: string, val: any) => Application", + "Application.setMaxListeners": "(n: number) => Application", + "Application.settings": "any", + "Application.stack": "ILayer[]", + "Application.subscribe": "IRouterMatcher", + "Application.trace": "IRouterMatcher", + "Application.unlink": "IRouterMatcher", + "Application.unlock": "IRouterMatcher", + "Application.unsubscribe": "IRouterMatcher", + "Application.use": "ApplicationRequestHandler", + "CookieOptions": "interface: CookieOptions", + "CookieOptions.domain": "?string", + "CookieOptions.encode": "?(val: string) => string", + "CookieOptions.expires": "?Date", + "CookieOptions.httpOnly": "?boolean", + "CookieOptions.maxAge": "?number", + "CookieOptions.partitioned": "?boolean", + "CookieOptions.path": "?string", + "CookieOptions.priority": "?\"low\" | \"medium\" | \"high\"", + "CookieOptions.sameSite": "?boolean | \"lax\" | \"strict\" | \"none\"", + "CookieOptions.secure": "?boolean", + "CookieOptions.signed": "?boolean", + "Errback": "interface: Errback", + "ErrorRequestHandler": "interface: ErrorRequestHandler", + "Express": "interface: Express", + "Express._router": "any", + "Express.addListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Express", + "Express.all": "IRouterMatcher", + "Express.checkout": "IRouterMatcher", + "Express.connect": "IRouterMatcher", + "Express.copy": "IRouterMatcher", + "Express.defaultConfiguration": "() => void", + "Express.delete": "IRouterMatcher", + "Express.disable": "(setting: string) => Express", + "Express.disabled": "(setting: string) => boolean", + "Express.emit": "(eventName: string | symbol, ...args: AnyRest) => boolean", + "Express.enable": "(setting: string) => Express", + "Express.enabled": "(setting: string) => boolean", + "Express.engine": "(ext: string, fn: (path: string, options: object, callback: (e: any, rendered?: string) => void) => void) => Express", + "Express.eventNames": "() => (string | symbol)[]", + "Express.get": "((name: string) => any) & IRouterMatcher", + "Express.getMaxListeners": "() => number", + "Express.head": "IRouterMatcher", + "Express.init": "() => void", + "Express.link": "IRouterMatcher", + "Express.listen": "{ (port: number, hostname: string, backlog: number, callback?: () => void): Server; (port: number, hostname: string, callback?: () => void): Server<...>; (port: number, callback?: () => void): Server<...>; (callback?: () => void): Server<...>; (path: string, callback?: ...", + "Express.listenerCount": "(eventName: string | symbol, listener?: Function) => number", + "Express.listeners": "(eventName: string | symbol) => Function[]", + "Express.locals": "Record & Locals", + "Express.lock": "IRouterMatcher", + "Express.m-search": "IRouterMatcher", + "Express.map": "any", + "Express.merge": "IRouterMatcher", + "Express.mkactivity": "IRouterMatcher", + "Express.mkcol": "IRouterMatcher", + "Express.mountpath": "string | string[]", + "Express.move": "IRouterMatcher", + "Express.notify": "IRouterMatcher", + "Express.off": "(eventName: string | symbol, listener: (...args: any[]) => void) => Express", + "Express.on": "(event: \"mount\", callback: (parent: Application>) => void) => Express", + "Express.once": "(eventName: string | symbol, listener: (...args: any[]) => void) => Express", + "Express.options": "IRouterMatcher", + "Express.param": "{ (name: string | string[], handler: RequestParamHandler): Express; (callback: (name: string, matcher: RegExp) => RequestParamHandler): Express; }", + "Express.patch": "IRouterMatcher", + "Express.path": "() => string", + "Express.post": "IRouterMatcher", + "Express.prependListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Express", + "Express.prependOnceListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Express", + "Express.propfind": "IRouterMatcher", + "Express.proppatch": "IRouterMatcher", + "Express.purge": "IRouterMatcher", + "Express.put": "IRouterMatcher", + "Express.query": "?IRouterMatcher", + "Express.rawListeners": "(eventName: string | symbol) => Function[]", + "Express.removeAllListeners": "(eventName?: string | symbol) => Express", + "Express.removeListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Express", + "Express.render": "{ (name: string, options?: object, callback?: (err: Error, html: string) => void): void; (name: string, callback: (err: Error, html: string) => void): void; }", + "Express.report": "IRouterMatcher", + "Express.request": "Request>", + "Express.resource": "any", + "Express.response": "Response, number>", + "Express.route": "{ (prefix: T): IRoute; (prefix: PathParams): IRoute; }", + "Express.router": "string", + "Express.routes": "any", + "Express.search": "IRouterMatcher", + "Express.set": "(setting: string, val: any) => Express", + "Express.setMaxListeners": "(n: number) => Express", + "Express.settings": "any", + "Express.stack": "ILayer[]", + "Express.subscribe": "IRouterMatcher", + "Express.trace": "IRouterMatcher", + "Express.unlink": "IRouterMatcher", + "Express.unlock": "IRouterMatcher", + "Express.unsubscribe": "IRouterMatcher", + "Express.use": "ApplicationRequestHandler", + "Handler": "interface: Handler", + "IRoute": "interface: IRoute", + "IRoute.all": "IRouterHandler", + "IRoute.checkout": "IRouterHandler", + "IRoute.copy": "IRouterHandler", + "IRoute.delete": "IRouterHandler", + "IRoute.get": "IRouterHandler", + "IRoute.head": "IRouterHandler", + "IRoute.lock": "IRouterHandler", + "IRoute.m-search": "IRouterHandler", + "IRoute.merge": "IRouterHandler", + "IRoute.mkactivity": "IRouterHandler", + "IRoute.mkcol": "IRouterHandler", + "IRoute.move": "IRouterHandler", + "IRoute.notify": "IRouterHandler", + "IRoute.options": "IRouterHandler", + "IRoute.patch": "IRouterHandler", + "IRoute.path": "string", + "IRoute.post": "IRouterHandler", + "IRoute.purge": "IRouterHandler", + "IRoute.put": "IRouterHandler", + "IRoute.report": "IRouterHandler", + "IRoute.search": "IRouterHandler", + "IRoute.stack": "ILayer[]", + "IRoute.subscribe": "IRouterHandler", + "IRoute.trace": "IRouterHandler", + "IRoute.unlock": "IRouterHandler", + "IRoute.unsubscribe": "IRouterHandler", + "IRouter": "interface: IRouter", + "IRouter.all": "IRouterMatcher", + "IRouter.checkout": "IRouterMatcher", + "IRouter.connect": "IRouterMatcher", + "IRouter.copy": "IRouterMatcher", + "IRouter.delete": "IRouterMatcher", + "IRouter.get": "IRouterMatcher", + "IRouter.head": "IRouterMatcher", + "IRouter.link": "IRouterMatcher", + "IRouter.lock": "IRouterMatcher", + "IRouter.m-search": "IRouterMatcher", + "IRouter.merge": "IRouterMatcher", + "IRouter.mkactivity": "IRouterMatcher", + "IRouter.mkcol": "IRouterMatcher", + "IRouter.move": "IRouterMatcher", + "IRouter.notify": "IRouterMatcher", + "IRouter.options": "IRouterMatcher", + "IRouter.param": "{ (name: string, handler: RequestParamHandler): IRouter; (callback: (name: string, matcher: RegExp) => RequestParamHandler): IRouter; }", + "IRouter.patch": "IRouterMatcher", + "IRouter.post": "IRouterMatcher", + "IRouter.propfind": "IRouterMatcher", + "IRouter.proppatch": "IRouterMatcher", + "IRouter.purge": "IRouterMatcher", + "IRouter.put": "IRouterMatcher", + "IRouter.query": "?IRouterMatcher", + "IRouter.report": "IRouterMatcher", + "IRouter.route": "{ (prefix: T): IRoute; (prefix: PathParams): IRoute; }", + "IRouter.search": "IRouterMatcher", + "IRouter.stack": "ILayer[]", + "IRouter.subscribe": "IRouterMatcher", + "IRouter.trace": "IRouterMatcher", + "IRouter.unlink": "IRouterMatcher", + "IRouter.unlock": "IRouterMatcher", + "IRouter.unsubscribe": "IRouterMatcher", + "IRouter.use": "IRouterHandler & IRouterMatcher", + "IRouterHandler": "interface: IRouterHandler", + "IRouterMatcher": "interface: IRouterMatcher", + "Locals": "interface: Locals", + "MediaType": "interface: MediaType", + "MediaType.quality": "number", + "MediaType.subtype": "string", + "MediaType.type": "string", + "MediaType.value": "string", + "NextFunction": "interface: NextFunction", + "Request": "interface: Request", + "Request._construct": "?(callback: (error?: Error) => void) => void", + "Request._destroy": "(error: Error, callback: (error?: Error) => void) => void", + "Request._read": "(size: number) => void", + "Request.aborted": "boolean", + "Request.accepted": "MediaType[]", + "Request.accepts": "{ (): string[]; (type: string): string | false; (type: string[]): string | false; (...type: string[]): string | false; }", + "Request.acceptsCharsets": "{ (): string[]; (charset: string): string | false; (charset: string[]): string | false; (...charset: string[]): string | false; }", + "Request.acceptsEncodings": "{ (): string[]; (encoding: string): string | false; (encoding: string[]): string | false; (...encoding: string[]): string | false; }", + "Request.acceptsLanguages": "{ (): string[]; (lang: string): string | false; (lang: string[]): string | false; (...lang: string[]): string | false; }", + "Request.addListener": "{ (event: \"close\", listener: () => void): Request; (event: \"data\", listener: (chunk: any) => void): Request<...>; (event: \"end\", listener: () => void): Request<...>; (event: \"error\", listener: (err: Error) => void): Request<...>; (event: \"pause\", listener: () => void): Request<...", + "Request.app": "Application>", + "Request.asIndexedPairs": "(options?: Pick) => Readable", + "Request.baseUrl": "string", + "Request.body": "ReqBody", + "Request.closed": "boolean", + "Request.complete": "boolean", + "Request.compose": "(stream: T | ComposeFnParam | Iterable | AsyncIterable, options?: { signal: AbortSignal; }) => T", + "Request.connection": "Socket", + "Request.cookies": "any", + "Request.destroy": "(error?: Error) => Request", + "Request.destroyed": "boolean", + "Request.drop": "(limit: number, options?: Pick) => Readable", + "Request.emit": "{ (event: \"close\"): boolean; (event: \"data\", chunk: any): boolean; (event: \"end\"): boolean; (event: \"error\", err: Error): boolean; (event: \"pause\"): boolean; (event: \"readable\"): boolean; (event: \"resume\"): boolean; (event: string | symbol, ...args: any[]): boolean; }", + "Request.errored": "Error", + "Request.eventNames": "() => (string | symbol)[]", + "Request.every": "(fn: (data: any, options?: Pick) => boolean | Promise, options?: ArrayOptions) => Promise", + "Request.filter": "(fn: (data: any, options?: Pick) => boolean | Promise, options?: ArrayOptions) => Readable", + "Request.find": "{ (fn: (data: any, options?: Pick) => data is T, options?: ArrayOptions): Promise; (fn: (data: any, options?: Pick) => boolean | Promise<...>, options?: ArrayOptions): Promise<...>; }", + "Request.flatMap": "(fn: (data: any, options?: Pick) => any, options?: ArrayOptions) => Readable", + "Request.forEach": "(fn: (data: any, options?: Pick) => void | Promise, options?: ArrayOptions) => Promise", + "Request.fresh": "boolean", + "Request.get": "{ (name: \"set-cookie\"): string[]; (name: string): string; }", + "Request.getMaxListeners": "() => number", + "Request.header": "{ (name: \"set-cookie\"): string[]; (name: string): string; }", + "Request.headers": "IncomingHttpHeaders", + "Request.headersDistinct": "Dict", + "Request.host": "string", + "Request.hostname": "string", + "Request.httpVersion": "string", + "Request.httpVersionMajor": "number", + "Request.httpVersionMinor": "number", + "Request.ip": "string", + "Request.ips": "string[]", + "Request.is": "(type: string | string[]) => string | false", + "Request.isPaused": "() => boolean", + "Request.iterator": "(options?: { destroyOnReturn?: boolean; }) => AsyncIterator", + "Request.listenerCount": "(eventName: string | symbol, listener?: Function) => number", + "Request.listeners": "(eventName: string | symbol) => Function[]", + "Request.map": "(fn: (data: any, options?: Pick) => any, options?: ArrayOptions) => Readable", + "Request.method": "string", + "Request.next": "?NextFunction", + "Request.off": "(eventName: string | symbol, listener: (...args: any[]) => void) => Request", + "Request.on": "{ (event: \"close\", listener: () => void): Request; (event: \"data\", listener: (chunk: any) => void): Request<...>; (event: \"end\", listener: () => void): Request<...>; (event: \"error\", listener: (err: Error) => void): Request<...>; (event: \"pause\", listener: () => void): Request<...", + "Request.once": "{ (event: \"close\", listener: () => void): Request; (event: \"data\", listener: (chunk: any) => void): Request<...>; (event: \"end\", listener: () => void): Request<...>; (event: \"error\", listener: (err: Error) => void): Request<...>; (event: \"pause\", listener: () => void): Request<...", + "Request.originalUrl": "string", + "Request.param": "(name: string, defaultValue?: any) => string", + "Request.params": "P", + "Request.path": "string", + "Request.pause": "() => Request", + "Request.pipe": "(destination: T, options?: { end?: boolean; }) => T", + "Request.prependListener": "{ (event: \"close\", listener: () => void): Request; (event: \"data\", listener: (chunk: any) => void): Request<...>; (event: \"end\", listener: () => void): Request<...>; (event: \"error\", listener: (err: Error) => void): Request<...>; (event: \"pause\", listener: () => void): Request<...", + "Request.prependOnceListener": "{ (event: \"close\", listener: () => void): Request; (event: \"data\", listener: (chunk: any) => void): Request<...>; (event: \"end\", listener: () => void): Request<...>; (event: \"error\", listener: (err: Error) => void): Request<...>; (event: \"pause\", listener: () => void): Request<...", + "Request.protocol": "string", + "Request.push": "(chunk: any, encoding?: BufferEncoding) => boolean", + "Request.query": "ReqQuery", + "Request.range": "(size: number, options?: Options) => Ranges | Result", + "Request.rawHeaders": "string[]", + "Request.rawListeners": "(eventName: string | symbol) => Function[]", + "Request.rawTrailers": "string[]", + "Request.read": "(size?: number) => any", + "Request.readable": "boolean", + "Request.readableAborted": "boolean", + "Request.readableDidRead": "boolean", + "Request.readableEncoding": "BufferEncoding", + "Request.readableEnded": "boolean", + "Request.readableFlowing": "boolean", + "Request.readableHighWaterMark": "number", + "Request.readableLength": "number", + "Request.readableObjectMode": "boolean", + "Request.reduce": "{ (fn: (previous: any, data: any, options?: Pick) => T, initial?: undefined, options?: Pick): Promise<...>; (fn: (previous: T, data: any, options?: Pick<...>) => T, initial: T, options?: Pick<...>): Promise<...>; }", + "Request.removeAllListeners": "(eventName?: string | symbol) => Request", + "Request.removeListener": "{ (event: \"close\", listener: () => void): Request; (event: \"data\", listener: (chunk: any) => void): Request<...>; (event: \"end\", listener: () => void): Request<...>; (event: \"error\", listener: (err: Error) => void): Request<...>; (event: \"pause\", listener: () => void): Request<...", + "Request.res": "?Response", + "Request.resume": "() => Request", + "Request.route": "any", + "Request.secure": "boolean", + "Request.setEncoding": "(encoding: BufferEncoding) => Request", + "Request.setMaxListeners": "(n: number) => Request", + "Request.setTimeout": "(msecs: number, callback?: () => void) => Request", + "Request.signedCookies": "any", + "Request.socket": "Socket", + "Request.some": "(fn: (data: any, options?: Pick) => boolean | Promise, options?: ArrayOptions) => Promise", + "Request.stale": "boolean", + "Request.statusCode": "?number", + "Request.statusMessage": "?string", + "Request.subdomains": "string[]", + "Request.take": "(limit: number, options?: Pick) => Readable", + "Request.toArray": "(options?: Pick) => Promise", + "Request.trailers": "Dict", + "Request.trailersDistinct": "Dict", + "Request.unpipe": "(destination?: WritableStream) => Request", + "Request.unshift": "(chunk: any, encoding?: BufferEncoding) => void", + "Request.url": "string", + "Request.wrap": "(stream: ReadableStream) => Request", + "Request.xhr": "boolean", + "RequestHandler": "interface: RequestHandler", + "RequestParamHandler": "interface: RequestParamHandler", + "Response": "interface: Response", + "Response._construct": "?(callback: (error?: Error) => void) => void", + "Response._destroy": "(error: Error, callback: (error?: Error) => void) => void", + "Response._final": "(callback: (error?: Error) => void) => void", + "Response._write": "(chunk: any, encoding: BufferEncoding, callback: (error?: Error) => void) => void", + "Response._writev": "?(chunks: { chunk: any; encoding: BufferEncoding; }[], callback: (error?: Error) => void) => void", + "Response.addListener": "{ (event: \"close\", listener: () => void): Response; (event: \"drain\", listener: () => void): Response; (event: \"error\", listener: (err: Error) => void): Response<...>; (event: \"finish\", listener: () => void): Response<...>; (event: \"pipe\", listener: (src: Readable) => void): Response...", + "Response.addTrailers": "(headers: OutgoingHttpHeaders | readonly [string, string][]) => void", + "Response.app": "Application>", + "Response.append": "(field: string, value?: string | string[]) => Response", + "Response.appendHeader": "(name: string, value: string | readonly string[]) => Response", + "Response.assignSocket": "(socket: Socket) => void", + "Response.attachment": "(filename?: string) => Response", + "Response.charset": "string", + "Response.chunkedEncoding": "boolean", + "Response.clearCookie": "(name: string, options?: CookieOptions) => Response", + "Response.closed": "boolean", + "Response.compose": "(stream: ComposeFnParam | T | Iterable | AsyncIterable, options?: { signal: AbortSignal; }) => T", + "Response.connection": "Socket", + "Response.contentType": "(type: string) => Response", + "Response.cookie": "{ (name: string, val: string, options: CookieOptions): Response; (name: string, val: any, options: CookieOptions): Response<...>; (name: string, val: any): Response<...>; }", + "Response.cork": "() => void", + "Response.destroy": "(error?: Error) => Response", + "Response.destroyed": "boolean", + "Response.detachSocket": "(socket: Socket) => void", + "Response.download": "{ (path: string, fn?: Errback): void; (path: string, filename: string, fn?: Errback): void; (path: string, filename: string, options: DownloadOptions, fn?: Errback): void; }", + "Response.emit": "{ (event: \"close\"): boolean; (event: \"drain\"): boolean; (event: \"error\", err: Error): boolean; (event: \"finish\"): boolean; (event: \"pipe\", src: Readable): boolean; (event: \"unpipe\", src: Readable): boolean; (event: string | symbol, ...args: any[]): boolean; }", + "Response.end": "{ (cb?: () => void): Response; (chunk: any, cb?: () => void): Response; (chunk: any, encoding: BufferEncoding, cb?: () => void): Response<...>; }", + "Response.errored": "Error", + "Response.eventNames": "() => (string | symbol)[]", + "Response.finished": "boolean", + "Response.flushHeaders": "() => void", + "Response.format": "(obj: any) => Response", + "Response.get": "(field: string) => string", + "Response.getHeader": "(name: string) => string | number | string[]", + "Response.getHeaderNames": "() => string[]", + "Response.getHeaders": "() => OutgoingHttpHeaders", + "Response.getMaxListeners": "() => number", + "Response.hasHeader": "(name: string) => boolean", + "Response.header": "{ (field: any): Response; (field: string, value?: string | string[]): Response; }", + "Response.headersSent": "boolean", + "Response.json": "Send>", + "Response.jsonp": "Send>", + "Response.links": "(links: any) => Response", + "Response.listenerCount": "(eventName: string | symbol, listener?: Function) => number", + "Response.listeners": "(eventName: string | symbol) => Function[]", + "Response.locals": "Locals & Locals", + "Response.location": "(url: string) => Response", + "Response.off": "(eventName: string | symbol, listener: (...args: any[]) => void) => Response", + "Response.on": "{ (event: \"close\", listener: () => void): Response; (event: \"drain\", listener: () => void): Response; (event: \"error\", listener: (err: Error) => void): Response<...>; (event: \"finish\", listener: () => void): Response<...>; (event: \"pipe\", listener: (src: Readable) => void): Response...", + "Response.once": "{ (event: \"close\", listener: () => void): Response; (event: \"drain\", listener: () => void): Response; (event: \"error\", listener: (err: Error) => void): Response<...>; (event: \"finish\", listener: () => void): Response<...>; (event: \"pipe\", listener: (src: Readable) => void): Response...", + "Response.pipe": "(destination: T, options?: { end?: boolean; }) => T", + "Response.prependListener": "{ (event: \"close\", listener: () => void): Response; (event: \"drain\", listener: () => void): Response; (event: \"error\", listener: (err: Error) => void): Response<...>; (event: \"finish\", listener: () => void): Response<...>; (event: \"pipe\", listener: (src: Readable) => void): Response...", + "Response.prependOnceListener": "{ (event: \"close\", listener: () => void): Response; (event: \"drain\", listener: () => void): Response; (event: \"error\", listener: (err: Error) => void): Response<...>; (event: \"finish\", listener: () => void): Response<...>; (event: \"pipe\", listener: (src: Readable) => void): Response...", + "Response.rawListeners": "(eventName: string | symbol) => Function[]", + "Response.redirect": "{ (url: string): void; (status: number, url: string): void; (url: string, status: number): void; }", + "Response.removeAllListeners": "(eventName?: string | symbol) => Response", + "Response.removeHeader": "(name: string) => void", + "Response.removeListener": "{ (event: \"close\", listener: () => void): Response; (event: \"drain\", listener: () => void): Response; (event: \"error\", listener: (err: Error) => void): Response<...>; (event: \"finish\", listener: () => void): Response<...>; (event: \"pipe\", listener: (src: Readable) => void): Response...", + "Response.render": "{ (view: string, options?: object, callback?: (err: Error, html: string) => void): void; (view: string, callback?: (err: Error, html: string) => void): void; }", + "Response.req": "Request>", + "Response.send": "Send>", + "Response.sendDate": "boolean", + "Response.sendFile": "{ (path: string, fn?: Errback): void; (path: string, options: SendFileOptions, fn?: Errback): void; }", + "Response.sendStatus": "(code: number) => Response", + "Response.sendfile": "{ (path: string): void; (path: string, options: SendFileOptions): void; (path: string, fn: Errback): void; (path: string, options: SendFileOptions, fn: Errback): void; }", + "Response.set": "{ (field: any): Response; (field: string, value?: string | string[]): Response; }", + "Response.setDefaultEncoding": "(encoding: BufferEncoding) => Response", + "Response.setHeader": "(name: string, value: string | number | readonly string[]) => Response", + "Response.setHeaders": "(headers: Headers | Map) => Response", + "Response.setMaxListeners": "(n: number) => Response", + "Response.setTimeout": "(msecs: number, callback?: () => void) => Response", + "Response.shouldKeepAlive": "boolean", + "Response.socket": "Socket", + "Response.status": "(code: number) => Response", + "Response.statusCode": "number", + "Response.statusMessage": "string", + "Response.strictContentLength": "boolean", + "Response.type": "(type: string) => Response", + "Response.uncork": "() => void", + "Response.useChunkedEncodingByDefault": "boolean", + "Response.vary": "(field: string) => Response", + "Response.writable": "boolean", + "Response.writableAborted": "boolean", + "Response.writableCorked": "number", + "Response.writableEnded": "boolean", + "Response.writableFinished": "boolean", + "Response.writableHighWaterMark": "number", + "Response.writableLength": "number", + "Response.writableNeedDrain": "boolean", + "Response.writableObjectMode": "boolean", + "Response.write": "{ (chunk: any, callback?: (error: Error) => void): boolean; (chunk: any, encoding: BufferEncoding, callback?: (error: Error) => void): boolean; }", + "Response.writeContinue": "(callback?: () => void) => void", + "Response.writeEarlyHints": "(hints: Record, callback?: () => void) => void", + "Response.writeHead": "{ (statusCode: number, statusMessage?: string, headers?: OutgoingHttpHeaders | OutgoingHttpHeader[]): Response; (statusCode: number, headers?: OutgoingHttpHeaders | OutgoingHttpHeader[]): Response<...>; }", + "Response.writeProcessing": "(callback?: () => void) => void", + "Router": "function: (options?: RouterOptions): Router", + "RouterOptions": "interface: RouterOptions", + "RouterOptions.caseSensitive": "?boolean", + "RouterOptions.mergeParams": "?boolean", + "RouterOptions.strict": "?boolean", + "Send": "interface: Send", + "application": "function: (req: Request> | IncomingMessage, res: Response, number> | ServerResponse<...>): any", + "application#1": "function: (req: Request>, res: Response, number>, next: NextFunction): void", + "json": "function: (options?: OptionsJson): NextHandleFunction", + "module": "function: (): Express", + "query": "function: (options: IParseOptions | { (str: string, options?: IParseOptions & { decoder?: undefined; }): ParsedQs; (str: string | Record<...>, options?: IParseOptions<...>): { ...; }; }): Handler", + "raw": "function: (options?: Options): NextHandleFunction", + "request": "const: Request>", + "response": "const: Response>", + "static": "function: (root: string, options?: ServeStaticOptions>>): RequestHandler>>", + "text": "function: (options?: OptionsText): NextHandleFunction", + "urlencoded": "function: (options?: OptionsUrlencoded): NextHandleFunction" + } + }, + "express-rate-limit": { + "version": "8.7.0", + "surface": { + "AugmentedRequest": "type: AugmentedRequest", + "Client": "type: Client", + "ClientRateLimitInfo": "type: ClientRateLimitInfo", + "DAY": "const: number", + "DraftHeadersVersion": "type: \"draft-6\" | \"draft-7\" | \"draft-8\"", + "EnabledValidations": "type: EnabledValidations", + "HOUR": "const: number", + "IncrementCallback": "type: IncrementCallback", + "IncrementResponse": "type: ClientRateLimitInfo", + "LegacyStore": "type: LegacyStore", + "Logger": "type: Logger", + "LoggerFn": "type: LoggerFn", + "MINUTE": "const: number", + "MemoryStore": "class: MemoryStore", + "MemoryStore.clearExpired": "any", + "MemoryStore.current": "Map", + "MemoryStore.decrement": "(key: string) => Promise", + "MemoryStore.get": "(key: string) => Promise", + "MemoryStore.getClient": "any", + "MemoryStore.increment": "(key: string) => Promise", + "MemoryStore.init": "(options: Options) => void", + "MemoryStore.interval": "?Timeout", + "MemoryStore.localKeys": "boolean", + "MemoryStore.previous": "Map", + "MemoryStore.resetAll": "() => Promise", + "MemoryStore.resetClient": "any", + "MemoryStore.resetKey": "(key: string) => Promise", + "MemoryStore.shutdown": "() => void", + "MemoryStore.validations": "?any", + "MemoryStore.windowMs": "number", + "Options": "type: Options", + "RateLimitExceededEventHandler": "type: RateLimitExceededEventHandler", + "RateLimitInfo": "type: RateLimitInfo", + "RateLimitReachedEventHandler": "type: RateLimitReachedEventHandler", + "RateLimitRequestHandler": "type: RateLimitRequestHandler", + "SECOND": "const: number", + "Store": "type: Store", + "Validations": "type: { enabled: { [key: string]: boolean; }; disable(): void; ip(ip: string): void; trustProxy(request: Request>): void; ... 16 more ...; windowMs(windowMs: number): void; }", + "ValueDeterminingMiddleware": "type: ValueDeterminingMiddleware", + "ipKeyGenerator": "function: (ip: string, ipv6Subnet?: number | false): string", + "rateLimit": "function: (passedOptions?: Partial): RateLimitRequestHandler" + } + }, + "fuse.js": { + "version": "7.5.0", + "surface": { + "Expression": "type: Expression", + "FuseGetFunction": "type: FuseGetFunction", + "FuseIndex": "class: FuseIndex", + "FuseIndex._createObjectRecord": "(doc: any, docIndex: number) => IndexRecord", + "FuseIndex._createStringRecord": "(doc: string, docIndex: number) => IndexRecord", + "FuseIndex._keysMap": "Record", + "FuseIndex.add": "(doc: T, docIndex: number) => IndexRecord", + "FuseIndex.create": "() => void", + "FuseIndex.docs": "readonly T[]", + "FuseIndex.getFn": "GetFunction", + "FuseIndex.getValueForItemAtKeyId": "(item: any, keyId: string) => any", + "FuseIndex.isCreated": "boolean", + "FuseIndex.keys": "KeyObject[]", + "FuseIndex.norm": "NormInterface", + "FuseIndex.records": "IndexRecord[]", + "FuseIndex.removeAll": "(indices: number[]) => void", + "FuseIndex.removeAt": "(idx: number) => void", + "FuseIndex.setIndexRecords": "(records?: IndexRecord[]) => void", + "FuseIndex.setKeys": "(keys?: KeyObject[]) => void", + "FuseIndex.setSources": "(docs?: readonly T[]) => void", + "FuseIndex.size": "() => number", + "FuseIndex.toJSON": "() => { keys: readonly Omit[]; records: IndexRecord[]; }", + "FuseIndexObjectRecord": "interface: FuseIndexObjectRecord", + "FuseIndexObjectRecord.$": "RecordEntry", + "FuseIndexObjectRecord.i": "number", + "FuseIndexOptions": "interface: FuseIndexOptions", + "FuseIndexOptions.fieldNormWeight": "?number", + "FuseIndexOptions.getFn": "?FuseGetFunction", + "FuseIndexRecords": "type: FuseIndexRecords", + "FuseIndexStringRecord": "interface: FuseIndexStringRecord", + "FuseIndexStringRecord.i": "number", + "FuseIndexStringRecord.n": "number", + "FuseIndexStringRecord.v": "string", + "FuseOptionKey": "type: FuseOptionKey", + "FuseOptionKeyObject": "interface: FuseOptionKeyObject", + "FuseOptionKeyObject.getFn": "?(obj: T) => string | readonly string[]", + "FuseOptionKeyObject.name": "string | string[]", + "FuseOptionKeyObject.weight": "?number", + "FuseResult": "interface: FuseResult", + "FuseResult.item": "T", + "FuseResult.matches": "?readonly FuseResultMatch[]", + "FuseResult.refIndex": "number", + "FuseResult.score": "?number", + "FuseResultMatch": "interface: FuseResultMatch", + "FuseResultMatch.indices": "readonly RangeTuple[]", + "FuseResultMatch.key": "?string", + "FuseResultMatch.refIndex": "?number", + "FuseResultMatch.value": "?string", + "FuseSearchOptions": "interface: FuseSearchOptions", + "FuseSearchOptions.limit": "?number", + "FuseSortFunction": "type: FuseSortFunction", + "FuseSortFunctionArg": "type: FuseSortFunctionArg", + "FuseSortFunctionItem": "type: FuseSortFunctionItem", + "FuseSortFunctionMatch": "type: FuseSortFunctionMatch", + "FuseSortFunctionMatchList": "type: FuseSortFunctionMatchList", + "FuseTokenizeFunction": "type: FuseTokenizeFunction", + "IFuseOptions": "interface: IFuseOptions", + "IFuseOptions.distance": "?number", + "IFuseOptions.fieldNormWeight": "?number", + "IFuseOptions.findAllMatches": "?boolean", + "IFuseOptions.getFn": "?FuseGetFunction", + "IFuseOptions.ignoreDiacritics": "?boolean", + "IFuseOptions.ignoreFieldNorm": "?boolean", + "IFuseOptions.ignoreLocation": "?boolean", + "IFuseOptions.includeMatches": "?boolean", + "IFuseOptions.includeScore": "?boolean", + "IFuseOptions.isCaseSensitive": "?boolean", + "IFuseOptions.keys": "?FuseOptionKey[]", + "IFuseOptions.location": "?number", + "IFuseOptions.minMatchCharLength": "?number", + "IFuseOptions.shouldSort": "?boolean", + "IFuseOptions.sortFn": "?FuseSortFunction", + "IFuseOptions.threshold": "?number", + "IFuseOptions.tokenMatch": "?\"all\" | \"any\"", + "IFuseOptions.tokenize": "?RegExp | FuseTokenizeFunction", + "IFuseOptions.useExtendedSearch": "?boolean", + "IFuseOptions.useTokenSearch": "?boolean", + "RangeTuple": "type: RangeTuple", + "RecordEntry": "type: RecordEntry", + "RecordEntryArrayItem": "type: RecordEntryArrayItem", + "RecordEntryObject": "interface: RecordEntryObject", + "RecordEntryObject.n": "number", + "RecordEntryObject.v": "string", + "SearchResult": "interface: SearchResult", + "SearchResult.hasInverse": "?boolean", + "SearchResult.indices": "?readonly RangeTuple[]", + "SearchResult.isMatch": "boolean", + "SearchResult.matchedMask": "?number", + "SearchResult.matchedTerms": "?Set", + "SearchResult.score": "number", + "SearchResult.termCount": "?number", + "config": "const: Required>", + "createIndex": "function: (keys: FuseOptionKey[], docs: readonly T[], { getFn, fieldNormWeight }?: FuseIndexOptions): FuseIndex", + "match": "function: (pattern: string, text: string, options?: IFuseOptions): SearchResult", + "module": "class: Fuse", + "module._coversAllTokens": "(matches: MatchScore[]) => boolean", + "module._docs": "T[]", + "module._findMatches": "({ key, value, searcher }: { key: KeyObject; value: SubRecord | SubRecord[]; searcher: Searcher; }) => MatchScore[]", + "module._getSearcher": "(query: string) => Searcher", + "module._invalidateSearcherCache": "() => void", + "module._invertedIndex": "InvertedIndexData", + "module._keyStore": "KeyStore", + "module._lastQuery": "string", + "module._lastSearcher": "Searcher", + "module._myIndex": "FuseIndex", + "module._normalizedKeys": "() => KeyObject[]", + "module._searchLogical": "(query: Expression) => InternalResult[]", + "module._searchObjectList": "(query: string, { heap, ignoreFieldNorm }?: HeapSearchOptions) => InternalResult[]", + "module._searchStringList": "(query: string, { heap, ignoreFieldNorm }?: HeapSearchOptions) => InternalResult[]", + "module.add": "(doc: T) => void", + "module.getIndex": "() => FuseIndex", + "module.options": "Required>", + "module.remove": "(predicate?: (doc: T, idx: number) => boolean) => T[]", + "module.removeAt": "(idx: number) => T", + "module.search": "(query: Expression, options?: FuseSearchOptions) => FuseResult[]", + "module.setCollection": "(docs: readonly T[], index?: FuseIndex) => void", + "parseIndex": "function: (data: { keys: readonly KeyObject[]; records: IndexRecord[]; }, { getFn, fieldNormWeight }?: FuseIndexOptions): FuseIndex", + "parseQuery": "function: (query: Expression, options: any, { auto }?: { auto?: boolean; }): ParsedNode", + "prototype": "value: Fuse", + "use": "function: (...plugins: any[]): void", + "version": "const: string" + } + }, + "graphql": { + "version": "16.14.2", + "surface": { + "ASTKindToNode": "type: ASTKindToNode", + "ASTNode": "type: ASTNode", + "ASTVisitFn": "type: ASTVisitFn", + "ASTVisitor": "type: ASTVisitor", + "ASTVisitorKeyMap": "type: ASTVisitorKeyMap", + "ArgumentCoordinateNode": "interface: ArgumentCoordinateNode", + "ArgumentCoordinateNode.argumentName": "NameNode", + "ArgumentCoordinateNode.fieldName": "NameNode", + "ArgumentCoordinateNode.kind": "Kind.ARGUMENT_COORDINATE", + "ArgumentCoordinateNode.loc": "?Location", + "ArgumentCoordinateNode.name": "NameNode", + "ArgumentNode": "interface: ArgumentNode", + "ArgumentNode.kind": "Kind.ARGUMENT", + "ArgumentNode.loc": "?Location", + "ArgumentNode.name": "NameNode", + "ArgumentNode.value": "ValueNode", + "BREAK": "const: unknown", + "BooleanValueNode": "interface: BooleanValueNode", + "BooleanValueNode.kind": "Kind.BOOLEAN", + "BooleanValueNode.loc": "?Location", + "BooleanValueNode.value": "boolean", + "BreakingChange": "interface: BreakingChange", + "BreakingChange.description": "string", + "BreakingChange.type": "BreakingChangeType", + "BreakingChangeType": "enum: BreakingChangeType", + "BreakingChangeType.ARG_CHANGED_KIND": "enum-member", + "BreakingChangeType.ARG_REMOVED": "enum-member", + "BreakingChangeType.DIRECTIVE_ARG_REMOVED": "enum-member", + "BreakingChangeType.DIRECTIVE_LOCATION_REMOVED": "enum-member", + "BreakingChangeType.DIRECTIVE_REMOVED": "enum-member", + "BreakingChangeType.DIRECTIVE_REPEATABLE_REMOVED": "enum-member", + "BreakingChangeType.FIELD_CHANGED_KIND": "enum-member", + "BreakingChangeType.FIELD_REMOVED": "enum-member", + "BreakingChangeType.IMPLEMENTED_INTERFACE_REMOVED": "enum-member", + "BreakingChangeType.REQUIRED_ARG_ADDED": "enum-member", + "BreakingChangeType.REQUIRED_DIRECTIVE_ARG_ADDED": "enum-member", + "BreakingChangeType.REQUIRED_INPUT_FIELD_ADDED": "enum-member", + "BreakingChangeType.TYPE_CHANGED_KIND": "enum-member", + "BreakingChangeType.TYPE_REMOVED": "enum-member", + "BreakingChangeType.TYPE_REMOVED_FROM_UNION": "enum-member", + "BreakingChangeType.VALUE_REMOVED_FROM_ENUM": "enum-member", + "BuildSchemaOptions": "interface: BuildSchemaOptions", + "BuildSchemaOptions.assumeValid": "?boolean", + "BuildSchemaOptions.assumeValidSDL": "?boolean", + "ConstArgumentNode": "interface: ConstArgumentNode", + "ConstArgumentNode.kind": "Kind.ARGUMENT", + "ConstArgumentNode.loc": "?Location", + "ConstArgumentNode.name": "NameNode", + "ConstArgumentNode.value": "ConstValueNode", + "ConstDirectiveNode": "interface: ConstDirectiveNode", + "ConstDirectiveNode.arguments": "?readonly ConstArgumentNode[]", + "ConstDirectiveNode.kind": "Kind.DIRECTIVE", + "ConstDirectiveNode.loc": "?Location", + "ConstDirectiveNode.name": "NameNode", + "ConstListValueNode": "interface: ConstListValueNode", + "ConstListValueNode.kind": "Kind.LIST", + "ConstListValueNode.loc": "?Location", + "ConstListValueNode.values": "readonly ConstValueNode[]", + "ConstObjectFieldNode": "interface: ConstObjectFieldNode", + "ConstObjectFieldNode.kind": "Kind.OBJECT_FIELD", + "ConstObjectFieldNode.loc": "?Location", + "ConstObjectFieldNode.name": "NameNode", + "ConstObjectFieldNode.value": "ConstValueNode", + "ConstObjectValueNode": "interface: ConstObjectValueNode", + "ConstObjectValueNode.fields": "readonly ConstObjectFieldNode[]", + "ConstObjectValueNode.kind": "Kind.OBJECT", + "ConstObjectValueNode.loc": "?Location", + "ConstValueNode": "type: ConstValueNode", + "DEFAULT_DEPRECATION_REASON": "const: \"No longer supported\"", + "DangerousChange": "interface: DangerousChange", + "DangerousChange.description": "string", + "DangerousChange.type": "DangerousChangeType", + "DangerousChangeType": "enum: DangerousChangeType", + "DangerousChangeType.ARG_DEFAULT_VALUE_CHANGE": "enum-member", + "DangerousChangeType.IMPLEMENTED_INTERFACE_ADDED": "enum-member", + "DangerousChangeType.OPTIONAL_ARG_ADDED": "enum-member", + "DangerousChangeType.OPTIONAL_INPUT_FIELD_ADDED": "enum-member", + "DangerousChangeType.TYPE_ADDED_TO_UNION": "enum-member", + "DangerousChangeType.VALUE_ADDED_TO_ENUM": "enum-member", + "DefinitionNode": "type: DefinitionNode", + "DirectiveArgumentCoordinateNode": "interface: DirectiveArgumentCoordinateNode", + "DirectiveArgumentCoordinateNode.argumentName": "NameNode", + "DirectiveArgumentCoordinateNode.kind": "Kind.DIRECTIVE_ARGUMENT_COORDINATE", + "DirectiveArgumentCoordinateNode.loc": "?Location", + "DirectiveArgumentCoordinateNode.name": "NameNode", + "DirectiveCoordinateNode": "interface: DirectiveCoordinateNode", + "DirectiveCoordinateNode.kind": "Kind.DIRECTIVE_COORDINATE", + "DirectiveCoordinateNode.loc": "?Location", + "DirectiveCoordinateNode.name": "NameNode", + "DirectiveDefinitionNode": "interface: DirectiveDefinitionNode", + "DirectiveDefinitionNode.arguments": "?readonly InputValueDefinitionNode[]", + "DirectiveDefinitionNode.description": "?StringValueNode", + "DirectiveDefinitionNode.directives": "?readonly ConstDirectiveNode[]", + "DirectiveDefinitionNode.kind": "Kind.DIRECTIVE_DEFINITION", + "DirectiveDefinitionNode.loc": "?Location", + "DirectiveDefinitionNode.locations": "readonly NameNode[]", + "DirectiveDefinitionNode.name": "NameNode", + "DirectiveDefinitionNode.repeatable": "boolean", + "DirectiveExtensionNode": "interface: DirectiveExtensionNode", + "DirectiveExtensionNode.directives": "?readonly ConstDirectiveNode[]", + "DirectiveExtensionNode.kind": "Kind.DIRECTIVE_EXTENSION", + "DirectiveExtensionNode.loc": "?Location", + "DirectiveExtensionNode.name": "NameNode", + "DirectiveLocation": "enum: DirectiveLocation", + "DirectiveLocation.ARGUMENT_DEFINITION": "enum-member", + "DirectiveLocation.DIRECTIVE_DEFINITION": "enum-member", + "DirectiveLocation.ENUM": "enum-member", + "DirectiveLocation.ENUM_VALUE": "enum-member", + "DirectiveLocation.FIELD": "enum-member", + "DirectiveLocation.FIELD_DEFINITION": "enum-member", + "DirectiveLocation.FRAGMENT_DEFINITION": "enum-member", + "DirectiveLocation.FRAGMENT_SPREAD": "enum-member", + "DirectiveLocation.INLINE_FRAGMENT": "enum-member", + "DirectiveLocation.INPUT_FIELD_DEFINITION": "enum-member", + "DirectiveLocation.INPUT_OBJECT": "enum-member", + "DirectiveLocation.INTERFACE": "enum-member", + "DirectiveLocation.MUTATION": "enum-member", + "DirectiveLocation.OBJECT": "enum-member", + "DirectiveLocation.QUERY": "enum-member", + "DirectiveLocation.SCALAR": "enum-member", + "DirectiveLocation.SCHEMA": "enum-member", + "DirectiveLocation.SUBSCRIPTION": "enum-member", + "DirectiveLocation.UNION": "enum-member", + "DirectiveLocation.VARIABLE_DEFINITION": "enum-member", + "DirectiveLocationEnum": "type: typeof DirectiveLocation", + "DirectiveNode": "interface: DirectiveNode", + "DirectiveNode.arguments": "?readonly ArgumentNode[]", + "DirectiveNode.kind": "Kind.DIRECTIVE", + "DirectiveNode.loc": "?Location", + "DirectiveNode.name": "NameNode", + "DocumentNode": "interface: DocumentNode", + "DocumentNode.definitions": "readonly DefinitionNode[]", + "DocumentNode.kind": "Kind.DOCUMENT", + "DocumentNode.loc": "?Location", + "DocumentNode.tokenCount": "?number", + "EnumTypeDefinitionNode": "interface: EnumTypeDefinitionNode", + "EnumTypeDefinitionNode.description": "?StringValueNode", + "EnumTypeDefinitionNode.directives": "?readonly ConstDirectiveNode[]", + "EnumTypeDefinitionNode.kind": "Kind.ENUM_TYPE_DEFINITION", + "EnumTypeDefinitionNode.loc": "?Location", + "EnumTypeDefinitionNode.name": "NameNode", + "EnumTypeDefinitionNode.values": "?readonly EnumValueDefinitionNode[]", + "EnumTypeExtensionNode": "interface: EnumTypeExtensionNode", + "EnumTypeExtensionNode.directives": "?readonly ConstDirectiveNode[]", + "EnumTypeExtensionNode.kind": "Kind.ENUM_TYPE_EXTENSION", + "EnumTypeExtensionNode.loc": "?Location", + "EnumTypeExtensionNode.name": "NameNode", + "EnumTypeExtensionNode.values": "?readonly EnumValueDefinitionNode[]", + "EnumValueDefinitionNode": "interface: EnumValueDefinitionNode", + "EnumValueDefinitionNode.description": "?StringValueNode", + "EnumValueDefinitionNode.directives": "?readonly ConstDirectiveNode[]", + "EnumValueDefinitionNode.kind": "Kind.ENUM_VALUE_DEFINITION", + "EnumValueDefinitionNode.loc": "?Location", + "EnumValueDefinitionNode.name": "NameNode", + "EnumValueNode": "interface: EnumValueNode", + "EnumValueNode.kind": "Kind.ENUM", + "EnumValueNode.loc": "?Location", + "EnumValueNode.value": "string", + "ExecutableDefinitionNode": "type: ExecutableDefinitionNode", + "ExecutableDefinitionsRule": "function: (context: ASTValidationContext): ASTVisitor", + "ExecutionArgs": "interface: ExecutionArgs", + "ExecutionArgs.contextValue": "?unknown", + "ExecutionArgs.document": "DocumentNode", + "ExecutionArgs.fieldResolver": "?GraphQLFieldResolver", + "ExecutionArgs.operationName": "?string", + "ExecutionArgs.options": "?{ maxCoercionErrors?: number; }", + "ExecutionArgs.rootValue": "?unknown", + "ExecutionArgs.schema": "GraphQLSchema", + "ExecutionArgs.subscribeFieldResolver": "?GraphQLFieldResolver", + "ExecutionArgs.typeResolver": "?GraphQLTypeResolver", + "ExecutionArgs.variableValues": "?{ readonly [variable: string]: unknown; }", + "ExecutionResult": "interface: ExecutionResult", + "ExecutionResult.data": "?TData", + "ExecutionResult.errors": "?readonly GraphQLError[]", + "ExecutionResult.extensions": "?TExtensions", + "FieldDefinitionNode": "interface: FieldDefinitionNode", + "FieldDefinitionNode.arguments": "?readonly InputValueDefinitionNode[]", + "FieldDefinitionNode.description": "?StringValueNode", + "FieldDefinitionNode.directives": "?readonly ConstDirectiveNode[]", + "FieldDefinitionNode.kind": "Kind.FIELD_DEFINITION", + "FieldDefinitionNode.loc": "?Location", + "FieldDefinitionNode.name": "NameNode", + "FieldDefinitionNode.type": "TypeNode", + "FieldNode": "interface: FieldNode", + "FieldNode.alias": "?NameNode", + "FieldNode.arguments": "?readonly ArgumentNode[]", + "FieldNode.directives": "?readonly DirectiveNode[]", + "FieldNode.kind": "Kind.FIELD", + "FieldNode.loc": "?Location", + "FieldNode.name": "NameNode", + "FieldNode.selectionSet": "?SelectionSetNode", + "FieldsOnCorrectTypeRule": "function: (context: ValidationContext): ASTVisitor", + "FloatValueNode": "interface: FloatValueNode", + "FloatValueNode.kind": "Kind.FLOAT", + "FloatValueNode.loc": "?Location", + "FloatValueNode.value": "string", + "FormattedExecutionResult": "interface: FormattedExecutionResult", + "FormattedExecutionResult.data": "?TData", + "FormattedExecutionResult.errors": "?readonly GraphQLFormattedError[]", + "FormattedExecutionResult.extensions": "?TExtensions", + "FragmentDefinitionNode": "interface: FragmentDefinitionNode", + "FragmentDefinitionNode.description": "?StringValueNode", + "FragmentDefinitionNode.directives": "?readonly DirectiveNode[]", + "FragmentDefinitionNode.kind": "Kind.FRAGMENT_DEFINITION", + "FragmentDefinitionNode.loc": "?Location", + "FragmentDefinitionNode.name": "NameNode", + "FragmentDefinitionNode.selectionSet": "SelectionSetNode", + "FragmentDefinitionNode.typeCondition": "NamedTypeNode", + "FragmentDefinitionNode.variableDefinitions": "?readonly VariableDefinitionNode[]", + "FragmentSpreadNode": "interface: FragmentSpreadNode", + "FragmentSpreadNode.directives": "?readonly DirectiveNode[]", + "FragmentSpreadNode.kind": "Kind.FRAGMENT_SPREAD", + "FragmentSpreadNode.loc": "?Location", + "FragmentSpreadNode.name": "NameNode", + "FragmentsOnCompositeTypesRule": "function: (context: ValidationContext): ASTVisitor", + "GRAPHQL_MAX_INT": "const: 2147483647", + "GRAPHQL_MIN_INT": "const: -2147483648", + "GraphQLAbstractType": "type: GraphQLAbstractType", + "GraphQLArgs": "interface: GraphQLArgs", + "GraphQLArgs.contextValue": "?unknown", + "GraphQLArgs.fieldResolver": "?GraphQLFieldResolver", + "GraphQLArgs.operationName": "?string", + "GraphQLArgs.rootValue": "?unknown", + "GraphQLArgs.schema": "GraphQLSchema", + "GraphQLArgs.source": "string | Source", + "GraphQLArgs.typeResolver": "?GraphQLTypeResolver", + "GraphQLArgs.variableValues": "?{ readonly [variable: string]: unknown; }", + "GraphQLArgument": "interface: GraphQLArgument", + "GraphQLArgument.astNode": "InputValueDefinitionNode", + "GraphQLArgument.defaultValue": "unknown", + "GraphQLArgument.deprecationReason": "string", + "GraphQLArgument.description": "string", + "GraphQLArgument.extensions": "Readonly", + "GraphQLArgument.name": "string", + "GraphQLArgument.type": "GraphQLInputType", + "GraphQLArgumentConfig": "interface: GraphQLArgumentConfig", + "GraphQLArgumentConfig.astNode": "?InputValueDefinitionNode", + "GraphQLArgumentConfig.defaultValue": "?unknown", + "GraphQLArgumentConfig.deprecationReason": "?string", + "GraphQLArgumentConfig.description": "?string", + "GraphQLArgumentConfig.extensions": "?Readonly", + "GraphQLArgumentConfig.type": "GraphQLInputType", + "GraphQLArgumentExtensions": "interface: GraphQLArgumentExtensions", + "GraphQLBoolean": "const: GraphQLScalarType", + "GraphQLCompositeType": "type: GraphQLCompositeType", + "GraphQLDeprecatedDirective": "const: GraphQLDirective", + "GraphQLDirective": "class: GraphQLDirective", + "GraphQLDirective.args": "readonly GraphQLArgument[]", + "GraphQLDirective.astNode": "DirectiveDefinitionNode", + "GraphQLDirective.deprecationReason": "string", + "GraphQLDirective.description": "string", + "GraphQLDirective.extensionASTNodes": "readonly DirectiveExtensionNode[]", + "GraphQLDirective.extensions": "Readonly", + "GraphQLDirective.isRepeatable": "boolean", + "GraphQLDirective.locations": "readonly DirectiveLocation[]", + "GraphQLDirective.name": "string", + "GraphQLDirective.toConfig": "() => GraphQLDirectiveNormalizedConfig", + "GraphQLDirective.toJSON": "() => string", + "GraphQLDirective.toString": "() => string", + "GraphQLDirectiveConfig": "interface: GraphQLDirectiveConfig", + "GraphQLDirectiveConfig.args": "?GraphQLFieldConfigArgumentMap", + "GraphQLDirectiveConfig.astNode": "?DirectiveDefinitionNode", + "GraphQLDirectiveConfig.deprecationReason": "?string", + "GraphQLDirectiveConfig.description": "?string", + "GraphQLDirectiveConfig.extensionASTNodes": "?readonly DirectiveExtensionNode[]", + "GraphQLDirectiveConfig.extensions": "?Readonly", + "GraphQLDirectiveConfig.isRepeatable": "?boolean", + "GraphQLDirectiveConfig.locations": "readonly DirectiveLocation[]", + "GraphQLDirectiveConfig.name": "string", + "GraphQLDirectiveExtensions": "interface: GraphQLDirectiveExtensions", + "GraphQLEnumType": "class: GraphQLEnumType", + "GraphQLEnumType._nameLookup": "any", + "GraphQLEnumType._valueLookup": "any", + "GraphQLEnumType._values": "any", + "GraphQLEnumType.astNode": "EnumTypeDefinitionNode", + "GraphQLEnumType.description": "string", + "GraphQLEnumType.extensionASTNodes": "readonly EnumTypeExtensionNode[]", + "GraphQLEnumType.extensions": "Readonly", + "GraphQLEnumType.getValue": "(name: string) => GraphQLEnumValue", + "GraphQLEnumType.getValues": "() => readonly GraphQLEnumValue[]", + "GraphQLEnumType.name": "string", + "GraphQLEnumType.parseLiteral": "(valueNode: ValueNode, _variables: ObjMap) => any", + "GraphQLEnumType.parseValue": "(inputValue: unknown) => any", + "GraphQLEnumType.serialize": "(outputValue: unknown) => string", + "GraphQLEnumType.toConfig": "() => GraphQLEnumTypeNormalizedConfig", + "GraphQLEnumType.toJSON": "() => string", + "GraphQLEnumType.toString": "() => string", + "GraphQLEnumTypeConfig": "interface: GraphQLEnumTypeConfig", + "GraphQLEnumTypeConfig.astNode": "?EnumTypeDefinitionNode", + "GraphQLEnumTypeConfig.description": "?string", + "GraphQLEnumTypeConfig.extensionASTNodes": "?readonly EnumTypeExtensionNode[]", + "GraphQLEnumTypeConfig.extensions": "?Readonly", + "GraphQLEnumTypeConfig.name": "string", + "GraphQLEnumTypeConfig.values": "ThunkObjMap", + "GraphQLEnumTypeExtensions": "interface: GraphQLEnumTypeExtensions", + "GraphQLEnumValue": "interface: GraphQLEnumValue", + "GraphQLEnumValue.astNode": "EnumValueDefinitionNode", + "GraphQLEnumValue.deprecationReason": "string", + "GraphQLEnumValue.description": "string", + "GraphQLEnumValue.extensions": "Readonly", + "GraphQLEnumValue.name": "string", + "GraphQLEnumValue.value": "any", + "GraphQLEnumValueConfig": "interface: GraphQLEnumValueConfig", + "GraphQLEnumValueConfig.astNode": "?EnumValueDefinitionNode", + "GraphQLEnumValueConfig.deprecationReason": "?string", + "GraphQLEnumValueConfig.description": "?string", + "GraphQLEnumValueConfig.extensions": "?Readonly", + "GraphQLEnumValueConfig.value": "?any", + "GraphQLEnumValueConfigMap": "type: GraphQLEnumValueConfigMap", + "GraphQLEnumValueExtensions": "interface: GraphQLEnumValueExtensions", + "GraphQLError": "class: GraphQLError", + "GraphQLError.cause": "?unknown", + "GraphQLError.extensions": "GraphQLErrorExtensions", + "GraphQLError.locations": "readonly SourceLocation[]", + "GraphQLError.message": "string", + "GraphQLError.name": "string", + "GraphQLError.nodes": "readonly ASTNode[]", + "GraphQLError.originalError": "Error", + "GraphQLError.path": "readonly (string | number)[]", + "GraphQLError.positions": "readonly number[]", + "GraphQLError.source": "Source", + "GraphQLError.stack": "?string", + "GraphQLError.toJSON": "() => GraphQLFormattedError", + "GraphQLError.toString": "() => string", + "GraphQLErrorExtensions": "interface: GraphQLErrorExtensions", + "GraphQLErrorOptions": "interface: GraphQLErrorOptions", + "GraphQLErrorOptions.extensions": "?GraphQLErrorExtensions", + "GraphQLErrorOptions.nodes": "?ASTNode | readonly ASTNode[]", + "GraphQLErrorOptions.originalError": "?Error & { readonly extensions?: unknown; }", + "GraphQLErrorOptions.path": "?readonly (string | number)[]", + "GraphQLErrorOptions.positions": "?readonly number[]", + "GraphQLErrorOptions.source": "?Source", + "GraphQLField": "interface: GraphQLField", + "GraphQLField.args": "readonly GraphQLArgument[]", + "GraphQLField.astNode": "FieldDefinitionNode", + "GraphQLField.deprecationReason": "string", + "GraphQLField.description": "string", + "GraphQLField.extensions": "Readonly>", + "GraphQLField.name": "string", + "GraphQLField.resolve": "?GraphQLFieldResolver", + "GraphQLField.subscribe": "?GraphQLFieldResolver", + "GraphQLField.type": "GraphQLOutputType", + "GraphQLFieldConfig": "interface: GraphQLFieldConfig", + "GraphQLFieldConfig.args": "?GraphQLFieldConfigArgumentMap", + "GraphQLFieldConfig.astNode": "?FieldDefinitionNode", + "GraphQLFieldConfig.deprecationReason": "?string", + "GraphQLFieldConfig.description": "?string", + "GraphQLFieldConfig.extensions": "?Readonly>", + "GraphQLFieldConfig.resolve": "?GraphQLFieldResolver", + "GraphQLFieldConfig.subscribe": "?GraphQLFieldResolver", + "GraphQLFieldConfig.type": "GraphQLOutputType", + "GraphQLFieldConfigArgumentMap": "type: GraphQLFieldConfigArgumentMap", + "GraphQLFieldConfigMap": "type: GraphQLFieldConfigMap", + "GraphQLFieldExtensions": "interface: GraphQLFieldExtensions<_TSource, _TContext, _TArgs>", + "GraphQLFieldMap": "type: GraphQLFieldMap", + "GraphQLFieldResolver": "type: GraphQLFieldResolver", + "GraphQLFloat": "const: GraphQLScalarType", + "GraphQLFormattedError": "interface: GraphQLFormattedError", + "GraphQLFormattedError.extensions": "?GraphQLFormattedErrorExtensions", + "GraphQLFormattedError.locations": "?readonly SourceLocation[]", + "GraphQLFormattedError.message": "string", + "GraphQLFormattedError.path": "?readonly (string | number)[]", + "GraphQLFormattedErrorExtensions": "interface: GraphQLFormattedErrorExtensions", + "GraphQLID": "const: GraphQLScalarType", + "GraphQLIncludeDirective": "const: GraphQLDirective", + "GraphQLInputField": "interface: GraphQLInputField", + "GraphQLInputField.astNode": "InputValueDefinitionNode", + "GraphQLInputField.defaultValue": "unknown", + "GraphQLInputField.deprecationReason": "string", + "GraphQLInputField.description": "string", + "GraphQLInputField.extensions": "Readonly", + "GraphQLInputField.name": "string", + "GraphQLInputField.type": "GraphQLInputType", + "GraphQLInputFieldConfig": "interface: GraphQLInputFieldConfig", + "GraphQLInputFieldConfig.astNode": "?InputValueDefinitionNode", + "GraphQLInputFieldConfig.defaultValue": "?unknown", + "GraphQLInputFieldConfig.deprecationReason": "?string", + "GraphQLInputFieldConfig.description": "?string", + "GraphQLInputFieldConfig.extensions": "?Readonly", + "GraphQLInputFieldConfig.type": "GraphQLInputType", + "GraphQLInputFieldConfigMap": "type: GraphQLInputFieldConfigMap", + "GraphQLInputFieldExtensions": "interface: GraphQLInputFieldExtensions", + "GraphQLInputFieldMap": "type: GraphQLInputFieldMap", + "GraphQLInputObjectType": "class: GraphQLInputObjectType", + "GraphQLInputObjectType._fields": "any", + "GraphQLInputObjectType.astNode": "InputObjectTypeDefinitionNode", + "GraphQLInputObjectType.description": "string", + "GraphQLInputObjectType.extensionASTNodes": "readonly InputObjectTypeExtensionNode[]", + "GraphQLInputObjectType.extensions": "Readonly", + "GraphQLInputObjectType.getFields": "() => GraphQLInputFieldMap", + "GraphQLInputObjectType.isOneOf": "boolean", + "GraphQLInputObjectType.name": "string", + "GraphQLInputObjectType.toConfig": "() => GraphQLInputObjectTypeNormalizedConfig", + "GraphQLInputObjectType.toJSON": "() => string", + "GraphQLInputObjectType.toString": "() => string", + "GraphQLInputObjectTypeConfig": "interface: GraphQLInputObjectTypeConfig", + "GraphQLInputObjectTypeConfig.astNode": "?InputObjectTypeDefinitionNode", + "GraphQLInputObjectTypeConfig.description": "?string", + "GraphQLInputObjectTypeConfig.extensionASTNodes": "?readonly InputObjectTypeExtensionNode[]", + "GraphQLInputObjectTypeConfig.extensions": "?Readonly", + "GraphQLInputObjectTypeConfig.fields": "ThunkObjMap", + "GraphQLInputObjectTypeConfig.isOneOf": "?boolean", + "GraphQLInputObjectTypeConfig.name": "string", + "GraphQLInputObjectTypeExtensions": "interface: GraphQLInputObjectTypeExtensions", + "GraphQLInputType": "type: GraphQLInputType", + "GraphQLInt": "const: GraphQLScalarType", + "GraphQLInterfaceType": "class: GraphQLInterfaceType", + "GraphQLInterfaceType._fields": "any", + "GraphQLInterfaceType._interfaces": "any", + "GraphQLInterfaceType.astNode": "InterfaceTypeDefinitionNode", + "GraphQLInterfaceType.description": "string", + "GraphQLInterfaceType.extensionASTNodes": "readonly InterfaceTypeExtensionNode[]", + "GraphQLInterfaceType.extensions": "Readonly", + "GraphQLInterfaceType.getFields": "() => GraphQLFieldMap", + "GraphQLInterfaceType.getInterfaces": "() => readonly GraphQLInterfaceType[]", + "GraphQLInterfaceType.name": "string", + "GraphQLInterfaceType.resolveType": "GraphQLTypeResolver", + "GraphQLInterfaceType.toConfig": "() => GraphQLInterfaceTypeNormalizedConfig", + "GraphQLInterfaceType.toJSON": "() => string", + "GraphQLInterfaceType.toString": "() => string", + "GraphQLInterfaceTypeConfig": "interface: GraphQLInterfaceTypeConfig", + "GraphQLInterfaceTypeConfig.astNode": "?InterfaceTypeDefinitionNode", + "GraphQLInterfaceTypeConfig.description": "?string", + "GraphQLInterfaceTypeConfig.extensionASTNodes": "?readonly InterfaceTypeExtensionNode[]", + "GraphQLInterfaceTypeConfig.extensions": "?Readonly", + "GraphQLInterfaceTypeConfig.fields": "ThunkObjMap>", + "GraphQLInterfaceTypeConfig.interfaces": "?ThunkReadonlyArray", + "GraphQLInterfaceTypeConfig.name": "string", + "GraphQLInterfaceTypeConfig.resolveType": "?GraphQLTypeResolver", + "GraphQLInterfaceTypeExtensions": "interface: GraphQLInterfaceTypeExtensions", + "GraphQLIsTypeOfFn": "type: GraphQLIsTypeOfFn", + "GraphQLLeafType": "type: GraphQLLeafType", + "GraphQLList": "class: GraphQLList", + "GraphQLList.ofType": "T", + "GraphQLList.toJSON": "() => string", + "GraphQLList.toString": "() => string", + "GraphQLNamedInputType": "type: GraphQLNamedInputType", + "GraphQLNamedOutputType": "type: GraphQLNamedOutputType", + "GraphQLNamedType": "type: GraphQLNamedType", + "GraphQLNonNull": "class: GraphQLNonNull", + "GraphQLNonNull.ofType": "T", + "GraphQLNonNull.toJSON": "() => string", + "GraphQLNonNull.toString": "() => string", + "GraphQLNullableType": "type: GraphQLNullableType", + "GraphQLObjectType": "class: GraphQLObjectType", + "GraphQLObjectType._fields": "any", + "GraphQLObjectType._interfaces": "any", + "GraphQLObjectType.astNode": "ObjectTypeDefinitionNode", + "GraphQLObjectType.description": "string", + "GraphQLObjectType.extensionASTNodes": "readonly ObjectTypeExtensionNode[]", + "GraphQLObjectType.extensions": "Readonly>", + "GraphQLObjectType.getFields": "() => GraphQLFieldMap", + "GraphQLObjectType.getInterfaces": "() => readonly GraphQLInterfaceType[]", + "GraphQLObjectType.isTypeOf": "GraphQLIsTypeOfFn", + "GraphQLObjectType.name": "string", + "GraphQLObjectType.toConfig": "() => GraphQLObjectTypeNormalizedConfig", + "GraphQLObjectType.toJSON": "() => string", + "GraphQLObjectType.toString": "() => string", + "GraphQLObjectTypeConfig": "interface: GraphQLObjectTypeConfig", + "GraphQLObjectTypeConfig.astNode": "?ObjectTypeDefinitionNode", + "GraphQLObjectTypeConfig.description": "?string", + "GraphQLObjectTypeConfig.extensionASTNodes": "?readonly ObjectTypeExtensionNode[]", + "GraphQLObjectTypeConfig.extensions": "?Readonly>", + "GraphQLObjectTypeConfig.fields": "ThunkObjMap>", + "GraphQLObjectTypeConfig.interfaces": "?ThunkReadonlyArray", + "GraphQLObjectTypeConfig.isTypeOf": "?GraphQLIsTypeOfFn", + "GraphQLObjectTypeConfig.name": "string", + "GraphQLObjectTypeExtensions": "interface: GraphQLObjectTypeExtensions<_TSource, _TContext>", + "GraphQLOneOfDirective": "const: GraphQLDirective", + "GraphQLOutputType": "type: GraphQLOutputType", + "GraphQLResolveInfo": "interface: GraphQLResolveInfo", + "GraphQLResolveInfo.fieldName": "string", + "GraphQLResolveInfo.fieldNodes": "readonly FieldNode[]", + "GraphQLResolveInfo.fragments": "ObjMap", + "GraphQLResolveInfo.operation": "OperationDefinitionNode", + "GraphQLResolveInfo.parentType": "GraphQLObjectType", + "GraphQLResolveInfo.path": "Path", + "GraphQLResolveInfo.returnType": "GraphQLOutputType", + "GraphQLResolveInfo.rootValue": "unknown", + "GraphQLResolveInfo.schema": "GraphQLSchema", + "GraphQLResolveInfo.variableValues": "{ [variable: string]: unknown; }", + "GraphQLScalarLiteralParser": "type: GraphQLScalarLiteralParser", + "GraphQLScalarSerializer": "type: GraphQLScalarSerializer", + "GraphQLScalarType": "class: GraphQLScalarType", + "GraphQLScalarType.astNode": "ScalarTypeDefinitionNode", + "GraphQLScalarType.description": "string", + "GraphQLScalarType.extensionASTNodes": "readonly ScalarTypeExtensionNode[]", + "GraphQLScalarType.extensions": "Readonly", + "GraphQLScalarType.name": "string", + "GraphQLScalarType.parseLiteral": "GraphQLScalarLiteralParser", + "GraphQLScalarType.parseValue": "GraphQLScalarValueParser", + "GraphQLScalarType.serialize": "GraphQLScalarSerializer", + "GraphQLScalarType.specifiedByURL": "string", + "GraphQLScalarType.toConfig": "() => GraphQLScalarTypeNormalizedConfig", + "GraphQLScalarType.toJSON": "() => string", + "GraphQLScalarType.toString": "() => string", + "GraphQLScalarTypeConfig": "interface: GraphQLScalarTypeConfig", + "GraphQLScalarTypeConfig.astNode": "?ScalarTypeDefinitionNode", + "GraphQLScalarTypeConfig.description": "?string", + "GraphQLScalarTypeConfig.extensionASTNodes": "?readonly ScalarTypeExtensionNode[]", + "GraphQLScalarTypeConfig.extensions": "?Readonly", + "GraphQLScalarTypeConfig.name": "string", + "GraphQLScalarTypeConfig.parseLiteral": "?GraphQLScalarLiteralParser", + "GraphQLScalarTypeConfig.parseValue": "?GraphQLScalarValueParser", + "GraphQLScalarTypeConfig.serialize": "?GraphQLScalarSerializer", + "GraphQLScalarTypeConfig.specifiedByURL": "?string", + "GraphQLScalarTypeExtensions": "interface: GraphQLScalarTypeExtensions", + "GraphQLScalarValueParser": "type: GraphQLScalarValueParser", + "GraphQLSchema": "class: GraphQLSchema", + "GraphQLSchema.__validationErrors": "readonly GraphQLError[]", + "GraphQLSchema._directives": "any", + "GraphQLSchema._implementationsMap": "any", + "GraphQLSchema._mutationType": "any", + "GraphQLSchema._queryType": "any", + "GraphQLSchema._subTypeMap": "any", + "GraphQLSchema._subscriptionType": "any", + "GraphQLSchema._typeMap": "any", + "GraphQLSchema.astNode": "SchemaDefinitionNode", + "GraphQLSchema.description": "string", + "GraphQLSchema.extensionASTNodes": "readonly SchemaExtensionNode[]", + "GraphQLSchema.extensions": "Readonly", + "GraphQLSchema.getDirective": "(name: string) => GraphQLDirective", + "GraphQLSchema.getDirectives": "() => readonly GraphQLDirective[]", + "GraphQLSchema.getImplementations": "(interfaceType: GraphQLInterfaceType) => { objects: readonly GraphQLObjectType[]; interfaces: readonly GraphQLInterfaceType[]; }", + "GraphQLSchema.getMutationType": "() => GraphQLObjectType", + "GraphQLSchema.getPossibleTypes": "(abstractType: GraphQLAbstractType) => readonly GraphQLObjectType[]", + "GraphQLSchema.getQueryType": "() => GraphQLObjectType", + "GraphQLSchema.getRootType": "(operation: OperationTypeNode) => GraphQLObjectType", + "GraphQLSchema.getSubscriptionType": "() => GraphQLObjectType", + "GraphQLSchema.getType": "(name: string) => GraphQLNamedType", + "GraphQLSchema.getTypeMap": "() => TypeMap", + "GraphQLSchema.isSubType": "(abstractType: GraphQLAbstractType, maybeSubType: GraphQLObjectType | GraphQLInterfaceType) => boolean", + "GraphQLSchema.toConfig": "() => GraphQLSchemaNormalizedConfig", + "GraphQLSchemaConfig": "interface: GraphQLSchemaConfig", + "GraphQLSchemaConfig.assumeValid": "?boolean", + "GraphQLSchemaConfig.astNode": "?SchemaDefinitionNode", + "GraphQLSchemaConfig.description": "?string", + "GraphQLSchemaConfig.directives": "?readonly GraphQLDirective[]", + "GraphQLSchemaConfig.extensionASTNodes": "?readonly SchemaExtensionNode[]", + "GraphQLSchemaConfig.extensions": "?Readonly", + "GraphQLSchemaConfig.mutation": "?GraphQLObjectType", + "GraphQLSchemaConfig.query": "?GraphQLObjectType", + "GraphQLSchemaConfig.subscription": "?GraphQLObjectType", + "GraphQLSchemaConfig.types": "?readonly GraphQLNamedType[]", + "GraphQLSchemaExtensions": "interface: GraphQLSchemaExtensions", + "GraphQLSkipDirective": "const: GraphQLDirective", + "GraphQLSpecifiedByDirective": "const: GraphQLDirective", + "GraphQLString": "const: GraphQLScalarType", + "GraphQLType": "type: GraphQLType", + "GraphQLTypeResolver": "type: GraphQLTypeResolver", + "GraphQLUnionType": "class: GraphQLUnionType", + "GraphQLUnionType._types": "any", + "GraphQLUnionType.astNode": "UnionTypeDefinitionNode", + "GraphQLUnionType.description": "string", + "GraphQLUnionType.extensionASTNodes": "readonly UnionTypeExtensionNode[]", + "GraphQLUnionType.extensions": "Readonly", + "GraphQLUnionType.getTypes": "() => readonly GraphQLObjectType[]", + "GraphQLUnionType.name": "string", + "GraphQLUnionType.resolveType": "GraphQLTypeResolver", + "GraphQLUnionType.toConfig": "() => GraphQLUnionTypeNormalizedConfig", + "GraphQLUnionType.toJSON": "() => string", + "GraphQLUnionType.toString": "() => string", + "GraphQLUnionTypeConfig": "interface: GraphQLUnionTypeConfig", + "GraphQLUnionTypeConfig.astNode": "?UnionTypeDefinitionNode", + "GraphQLUnionTypeConfig.description": "?string", + "GraphQLUnionTypeConfig.extensionASTNodes": "?readonly UnionTypeExtensionNode[]", + "GraphQLUnionTypeConfig.extensions": "?Readonly", + "GraphQLUnionTypeConfig.name": "string", + "GraphQLUnionTypeConfig.resolveType": "?GraphQLTypeResolver", + "GraphQLUnionTypeConfig.types": "ThunkReadonlyArray>", + "GraphQLUnionTypeExtensions": "interface: GraphQLUnionTypeExtensions", + "GraphQLWrappingType": "type: GraphQLWrappingType", + "InlineFragmentNode": "interface: InlineFragmentNode", + "InlineFragmentNode.directives": "?readonly DirectiveNode[]", + "InlineFragmentNode.kind": "Kind.INLINE_FRAGMENT", + "InlineFragmentNode.loc": "?Location", + "InlineFragmentNode.selectionSet": "SelectionSetNode", + "InlineFragmentNode.typeCondition": "?NamedTypeNode", + "InputObjectTypeDefinitionNode": "interface: InputObjectTypeDefinitionNode", + "InputObjectTypeDefinitionNode.description": "?StringValueNode", + "InputObjectTypeDefinitionNode.directives": "?readonly ConstDirectiveNode[]", + "InputObjectTypeDefinitionNode.fields": "?readonly InputValueDefinitionNode[]", + "InputObjectTypeDefinitionNode.kind": "Kind.INPUT_OBJECT_TYPE_DEFINITION", + "InputObjectTypeDefinitionNode.loc": "?Location", + "InputObjectTypeDefinitionNode.name": "NameNode", + "InputObjectTypeExtensionNode": "interface: InputObjectTypeExtensionNode", + "InputObjectTypeExtensionNode.directives": "?readonly ConstDirectiveNode[]", + "InputObjectTypeExtensionNode.fields": "?readonly InputValueDefinitionNode[]", + "InputObjectTypeExtensionNode.kind": "Kind.INPUT_OBJECT_TYPE_EXTENSION", + "InputObjectTypeExtensionNode.loc": "?Location", + "InputObjectTypeExtensionNode.name": "NameNode", + "InputValueDefinitionNode": "interface: InputValueDefinitionNode", + "InputValueDefinitionNode.defaultValue": "?ConstValueNode", + "InputValueDefinitionNode.description": "?StringValueNode", + "InputValueDefinitionNode.directives": "?readonly ConstDirectiveNode[]", + "InputValueDefinitionNode.kind": "Kind.INPUT_VALUE_DEFINITION", + "InputValueDefinitionNode.loc": "?Location", + "InputValueDefinitionNode.name": "NameNode", + "InputValueDefinitionNode.type": "TypeNode", + "IntValueNode": "interface: IntValueNode", + "IntValueNode.kind": "Kind.INT", + "IntValueNode.loc": "?Location", + "IntValueNode.value": "string", + "InterfaceTypeDefinitionNode": "interface: InterfaceTypeDefinitionNode", + "InterfaceTypeDefinitionNode.description": "?StringValueNode", + "InterfaceTypeDefinitionNode.directives": "?readonly ConstDirectiveNode[]", + "InterfaceTypeDefinitionNode.fields": "?readonly FieldDefinitionNode[]", + "InterfaceTypeDefinitionNode.interfaces": "?readonly NamedTypeNode[]", + "InterfaceTypeDefinitionNode.kind": "Kind.INTERFACE_TYPE_DEFINITION", + "InterfaceTypeDefinitionNode.loc": "?Location", + "InterfaceTypeDefinitionNode.name": "NameNode", + "InterfaceTypeExtensionNode": "interface: InterfaceTypeExtensionNode", + "InterfaceTypeExtensionNode.directives": "?readonly ConstDirectiveNode[]", + "InterfaceTypeExtensionNode.fields": "?readonly FieldDefinitionNode[]", + "InterfaceTypeExtensionNode.interfaces": "?readonly NamedTypeNode[]", + "InterfaceTypeExtensionNode.kind": "Kind.INTERFACE_TYPE_EXTENSION", + "InterfaceTypeExtensionNode.loc": "?Location", + "InterfaceTypeExtensionNode.name": "NameNode", + "IntrospectionDirective": "interface: IntrospectionDirective", + "IntrospectionDirective.args": "readonly IntrospectionInputValue[]", + "IntrospectionDirective.deprecationReason": "?string", + "IntrospectionDirective.description": "?string", + "IntrospectionDirective.isDeprecated": "?boolean", + "IntrospectionDirective.isRepeatable": "?boolean", + "IntrospectionDirective.locations": "readonly DirectiveLocation[]", + "IntrospectionDirective.name": "string", + "IntrospectionEnumType": "interface: IntrospectionEnumType", + "IntrospectionEnumType.description": "?string", + "IntrospectionEnumType.enumValues": "readonly IntrospectionEnumValue[]", + "IntrospectionEnumType.kind": "\"ENUM\"", + "IntrospectionEnumType.name": "string", + "IntrospectionEnumValue": "interface: IntrospectionEnumValue", + "IntrospectionEnumValue.deprecationReason": "string", + "IntrospectionEnumValue.description": "?string", + "IntrospectionEnumValue.isDeprecated": "boolean", + "IntrospectionEnumValue.name": "string", + "IntrospectionField": "interface: IntrospectionField", + "IntrospectionField.args": "readonly IntrospectionInputValue[]", + "IntrospectionField.deprecationReason": "string", + "IntrospectionField.description": "?string", + "IntrospectionField.isDeprecated": "boolean", + "IntrospectionField.name": "string", + "IntrospectionField.type": "IntrospectionOutputTypeRef", + "IntrospectionInputObjectType": "interface: IntrospectionInputObjectType", + "IntrospectionInputObjectType.description": "?string", + "IntrospectionInputObjectType.inputFields": "readonly IntrospectionInputValue[]", + "IntrospectionInputObjectType.isOneOf": "boolean", + "IntrospectionInputObjectType.kind": "\"INPUT_OBJECT\"", + "IntrospectionInputObjectType.name": "string", + "IntrospectionInputType": "type: IntrospectionInputType", + "IntrospectionInputTypeRef": "type: IntrospectionInputTypeRef", + "IntrospectionInputValue": "interface: IntrospectionInputValue", + "IntrospectionInputValue.defaultValue": "string", + "IntrospectionInputValue.deprecationReason": "?string", + "IntrospectionInputValue.description": "?string", + "IntrospectionInputValue.isDeprecated": "?boolean", + "IntrospectionInputValue.name": "string", + "IntrospectionInputValue.type": "IntrospectionInputTypeRef", + "IntrospectionInterfaceType": "interface: IntrospectionInterfaceType", + "IntrospectionInterfaceType.description": "?string", + "IntrospectionInterfaceType.fields": "readonly IntrospectionField[]", + "IntrospectionInterfaceType.interfaces": "readonly IntrospectionNamedTypeRef[]", + "IntrospectionInterfaceType.kind": "\"INTERFACE\"", + "IntrospectionInterfaceType.name": "string", + "IntrospectionInterfaceType.possibleTypes": "readonly IntrospectionNamedTypeRef[]", + "IntrospectionListTypeRef": "interface: IntrospectionListTypeRef", + "IntrospectionListTypeRef.kind": "\"LIST\"", + "IntrospectionListTypeRef.ofType": "T", + "IntrospectionNamedTypeRef": "interface: IntrospectionNamedTypeRef", + "IntrospectionNamedTypeRef.kind": "T[\"kind\"]", + "IntrospectionNamedTypeRef.name": "string", + "IntrospectionNonNullTypeRef": "interface: IntrospectionNonNullTypeRef", + "IntrospectionNonNullTypeRef.kind": "\"NON_NULL\"", + "IntrospectionNonNullTypeRef.ofType": "T", + "IntrospectionObjectType": "interface: IntrospectionObjectType", + "IntrospectionObjectType.description": "?string", + "IntrospectionObjectType.fields": "readonly IntrospectionField[]", + "IntrospectionObjectType.interfaces": "readonly IntrospectionNamedTypeRef[]", + "IntrospectionObjectType.kind": "\"OBJECT\"", + "IntrospectionObjectType.name": "string", + "IntrospectionOptions": "interface: IntrospectionOptions", + "IntrospectionOptions.descriptions": "?boolean", + "IntrospectionOptions.directiveIsRepeatable": "?boolean", + "IntrospectionOptions.experimentalDirectiveDeprecation": "?boolean", + "IntrospectionOptions.inputValueDeprecation": "?boolean", + "IntrospectionOptions.oneOf": "?boolean", + "IntrospectionOptions.schemaDescription": "?boolean", + "IntrospectionOptions.specifiedByUrl": "?boolean", + "IntrospectionOptions.typeDepth": "?number", + "IntrospectionOutputType": "type: IntrospectionOutputType", + "IntrospectionOutputTypeRef": "type: IntrospectionOutputTypeRef", + "IntrospectionQuery": "interface: IntrospectionQuery", + "IntrospectionQuery.__schema": "IntrospectionSchema", + "IntrospectionScalarType": "interface: IntrospectionScalarType", + "IntrospectionScalarType.description": "?string", + "IntrospectionScalarType.kind": "\"SCALAR\"", + "IntrospectionScalarType.name": "string", + "IntrospectionScalarType.specifiedByURL": "?string", + "IntrospectionSchema": "interface: IntrospectionSchema", + "IntrospectionSchema.description": "?string", + "IntrospectionSchema.directives": "readonly IntrospectionDirective[]", + "IntrospectionSchema.mutationType": "IntrospectionNamedTypeRef", + "IntrospectionSchema.queryType": "IntrospectionNamedTypeRef", + "IntrospectionSchema.subscriptionType": "IntrospectionNamedTypeRef", + "IntrospectionSchema.types": "readonly IntrospectionType[]", + "IntrospectionType": "type: IntrospectionType", + "IntrospectionTypeRef": "type: IntrospectionTypeRef", + "IntrospectionUnionType": "interface: IntrospectionUnionType", + "IntrospectionUnionType.description": "?string", + "IntrospectionUnionType.kind": "\"UNION\"", + "IntrospectionUnionType.name": "string", + "IntrospectionUnionType.possibleTypes": "readonly IntrospectionNamedTypeRef[]", + "Kind": "enum: Kind", + "Kind.ARGUMENT": "enum-member", + "Kind.ARGUMENT_COORDINATE": "enum-member", + "Kind.BOOLEAN": "enum-member", + "Kind.DIRECTIVE": "enum-member", + "Kind.DIRECTIVE_ARGUMENT_COORDINATE": "enum-member", + "Kind.DIRECTIVE_COORDINATE": "enum-member", + "Kind.DIRECTIVE_DEFINITION": "enum-member", + "Kind.DIRECTIVE_EXTENSION": "enum-member", + "Kind.DOCUMENT": "enum-member", + "Kind.ENUM": "enum-member", + "Kind.ENUM_TYPE_DEFINITION": "enum-member", + "Kind.ENUM_TYPE_EXTENSION": "enum-member", + "Kind.ENUM_VALUE_DEFINITION": "enum-member", + "Kind.FIELD": "enum-member", + "Kind.FIELD_DEFINITION": "enum-member", + "Kind.FLOAT": "enum-member", + "Kind.FRAGMENT_DEFINITION": "enum-member", + "Kind.FRAGMENT_SPREAD": "enum-member", + "Kind.INLINE_FRAGMENT": "enum-member", + "Kind.INPUT_OBJECT_TYPE_DEFINITION": "enum-member", + "Kind.INPUT_OBJECT_TYPE_EXTENSION": "enum-member", + "Kind.INPUT_VALUE_DEFINITION": "enum-member", + "Kind.INT": "enum-member", + "Kind.INTERFACE_TYPE_DEFINITION": "enum-member", + "Kind.INTERFACE_TYPE_EXTENSION": "enum-member", + "Kind.LIST": "enum-member", + "Kind.LIST_TYPE": "enum-member", + "Kind.MEMBER_COORDINATE": "enum-member", + "Kind.NAME": "enum-member", + "Kind.NAMED_TYPE": "enum-member", + "Kind.NON_NULL_TYPE": "enum-member", + "Kind.NULL": "enum-member", + "Kind.OBJECT": "enum-member", + "Kind.OBJECT_FIELD": "enum-member", + "Kind.OBJECT_TYPE_DEFINITION": "enum-member", + "Kind.OBJECT_TYPE_EXTENSION": "enum-member", + "Kind.OPERATION_DEFINITION": "enum-member", + "Kind.OPERATION_TYPE_DEFINITION": "enum-member", + "Kind.SCALAR_TYPE_DEFINITION": "enum-member", + "Kind.SCALAR_TYPE_EXTENSION": "enum-member", + "Kind.SCHEMA_DEFINITION": "enum-member", + "Kind.SCHEMA_EXTENSION": "enum-member", + "Kind.SELECTION_SET": "enum-member", + "Kind.STRING": "enum-member", + "Kind.TYPE_COORDINATE": "enum-member", + "Kind.UNION_TYPE_DEFINITION": "enum-member", + "Kind.UNION_TYPE_EXTENSION": "enum-member", + "Kind.VARIABLE": "enum-member", + "Kind.VARIABLE_DEFINITION": "enum-member", + "KindEnum": "type: typeof Kind", + "KnownArgumentNamesRule": "function: (context: ValidationContext): ASTVisitor", + "KnownDirectivesRule": "function: (context: ValidationContext | SDLValidationContext): ASTVisitor", + "KnownFragmentNamesRule": "function: (context: ValidationContext): ASTVisitor", + "KnownTypeNamesRule": "function: (context: ValidationContext | SDLValidationContext): ASTVisitor", + "Lexer": "class: Lexer", + "Lexer.advance": "() => Token", + "Lexer.lastToken": "Token", + "Lexer.line": "number", + "Lexer.lineStart": "number", + "Lexer.lookahead": "() => Token", + "Lexer.source": "Source", + "Lexer.token": "Token", + "ListTypeNode": "interface: ListTypeNode", + "ListTypeNode.kind": "Kind.LIST_TYPE", + "ListTypeNode.loc": "?Location", + "ListTypeNode.type": "TypeNode", + "ListValueNode": "interface: ListValueNode", + "ListValueNode.kind": "Kind.LIST", + "ListValueNode.loc": "?Location", + "ListValueNode.values": "readonly ValueNode[]", + "Location": "class: Location", + "Location.end": "number", + "Location.endToken": "Token", + "Location.source": "Source", + "Location.start": "number", + "Location.startToken": "Token", + "Location.toJSON": "() => { start: number; end: number; }", + "LoneAnonymousOperationRule": "function: (context: ASTValidationContext): ASTVisitor", + "LoneSchemaDefinitionRule": "function: (context: SDLValidationContext): ASTVisitor", + "MaxIntrospectionDepthRule": "function: (context: ASTValidationContext): ASTVisitor", + "MemberCoordinateNode": "interface: MemberCoordinateNode", + "MemberCoordinateNode.kind": "Kind.MEMBER_COORDINATE", + "MemberCoordinateNode.loc": "?Location", + "MemberCoordinateNode.memberName": "NameNode", + "MemberCoordinateNode.name": "NameNode", + "NameNode": "interface: NameNode", + "NameNode.kind": "Kind.NAME", + "NameNode.loc": "?Location", + "NameNode.value": "string", + "NamedTypeNode": "interface: NamedTypeNode", + "NamedTypeNode.kind": "Kind.NAMED_TYPE", + "NamedTypeNode.loc": "?Location", + "NamedTypeNode.name": "NameNode", + "NoDeprecatedCustomRule": "function: (context: ValidationContext): ASTVisitor", + "NoFragmentCyclesRule": "function: (context: ASTValidationContext): ASTVisitor", + "NoSchemaIntrospectionCustomRule": "function: (context: ValidationContext): ASTVisitor", + "NoUndefinedVariablesRule": "function: (context: ValidationContext): ASTVisitor", + "NoUnusedFragmentsRule": "function: (context: ASTValidationContext): ASTVisitor", + "NoUnusedVariablesRule": "function: (context: ValidationContext): ASTVisitor", + "NonNullTypeNode": "interface: NonNullTypeNode", + "NonNullTypeNode.kind": "Kind.NON_NULL_TYPE", + "NonNullTypeNode.loc": "?Location", + "NonNullTypeNode.type": "NamedTypeNode | ListTypeNode", + "NullValueNode": "interface: NullValueNode", + "NullValueNode.kind": "Kind.NULL", + "NullValueNode.loc": "?Location", + "ObjectFieldNode": "interface: ObjectFieldNode", + "ObjectFieldNode.kind": "Kind.OBJECT_FIELD", + "ObjectFieldNode.loc": "?Location", + "ObjectFieldNode.name": "NameNode", + "ObjectFieldNode.value": "ValueNode", + "ObjectTypeDefinitionNode": "interface: ObjectTypeDefinitionNode", + "ObjectTypeDefinitionNode.description": "?StringValueNode", + "ObjectTypeDefinitionNode.directives": "?readonly ConstDirectiveNode[]", + "ObjectTypeDefinitionNode.fields": "?readonly FieldDefinitionNode[]", + "ObjectTypeDefinitionNode.interfaces": "?readonly NamedTypeNode[]", + "ObjectTypeDefinitionNode.kind": "Kind.OBJECT_TYPE_DEFINITION", + "ObjectTypeDefinitionNode.loc": "?Location", + "ObjectTypeDefinitionNode.name": "NameNode", + "ObjectTypeExtensionNode": "interface: ObjectTypeExtensionNode", + "ObjectTypeExtensionNode.directives": "?readonly ConstDirectiveNode[]", + "ObjectTypeExtensionNode.fields": "?readonly FieldDefinitionNode[]", + "ObjectTypeExtensionNode.interfaces": "?readonly NamedTypeNode[]", + "ObjectTypeExtensionNode.kind": "Kind.OBJECT_TYPE_EXTENSION", + "ObjectTypeExtensionNode.loc": "?Location", + "ObjectTypeExtensionNode.name": "NameNode", + "ObjectValueNode": "interface: ObjectValueNode", + "ObjectValueNode.fields": "readonly ObjectFieldNode[]", + "ObjectValueNode.kind": "Kind.OBJECT", + "ObjectValueNode.loc": "?Location", + "OperationDefinitionNode": "interface: OperationDefinitionNode", + "OperationDefinitionNode.description": "?StringValueNode", + "OperationDefinitionNode.directives": "?readonly DirectiveNode[]", + "OperationDefinitionNode.kind": "Kind.OPERATION_DEFINITION", + "OperationDefinitionNode.loc": "?Location", + "OperationDefinitionNode.name": "?NameNode", + "OperationDefinitionNode.operation": "OperationTypeNode", + "OperationDefinitionNode.selectionSet": "SelectionSetNode", + "OperationDefinitionNode.variableDefinitions": "?readonly VariableDefinitionNode[]", + "OperationTypeDefinitionNode": "interface: OperationTypeDefinitionNode", + "OperationTypeDefinitionNode.kind": "Kind.OPERATION_TYPE_DEFINITION", + "OperationTypeDefinitionNode.loc": "?Location", + "OperationTypeDefinitionNode.operation": "OperationTypeNode", + "OperationTypeDefinitionNode.type": "NamedTypeNode", + "OperationTypeNode": "enum: OperationTypeNode", + "OperationTypeNode.MUTATION": "enum-member", + "OperationTypeNode.QUERY": "enum-member", + "OperationTypeNode.SUBSCRIPTION": "enum-member", + "OverlappingFieldsCanBeMergedRule": "function: (context: ValidationContext): ASTVisitor", + "ParseOptions": "interface: ParseOptions", + "ParseOptions.allowLegacyFragmentVariables": "?boolean", + "ParseOptions.experimentalDirectivesOnDirectiveDefinitions": "?boolean", + "ParseOptions.lexer": "?LexerInterface", + "ParseOptions.maxTokens": "?number", + "ParseOptions.noLocation": "?boolean", + "PossibleFragmentSpreadsRule": "function: (context: ValidationContext): ASTVisitor", + "PossibleTypeExtensionsRule": "function: (context: SDLValidationContext): ASTVisitor", + "ProvidedRequiredArgumentsRule": "function: (context: ValidationContext): ASTVisitor", + "ResolvedSchemaElement": "type: ResolvedSchemaElement", + "ResponsePath": "interface: Path", + "ResponsePath.key": "string | number", + "ResponsePath.prev": "Path", + "ResponsePath.typename": "string", + "ScalarLeafsRule": "function: (context: ValidationContext): ASTVisitor", + "ScalarTypeDefinitionNode": "interface: ScalarTypeDefinitionNode", + "ScalarTypeDefinitionNode.description": "?StringValueNode", + "ScalarTypeDefinitionNode.directives": "?readonly ConstDirectiveNode[]", + "ScalarTypeDefinitionNode.kind": "Kind.SCALAR_TYPE_DEFINITION", + "ScalarTypeDefinitionNode.loc": "?Location", + "ScalarTypeDefinitionNode.name": "NameNode", + "ScalarTypeExtensionNode": "interface: ScalarTypeExtensionNode", + "ScalarTypeExtensionNode.directives": "?readonly ConstDirectiveNode[]", + "ScalarTypeExtensionNode.kind": "Kind.SCALAR_TYPE_EXTENSION", + "ScalarTypeExtensionNode.loc": "?Location", + "ScalarTypeExtensionNode.name": "NameNode", + "SchemaCoordinateNode": "type: SchemaCoordinateNode", + "SchemaDefinitionNode": "interface: SchemaDefinitionNode", + "SchemaDefinitionNode.description": "?StringValueNode", + "SchemaDefinitionNode.directives": "?readonly ConstDirectiveNode[]", + "SchemaDefinitionNode.kind": "Kind.SCHEMA_DEFINITION", + "SchemaDefinitionNode.loc": "?Location", + "SchemaDefinitionNode.operationTypes": "readonly OperationTypeDefinitionNode[]", + "SchemaExtensionNode": "interface: SchemaExtensionNode", + "SchemaExtensionNode.directives": "?readonly ConstDirectiveNode[]", + "SchemaExtensionNode.kind": "Kind.SCHEMA_EXTENSION", + "SchemaExtensionNode.loc": "?Location", + "SchemaExtensionNode.operationTypes": "?readonly OperationTypeDefinitionNode[]", + "SchemaMetaFieldDef": "const: GraphQLField", + "SelectionNode": "type: SelectionNode", + "SelectionSetNode": "interface: SelectionSetNode", + "SelectionSetNode.kind": "Kind.SELECTION_SET", + "SelectionSetNode.loc": "?Location", + "SelectionSetNode.selections": "readonly SelectionNode[]", + "SingleFieldSubscriptionsRule": "function: (context: ValidationContext): ASTVisitor", + "Source": "class: Source", + "Source.body": "string", + "Source.locationOffset": "Location", + "Source.name": "string", + "SourceLocation": "interface: SourceLocation", + "SourceLocation.column": "number", + "SourceLocation.line": "number", + "StringValueNode": "interface: StringValueNode", + "StringValueNode.block": "?boolean", + "StringValueNode.kind": "Kind.STRING", + "StringValueNode.loc": "?Location", + "StringValueNode.value": "string", + "SubscriptionArgs": "interface: SubscriptionArgs", + "SubscriptionArgs.contextValue": "?unknown", + "SubscriptionArgs.document": "DocumentNode", + "SubscriptionArgs.fieldResolver": "?GraphQLFieldResolver", + "SubscriptionArgs.operationName": "?string", + "SubscriptionArgs.options": "?{ maxCoercionErrors?: number; }", + "SubscriptionArgs.rootValue": "?unknown", + "SubscriptionArgs.schema": "GraphQLSchema", + "SubscriptionArgs.subscribeFieldResolver": "?GraphQLFieldResolver", + "SubscriptionArgs.typeResolver": "?GraphQLTypeResolver", + "SubscriptionArgs.variableValues": "?{ readonly [variable: string]: unknown; }", + "ThunkObjMap": "type: ThunkObjMap", + "ThunkReadonlyArray": "type: ThunkReadonlyArray", + "Token": "class: Token", + "Token.column": "number", + "Token.end": "number", + "Token.kind": "TokenKind", + "Token.line": "number", + "Token.next": "Token", + "Token.prev": "Token", + "Token.start": "number", + "Token.toJSON": "() => { kind: TokenKind; value?: string; line: number; column: number; }", + "Token.value": "string", + "TokenKind": "enum: TokenKind", + "TokenKind.AMP": "enum-member", + "TokenKind.AT": "enum-member", + "TokenKind.BANG": "enum-member", + "TokenKind.BLOCK_STRING": "enum-member", + "TokenKind.BRACE_L": "enum-member", + "TokenKind.BRACE_R": "enum-member", + "TokenKind.BRACKET_L": "enum-member", + "TokenKind.BRACKET_R": "enum-member", + "TokenKind.COLON": "enum-member", + "TokenKind.COMMENT": "enum-member", + "TokenKind.DOLLAR": "enum-member", + "TokenKind.DOT": "enum-member", + "TokenKind.EOF": "enum-member", + "TokenKind.EQUALS": "enum-member", + "TokenKind.FLOAT": "enum-member", + "TokenKind.INT": "enum-member", + "TokenKind.NAME": "enum-member", + "TokenKind.PAREN_L": "enum-member", + "TokenKind.PAREN_R": "enum-member", + "TokenKind.PIPE": "enum-member", + "TokenKind.SOF": "enum-member", + "TokenKind.SPREAD": "enum-member", + "TokenKind.STRING": "enum-member", + "TokenKindEnum": "type: typeof TokenKind", + "TypeCoordinateNode": "interface: TypeCoordinateNode", + "TypeCoordinateNode.kind": "Kind.TYPE_COORDINATE", + "TypeCoordinateNode.loc": "?Location", + "TypeCoordinateNode.name": "NameNode", + "TypeDefinitionNode": "type: TypeDefinitionNode", + "TypeExtensionNode": "type: TypeExtensionNode", + "TypeInfo": "class: TypeInfo", + "TypeInfo._argument": "any", + "TypeInfo._defaultValueStack": "any", + "TypeInfo._directive": "any", + "TypeInfo._enumValue": "any", + "TypeInfo._fieldDefStack": "any", + "TypeInfo._getFieldDef": "any", + "TypeInfo._inputTypeStack": "any", + "TypeInfo._parentTypeStack": "any", + "TypeInfo._schema": "any", + "TypeInfo._typeStack": "any", + "TypeInfo.enter": "(node: ASTNode) => void", + "TypeInfo.getArgument": "() => GraphQLArgument", + "TypeInfo.getDefaultValue": "() => unknown", + "TypeInfo.getDirective": "() => GraphQLDirective", + "TypeInfo.getEnumValue": "() => GraphQLEnumValue", + "TypeInfo.getFieldDef": "() => GraphQLField", + "TypeInfo.getInputType": "() => GraphQLInputType", + "TypeInfo.getParentInputType": "() => GraphQLInputType", + "TypeInfo.getParentType": "() => GraphQLCompositeType", + "TypeInfo.getType": "() => GraphQLOutputType", + "TypeInfo.leave": "(node: ASTNode) => void", + "TypeKind": "enum: TypeKind", + "TypeKind.ENUM": "enum-member", + "TypeKind.INPUT_OBJECT": "enum-member", + "TypeKind.INTERFACE": "enum-member", + "TypeKind.LIST": "enum-member", + "TypeKind.NON_NULL": "enum-member", + "TypeKind.OBJECT": "enum-member", + "TypeKind.SCALAR": "enum-member", + "TypeKind.UNION": "enum-member", + "TypeMetaFieldDef": "const: GraphQLField", + "TypeNameMetaFieldDef": "const: GraphQLField", + "TypeNode": "type: TypeNode", + "TypeSystemDefinitionNode": "type: TypeSystemDefinitionNode", + "TypeSystemExtensionNode": "type: TypeSystemExtensionNode", + "TypedQueryDocumentNode": "interface: TypedQueryDocumentNode", + "TypedQueryDocumentNode.__ensureTypesOfVariablesAndResultMatching": "?(variables: TRequestVariables) => TResponseData", + "TypedQueryDocumentNode.definitions": "readonly ExecutableDefinitionNode[]", + "TypedQueryDocumentNode.kind": "Kind.DOCUMENT", + "TypedQueryDocumentNode.loc": "?Location", + "TypedQueryDocumentNode.tokenCount": "?number", + "UnionTypeDefinitionNode": "interface: UnionTypeDefinitionNode", + "UnionTypeDefinitionNode.description": "?StringValueNode", + "UnionTypeDefinitionNode.directives": "?readonly ConstDirectiveNode[]", + "UnionTypeDefinitionNode.kind": "Kind.UNION_TYPE_DEFINITION", + "UnionTypeDefinitionNode.loc": "?Location", + "UnionTypeDefinitionNode.name": "NameNode", + "UnionTypeDefinitionNode.types": "?readonly NamedTypeNode[]", + "UnionTypeExtensionNode": "interface: UnionTypeExtensionNode", + "UnionTypeExtensionNode.directives": "?readonly ConstDirectiveNode[]", + "UnionTypeExtensionNode.kind": "Kind.UNION_TYPE_EXTENSION", + "UnionTypeExtensionNode.loc": "?Location", + "UnionTypeExtensionNode.name": "NameNode", + "UnionTypeExtensionNode.types": "?readonly NamedTypeNode[]", + "UniqueArgumentDefinitionNamesRule": "function: (context: SDLValidationContext): ASTVisitor", + "UniqueArgumentNamesRule": "function: (context: ASTValidationContext): ASTVisitor", + "UniqueDirectiveNamesRule": "function: (context: SDLValidationContext): ASTVisitor", + "UniqueDirectivesPerLocationRule": "function: (context: ValidationContext | SDLValidationContext): ASTVisitor", + "UniqueEnumValueNamesRule": "function: (context: SDLValidationContext): ASTVisitor", + "UniqueFieldDefinitionNamesRule": "function: (context: SDLValidationContext): ASTVisitor", + "UniqueFragmentNamesRule": "function: (context: ASTValidationContext): ASTVisitor", + "UniqueInputFieldNamesRule": "function: (context: ASTValidationContext): ASTVisitor", + "UniqueOperationNamesRule": "function: (context: ASTValidationContext): ASTVisitor", + "UniqueOperationTypesRule": "function: (context: SDLValidationContext): ASTVisitor", + "UniqueTypeNamesRule": "function: (context: SDLValidationContext): ASTVisitor", + "UniqueVariableNamesRule": "function: (context: ASTValidationContext): ASTVisitor", + "ValidationContext": "class: ValidationContext", + "ValidationContext._ast": "any", + "ValidationContext._fragmentSpreads": "any", + "ValidationContext._fragments": "any", + "ValidationContext._onError": "any", + "ValidationContext._recursiveVariableUsages": "any", + "ValidationContext._recursivelyReferencedFragments": "any", + "ValidationContext._schema": "any", + "ValidationContext._typeInfo": "any", + "ValidationContext._variableUsages": "any", + "ValidationContext.getArgument": "() => GraphQLArgument", + "ValidationContext.getDirective": "() => GraphQLDirective", + "ValidationContext.getDocument": "() => DocumentNode", + "ValidationContext.getEnumValue": "() => GraphQLEnumValue", + "ValidationContext.getFieldDef": "() => GraphQLField", + "ValidationContext.getFragment": "(name: string) => FragmentDefinitionNode", + "ValidationContext.getFragmentSpreads": "(node: SelectionSetNode) => readonly FragmentSpreadNode[]", + "ValidationContext.getInputType": "() => GraphQLInputType", + "ValidationContext.getParentInputType": "() => GraphQLInputType", + "ValidationContext.getParentType": "() => GraphQLCompositeType", + "ValidationContext.getRecursiveVariableUsages": "(operation: OperationDefinitionNode) => readonly VariableUsage[]", + "ValidationContext.getRecursivelyReferencedFragments": "(operation: OperationDefinitionNode) => readonly FragmentDefinitionNode[]", + "ValidationContext.getSchema": "() => GraphQLSchema", + "ValidationContext.getType": "() => GraphQLOutputType", + "ValidationContext.getVariableUsages": "(node: NodeWithSelectionSet) => readonly VariableUsage[]", + "ValidationContext.reportError": "(error: GraphQLError) => void", + "ValidationRule": "type: ValidationRule", + "ValueNode": "type: ValueNode", + "ValuesOfCorrectTypeRule": "function: (context: ValidationContext): ASTVisitor", + "VariableDefinitionNode": "interface: VariableDefinitionNode", + "VariableDefinitionNode.defaultValue": "?ConstValueNode", + "VariableDefinitionNode.description": "?StringValueNode", + "VariableDefinitionNode.directives": "?readonly ConstDirectiveNode[]", + "VariableDefinitionNode.kind": "Kind.VARIABLE_DEFINITION", + "VariableDefinitionNode.loc": "?Location", + "VariableDefinitionNode.type": "TypeNode", + "VariableDefinitionNode.variable": "VariableNode", + "VariableNode": "interface: VariableNode", + "VariableNode.kind": "Kind.VARIABLE", + "VariableNode.loc": "?Location", + "VariableNode.name": "NameNode", + "VariablesAreInputTypesRule": "function: (context: ValidationContext): ASTVisitor", + "VariablesInAllowedPositionRule": "function: (context: ValidationContext): ASTVisitor", + "__Directive": "const: GraphQLObjectType", + "__DirectiveLocation": "const: GraphQLEnumType", + "__EnumValue": "const: GraphQLObjectType", + "__Field": "const: GraphQLObjectType", + "__InputValue": "const: GraphQLObjectType", + "__Schema": "const: GraphQLObjectType", + "__Type": "const: GraphQLObjectType", + "__TypeKind": "const: GraphQLEnumType", + "assertAbstractType": "function: (type: unknown): GraphQLAbstractType", + "assertCompositeType": "function: (type: unknown): GraphQLCompositeType", + "assertDirective": "function: (directive: unknown): GraphQLDirective", + "assertEnumType": "function: (type: unknown): GraphQLEnumType", + "assertEnumValueName": "function: (name: string): string", + "assertInputObjectType": "function: (type: unknown): GraphQLInputObjectType", + "assertInputType": "function: (type: unknown): GraphQLInputType", + "assertInterfaceType": "function: (type: unknown): GraphQLInterfaceType", + "assertLeafType": "function: (type: unknown): GraphQLLeafType", + "assertListType": "function: (type: unknown): GraphQLList", + "assertName": "function: (name: string): string", + "assertNamedType": "function: (type: unknown): GraphQLNamedType", + "assertNonNullType": "function: (type: unknown): GraphQLNonNull", + "assertNullableType": "function: (type: unknown): GraphQLNullableType", + "assertObjectType": "function: (type: unknown): GraphQLObjectType", + "assertOutputType": "function: (type: unknown): GraphQLOutputType", + "assertScalarType": "function: (type: unknown): GraphQLScalarType", + "assertSchema": "function: (schema: unknown): GraphQLSchema", + "assertType": "function: (type: unknown): GraphQLType", + "assertUnionType": "function: (type: unknown): GraphQLUnionType", + "assertValidName": "function: (name: string): string", + "assertValidSchema": "function: (schema: GraphQLSchema): void", + "assertWrappingType": "function: (type: unknown): GraphQLWrappingType", + "astFromValue": "function: (value: unknown, type: GraphQLInputType): ValueNode", + "buildASTSchema": "function: (documentAST: DocumentNode, options?: BuildSchemaOptions): GraphQLSchema", + "buildClientSchema": "function: (introspection: IntrospectionQuery, options?: GraphQLSchemaValidationOptions): GraphQLSchema", + "buildSchema": "function: (source: string | Source, options?: BuildSchemaOptions & ParseOptions): GraphQLSchema", + "coerceInputValue": "function: (inputValue: unknown, type: GraphQLInputType, onError?: OnErrorCB): unknown", + "concatAST": "function: (documents: readonly DocumentNode[]): DocumentNode", + "createSourceEventStream": "function: (args: ExecutionArgs): Promise, ObjMap> | AsyncIterable>", + "createSourceEventStream#1": "function: (schema: GraphQLSchema, document: DocumentNode, rootValue?: unknown, contextValue?: unknown, variableValues?: { readonly [variable: string]: unknown; }, operationName?: string, subscribeFieldResolver?: GraphQLFieldResolver<...>): Promise<...>", + "defaultFieldResolver": "function: (source: unknown, args: any, context: unknown, info: GraphQLResolveInfo): unknown", + "defaultTypeResolver": "function: (value: unknown, context: unknown, info: GraphQLResolveInfo, abstractType: GraphQLAbstractType): PromiseOrValue", + "doTypesOverlap": "function: (schema: GraphQLSchema, typeA: GraphQLCompositeType, typeB: GraphQLCompositeType): boolean", + "execute": "function: (args: ExecutionArgs): PromiseOrValue, ObjMap>>", + "executeSync": "function: (args: ExecutionArgs): ExecutionResult, ObjMap>", + "extendSchema": "function: (schema: GraphQLSchema, documentAST: DocumentNode, options?: Options): GraphQLSchema", + "findBreakingChanges": "function: (oldSchema: GraphQLSchema, newSchema: GraphQLSchema): BreakingChange[]", + "findDangerousChanges": "function: (oldSchema: GraphQLSchema, newSchema: GraphQLSchema): DangerousChange[]", + "formatError": "function: (error: GraphQLError): GraphQLFormattedError", + "getArgumentValues": "function: (def: GraphQLDirective | GraphQLField, node: FieldNode | DirectiveNode, variableValues?: ObjMap): { ...; }", + "getDirectiveValues": "function: (directiveDef: GraphQLDirective, node: DirectiveValuesNode, variableValues?: ObjMap): { [argument: string]: unknown; }", + "getEnterLeaveForKind": "function: (visitor: ASTVisitor, kind: Kind): EnterLeaveVisitor", + "getIntrospectionQuery": "function: (options?: IntrospectionOptions): string", + "getLocation": "function: (source: Source, position: number): SourceLocation", + "getNamedType": "function: (type: null): void", + "getNamedType#1": "function: (type: GraphQLInputType): GraphQLNamedInputType", + "getNamedType#2": "function: (type: GraphQLOutputType): GraphQLNamedOutputType", + "getNamedType#3": "function: (type: GraphQLType): GraphQLNamedType", + "getNamedType#4": "function: (type: GraphQLType): GraphQLNamedType", + "getNullableType": "function: (type: null): void", + "getNullableType#1": "function: (type: T | GraphQLNonNull): T", + "getNullableType#2": "function: (type: GraphQLType): GraphQLNullableType", + "getOperationAST": "function: (documentAST: DocumentNode, operationName?: string): OperationDefinitionNode", + "getOperationRootType": "function: (schema: GraphQLSchema, operation: OperationDefinitionNode | OperationTypeDefinitionNode): GraphQLObjectType", + "getVariableValues": "function: (schema: GraphQLSchema, varDefNodes: readonly VariableDefinitionNode[], inputs: { readonly [variable: string]: unknown; }, options?: GetVariableValuesOptions): CoercedVariableValues", + "getVisitFn": "function: (visitor: ASTVisitor, kind: Kind, isLeaving: boolean): ASTVisitFn", + "graphql": "function: (args: GraphQLArgs): Promise, ObjMap>>", + "graphqlSync": "function: (args: GraphQLArgs): ExecutionResult, ObjMap>", + "introspectionFromSchema": "function: (schema: GraphQLSchema, options?: IntrospectionOptions): IntrospectionQuery", + "introspectionTypes": "const: readonly GraphQLNamedType[]", + "isAbstractType": "function: (type: unknown): type is GraphQLAbstractType", + "isCompositeType": "function: (type: unknown): type is GraphQLCompositeType", + "isConstValueNode": "function: (node: ASTNode): node is ConstValueNode", + "isDefinitionNode": "function: (node: ASTNode): node is DefinitionNode", + "isDirective": "function: (directive: unknown): directive is GraphQLDirective", + "isEnumType": "function: (type: unknown): type is GraphQLEnumType", + "isEqualType": "function: (typeA: GraphQLType, typeB: GraphQLType): boolean", + "isExecutableDefinitionNode": "function: (node: ASTNode): node is ExecutableDefinitionNode", + "isInputObjectType": "function: (type: unknown): type is GraphQLInputObjectType", + "isInputType": "function: (type: unknown): type is GraphQLInputType", + "isInterfaceType": "function: (type: unknown): type is GraphQLInterfaceType", + "isIntrospectionType": "function: (type: GraphQLNamedType): boolean", + "isLeafType": "function: (type: unknown): type is GraphQLLeafType", + "isListType": "function: (type: GraphQLInputType): type is GraphQLList", + "isListType#1": "function: (type: GraphQLOutputType): type is GraphQLList", + "isListType#2": "function: (type: unknown): type is GraphQLList", + "isNamedType": "function: (type: unknown): type is GraphQLNamedType", + "isNonNullType": "function: (type: GraphQLInputType): type is GraphQLNonNull", + "isNonNullType#1": "function: (type: GraphQLOutputType): type is GraphQLNonNull", + "isNonNullType#2": "function: (type: unknown): type is GraphQLNonNull", + "isNullableType": "function: (type: unknown): type is GraphQLNullableType", + "isObjectType": "function: (type: unknown): type is GraphQLObjectType", + "isOutputType": "function: (type: unknown): type is GraphQLOutputType", + "isRequiredArgument": "function: (arg: GraphQLArgument): boolean", + "isRequiredInputField": "function: (field: GraphQLInputField): boolean", + "isScalarType": "function: (type: unknown): type is GraphQLScalarType", + "isSchema": "function: (schema: unknown): schema is GraphQLSchema", + "isSchemaCoordinateNode": "function: (node: ASTNode): node is SchemaCoordinateNode", + "isSelectionNode": "function: (node: ASTNode): node is SelectionNode", + "isSpecifiedDirective": "function: (directive: GraphQLDirective): boolean", + "isSpecifiedScalarType": "function: (type: GraphQLNamedType): boolean", + "isType": "function: (type: unknown): type is GraphQLType", + "isTypeDefinitionNode": "function: (node: ASTNode): node is TypeDefinitionNode", + "isTypeExtensionNode": "function: (node: ASTNode): node is TypeExtensionNode", + "isTypeNode": "function: (node: ASTNode): node is TypeNode", + "isTypeSubTypeOf": "function: (schema: GraphQLSchema, maybeSubType: GraphQLType, superType: GraphQLType): boolean", + "isTypeSystemDefinitionNode": "function: (node: ASTNode): node is TypeSystemDefinitionNode", + "isTypeSystemExtensionNode": "function: (node: ASTNode): node is TypeSystemExtensionNode", + "isUnionType": "function: (type: unknown): type is GraphQLUnionType", + "isValidNameError": "function: (name: string): GraphQLError", + "isValueNode": "function: (node: ASTNode): node is ValueNode", + "isWrappingType": "function: (type: unknown): type is GraphQLWrappingType", + "lexicographicSortSchema": "function: (schema: GraphQLSchema): GraphQLSchema", + "locatedError": "function: (rawOriginalError: unknown, nodes: ASTNode | readonly ASTNode[], path?: readonly (string | number)[]): GraphQLError", + "parse": "function: (source: string | Source, options?: ParseOptions): DocumentNode", + "parseConstValue": "function: (source: string | Source, options?: ParseOptions): ConstValueNode", + "parseSchemaCoordinate": "function: (source: string | Source): SchemaCoordinateNode", + "parseType": "function: (source: string | Source, options?: ParseOptions): TypeNode", + "parseValue": "function: (source: string | Source, options?: ParseOptions): ValueNode", + "print": "function: (ast: ASTNode): string", + "printError": "function: (error: GraphQLError): string", + "printIntrospectionSchema": "function: (schema: GraphQLSchema): string", + "printLocation": "function: (location: Location): string", + "printSchema": "function: (schema: GraphQLSchema): string", + "printSourceLocation": "function: (source: Source, sourceLocation: SourceLocation): string", + "printType": "function: (type: GraphQLNamedType): string", + "recommendedRules": "const: readonly ((context: ASTValidationContext) => ASTVisitor)[]", + "resolveASTSchemaCoordinate": "function: (schema: GraphQLSchema, schemaCoordinate: SchemaCoordinateNode): ResolvedSchemaElement", + "resolveObjMapThunk": "function: (thunk: ThunkObjMap): ObjMap", + "resolveReadonlyArrayThunk": "function: (thunk: ThunkReadonlyArray): readonly T[]", + "resolveSchemaCoordinate": "function: (schema: GraphQLSchema, schemaCoordinate: string | Source): ResolvedSchemaElement", + "responsePathAsArray": "function: (path: Readonly): (string | number)[]", + "separateOperations": "function: (documentAST: DocumentNode): ObjMap", + "specifiedDirectives": "const: readonly GraphQLDirective[]", + "specifiedRules": "const: readonly ValidationRule[]", + "specifiedScalarTypes": "const: readonly GraphQLScalarType[]", + "stripIgnoredCharacters": "function: (source: string | Source): string", + "subscribe": "function: (args: ExecutionArgs): Promise, ObjMap> | AsyncGenerator, ObjMap>, void, void>>", + "syntaxError": "function: (source: Source, position: number, description: string): GraphQLError", + "typeFromAST": "function: (schema: GraphQLSchema, typeNode: NamedTypeNode): GraphQLNamedType", + "typeFromAST#1": "function: (schema: GraphQLSchema, typeNode: ListTypeNode): GraphQLList", + "typeFromAST#2": "function: (schema: GraphQLSchema, typeNode: NonNullTypeNode): GraphQLNonNull", + "typeFromAST#3": "function: (schema: GraphQLSchema, typeNode: TypeNode): GraphQLType", + "validate": "function: (schema: GraphQLSchema, documentAST: DocumentNode, rules?: readonly ValidationRule[], options?: ValidationOptions, typeInfo?: TypeInfo): readonly GraphQLError[]", + "validateSchema": "function: (schema: GraphQLSchema): readonly GraphQLError[]", + "valueFromAST": "function: (valueNode: ValueNode, type: GraphQLInputType, variables?: ObjMap): unknown", + "valueFromASTUntyped": "function: (valueNode: ValueNode, variables?: ObjMap): unknown", + "version": "const: string", + "versionInfo": "const: Readonly<{ major: number; minor: number; patch: number; preReleaseTag: string; }>", + "visit": "function: (root: N, visitor: ASTVisitor, visitorKeys?: ASTVisitorKeyMap): N", + "visit#1": "function: (root: ASTNode, visitor: ASTReducer, visitorKeys?: ASTVisitorKeyMap): R", + "visitInParallel": "function: (visitors: readonly ASTVisitor[]): ASTVisitor", + "visitWithTypeInfo": "function: (typeInfo: TypeInfo, visitor: ASTVisitor): ASTVisitor" + } + }, + "pg": { + "version": "8.23.0", + "typesPackage": "@types/pg", + "typesVersion": "8.23.1", + "surface": { + "BindConfig": "interface: BindConfig", + "BindConfig.binary": "?string", + "BindConfig.portal": "?string", + "BindConfig.statement": "?string", + "BindConfig.valueMapper": "?ValueMapper", + "BindConfig.values": "?(string | Buffer)[]", + "Client": "class: Client", + "Client.addListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Client", + "Client.connect": "{ (): Promise; (callback: ((err: Error) => void) | ((err: null, c: Client) => void)): void; }", + "Client.connection": "Connection", + "Client.copyFrom": "(queryText: string) => Writable", + "Client.copyTo": "(queryText: string) => Readable", + "Client.database": "?string", + "Client.emit": "(eventName: string | symbol, ...args: AnyRest) => boolean", + "Client.end": "{ (): Promise; (callback: (err: Error) => void): void; }", + "Client.escapeIdentifier": "(str: string) => string", + "Client.escapeLiteral": "(str: string) => string", + "Client.eventNames": "() => (string | symbol)[]", + "Client.getMaxListeners": "() => number", + "Client.getTransactionStatus": "() => TransactionStatus", + "Client.getTypeParser": "(id: TypeId, format?: TypeFormat) => any", + "Client.host": "string", + "Client.listenerCount": "(eventName: string | symbol, listener?: Function) => number", + "Client.listeners": "(eventName: string | symbol) => Function[]", + "Client.off": "(eventName: string | symbol, listener: (...args: any[]) => void) => Client", + "Client.on": "(event: E, listener: E extends \"end\" | \"drain\" | \"connect\" ? () => void : E extends \"error\" ? (err: Error) => void : E extends \"notice\" ? (notice: NoticeMessage) => void : (message: Notification) => void) => Client", + "Client.once": "(eventName: string | symbol, listener: (...args: any[]) => void) => Client", + "Client.password": "?string", + "Client.pauseDrain": "() => void", + "Client.pipeline": "boolean", + "Client.port": "number", + "Client.prependListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Client", + "Client.prependOnceListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Client", + "Client.query": "{ (queryStream: T): T; (queryConfig: QueryArrayConfig, values?: QueryConfigValues): Promise>; (queryConfig: QueryConfig<...>): Promise<...>; (queryT...", + "Client.rawListeners": "(eventName: string | symbol) => Function[]", + "Client.removeAllListeners": "(eventName?: string | symbol) => Client", + "Client.removeListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Client", + "Client.resumeDrain": "() => void", + "Client.setMaxListeners": "(n: number) => Client", + "Client.setTypeParser": "{ (id: TypeId, parseFn: (value: string) => any): void; (id: TypeId, format: TypeFormat, parseFn: (value: string) => any): void; }", + "Client.ssl": "boolean", + "Client.user": "?string", + "ClientBase": "class: ClientBase", + "ClientBase.addListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => ClientBase", + "ClientBase.connect": "{ (): Promise; (callback: ((err: Error) => void) | ((err: null, c: ClientBase) => void)): void; }", + "ClientBase.copyFrom": "(queryText: string) => Writable", + "ClientBase.copyTo": "(queryText: string) => Readable", + "ClientBase.emit": "(eventName: string | symbol, ...args: AnyRest) => boolean", + "ClientBase.escapeIdentifier": "(str: string) => string", + "ClientBase.escapeLiteral": "(str: string) => string", + "ClientBase.eventNames": "() => (string | symbol)[]", + "ClientBase.getMaxListeners": "() => number", + "ClientBase.getTransactionStatus": "() => TransactionStatus", + "ClientBase.getTypeParser": "(id: TypeId, format?: TypeFormat) => any", + "ClientBase.listenerCount": "(eventName: string | symbol, listener?: Function) => number", + "ClientBase.listeners": "(eventName: string | symbol) => Function[]", + "ClientBase.off": "(eventName: string | symbol, listener: (...args: any[]) => void) => ClientBase", + "ClientBase.on": "(event: E, listener: E extends \"end\" | \"drain\" | \"connect\" ? () => void : E extends \"error\" ? (err: Error) => void : E extends \"notice\" ? (notice: NoticeMessage) => void : (message: Notification) => void) => ClientBase", + "ClientBase.once": "(eventName: string | symbol, listener: (...args: any[]) => void) => ClientBase", + "ClientBase.pauseDrain": "() => void", + "ClientBase.prependListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => ClientBase", + "ClientBase.prependOnceListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => ClientBase", + "ClientBase.query": "{ (queryStream: T): T; (queryConfig: QueryArrayConfig, values?: QueryConfigValues): Promise>; (queryConfig: QueryConfig<...>): Promise<...>; (queryT...", + "ClientBase.rawListeners": "(eventName: string | symbol) => Function[]", + "ClientBase.removeAllListeners": "(eventName?: string | symbol) => ClientBase", + "ClientBase.removeListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => ClientBase", + "ClientBase.resumeDrain": "() => void", + "ClientBase.setMaxListeners": "(n: number) => ClientBase", + "ClientBase.setTypeParser": "{ (id: TypeId, parseFn: (value: string) => any): void; (id: TypeId, format: TypeFormat, parseFn: (value: string) => any): void; }", + "ClientConfig": "interface: ClientConfig", + "ClientConfig.application_name": "?string", + "ClientConfig.client_encoding": "?string", + "ClientConfig.connectionString": "?string", + "ClientConfig.connectionTimeoutMillis": "?number", + "ClientConfig.database": "?string", + "ClientConfig.enableChannelBinding": "?boolean", + "ClientConfig.fallback_application_name": "?string", + "ClientConfig.host": "?string", + "ClientConfig.idle_in_transaction_session_timeout": "?number", + "ClientConfig.keepAlive": "?boolean", + "ClientConfig.keepAliveInitialDelayMillis": "?number", + "ClientConfig.lock_timeout": "?number", + "ClientConfig.options": "?string", + "ClientConfig.password": "?string | (() => string | Promise)", + "ClientConfig.pipeline": "?boolean", + "ClientConfig.port": "?number", + "ClientConfig.query_timeout": "?number", + "ClientConfig.ssl": "?boolean | ConnectionOptions", + "ClientConfig.sslnegotiation": "?\"postgres\" | \"direct\"", + "ClientConfig.statement_timeout": "?number | false", + "ClientConfig.stream": "?() => Duplex", + "ClientConfig.types": "?CustomTypesConfig", + "ClientConfig.user": "?string", + "Connection": "class: Connection", + "Connection.addListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Connection", + "Connection.bind": "(config: BindConfig, more: boolean) => void", + "Connection.close": "(msg: MessageConfig, more: boolean) => void", + "Connection.describe": "(msg: MessageConfig, more: boolean) => void", + "Connection.emit": "(eventName: string | symbol, ...args: AnyRest) => boolean", + "Connection.end": "() => void", + "Connection.eventNames": "() => (string | symbol)[]", + "Connection.execute": "(config: ExecuteConfig, more: boolean) => void", + "Connection.flush": "() => void", + "Connection.getMaxListeners": "() => number", + "Connection.listenerCount": "(eventName: string | symbol, listener?: Function) => number", + "Connection.listeners": "(eventName: string | symbol) => Function[]", + "Connection.off": "(eventName: string | symbol, listener: (...args: any[]) => void) => Connection", + "Connection.on": "(eventName: string | symbol, listener: (...args: any[]) => void) => Connection", + "Connection.once": "(eventName: string | symbol, listener: (...args: any[]) => void) => Connection", + "Connection.parse": "(query: QueryParse, more: boolean) => void", + "Connection.prependListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Connection", + "Connection.prependOnceListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Connection", + "Connection.query": "(text: string) => void", + "Connection.rawListeners": "(eventName: string | symbol) => Function[]", + "Connection.removeAllListeners": "(eventName?: string | symbol) => Connection", + "Connection.removeListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Connection", + "Connection.setMaxListeners": "(n: number) => Connection", + "Connection.stream": "Duplex", + "Connection.sync": "() => void", + "ConnectionConfig": "type: ClientConfig", + "CustomTypesConfig": "interface: CustomTypesConfig", + "CustomTypesConfig.getTypeParser": "(id: TypeId, format?: TypeFormat) => any", + "DatabaseError": "class: DatabaseError", + "DatabaseError.cause": "?unknown", + "DatabaseError.code": "string", + "DatabaseError.column": "string", + "DatabaseError.constraint": "string", + "DatabaseError.dataType": "string", + "DatabaseError.detail": "string", + "DatabaseError.file": "string", + "DatabaseError.hint": "string", + "DatabaseError.internalPosition": "string", + "DatabaseError.internalQuery": "string", + "DatabaseError.length": "number", + "DatabaseError.line": "string", + "DatabaseError.message": "string", + "DatabaseError.name": "MessageName", + "DatabaseError.position": "string", + "DatabaseError.routine": "string", + "DatabaseError.schema": "string", + "DatabaseError.severity": "string", + "DatabaseError.stack": "?string", + "DatabaseError.table": "string", + "DatabaseError.where": "string", + "Defaults": "interface: Defaults", + "Defaults.application_name": "?string", + "Defaults.binary": "?boolean", + "Defaults.client_encoding": "?string", + "Defaults.connectionString": "?string", + "Defaults.connectionTimeoutMillis": "?number", + "Defaults.database": "?string", + "Defaults.enableChannelBinding": "?boolean", + "Defaults.fallback_application_name": "?string", + "Defaults.host": "?string", + "Defaults.idle_in_transaction_session_timeout": "?number", + "Defaults.keepAlive": "?boolean", + "Defaults.keepAliveInitialDelayMillis": "?number", + "Defaults.lock_timeout": "?number", + "Defaults.options": "?string", + "Defaults.parseInputDatesAsUTC": "?boolean", + "Defaults.parseInt8": "?boolean", + "Defaults.password": "?string | (() => string | Promise)", + "Defaults.pipeline": "?boolean", + "Defaults.poolIdleTimeout": "?number", + "Defaults.poolSize": "?number", + "Defaults.port": "?number", + "Defaults.query_timeout": "?number", + "Defaults.reapIntervalMillis": "?number", + "Defaults.ssl": "?boolean | ConnectionOptions", + "Defaults.sslnegotiation": "?\"postgres\" | \"direct\"", + "Defaults.statement_timeout": "?number | false", + "Defaults.stream": "?() => Duplex", + "Defaults.types": "?CustomTypesConfig", + "Defaults.user": "?string", + "Events": "class: Events", + "Events.addListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Events", + "Events.emit": "(eventName: string | symbol, ...args: AnyRest) => boolean", + "Events.eventNames": "() => (string | symbol)[]", + "Events.getMaxListeners": "() => number", + "Events.listenerCount": "(eventName: string | symbol, listener?: Function) => number", + "Events.listeners": "(eventName: string | symbol) => Function[]", + "Events.off": "(eventName: string | symbol, listener: (...args: any[]) => void) => Events", + "Events.on": "(event: \"error\", listener: (err: Error, client: Client) => void) => Events", + "Events.once": "(eventName: string | symbol, listener: (...args: any[]) => void) => Events", + "Events.prependListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Events", + "Events.prependOnceListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Events", + "Events.rawListeners": "(eventName: string | symbol) => Function[]", + "Events.removeAllListeners": "(eventName?: string | symbol) => Events", + "Events.removeListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Events", + "Events.setMaxListeners": "(n: number) => Events", + "ExecuteConfig": "interface: ExecuteConfig", + "ExecuteConfig.portal": "?string", + "ExecuteConfig.rows": "?string", + "FieldDef": "interface: FieldDef", + "FieldDef.columnID": "number", + "FieldDef.dataTypeID": "number", + "FieldDef.dataTypeModifier": "number", + "FieldDef.dataTypeSize": "number", + "FieldDef.format": "string", + "FieldDef.name": "string", + "FieldDef.tableID": "number", + "MessageConfig": "interface: MessageConfig", + "MessageConfig.name": "?string", + "MessageConfig.type": "string", + "Notification": "interface: Notification", + "Notification.channel": "string", + "Notification.payload": "?string", + "Notification.processId": "number", + "Pool": "class: Pool", + "Pool.addListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Pool", + "Pool.connect": "{ (): Promise; (callback: (err: Error, client: PoolClient, done: (release?: any) => void) => void): void; }", + "Pool.emit": "(eventName: string | symbol, ...args: AnyRest) => boolean", + "Pool.end": "{ (): Promise; (callback: () => void): void; }", + "Pool.ended": "boolean", + "Pool.ending": "boolean", + "Pool.eventNames": "() => (string | symbol)[]", + "Pool.expiredCount": "number", + "Pool.getMaxListeners": "() => number", + "Pool.idleCount": "number", + "Pool.listenerCount": "(eventName: string | symbol, listener?: Function) => number", + "Pool.listeners": "(eventName: string | symbol) => Function[]", + "Pool.off": "(eventName: string | symbol, listener: (...args: any[]) => void) => Pool", + "Pool.on": "(event: K, listener: K extends \"error\" | \"release\" ? (err: Error, client: PoolClient) => void : (client: PoolClient) => void) => Pool", + "Pool.once": "(eventName: string | symbol, listener: (...args: any[]) => void) => Pool", + "Pool.options": "PoolOptions", + "Pool.prependListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Pool", + "Pool.prependOnceListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Pool", + "Pool.query": "{ (queryStream: T): T; (queryConfig: QueryArrayConfig, values?: QueryConfigValues): Promise>; (queryConfig: QueryConfig<...>): Promise<...>; (quer...", + "Pool.rawListeners": "(eventName: string | symbol) => Function[]", + "Pool.removeAllListeners": "(eventName?: string | symbol) => Pool", + "Pool.removeListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Pool", + "Pool.setMaxListeners": "(n: number) => Pool", + "Pool.totalCount": "number", + "Pool.waitingCount": "number", + "PoolClient": "interface: PoolClient", + "PoolClient.addListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => PoolClient", + "PoolClient.connect": "{ (): Promise; (callback: ((err: Error) => void) | ((err: null, c: Client) => void)): void; }", + "PoolClient.connection": "Connection", + "PoolClient.copyFrom": "(queryText: string) => Writable", + "PoolClient.copyTo": "(queryText: string) => Readable", + "PoolClient.database": "?string", + "PoolClient.emit": "(eventName: string | symbol, ...args: AnyRest) => boolean", + "PoolClient.end": "{ (): Promise; (callback: (err: Error) => void): void; }", + "PoolClient.escapeIdentifier": "(str: string) => string", + "PoolClient.escapeLiteral": "(str: string) => string", + "PoolClient.eventNames": "() => (string | symbol)[]", + "PoolClient.getMaxListeners": "() => number", + "PoolClient.getTransactionStatus": "() => TransactionStatus", + "PoolClient.getTypeParser": "(id: TypeId, format?: TypeFormat) => any", + "PoolClient.host": "string", + "PoolClient.listenerCount": "(eventName: string | symbol, listener?: Function) => number", + "PoolClient.listeners": "(eventName: string | symbol) => Function[]", + "PoolClient.off": "(eventName: string | symbol, listener: (...args: any[]) => void) => PoolClient", + "PoolClient.on": "(event: E, listener: E extends \"end\" | \"drain\" | \"connect\" ? () => void : E extends \"error\" ? (err: Error) => void : E extends \"notice\" ? (notice: NoticeMessage) => void : (message: Notification) => void) => PoolClient", + "PoolClient.once": "(eventName: string | symbol, listener: (...args: any[]) => void) => PoolClient", + "PoolClient.password": "?string", + "PoolClient.pauseDrain": "() => void", + "PoolClient.pipeline": "boolean", + "PoolClient.port": "number", + "PoolClient.prependListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => PoolClient", + "PoolClient.prependOnceListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => PoolClient", + "PoolClient.query": "{ (queryStream: T): T; (queryConfig: QueryArrayConfig, values?: QueryConfigValues): Promise>; (queryConfig: QueryConfig<...>): Promise<...>; (queryT...", + "PoolClient.rawListeners": "(eventName: string | symbol) => Function[]", + "PoolClient.release": "(err?: boolean | Error) => void", + "PoolClient.removeAllListeners": "(eventName?: string | symbol) => PoolClient", + "PoolClient.removeListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => PoolClient", + "PoolClient.resumeDrain": "() => void", + "PoolClient.setMaxListeners": "(n: number) => PoolClient", + "PoolClient.setTypeParser": "{ (id: TypeId, parseFn: (value: string) => any): void; (id: TypeId, format: TypeFormat, parseFn: (value: string) => any): void; }", + "PoolClient.ssl": "boolean", + "PoolClient.user": "?string", + "PoolConfig": "interface: PoolConfig", + "PoolConfig.Client": "?new () => ClientBase", + "PoolConfig.Promise": "?PromiseConstructorLike", + "PoolConfig.allowExitOnIdle": "?boolean", + "PoolConfig.application_name": "?string", + "PoolConfig.client_encoding": "?string", + "PoolConfig.connectionString": "?string", + "PoolConfig.connectionTimeoutMillis": "?number", + "PoolConfig.database": "?string", + "PoolConfig.enableChannelBinding": "?boolean", + "PoolConfig.fallback_application_name": "?string", + "PoolConfig.host": "?string", + "PoolConfig.idleTimeoutMillis": "?number", + "PoolConfig.idle_in_transaction_session_timeout": "?number", + "PoolConfig.keepAlive": "?boolean", + "PoolConfig.keepAliveInitialDelayMillis": "?number", + "PoolConfig.lock_timeout": "?number", + "PoolConfig.log": "?(...messages: any[]) => void", + "PoolConfig.max": "?number", + "PoolConfig.maxLifetimeSeconds": "?number", + "PoolConfig.maxUses": "?number", + "PoolConfig.min": "?number", + "PoolConfig.onConnect": "?(client: ClientBase) => void", + "PoolConfig.options": "?string", + "PoolConfig.password": "?string | (() => string | Promise)", + "PoolConfig.pipeline": "?boolean", + "PoolConfig.port": "?number", + "PoolConfig.query_timeout": "?number", + "PoolConfig.ssl": "?boolean | ConnectionOptions", + "PoolConfig.sslnegotiation": "?\"postgres\" | \"direct\"", + "PoolConfig.statement_timeout": "?number | false", + "PoolConfig.stream": "?() => Duplex", + "PoolConfig.types": "?CustomTypesConfig", + "PoolConfig.user": "?string", + "PoolConfig.verify": "?(client: PoolClient, done: (err?: Error) => void) => void", + "PoolOptions": "interface: PoolOptions", + "PoolOptions.Client": "?new () => ClientBase", + "PoolOptions.Promise": "?PromiseConstructorLike", + "PoolOptions.allowExitOnIdle": "boolean", + "PoolOptions.application_name": "?string", + "PoolOptions.client_encoding": "?string", + "PoolOptions.connectionString": "?string", + "PoolOptions.connectionTimeoutMillis": "?number", + "PoolOptions.database": "?string", + "PoolOptions.enableChannelBinding": "?boolean", + "PoolOptions.fallback_application_name": "?string", + "PoolOptions.host": "?string", + "PoolOptions.idleTimeoutMillis": "number", + "PoolOptions.idle_in_transaction_session_timeout": "?number", + "PoolOptions.keepAlive": "?boolean", + "PoolOptions.keepAliveInitialDelayMillis": "?number", + "PoolOptions.lock_timeout": "?number", + "PoolOptions.log": "?(...messages: any[]) => void", + "PoolOptions.max": "number", + "PoolOptions.maxLifetimeSeconds": "number", + "PoolOptions.maxUses": "number", + "PoolOptions.min": "?number", + "PoolOptions.onConnect": "?(client: ClientBase) => void", + "PoolOptions.options": "?string", + "PoolOptions.password": "?string | (() => string | Promise)", + "PoolOptions.pipeline": "?boolean", + "PoolOptions.port": "?number", + "PoolOptions.query_timeout": "?number", + "PoolOptions.ssl": "?boolean | ConnectionOptions", + "PoolOptions.sslnegotiation": "?\"postgres\" | \"direct\"", + "PoolOptions.statement_timeout": "?number | false", + "PoolOptions.stream": "?() => Duplex", + "PoolOptions.types": "?CustomTypesConfig", + "PoolOptions.user": "?string", + "PoolOptions.verify": "?(client: PoolClient, done: (err?: Error) => void) => void", + "Query": "class: Query", + "Query.addListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Query", + "Query.emit": "(eventName: string | symbol, ...args: AnyRest) => boolean", + "Query.eventNames": "() => (string | symbol)[]", + "Query.getMaxListeners": "() => number", + "Query.listenerCount": "(eventName: string | symbol, listener?: Function) => number", + "Query.listeners": "(eventName: string | symbol) => Function[]", + "Query.off": "(eventName: string | symbol, listener: (...args: any[]) => void) => Query", + "Query.on": "(event: E, listener: E extends \"row\" ? (row: R, result?: ResultBuilder) => void : E extends \"error\" ? (err: Error) => void : (result: ResultBuilder<...>) => void) => Query<...>", + "Query.once": "(eventName: string | symbol, listener: (...args: any[]) => void) => Query", + "Query.prependListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Query", + "Query.prependOnceListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Query", + "Query.rawListeners": "(eventName: string | symbol) => Function[]", + "Query.removeAllListeners": "(eventName?: string | symbol) => Query", + "Query.removeListener": "(eventName: string | symbol, listener: (...args: any[]) => void) => Query", + "Query.setMaxListeners": "(n: number) => Query", + "Query.submit": "(connection: Connection) => void", + "QueryArrayConfig": "interface: QueryArrayConfig", + "QueryArrayConfig.name": "?string", + "QueryArrayConfig.rowMode": "\"array\"", + "QueryArrayConfig.text": "string", + "QueryArrayConfig.types": "?CustomTypesConfig", + "QueryArrayConfig.values": "?QueryConfigValues", + "QueryArrayResult": "interface: QueryArrayResult", + "QueryArrayResult.command": "string", + "QueryArrayResult.fields": "FieldDef[]", + "QueryArrayResult.oid": "number", + "QueryArrayResult.rowCount": "number", + "QueryArrayResult.rows": "R[]", + "QueryConfig": "interface: QueryConfig", + "QueryConfig.name": "?string", + "QueryConfig.text": "string", + "QueryConfig.types": "?CustomTypesConfig", + "QueryConfig.values": "?QueryConfigValues", + "QueryConfigValues": "type: QueryConfigValues", + "QueryParse": "interface: QueryParse", + "QueryParse.name": "string", + "QueryParse.text": "string", + "QueryParse.types": "string[]", + "QueryResult": "interface: QueryResult", + "QueryResult.command": "string", + "QueryResult.fields": "FieldDef[]", + "QueryResult.oid": "number", + "QueryResult.rowCount": "number", + "QueryResult.rows": "R[]", + "QueryResultBase": "interface: QueryResultBase", + "QueryResultBase.command": "string", + "QueryResultBase.fields": "FieldDef[]", + "QueryResultBase.oid": "number", + "QueryResultBase.rowCount": "number", + "QueryResultRow": "interface: QueryResultRow", + "Result": "class: Result", + "Result.command": "string", + "Result.fields": "FieldDef[]", + "Result.oid": "number", + "Result.rowCount": "number", + "Result.rows": "R[]", + "ResultBuilder": "interface: ResultBuilder", + "ResultBuilder.addRow": "(row: R) => void", + "ResultBuilder.command": "string", + "ResultBuilder.fields": "FieldDef[]", + "ResultBuilder.oid": "number", + "ResultBuilder.rowCount": "number", + "ResultBuilder.rows": "R[]", + "Submittable": "interface: Submittable", + "Submittable.submit": "(connection: Connection) => void", + "TransactionStatus": "type: TransactionStatus", + "TypeOverrides": "class: TypeOverrides", + "TypeOverrides.getTypeParser": "(oid: number, format?: TypeFormat) => TypeParser", + "TypeOverrides.setTypeParser": "{ (oid: number, parseFn: TypeParser): void; (oid: number, format: \"text\", parseFn: TypeParser): void; (oid: number, format: \"binary\", parseFn: TypeParser<...>): void; }", + "defaults": "const: Defaults & ClientConfig", + "escapeIdentifier": "function: (str: string): string", + "escapeLiteral": "function: (str: string): string", + "native": "const: typeof import(\"@types/pg/index\")", + "types": "const: typeof import(\"pg-types/index\")" + } + }, + "react-dom": { + "version": "19.2.7", + "typesPackage": "@types/react-dom", + "typesVersion": "19.3.0", + "surface": { + "BrowserUsable": "interface: BrowserUsable", + "FormStatus": "type: FormStatus", + "FormStatusNotPending": "interface: FormStatusNotPending", + "FormStatusNotPending.action": "null", + "FormStatusNotPending.data": "null", + "FormStatusNotPending.method": "null", + "FormStatusNotPending.pending": "false", + "FormStatusPending": "interface: FormStatusPending", + "FormStatusPending.action": "string | ((formData: FormData) => void | Promise)", + "FormStatusPending.data": "FormData", + "FormStatusPending.method": "string", + "FormStatusPending.pending": "true", + "PreconnectOptions": "interface: PreconnectOptions", + "PreconnectOptions.crossOrigin": "?\"\" | \"anonymous\" | \"use-credentials\"", + "PreinitAs": "type: PreinitAs", + "PreinitModuleAs": "type: \"script\"", + "PreinitModuleOptions": "interface: PreinitModuleOptions", + "PreinitModuleOptions.as": "?\"script\"", + "PreinitModuleOptions.crossOrigin": "?\"\" | \"anonymous\" | \"use-credentials\"", + "PreinitModuleOptions.integrity": "?string", + "PreinitModuleOptions.nonce": "?string", + "PreinitOptions": "interface: PreinitOptions", + "PreinitOptions.as": "PreinitAs", + "PreinitOptions.crossOrigin": "?\"\" | \"anonymous\" | \"use-credentials\"", + "PreinitOptions.fetchPriority": "?\"low\" | \"high\" | \"auto\"", + "PreinitOptions.integrity": "?string", + "PreinitOptions.nonce": "?string", + "PreinitOptions.precedence": "?string", + "PreloadAs": "type: PreloadAs", + "PreloadModuleAs": "type: RequestDestination", + "PreloadModuleOptions": "interface: PreloadModuleOptions", + "PreloadModuleOptions.as": "RequestDestination", + "PreloadModuleOptions.crossOrigin": "?\"\" | \"anonymous\" | \"use-credentials\"", + "PreloadModuleOptions.integrity": "?string", + "PreloadModuleOptions.nonce": "?string", + "PreloadOptions": "interface: PreloadOptions", + "PreloadOptions.as": "PreloadAs", + "PreloadOptions.crossOrigin": "?\"\" | \"anonymous\" | \"use-credentials\"", + "PreloadOptions.fetchPriority": "?\"low\" | \"high\" | \"auto\"", + "PreloadOptions.imageSizes": "?string", + "PreloadOptions.imageSrcSet": "?string", + "PreloadOptions.integrity": "?string", + "PreloadOptions.media": "?string", + "PreloadOptions.nonce": "?string", + "PreloadOptions.referrerPolicy": "?ReferrerPolicy", + "PreloadOptions.type": "?string", + "browser": "function: (reason?: string | (() => unknown)): BrowserUsable", + "createPortal": "function: (children: ReactNode, container: Element | DocumentFragment, key?: Key): ReactPortal", + "flushSync": "function: (fn: () => R): R", + "preconnect": "function: (href: string, options?: PreconnectOptions): void", + "prefetchDNS": "function: (href: string): void", + "preinit": "function: (href: string, options?: PreinitOptions): void", + "preinitModule": "function: (href: string, options?: PreinitModuleOptions): void", + "preload": "function: (href: string, options?: PreloadOptions): void", + "preloadModule": "function: (href: string, options?: PreloadModuleOptions): void", + "requestFormReset": "function: (form: HTMLFormElement): void", + "unstable_batchedUpdates": "function: (callback: (a: A) => R, a: A): R", + "unstable_batchedUpdates#1": "function: (callback: () => R): R", + "useFormState": "function: (action: (state: Awaited) => State | Promise, initialState: Awaited, permalink?: string): [state: ...]", + "useFormState#1": "function: (action: (state: Awaited, payload: Payload) => State | Promise, initialState: Awaited, permalink?: string): [state: ...]", + "useFormStatus": "function: (): FormStatus", + "version": "const: string" + } + } + } } } diff --git a/scripts/detect-api-drift.js b/scripts/detect-api-drift.js new file mode 100644 index 00000000..a43af331 --- /dev/null +++ b/scripts/detect-api-drift.js @@ -0,0 +1,1049 @@ +#!/usr/bin/env node +/** + * detect-api-drift.js — automated dependency version drift & breaking API + * change analyzer for the build pipeline. + * + * Uses the TypeScript Compiler API to extract the exported type surface of a + * dependency (functions, classes, interfaces, members and call signatures) + * from its `.d.ts` entry point, then compares the installed surface against + * the reviewed baseline committed in `package.json` → `apiDrift.baseline`. + * + * Findings: + * - dropped exports / dropped methods (removed keys) + * - altered parameter or return types (changed values) + * - version drift with no breaking change (reported, not fatal) + * - semver-compatible (minor/patch) upgrade that introduces a breaking + * signature change → Version Pinning Guard fails the build (exit 1) + * - non-exact version ranges for protected packages (`--require-exact-pin`) + * - Rust half: unpinned version requirements in Cargo.toml + * `[workspace.dependencies]` when `require-exact-pin` is set. + * + * Usage: + * node scripts/detect-api-drift.js [--check|--update] [options] + * + * Options: + * --check Compare installed packages against the baseline + * (default). + * --update Re-extract and write the baseline into package.json. + * --packages a,b,c Override the protected package list. + * --root Package root to inspect (default: repository root). + * --config tsconfig.json holding `apiDrift.compilerOptions`. + * --require-exact-pin Fail packages declared with ^ / ~ / >= ranges. + * --json Emit machine-readable JSON instead of a report. + * -h, --help Show this help. + * + * Exit codes: 0 clean · 1 drift findings · 2 usage/config error. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +export const REPO_ROOT = path.resolve(HERE, ".."); + +export const DEFAULT_COMPILER_OPTIONS = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + moduleResolution: ts.ModuleResolutionKind.Node10, + strict: false, + skipLibCheck: true, + noEmit: true, + esModuleInterop: true, + allowJs: false, + checkJs: false, + resolveJsonModule: false, + types: [], +}; + +// --------------------------------------------------------------------------- +// Pure helpers (unit-tested without the compiler) +// --------------------------------------------------------------------------- + +function normalizeSpecifier(spec) { + const marker = "node_modules/"; + const idx = spec.lastIndexOf(marker); + if (idx !== -1) return spec.slice(idx + marker.length); + if (spec.startsWith("/") || /^[A-Za-z]:[\\/]/.test(spec)) { + const parts = spec.split(/[/\\]+/).filter(Boolean); + return parts.slice(-2).join("/"); + } + return spec; +} + +/** + * Collapse whitespace and rewrite `import("…")` module specifiers so the same + * package produces byte-identical signatures on every machine (CI runners, + * developer laptops, different checkout paths). + */ +export function normalizeSignature(text) { + if (text === undefined || text === null) return ""; + let out = String(text).replace(/\s+/g, " ").trim(); + out = out.replace(/import\(\s*(["'])([^"']*)\1\s*\)/g, (_m, q, spec) => `import(${q}${normalizeSpecifier(spec)}${q})`); + out = out.replace(/\btypeof import\(/g, "typeof import("); + return out; +} + +/** + * TypeScript renders well-known symbol members as `__@captureRejectionSymbol@123` + * where the trailing number is an internal id that changes between TS versions. + * Those names are not part of a package's authored API surface — drop them. + */ +export function isInternalMemberName(name) { + return typeof name === "string" && name.includes("@"); +} + +function parseVersion(raw) { + if (typeof raw !== "string") return null; + const m = raw.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/); + if (!m) return null; + return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), prerelease: m[4] ?? "" }; +} + +/** 'major' | 'minor' | 'patch' | 'none' | 'downgrade' | 'unknown' */ +export function classifyUpgrade(from, to) { + const a = parseVersion(from); + const b = parseVersion(to); + if (!a || !b) return "unknown"; + if (a.major !== b.major) return b.major > a.major ? "major" : "downgrade"; + if (a.minor !== b.minor) return b.minor > a.minor ? "minor" : "downgrade"; + if (a.patch !== b.patch) return b.patch > a.patch ? "patch" : "downgrade"; + if (a.prerelease !== b.prerelease) return "none"; + return "none"; +} + +/** npm: "1.2.3" is exact; "^1.2.3", "~1.2.3", ">=1.2.3", "1.x" are not. */ +export function isExactNpmPin(range) { + if (typeof range !== "string") return false; + return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(range.trim()); +} + +/** Cargo: only "=1.2.3" is exact — a bare "1.2.3" means "^1.2.3". */ +export function isExactCargoPin(requirement) { + if (typeof requirement !== "string") return false; + return /^=\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(requirement.trim()); +} + +/** + * @param {Record} before reviewed surface + * @param {Record} after installed surface + * @returns {{breaking: Array, additive: Array}} + * breaking: dropped (`removed`) or re-typed (`changed`) signatures. + * additive: brand-new signatures (never a compatibility break). + */ +export function diffSurface(before = {}, after = {}) { + const breaking = []; + const additive = []; + for (const key of Object.keys(before)) { + if (!Object.prototype.hasOwnProperty.call(after, key)) { + breaking.push({ type: "removed", key, before: before[key] }); + } else if (before[key] !== after[key]) { + breaking.push({ type: "changed", key, before: before[key], after: after[key] }); + } + } + for (const key of Object.keys(after)) { + if (!Object.prototype.hasOwnProperty.call(before, key)) { + additive.push({ type: "added", key, after: after[key] }); + } + } + return { breaking, additive }; +} + +/** + * Version Pinning Guard: a semver-compatible upgrade may never change the + * public type surface. Major upgrades are allowed to break (reported as a + * warning), everything else is an error. + */ +export function evaluateDrift({ + name, + range = "", + baselineVersion = null, + baselineTypesVersion = null, + installedVersion = null, + typesPackage = null, + typesVersion = null, + breaking = [], + additive = [], + hasBaseline = true, + requireExactPin = false, +}) { + const findings = []; + const effective = typesVersion || installedVersion || baselineVersion || null; + // The type surface comes from @types/ when a package does not bundle + // its own declarations: in that case the meaningful version pair is the + // types-package version, otherwise the runtime package version. + const typeDrift = Boolean(baselineTypesVersion && typesVersion && baselineTypesVersion !== typesVersion); + const from = typeDrift ? baselineTypesVersion : baselineVersion ?? installedVersion; + const to = typeDrift ? typesVersion : installedVersion ?? typesVersion; + const upgrade = classifyUpgrade(from, to); + const hint = typeDrift + ? `Pin ${typesPackage ?? "the types package"} to "${baselineTypesVersion}" or re-baseline after reviewing the full diff.` + : `Pin ${name} to "${baselineVersion ?? "the reviewed version"}" or re-baseline after reviewing the full diff.`; + + if (!hasBaseline) { + findings.push({ + level: "error", + code: "missing-baseline", + message: `${name}: no baseline recorded — run \`npm run security:api-drift:update\` and review the diff before committing`, + }); + } else if (breaking.length > 0) { + const compatible = upgrade === "none" || upgrade === "minor" || upgrade === "patch"; + const detail = breaking + .slice(0, 3) + .map((b) => (b.type === "removed" ? `-${b.key}` : `~${b.key}`)) + .join(", "); + const more = breaking.length > 3 ? ` (+${breaking.length - 3} more)` : ""; + if (compatible) { + findings.push({ + level: "error", + code: "breaking-compatible-upgrade", + message: + `${name}: breaking API change in a ${upgrade} upgrade ` + + `(${from} → ${to})${typeDrift ? ` via ${typesPackage}` : ""}: ${detail}${more}. ${hint}`, + }); + } else if (upgrade === "major") { + findings.push({ + level: "warning", + code: "breaking-major-upgrade", + message: `${name}: breaking API change in major upgrade ${from} → ${to}: ${detail}${more} (expected for a major bump — re-baseline after migrating call sites)`, + }); + } else { + findings.push({ + level: "error", + code: "breaking-unexpected-version-change", + message: `${name}: breaking API change while the version moved ${upgrade} (${from} → ${to}): ${detail}${more}`, + }); + } + } + + if (requireExactPin && range && !isExactNpmPin(range)) { + findings.push({ + level: "error", + code: "range-not-exact", + message: `${name}: declared range "${range}" allows automatic minor upgrades — pin it exactly ("${installedVersion ?? baselineVersion ?? ""}")`, + }); + } + + return { + name, + range, + baselineVersion, + baselineTypesVersion, + installedVersion, + typesPackage, + typesVersion, + effectiveVersion: effective, + upgrade, + breaking, + additive, + findings, + ok: !findings.some((f) => f.level === "error"), + }; +} + +// --------------------------------------------------------------------------- +// Minimal TOML reader (comments, sections, multi-line arrays, inline tables) +// --------------------------------------------------------------------------- + +function tomlLogicalLines(text) { + const lines = []; + let buf = ""; + let depth = 0; + let quote = null; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (quote) { + buf += ch; + if (ch === quote && text[i - 1] !== "\\") quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + buf += ch; + continue; + } + if (ch === "#") { + while (i < text.length && text[i] !== "\n") i++; + continue; + } + if (ch === "[" || ch === "{") depth++; + if (ch === "]" || ch === "}") depth = Math.max(0, depth - 1); + if (ch === "\n" && depth === 0) { + lines.push(buf); + buf = ""; + continue; + } + buf += ch; + } + if (buf.trim()) lines.push(buf); + return lines; +} + +function parseTomlValue(raw) { + const value = raw.trim(); + if (value.startsWith("[") && value.endsWith("]")) { + const inner = value.slice(1, -1).trim(); + if (!inner) return []; + return splitTopLevel(inner).map(parseTomlValue); + } + if (value.startsWith("{") && value.endsWith("}")) { + const inner = value.slice(1, -1).trim(); + const table = {}; + for (const pair of splitTopLevel(inner)) { + const eq = pair.indexOf("="); + if (eq === -1) continue; + table[pair.slice(0, eq).trim().replace(/^["']|["']$/g, "")] = parseTomlValue(pair.slice(eq + 1)); + } + return table; + } + if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) return value.slice(1, -1); + if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) return value.slice(1, -1); + if (value === "true") return true; + if (value === "false") return false; + return value; +} + +function splitTopLevel(text) { + const parts = []; + let buf = ""; + let depth = 0; + let quote = null; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (quote) { + buf += ch; + if (ch === quote && text[i - 1] !== "\\") quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + buf += ch; + continue; + } + if (ch === "[" || ch === "{") depth++; + if (ch === "]" || ch === "}") depth--; + if (ch === "," && depth === 0) { + parts.push(buf.trim()); + buf = ""; + continue; + } + buf += ch; + } + if (buf.trim()) parts.push(buf.trim()); + return parts; +} + +/** + * Parse the subset of TOML used by Cargo manifests. + * @returns {{sections: Record>, error: string|null}} + */ +export function parseCargoManifest(text) { + const sections = {}; + let current = null; + for (const raw of tomlLogicalLines(text)) { + const line = raw.trim(); + if (!line) continue; + const header = line.match(/^\[([^\]]+)\]$/); + if (header) { + current = header[1].trim(); + if (!sections[current]) sections[current] = {}; + continue; + } + const kv = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*([\s\S]+)$/); + if (!kv) return { sections, error: `unparsable line: ${line}` }; + if (!current) return { sections, error: `key outside of a section: ${line}` }; + sections[current][kv[1]] = parseTomlValue(kv[2]); + } + return { sections, error: null }; +} + +const DEP_SECTIONS = ["workspace.dependencies", "dependencies", "dev-dependencies"]; + +/** + * Rust half of the version pinning guard. + * @param {string} text Cargo.toml contents + * @param {{requireExactPin?: boolean, protectedCrates?: string[]}} [overrides] + */ +export function checkCargoPinning(text, overrides = {}) { + const { sections, error } = parseCargoManifest(text); + const findings = []; + if (error) { + findings.push({ level: "error", code: "invalid-manifest", message: `Cargo.toml: ${error}` }); + return { ok: false, findings, config: null }; + } + + const meta = sections["workspace.metadata.api-drift"]; + if (!meta) { + findings.push({ + level: "error", + code: "missing-guard-config", + message: "Cargo.toml: missing [workspace.metadata.api-drift] (require-exact-pin / protected-crates)", + }); + return { ok: false, findings, config: null }; + } + + const requireExactPin = overrides.requireExactPin ?? Boolean(meta["require-exact-pin"]); + const protectedCrates = overrides.protectedCrates ?? (Array.isArray(meta["protected-crates"]) ? meta["protected-crates"] : []); + const config = { requireExactPin, protectedCrates }; + + for (const section of DEP_SECTIONS) { + const deps = sections[section]; + if (!deps) continue; + for (const [name, spec] of Object.entries(deps)) { + const requirement = + spec && typeof spec === "object" && !Array.isArray(spec) ? String(spec.version ?? "") : String(spec ?? ""); + const mustPin = requireExactPin || protectedCrates.includes(name); + if (!requirement) continue; + if (mustPin && !isExactCargoPin(requirement)) { + findings.push({ + level: "error", + code: "range-not-exact", + message: `Cargo.toml [${section}] ${name} = "${requirement}" — use "=${requirement.replace(/^=/, "")}" so cargo update cannot pull a semver-compatible API change`, + }); + } + } + } + + return { ok: !findings.some((f) => f.level === "error"), findings, config }; +} + +// --------------------------------------------------------------------------- +// TypeScript API surface extraction +// --------------------------------------------------------------------------- + +function createHost(files, options) { + const host = ts.createCompilerHost(options, true); + const originalGetSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (fileName, languageVersionOrOptions, onError, shouldCreateNewSourceFile) => { + if (Object.prototype.hasOwnProperty.call(files, fileName)) { + const text = files[fileName]; + const kind = fileName.endsWith(".tsx") + ? ts.ScriptKind.TSX + : fileName.endsWith(".js") || fileName.endsWith(".mjs") + ? ts.ScriptKind.JS + : ts.ScriptKind.TS; + return ts.createSourceFile(fileName, text, languageVersionOrOptions, true, kind); + } + return originalGetSourceFile(fileName, languageVersionOrOptions, onError, shouldCreateNewSourceFile); + }; + const originalFileExists = host.fileExists.bind(host); + host.fileExists = (fileName) => + Object.prototype.hasOwnProperty.call(files, fileName) || originalFileExists(fileName); + const originalReadFile = host.readFile.bind(host); + host.readFile = (fileName) => + Object.prototype.hasOwnProperty.call(files, fileName) ? files[fileName] : originalReadFile(fileName); + return host; +} + +function resolveAlias(symbol, checker) { + if (symbol.flags & ts.SymbolFlags.Alias) { + try { + const target = checker.getAliasedSymbol(symbol); + if (target && target.flags && !(target.flags & ts.SymbolFlags.Unknown)) return target; + } catch { + /* fall through to the alias itself */ + } + } + return symbol; +} + +function declarationKind(decl) { + if (!decl) return "value"; + if (ts.isClassDeclaration(decl)) return "class"; + if (ts.isInterfaceDeclaration(decl)) return "interface"; + if (ts.isTypeAliasDeclaration(decl)) return "type"; + if (ts.isFunctionDeclaration(decl)) return "function"; + if (ts.isEnumDeclaration(decl)) return "enum"; + if (ts.isModuleDeclaration(decl)) return "namespace"; + if (ts.isVariableDeclaration(decl) || ts.isPropertyDeclaration(decl) || ts.isPropertySignature(decl)) return "const"; + if (ts.isExportSpecifier(decl) || ts.isExportAssignment(decl)) return "value"; + return "value"; +} + +function describeSymbol(symbol, baseKey, surface, checker, fallbackNode) { + if (!symbol) return; + const decls = symbol.getDeclarations() || []; + const decl = decls[0] || fallbackNode; + const kind = declarationKind(decls[0]); + + const valueType = checker.getTypeOfSymbolAtLocation(symbol, decl || fallbackNode); + const callSignatures = valueType.getCallSignatures(); + + if (kind === "class" || kind === "interface" || kind === "type" || kind === "namespace" || kind === "enum") { + const declared = checker.getDeclaredTypeOfSymbol(symbol); + const summary = normalizeSignature(checker.typeToString(declared)); + surface[baseKey] = `${kind}: ${summary}`.trim(); + + if (kind === "enum" && decls.some(ts.isEnumDeclaration)) { + const enumDecl = decls.find(ts.isEnumDeclaration); + for (const member of enumDecl.members) { + const memberName = member.name && ts.isIdentifier(member.name) ? member.name.text : null; + if (memberName) surface[`${baseKey}.${memberName}`] = "enum-member"; + } + return; + } + + if (kind === "class" || kind === "interface") { + for (const prop of checker.getPropertiesOfType(declared)) { + if (isInternalMemberName(prop.getName())) continue; + const propDecl = (prop.getDeclarations() || [])[0] || decl; + const optional = (prop.flags & ts.SymbolFlags.Optional) !== 0 ? "?" : ""; + const text = normalizeSignature(checker.typeToString(checker.getTypeOfSymbolAtLocation(prop, propDecl))); + surface[`${baseKey}.${prop.getName()}`] = `${optional}${text}`.trim(); + } + for (const ctor of declared.getConstructSignatures()) { + surface[`${baseKey}.new`] = normalizeSignature(checker.signatureToString(ctor)); + } + } + + if (kind === "interface" || kind === "type" || kind === "namespace") { + callSignatures.forEach((sig, index) => { + const key = index === 0 ? `${baseKey}.call` : `${baseKey}.call${index + 1}`; + surface[key] = normalizeSignature(checker.signatureToString(sig)); + }); + if (kind === "namespace") { + for (const prop of valueType.getProperties()) { + if (isInternalMemberName(prop.getName())) continue; + const propDecl = (prop.getDeclarations() || [])[0] || decl; + const text = normalizeSignature(checker.typeToString(checker.getTypeOfSymbolAtLocation(prop, propDecl))); + surface[`${baseKey}.${prop.getName()}`] = text; + } + } + } + return; + } + + if (callSignatures.length > 0) { + surface[baseKey] = `function: ${normalizeSignature(checker.signatureToString(callSignatures[0]))}`; + callSignatures.forEach((sig, index) => { + if (index === 0) return; + surface[`${baseKey}#${index}`] = `function: ${normalizeSignature(checker.signatureToString(sig))}`; + }); + return; + } + + surface[baseKey] = `${kind}: ${normalizeSignature(checker.typeToString(valueType))}`.trim(); +} + +function sortKeys(surface) { + const sorted = {}; + for (const key of Object.keys(surface).sort()) sorted[key] = surface[key]; + return sorted; +} + +/** + * Extract the exported API surface of a program's entry file. + * Works from in-memory `files` (fixtures in tests) and/or the real filesystem. + */ +export function extractSurface({ entryFile, files = {}, compilerOptions = {} }) { + const options = { ...DEFAULT_COMPILER_OPTIONS, ...compilerOptions }; + const program = ts.createProgram([entryFile], options, createHost(files, options)); + return describeProgramEntry(program, entryFile); +} + +/** Describe one entry file of an existing program (shared program for speed). */ +export function describeProgramEntry(program, entryFile) { + const checker = program.getTypeChecker(); + const sourceFile = program.getSourceFile(entryFile); + if (!sourceFile) throw new Error(`type entry not found: ${entryFile}`); + const moduleSymbol = checker.getSymbolAtLocation(sourceFile); + if (!moduleSymbol) throw new Error(`no module symbol for: ${entryFile}`); + + const surface = {}; + for (const exported of checker.getExportsOfModule(moduleSymbol)) { + const name = exported.getName(); + if (name === "default" || isInternalMemberName(name)) continue; + describeSymbol(resolveAlias(exported, checker), name, surface, checker, sourceFile); + } + + // `export = foo` modules (common in @types): the callable/class the module + // itself resolves to is not part of the export list. + const exportEquals = moduleSymbol.exports ? moduleSymbol.exports.get("export=") : undefined; + if (exportEquals) { + describeSymbol(resolveAlias(exportEquals, checker), "module", surface, checker, sourceFile); + } + + return sortKeys(surface); +} + +// --------------------------------------------------------------------------- +// Package resolution +// --------------------------------------------------------------------------- + +function isFile(p) { + try { + return fs.statSync(p).isFile(); + } catch { + return false; + } +} + +function findPackageDir(pkgName, require_) { + try { + return path.dirname(require_.resolve(`${pkgName}/package.json`)); + } catch { + /* package.json hidden by "exports" — fall back to the entry point */ + } + let entry; + try { + entry = require_.resolve(pkgName); + } catch { + return null; + } + if (!isFile(entry)) entry = path.join(entry, "index.js"); + // Walk up from the entry point. Bundles ship type-marker package.json files + // (`lib/cjs/package.json`, `lib/esm/package.json`) that must not stop the walk, + // so only a manifest with a `name` identifies the package root. + let dir = path.dirname(entry); + while (dir !== path.dirname(dir)) { + const manifestPath = path.join(dir, "package.json"); + if (isFile(manifestPath)) { + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + if (manifest && typeof manifest.name === "string" && manifest.name) return dir; + } catch { + /* unreadable or invalid manifest — keep walking */ + } + } + dir = path.dirname(dir); + } + return null; +} + +function typesPackageName(pkgName) { + if (pkgName.startsWith("@types/")) return pkgName; + if (pkgName.startsWith("@")) return `@types/${pkgName.slice(1).replace("/", "__")}`; + return `@types/${pkgName}`; +} + +/** + * Locate the `.d.ts` entry point for a package: its own bundled types first, + * then DefinitelyTyped. Returns null when the package has no types at all. + */ +export function resolveTypesEntry(pkgName, root = REPO_ROOT) { + const require_ = createRequire(path.join(path.resolve(root), "noop.js")); + const dir = findPackageDir(pkgName, require_); + if (!dir) return null; + + let manifest = {}; + try { + manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")); + } catch { + return null; + } + + const candidates = []; + if (manifest.types) candidates.push(manifest.types); + if (manifest.typings) candidates.push(manifest.typings); + const exportsField = manifest.exports; + const entryExport = exportsField && typeof exportsField === "object" ? (exportsField["."] ?? exportsField) : null; + if (entryExport && typeof entryExport === "object" && typeof entryExport.types === "string") { + candidates.push(entryExport.types); + } + if (manifest.main) { + candidates.push(manifest.main.replace(/\.(c|m)?js$/, ".d.$1ts")); + candidates.push(manifest.main.replace(/\.(c|m)?js$/, ".d.ts")); + candidates.push(path.join(path.dirname(manifest.main), "index.d.ts")); + } + candidates.push("index.d.ts"); + for (const candidate of candidates) { + const file = path.resolve(dir, candidate); + if (isFile(file)) return file; + } + + if (pkgName.startsWith("@types/")) return null; + return resolveTypesEntry(typesPackageName(pkgName), root); +} + +/** Installed version + type-provider metadata for a package. */ +export function readPackageMeta(pkgName, root = REPO_ROOT) { + const require_ = createRequire(path.join(path.resolve(root), "noop.js")); + const dir = findPackageDir(pkgName, require_); + if (!dir) return null; + const read = (name) => { + try { + return JSON.parse(fs.readFileSync(path.join(name, "package.json"), "utf8")); + } catch { + return null; + } + }; + const manifest = read(dir) || {}; + const entry = resolveTypesEntry(pkgName, root); + let typesPackage = null; + let typesVersion = null; + if (entry) { + // Who owns the declarations? DefinitelyTyped packages keep them in + // node_modules/@types/, a *different* package than `pkgName`, so + // walk up from the .d.ts to the nearest manifest and stop at our own dir. + let owner = path.dirname(entry); + while (owner && owner !== dir && !isFile(path.join(owner, "package.json"))) { + const parent = path.dirname(owner); + if (parent === owner) break; + owner = parent; + } + if (owner && owner !== dir && isFile(path.join(owner, "package.json"))) { + const ownerManifest = read(owner); + if (ownerManifest) { + typesPackage = ownerManifest.name ?? null; + typesVersion = ownerManifest.version ?? null; + } + } + } + return { + version: manifest.version ?? null, + entry, + typesPackage: typesPackage && typesPackage !== pkgName ? typesPackage : null, + typesVersion, + }; +} + +/** + * Extract surfaces for several packages with one shared program. + * @returns {Record, meta: object}>} + */ +export function extractPackageSurfaces(packages, { root = REPO_ROOT, compilerOptions = {} } = {}) { + const entries = []; + const skipped = {}; + for (const pkg of packages) { + const meta = readPackageMeta(pkg, root); + if (!meta || !meta.entry) { + skipped[pkg] = "no TypeScript declarations found"; + continue; + } + entries.push({ pkg, meta }); + } + const options = { ...DEFAULT_COMPILER_OPTIONS, ...compilerOptions }; + const program = ts.createProgram( + entries.map((e) => e.meta.entry), + options, + createHost({}, options), + ); + const out = {}; + for (const { pkg, meta } of entries) { + out[pkg] = { surface: describeProgramEntry(program, meta.entry), meta }; + } + return { surfaces: out, skipped }; +} + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +function readJsonc(filePath) { + const raw = fs.readFileSync(filePath, "utf8"); + if (filePath.endsWith(".json")) { + const parsed = ts.readConfigFile(filePath, (p) => fs.readFileSync(p, "utf8")); + if (parsed.error) throw new Error(`failed to parse ${filePath}: ${ts.flattenDiagnosticMessageText(parsed.error.messageText, " ")}`); + return parsed.config ?? {}; + } + return JSON.parse(raw); +} + +/** + * Merge tool configuration: + * - `apiDrift.compilerOptions` from tsconfig.json (extraction program) + * - `apiDrift` from /package.json (packages + baseline) + */ +export function loadConfig({ root = REPO_ROOT, configPath = null } = {}) { + const manifestPath = path.join(path.resolve(root), "package.json"); + if (!fs.existsSync(manifestPath)) throw new Error(`no package.json under ${root}`); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + + const candidates = [configPath, path.join(path.resolve(root), "tsconfig.json"), path.join(REPO_ROOT, "tsconfig.json")].filter( + Boolean, + ); + const tsconfigPath = candidates.find((p) => fs.existsSync(p)); + const tsconfig = tsconfigPath ? readJsonc(tsconfigPath) : {}; + + const fromTsconfig = tsconfig.apiDrift ?? {}; + const fromManifest = manifest.apiDrift ?? {}; + + let compilerOptions = {}; + if (fromTsconfig.compilerOptions) { + const converted = ts.convertCompilerOptionsFromJson(fromTsconfig.compilerOptions, path.dirname(tsconfigPath ?? REPO_ROOT)); + if (converted.errors.length) { + const first = ts.flattenDiagnosticMessageText(converted.errors[0].messageText, " "); + throw new Error(`apiDrift.compilerOptions in ${tsconfigPath}: ${first}`); + } + compilerOptions = converted.options; + } + + return { + root: path.resolve(root), + manifestPath, + manifest, + tsconfigPath, + packages: fromManifest.packages ?? fromTsconfig.packages ?? [], + baseline: fromManifest.baseline ?? {}, + requireExactPin: Boolean(fromManifest.requireExactPin ?? fromTsconfig.requireExactPin ?? false), + compilerOptions, + }; +} + +// --------------------------------------------------------------------------- +// Check / update orchestration +// --------------------------------------------------------------------------- + +function sortObject(obj) { + const out = {}; + for (const key of Object.keys(obj).sort()) out[key] = obj[key]; + return out; +} + +/** + * Compare installed surfaces against the committed baseline. + * @returns {{results: Array, cargo: object|null, ok: boolean}} + */ +export function runCheck({ root = REPO_ROOT, configPath = null, packages: packageOverride = null, requireExactPin = null } = {}) { + const config = loadConfig({ root, configPath }); + const packages = packageOverride ?? config.packages; + const pinning = requireExactPin ?? config.requireExactPin; + const { surfaces, skipped } = extractPackageSurfaces(packages, { root: config.root, compilerOptions: config.compilerOptions }); + + const results = packages.map((pkg) => { + const range = config.manifest.dependencies?.[pkg] ?? config.manifest.devDependencies?.[pkg] ?? ""; + const baselineEntry = config.baseline[pkg]; + const meta = surfaces[pkg]?.meta ?? null; + + if (!meta) { + const reason = skipped[pkg] ?? "package not installed"; + const drift = evaluateDrift({ + name: pkg, + range, + baselineVersion: baselineEntry?.version ?? null, + installedVersion: null, + breaking: [], + additive: [], + // The finding below already carries the whole story for this branch. + hasBaseline: true, + requireExactPin: pinning, + }); + drift.skipped = reason; + drift.findings.push({ + level: "error", + code: "types-unavailable", + message: `${pkg}: ${reason}${baselineEntry ? "" : " (and no baseline recorded)"}`, + }); + drift.ok = false; + return drift; + } + + const { breaking, additive } = diffSurface(baselineEntry?.surface ?? {}, surfaces[pkg].surface); + return evaluateDrift({ + name: pkg, + range, + baselineVersion: baselineEntry?.version ?? null, + baselineTypesVersion: baselineEntry?.typesVersion ?? null, + installedVersion: meta.version, + typesPackage: meta.typesPackage, + typesVersion: meta.typesVersion, + breaking, + additive, + hasBaseline: Boolean(baselineEntry), + requireExactPin: pinning, + }); + }); + + const cargo = checkRepoCargoManifest(config.root); + const ok = results.every((r) => r.ok) && (cargo ? cargo.ok : true); + return { config, results, cargo, ok, surfaces }; +} + +function checkRepoCargoManifest(root) { + const cargoPath = path.join(root, "Cargo.toml"); + if (!fs.existsSync(cargoPath)) return null; + return checkCargoPinning(fs.readFileSync(cargoPath, "utf8")); +} + +/** Re-extract the surfaces and write them back into package.json (merges into any existing baseline). */ +export function runUpdate({ root = REPO_ROOT, configPath = null, packages: packageOverride = null } = {}) { + const config = loadConfig({ root, configPath }); + const requested = packageOverride ?? config.packages; + const { surfaces, skipped } = extractPackageSurfaces(requested, { root: config.root, compilerOptions: config.compilerOptions }); + + const next = JSON.parse(fs.readFileSync(config.manifestPath, "utf8")); + const apiDrift = { ...(next.apiDrift ?? {}) }; + const packages = [...(apiDrift.packages ?? [])]; + const baseline = { ...(apiDrift.baseline ?? {}) }; + + for (const pkg of requested) { + const found = surfaces[pkg]; + if (!found) { + throw new Error(`${pkg}: cannot baseline — ${skipped[pkg] ?? "package not installed"}`); + } + baseline[pkg] = { + version: found.meta.version, + ...(found.meta.typesPackage ? { typesPackage: found.meta.typesPackage, typesVersion: found.meta.typesVersion } : {}), + surface: sortObject(found.surface), + }; + if (!packages.includes(pkg)) packages.push(pkg); + } + + apiDrift.packages = packages; + apiDrift.baseline = sortObject(baseline); + next.apiDrift = apiDrift; + fs.writeFileSync(config.manifestPath, `${JSON.stringify(next, null, 2)}\n`); + return { manifestPath: config.manifestPath, baseline, count: requested.length }; +} + +// --------------------------------------------------------------------------- +// Reporting / CLI +// --------------------------------------------------------------------------- + +function formatResult(result) { + const via = result.typesPackage + ? ` via ${result.typesPackage}${result.typesVersion ? `@${result.typesVersion}` : ""}` + : ""; + const head = ` ${result.name.padEnd(24)} ${result.installedVersion ?? "(not installed)"}${via} baseline ${result.baselineVersion ?? "—"} (${result.upgrade}${ + result.breaking.length ? `, ${result.breaking.length} breaking` : "" + }, ${result.additive.length} added)`; + const lines = [head]; + for (const finding of result.findings) { + lines.push(` [${finding.level}] ${finding.message}`); + } + for (const b of result.breaking.slice(0, 10)) { + if (b.type === "removed") lines.push(` - removed ${b.key}: ${b.before}`); + else lines.push(` ~ changed ${b.key}: ${b.before} → ${b.after}`); + } + if (result.breaking.length > 10) lines.push(` … ${result.breaking.length - 10} more breaking signatures`); + return lines.join("\n"); +} + +function formatReport({ results, cargo, ok }) { + const lines = ["API drift report", "================"]; + for (const result of results) lines.push(formatResult(result)); + if (cargo) { + lines.push("", "Rust version pinning guard", "--------------------------"); + if (cargo.findings.length === 0) lines.push(" Cargo.toml pinned as configured."); + for (const finding of cargo.findings) lines.push(` [${finding.level}] ${finding.message}`); + } + lines.push("", `verdict: ${ok ? "PASS — no unreviewed breaking API changes" : "FAIL — breaking API change or unpinned version range detected"}`); + return lines.join("\n"); +} + +function parseArgs(argv) { + const opts = { mode: "check", json: false, packages: null, root: REPO_ROOT, configPath: null, requireExactPin: null }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + const next = () => { + const value = argv[++i]; + if (value === undefined) throw new Error(`${arg} requires a value`); + return value; + }; + switch (arg) { + case "--check": + opts.mode = "check"; + break; + case "--update": + opts.mode = "update"; + break; + case "--packages": + opts.packages = next() + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + break; + case "--root": + opts.root = path.resolve(next()); + break; + case "--config": + opts.configPath = path.resolve(next()); + break; + case "--require-exact-pin": + opts.requireExactPin = true; + break; + case "--json": + opts.json = true; + break; + case "-h": + case "--help": + opts.mode = "help"; + break; + default: + throw new Error(`unknown option: ${arg}`); + } + } + return opts; +} + +function usage() { + const header = fs.readFileSync(fileURLToPath(import.meta.url), "utf8"); + const lines = header.split("\n"); + const start = lines.findIndex((line) => line.startsWith("/**")); + const out = []; + for (let i = start + 1; i < lines.length; i++) { + const line = lines[i]; + if (!line.startsWith(" *") || line.trim() === "*/") break; + out.push(line.replace(/^ \* ?/, "")); + } + return out.join("\n"); +} + +export function main(argv = process.argv.slice(2)) { + let opts; + try { + opts = parseArgs(argv); + } catch (err) { + console.error(`api-drift: ${err.message}`); + return 2; + } + if (opts.mode === "help") { + console.log(usage()); + return 0; + } + + try { + if (opts.mode === "update") { + const { manifestPath, count } = runUpdate({ + root: opts.root, + configPath: opts.configPath, + packages: opts.packages, + }); + console.log(`api-drift: baseline updated for ${count} package(s) → ${path.relative(REPO_ROOT, manifestPath)}`); + return 0; + } + + const outcome = runCheck({ + root: opts.root, + configPath: opts.configPath, + packages: opts.packages, + requireExactPin: opts.requireExactPin, + }); + if (opts.json) { + console.log( + JSON.stringify( + { + ok: outcome.ok, + results: outcome.results.map((r) => ({ + name: r.name, + baselineVersion: r.baselineVersion, + installedVersion: r.installedVersion, + typesPackage: r.typesPackage, + typesVersion: r.typesVersion, + upgrade: r.upgrade, + breaking: r.breaking, + additiveCount: r.additive.length, + findings: r.findings, + ok: r.ok, + })), + cargo: outcome.cargo, + }, + null, + 2, + ), + ); + } else { + console.log(formatReport(outcome)); + } + return outcome.ok ? 0 : 1; + } catch (err) { + console.error(`api-drift: ${err.message}`); + return 2; + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exit(main()); +} diff --git a/server/package.json b/server/package.json index 3348d626..989c19a3 100644 --- a/server/package.json +++ b/server/package.json @@ -8,7 +8,9 @@ "build": "node -e \"process.exit(0)\"", "test": "node --test *.test.js", "audit:deps": "node ../scripts/audit-deps.js --lock server/package-lock.json --no-write", - "security:egress": "bash ../scripts/monitor-build-egress.sh --strict -- npm run build" + "security:egress": "bash ../scripts/monitor-build-egress.sh --strict -- npm run build", + "security:api-drift": "node ../scripts/detect-api-drift.js --root . --check", + "security:api-drift:update": "node ../scripts/detect-api-drift.js --root . --update" }, "dependencies": { "@aztec/bb.js": "^0.87.9", @@ -19,5 +21,1134 @@ }, "devDependencies": { "supertest": "^7.0.0" + }, + "apiDrift": { + "packages": [ + "@stellar/stellar-sdk", + "@aztec/bb.js", + "@noir-lang/noir_js" + ], + "baseline": { + "@aztec/bb.js": { + "version": "0.87.9", + "surface": { + "AztecClientBackend": "class: AztecClientBackend", + "AztecClientBackend.acirBuf": "Uint8Array[]", + "AztecClientBackend.api": "Barretenberg", + "AztecClientBackend.destroy": "() => Promise", + "AztecClientBackend.gates": "() => Promise", + "AztecClientBackend.instantiate": "any", + "AztecClientBackend.options": "BackendOptions", + "AztecClientBackend.prove": "(witnessBuf: Uint8Array[], vksBuf?: Uint8Array[]) => Promise<[Uint8Array, Uint8Array<...>]>", + "AztecClientBackend.verify": "(proof: Uint8Array, vk: Uint8Array) => Promise", + "BackendOptions": "type: BackendOptions", + "Barretenberg": "class: Barretenberg", + "Barretenberg.acirGatesAztecClient": "(ivcInputsBuf: Uint8Array) => Promise>", + "Barretenberg.acirGetCircuitSizes": "(constraintSystemBuf: Uint8Array, recursive: boolean, honkRecursion: boolean) => Promise<[number, number]>", + "Barretenberg.acirGetProvingKey": "(acirComposerPtr: Ptr, acirVec: Uint8Array, recursive: boolean) => Promise>", + "Barretenberg.acirGetSolidityVerifier": "(acirComposerPtr: Ptr) => Promise", + "Barretenberg.acirGetVerificationKey": "(acirComposerPtr: Ptr) => Promise>", + "Barretenberg.acirHonkSolidityVerifier": "(proofBuf: Uint8Array, vkBuf: Uint8Array) => Promise", + "Barretenberg.acirInitSRS": "(bytecode: Uint8Array, recursive: boolean, honkRecursion: boolean) => Promise", + "Barretenberg.acirInitVerificationKey": "(acirComposerPtr: Ptr) => Promise", + "Barretenberg.acirLoadVerificationKey": "(acirComposerPtr: Ptr, vkBuf: Uint8Array) => Promise", + "Barretenberg.acirProofAsFieldsUltraHonk": "(proofBuf: Uint8Array) => Promise", + "Barretenberg.acirProveAndVerifyMegaHonk": "(constraintSystemBuf: Uint8Array, witnessBuf: Uint8Array) => Promise", + "Barretenberg.acirProveAndVerifyUltraHonk": "(constraintSystemBuf: Uint8Array, witnessBuf: Uint8Array) => Promise", + "Barretenberg.acirProveAztecClient": "(ivcInputsBuf: Uint8Array) => Promise<[Uint8Array, Uint8Array]>", + "Barretenberg.acirProveUltraHonk": "(acirVec: Uint8Array, witnessVec: Uint8Array) => Promise>", + "Barretenberg.acirProveUltraKeccakHonk": "(acirVec: Uint8Array, witnessVec: Uint8Array) => Promise>", + "Barretenberg.acirProveUltraKeccakZkHonk": "(acirVec: Uint8Array, witnessVec: Uint8Array) => Promise>", + "Barretenberg.acirProveUltraStarknetHonk": "(acirVec: Uint8Array, witnessVec: Uint8Array) => Promise>", + "Barretenberg.acirProveUltraStarknetZkHonk": "(acirVec: Uint8Array, witnessVec: Uint8Array) => Promise>", + "Barretenberg.acirSerializeProofIntoFields": "(acirComposerPtr: Ptr, proofBuf: Uint8Array, numInnerPublicInputs: number) => Promise", + "Barretenberg.acirSerializeVerificationKeyIntoFields": "(acirComposerPtr: Ptr) => Promise<[Fr[], Fr]>", + "Barretenberg.acirVerifyAztecClient": "(proofBuf: Uint8Array, vkBuf: Uint8Array) => Promise", + "Barretenberg.acirVerifyProof": "(acirComposerPtr: Ptr, proofBuf: Uint8Array) => Promise", + "Barretenberg.acirVerifyUltraHonk": "(proofBuf: Uint8Array, vkBuf: Uint8Array) => Promise", + "Barretenberg.acirVerifyUltraKeccakHonk": "(proofBuf: Uint8Array, vkBuf: Uint8Array) => Promise", + "Barretenberg.acirVerifyUltraKeccakZkHonk": "(proofBuf: Uint8Array, vkBuf: Uint8Array) => Promise", + "Barretenberg.acirVerifyUltraStarknetHonk": "(proofBuf: Uint8Array, vkBuf: Uint8Array) => Promise", + "Barretenberg.acirVerifyUltraStarknetZkHonk": "(proofBuf: Uint8Array, vkBuf: Uint8Array) => Promise", + "Barretenberg.acirVkAsFieldsMegaHonk": "(vkBuf: Uint8Array) => Promise", + "Barretenberg.acirVkAsFieldsUltraHonk": "(vkBuf: Uint8Array) => Promise", + "Barretenberg.acirWriteVkUltraHonk": "(acirVec: Uint8Array) => Promise>", + "Barretenberg.acirWriteVkUltraKeccakHonk": "(acirVec: Uint8Array) => Promise>", + "Barretenberg.acirWriteVkUltraKeccakZkHonk": "(acirVec: Uint8Array) => Promise>", + "Barretenberg.acirWriteVkUltraStarknetHonk": "(acirVec: Uint8Array) => Promise>", + "Barretenberg.acirWriteVkUltraStarknetZkHonk": "(acirVec: Uint8Array) => Promise>", + "Barretenberg.aesDecryptBufferCbc": "(input: Uint8Array, iv: Uint8Array, key: Uint8Array, length: number) => Promise<...>", + "Barretenberg.aesEncryptBufferCbc": "(input: Uint8Array, iv: Uint8Array, key: Uint8Array, length: number) => Promise<...>", + "Barretenberg.blake2s": "(data: Uint8Array) => Promise", + "Barretenberg.blake2sToField": "(data: Uint8Array) => Promise", + "Barretenberg.commonInitSlabAllocator": "(circuitSize: number) => Promise", + "Barretenberg.destroy": "() => Promise", + "Barretenberg.getDefaultSrsSize": "() => number", + "Barretenberg.getNumThreads": "() => Promise", + "Barretenberg.getWasm": "() => BarretenbergWasmMainWorker", + "Barretenberg.initSRSClientIVC": "(srsSize?: number) => Promise", + "Barretenberg.initSRSForCircuitSize": "(circuitSize: number) => Promise", + "Barretenberg.options": "any", + "Barretenberg.pedersenCommit": "(inputsBuffer: Fr[], ctxIndex: number) => Promise", + "Barretenberg.pedersenHash": "(inputsBuffer: Fr[], hashIndex: number) => Promise", + "Barretenberg.pedersenHashBuffer": "(inputBuffer: Uint8Array, hashIndex: number) => Promise", + "Barretenberg.pedersenHashes": "(inputsBuffer: Fr[], hashIndex: number) => Promise", + "Barretenberg.poseidon2Hash": "(inputsBuffer: Fr[]) => Promise", + "Barretenberg.poseidon2HashAccumulate": "(inputsBuffer: Fr[]) => Promise", + "Barretenberg.poseidon2Hashes": "(inputsBuffer: Fr[]) => Promise", + "Barretenberg.poseidon2Permutation": "(inputsBuffer: Fr[]) => Promise", + "Barretenberg.srsInitGrumpkinSrs": "(pointsBuf: Uint8Array, numPoints: number) => Promise", + "Barretenberg.srsInitSrs": "(pointsBuf: Uint8Array, numPoints: number, g2PointBuf: Uint8Array) => Promise", + "Barretenberg.testThreads": "(threads: number, iterations: number) => Promise", + "Barretenberg.wasm": "BarretenbergWasmMainWorker", + "Barretenberg.worker": "any", + "BarretenbergSync": "class: BarretenbergSync", + "BarretenbergSync.acirGatesAztecClient": "(ivcInputsBuf: Uint8Array) => Uint8Array", + "BarretenbergSync.acirGetCircuitSizes": "(constraintSystemBuf: Uint8Array, recursive: boolean, honkRecursion: boolean) => [number, number]", + "BarretenbergSync.acirGetProvingKey": "(acirComposerPtr: Ptr, acirVec: Uint8Array, recursive: boolean) => Uint8Array", + "BarretenbergSync.acirGetSolidityVerifier": "(acirComposerPtr: Ptr) => string", + "BarretenbergSync.acirGetVerificationKey": "(acirComposerPtr: Ptr) => Uint8Array", + "BarretenbergSync.acirHonkSolidityVerifier": "(proofBuf: Uint8Array, vkBuf: Uint8Array) => string", + "BarretenbergSync.acirInitVerificationKey": "(acirComposerPtr: Ptr) => void", + "BarretenbergSync.acirLoadVerificationKey": "(acirComposerPtr: Ptr, vkBuf: Uint8Array) => void", + "BarretenbergSync.acirProofAsFieldsUltraHonk": "(proofBuf: Uint8Array) => Fr[]", + "BarretenbergSync.acirProveAndVerifyMegaHonk": "(constraintSystemBuf: Uint8Array, witnessBuf: Uint8Array) => boolean", + "BarretenbergSync.acirProveAndVerifyUltraHonk": "(constraintSystemBuf: Uint8Array, witnessBuf: Uint8Array) => boolean", + "BarretenbergSync.acirProveAztecClient": "(ivcInputsBuf: Uint8Array) => [Uint8Array, Uint8Array]", + "BarretenbergSync.acirProveUltraHonk": "(acirVec: Uint8Array, witnessVec: Uint8Array) => Uint8Array", + "BarretenbergSync.acirProveUltraKeccakHonk": "(acirVec: Uint8Array, witnessVec: Uint8Array) => Uint8Array", + "BarretenbergSync.acirProveUltraKeccakZkHonk": "(acirVec: Uint8Array, witnessVec: Uint8Array) => Uint8Array", + "BarretenbergSync.acirProveUltraStarknetHonk": "(acirVec: Uint8Array, witnessVec: Uint8Array) => Uint8Array", + "BarretenbergSync.acirProveUltraStarknetZkHonk": "(acirVec: Uint8Array, witnessVec: Uint8Array) => Uint8Array", + "BarretenbergSync.acirSerializeProofIntoFields": "(acirComposerPtr: Ptr, proofBuf: Uint8Array, numInnerPublicInputs: number) => Fr[]", + "BarretenbergSync.acirSerializeVerificationKeyIntoFields": "(acirComposerPtr: Ptr) => [Fr[], Fr]", + "BarretenbergSync.acirVerifyAztecClient": "(proofBuf: Uint8Array, vkBuf: Uint8Array) => boolean", + "BarretenbergSync.acirVerifyProof": "(acirComposerPtr: Ptr, proofBuf: Uint8Array) => boolean", + "BarretenbergSync.acirVerifyUltraHonk": "(proofBuf: Uint8Array, vkBuf: Uint8Array) => boolean", + "BarretenbergSync.acirVerifyUltraKeccakHonk": "(proofBuf: Uint8Array, vkBuf: Uint8Array) => boolean", + "BarretenbergSync.acirVerifyUltraKeccakZkHonk": "(proofBuf: Uint8Array, vkBuf: Uint8Array) => boolean", + "BarretenbergSync.acirVerifyUltraStarknetHonk": "(proofBuf: Uint8Array, vkBuf: Uint8Array) => boolean", + "BarretenbergSync.acirVerifyUltraStarknetZkHonk": "(proofBuf: Uint8Array, vkBuf: Uint8Array) => boolean", + "BarretenbergSync.acirVkAsFieldsMegaHonk": "(vkBuf: Uint8Array) => Fr[]", + "BarretenbergSync.acirVkAsFieldsUltraHonk": "(vkBuf: Uint8Array) => Fr[]", + "BarretenbergSync.acirWriteVkUltraHonk": "(acirVec: Uint8Array) => Uint8Array", + "BarretenbergSync.acirWriteVkUltraKeccakHonk": "(acirVec: Uint8Array) => Uint8Array", + "BarretenbergSync.acirWriteVkUltraKeccakZkHonk": "(acirVec: Uint8Array) => Uint8Array", + "BarretenbergSync.acirWriteVkUltraStarknetHonk": "(acirVec: Uint8Array) => Uint8Array", + "BarretenbergSync.acirWriteVkUltraStarknetZkHonk": "(acirVec: Uint8Array) => Uint8Array", + "BarretenbergSync.aesDecryptBufferCbc": "(input: Uint8Array, iv: Uint8Array, key: Uint8Array, length: number) => Uint8Array<...>", + "BarretenbergSync.aesEncryptBufferCbc": "(input: Uint8Array, iv: Uint8Array, key: Uint8Array, length: number) => Uint8Array<...>", + "BarretenbergSync.blake2s": "(data: Uint8Array) => Buffer32", + "BarretenbergSync.blake2sToField": "(data: Uint8Array) => Fr", + "BarretenbergSync.commonInitSlabAllocator": "(circuitSize: number) => void", + "BarretenbergSync.getWasm": "() => BarretenbergWasmMain", + "BarretenbergSync.pedersenCommit": "(inputsBuffer: Fr[], ctxIndex: number) => Point", + "BarretenbergSync.pedersenHash": "(inputsBuffer: Fr[], hashIndex: number) => Fr", + "BarretenbergSync.pedersenHashBuffer": "(inputBuffer: Uint8Array, hashIndex: number) => Fr", + "BarretenbergSync.pedersenHashes": "(inputsBuffer: Fr[], hashIndex: number) => Fr", + "BarretenbergSync.poseidon2Hash": "(inputsBuffer: Fr[]) => Fr", + "BarretenbergSync.poseidon2HashAccumulate": "(inputsBuffer: Fr[]) => Fr", + "BarretenbergSync.poseidon2Hashes": "(inputsBuffer: Fr[]) => Fr", + "BarretenbergSync.poseidon2Permutation": "(inputsBuffer: Fr[]) => Fr[]", + "BarretenbergSync.srsInitGrumpkinSrs": "(pointsBuf: Uint8Array, numPoints: number) => void", + "BarretenbergSync.srsInitSrs": "(pointsBuf: Uint8Array, numPoints: number, g2PointBuf: Uint8Array) => void", + "BarretenbergSync.testThreads": "(threads: number, iterations: number) => number", + "BarretenbergSync.wasm": "BarretenbergWasmMain", + "BarretenbergVerifier": "class: BarretenbergVerifier", + "BarretenbergVerifier.acirComposer": "any", + "BarretenbergVerifier.api": "any", + "BarretenbergVerifier.destroy": "() => Promise", + "BarretenbergVerifier.instantiate": "() => Promise", + "BarretenbergVerifier.options": "any", + "BarretenbergVerifier.verifyUltraHonkProof": "(proofData: ProofData, verificationKey: Uint8Array) => Promise", + "Crs": "class: Crs", + "Crs.getG1Data": "() => Uint8Array", + "Crs.getG2Data": "() => Uint8Array", + "Crs.init": "() => Promise", + "Crs.logger": "any", + "Crs.numPoints": "number", + "Crs.path": "string", + "Fr": "class: Fr", + "Fr.equals": "(rhs: Fr) => boolean", + "Fr.isZero": "() => boolean", + "Fr.toBuffer": "() => Uint8Array", + "Fr.toString": "() => string", + "Fr.value": "Uint8Array", + "GrumpkinCrs": "class: GrumpkinCrs", + "GrumpkinCrs.getG1Data": "() => Uint8Array", + "GrumpkinCrs.init": "() => Promise", + "GrumpkinCrs.logger": "any", + "GrumpkinCrs.numPoints": "number", + "GrumpkinCrs.path": "string", + "ProofData": "type: ProofData", + "RawBuffer": "class: RawBuffer", + "RawBuffer.BYTES_PER_ELEMENT": "number", + "RawBuffer.at": "(index: number) => number", + "RawBuffer.buffer": "ArrayBuffer", + "RawBuffer.byteLength": "number", + "RawBuffer.byteOffset": "number", + "RawBuffer.copyWithin": "(target: number, start: number, end?: number) => RawBuffer", + "RawBuffer.entries": "() => ArrayIterator<[number, number]>", + "RawBuffer.every": "(predicate: (value: number, index: number, array: RawBuffer) => unknown, thisArg?: any) => boolean", + "RawBuffer.fill": "(value: number, start?: number, end?: number) => RawBuffer", + "RawBuffer.filter": "(predicate: (value: number, index: number, array: RawBuffer) => any, thisArg?: any) => Uint8Array", + "RawBuffer.find": "(predicate: (value: number, index: number, obj: RawBuffer) => boolean, thisArg?: any) => number", + "RawBuffer.findIndex": "(predicate: (value: number, index: number, obj: RawBuffer) => boolean, thisArg?: any) => number", + "RawBuffer.forEach": "(callbackfn: (value: number, index: number, array: RawBuffer) => void, thisArg?: any) => void", + "RawBuffer.includes": "(searchElement: number, fromIndex?: number) => boolean", + "RawBuffer.indexOf": "(searchElement: number, fromIndex?: number) => number", + "RawBuffer.join": "(separator?: string) => string", + "RawBuffer.keys": "() => ArrayIterator", + "RawBuffer.lastIndexOf": "(searchElement: number, fromIndex?: number) => number", + "RawBuffer.length": "number", + "RawBuffer.map": "(callbackfn: (value: number, index: number, array: RawBuffer) => number, thisArg?: any) => Uint8Array", + "RawBuffer.reduce": "{ (callbackfn: (previousValue: number, currentValue: number, currentIndex: number, array: RawBuffer) => number): number; (callbackfn: (previousValue: number, currentValue: number, currentIndex: number, array: RawBuffer) => number, initialValue: number): number; (callbackfn: (previousValue: U, currentValue: number...", + "RawBuffer.reduceRight": "{ (callbackfn: (previousValue: number, currentValue: number, currentIndex: number, array: RawBuffer) => number): number; (callbackfn: (previousValue: number, currentValue: number, currentIndex: number, array: RawBuffer) => number, initialValue: number): number; (callbackfn: (previousValue: U, currentValue: number...", + "RawBuffer.reverse": "() => RawBuffer", + "RawBuffer.set": "(array: ArrayLike, offset?: number) => void", + "RawBuffer.slice": "(start?: number, end?: number) => Uint8Array", + "RawBuffer.some": "(predicate: (value: number, index: number, array: RawBuffer) => unknown, thisArg?: any) => boolean", + "RawBuffer.sort": "(compareFn?: (a: number, b: number) => number) => RawBuffer", + "RawBuffer.subarray": "(begin?: number, end?: number) => Uint8Array", + "RawBuffer.toLocaleString": "{ (): string; (locales: string | string[], options?: NumberFormatOptions): string; }", + "RawBuffer.toString": "() => string", + "RawBuffer.valueOf": "() => RawBuffer", + "RawBuffer.values": "() => ArrayIterator", + "UltraHonkBackend": "class: UltraHonkBackend", + "UltraHonkBackend.acirUncompressedBytecode": "Uint8Array", + "UltraHonkBackend.api": "Barretenberg", + "UltraHonkBackend.backendOptions": "BackendOptions", + "UltraHonkBackend.circuitOptions": "CircuitOptions", + "UltraHonkBackend.destroy": "() => Promise", + "UltraHonkBackend.generateProof": "(compressedWitness: Uint8Array, options?: UltraHonkBackendOptions) => Promise", + "UltraHonkBackend.generateRecursiveProofArtifacts": "(_proof: Uint8Array, _numOfPublicInputs: number) => Promise<{ proofAsFields: string[]; vkAsFields: string[]; vkHash: string; }>", + "UltraHonkBackend.getSolidityVerifier": "(vk?: Uint8Array) => Promise", + "UltraHonkBackend.getVerificationKey": "(options?: UltraHonkBackendOptions) => Promise>", + "UltraHonkBackend.instantiate": "any", + "UltraHonkBackend.verifyProof": "(proofData: ProofData, options?: UltraHonkBackendOptions) => Promise", + "deflattenFields": "function: (flattenedFields: Uint8Array): string[]", + "randomBytes": "function: (len: number): Uint8Array", + "reconstructHonkProof": "function: (publicInputs: Uint8Array, proof: Uint8Array): Uint8Array", + "splitHonkProof": "function: (proofWithPublicInputs: Uint8Array, numPublicInputs: number): { publicInputs: Uint8Array; proof: Uint8Array<...>; }" + } + }, + "@noir-lang/noir_js": { + "version": "1.0.0-beta.9", + "surface": { + "CompiledCircuit": "type: CompiledCircuit", + "ErrorWithPayload": "type: ErrorWithPayload", + "ForeignCallHandler": "type: ForeignCallHandler", + "ForeignCallInput": "type: ForeignCallInput", + "ForeignCallOutput": "type: ForeignCallOutput", + "InputMap": "type: InputMap", + "Noir": "class: Noir", + "Noir.circuit": "any", + "Noir.execute": "(inputs: InputMap, foreignCallHandler?: ForeignCallHandler) => Promise<{ witness: Uint8Array; returnValue: InputValue; }>", + "Noir.init": "() => Promise", + "WitnessMap": "type: WitnessMap", + "abi": "value: typeof import(\"@noir-lang/noirc_abi/web/noirc_abi_wasm\")", + "acvm": "value: typeof import(\"@noir-lang/acvm_js/web/acvm_js\")", + "and": "function: (lhs: string, rhs: string): string", + "blake2s256": "function: (inputs: Uint8Array): Uint8Array", + "ecdsa_secp256k1_verify": "function: (hashed_msg: Uint8Array, public_key_x_bytes: Uint8Array, public_key_y_bytes: Uint8Array, signature: Uint8Array<...>): boolean", + "ecdsa_secp256r1_verify": "function: (hashed_msg: Uint8Array, public_key_x_bytes: Uint8Array, public_key_y_bytes: Uint8Array, signature: Uint8Array<...>): boolean", + "xor": "function: (lhs: string, rhs: string): string" + } + }, + "@stellar/stellar-sdk": { + "version": "16.0.0", + "surface": { + "Account": "class: Account", + "Account._accountId": "any", + "Account.accountId": "() => string", + "Account.incrementSequenceNumber": "() => void", + "Account.sequence": "any", + "Account.sequenceNumber": "() => string", + "AccountRequiresMemoError": "class: AccountRequiresMemoError", + "AccountRequiresMemoError.accountId": "string", + "AccountRequiresMemoError.cause": "?unknown", + "AccountRequiresMemoError.message": "string", + "AccountRequiresMemoError.name": "string", + "AccountRequiresMemoError.operationIndex": "number", + "AccountRequiresMemoError.stack": "?string", + "Address": "class: Address", + "Address._key": "any", + "Address._type": "any", + "Address.toBuffer": "() => Buffer", + "Address.toScAddress": "() => ScAddress", + "Address.toScVal": "() => ScVal", + "Address.toString": "() => string", + "Address.type": "AddressType", + "Asset": "class: Asset", + "Asset._toXDRObject": "any", + "Asset.code": "string", + "Asset.contractId": "(networkPassphrase: string) => string", + "Asset.equals": "(asset: Asset) => boolean", + "Asset.getAssetType": "() => AssetType", + "Asset.getCode": "() => string", + "Asset.getIssuer": "() => string", + "Asset.getRawAssetType": "() => AssetType", + "Asset.isNative": "() => boolean", + "Asset.issuer": "string", + "Asset.toChangeTrustXDRObject": "() => ChangeTrustAsset", + "Asset.toString": "() => string", + "Asset.toTrustLineXDRObject": "() => TrustLineAsset", + "Asset.toXDRObject": "() => Asset", + "AssetType": "const: { readonly native: \"native\"; readonly credit4: \"credit_alphanum4\"; readonly credit12: \"credit_alphanum12\"; readonly liquidityPoolShares: \"liquidity_pool_shares\"; }", + "AuthClawbackEnabledFlag": "const: number", + "AuthFlag": "const: { readonly required: 1; readonly revocable: 2; readonly immutable: 4; readonly clawbackEnabled: 8; }", + "AuthImmutableFlag": "const: number", + "AuthRequiredFlag": "const: number", + "AuthRevocableFlag": "const: number", + "AuthorizeInvocationParams": "interface: AuthorizeInvocationParams", + "AuthorizeInvocationParams.authV2": "?boolean", + "AuthorizeInvocationParams.invocation": "SorobanAuthorizedInvocation", + "AuthorizeInvocationParams.networkPassphrase": "string", + "AuthorizeInvocationParams.publicKey": "?string", + "AuthorizeInvocationParams.signer": "Keypair | SigningCallback", + "AuthorizeInvocationParams.validUntilLedgerSeq": "number", + "BASE_FEE": "const: \"100\"", + "BadRequestError": "class: BadRequestError", + "BadRequestError.cause": "?unknown", + "BadRequestError.getResponse": "() => { data?: ErrorResponseData; status?: number; statusText?: string; url?: string; }", + "BadRequestError.message": "string", + "BadRequestError.name": "string", + "BadRequestError.response": "{ data?: ErrorResponseData; status?: number; statusText?: string; url?: string; }", + "BadRequestError.stack": "?string", + "BadResponseError": "class: BadResponseError", + "BadResponseError.cause": "?unknown", + "BadResponseError.getResponse": "() => { data?: ErrorResponseData; status?: number; statusText?: string; url?: string; }", + "BadResponseError.message": "string", + "BadResponseError.name": "string", + "BadResponseError.response": "{ data?: ErrorResponseData; status?: number; statusText?: string; url?: string; }", + "BadResponseError.stack": "?string", + "BindingGenerator": "class: BindingGenerator", + "BindingGenerator.generate": "(options: GenerateOptions) => GeneratedBindings", + "BindingGenerator.spec": "any", + "BindingGenerator.validateOptions": "any", + "BuildWithDelegatesParams": "interface: BuildWithDelegatesParams", + "BuildWithDelegatesParams.delegates": "DelegateSignature[]", + "BuildWithDelegatesParams.entry": "SorobanAuthorizationEntry", + "BuildWithDelegatesParams.signature": "?ScVal", + "BuildWithDelegatesParams.validUntilLedgerSeq": "number", + "Claimant": "class: Claimant", + "Claimant._destination": "any", + "Claimant._predicate": "any", + "Claimant.destination": "string", + "Claimant.predicate": "ClaimPredicate", + "Claimant.toXDRObject": "() => Claimant", + "Config": "class: Config", + "Contract": "class: Contract", + "Contract._id": "any", + "Contract.address": "() => Address", + "Contract.call": "(method: string, ...params: ScVal[]) => Operation2", + "Contract.contractId": "() => string", + "Contract.getFootprint": "() => LedgerKey", + "Contract.toString": "() => string", + "CreateInvocation": "interface: CreateInvocation", + "CreateInvocation.asset": "?string", + "CreateInvocation.type": "\"sac\" | \"wasm\"", + "CreateInvocation.wasm": "?WasmCreateDetails", + "DelegateSignature": "interface: DelegateSignature", + "DelegateSignature.address": "string", + "DelegateSignature.nestedDelegates": "?DelegateSignature[]", + "DelegateSignature.signature": "?ScVal", + "ExecuteInvocation": "interface: ExecuteInvocation", + "ExecuteInvocation.args": "any[]", + "ExecuteInvocation.function": "string", + "ExecuteInvocation.source": "string", + "Federation": "value: typeof import(\"@stellar/stellar-sdk/lib/esm/federation/index\")", + "FeeBumpTransaction": "class: FeeBumpTransaction", + "FeeBumpTransaction._fee": "any", + "FeeBumpTransaction._feeSource": "any", + "FeeBumpTransaction._innerTransaction": "any", + "FeeBumpTransaction._networkPassphrase": "any", + "FeeBumpTransaction._signatures": "any", + "FeeBumpTransaction._tx": "any", + "FeeBumpTransaction.addDecoratedSignature": "(signature: DecoratedSignature) => void", + "FeeBumpTransaction.addSignature": "(publicKey?: string, signature?: string) => void", + "FeeBumpTransaction.fee": "string", + "FeeBumpTransaction.feeSource": "string", + "FeeBumpTransaction.getKeypairSignature": "(keypair: Keypair) => string", + "FeeBumpTransaction.hash": "() => Buffer", + "FeeBumpTransaction.innerTransaction": "Transaction", + "FeeBumpTransaction.networkPassphrase": "string", + "FeeBumpTransaction.operations": "OperationRecord[]", + "FeeBumpTransaction.sign": "(...keypairs: Keypair[]) => void", + "FeeBumpTransaction.signHashX": "(preimage: any) => void", + "FeeBumpTransaction.signatureBase": "() => Buffer", + "FeeBumpTransaction.signatures": "DecoratedSignature[]", + "FeeBumpTransaction.toEnvelope": "() => TransactionEnvelope", + "FeeBumpTransaction.toXDR": "() => string", + "FeeBumpTransaction.tx": "FeeBumpTransaction", + "Friendbot": "value: typeof import(\"@stellar/stellar-sdk/lib/esm/friendbot/index\")", + "Horizon": "value: typeof import(\"@stellar/stellar-sdk/lib/esm/horizon/index\")", + "Hyper": "value: any", + "Int128": "class: Int128", + "Int128.size": "number", + "Int128.unsigned": "boolean", + "Int256": "class: Int256", + "Int256.size": "number", + "Int256.unsigned": "boolean", + "IntLike": "type: IntLike", + "InvocationTree": "interface: InvocationTree", + "InvocationTree.args": "CreateInvocation | ExecuteInvocation", + "InvocationTree.invocations": "InvocationTree[]", + "InvocationTree.type": "\"create\" | \"execute\"", + "InvocationWalker": "type: InvocationWalker", + "Keypair": "class: Keypair", + "Keypair._publicKey": "any", + "Keypair._secretKey": "?any", + "Keypair._secretSeed": "?any", + "Keypair.canSign": "() => boolean", + "Keypair.publicKey": "() => string", + "Keypair.rawPublicKey": "() => Buffer", + "Keypair.rawSecretKey": "() => Buffer", + "Keypair.secret": "() => string", + "Keypair.sign": "(data: Buffer) => Buffer", + "Keypair.signDecorated": "(data: Buffer) => DecoratedSignature", + "Keypair.signPayloadDecorated": "(data: Buffer) => DecoratedSignature", + "Keypair.signatureHint": "() => Buffer", + "Keypair.type": "\"ed25519\"", + "Keypair.verify": "(data: Buffer, signature: Buffer) => boolean", + "Keypair.xdrAccountId": "() => PublicKey", + "Keypair.xdrMuxedAccount": "(id?: string) => MuxedAccount", + "Keypair.xdrPublicKey": "() => PublicKey", + "LiquidityPoolAsset": "class: LiquidityPoolAsset", + "LiquidityPoolAsset.assetA": "Asset", + "LiquidityPoolAsset.assetB": "Asset", + "LiquidityPoolAsset.equals": "(other: LiquidityPoolAsset) => boolean", + "LiquidityPoolAsset.fee": "number", + "LiquidityPoolAsset.getAssetType": "() => \"liquidity_pool_shares\"", + "LiquidityPoolAsset.getLiquidityPoolParameters": "() => ConstantProduct", + "LiquidityPoolAsset.toString": "() => string", + "LiquidityPoolAsset.toXDRObject": "() => ChangeTrustAsset", + "LiquidityPoolFeeV18": "const: 30", + "LiquidityPoolId": "class: LiquidityPoolId", + "LiquidityPoolId.equals": "(asset: LiquidityPoolId) => boolean", + "LiquidityPoolId.getAssetType": "() => \"liquidity_pool_shares\"", + "LiquidityPoolId.getLiquidityPoolId": "() => string", + "LiquidityPoolId.liquidityPoolId": "string", + "LiquidityPoolId.toString": "() => string", + "LiquidityPoolId.toXDRObject": "() => TrustLineAsset", + "LiquidityPoolParameters": "namespace: ConstantProduct", + "LiquidityPoolType": "namespace: \"constant_product\"", + "Memo": "class: Memo", + "Memo._type": "any", + "Memo._value": "any", + "Memo.toXDRObject": "() => Memo", + "Memo.type": "T", + "Memo.value": "MemoTypeToValue", + "MemoHash": "const: \"hash\"", + "MemoID": "const: \"id\"", + "MemoNone": "const: \"none\"", + "MemoReturn": "const: \"return\"", + "MemoText": "const: \"text\"", + "MemoType": "namespace: MemoType", + "MemoTypeHash": "type: \"hash\"", + "MemoTypeID": "type: \"id\"", + "MemoTypeNone": "type: \"none\"", + "MemoTypeReturn": "type: \"return\"", + "MemoTypeText": "type: \"text\"", + "MemoValue": "type: any", + "MuxedAccount": "class: MuxedAccount", + "MuxedAccount._id": "any", + "MuxedAccount._mAddress": "any", + "MuxedAccount._muxedXdr": "any", + "MuxedAccount.account": "any", + "MuxedAccount.accountId": "() => string", + "MuxedAccount.baseAccount": "() => Account", + "MuxedAccount.equals": "(otherMuxedAccount: MuxedAccount) => boolean", + "MuxedAccount.id": "() => string", + "MuxedAccount.incrementSequenceNumber": "() => void", + "MuxedAccount.sequenceNumber": "() => string", + "MuxedAccount.setId": "(id: string) => MuxedAccount", + "MuxedAccount.toXDRObject": "() => MuxedAccount", + "NativeToScValOpts": "interface: NativeToScValOpts", + "NativeToScValOpts.type": "?ScValType | ScValType[] | ScValMapTypeSpec", + "NetworkError": "class: NetworkError", + "NetworkError.cause": "?unknown", + "NetworkError.getResponse": "() => { data?: ErrorResponseData; status?: number; statusText?: string; url?: string; }", + "NetworkError.message": "string", + "NetworkError.name": "string", + "NetworkError.response": "{ data?: ErrorResponseData; status?: number; statusText?: string; url?: string; }", + "NetworkError.stack": "?string", + "Networks": "enum: Networks", + "Networks.FUTURENET": "enum-member", + "Networks.PUBLIC": "enum-member", + "Networks.SANDBOX": "enum-member", + "Networks.STANDALONE": "enum-member", + "Networks.TESTNET": "enum-member", + "NotFoundError": "class: NotFoundError", + "NotFoundError.cause": "?unknown", + "NotFoundError.getResponse": "() => { data?: ErrorResponseData; status?: number; statusText?: string; url?: string; }", + "NotFoundError.message": "string", + "NotFoundError.name": "string", + "NotFoundError.response": "{ data?: ErrorResponseData; status?: number; statusText?: string; url?: string; }", + "NotFoundError.stack": "?string", + "Operation": "class: Operation", + "OperationOptions": "type: OperationOptions", + "OperationRecord": "type: OperationRecord", + "OperationType": "namespace: OperationType", + "ScInt": "class: ScInt", + "ScInt._sizeCheck": "any", + "ScInt.int": "LargeInt", + "ScInt.toBigInt": "() => bigint", + "ScInt.toDuration": "() => ScVal", + "ScInt.toI128": "() => ScVal", + "ScInt.toI256": "() => ScVal", + "ScInt.toI64": "() => ScVal", + "ScInt.toJSON": "() => { value: string; type: string; }", + "ScInt.toNumber": "() => number", + "ScInt.toScVal": "() => ScVal", + "ScInt.toString": "() => string", + "ScInt.toTimepoint": "() => ScVal", + "ScInt.toU128": "() => ScVal", + "ScInt.toU256": "() => ScVal", + "ScInt.toU64": "() => ScVal", + "ScInt.type": "ScIntType", + "ScInt.valueOf": "() => unknown", + "ScIntType": "type: ScIntType", + "Signer": "namespace: Signer", + "SignerKey": "class: SignerKey", + "SigningCallback": "type: SigningCallback", + "Soroban": "class: Soroban", + "SorobanDataBuilder": "class: SorobanDataBuilder", + "SorobanDataBuilder._data": "any", + "SorobanDataBuilder.appendFootprint": "(readOnly: LedgerKey[], readWrite: LedgerKey[]) => SorobanDataBuilder", + "SorobanDataBuilder.build": "() => SorobanTransactionData", + "SorobanDataBuilder.getFootprint": "() => LedgerFootprint", + "SorobanDataBuilder.getReadOnly": "() => LedgerKey[]", + "SorobanDataBuilder.getReadWrite": "() => LedgerKey[]", + "SorobanDataBuilder.setFootprint": "(readOnly?: LedgerKey[], readWrite?: LedgerKey[]) => SorobanDataBuilder", + "SorobanDataBuilder.setReadOnly": "(readOnly?: LedgerKey[]) => SorobanDataBuilder", + "SorobanDataBuilder.setReadWrite": "(readWrite?: LedgerKey[]) => SorobanDataBuilder", + "SorobanDataBuilder.setResourceFee": "(fee: IntLike) => SorobanDataBuilder", + "SorobanDataBuilder.setResources": "(cpuInstrs: number, diskReadBytes: number, writeBytes: number) => SorobanDataBuilder", + "SorobanFees": "interface: SorobanFees", + "SorobanFees.instructions": "number", + "SorobanFees.readBytes": "number", + "SorobanFees.resourceFee": "bigint", + "SorobanFees.writeBytes": "number", + "StellarToml": "value: typeof import(\"@stellar/stellar-sdk/lib/esm/stellartoml/index\")", + "StrKey": "class: StrKey", + "TimeoutInfinite": "const: 0", + "Transaction": "class: Transaction", + "Transaction._envelopeType": "any", + "Transaction._extraSigners": "?any", + "Transaction._fee": "any", + "Transaction._ledgerBounds": "?any", + "Transaction._memo": "any", + "Transaction._minAccountSequence": "?any", + "Transaction._minAccountSequenceAge": "?any", + "Transaction._minAccountSequenceLedgerGap": "?any", + "Transaction._networkPassphrase": "any", + "Transaction._operations": "any", + "Transaction._sequence": "any", + "Transaction._signatures": "any", + "Transaction._source": "any", + "Transaction._timeBounds": "?any", + "Transaction._tx": "any", + "Transaction.addDecoratedSignature": "(signature: DecoratedSignature) => void", + "Transaction.addSignature": "(publicKey?: string, signature?: string) => void", + "Transaction.extraSigners": "SignerKey[]", + "Transaction.fee": "string", + "Transaction.getClaimableBalanceId": "(opIndex: number) => string", + "Transaction.getKeypairSignature": "(keypair: Keypair) => string", + "Transaction.hash": "() => Buffer", + "Transaction.ledgerBounds": "{ minLedger: number; maxLedger: number; }", + "Transaction.memo": "Memo", + "Transaction.minAccountSequence": "string", + "Transaction.minAccountSequenceAge": "bigint", + "Transaction.minAccountSequenceLedgerGap": "number", + "Transaction.networkPassphrase": "string", + "Transaction.operations": "OperationRecord[]", + "Transaction.sequence": "string", + "Transaction.sign": "(...keypairs: Keypair[]) => void", + "Transaction.signHashX": "(preimage: any) => void", + "Transaction.signatureBase": "() => Buffer", + "Transaction.signatures": "DecoratedSignature[]", + "Transaction.source": "string", + "Transaction.timeBounds": "{ minTime: string; maxTime: string; }", + "Transaction.toEnvelope": "() => TransactionEnvelope", + "Transaction.toXDR": "() => string", + "Transaction.tx": "TransactionV0 | Transaction", + "TransactionBase": "class: TransactionBase", + "TransactionBase._fee": "any", + "TransactionBase._networkPassphrase": "any", + "TransactionBase._signatures": "any", + "TransactionBase._tx": "any", + "TransactionBase.addDecoratedSignature": "(signature: DecoratedSignature) => void", + "TransactionBase.addSignature": "(publicKey?: string, signature?: string) => void", + "TransactionBase.fee": "string", + "TransactionBase.getKeypairSignature": "(keypair: Keypair) => string", + "TransactionBase.hash": "() => Buffer", + "TransactionBase.networkPassphrase": "string", + "TransactionBase.sign": "(...keypairs: Keypair[]) => void", + "TransactionBase.signHashX": "(preimage: any) => void", + "TransactionBase.signatureBase": "() => Buffer", + "TransactionBase.signatures": "DecoratedSignature[]", + "TransactionBase.toEnvelope": "() => TransactionEnvelope", + "TransactionBase.toXDR": "() => string", + "TransactionBase.tx": "TTx", + "TransactionBuilder": "class: TransactionBuilder", + "TransactionBuilder.addMemo": "(memo: Memo) => TransactionBuilder", + "TransactionBuilder.addOperation": "(operation: Operation2) => TransactionBuilder", + "TransactionBuilder.addOperationAt": "(operation: Operation2, index: number) => TransactionBuilder", + "TransactionBuilder.addSacTransferOperation": "(destination: string, asset: Asset, amount: string | bigint, sorobanFees?: SorobanFees) => TransactionBuilder", + "TransactionBuilder.baseFee": "string", + "TransactionBuilder.build": "() => Transaction", + "TransactionBuilder.clearOperationAt": "(index: number) => TransactionBuilder", + "TransactionBuilder.clearOperations": "() => TransactionBuilder", + "TransactionBuilder.extraSigners": "string[]", + "TransactionBuilder.hasV2Preconditions": "() => boolean", + "TransactionBuilder.ledgerbounds": "{ minLedger?: number; maxLedger?: number; }", + "TransactionBuilder.memo": "Memo", + "TransactionBuilder.minAccountSequence": "string", + "TransactionBuilder.minAccountSequenceAge": "bigint", + "TransactionBuilder.minAccountSequenceLedgerGap": "number", + "TransactionBuilder.networkPassphrase": "string", + "TransactionBuilder.operations": "Operation2[]", + "TransactionBuilder.setExtraSigners": "(extraSigners: string[]) => TransactionBuilder", + "TransactionBuilder.setLedgerbounds": "(minLedger: number, maxLedger: number) => TransactionBuilder", + "TransactionBuilder.setMinAccountSequence": "(minAccountSequence: string) => TransactionBuilder", + "TransactionBuilder.setMinAccountSequenceAge": "(durationInSeconds: bigint) => TransactionBuilder", + "TransactionBuilder.setMinAccountSequenceLedgerGap": "(gap: number) => TransactionBuilder", + "TransactionBuilder.setNetworkPassphrase": "(networkPassphrase: string) => TransactionBuilder", + "TransactionBuilder.setSorobanData": "(sorobanData: string | SorobanTransactionData) => TransactionBuilder", + "TransactionBuilder.setTimebounds": "(minEpochOrDate: number | Date, maxEpochOrDate: number | Date) => TransactionBuilder", + "TransactionBuilder.setTimeout": "(timeoutSeconds: number) => TransactionBuilder", + "TransactionBuilder.sorobanData": "SorobanTransactionData", + "TransactionBuilder.source": "TransactionSource", + "TransactionBuilder.timebounds": "{ minTime?: string | number | Date; maxTime?: string | number | Date; }", + "TransactionSource": "interface: TransactionSource", + "TransactionSource.accountId": "() => string", + "TransactionSource.incrementSequenceNumber": "() => void", + "TransactionSource.sequenceNumber": "() => string", + "TrustLineFlag": "namespace: TrustLineFlag", + "Uint128": "class: Uint128", + "Uint128.size": "number", + "Uint128.unsigned": "boolean", + "Uint256": "class: Uint256", + "Uint256.size": "number", + "Uint256.unsigned": "boolean", + "UnsignedHyper": "value: any", + "Utils": "class: Utils", + "WasmCreateDetails": "interface: WasmCreateDetails", + "WasmCreateDetails.address": "string", + "WasmCreateDetails.constructorArgs": "?any[]", + "WasmCreateDetails.hash": "string", + "WasmCreateDetails.salt": "string", + "WebAuth": "value: typeof import(\"@stellar/stellar-sdk/lib/esm/webauth/index\")", + "XdrLargeInt": "class: XdrLargeInt", + "XdrLargeInt._sizeCheck": "any", + "XdrLargeInt.int": "LargeInt", + "XdrLargeInt.toBigInt": "() => bigint", + "XdrLargeInt.toDuration": "() => ScVal", + "XdrLargeInt.toI128": "() => ScVal", + "XdrLargeInt.toI256": "() => ScVal", + "XdrLargeInt.toI64": "() => ScVal", + "XdrLargeInt.toJSON": "() => { value: string; type: string; }", + "XdrLargeInt.toNumber": "() => number", + "XdrLargeInt.toScVal": "() => ScVal", + "XdrLargeInt.toString": "() => string", + "XdrLargeInt.toTimepoint": "() => ScVal", + "XdrLargeInt.toU128": "() => ScVal", + "XdrLargeInt.toU256": "() => ScVal", + "XdrLargeInt.toU64": "() => ScVal", + "XdrLargeInt.type": "ScIntType", + "XdrLargeInt.valueOf": "() => unknown", + "authorizeEntry": "function: (entry: SorobanAuthorizationEntry, signer: Keypair | SigningCallback, validUntilLedgerSeq: number, networkPassphrase: string, forAddress?: string): Promise<...>", + "authorizeInvocation": "function: (params: AuthorizeInvocationParams): Promise", + "buildAuthorizationEntryPreimage": "function: (entry: SorobanAuthorizationEntry, validUntilLedgerSeq: number, networkPassphrase: string): HashIdPreimage", + "buildInvocationTree": "function: (root: SorobanAuthorizedInvocation): InvocationTree", + "buildWithDelegatesEntry": "function: (params: BuildWithDelegatesParams): SorobanAuthorizationEntry", + "cereal": "const: { XdrWriter: any; XdrReader: any; }", + "contract": "value: typeof import(\"@stellar/stellar-sdk/lib/esm/contract/index\")", + "decodeAddressToMuxedAccount": "function: (address: string): MuxedAccount", + "encodeMuxedAccount": "function: (address: string, id: string): MuxedAccount", + "encodeMuxedAccountToAddress": "function: (muxedAccount: MuxedAccount): string", + "extractBaseAddress": "function: (address: string): string", + "getLiquidityPoolId": "function: (liquidityPoolType: \"constant_product\", liquidityPoolParameters: ConstantProduct): Buffer", + "hash": "function: (data: any): Buffer", + "humanizeEvents": "function: (events: ContractEvent[] | DiagnosticEvent[]): SorobanEvent[]", + "nativeToScVal": "function: (val: unknown, opts?: NativeToScValOpts): ScVal", + "rpc": "value: typeof import(\"@stellar/stellar-sdk/lib/esm/rpc/index\")", + "scValToBigInt": "function: (scv: ScVal): bigint", + "scValToNative": "function: (scv: ScVal): any", + "scvSortedMap": "function: (items: ScMapEntry[]): ScVal", + "sign": "function: (data: Buffer, rawSecret: any): Buffer", + "verify": "function: (data: Buffer, signature: Buffer, rawPublicKey: any): boolean", + "walkInvocationTree": "function: (root: SorobanAuthorizedInvocation, callback: InvocationWalker): void", + "xdr": "namespace: any", + "xdr.AccountEntry": "typeof AccountEntry", + "xdr.AccountEntryExt": "typeof AccountEntryExt", + "xdr.AccountEntryExtensionV1": "typeof AccountEntryExtensionV1", + "xdr.AccountEntryExtensionV1Ext": "typeof AccountEntryExtensionV1Ext", + "xdr.AccountEntryExtensionV2": "typeof AccountEntryExtensionV2", + "xdr.AccountEntryExtensionV2Ext": "typeof AccountEntryExtensionV2Ext", + "xdr.AccountEntryExtensionV3": "typeof AccountEntryExtensionV3", + "xdr.AccountFlags": "typeof AccountFlags", + "xdr.AccountMergeResult": "typeof AccountMergeResult", + "xdr.AccountMergeResultCode": "typeof AccountMergeResultCode", + "xdr.AllowTrustOp": "typeof AllowTrustOp", + "xdr.AllowTrustResult": "typeof AllowTrustResult", + "xdr.AllowTrustResultCode": "typeof AllowTrustResultCode", + "xdr.AlphaNum12": "typeof AlphaNum12", + "xdr.AlphaNum4": "typeof AlphaNum4", + "xdr.Asset": "typeof Asset", + "xdr.AssetCode": "typeof AssetCode", + "xdr.AssetCode12": "Opaque", + "xdr.AssetCode4": "Opaque", + "xdr.AssetType": "typeof AssetType", + "xdr.Auth": "typeof Auth", + "xdr.AuthCert": "typeof AuthCert", + "xdr.AuthenticatedMessage": "typeof AuthenticatedMessage", + "xdr.AuthenticatedMessageV0": "typeof AuthenticatedMessageV0", + "xdr.BeginSponsoringFutureReservesOp": "typeof BeginSponsoringFutureReservesOp", + "xdr.BeginSponsoringFutureReservesResult": "typeof BeginSponsoringFutureReservesResult", + "xdr.BeginSponsoringFutureReservesResultCode": "typeof BeginSponsoringFutureReservesResultCode", + "xdr.BinaryFuseFilterType": "typeof BinaryFuseFilterType", + "xdr.BucketEntry": "typeof BucketEntry", + "xdr.BucketEntryType": "typeof BucketEntryType", + "xdr.BucketListType": "typeof BucketListType", + "xdr.BucketMetadata": "typeof BucketMetadata", + "xdr.BucketMetadataExt": "typeof BucketMetadataExt", + "xdr.BumpSequenceOp": "typeof BumpSequenceOp", + "xdr.BumpSequenceResult": "typeof BumpSequenceResult", + "xdr.BumpSequenceResultCode": "typeof BumpSequenceResultCode", + "xdr.ChangeTrustAsset": "typeof ChangeTrustAsset", + "xdr.ChangeTrustOp": "typeof ChangeTrustOp", + "xdr.ChangeTrustResult": "typeof ChangeTrustResult", + "xdr.ChangeTrustResultCode": "typeof ChangeTrustResultCode", + "xdr.ClaimAtom": "typeof ClaimAtom", + "xdr.ClaimAtomType": "typeof ClaimAtomType", + "xdr.ClaimClaimableBalanceOp": "typeof ClaimClaimableBalanceOp", + "xdr.ClaimClaimableBalanceResult": "typeof ClaimClaimableBalanceResult", + "xdr.ClaimClaimableBalanceResultCode": "typeof ClaimClaimableBalanceResultCode", + "xdr.ClaimLiquidityAtom": "typeof ClaimLiquidityAtom", + "xdr.ClaimOfferAtom": "typeof ClaimOfferAtom", + "xdr.ClaimOfferAtomV0": "typeof ClaimOfferAtomV0", + "xdr.ClaimPredicate": "typeof ClaimPredicate", + "xdr.ClaimPredicateType": "typeof ClaimPredicateType", + "xdr.ClaimableBalanceEntry": "typeof ClaimableBalanceEntry", + "xdr.ClaimableBalanceEntryExt": "typeof ClaimableBalanceEntryExt", + "xdr.ClaimableBalanceEntryExtensionV1": "typeof ClaimableBalanceEntryExtensionV1", + "xdr.ClaimableBalanceEntryExtensionV1Ext": "typeof ClaimableBalanceEntryExtensionV1Ext", + "xdr.ClaimableBalanceFlags": "typeof ClaimableBalanceFlags", + "xdr.ClaimableBalanceId": "typeof ClaimableBalanceId", + "xdr.ClaimableBalanceIdType": "typeof ClaimableBalanceIdType", + "xdr.Claimant": "typeof Claimant", + "xdr.ClaimantType": "typeof ClaimantType", + "xdr.ClaimantV0": "typeof ClaimantV0", + "xdr.ClawbackClaimableBalanceOp": "typeof ClawbackClaimableBalanceOp", + "xdr.ClawbackClaimableBalanceResult": "typeof ClawbackClaimableBalanceResult", + "xdr.ClawbackClaimableBalanceResultCode": "typeof ClawbackClaimableBalanceResultCode", + "xdr.ClawbackOp": "typeof ClawbackOp", + "xdr.ClawbackResult": "typeof ClawbackResult", + "xdr.ClawbackResultCode": "typeof ClawbackResultCode", + "xdr.ConfigSettingContractBandwidthV0": "typeof ConfigSettingContractBandwidthV0", + "xdr.ConfigSettingContractComputeV0": "typeof ConfigSettingContractComputeV0", + "xdr.ConfigSettingContractEventsV0": "typeof ConfigSettingContractEventsV0", + "xdr.ConfigSettingContractExecutionLanesV0": "typeof ConfigSettingContractExecutionLanesV0", + "xdr.ConfigSettingContractHistoricalDataV0": "typeof ConfigSettingContractHistoricalDataV0", + "xdr.ConfigSettingContractLedgerCostExtV0": "typeof ConfigSettingContractLedgerCostExtV0", + "xdr.ConfigSettingContractLedgerCostV0": "typeof ConfigSettingContractLedgerCostV0", + "xdr.ConfigSettingContractParallelComputeV0": "typeof ConfigSettingContractParallelComputeV0", + "xdr.ConfigSettingEntry": "typeof ConfigSettingEntry", + "xdr.ConfigSettingId": "typeof ConfigSettingId", + "xdr.ConfigSettingScpTiming": "typeof ConfigSettingScpTiming", + "xdr.ConfigUpgradeSet": "typeof ConfigUpgradeSet", + "xdr.ConfigUpgradeSetKey": "typeof ConfigUpgradeSetKey", + "xdr.ContractCodeCostInputs": "typeof ContractCodeCostInputs", + "xdr.ContractCodeEntry": "typeof ContractCodeEntry", + "xdr.ContractCodeEntryExt": "typeof ContractCodeEntryExt", + "xdr.ContractCodeEntryV1": "typeof ContractCodeEntryV1", + "xdr.ContractCostParamEntry": "typeof ContractCostParamEntry", + "xdr.ContractCostParams": "XDRArray", + "xdr.ContractCostType": "typeof ContractCostType", + "xdr.ContractDataDurability": "typeof ContractDataDurability", + "xdr.ContractDataEntry": "typeof ContractDataEntry", + "xdr.ContractEvent": "typeof ContractEvent", + "xdr.ContractEventBody": "typeof ContractEventBody", + "xdr.ContractEventType": "typeof ContractEventType", + "xdr.ContractEventV0": "typeof ContractEventV0", + "xdr.ContractExecutable": "typeof ContractExecutable", + "xdr.ContractExecutableType": "typeof ContractExecutableType", + "xdr.ContractIdPreimage": "typeof ContractIdPreimage", + "xdr.ContractIdPreimageFromAddress": "typeof ContractIdPreimageFromAddress", + "xdr.ContractIdPreimageType": "typeof ContractIdPreimageType", + "xdr.CreateAccountOp": "typeof CreateAccountOp", + "xdr.CreateAccountResult": "typeof CreateAccountResult", + "xdr.CreateAccountResultCode": "typeof CreateAccountResultCode", + "xdr.CreateClaimableBalanceOp": "typeof CreateClaimableBalanceOp", + "xdr.CreateClaimableBalanceResult": "typeof CreateClaimableBalanceResult", + "xdr.CreateClaimableBalanceResultCode": "typeof CreateClaimableBalanceResultCode", + "xdr.CreateContractArgs": "typeof CreateContractArgs", + "xdr.CreateContractArgsV2": "typeof CreateContractArgsV2", + "xdr.CreatePassiveSellOfferOp": "typeof CreatePassiveSellOfferOp", + "xdr.CryptoKeyType": "typeof CryptoKeyType", + "xdr.Curve25519Public": "typeof Curve25519Public", + "xdr.Curve25519Secret": "typeof Curve25519Secret", + "xdr.DataEntry": "typeof DataEntry", + "xdr.DataEntryExt": "typeof DataEntryExt", + "xdr.DataValue": "VarOpaque", + "xdr.DecoratedSignature": "typeof DecoratedSignature", + "xdr.DependentTxCluster": "XDRArray", + "xdr.DiagnosticEvent": "typeof DiagnosticEvent", + "xdr.DontHave": "typeof DontHave", + "xdr.EncodedLedgerKey": "VarOpaque", + "xdr.EncryptedBody": "VarOpaque", + "xdr.EndSponsoringFutureReservesResult": "typeof EndSponsoringFutureReservesResult", + "xdr.EndSponsoringFutureReservesResultCode": "typeof EndSponsoringFutureReservesResultCode", + "xdr.EnvelopeType": "typeof EnvelopeType", + "xdr.Error": "typeof Error", + "xdr.ErrorCode": "typeof ErrorCode", + "xdr.EvictionIterator": "typeof EvictionIterator", + "xdr.ExtendFootprintTtlOp": "typeof ExtendFootprintTtlOp", + "xdr.ExtendFootprintTtlResult": "typeof ExtendFootprintTtlResult", + "xdr.ExtendFootprintTtlResultCode": "typeof ExtendFootprintTtlResultCode", + "xdr.ExtensionPoint": "typeof ExtensionPoint", + "xdr.FeeBumpTransaction": "typeof FeeBumpTransaction", + "xdr.FeeBumpTransactionEnvelope": "typeof FeeBumpTransactionEnvelope", + "xdr.FeeBumpTransactionExt": "typeof FeeBumpTransactionExt", + "xdr.FeeBumpTransactionInnerTx": "typeof FeeBumpTransactionInnerTx", + "xdr.FloodAdvert": "typeof FloodAdvert", + "xdr.FloodDemand": "typeof FloodDemand", + "xdr.FreezeBypassTxes": "typeof FreezeBypassTxes", + "xdr.FreezeBypassTxsDelta": "typeof FreezeBypassTxsDelta", + "xdr.FrozenLedgerKeys": "typeof FrozenLedgerKeys", + "xdr.FrozenLedgerKeysDelta": "typeof FrozenLedgerKeysDelta", + "xdr.GeneralizedTransactionSet": "typeof GeneralizedTransactionSet", + "xdr.Hash": "Opaque", + "xdr.HashIdPreimage": "typeof HashIdPreimage", + "xdr.HashIdPreimageContractId": "typeof HashIdPreimageContractId", + "xdr.HashIdPreimageOperationId": "typeof HashIdPreimageOperationId", + "xdr.HashIdPreimageRevokeId": "typeof HashIdPreimageRevokeId", + "xdr.HashIdPreimageSorobanAuthorization": "typeof HashIdPreimageSorobanAuthorization", + "xdr.HashIdPreimageSorobanAuthorizationWithAddress": "typeof HashIdPreimageSorobanAuthorizationWithAddress", + "xdr.Hello": "typeof Hello", + "xdr.HmacSha256Key": "typeof HmacSha256Key", + "xdr.HmacSha256Mac": "typeof HmacSha256Mac", + "xdr.HostFunction": "typeof HostFunction", + "xdr.HostFunctionType": "typeof HostFunctionType", + "xdr.HotArchiveBucketEntry": "typeof HotArchiveBucketEntry", + "xdr.HotArchiveBucketEntryType": "typeof HotArchiveBucketEntryType", + "xdr.Hyper": "typeof Hyper", + "xdr.InflationPayout": "typeof InflationPayout", + "xdr.InflationResult": "typeof InflationResult", + "xdr.InflationResultCode": "typeof InflationResultCode", + "xdr.InnerTransactionResult": "typeof InnerTransactionResult", + "xdr.InnerTransactionResultExt": "typeof InnerTransactionResultExt", + "xdr.InnerTransactionResultPair": "typeof InnerTransactionResultPair", + "xdr.InnerTransactionResultResult": "typeof InnerTransactionResultResult", + "xdr.Int128Parts": "typeof Int128Parts", + "xdr.Int256Parts": "typeof Int256Parts", + "xdr.Int32": "SignedInt", + "xdr.Int64": "typeof Int64", + "xdr.InvokeContractArgs": "typeof InvokeContractArgs", + "xdr.InvokeHostFunctionOp": "typeof InvokeHostFunctionOp", + "xdr.InvokeHostFunctionResult": "typeof InvokeHostFunctionResult", + "xdr.InvokeHostFunctionResultCode": "typeof InvokeHostFunctionResultCode", + "xdr.InvokeHostFunctionSuccessPreImage": "typeof InvokeHostFunctionSuccessPreImage", + "xdr.IpAddrType": "typeof IpAddrType", + "xdr.LedgerBounds": "typeof LedgerBounds", + "xdr.LedgerCloseMeta": "typeof LedgerCloseMeta", + "xdr.LedgerCloseMetaBatch": "typeof LedgerCloseMetaBatch", + "xdr.LedgerCloseMetaExt": "typeof LedgerCloseMetaExt", + "xdr.LedgerCloseMetaExtV1": "typeof LedgerCloseMetaExtV1", + "xdr.LedgerCloseMetaV0": "typeof LedgerCloseMetaV0", + "xdr.LedgerCloseMetaV1": "typeof LedgerCloseMetaV1", + "xdr.LedgerCloseMetaV2": "typeof LedgerCloseMetaV2", + "xdr.LedgerCloseValueSignature": "typeof LedgerCloseValueSignature", + "xdr.LedgerEntry": "typeof LedgerEntry", + "xdr.LedgerEntryChange": "typeof LedgerEntryChange", + "xdr.LedgerEntryChangeType": "typeof LedgerEntryChangeType", + "xdr.LedgerEntryChanges": "XDRArray", + "xdr.LedgerEntryData": "typeof LedgerEntryData", + "xdr.LedgerEntryExt": "typeof LedgerEntryExt", + "xdr.LedgerEntryExtensionV1": "typeof LedgerEntryExtensionV1", + "xdr.LedgerEntryExtensionV1Ext": "typeof LedgerEntryExtensionV1Ext", + "xdr.LedgerEntryType": "typeof LedgerEntryType", + "xdr.LedgerFootprint": "typeof LedgerFootprint", + "xdr.LedgerHeader": "typeof LedgerHeader", + "xdr.LedgerHeaderExt": "typeof LedgerHeaderExt", + "xdr.LedgerHeaderExtensionV1": "typeof LedgerHeaderExtensionV1", + "xdr.LedgerHeaderExtensionV1Ext": "typeof LedgerHeaderExtensionV1Ext", + "xdr.LedgerHeaderFlags": "typeof LedgerHeaderFlags", + "xdr.LedgerHeaderHistoryEntry": "typeof LedgerHeaderHistoryEntry", + "xdr.LedgerHeaderHistoryEntryExt": "typeof LedgerHeaderHistoryEntryExt", + "xdr.LedgerKey": "typeof LedgerKey", + "xdr.LedgerKeyAccount": "typeof LedgerKeyAccount", + "xdr.LedgerKeyClaimableBalance": "typeof LedgerKeyClaimableBalance", + "xdr.LedgerKeyConfigSetting": "typeof LedgerKeyConfigSetting", + "xdr.LedgerKeyContractCode": "typeof LedgerKeyContractCode", + "xdr.LedgerKeyContractData": "typeof LedgerKeyContractData", + "xdr.LedgerKeyData": "typeof LedgerKeyData", + "xdr.LedgerKeyLiquidityPool": "typeof LedgerKeyLiquidityPool", + "xdr.LedgerKeyOffer": "typeof LedgerKeyOffer", + "xdr.LedgerKeyTrustLine": "typeof LedgerKeyTrustLine", + "xdr.LedgerKeyTtl": "typeof LedgerKeyTtl", + "xdr.LedgerScpMessages": "typeof LedgerScpMessages", + "xdr.LedgerUpgrade": "typeof LedgerUpgrade", + "xdr.LedgerUpgradeType": "typeof LedgerUpgradeType", + "xdr.Liabilities": "typeof Liabilities", + "xdr.LiquidityPoolConstantProductParameters": "typeof LiquidityPoolConstantProductParameters", + "xdr.LiquidityPoolDepositOp": "typeof LiquidityPoolDepositOp", + "xdr.LiquidityPoolDepositResult": "typeof LiquidityPoolDepositResult", + "xdr.LiquidityPoolDepositResultCode": "typeof LiquidityPoolDepositResultCode", + "xdr.LiquidityPoolEntry": "typeof LiquidityPoolEntry", + "xdr.LiquidityPoolEntryBody": "typeof LiquidityPoolEntryBody", + "xdr.LiquidityPoolEntryConstantProduct": "typeof LiquidityPoolEntryConstantProduct", + "xdr.LiquidityPoolParameters": "typeof LiquidityPoolParameters", + "xdr.LiquidityPoolType": "typeof LiquidityPoolType", + "xdr.LiquidityPoolWithdrawOp": "typeof LiquidityPoolWithdrawOp", + "xdr.LiquidityPoolWithdrawResult": "typeof LiquidityPoolWithdrawResult", + "xdr.LiquidityPoolWithdrawResultCode": "typeof LiquidityPoolWithdrawResultCode", + "xdr.ManageBuyOfferOp": "typeof ManageBuyOfferOp", + "xdr.ManageBuyOfferResult": "typeof ManageBuyOfferResult", + "xdr.ManageBuyOfferResultCode": "typeof ManageBuyOfferResultCode", + "xdr.ManageDataOp": "typeof ManageDataOp", + "xdr.ManageDataResult": "typeof ManageDataResult", + "xdr.ManageDataResultCode": "typeof ManageDataResultCode", + "xdr.ManageOfferEffect": "typeof ManageOfferEffect", + "xdr.ManageOfferSuccessResult": "typeof ManageOfferSuccessResult", + "xdr.ManageOfferSuccessResultOffer": "typeof ManageOfferSuccessResultOffer", + "xdr.ManageSellOfferOp": "typeof ManageSellOfferOp", + "xdr.ManageSellOfferResult": "typeof ManageSellOfferResult", + "xdr.ManageSellOfferResultCode": "typeof ManageSellOfferResultCode", + "xdr.Memo": "typeof Memo", + "xdr.MemoType": "typeof MemoType", + "xdr.MessageType": "typeof MessageType", + "xdr.MuxedAccount": "typeof MuxedAccount", + "xdr.MuxedAccountMed25519": "typeof MuxedAccountMed25519", + "xdr.MuxedEd25519Account": "typeof MuxedEd25519Account", + "xdr.OfferEntry": "typeof OfferEntry", + "xdr.OfferEntryExt": "typeof OfferEntryExt", + "xdr.OfferEntryFlags": "typeof OfferEntryFlags", + "xdr.Opaque": "typeof Opaque", + "xdr.Operation": "typeof Operation2", + "xdr.OperationBody": "typeof OperationBody", + "xdr.OperationMeta": "typeof OperationMeta", + "xdr.OperationMetaV2": "typeof OperationMetaV2", + "xdr.OperationResult": "typeof OperationResult", + "xdr.OperationResultCode": "typeof OperationResultCode", + "xdr.OperationResultTr": "typeof OperationResultTr", + "xdr.OperationType": "typeof OperationType", + "xdr.Option": "typeof Option", + "xdr.ParallelTxExecutionStage": "XDRArray", + "xdr.ParallelTxsComponent": "typeof ParallelTxsComponent", + "xdr.PathPaymentStrictReceiveOp": "typeof PathPaymentStrictReceiveOp", + "xdr.PathPaymentStrictReceiveResult": "typeof PathPaymentStrictReceiveResult", + "xdr.PathPaymentStrictReceiveResultCode": "typeof PathPaymentStrictReceiveResultCode", + "xdr.PathPaymentStrictReceiveResultSuccess": "typeof PathPaymentStrictReceiveResultSuccess", + "xdr.PathPaymentStrictSendOp": "typeof PathPaymentStrictSendOp", + "xdr.PathPaymentStrictSendResult": "typeof PathPaymentStrictSendResult", + "xdr.PathPaymentStrictSendResultCode": "typeof PathPaymentStrictSendResultCode", + "xdr.PathPaymentStrictSendResultSuccess": "typeof PathPaymentStrictSendResultSuccess", + "xdr.PaymentOp": "typeof PaymentOp", + "xdr.PaymentResult": "typeof PaymentResult", + "xdr.PaymentResultCode": "typeof PaymentResultCode", + "xdr.PeerAddress": "typeof PeerAddress", + "xdr.PeerAddressIp": "typeof PeerAddressIp", + "xdr.PeerStats": "typeof PeerStats", + "xdr.PreconditionType": "typeof PreconditionType", + "xdr.Preconditions": "typeof Preconditions", + "xdr.PreconditionsV2": "typeof PreconditionsV2", + "xdr.Price": "typeof Price", + "xdr.PublicKey": "typeof PublicKey", + "xdr.PublicKeyType": "typeof PublicKeyType", + "xdr.RestoreFootprintOp": "typeof RestoreFootprintOp", + "xdr.RestoreFootprintResult": "typeof RestoreFootprintResult", + "xdr.RestoreFootprintResultCode": "typeof RestoreFootprintResultCode", + "xdr.RevokeSponsorshipOp": "typeof RevokeSponsorshipOp", + "xdr.RevokeSponsorshipOpSigner": "typeof RevokeSponsorshipOpSigner", + "xdr.RevokeSponsorshipResult": "typeof RevokeSponsorshipResult", + "xdr.RevokeSponsorshipResultCode": "typeof RevokeSponsorshipResultCode", + "xdr.RevokeSponsorshipType": "typeof RevokeSponsorshipType", + "xdr.ScAddress": "typeof ScAddress", + "xdr.ScAddressType": "typeof ScAddressType", + "xdr.ScBytes": "VarOpaque", + "xdr.ScContractInstance": "typeof ScContractInstance", + "xdr.ScEnvMetaEntry": "typeof ScEnvMetaEntry", + "xdr.ScEnvMetaEntryInterfaceVersion": "typeof ScEnvMetaEntryInterfaceVersion", + "xdr.ScEnvMetaKind": "typeof ScEnvMetaKind", + "xdr.ScError": "typeof ScError", + "xdr.ScErrorCode": "typeof ScErrorCode", + "xdr.ScErrorType": "typeof ScErrorType", + "xdr.ScMap": "XDRArray", + "xdr.ScMapEntry": "typeof ScMapEntry", + "xdr.ScMetaEntry": "typeof ScMetaEntry", + "xdr.ScMetaKind": "typeof ScMetaKind", + "xdr.ScMetaV0": "typeof ScMetaV0", + "xdr.ScNonceKey": "typeof ScNonceKey", + "xdr.ScSpecEntry": "typeof ScSpecEntry", + "xdr.ScSpecEntryKind": "typeof ScSpecEntryKind", + "xdr.ScSpecEventDataFormat": "typeof ScSpecEventDataFormat", + "xdr.ScSpecEventParamLocationV0": "typeof ScSpecEventParamLocationV0", + "xdr.ScSpecEventParamV0": "typeof ScSpecEventParamV0", + "xdr.ScSpecEventV0": "typeof ScSpecEventV0", + "xdr.ScSpecFunctionInputV0": "typeof ScSpecFunctionInputV0", + "xdr.ScSpecFunctionV0": "typeof ScSpecFunctionV0", + "xdr.ScSpecType": "typeof ScSpecType", + "xdr.ScSpecTypeBytesN": "typeof ScSpecTypeBytesN", + "xdr.ScSpecTypeDef": "typeof ScSpecTypeDef", + "xdr.ScSpecTypeMap": "typeof ScSpecTypeMap", + "xdr.ScSpecTypeOption": "typeof ScSpecTypeOption", + "xdr.ScSpecTypeResult": "typeof ScSpecTypeResult", + "xdr.ScSpecTypeTuple": "typeof ScSpecTypeTuple", + "xdr.ScSpecTypeUdt": "typeof ScSpecTypeUdt", + "xdr.ScSpecTypeVec": "typeof ScSpecTypeVec", + "xdr.ScSpecUdtEnumCaseV0": "typeof ScSpecUdtEnumCaseV0", + "xdr.ScSpecUdtEnumV0": "typeof ScSpecUdtEnumV0", + "xdr.ScSpecUdtErrorEnumCaseV0": "typeof ScSpecUdtErrorEnumCaseV0", + "xdr.ScSpecUdtErrorEnumV0": "typeof ScSpecUdtErrorEnumV0", + "xdr.ScSpecUdtStructFieldV0": "typeof ScSpecUdtStructFieldV0", + "xdr.ScSpecUdtStructV0": "typeof ScSpecUdtStructV0", + "xdr.ScSpecUdtUnionCaseTupleV0": "typeof ScSpecUdtUnionCaseTupleV0", + "xdr.ScSpecUdtUnionCaseV0": "typeof ScSpecUdtUnionCaseV0", + "xdr.ScSpecUdtUnionCaseV0Kind": "typeof ScSpecUdtUnionCaseV0Kind", + "xdr.ScSpecUdtUnionCaseVoidV0": "typeof ScSpecUdtUnionCaseVoidV0", + "xdr.ScSpecUdtUnionV0": "typeof ScSpecUdtUnionV0", + "xdr.ScString": "XDRString", + "xdr.ScSymbol": "XDRString", + "xdr.ScVal": "typeof ScVal", + "xdr.ScValType": "typeof ScValType", + "xdr.ScVec": "XDRArray", + "xdr.ScpBallot": "typeof ScpBallot", + "xdr.ScpEnvelope": "typeof ScpEnvelope", + "xdr.ScpHistoryEntry": "typeof ScpHistoryEntry", + "xdr.ScpHistoryEntryV0": "typeof ScpHistoryEntryV0", + "xdr.ScpNomination": "typeof ScpNomination", + "xdr.ScpQuorumSet": "typeof ScpQuorumSet", + "xdr.ScpStatement": "typeof ScpStatement", + "xdr.ScpStatementConfirm": "typeof ScpStatementConfirm", + "xdr.ScpStatementExternalize": "typeof ScpStatementExternalize", + "xdr.ScpStatementPledges": "typeof ScpStatementPledges", + "xdr.ScpStatementPrepare": "typeof ScpStatementPrepare", + "xdr.ScpStatementType": "typeof ScpStatementType", + "xdr.SendMore": "typeof SendMore", + "xdr.SendMoreExtended": "typeof SendMoreExtended", + "xdr.SerializedBinaryFuseFilter": "typeof SerializedBinaryFuseFilter", + "xdr.SetOptionsOp": "typeof SetOptionsOp", + "xdr.SetOptionsResult": "typeof SetOptionsResult", + "xdr.SetOptionsResultCode": "typeof SetOptionsResultCode", + "xdr.SetTrustLineFlagsOp": "typeof SetTrustLineFlagsOp", + "xdr.SetTrustLineFlagsResult": "typeof SetTrustLineFlagsResult", + "xdr.SetTrustLineFlagsResultCode": "typeof SetTrustLineFlagsResultCode", + "xdr.ShortHashSeed": "typeof ShortHashSeed", + "xdr.Signature": "VarOpaque", + "xdr.SignatureHint": "Opaque", + "xdr.SignedTimeSlicedSurveyRequestMessage": "typeof SignedTimeSlicedSurveyRequestMessage", + "xdr.SignedTimeSlicedSurveyResponseMessage": "typeof SignedTimeSlicedSurveyResponseMessage", + "xdr.SignedTimeSlicedSurveyStartCollectingMessage": "typeof SignedTimeSlicedSurveyStartCollectingMessage", + "xdr.SignedTimeSlicedSurveyStopCollectingMessage": "typeof SignedTimeSlicedSurveyStopCollectingMessage", + "xdr.Signer": "typeof Signer", + "xdr.SignerKey": "typeof SignerKey", + "xdr.SignerKeyEd25519SignedPayload": "typeof SignerKeyEd25519SignedPayload", + "xdr.SignerKeyType": "typeof SignerKeyType", + "xdr.SimplePaymentResult": "typeof SimplePaymentResult", + "xdr.SorobanAddressCredentials": "typeof SorobanAddressCredentials", + "xdr.SorobanAddressCredentialsWithDelegates": "typeof SorobanAddressCredentialsWithDelegates", + "xdr.SorobanAuthorizationEntries": "XDRArray", + "xdr.SorobanAuthorizationEntry": "typeof SorobanAuthorizationEntry", + "xdr.SorobanAuthorizedFunction": "typeof SorobanAuthorizedFunction", + "xdr.SorobanAuthorizedFunctionType": "typeof SorobanAuthorizedFunctionType", + "xdr.SorobanAuthorizedInvocation": "typeof SorobanAuthorizedInvocation", + "xdr.SorobanCredentials": "typeof SorobanCredentials", + "xdr.SorobanCredentialsType": "typeof SorobanCredentialsType", + "xdr.SorobanDelegateSignature": "typeof SorobanDelegateSignature", + "xdr.SorobanResources": "typeof SorobanResources", + "xdr.SorobanResourcesExtV0": "typeof SorobanResourcesExtV0", + "xdr.SorobanTransactionData": "typeof SorobanTransactionData", + "xdr.SorobanTransactionDataExt": "typeof SorobanTransactionDataExt", + "xdr.SorobanTransactionMeta": "typeof SorobanTransactionMeta", + "xdr.SorobanTransactionMetaExt": "typeof SorobanTransactionMetaExt", + "xdr.SorobanTransactionMetaExtV1": "typeof SorobanTransactionMetaExtV1", + "xdr.SorobanTransactionMetaV2": "typeof SorobanTransactionMetaV2", + "xdr.StateArchivalSettings": "typeof StateArchivalSettings", + "xdr.StellarMessage": "typeof StellarMessage", + "xdr.StellarValue": "typeof StellarValue", + "xdr.StellarValueExt": "typeof StellarValueExt", + "xdr.StellarValueType": "typeof StellarValueType", + "xdr.String32": "XDRString", + "xdr.String64": "XDRString", + "xdr.SurveyMessageCommandType": "typeof SurveyMessageCommandType", + "xdr.SurveyMessageResponseType": "typeof SurveyMessageResponseType", + "xdr.SurveyRequestMessage": "typeof SurveyRequestMessage", + "xdr.SurveyResponseBody": "typeof SurveyResponseBody", + "xdr.SurveyResponseMessage": "typeof SurveyResponseMessage", + "xdr.ThresholdIndices": "typeof ThresholdIndices", + "xdr.Thresholds": "Opaque", + "xdr.TimeBounds": "typeof TimeBounds", + "xdr.TimeSlicedNodeData": "typeof TimeSlicedNodeData", + "xdr.TimeSlicedPeerData": "typeof TimeSlicedPeerData", + "xdr.TimeSlicedPeerDataList": "XDRArray", + "xdr.TimeSlicedSurveyRequestMessage": "typeof TimeSlicedSurveyRequestMessage", + "xdr.TimeSlicedSurveyResponseMessage": "typeof TimeSlicedSurveyResponseMessage", + "xdr.TimeSlicedSurveyStartCollectingMessage": "typeof TimeSlicedSurveyStartCollectingMessage", + "xdr.TimeSlicedSurveyStopCollectingMessage": "typeof TimeSlicedSurveyStopCollectingMessage", + "xdr.TopologyResponseBodyV2": "typeof TopologyResponseBodyV2", + "xdr.Transaction": "typeof Transaction", + "xdr.TransactionEnvelope": "typeof TransactionEnvelope", + "xdr.TransactionEvent": "typeof TransactionEvent", + "xdr.TransactionEventStage": "typeof TransactionEventStage", + "xdr.TransactionExt": "typeof TransactionExt", + "xdr.TransactionHistoryEntry": "typeof TransactionHistoryEntry", + "xdr.TransactionHistoryEntryExt": "typeof TransactionHistoryEntryExt", + "xdr.TransactionHistoryResultEntry": "typeof TransactionHistoryResultEntry", + "xdr.TransactionHistoryResultEntryExt": "typeof TransactionHistoryResultEntryExt", + "xdr.TransactionMeta": "typeof TransactionMeta", + "xdr.TransactionMetaV1": "typeof TransactionMetaV1", + "xdr.TransactionMetaV2": "typeof TransactionMetaV2", + "xdr.TransactionMetaV3": "typeof TransactionMetaV3", + "xdr.TransactionMetaV4": "typeof TransactionMetaV4", + "xdr.TransactionPhase": "typeof TransactionPhase", + "xdr.TransactionResult": "typeof TransactionResult", + "xdr.TransactionResultCode": "typeof TransactionResultCode", + "xdr.TransactionResultExt": "typeof TransactionResultExt", + "xdr.TransactionResultMeta": "typeof TransactionResultMeta", + "xdr.TransactionResultMetaV1": "typeof TransactionResultMetaV1", + "xdr.TransactionResultPair": "typeof TransactionResultPair", + "xdr.TransactionResultResult": "typeof TransactionResultResult", + "xdr.TransactionResultSet": "typeof TransactionResultSet", + "xdr.TransactionSet": "typeof TransactionSet", + "xdr.TransactionSetV1": "typeof TransactionSetV1", + "xdr.TransactionSignaturePayload": "typeof TransactionSignaturePayload", + "xdr.TransactionSignaturePayloadTaggedTransaction": "typeof TransactionSignaturePayloadTaggedTransaction", + "xdr.TransactionV0": "typeof TransactionV0", + "xdr.TransactionV0Envelope": "typeof TransactionV0Envelope", + "xdr.TransactionV0Ext": "typeof TransactionV0Ext", + "xdr.TransactionV1Envelope": "typeof TransactionV1Envelope", + "xdr.TrustLineAsset": "typeof TrustLineAsset", + "xdr.TrustLineEntry": "typeof TrustLineEntry", + "xdr.TrustLineEntryExt": "typeof TrustLineEntryExt", + "xdr.TrustLineEntryExtensionV2": "typeof TrustLineEntryExtensionV2", + "xdr.TrustLineEntryExtensionV2Ext": "typeof TrustLineEntryExtensionV2Ext", + "xdr.TrustLineEntryV1": "typeof TrustLineEntryV1", + "xdr.TrustLineEntryV1Ext": "typeof TrustLineEntryV1Ext", + "xdr.TrustLineFlags": "typeof TrustLineFlags", + "xdr.TtlEntry": "typeof TtlEntry", + "xdr.TxAdvertVector": "XDRArray", + "xdr.TxDemandVector": "XDRArray", + "xdr.TxSetComponent": "typeof TxSetComponent", + "xdr.TxSetComponentTxsMaybeDiscountedFee": "typeof TxSetComponentTxsMaybeDiscountedFee", + "xdr.TxSetComponentType": "typeof TxSetComponentType", + "xdr.UInt128Parts": "typeof UInt128Parts", + "xdr.UInt256Parts": "typeof UInt256Parts", + "xdr.Uint256": "Opaque", + "xdr.Uint32": "UnsignedInt", + "xdr.Uint64": "typeof Uint64", + "xdr.UnsignedHyper": "typeof UnsignedHyper", + "xdr.UpgradeEntryMeta": "typeof UpgradeEntryMeta", + "xdr.UpgradeType": "VarOpaque", + "xdr.Value": "VarOpaque", + "xdr.VarOpaque": "typeof VarOpaque", + "xdr.XDRArray": "typeof XDRArray", + "xdr.XDRString": "typeof XDRString", + "xdr.scvSortedMap": "(items: ScMapEntry[]) => ScVal" + } + } + } } } diff --git a/test/api-drift.test.js b/test/api-drift.test.js new file mode 100644 index 00000000..f6989872 --- /dev/null +++ b/test/api-drift.test.js @@ -0,0 +1,672 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { + normalizeSignature, + classifyUpgrade, + isExactNpmPin, + isExactCargoPin, + isInternalMemberName, + diffSurface, + evaluateDrift, + parseCargoManifest, + checkCargoPinning, + extractSurface, + resolveTypesEntry, + readPackageMeta, + extractPackageSurfaces, + loadConfig, + runCheck, + runUpdate, + REPO_ROOT, +} from "../scripts/detect-api-drift.js"; + +// Automated dependency version drift & breaking API change analyzer. +// Everything here is offline: surfaces come from in-memory .d.ts fixtures or +// from temp-dir package layouts, the real package.json / tsconfig / Cargo.toml +// are only read for structural assertions. + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, ".."); +const SCRIPT = path.join(ROOT, "scripts", "detect-api-drift.js"); +const SLOW = { timeout: 60_000 }; + +const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); + +// --------------------------------------------------------------------------- +// Signature normalization +// --------------------------------------------------------------------------- + +describe("normalizeSignature", () => { + it("collapses whitespace so formatting differences are not drift", () => { + expect(normalizeSignature(" (a: string,\n\t b: number) => void ")).toBe("(a: string, b: number) => void"); + }); + + it("rewrites absolute import() specifiers to a stable node_modules form", () => { + expect(normalizeSignature('import("/home/ci/work/node_modules/lodash/index.d.ts")')).toBe('import("lodash/index.d.ts")'); + expect(normalizeSignature('import("lodash")')).toBe('import("lodash")'); + }); + + it("handles empty input", () => { + expect(normalizeSignature(undefined)).toBe(""); + expect(normalizeSignature(null)).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- +// Version classification & pin helpers +// --------------------------------------------------------------------------- + +describe("classifyUpgrade", () => { + it("classifies major / minor / patch movement", () => { + expect(classifyUpgrade("1.0.0", "2.0.0")).toBe("major"); + expect(classifyUpgrade("1.4.0", "1.5.0")).toBe("minor"); + expect(classifyUpgrade("1.4.0", "1.4.1")).toBe("patch"); + expect(classifyUpgrade("1.4.1", "1.4.1")).toBe("none"); + }); + + it("flags downgrades and unparseable versions", () => { + expect(classifyUpgrade("2.0.0", "1.9.9")).toBe("downgrade"); + expect(classifyUpgrade(null, "1.0.0")).toBe("unknown"); + expect(classifyUpgrade("latest", "1.0.0")).toBe("unknown"); + }); +}); + +describe("version pin helpers", () => { + it("accepts only exact npm versions", () => { + expect(isExactNpmPin("1.2.3")).toBe(true); + expect(isExactNpmPin("1.2.3-rc.1")).toBe(true); + expect(isExactNpmPin("^1.2.3")).toBe(false); + expect(isExactNpmPin("~1.2")).toBe(false); + expect(isExactNpmPin(">=1.2.3")).toBe(false); + expect(isExactNpmPin("1.x")).toBe(false); + expect(isExactNpmPin("workspace:*")).toBe(false); + expect(isExactNpmPin("")).toBe(false); + }); + + it("requires the = prefix for cargo (bare 1.2.3 means ^1.2.3)", () => { + expect(isExactCargoPin("=1.2.3")).toBe(true); + expect(isExactCargoPin("=1.2.3-rc.1")).toBe(true); + expect(isExactCargoPin("1.2.3")).toBe(false); + expect(isExactCargoPin("^1.2.3")).toBe(false); + expect(isExactCargoPin("1.2")).toBe(false); + expect(isExactCargoPin("")).toBe(false); + }); + + it("drops TypeScript well-known symbol member names (unstable ids)", () => { + expect(isInternalMemberName("__@captureRejectionSymbol@123")).toBe(true); + expect(isInternalMemberName("__@iterator@42")).toBe(true); + expect(isInternalMemberName("on")).toBe(false); + expect(isInternalMemberName("constructor")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Surface diff +// --------------------------------------------------------------------------- + +describe("diffSurface", () => { + const before = { keep: "function: (): void", drop: "const: string", retype: "function: (): void" }; + const after = { keep: "function: (): void", retype: "function: (): Promise", added: "const: number" }; + + it("separates breaking changes from additive ones", () => { + const { breaking, additive } = diffSurface(before, after); + expect(breaking).toEqual([ + { type: "removed", key: "drop", before: "const: string" }, + { type: "changed", key: "retype", before: "function: (): void", after: "function: (): Promise" }, + ]); + expect(additive).toEqual([{ type: "added", key: "added", after: "const: number" }]); + }); + + it("reports nothing when the surfaces are identical", () => { + expect(diffSurface(before, { ...before })).toEqual({ breaking: [], additive: [] }); + expect(diffSurface({}, {})).toEqual({ breaking: [], additive: [] }); + }); +}); + +// --------------------------------------------------------------------------- +// Version pinning guard +// --------------------------------------------------------------------------- + +describe("evaluateDrift (version pinning guard)", () => { + const base = { name: "pkg", range: "^1.2.0", baselineVersion: "1.2.0", installedVersion: "1.2.0" }; + + it("fails when a package has no reviewed baseline", () => { + const result = evaluateDrift({ ...base, hasBaseline: false }); + expect(result.ok).toBe(false); + expect(result.findings.map((f) => f.code)).toContain("missing-baseline"); + }); + + it("rejects a breaking change inside a semver-compatible upgrade", () => { + const breaking = [{ type: "removed", key: "run", before: "function: (): void" }]; + for (const [installedVersion, upgrade] of [ + ["1.2.1", "patch"], + ["1.3.0", "minor"], + ["1.2.0", "none"], + ]) { + const result = evaluateDrift({ ...base, installedVersion, breaking }); + expect(result.upgrade).toBe(upgrade); + expect(result.ok).toBe(false); + const finding = result.findings.find((f) => f.code === "breaking-compatible-upgrade"); + expect(finding, `${installedVersion} must be rejected`).toBeDefined(); + expect(finding.level).toBe("error"); + expect(finding.message).toContain("-run"); + expect(finding.message).toContain('Pin pkg to "1.2.0"'); + } + }); + + it("allows breaking changes in a major upgrade but reports them", () => { + const breaking = [{ type: "removed", key: "run", before: "function: (): void" }]; + const result = evaluateDrift({ ...base, installedVersion: "2.0.0", breaking }); + expect(result.upgrade).toBe("major"); + expect(result.ok).toBe(true); + const finding = result.findings.find((f) => f.code === "breaking-major-upgrade"); + expect(finding.level).toBe("warning"); + expect(finding.message).toContain("1.2.0 → 2.0.0"); + }); + + it("reports clean upgrades as ok with no findings", () => { + const result = evaluateDrift({ + ...base, + installedVersion: "1.9.0", + additive: [{ type: "added", key: "extra", after: "const: number" }], + }); + expect(result.ok).toBe(true); + expect(result.findings).toEqual([]); + expect(result.upgrade).toBe("minor"); + }); + + it("enforces exact pins only when requested", () => { + expect(evaluateDrift({ ...base, requireExactPin: true }).findings.map((f) => f.code)).toContain("range-not-exact"); + expect(evaluateDrift({ ...base, requireExactPin: false }).findings).toEqual([]); + expect( + evaluateDrift({ ...base, range: "1.2.0", requireExactPin: true }).findings.map((f) => f.code), + ).toEqual([]); + }); + + it("classifies drift that comes from a DefinitelyTyped bump", () => { + const result = evaluateDrift({ + ...base, + installedVersion: "4.22.2", + baselineTypesVersion: "4.17.20", + typesPackage: "@types/express", + typesVersion: "4.17.21", + breaking: [{ type: "removed", key: "handler", before: "function: (): void" }], + }); + expect(result.upgrade).toBe("patch"); + expect(result.ok).toBe(false); + const finding = result.findings.find((f) => f.code === "breaking-compatible-upgrade"); + expect(finding.message).toContain("4.17.20 → 4.17.21"); + expect(finding.message).toContain("via @types/express"); + expect(finding.message).toContain('Pin @types/express to "4.17.20"'); + }); + + it("falls back to runtime versions when the types package is unchanged", () => { + const result = evaluateDrift({ + ...base, + installedVersion: "1.3.0", + baselineTypesVersion: "4.17.21", + typesPackage: "@types/express", + typesVersion: "4.17.21", + }); + expect(result.upgrade).toBe("minor"); + expect(result.findings).toEqual([]); + expect(result.typesPackage).toBe("@types/express"); + }); +}); + +// --------------------------------------------------------------------------- +// Rust half +// --------------------------------------------------------------------------- + +const CARGO_PINNED = ` +[package] +name = "fixture" +version = "0.1.0" + +[workspace] +members = ["crates/*"] + +[workspace.dependencies] +soroban-sdk = "=2.3.0" +serde = { version = "=1.0.200", features = ["derive"] } + +[workspace.metadata.api-drift] +require-exact-pin = true +protected-crates = ["soroban-sdk"] +`; + +describe("parseCargoManifest", () => { + it("reads sections, arrays, inline tables and strips comments", () => { + const { sections, error } = parseCargoManifest(CARGO_PINNED); + expect(error).toBeNull(); + expect(sections.package.name).toBe("fixture"); + expect(sections.workspace.members).toEqual(["crates/*"]); + expect(sections["workspace.dependencies"]["soroban-sdk"]).toBe("=2.3.0"); + expect(sections["workspace.dependencies"].serde).toEqual({ version: "=1.0.200", features: ["derive"] }); + expect(sections["workspace.metadata.api-drift"]["require-exact-pin"]).toBe(true); + expect(sections["workspace.metadata.api-drift"]["protected-crates"]).toEqual(["soroban-sdk"]); + }); + + it("surfaces unparsable lines instead of silently skipping them", () => { + const { error } = parseCargoManifest("[package]\nthis is not toml\n"); + expect(error).toContain("unparsable line"); + }); +}); + +describe("checkCargoPinning", () => { + it("requires the [workspace.metadata.api-drift] guard section", () => { + const result = checkCargoPinning('[package]\nname = "x"\n'); + expect(result.ok).toBe(false); + expect(result.findings[0].code).toBe("missing-guard-config"); + }); + + it("fails unpinned requirements when require-exact-pin is on", () => { + const result = checkCargoPinning('[workspace.metadata.api-drift]\nrequire-exact-pin = true\n\n[workspace.dependencies]\nfoo = "1.2.3"\n'); + expect(result.ok).toBe(false); + expect(result.findings[0].code).toBe("range-not-exact"); + expect(result.findings[0].message).toContain('"=1.2.3"'); + }); + + it("passes when every requirement carries an exact = pin", () => { + const result = checkCargoPinning(CARGO_PINNED); + expect(result.findings).toEqual([]); + expect(result.ok).toBe(true); + expect(result.config.requireExactPin).toBe(true); + }); + + it("leaves unpinned crates alone unless they are protected", () => { + const manifest = ` +[workspace.metadata.api-drift] +require-exact-pin = false +protected-crates = ["soroban-sdk"] + +[workspace.dependencies] +soroban-sdk = "2.3.0" +serde = "1.0.200" +`; + const result = checkCargoPinning(manifest); + expect(result.ok).toBe(false); + expect(result.findings).toHaveLength(1); + expect(result.findings[0].message).toContain("soroban-sdk"); + expect(checkCargoPinning(manifest, { protectedCrates: [] }).ok).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Type surface extraction (in-memory declaration files) +// --------------------------------------------------------------------------- + +const V1 = { + "/pkg/index.d.ts": ` +export interface Options { + retries: number; + label?: string; +} +export declare function run(opts: Options): void; +export declare class Runner { + start(): void; + stop(): Promise; +} +export declare const VERSION: string; +export default function fallback(): void; +`, +}; + +const V2 = { + "/pkg/index.d.ts": ` +export interface Options { + retries: string; + label?: string; +} +export declare function run(opts: Options): Promise; +export declare class Runner { + start(): void; + pause(): void; +} +export declare const VERSION: string; +export default function fallback(): void; +`, +}; + +const EXPORT_EQUALS = { + "/eq/index.d.ts": ` +interface Client { get(path: string): Promise; } +declare function create(url: string): Client; +declare namespace create { + const version: string; + type Factory = (u: string) => Client; +} +export = create; +`, +}; + +describe("type surface extraction", () => { + it("captures interfaces, methods, call signatures and constants", () => { + const surface = extractSurface({ entryFile: "/pkg/index.d.ts", files: V1 }); + expect(surface).toEqual({ + Options: "interface: Options", + "Options.label": "?string", + "Options.retries": "number", + Runner: "class: Runner", + "Runner.start": "() => void", + "Runner.stop": "() => Promise", + VERSION: "const: string", + run: "function: (opts: Options): void", + }); + expect(Object.keys(surface)).toEqual([...Object.keys(surface)].sort()); + }); + + it("turns a semver-compatible signature change into a breaking diff", () => { + const before = extractSurface({ entryFile: "/pkg/index.d.ts", files: V1 }); + const after = extractSurface({ entryFile: "/pkg/index.d.ts", files: V2 }); + const { breaking, additive } = diffSurface(before, after); + expect(breaking).toEqual( + expect.arrayContaining([ + { type: "changed", key: "Options.retries", before: "number", after: "string" }, + { type: "changed", key: "run", before: "function: (opts: Options): void", after: "function: (opts: Options): Promise" }, + { type: "removed", key: "Runner.stop", before: "() => Promise" }, + ]), + ); + expect(breaking).toHaveLength(3); + expect(additive).toEqual([{ type: "added", key: "Runner.pause", after: "() => void" }]); + + const drift = evaluateDrift({ + name: "pkg", + range: "^1.0.0", + baselineVersion: "1.0.0", + installedVersion: "1.0.1", + breaking, + additive, + }); + expect(drift.ok).toBe(false); + expect(drift.findings[0].code).toBe("breaking-compatible-upgrade"); + }); + + it("describes export = modules through the module key", () => { + const surface = extractSurface({ entryFile: "/eq/index.d.ts", files: EXPORT_EQUALS }); + expect(surface.module).toBe("function: (url: string): Client"); + expect(surface.version).toBe("const: string"); + expect(surface.Factory).toBe("type: Factory"); + }); + + it("never exposes default exports as part of the surface", () => { + const surface = extractSurface({ entryFile: "/pkg/index.d.ts", files: V1 }); + expect(Object.keys(surface)).not.toContain("default"); + expect(Object.keys(surface)).not.toContain("fallback"); + }); +}); + +// --------------------------------------------------------------------------- +// Package layout resolution +// --------------------------------------------------------------------------- + +describe("resolveTypesEntry", () => { + let root; + + const write = (rel, contents) => { + const file = path.join(root, rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); + }; + + beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "helphone-apidrift-resolve-")); + // bundled types via "types" + write("node_modules/bundled/package.json", JSON.stringify({ name: "bundled", version: "1.0.0", types: "index.d.ts" })); + write("node_modules/bundled/index.d.ts", "export declare const a: number;\n"); + // type-marker package.json files must not stop the package-root walk + write( + "node_modules/chained/package.json", + JSON.stringify({ name: "chained", version: "1.0.0", main: "lib/cjs/index.js" }), + ); + write("node_modules/chained/lib/cjs/package.json", JSON.stringify({ type: "commonjs" })); + write("node_modules/chained/lib/cjs/index.js", "module.exports = {};\n"); + write("node_modules/chained/lib/cjs/index.d.ts", "export declare const b: number;\n"); + // DefinitelyTyped fallback + write("node_modules/untyped/package.json", JSON.stringify({ name: "untyped", version: "2.0.0", main: "index.js" })); + write("node_modules/untyped/index.js", "module.exports = {};\n"); + write("node_modules/@types/untyped/index.d.ts", "export declare const c: number;\n"); + write("node_modules/@types/untyped/package.json", JSON.stringify({ name: "@types/untyped", version: "2.4.0" })); + }); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it("uses the package's own types entry", () => { + expect(resolveTypesEntry("bundled", root)).toBe(path.join(root, "node_modules/bundled/index.d.ts")); + }); + + it("walks past lib/cjs type markers to find the package root", () => { + expect(resolveTypesEntry("chained", root)).toBe(path.join(root, "node_modules/chained/lib/cjs/index.d.ts")); + }); + + it("falls back to DefinitelyTyped when the package ships no types", () => { + expect(resolveTypesEntry("untyped", root)).toBe(path.join(root, "node_modules/@types/untyped/index.d.ts")); + expect(resolveTypesEntry("@types/untyped", root)).toBe(path.join(root, "node_modules/@types/untyped/index.d.ts")); + }); + + it("returns null when neither the package nor @types provide declarations", () => { + expect(resolveTypesEntry("missing-package", root)).toBeNull(); + }); + + it("reads installed version and type-provider metadata", () => { + expect(readPackageMeta("bundled", root)).toMatchObject({ version: "1.0.0", entry: path.join(root, "node_modules/bundled/index.d.ts") }); + expect(readPackageMeta("untyped", root)).toMatchObject({ + version: "2.0.0", + typesPackage: "@types/untyped", + typesVersion: "2.4.0", + }); + }); +}); + +// --------------------------------------------------------------------------- +// End-to-end check/update flow against a fixture package root +// --------------------------------------------------------------------------- + +describe("runCheck / runUpdate", () => { + let root; + const manifestPath = () => path.join(root, "package.json"); + + const writeManifest = (apiDrift) => { + fs.writeFileSync( + manifestPath(), + JSON.stringify( + { + name: "fixture-root", + version: "0.0.1", + dependencies: { fake: "^1.0.0" }, + ...(apiDrift ? { apiDrift } : {}), + }, + null, + 2, + ), + ); + }; + + beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "helphone-apidrift-root-")); + fs.mkdirSync(path.join(root, "node_modules/fake"), { recursive: true }); + fs.writeFileSync( + path.join(root, "node_modules/fake/package.json"), + JSON.stringify({ name: "fake", version: "1.1.0", types: "index.d.ts" }), + ); + fs.writeFileSync(path.join(root, "node_modules/fake/index.d.ts"), "export declare function greet(name: string): number;\n"); + fs.writeFileSync(path.join(root, "tsconfig.json"), JSON.stringify({ apiDrift: { compilerOptions: { target: "ES2020", strict: true } } })); + }); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it("loads compiler options and package config from the root", () => { + writeManifest({ packages: ["fake"], baseline: {} }); + const config = loadConfig({ root }); + expect(config.packages).toEqual(["fake"]); + expect(config.compilerOptions.strict).toBe(true); + expect(config.tsconfigPath).toBe(path.join(root, "tsconfig.json")); + expect(() => loadConfig({ root: path.join(root, "does-not-exist") })).toThrow(); + }); + + it( + "flags a semver-compatible upgrade that changed a signature, then passes after re-baselining", + SLOW, + () => { + writeManifest({ + packages: ["fake"], + baseline: { fake: { version: "1.0.0", surface: { greet: "function: (name: string): string" } } }, + }); + + const first = runCheck({ root }); + expect(first.ok).toBe(false); + const [result] = first.results; + expect(result.installedVersion).toBe("1.1.0"); + expect(result.upgrade).toBe("minor"); + expect(result.breaking).toEqual([ + { type: "changed", key: "greet", before: "function: (name: string): string", after: "function: (name: string): number" }, + ]); + expect(result.findings.map((f) => f.code)).toContain("breaking-compatible-upgrade"); + expect(first.cargo).toBeNull(); + + runUpdate({ root }); + const manifest = readJson(manifestPath()); + expect(manifest.apiDrift.packages).toEqual(["fake"]); + expect(manifest.apiDrift.baseline.fake.version).toBe("1.1.0"); + expect(manifest.apiDrift.baseline.fake.surface.greet).toBe("function: (name: string): number"); + + const second = runCheck({ root }); + expect(second.ok).toBe(true); + expect(second.results[0].findings).toEqual([]); + }, + ); + + it( + "errors on a protected package that is missing or has no baseline", + SLOW, + () => { + writeManifest({ packages: ["fake", "ghost"], baseline: {} }); + const outcome = runCheck({ root }); + expect(outcome.ok).toBe(false); + const ghost = outcome.results.find((r) => r.name === "ghost"); + expect(ghost.findings).toHaveLength(1); + expect(ghost.findings[0].code).toBe("types-unavailable"); + expect(ghost.findings[0].message).toContain("no baseline recorded"); + + const fake = outcome.results.find((r) => r.name === "fake"); + expect(fake.findings.map((f) => f.code)).toContain("missing-baseline"); + + expect(() => runUpdate({ root, packages: ["ghost"] })).toThrow(/cannot baseline/); + }, + ); +}); + +// --------------------------------------------------------------------------- +// Committed guardrail configuration +// --------------------------------------------------------------------------- + +describe("committed configuration", () => { + it("records a protected package list and baseline in package.json", () => { + const manifest = readJson(path.join(ROOT, "package.json")); + expect(manifest.scripts["security:api-drift"]).toBe("node scripts/detect-api-drift.js --check"); + expect(manifest.scripts["security:api-drift:update"]).toBe("node scripts/detect-api-drift.js --update"); + expect(Array.isArray(manifest.apiDrift.packages)).toBe(true); + expect(manifest.apiDrift.packages.length).toBeGreaterThan(0); + for (const pkg of manifest.apiDrift.packages) { + const entry = manifest.apiDrift.baseline[pkg]; + expect(entry, `${pkg} has no baseline`).toBeDefined(); + expect(entry.version).toMatch(/^\d+\.\d+\.\d+/); + expect(Object.keys(entry.surface).length).toBeGreaterThan(0); + } + expect(Object.keys(manifest.apiDrift.baseline).sort()).toEqual([...manifest.apiDrift.packages].sort()); + }); + + it("keeps the extraction compiler options in tsconfig.json (outside compilerOptions)", () => { + const file = path.join(ROOT, "tsconfig.json"); + const { config, error } = ts.readConfigFile(file, (p) => fs.readFileSync(p, "utf8")); + expect(error).toBeUndefined(); + expect(config.apiDrift.compilerOptions).toMatchObject({ + target: "ES2022", + module: "CommonJS", + skipLibCheck: true, + noEmit: true, + }); + expect(config.compilerOptions).not.toHaveProperty("apiDrift"); + expect(() => ts.parseJsonConfigFileContent({ ...config, compilerOptions: config.apiDrift.compilerOptions }, ts.sys, ROOT)).not.toThrow(); + }); + + it("guards the server package with its own baseline", () => { + const manifest = readJson(path.join(ROOT, "server/package.json")); + expect(manifest.scripts["security:api-drift"]).toContain("detect-api-drift.js"); + expect(manifest.apiDrift.packages.length).toBeGreaterThan(0); + for (const pkg of manifest.apiDrift.packages) { + expect(manifest.apiDrift.baseline[pkg].surface).toBeTypeOf("object"); + } + }); + + it("declares the Rust guard metadata in Cargo.toml", () => { + const cargo = fs.readFileSync(path.join(ROOT, "Cargo.toml"), "utf8"); + const result = checkCargoPinning(cargo); + expect(result.config).not.toBeNull(); + expect(cargo).toContain("[workspace.metadata.api-drift]"); + expect(result.findings.filter((f) => f.code === "missing-guard-config")).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Real package extraction (uses the installed node_modules) +// --------------------------------------------------------------------------- + +describe("real package surface extraction", () => { + it( + "extracts a real installed dependency and matches the committed baseline", + SLOW, + () => { + const manifest = readJson(path.join(ROOT, "package.json")); + const pkg = manifest.apiDrift.packages.find((name) => name === "cors") ?? manifest.apiDrift.packages[0]; + const { surfaces, skipped } = extractPackageSurfaces([pkg], { root: REPO_ROOT }); + expect(skipped[pkg]).toBeUndefined(); + + const { surface, meta } = surfaces[pkg]; + expect(meta.version).toMatch(/^\d+\.\d+\.\d+/); + expect(Object.keys(surface).length).toBeGreaterThan(0); + + const committed = manifest.apiDrift.baseline[pkg].surface; + const { breaking } = diffSurface(committed, surface); + expect(breaking, `${pkg} drifted from its committed baseline — run npm run security:api-drift:update`).toEqual([]); + }, + ); +}); + +// --------------------------------------------------------------------------- +// CLI contract +// --------------------------------------------------------------------------- + +describe("CLI", () => { + const run = (args, opts = {}) => spawnSync(process.execPath, [SCRIPT, ...args], { encoding: "utf8", ...opts }); + + it("prints usage for --help", () => { + const result = run(["--help"]); + expect(result.status).toBe(0); + expect(result.stdout).toContain("Usage:"); + expect(result.stdout).toContain("--require-exact-pin"); + }); + + it("rejects unknown options with exit code 2", () => { + const result = run(["--nope"]); + expect(result.status).toBe(2); + expect(result.stderr).toContain("unknown option: --nope"); + }); + + it("emits machine readable JSON with --json", () => { + const result = run(["--json", "--packages", "no-such-package", "--root", path.join(ROOT, "server")]); + const payload = JSON.parse(result.stdout); + expect(payload.ok).toBe(false); + expect(payload.results[0].name).toBe("no-such-package"); + expect(payload.results[0].ok).toBe(false); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index bd9779ec..bec2e17b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,5 +23,23 @@ "noFallthroughCasesInSwitch": true }, "include": ["src", "server", "scripts", "test"], - "references": [] + "references": [], + + /* Type-surface extraction options for scripts/detect-api-drift.js. + Kept out of compilerOptions so `tsc --noEmit` ignores it. */ + "apiDrift": { + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "strict": false, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "allowJs": false, + "checkJs": false, + "resolveJsonModule": false, + "types": [] + } + } } From 835df40c054e19a91d12c4416bf3af0ee78a8df6 Mon Sep 17 00:00:00 2001 From: Petrelid Date: Sat, 26 Sep 2026 16:16:46 +0100 Subject: [PATCH 3/3] Add comprehensive tests for worker sandbox isolation and message handling - Implement tests for in-worker lockdown, ensuring restricted access to storage and globals. - Validate the boot process and message sanitization for zk and cluster worker schemas. - Test the creation and behavior of sandboxed workers with both same-origin and opaque iframe transports. - Ensure proper handling of message transferables and schema validation for inbound and outbound messages. - Integrate Vite plugin tests to confirm correct worker sandboxing behavior during development. --- docs/security-architecture.md | 72 ++ package-lock.json | 4 +- package.json | 3 +- plugins/vite-plugin-worker-sandbox.js | 65 ++ src/lib/workerSandbox.ts | 1359 +++++++++++++++++++++++++ src/types/index.ts | 26 + src/workers/clusterWorker.js | 41 +- src/workers/zk-worker.js | 56 +- test/worker-sandbox.test.js | 840 +++++++++++++++ vite.config.ts | 4 + 10 files changed, 2450 insertions(+), 20 deletions(-) create mode 100644 plugins/vite-plugin-worker-sandbox.js create mode 100644 src/lib/workerSandbox.ts create mode 100644 test/worker-sandbox.test.js diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 3326b4d9..b135695b 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -108,3 +108,75 @@ The header and the markup use the same value because both come from `res.locals. **The service worker precaches `index.html`.** A precached shell would serve a stale nonce. Keep the navigation route network-first for HTML when this ships; until then a service-worker-served page will be blocked by the policy rather than run unnonced scripts. **Unchanged for API-only deploys.** The HTML route falls through when `dist/index.html` does not exist. + +# Browser Sandbox Isolation for Untrusted Web Workers + +Worker code is not fully trusted: the ZK prover pulls in third-party WASM and JS runtimes, and the canvas/cluster workers run vendored algorithm modules. A worker normally shares the page's origin, so a bug or compromise there could read `localStorage` / `sessionStorage`, reach the app origin's IndexedDB and Cache storage, use DOM globals a library happened to leak, or smuggle prototype-polluting payloads across `postMessage`. + +Three independent layers close that off (`src/lib/workerSandbox.ts`, wired in by `plugins/vite-plugin-worker-sandbox.js`, covered by `test/worker-sandbox.test.js`): + +1. **Origin isolation.** The worker is launched from a `blob:` URL created *inside* an iframe marked `sandbox="allow-scripts"` (no `allow-same-origin`), so it inherits an opaque ("null") origin: no cookies, no app storage, no same-origin privileges. The iframe owns the `Worker` and relays both directions, re-transferring transferables (`ArrayBuffer`s, `OffscreenCanvas`, …) so nothing is copied. +2. **Lockdown.** A bootstrap blob runs `installWorkerLockdown()` *before* any application module is imported, deleting (or, where a property cannot be deleted, making access throw on) `localStorage`, `sessionStorage`, `indexedDB`, `caches`, `BroadcastChannel`, `SharedWorker`, `importScripts`, `window`, `document`, `parent`, `top`, `frames` and `opener`. `self` is never touched, the function is idempotent, and it refuses to run against a window-like realm (anything with a `document`), so the same call is safe as a second line of defence inside the worker module. +3. **Message sanitization.** Every payload is parsed with a strict zod schema chosen for that worker's protocol, in *both* directions. Rejected payloads never cross the boundary; they are recorded as violations on the handle. + +## Launch flow + +`vite build`/`vite dev` transform (`enforce: 'post'`, i.e. after `vite:worker-import-meta-url` has turned `new URL('../workers/x.js', import.meta.url)` into a worker chunk URL): + +```js +new Worker(new URL("../workers/zk-worker.js", import.meta.url), { type: "module" }) +// becomes +import { createSandboxedWorker } from "/src/lib/workerSandbox.ts"; +createSandboxedWorker(new URL("../workers/zk-worker.js", import.meta.url), { type: "module" }) +``` + +The argument list is untouched, so Vite's worker chunking and `worker: { format: "es" }` keep working. `src/workers/**`, `test/**` and `node_modules/**` are excluded, and the transform is skipped entirely while `process.env.VITEST` is set (tests supply their own Worker doubles). + +At runtime the handle resolves in this order: + +1. Build the bootstrap (lockdown source + `import(workerUrl)`), create the sandboxed iframe, pass it the embedding document's CSP nonce (`srcdoc` documents inherit the parent's policy, so its inline script has to carry the nonce). +2. The frame mints the blob URL, creates the worker, and relays `hello`/`create`/`message`/`terminate` envelopes back and forth; both ends authenticate with a per-launch token and check `event.source`/`event.origin`. +3. Messages posted before the frame connects are queued (cap 64) and flushed once it is ready. +4. The worker posts `__helphone: 'boot'` after the lockdown runs; the handle marks the worker booted and clears the boot timer. Control messages are consumed by the handle and never reach `onmessage`. + +## Violation codes + +| Code | Meaning | +| --- | --- | +| `inbound-schema` | A message *to* the worker failed its protocol schema; dropped | +| `outbound-schema` | A message *from* the worker failed its protocol schema; dropped | +| `boot-failure` | Frame error, missing boot handshake, or the bootstrap could not import the worker module | +| `frame-timeout` | The opaque transport did not become ready in time | +| `queue-overflow` | More than 64 messages were posted while connecting (oldest dropped) | +| `transport-error` | The relay threw while posting | + +Violations are exposed as `handle.violations` (and through `onViolation`); without a handler they are logged with a `[worker-sandbox]` prefix. + +## Degradation + +Opaque isolation costs something in restricted environments, so it fails *open to a weaker but still protected* configuration rather than breaking the worker: + +- frame unavailable, frame never connects, boot handshake missing, or the module graph is not CORS-readable → **same-origin blob worker** (lockdown + sanitization still apply), with a `frame-timeout` / `boot-failure` violation and a console warning; +- `origin: "same-origin"` skips the iframe by design; +- `origin: "opaque"` (or `fallbackToSameOrigin: false`) never degrades — it surfaces the failure through `onerror` instead. + +## Production deployment notes + +The sandbox is fully functional under `npm run dev` and `npm run preview`, where the plugin also answers `Origin: null` fetches with `Access-Control-Allow-Origin: null` for non-`/api` GETs. Two deployment knobs are required for the opaque path to work behind the production CSP/server: + +- **Static assets need `Access-Control-Allow-Origin: null`** (or `*`). An opaque-origin worker fetches its module graph as a cross-origin CORS request; without the header the import fails, the bootstrap reports `boot-error`, and the handle degrades to same-origin. +- **`script-src` is evaluated against the worker's origin.** For a `blob:null/...` worker the inherited `'self'` is the null origin, so the inherited production policy can reject the module import for the same reason. Serving the app without that inheritance (or with an explicit source for the worker entry) keeps opaque mode enabled; otherwise the automatic same-origin fallback keeps the app working. + +Known trade-offs: an opaque worker reports `crossOriginIsolated === false`, so `zk-worker.getThreadCount()` falls back to one thread (slower proving — pass `origin: "same-origin"` to that launch site if threaded proving matters more than isolation); and `frame-src` is not needed for the frame, because the frame document is created from `srcdoc` rather than navigated to a URL. + +## Design decisions + +**The parent never talks to the worker directly in opaque mode.** A `blob:null/...` URL can only be resolved inside the frame that minted it, so the iframe owns the `Worker` and the parent only ever sees relayed messages. The relay is what keeps `transfer` semantics intact on both hops. + +**Schemas live with the protocol, not the call site.** `selectWorkerSchemas()` picks the boundary from the emitted worker file name (`zk-worker*` → zk, `clusterWorker*` → cluster, everything else → generic), so a renamed chunk still gets a sanitizer and a test can assert exactly which protocol enforced a rejection. The two in-scope workers validate on their side too — defence in depth, and it keeps a future same-origin launch just as strict. + +**The lockdown is stringified, not imported.** It has to run before any application code, from a blob that cannot resolve imports, so it may not reference module scope. That constraint is enforced by construction (and by `buildWorkerBootstrap`'s source assertions). + +**Schema failures drop the payload rather than the worker.** A malformed message is a bug or an attack on one boundary, not a reason to tear down the prover; every drop is counted on the handle so callers can see them. + +**The rewrite is masked text substitution, not a regex over raw source.** Comments and string literals are blanked (same length, same offsets) before searching, so documentation mentioning `new Worker(` cannot be rewritten, and the injected import is only added when the module does not already import the launcher. diff --git a/package-lock.json b/package-lock.json index 037669cd..9ea6f5bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,8 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-map-gl": "^8.1.1", - "react-router-dom": "^7.18.0" + "react-router-dom": "^7.18.0", + "zod": "^3.25.76" }, "devDependencies": { "@eslint/js": "^9.0.0", @@ -18045,7 +18046,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "optional": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 29ad4ac4..731371c1 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,8 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-map-gl": "^8.1.1", - "react-router-dom": "^7.18.0" + "react-router-dom": "^7.18.0", + "zod": "^3.25.76" }, "apiDrift": { "packages": [ diff --git a/plugins/vite-plugin-worker-sandbox.js b/plugins/vite-plugin-worker-sandbox.js new file mode 100644 index 00000000..aa0b6164 --- /dev/null +++ b/plugins/vite-plugin-worker-sandbox.js @@ -0,0 +1,65 @@ +// plugins/vite-plugin-worker-sandbox.js +// +// Browser sandbox isolation for untrusted Web Worker scripts (see +// src/lib/workerSandbox.ts and docs/security-architecture.md). +// +// - transform (enforce: 'post', i.e. after vite:worker-import-meta-url has +// rewritten `new URL('../workers/x.js', import.meta.url)` into a worker +// chunk URL): swaps `new Worker(` for `createSandboxedWorker(` and injects +// the launcher import, so every worker starts from a blob URL created inside +// a sandboxed (null-origin) iframe. +// - configureServer / configurePreviewServer: a worker running in an opaque +// origin fetches its module graph with `Origin: null`; without +// `Access-Control-Allow-Origin` the sandbox logs the failure and degrades to +// a same-origin blob worker. Scoped to non-API GETs so the API is unaffected. + +import path from "node:path"; +import { rewriteWorkerConstructors } from "../src/lib/workerSandbox.ts"; + +/** App source that owns worker launch sites (never the workers themselves). */ +function isAppModule(file) { + if (file.includes("node_modules")) return false; + return /\.(js|jsx|ts|tsx)$/.test(file); +} + +export function workerSandboxVitePlugin() { + let root = process.cwd(); + + const allowOpaqueOrigin = (req, res, next) => { + const url = req.url || ""; + if (req.headers.origin === "null" && req.method === "GET" && !url.startsWith("/api")) { + res.setHeader("Access-Control-Allow-Origin", "null"); + res.setHeader("Vary", "Origin"); + } + next(); + }; + + return { + name: "helphone:worker-sandbox", + enforce: "post", + + configResolved(config) { + root = config.root; + }, + + transform(code, id) { + // Vitest supplies its own Worker doubles at the launch sites; never + // rewrite modules while the test suite is running. + if (process.env.VITEST) return null; + const file = String(id).split("?")[0]; + if (!isAppModule(file)) return null; + const rel = path.relative(root, file).split(path.sep).join("/"); + if (!rel.startsWith("src/") || rel.startsWith("src/workers/")) return null; + const rewritten = rewriteWorkerConstructors(code, file); + return rewritten ? { code: rewritten, map: null } : null; + }, + + configureServer(server) { + server.middlewares.use(allowOpaqueOrigin); + }, + + configurePreviewServer(server) { + server.middlewares.use(allowOpaqueOrigin); + }, + }; +} diff --git a/src/lib/workerSandbox.ts b/src/lib/workerSandbox.ts new file mode 100644 index 00000000..ebaf8dc4 --- /dev/null +++ b/src/lib/workerSandbox.ts @@ -0,0 +1,1359 @@ +/** + * Browser sandbox isolation for untrusted Web Worker scripts. + * + * Threat model: worker code is not fully trusted (third-party prover/WASM + * runtimes, map and telemetry helpers, vendored algorithm modules). A worker + * that runs with the page's origin could read `localStorage` / `sessionStorage`, + * reach the app origin's IndexedDB / Cache storage, use DOM globals a library + * happened to leak, or smuggle prototype-polluting / type-confused payloads + * across `postMessage`. + * + * Three independent layers: + * + * 1. **Origin isolation** — the worker is launched from a `blob:` URL created + * inside an iframe marked `sandbox="allow-scripts"` (deliberately *without* + * `allow-same-origin`), so it inherits an opaque ("null") origin: no + * cookies, no app storage, no same-origin privileges. The iframe relays + * messages in both directions and re-transfers transferables. When the + * opaque origin cannot boot (frame blocked, module graph not CORS + * readable, CSP) the sandbox degrades to a same-origin blob worker after a + * bounded wait instead of breaking the app. + * 2. **Lockdown** — a bootstrap blob runs `installWorkerLockdown()` inside the + * worker *before* any application code executes, revoking storage and DOM + * globals (this layer holds even when origin isolation is unavailable). + * 3. **Message sanitization** — every payload crossing the boundary is parsed + * with a strict zod schema chosen for that worker's protocol, in both + * directions. Rejected payloads never reach the other side and are + * recorded as violations. + * + * The build wires this in through {@link rewriteWorkerConstructors} (invoked + * from vite.config.ts): every `new Worker(...)` under `src/` becomes a + * sandboxed launch while the `new URL(..., import.meta.url)` argument keeps + * Vite's own worker chunking intact. + */ + +import { z, type ZodTypeAny } from "zod"; + +// --------------------------------------------------------------------------- +// Message schemas (strict, one per worker protocol) +// --------------------------------------------------------------------------- + +/** Structured-clone friendly binary payload (typed arrays, not DataView). */ +const typedArraySchema = z.custom( + (value) => ArrayBuffer.isView(value) && !(value instanceof DataView), + { message: "expected a typed array" }, +); + +const messageIdSchema = z + .number() + .int() + .nonnegative() + .lte(Number.MAX_SAFE_INTEGER); + +/** + * Fallback schema for workers without a dedicated protocol: it still rejects + * payloads that could poison the receiving realm (prototype-pollution keys, + * absurd nesting) or that are too large to walk. + */ +export const genericWorkerMessageSchema = z.unknown().superRefine((value, ctx) => { + const FORBIDDEN = ["__proto__", "constructor", "prototype"]; + const MAX_DEPTH = 24; + const MAX_ARRAY_ITEMS = 4096; + let budget = 10000; + + const walk = (node: unknown, depth: number, path: (string | number)[]): void => { + if (depth > MAX_DEPTH) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path, + message: `message nesting exceeds ${MAX_DEPTH} levels`, + }); + return; + } + if (node === null || typeof node !== "object") return; + if (ArrayBuffer.isView(node) || node instanceof ArrayBuffer) return; + if (budget-- <= 0) return; + if (Array.isArray(node)) { + const items = node.slice(0, MAX_ARRAY_ITEMS); + for (let i = 0; i < items.length; i++) walk(items[i], depth + 1, [...path, i]); + return; + } + let keys: string[]; + try { + keys = Object.getOwnPropertyNames(node); + } catch { + return; // exotic host object: nothing to inspect, nothing to reject + } + for (const key of keys) { + if (FORBIDDEN.includes(key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [...path, key], + message: `forbidden key "${key}"`, + }); + return; + } + let child: unknown; + try { + child = (node as Record)[key]; + } catch { + continue; // throwing getter on a host object + } + walk(child, depth + 1, [...path, key]); + } + }; + + walk(value, 0, []); +}); + +/** Parent → zk-worker: prove / warmProver / isProverReady / initHumanity. */ +export const zkWorkerInboundSchema = z + .strictObject({ + id: messageIdSchema, + type: z.literal("prove").optional(), + action: z.enum(["prove", "warmProver", "isProverReady", "initHumanity"]).optional(), + inputs: z.record(z.unknown()).optional(), + payload: z.unknown().optional(), + }) + .refine((value) => value.type === "prove" || value.action !== undefined, { + message: "either type: 'prove' or an action is required", + path: ["action"], + }); + +/** Log text: the worker only ever sends strings, numbers or booleans. */ +const logText = z.union([z.string().max(4096), z.number(), z.boolean()]); + +/** zk-worker → parent: progress / result / log / completion / error. */ +export const zkWorkerOutboundSchema = z.union([ + z.strictObject({ + type: z.literal("progress"), + id: messageIdSchema, + action: z.literal("log"), + message: logText, + }), + z.strictObject({ + type: z.literal("done"), + id: messageIdSchema, + action: z.literal("proveComplete"), + proof: typedArraySchema, + publicInputs: z.unknown(), + profiling: z + .strictObject({ + provingMs: z.number(), + heapDeltaBytes: z.number(), + memStats: z + .strictObject({ + totalAllocated: z.number(), + pooled: z.number(), + active: z.number(), + }) + .passthrough(), + }) + .passthrough(), + }), + z.strictObject({ + type: z.literal("error"), + id: messageIdSchema, + action: z.literal("error"), + error: z.string().max(8192), + }), + z.strictObject({ + id: messageIdSchema, + action: z.enum([ + "log", + "warmProverComplete", + "initHumanityComplete", + "isProverReadyResult", + "error", + ]), + message: logText.optional(), + error: z.string().max(8192).optional(), + success: z.boolean().optional(), + ready: z.boolean().optional(), + }), +]); + +/** Parent → cluster worker: one binning job. */ +export const clusterWorkerInboundSchema = z.strictObject({ + id: messageIdSchema, + points: z.union([typedArraySchema, z.array(z.number())]), + params: z.strictObject({ + originX: z.number(), + originY: z.number(), + cellSize: z.number(), + fixedScale: z.number(), + gridW: z.number(), + gridH: z.number(), + maxPoints: z.number(), + }), +}); + +/** cluster worker → parent: bin results or an error string. */ +export const clusterWorkerOutboundSchema = z.union([ + z.strictObject({ + id: messageIdSchema, + computeMs: z.number().nonnegative(), + grid: z.strictObject({ + cellCount: typedArraySchema, + cellSum: typedArraySchema, + cellRadius: typedArraySchema, + }), + }), + z.strictObject({ id: messageIdSchema, error: z.string().max(2048) }), +]); + +export type WorkerSchemas = { + /** Short protocol label used in violation reports. */ + boundary: string; + inbound: ZodTypeAny | null; + outbound: ZodTypeAny | null; +}; + +/** + * Pick the schemas for a worker from its emitted file name. Workers without a + * dedicated protocol still get the generic sanitizer in both directions. + */ +export function selectWorkerSchemas(workerUrl: string): WorkerSchemas { + const file = String(workerUrl).split("?")[0].split("/").pop() ?? ""; + if (file.startsWith("zk-worker")) { + return { boundary: "zk", inbound: zkWorkerInboundSchema, outbound: zkWorkerOutboundSchema }; + } + if (file.startsWith("clusterWorker")) { + return { boundary: "cluster", inbound: clusterWorkerInboundSchema, outbound: clusterWorkerOutboundSchema }; + } + return { boundary: "generic", inbound: genericWorkerMessageSchema, outbound: genericWorkerMessageSchema }; +} + +export type ValidationResult = { ok: true; data: T } | { ok: false; error: string }; + +/** Parse `data` with `schema`, collapsing zod's issue list to one line. */ +export function validateMessage(schema: S, data: unknown): ValidationResult> { + const result = schema.safeParse(data); + if (result.success) return { ok: true, data: result.data }; + const issue = result.error.issues[0]; + const path = issue.path.length ? issue.path.map(String).join(".") : "(root)"; + return { ok: false, error: `${path}: ${issue.message}` }; +} + +// --------------------------------------------------------------------------- +// In-worker lockdown +// --------------------------------------------------------------------------- + +export interface WorkerLockdownReport { + /** Whether the lockdown ran (false when called on a window-like realm). */ + applied: boolean; + /** Every key the lockdown is designed to remove. */ + blocked: string[]; + /** Keys that were present and are now gone. */ + revoked: string[]; + /** Keys that could not be deleted and now throw on access. */ + guarded: string[]; + /** Keys that stayed readable because neither removal worked. */ + unrevocable: string[]; + at: number; +} + +/** + * Revoke storage and DOM globals inside a worker. + * + * Deliberately **self contained**: the source is stringified into the + * bootstrap blob, so it must not reference any binding declared outside its + * own body (no shared constants, no helpers, no imports). It is idempotent and + * is also called directly from worker modules as a second line of defence. + * + * Safety rail: refuses to run against a window-like realm (anything that has a + * `document`), because stripping DOM globals from the main thread would break + * the application — this function is for workers only. + * + * `localStorage` / `sessionStorage` do not exist in dedicated workers, so each + * property is deleted where possible (keeps `typeof x === "undefined"` feature + * detection working for third-party code) and made to throw where it cannot be + * removed. + */ +export function installWorkerLockdown(target?: Record): WorkerLockdownReport { + const g = (target ?? (globalThis as unknown as Record)) as Record; + + const prior = g.__helphoneWorkerLockdown as WorkerLockdownReport | undefined; + if (prior) return prior; + + const blocked = [ + // storage & persistence + "localStorage", + "sessionStorage", + "indexedDB", + "caches", + "BroadcastChannel", + "SharedWorker", + // ad-hoc script loading (classic workers only) + "importScripts", + // parent window / DOM references a leaked binding would resolve to + "window", + "document", + "parent", + "top", + "frames", + "opener", + ]; + + let hasDocument = false; + try { + hasDocument = "document" in g && g.document != null; + } catch { + hasDocument = false; + } + + const base: WorkerLockdownReport = { + applied: false, + blocked: blocked.slice(), + revoked: [], + guarded: [], + unrevocable: [], + at: Date.now(), + }; + if (hasDocument) { + return base; + } + + for (const key of blocked) { + let present = false; + try { + present = key in g; + } catch { + present = false; + } + try { + // Prefer deletion: third-party feature detection keeps working. + delete g[key]; + } catch { + /* non-deletable property, handled below */ + } + let stillThere = false; + try { + stillThere = key in g; + } catch { + stillThere = true; + } + if (!stillThere) { + if (present) base.revoked.push(key); + continue; + } + // Could not delete it: make every access fail loudly instead of silently + // handing out the real object. + try { + const error = new Error(`worker sandbox: "${key}" is revoked`); + Object.defineProperty(g, key, { + configurable: false, + enumerable: false, + get() { + throw error; + }, + set() { + throw error; + }, + }); + base.guarded.push(key); + } catch { + base.unrevocable.push(key); + } + } + + base.applied = true; + try { + g.__helphoneWorkerLockdown = base; + } catch { + /* frozen global — the lockdown itself still applied */ + } + return base; +} + +// --------------------------------------------------------------------------- +// Bootstrap blob +// --------------------------------------------------------------------------- + +/** Namespace for messages that must never reach application code. */ +export const INTERNAL_CHANNEL = "__helphone"; +export const BOOT_MESSAGE = "boot"; +export const BOOT_ERROR_MESSAGE = "boot-error"; + +/** True for sandbox control messages (boot handshake, boot failures). */ +export function isInternalMessage(data: unknown): data is Record { + return Boolean(data && typeof data === "object" && (data as Record)[INTERNAL_CHANNEL]); +} + +/** + * Source of the bootstrap module executed inside the worker: run the lockdown + * first, announce it (with its report), then import the real worker module. + * The URL must be absolute — a `blob:` URL has no base for relative imports. + */ +export function buildWorkerBootstrap(workerUrl: string): string { + const url = JSON.stringify(String(workerUrl)); + const channel = JSON.stringify(INTERNAL_CHANNEL); + return [ + "/* helphone worker sandbox bootstrap */", + `const __lockdown = (${installWorkerLockdown.toString()})();`, + `self.postMessage({ ${channel}: ${JSON.stringify(BOOT_MESSAGE)}, lockdown: __lockdown });`, + `import(${url}).catch((error) => {`, + ` self.postMessage({ ${channel}: ${JSON.stringify(BOOT_ERROR_MESSAGE)},`, + ` message: String((error && error.message) || error) });`, + `});`, + ].join("\n"); +} + +// --------------------------------------------------------------------------- +// Transferables +// --------------------------------------------------------------------------- + +/** `Object.prototype.toString` brands of transferable host objects. */ +const TRANSFERABLE_BRANDS = new Set([ + "[object OffscreenCanvas]", + "[object ImageBitmap]", + "[object MessagePort]", + "[object ReadableStream]", + "[object WritableStream]", + "[object TransformStream]", + "[object VideoFrame]", + "[object AudioData]", +]); + +function isTransferable(value: object): boolean { + try { + return TRANSFERABLE_BRANDS.has(Object.prototype.toString.call(value)); + } catch { + return false; + } +} + +/** + * Collect every transferable reachable in a structured-clone payload so the + * sandbox relay keeps zero-copy semantics on both hops (ArrayBuffer views, + * OffscreenCanvas, ImageBitmap, streams, …). + */ +export function collectTransferables( + value: unknown, + out: unknown[] = [], + seen = new Set(), + depth = 0, +): unknown[] { + if (value === null || typeof value !== "object" || depth > 32 || seen.has(value)) return out; + seen.add(value); + if (value instanceof ArrayBuffer) { + if (!out.includes(value)) out.push(value); + return out; + } + if (ArrayBuffer.isView(value)) { + const buffer = (value as ArrayBufferView).buffer; + if (buffer instanceof ArrayBuffer && !out.includes(buffer)) out.push(buffer); + return out; + } + if (isTransferable(value) || (typeof MessagePort !== "undefined" && value instanceof MessagePort)) { + if (!out.includes(value)) out.push(value); + return out; + } + if (Array.isArray(value)) { + for (const item of value) collectTransferables(item, out, seen, depth + 1); + return out; + } + let keys: string[]; + try { + keys = Object.keys(value as Record); + } catch { + return out; + } + for (const key of keys) { + let child: unknown; + try { + child = (value as Record)[key]; + } catch { + continue; + } + collectTransferables(child, out, seen, depth + 1); + } + return out; +} + +// --------------------------------------------------------------------------- +// Sandbox frame (null origin) +// --------------------------------------------------------------------------- + +export type WorkerOriginMode = "opaque" | "same-origin"; + +/** Transport used by the handle: parent ↔ (iframe relay) ↔ worker. */ +export interface SandboxTransport { + readonly originMode: WorkerOriginMode; + /** Resolves once the transport can accept messages. */ + whenReady(): Promise; + post(data: unknown, transfer?: Transferable[]): void; + terminate(): void; + onMessage(listener: (data: unknown) => void): void; + onError(listener: (error: Error) => void): void; + onBoot(listener: (message: Record) => void): void; +} + +export interface SandboxTransportOptions { + workerUrl: string; + bootstrap: string; + name?: string; + /** Test seam: build the worker without the DOM. */ + createWorker?: (url: string, init?: WorkerOptions) => Worker; + /** Test seam: skip the iframe and hand-roll the relay. */ + createFrame?: () => SandboxFrame | null; +} + +/** Parent side of the sandboxed-iframe relay. */ +export interface SandboxFrame { + readonly origin: string; + /** Create the worker inside the frame; resolves once it is live. */ + create(bootstrap: string, name?: string): Promise; + post(data: unknown, transfer?: Transferable[]): void; + terminate(): void; + onMessage(listener: (data: unknown) => void): void; + onError(listener: (error: Error) => void): void; +} + +const FRAME_TOKEN_PREFIX = "helphone-worker-sandbox"; + +function randomToken(): string { + const rand = + typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + return `${FRAME_TOKEN_PREFIX}:${rand}`; +} + +function makeMessageEvent(data: unknown): MessageEvent { + if (typeof MessageEvent === "function") { + try { + return new MessageEvent("message", { data }); + } catch { + /* some environments reject MessageEvent construction */ + } + } + return { data } as MessageEvent; +} + +function makeErrorEvent(message: string): ErrorEvent { + if (typeof ErrorEvent === "function") { + try { + return new ErrorEvent("error", { message }); + } catch { + /* fall through */ + } + } + return { message } as unknown as ErrorEvent; +} + +/** + * Document loaded into the sandboxed iframe. It owns the blob URL and the + * worker, and mirrors every payload to the parent while re-transferring + * transferables. Emitted as a string so it stays independent of the bundle. + * + * `nonce` is forwarded from the embedding document: `srcdoc` documents inherit + * the parent's CSP, so an inline script under a nonce policy must carry it. + */ +export function buildSandboxFrameHtml(nonce = ""): string { + const collect = collectTransferables.toString(); + const nonceAttr = nonce ? ` nonce="${nonce.replace(/[^A-Za-z0-9+/=_-]/g, "")}"` : ""; + return `helphone worker sandbox +(function () { + var token = null, worker = null, objectUrl = null; + var __collect = (${collect}); + function send(message, transfer) { + try { parent.postMessage(message, "*", transfer || []); } catch (error) {} + } + window.addEventListener("message", function (event) { + if (event.source !== parent) return; + var data = event.data; + if (!data || typeof data !== "object") return; + if (data.kind === "hello") { token = data.token; return; } + if (!token || data.token !== token) return; + if (data.kind === "create") { + try { + objectUrl = URL.createObjectURL(new Blob([data.bootstrap], { type: "text/javascript" })); + worker = new Worker(objectUrl, { type: "module", name: data.name || "helphone-worker" }); + worker.onmessage = function (ev) { + send({ token: token, kind: "message", data: ev.data }, __collect(ev.data, [])); + }; + worker.onmessageerror = function () { + send({ token: token, kind: "error", message: "worker payload failed to deserialize" }); + }; + worker.onerror = function (ev) { + send({ token: token, kind: "error", message: String((ev && ev.message) || "worker script failed") }); + }; + send({ token: token, kind: "ready" }); + } catch (error) { + send({ token: token, kind: "error", message: String((error && error.message) || error) }); + } + return; + } + if (data.kind === "message") { + if (!worker) return; + var transfer = data.transfer || []; + if (!transfer.length && data.data && typeof data.data === "object") { + transfer = __collect(data.data, []); + } + try { worker.postMessage(data.data, transfer); } + catch (error) { send({ token: token, kind: "error", message: String((error && error.message) || error) }); } + return; + } + if (data.kind === "terminate") { + if (worker) { try { worker.terminate(); } catch (error) {} } + if (objectUrl) { try { URL.revokeObjectURL(objectUrl); } catch (error) {} } + worker = null; objectUrl = null; + send({ token: token, kind: "terminated" }); + } + }); + send({ kind: "loaded" }); +})(); +`; +} + +/** Read the embedding document's CSP nonce so the srcdoc script may run. */ +function readCspNonce(target: Document): string { + try { + const el = target.querySelector("script[nonce]") as HTMLScriptElement | null; + if (!el) return ""; + return (el.nonce as string) || el.getAttribute("nonce") || ""; + } catch { + return ""; + } +} + +/** + * Create a hidden `sandbox="allow-scripts"` iframe (opaque origin) that owns + * the blob URL and the worker. Returns `null` when the environment has no + * usable DOM — callers then fall back to a same-origin blob worker. + */ +export function createDomSandboxFrame(doc?: Document): SandboxFrame | null { + const target = doc ?? (typeof document !== "undefined" ? document : undefined); + if (!target || typeof target.createElement !== "function") return null; + if (typeof Worker !== "function") return null; + + const token = randomToken(); + const iframe = target.createElement("iframe"); + iframe.setAttribute("sandbox", "allow-scripts"); + iframe.setAttribute("title", "helphone worker sandbox"); + iframe.setAttribute("aria-hidden", "true"); + iframe.tabIndex = -1; + iframe.style.cssText = + "position:fixed;left:-1px;top:-1px;width:1px;height:1px;border:0;opacity:0;pointer-events:none"; + iframe.srcdoc = buildSandboxFrameHtml(readCspNonce(target)); + + const messageListeners: Array<(data: unknown) => void> = []; + const errorListeners: Array<(error: Error) => void> = []; + let settled: { resolve: () => void; reject: (error: Error) => void } | null = null; + let loaded = false; + let ready = false; + let terminated = false; + let pendingCreate: { bootstrap: string; name?: string } | null = null; + + const postToFrame = (payload: Record, transfer?: Transferable[]) => { + iframe.contentWindow?.postMessage(payload, "*", transfer ?? []); + }; + + const flushPending = () => { + if (!loaded || !pendingCreate) return; + const create = pendingCreate; + pendingCreate = null; + postToFrame({ kind: "hello", token }); + postToFrame({ kind: "create", token, bootstrap: create.bootstrap, name: create.name }); + }; + + const handleParentMessage = (event: MessageEvent) => { + if (terminated) return; + const source = (iframe.contentWindow as Window | null) ?? null; + if (!source || event.source !== source) return; + // A sandboxed frame reports an opaque origin; anything else is an impostor. + if (event.origin !== "null" && event.origin !== "") return; + const data = event.data as Record | null; + if (!data || typeof data !== "object") return; + + if (data.kind === "loaded") { + loaded = true; + flushPending(); + return; + } + if (data.token !== token) return; + if (data.kind === "ready") { + ready = true; + settled?.resolve(); + settled = null; + return; + } + if (data.kind === "message") { + for (const listener of messageListeners) listener(data.data); + return; + } + if (data.kind === "error") { + const error = new Error(String(data.message ?? "worker sandbox error")); + if (!ready && settled) { + settled.reject(error); + settled = null; + } + for (const listener of errorListeners) listener(error); + } + }; + + const dispose = () => { + terminated = true; + if (typeof window !== "undefined") window.removeEventListener("message", handleParentMessage); + iframe.remove(); + }; + + if (typeof window !== "undefined") window.addEventListener("message", handleParentMessage); + (target.documentElement ?? target.body)?.appendChild(iframe); + + return { + origin: "null", + create(bootstrap: string, name?: string) { + pendingCreate = { bootstrap, name }; + flushPending(); + return new Promise((resolve, reject) => { + settled = { resolve, reject }; + // The frame posts "loaded" once its script is live; if that never + // happens the handle's connect timeout degrades the worker instead. + setTimeout(() => { + if (!ready && settled) { + settled = null; + reject(new Error("worker sandbox frame did not become ready")); + } + }, 4000); + }); + }, + post(data, transfer) { + postToFrame({ kind: "message", token, data, transfer }, transfer as Transferable[]); + }, + terminate() { + if (terminated) return; + postToFrame({ kind: "terminate", token }); + setTimeout(dispose, 0); + }, + onMessage(listener) { + messageListeners.push(listener); + }, + onError(listener) { + errorListeners.push(listener); + }, + }; +} + +/** Same-origin transport: blob URL created and owned by the page itself. */ +export function createDirectTransport(options: SandboxTransportOptions): SandboxTransport { + const { workerUrl, bootstrap, name, createWorker } = options; + const factory = + createWorker ?? + ((url: string, init?: WorkerOptions) => { + if (typeof Worker !== "function") throw new Error("worker sandbox: no Worker implementation available"); + return new Worker(url, init); + }); + + const blob = new Blob([bootstrap], { type: "text/javascript" }); + const canMint = typeof URL !== "undefined" && typeof URL.createObjectURL === "function"; + // Environments without blob URLs (jsdom/tests) still get a stable handle. + const objectUrl = canMint ? URL.createObjectURL(blob) : `blob:helphone-worker-sandbox/${name ?? "worker"}`; + const worker = factory(objectUrl, { type: "module", name: name ?? "helphone-worker" }); + + const messageListeners: Array<(data: unknown) => void> = []; + const errorListeners: Array<(error: Error) => void> = []; + const bootListeners: Array<(message: Record) => void> = []; + + worker.onmessage = (event: MessageEvent) => { + const data = event.data; + if (isInternalMessage(data)) { + for (const listener of bootListeners) listener(data); + return; + } + for (const listener of messageListeners) listener(data); + }; + worker.onerror = (event: ErrorEvent) => { + const error = new Error(event?.message ?? "worker script failed"); + for (const listener of errorListeners) listener(error); + }; + + return { + originMode: "same-origin", + async whenReady() { + /* a same-origin blob worker is usable immediately */ + }, + post(data, transfer) { + worker.postMessage(data, transfer as Transferable[]); + }, + terminate() { + try { + worker.terminate(); + } catch { + /* already gone */ + } + if (canMint) URL.revokeObjectURL(objectUrl); + }, + onMessage(listener) { + messageListeners.push(listener); + }, + onError(listener) { + errorListeners.push(listener); + }, + onBoot(listener) { + bootListeners.push(listener); + }, + }; +} + +/** Opaque transport: iframe-owned blob URL + relayed messages. */ +export function createOpaqueTransport(options: SandboxTransportOptions): SandboxTransport | null { + const frame = (options.createFrame ?? createDomSandboxFrame)(); + if (!frame) return null; + + const messageListeners: Array<(data: unknown) => void> = []; + const errorListeners: Array<(error: Error) => void> = []; + const bootListeners: Array<(message: Record) => void> = []; + const ready = frame.create(options.bootstrap, options.name).catch((error: unknown) => { + const err = error instanceof Error ? error : new Error(String(error)); + for (const listener of errorListeners) listener(err); + throw err; + }); + + frame.onMessage((data) => { + if (isInternalMessage(data)) { + for (const listener of bootListeners) listener(data); + return; + } + for (const listener of messageListeners) listener(data); + }); + frame.onError((error) => { + for (const listener of errorListeners) listener(error); + }); + + return { + originMode: "opaque", + whenReady: () => ready, + post(data, transfer) { + frame.post(data, transfer); + }, + terminate() { + frame.terminate(); + }, + onMessage(listener) { + messageListeners.push(listener); + }, + onError(listener) { + errorListeners.push(listener); + }, + onBoot(listener) { + bootListeners.push(listener); + }, + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export type SandboxViolationCode = + | "inbound-schema" + | "outbound-schema" + | "boot-failure" + | "frame-timeout" + | "queue-overflow" + | "transport-error"; + +export interface WorkerSandboxViolation { + code: SandboxViolationCode; + /** Protocol label of the boundary that rejected the payload. */ + boundary: string; + direction?: "inbound" | "outbound"; + reason: string; + /** Correlation id when one could be read from the payload. */ + messageId?: number; + /** 1-based ordinal of this violation on the handle. */ + count: number; + at: number; +} + +export interface WorkerSandboxOptions { + /** Worker module type; module workers only (blob ES modules). */ + type?: "module"; + /** Diagnostics name forwarded to `new Worker(..., { name })`. */ + name?: string; + /** + * Origin isolation to attempt. + * - `auto` (default): opaque/null origin first, degrading on boot failure + * - `opaque`: never degrade (fail instead) + * - `same-origin`: skip the iframe, keep lockdown + sanitization + */ + origin?: "auto" | WorkerOriginMode; + /** Override protocol detection (used by tests and exotic entry points). */ + schemas?: Partial | null; + /** Test seam: build the worker without the DOM. */ + createWorker?: (url: string, init?: WorkerOptions) => Worker; + /** Test seam: inject the iframe relay. */ + createFrame?: () => SandboxFrame | null; + /** How long to wait for the opaque transport before degrading. */ + connectTimeoutMs?: number; + /** How long to wait for the worker's boot handshake before degrading. */ + bootTimeoutMs?: number; + /** Set `false` to surface boot failures instead of degrading. */ + fallbackToSameOrigin?: boolean; + /** Called for every rejected payload or degradation event. */ + onViolation?: (violation: WorkerSandboxViolation) => void; +} + +const MAX_QUEUE = 64; + +/** + * Worker-compatible handle returned by {@link createSandboxedWorker}. Exposes + * the subset of the `Worker` surface the app uses (`postMessage`, `terminate`, + * `onmessage`/`onerror`, `addEventListener`) plus sandbox diagnostics. + */ +export class SandboxedWorker implements Worker { + onmessage: ((this: Worker, ev: MessageEvent) => any) | null = null; + onmessageerror: ((this: Worker, ev: MessageEvent) => any) | null = null; + onerror: ((this: AbstractWorker, ev: ErrorEvent) => any) | null = null; + + readonly name: string; + readonly schemas: WorkerSchemas; + + private transport: SandboxTransport | null = null; + private transportReady = false; + /** Origin isolation of the transport currently (or last) in force. */ + private activeOrigin: WorkerOriginMode | null = null; + private readonly listeners = new Map>(); + private readonly queue: Array<{ data: unknown; transfer?: Transferable[] }> = []; + private readonly recorded: WorkerSandboxViolation[] = []; + private readonly options: WorkerSandboxOptions; + private readonly workerUrl: string; + private readonly bootstrap: string; + private readonly requestedOrigin: "auto" | WorkerOriginMode; + private booted = false; + private terminated = false; + private degraded = false; + private bootTimer: ReturnType | null = null; + + constructor(workerUrl: string | URL, options: WorkerSandboxOptions = {}) { + this.workerUrl = String(workerUrl); + this.options = options; + this.name = options.name ?? "helphone-worker"; + this.schemas = { ...selectWorkerSchemas(this.workerUrl), ...(options.schemas ?? {}) }; + this.bootstrap = buildWorkerBootstrap(this.workerUrl); + this.requestedOrigin = options.origin ?? "auto"; + this.start(this.requestedOrigin === "same-origin" ? "same-origin" : "opaque"); + } + + /** Which origin isolation is currently in force. */ + get originMode(): WorkerOriginMode { + return this.activeOrigin ?? "same-origin"; + } + + /** True once the worker executed the lockdown bootstrap. */ + get isBooted(): boolean { + return this.booted; + } + + get violations(): readonly WorkerSandboxViolation[] { + return this.recorded; + } + + postMessage(message: any, transfer: Transferable[]): void; + postMessage(message: any, options?: StructuredSerializeOptions): void; + postMessage(message: any, transferOrOptions?: Transferable[] | StructuredSerializeOptions): void { + if (this.terminated) return; + const transfer = Array.isArray(transferOrOptions) + ? transferOrOptions + : transferOrOptions && Array.isArray((transferOrOptions as StructuredSerializeOptions).transfer) + ? (transferOrOptions as StructuredSerializeOptions).transfer + : undefined; + + const result = this.schemas.inbound + ? validateMessage(this.schemas.inbound, message) + : ({ ok: true, data: message } as const); + if (!result.ok) { + this.record("inbound-schema", result.error, "inbound", message); + return; + } + const payload = result.data; + if (!this.transportReady || !this.transport) { + if (this.queue.length >= MAX_QUEUE) { + this.queue.shift(); + this.record("queue-overflow", `dropped oldest queued message (cap ${MAX_QUEUE})`, "inbound", payload); + } + this.queue.push({ data: payload, transfer }); + return; + } + this.deliver(payload, transfer); + } + + terminate(): void { + if (this.terminated) return; + this.terminated = true; + if (this.bootTimer) clearTimeout(this.bootTimer); + this.transport?.terminate(); + this.transport = null; + this.queue.length = 0; + this.listeners.clear(); + } + + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void { + if (!listener) return; + if (!this.listeners.has(type)) this.listeners.set(type, new Set()); + this.listeners.get(type)?.add(listener); + } + + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions, + ): void { + if (!listener) return; + this.listeners.get(type)?.delete(listener); + } + + dispatchEvent(event: Event): boolean { + this.emit(event.type, event); + return true; + } + + // -- internals ---------------------------------------------------------- + + private start(mode: WorkerOriginMode): void { + const options: SandboxTransportOptions = { + workerUrl: this.workerUrl, + bootstrap: this.bootstrap, + name: this.name, + createWorker: this.options.createWorker, + createFrame: this.options.createFrame, + }; + + let transport: SandboxTransport | null = null; + if (mode === "opaque") { + transport = createOpaqueTransport(options); + if (!transport) { + if (this.requestedOrigin === "opaque") { + this.record("frame-timeout", "opaque origin unavailable in this environment", "inbound"); + this.fail(new Error("worker sandbox: opaque origin unavailable")); + } + // `auto`: silently continue with a same-origin blob worker. + } + } + if (!transport) { + try { + transport = createDirectTransport(options); + } catch (error) { + this.emitError(error instanceof Error ? error : new Error(String(error))); + return; + } + } + + this.transport = transport; + this.activeOrigin = transport.originMode; + this.transportReady = transport.originMode === "same-origin"; + const bound = transport; + transport.onMessage((data) => { + if (this.transport !== bound) return; + this.fromWorker(data); + }); + transport.onError((error) => { + if (this.transport !== bound) return; + this.fromTransportError(error); + }); + transport.onBoot((message) => { + if (this.transport !== bound) return; + this.fromBoot(message); + }); + + if (transport.originMode === "opaque") { + this.armConnectTimeout(); + transport + .whenReady() + .then(() => { + if (this.terminated || this.transport !== bound) return; + this.transportReady = true; + this.armBootTimeout(); + this.flush(); + }) + .catch(() => { + /* handled through onError */ + }); + } else { + // Same-origin workers are usable immediately, including after a + // degradation replay of everything queued while the frame connected. + this.flush(); + } + } + + private armConnectTimeout(): void { + const timeout = this.options.connectTimeoutMs ?? 4000; + if (!Number.isFinite(timeout)) return; + setTimeout(() => { + if (this.terminated || this.degraded || this.transportReady || this.booted) return; + this.record("frame-timeout", `opaque origin not ready within ${timeout}ms`, "inbound"); + this.degrade("frame timeout"); + }, timeout); + } + + private armBootTimeout(): void { + const timeout = this.options.bootTimeoutMs ?? 6000; + if (!Number.isFinite(timeout)) return; + if (this.bootTimer) clearTimeout(this.bootTimer); + this.bootTimer = setTimeout(() => { + if (this.terminated || this.booted) return; + if (this.transport?.originMode !== "opaque" || this.degraded) return; + this.record("boot-failure", `no boot handshake within ${timeout}ms`, "inbound"); + this.degrade("boot timeout"); + }, timeout); + } + + private degrade(reason: string): void { + if (this.degraded || this.terminated) return; + this.degraded = true; + if (this.bootTimer) clearTimeout(this.bootTimer); + this.transport?.terminate(); + this.transport = null; + this.transportReady = false; + if (this.options.fallbackToSameOrigin === false || this.requestedOrigin === "opaque") { + this.fail(new Error(`worker sandbox: ${reason}`)); + return; + } + this.start("same-origin"); + if (typeof console !== "undefined") { + console.warn(`[worker-sandbox] degraded to same-origin (${reason}): ${this.workerUrl}`); + } + } + + private fail(error: Error): void { + this.terminated = true; + if (this.bootTimer) clearTimeout(this.bootTimer); + this.transport?.terminate(); + this.transport = null; + this.transportReady = false; + this.emitError(error); + } + + private deliver(data: unknown, transfer?: Transferable[]): void { + try { + this.transport?.post(data, transfer); + } catch (error) { + this.record("transport-error", error instanceof Error ? error.message : String(error), "inbound", data); + } + } + + private flush(): void { + const queued = this.queue.splice(0, this.queue.length); + for (const entry of queued) this.deliver(entry.data, entry.transfer); + } + + private fromBoot(message: Record): void { + if (message[INTERNAL_CHANNEL] === BOOT_ERROR_MESSAGE) { + this.record("boot-failure", String(message.message ?? "worker module failed to load"), "inbound"); + if (this.transport?.originMode === "opaque" && this.options.fallbackToSameOrigin !== false && !this.degraded) { + this.degrade("bootstrap import failed"); + } else { + this.emitError(new Error(String(message.message ?? "worker module failed to load"))); + } + return; + } + this.booted = true; + if (this.bootTimer) clearTimeout(this.bootTimer); + this.flush(); + } + + private fromWorker(data: unknown): void { + if (isInternalMessage(data)) { + this.fromBoot(data as Record); + return; + } + const result = this.schemas.outbound + ? validateMessage(this.schemas.outbound, data) + : ({ ok: true, data } as const); + if (!result.ok) { + this.record("outbound-schema", result.error, "outbound", data); + return; + } + const event = makeMessageEvent(result.data); + this.onmessage?.call(this as unknown as Worker, event); + this.emit("message", event); + } + + private fromTransportError(error: Error): void { + if (!this.booted && !this.degraded && this.transport?.originMode === "opaque") { + this.record("boot-failure", error.message, "inbound"); + this.degrade(error.message); + return; + } + this.emitError(error); + } + + private emitError(error: Error): void { + const event = makeErrorEvent(error.message); + this.onerror?.call(this as unknown as AbstractWorker, event); + this.emit("error", event); + } + + private emit(type: string, event: Event): void { + const set = this.listeners.get(type); + if (!set || !set.size) return; + for (const listener of [...set]) { + try { + if (typeof listener === "function") listener.call(this as unknown as Worker, event); + else listener.handleEvent(event); + } catch { + /* a broken listener must not break the boundary */ + } + } + } + + private record( + code: SandboxViolationCode, + reason: string, + direction?: "inbound" | "outbound", + payload?: unknown, + ): void { + const violation: WorkerSandboxViolation = { + code, + boundary: this.schemas.boundary, + direction, + reason, + messageId: readMessageId(payload), + count: this.recorded.length + 1, + at: Date.now(), + }; + this.recorded.push(violation); + if (this.recorded.length > 100) this.recorded.shift(); + const handler = this.options.onViolation; + if (handler) handler(violation); + else if (typeof console !== "undefined") { + console.warn(`[worker-sandbox] ${code} (${violation.boundary}): ${reason}`); + } + } +} + +function readMessageId(payload: unknown): number | undefined { + if (payload && typeof payload === "object" && typeof (payload as { id?: unknown }).id === "number") { + return (payload as { id: number }).id; + } + return undefined; +} + +/** + * Launch an untrusted worker through the sandbox. + * + * Returns synchronously (launch sites keep their `new Worker(...)` shape); the + * opaque transport connects in the background, messages posted before it is + * ready are queued and flushed, or replayed into a degraded same-origin worker. + */ +export function createSandboxedWorker(workerUrl: string | URL, options: WorkerSandboxOptions = {}): SandboxedWorker { + return new SandboxedWorker(workerUrl, options); +} + +export interface WorkerSandboxSupport { + /** A sandboxed (opaque) frame could be created in this environment. */ + opaqueOrigin: boolean; + /** Blob URLs can be minted at all. */ + blobUrls: boolean; +} + +/** + * Probe the current environment for sandbox support so the first worker launch + * does not pay the iframe warm-up cost. + */ +export function prepareWorkerSandbox(doc?: Document): WorkerSandboxSupport { + let blobUrls = false; + try { + blobUrls = typeof URL !== "undefined" && typeof URL.createObjectURL === "function"; + } catch { + blobUrls = false; + } + const frame = blobUrls ? createDomSandboxFrame(doc) : null; + if (frame) frame.terminate(); + return { opaqueOrigin: Boolean(frame), blobUrls }; +} + +// --------------------------------------------------------------------------- +// Build integration +// --------------------------------------------------------------------------- + +/** + * Blank out comments and quoted strings (same length, same offsets) so the + * constructor search never rewrites documentation or string literals. + */ +export function maskCode(code: string): string { + const out = code.split(""); + let i = 0; + const n = code.length; + const blank = (from: number, to: number) => { + for (let k = from; k < to && k < n; k++) out[k] = " "; + }; + while (i < n) { + const ch = code[i]; + const next = code[i + 1]; + if (ch === "/" && next === "/") { + const end = code.indexOf("\n", i); + blank(i, end === -1 ? n : end); + i = end === -1 ? n : end; + continue; + } + if (ch === "/" && next === "*") { + const end = code.indexOf("*/", i + 2); + blank(i, end === -1 ? n : end + 2); + i = end === -1 ? n : end + 2; + continue; + } + if (ch === '"' || ch === "'" || ch === "`") { + const quote = ch; + let j = i + 1; + while (j < n) { + if (code[j] === "\\") { + j += 2; + continue; + } + if (code[j] === quote) { + j += 1; + break; + } + // Template literals may nest `${ ... }`; treat the whole literal as + // opaque — nobody writes a worker launch inside an interpolation. + j += 1; + } + blank(i, j); + i = j; + continue; + } + i += 1; + } + return out.join(""); +} + +/** + * Rewrite `new Worker(...)` call sites into sandboxed launches. + * + * Invoked by the `helphone:worker-sandbox` plugin in vite.config.ts with + * `enforce: 'post'`, i.e. after `vite:worker-import-meta-url` has turned + * `new URL('../workers/x.js', import.meta.url)` into a worker chunk URL — the + * argument list is untouched, only the constructor is swapped. Returns `null` + * when the module has nothing to rewrite. + */ +export function rewriteWorkerConstructors(code: string, id: string): string | null { + if (id.includes("workerSandbox")) return null; + if (!/\bnew\s+Worker\s*\(/.test(maskCode(code))) return null; + if (/\bcreateSandboxedWorker\s*\(/.test(code)) return null; + + const masked = maskCode(code); + const spans: Array<[number, number]> = []; + const pattern = /\bnew\s+Worker\s*\(/g; + let match = pattern.exec(masked); + while (match) { + const start = match.index; + const end = start + match[0].lastIndexOf("Worker") + "Worker".length; + spans.push([start, end]); + match = pattern.exec(masked); + } + if (!spans.length) return null; + + let rewritten = code; + for (const [start, end] of spans.sort((a, b) => b[0] - a[0])) { + rewritten = `${rewritten.slice(0, start)}createSandboxedWorker${rewritten.slice(end)}`; + } + if (/from\s+["'][^"']*workerSandbox(\.ts)?["']/.test(rewritten)) return rewritten; + return `import { createSandboxedWorker } from "/src/lib/workerSandbox.ts";\n${rewritten}`; +} diff --git a/src/types/index.ts b/src/types/index.ts index 91d8e837..eb45cd19 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -287,3 +287,29 @@ export interface ExpertVerificationWindow { /** Entries that have been evicted (`oldest`). */ evicted: number } + +// --- Worker Sandbox Isolation (untrusted Web Worker scripts) --- +/** + * The sandbox lives next to the code that enforces it + * (`src/lib/workerSandbox.ts`) so the runtime and the types can never drift; + * this section re-exports the public surface for app-level imports. + * + * - `WorkerSandboxViolation` — one rejected payload or degradation event + * - `WorkerSandboxOptions` — per-launch knobs (origin mode, test seams, …) + * - `WorkerSandboxViolationCode` — stable codes: `inbound-schema`, + * `outbound-schema`, `boot-failure`, `frame-timeout`, `queue-overflow`, + * `transport-error` + * - `WorkerLockdownReport` — what the in-worker lockdown revoked + * - `WorkerOriginMode` — `'opaque'` (null origin) or `'same-origin'` + */ +export type { + SandboxTransport, + SandboxFrame, + SandboxViolationCode, + WorkerLockdownReport, + WorkerOriginMode, + WorkerSandboxOptions, + WorkerSandboxSupport, + WorkerSandboxViolation, + WorkerSchemas, +} from '../lib/workerSandbox' diff --git a/src/workers/clusterWorker.js b/src/workers/clusterWorker.js index 139398b8..a6630dbb 100644 --- a/src/workers/clusterWorker.js +++ b/src/workers/clusterWorker.js @@ -3,18 +3,47 @@ // float-blendable WebGL2 context is available. import { binPoints } from "../lib/spatialCluster/cpuGridCluster.js"; +import { + clusterWorkerInboundSchema, + clusterWorkerOutboundSchema, + installWorkerLockdown, + validateMessage, +} from "../lib/workerSandbox.ts"; + +// Worker sandbox, layer 2: the bootstrap blob already ran this before this +// module was imported. Running it again keeps the lockdown in place when the +// worker is launched through the same-origin fallback path. +installWorkerLockdown(); self.onmessage = (event) => { - const { id, points, params } = event.data; + // Worker sandbox, layer 3: only binning jobs in the documented shape are + // accepted, and only binning results in the documented shape are returned. + const request = validateMessage(clusterWorkerInboundSchema, event.data); + if (!request.ok) { + console.warn(`[cluster-worker] dropped inbound message: ${request.error}`); + return; + } + const { id, points, params } = request.data; try { const t0 = performance.now(); const { cellCount, cellSum, cellRadius } = binPoints(points, params); const computeMs = performance.now() - t0; - self.postMessage( - { id, grid: { cellCount, cellSum, cellRadius }, computeMs }, - [cellCount.buffer, cellSum.buffer, cellRadius.buffer], - ); + post({ id, grid: { cellCount, cellSum, cellRadius }, computeMs }, [ + cellCount.buffer, + cellSum.buffer, + cellRadius.buffer, + ]); } catch (err) { - self.postMessage({ id, error: err instanceof Error ? err.message : String(err) }); + post({ id, error: err instanceof Error ? err.message : String(err) }); } }; + +function post(message, transfer) { + const parsed = validateMessage(clusterWorkerOutboundSchema, message); + if (!parsed.ok) { + console.warn(`[cluster-worker] dropped outbound message: ${parsed.error}`); + return; + } + if (transfer) self.postMessage(parsed.data, transfer); + else self.postMessage(parsed.data); +} diff --git a/src/workers/zk-worker.js b/src/workers/zk-worker.js index ae53d754..bdb0f71b 100644 --- a/src/workers/zk-worker.js +++ b/src/workers/zk-worker.js @@ -1,3 +1,15 @@ +import { + installWorkerLockdown, + validateMessage, + zkWorkerInboundSchema, + zkWorkerOutboundSchema, +} from "../lib/workerSandbox.ts"; + +// Worker sandbox, layer 2: the bootstrap blob already ran this before this +// module was imported. Running it again keeps the lockdown in place when the +// worker is launched through the same-origin fallback path. +installWorkerLockdown(); + let _noir = null; let _backend = null; let _Noir = null; @@ -273,14 +285,36 @@ function isProverReady() { ); } +/** + * postMessage through the outbound schema: a payload that does not match the + * protocol never leaves the worker (defence in depth alongside the parent's + * own validation of worker output). + */ +function postToMain(message, transfer) { + const parsed = validateMessage(zkWorkerOutboundSchema, message); + if (!parsed.ok) { + console.warn(`[zk-worker] dropped outbound message: ${parsed.error}`); + return; + } + if (transfer) self.postMessage(parsed.data, transfer); + else self.postMessage(parsed.data); +} + self.onmessage = async (event) => { - const data = event.data || {}; + // Worker sandbox, layer 3: reject anything that is not a known request + // before it can touch prover state. + const request = validateMessage(zkWorkerInboundSchema, event.data || {}); + if (!request.ok) { + console.warn(`[zk-worker] dropped inbound message: ${request.error}`); + return; + } + const data = request.data; // Support both legacy {type: 'prove'} and {action: 'prove'} protocols plus upstream actions const isProve = data.type === 'prove' || data.action === 'prove'; if (isProve) { const id = data.id; const inputs = data.inputs || data.payload?.inputs; - const progress = (msg) => self.postMessage({ type: 'progress', id, action: 'log', message: msg }); + const progress = (msg) => postToMain({ type: 'progress', id, action: 'log', message: msg }); let witnessBuf = null; let proofBuf = null; try { @@ -300,9 +334,9 @@ self.onmessage = async (event) => { const { proof } = await _backend.generateProof(witness); const proofBytes = proof instanceof Uint8Array ? proof : new Uint8Array(proof); const profiling = { provingMs: performance.now() - profileStart, heapDeltaBytes: Math.max(0, (performance.memory?.usedJSHeapSize ?? heapStart) - heapStart), memStats: memPool.stats() }; - self.postMessage({ type: 'done', id, action: 'proveComplete', proof: proofBytes, publicInputs: returnValue, profiling }, [proofBytes.buffer]); + postToMain({ type: 'done', id, action: 'proveComplete', proof: proofBytes, publicInputs: returnValue, profiling }, [proofBytes.buffer]); } catch (error) { - self.postMessage({ type: 'error', id: data.id, action: 'error', error: error.message || String(error) }); + postToMain({ type: 'error', id: data.id, action: 'error', error: error.message || String(error) }); } finally { if (witnessBuf) memPool.release(witnessBuf); if (proofBuf) memPool.release(proofBuf); @@ -315,37 +349,37 @@ self.onmessage = async (event) => { switch (action) { case "warmProver": { const onLog = (msg) => { - self.postMessage({ id, action: "log", message: msg }); + postToMain({ id, action: "log", message: msg }); }; await warmProver(onLog); - self.postMessage({ id, action: "warmProverComplete", success: true }); + postToMain({ id, action: "warmProverComplete", success: true }); break; } case "isProverReady": { const ready = isProverReady(); - self.postMessage({ id, action: "isProverReadyResult", ready }); + postToMain({ id, action: "isProverReadyResult", ready }); break; } case "initHumanity": { const onLog = (msg) => { - self.postMessage({ id, action: "log", message: msg }); + postToMain({ id, action: "log", message: msg }); }; await initHumanity(onLog); - self.postMessage({ id, action: "initHumanityComplete", success: true }); + postToMain({ id, action: "initHumanityComplete", success: true }); break; } default: - self.postMessage({ + postToMain({ id, action: "error", error: `Unknown action: ${action}`, }); } } catch (error) { - self.postMessage({ + postToMain({ id, action: "error", error: error.message || String(error), diff --git a/test/worker-sandbox.test.js b/test/worker-sandbox.test.js new file mode 100644 index 00000000..28f104a9 --- /dev/null +++ b/test/worker-sandbox.test.js @@ -0,0 +1,840 @@ +// Browser sandbox isolation for untrusted Web Worker scripts. +// +// Covers the three layers independently (origin isolation, in-worker +// lockdown, message sanitization) with injected fakes for the Worker and the +// sandboxed-iframe relay, so the suite never touches a real DOM frame. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + BOOT_ERROR_MESSAGE, + BOOT_MESSAGE, + INTERNAL_CHANNEL, + buildSandboxFrameHtml, + buildWorkerBootstrap, + clusterWorkerInboundSchema, + clusterWorkerOutboundSchema, + collectTransferables, + createSandboxedWorker, + genericWorkerMessageSchema, + installWorkerLockdown, + isInternalMessage, + maskCode, + prepareWorkerSandbox, + rewriteWorkerConstructors, + selectWorkerSchemas, + validateMessage, + zkWorkerInboundSchema, + zkWorkerOutboundSchema, +} from "../src/lib/workerSandbox.ts"; + +const ZK_URL = "https://helphone.test/assets/zk-worker-abc123.js"; +const CLUSTER_URL = "/src/workers/clusterWorker.js?worker_file&type=module"; + +let warn; + +beforeEach(() => { + warn = vi.spyOn(console, "warn").mockImplementation(() => {}); +}); +afterEach(() => { + warn.mockRestore(); +}); + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Worker double: records launches, deliveries and errors. */ +function workerFactory() { + const workers = []; + const create = (url, init) => { + const worker = { + url, + init, + terminated: false, + onmessage: null, + onerror: null, + sent: [], + postMessage(data, transfer) { + this.sent.push({ data, transfer }); + }, + terminate() { + this.terminated = true; + }, + emit(data) { + this.onmessage?.({ data }); + }, + fail(message) { + this.onerror?.({ message }); + }, + }; + workers.push(worker); + return worker; + }; + return { workers, create }; +} + +/** Sandboxed-iframe relay double. */ +function makeFrame(opts = {}) { + const listeners = { message: [], error: [] }; + const frame = { + origin: "null", + createdWith: null, + posts: [], + terminated: false, + create(bootstrap, name) { + frame.createdWith = { bootstrap, name }; + if (opts.neverReady) return new Promise(() => {}); + if (opts.createError) return Promise.reject(new Error(opts.createError)); + return Promise.resolve(); + }, + post(data, transfer) { + frame.posts.push({ data, transfer }); + }, + terminate() { + frame.terminated = true; + }, + onMessage(fn) { + listeners.message.push(fn); + }, + onError(fn) { + listeners.error.push(fn); + }, + emit(data) { + for (const fn of listeners.message) fn(data); + }, + fail(message) { + for (const fn of listeners.error) fn(new Error(message)); + }, + }; + return frame; +} + +// --------------------------------------------------------------------------- +// Layer 2: in-worker lockdown +// --------------------------------------------------------------------------- + +describe("installWorkerLockdown", () => { + it("revokes storage, ad-hoc script loading and parent/DOM globals", () => { + const realm = { + localStorage: { getItem: () => "x" }, + sessionStorage: {}, + indexedDB: {}, + caches: {}, + BroadcastChannel: class {}, + SharedWorker: class {}, + importScripts() {}, + window: {}, + parent: {}, + top: {}, + frames: {}, + opener: {}, + self: {}, + navigator: { hardwareConcurrency: 8 }, + }; + const report = installWorkerLockdown(realm); + + expect(report.applied).toBe(true); + expect(report.revoked).toEqual( + expect.arrayContaining([ + "localStorage", + "sessionStorage", + "indexedDB", + "caches", + "importScripts", + "window", + "parent", + "top", + "frames", + "opener", + ]), + ); + expect(realm.localStorage).toBeUndefined(); + expect(realm.window).toBeUndefined(); + expect("document" in realm).toBe(false); + expect(report.blocked).toContain("document"); + expect(report.revoked).not.toContain("document"); + // Untouched globals stay available for the worker itself. + expect(realm.navigator.hardwareConcurrency).toBe(8); + expect(realm.self).toEqual({}); + }); + + it("is idempotent", () => { + const realm = { localStorage: {} }; + const first = installWorkerLockdown(realm); + const second = installWorkerLockdown(realm); + expect(second).toBe(first); + }); + + it("refuses to touch a window-like realm", () => { + const realm = { document: {}, localStorage: { secret: 1 } }; + const report = installWorkerLockdown(realm); + expect(report.applied).toBe(false); + expect(report.revoked).toEqual([]); + expect(realm.localStorage).toEqual({ secret: 1 }); + expect(realm.document).toEqual({}); + }); + + it("makes an undeletable key throw instead of leaking it", () => { + const realm = new Proxy( + { sessionStorage: { token: "abc" } }, + { + deleteProperty() { + throw new Error("protected"); + }, + }, + ); + const report = installWorkerLockdown(realm); + expect(report.guarded).toContain("sessionStorage"); + expect(() => realm.sessionStorage).toThrow(/revoked/); + }); + + it("reports keys that could not be removed at all", () => { + const realm = {}; + Object.defineProperty(realm, "opener", { value: {}, configurable: false, writable: false }); + const report = installWorkerLockdown(realm); + expect(report.unrevocable).toContain("opener"); + expect(realm.opener).toEqual({}); + }); +}); + +// --------------------------------------------------------------------------- +// Layer 1: bootstrap blob +// --------------------------------------------------------------------------- + +describe("buildWorkerBootstrap", () => { + it("runs the lockdown before importing the worker module", () => { + const source = buildWorkerBootstrap(ZK_URL); + expect(source).toContain("installWorkerLockdown"); + expect(source).toContain(`import(${JSON.stringify(ZK_URL)})`); + expect(source.indexOf("installWorkerLockdown")).toBeLessThan(source.indexOf("import(")); + expect(source).toContain(`${JSON.stringify(INTERNAL_CHANNEL)}: ${JSON.stringify(BOOT_MESSAGE)}`); + expect(source).toContain(BOOT_ERROR_MESSAGE); + // Self contained: no module-scope bindings may be referenced. + expect(source).not.toMatch(/\bimport\s+[{"']/); + }); + + it("tags control messages so they never reach application code", () => { + expect(isInternalMessage({ [INTERNAL_CHANNEL]: BOOT_MESSAGE })).toBe(true); + expect(isInternalMessage({ type: "progress", id: 1 })).toBe(false); + expect(isInternalMessage(null)).toBe(false); + expect(isInternalMessage("boot")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Layer 3: message sanitization +// --------------------------------------------------------------------------- + +describe("zk worker schemas", () => { + it("accepts the requests the app sends", () => { + expect(validateMessage(zkWorkerInboundSchema, { id: 1, action: "warmProver" }).ok).toBe(true); + expect(validateMessage(zkWorkerInboundSchema, { id: 2, action: "isProverReady" }).ok).toBe(true); + expect( + validateMessage(zkWorkerInboundSchema, { id: 3, type: "prove", inputs: { a: 1 } }).ok, + ).toBe(true); + expect( + validateMessage(zkWorkerInboundSchema, { id: 4, action: "initHumanity", payload: { inputs: {} } }) + .ok, + ).toBe(true); + }); + + it("rejects malformed, unidentified or over-specified requests", () => { + expect(validateMessage(zkWorkerInboundSchema, { action: "warmProver" })).toMatchObject({ ok: false }); + expect(validateMessage(zkWorkerInboundSchema, { id: 1 })).toMatchObject({ ok: false }); + expect(validateMessage(zkWorkerInboundSchema, { id: 1.5, action: "prove" })).toMatchObject({ + ok: false, + }); + expect(validateMessage(zkWorkerInboundSchema, { id: 1, action: "prove", extra: true })).toMatchObject( + { ok: false }, + ); + expect(validateMessage(zkWorkerInboundSchema, "prove")).toMatchObject({ ok: false }); + }); + + it("accepts every outbound message shape the worker emits", () => { + const shapes = [ + { type: "progress", id: 1, action: "log", message: "Executing witness" }, + { + type: "done", + id: 1, + action: "proveComplete", + proof: new Uint8Array(8), + publicInputs: [1n, 2n], + profiling: { + provingMs: 12.5, + heapDeltaBytes: 4096, + memStats: { totalAllocated: 1024, pooled: 0, active: 1 }, + }, + }, + { type: "error", id: 1, action: "error", error: "boom" }, + { id: 1, action: "warmProverComplete", success: true }, + { id: 1, action: "isProverReadyResult", ready: false }, + { id: 1, action: "initHumanityComplete", success: true }, + { id: 1, action: "log", message: "Prover ready" }, + { id: 1, action: "error", error: "Unknown action: nope" }, + ]; + for (const shape of shapes) { + const label = JSON.stringify(shape, (key, value) => (typeof value === "bigint" ? String(value) : value)); + expect(validateMessage(zkWorkerOutboundSchema, shape), label).toMatchObject({ ok: true }); + } + }); + + it("rejects a proof payload with the wrong binary type or a missing profile", () => { + expect( + validateMessage(zkWorkerOutboundSchema, { + type: "done", + id: 1, + action: "proveComplete", + proof: "not-bytes", + publicInputs: [], + profiling: { provingMs: 1, heapDeltaBytes: 1, memStats: { totalAllocated: 0, pooled: 0, active: 0 } }, + }), + ).toMatchObject({ ok: false }); + expect( + validateMessage(zkWorkerOutboundSchema, { + type: "done", + id: 1, + action: "proveComplete", + proof: new Uint8Array(2), + publicInputs: [], + profiling: { provingMs: 1, heapDeltaBytes: 1 }, + }), + ).toMatchObject({ ok: false }); + expect(validateMessage(zkWorkerOutboundSchema, { type: "nope", id: 1 })).toMatchObject({ ok: false }); + }); + + it("reports the offending path", () => { + const result = validateMessage(zkWorkerInboundSchema, { id: 1, action: "prove", inputs: [] }); + expect(result.ok).toBe(false); + expect(result.error).toContain("inputs"); + }); +}); + +describe("cluster worker schemas", () => { + const params = { + originX: 0, + originY: 0, + cellSize: 10, + fixedScale: 1024, + gridW: 64, + gridH: 64, + maxPoints: 1000, + }; + + it("accepts typed-array and plain-array jobs", () => { + expect( + validateMessage(clusterWorkerInboundSchema, { id: 0, points: new Float32Array([1, 2]), params }).ok, + ).toBe(true); + expect(validateMessage(clusterWorkerInboundSchema, { id: 1, points: [1, 2], params }).ok).toBe(true); + }); + + it("rejects NaN geometry and missing params", () => { + expect( + validateMessage(clusterWorkerInboundSchema, { id: 0, points: [], params: { ...params, originX: NaN } }), + ).toMatchObject({ ok: false }); + expect(validateMessage(clusterWorkerInboundSchema, { id: 0, points: [] })).toMatchObject({ ok: false }); + }); + + it("accepts transferred results and error replies, rejects foreign grids", () => { + const ok = { + id: 0, + computeMs: 1.5, + grid: { + cellCount: new Uint32Array(4), + cellSum: new Float64Array(4), + cellRadius: new Float32Array(4), + }, + }; + expect(validateMessage(clusterWorkerOutboundSchema, ok)).toMatchObject({ ok: true }); + expect(validateMessage(clusterWorkerOutboundSchema, { id: 0, error: "bad points" })).toMatchObject({ + ok: true, + }); + expect(validateMessage(clusterWorkerOutboundSchema, { ...ok, grid: { ...ok.grid, cellCount: [] } })).toMatchObject( + { ok: false }, + ); + }); +}); + +describe("generic worker schema", () => { + it("accepts ordinary telemetry payloads", () => { + expect(validateMessage(genericWorkerMessageSchema, { type: "stats", stats: { fps: 60, frames: 3 } })).toMatchObject( + { ok: true }, + ); + expect(validateMessage(genericWorkerMessageSchema, { type: "resize", width: 800, height: 600 })).toMatchObject( + { ok: true }, + ); + }); + + it("rejects prototype-pollution keys", () => { + const payload = JSON.parse('{"type":"stats","__proto__":{"polluted":true}}'); + expect(Object.prototype.hasOwnProperty.call(payload, "__proto__")).toBe(true); + expect(validateMessage(genericWorkerMessageSchema, payload)).toMatchObject({ ok: false }); + expect( + validateMessage(genericWorkerMessageSchema, { constructor: { prototype: {} } }), + ).toMatchObject({ ok: false }); + }); + + it("rejects absurdly nested payloads", () => { + let deep = { leaf: true }; + for (let i = 0; i < 40; i++) deep = { next: deep }; + expect(validateMessage(genericWorkerMessageSchema, deep)).toMatchObject({ ok: false }); + }); + + it("does not choke on typed arrays or host objects", () => { + expect(validateMessage(genericWorkerMessageSchema, { pixels: new Uint8ClampedArray(16) })).toMatchObject({ + ok: true, + }); + const host = {}; + Object.defineProperty(host, "throwing", { + enumerable: true, + get() { + throw new Error("nope"); + }, + }); + expect(validateMessage(genericWorkerMessageSchema, host)).toMatchObject({ ok: true }); + }); +}); + +describe("selectWorkerSchemas", () => { + it("picks the protocol from the emitted worker file name", () => { + expect(selectWorkerSchemas(ZK_URL)).toMatchObject({ boundary: "zk" }); + expect(selectWorkerSchemas(CLUSTER_URL)).toMatchObject({ boundary: "cluster" }); + expect(selectWorkerSchemas("/assets/canvas-worker-9f.js")).toMatchObject({ boundary: "generic" }); + }); +}); + +// --------------------------------------------------------------------------- +// Transferables +// --------------------------------------------------------------------------- + +describe("collectTransferables", () => { + it("collects buffers behind typed arrays, without duplicates", () => { + const proof = new Uint8Array(8); + const shared = new Float32Array(4); + const found = collectTransferables({ proof, alsoProof: proof, nested: [{ grid: shared }] }); + expect(found).toHaveLength(2); + expect(found).toContain(proof.buffer); + expect(found).toContain(shared.buffer); + }); + + it("collects non-ArrayBuffer transferables (OffscreenCanvas etc.)", () => { + const canvasLike = {}; + Object.defineProperty(canvasLike, Symbol.toStringTag, { value: "OffscreenCanvas" }); + expect(collectTransferables({ canvas: canvasLike })).toContain(canvasLike); + expect(collectTransferables({ n: 1, s: "x" })).toEqual([]); + }); + + it("survives cyclic and deeply nested payloads", () => { + const cyclic = { self: null }; + cyclic.self = cyclic; + expect(() => collectTransferables(cyclic)).not.toThrow(); + let deep = { leaf: new Uint8Array(1) }; + for (let i = 0; i < 40; i++) deep = { next: deep }; + expect(() => collectTransferables(deep)).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Build integration +// --------------------------------------------------------------------------- + +describe("rewriteWorkerConstructors", () => { + it("swaps the constructor and injects the launcher import", () => { + const input = [ + 'export function spawn() {', + ' return new Worker(new URL("../workers/zk-worker.js", import.meta.url), { type: "module" });', + "}", + "", + ].join("\n"); + const out = rewriteWorkerConstructors(input, "/src/lib/worker-manager.js"); + expect(out).toContain("createSandboxedWorker(new URL("); + expect(out).not.toContain("new Worker("); + expect(out).toMatch(/^import \{ createSandboxedWorker \} from "\/src\/lib\/workerSandbox\.ts";/); + // The Vite worker chunking argument must survive untouched. + expect(out).toContain('new URL("../workers/zk-worker.js", import.meta.url)'); + expect(out).toContain('{ type: "module" }'); + }); + + it("leaves comments and string literals alone", () => { + const input = ['// new Worker(example)', 'const note = "new Worker(example)";', "const w = new Worker(url);", ""].join( + "\n", + ); + const out = rewriteWorkerConstructors(input, "/src/lib/example.js"); + expect(out).toContain("// new Worker(example)"); + expect(out).toContain('"new Worker(example)"'); + expect(out.match(/createSandboxedWorker\(/g)).toHaveLength(1); + }); + + it("returns null when there is nothing to rewrite, or already rewritten", () => { + expect(rewriteWorkerConstructors("export const x = 1;\n", "/src/lib/x.js")).toBeNull(); + expect(rewriteWorkerConstructors("// only a comment mentions new Worker\n", "/src/lib/x.js")).toBeNull(); + const once = rewriteWorkerConstructors("new Worker(url);\n", "/src/lib/x.js"); + expect(once).toContain("createSandboxedWorker(url)"); + expect(rewriteWorkerConstructors(once, "/src/lib/x.js")).toBeNull(); + }); + + it("masks code so the search cannot see through comments", () => { + const masked = maskCode('/* new Worker(a) */ // new Worker(b)\nconst s = "new Worker(c)";\nnew Worker(d);'); + expect(masked).not.toContain("new Worker(a)"); + expect(masked).not.toContain("new Worker(b)"); + expect(masked).not.toContain('"new Worker(c)"'); + expect(masked).toContain("new Worker(d);"); + }); +}); + +// --------------------------------------------------------------------------- +// The handle: launch, validate, relay, degrade +// --------------------------------------------------------------------------- + +describe("SandboxedWorker (same-origin transport)", () => { + it("launches a module worker named for diagnostics", () => { + const factory = workerFactory(); + const w = createSandboxedWorker(ZK_URL, { origin: "same-origin", createWorker: factory.create }); + expect(factory.workers).toHaveLength(1); + expect(factory.workers[0].init).toMatchObject({ type: "module", name: "helphone-worker" }); + expect(factory.workers[0].url).toBeTruthy(); + expect(w.originMode).toBe("same-origin"); + w.terminate(); + }); + + it("forwards schema-valid messages and drops invalid ones", () => { + const factory = workerFactory(); + const violations = []; + const w = createSandboxedWorker(ZK_URL, { + origin: "same-origin", + createWorker: factory.create, + onViolation: (v) => violations.push(v), + }); + const transfer = new ArrayBuffer(4); + w.postMessage({ id: 1, action: "warmProver" }); + w.postMessage({ action: "warmProver" }); // no id + w.postMessage({ id: 2, action: "prove", inputs: { a: 1 } }, [transfer]); + + expect(factory.workers[0].sent.map((m) => m.data.id)).toEqual([1, 2]); + expect(factory.workers[0].sent[1].transfer).toEqual([transfer]); + expect(violations).toHaveLength(1); + expect(violations[0]).toMatchObject({ code: "inbound-schema", boundary: "zk", direction: "inbound", count: 1 }); + expect(w.violations).toHaveLength(1); + w.terminate(); + }); + + it("consumes the boot handshake without leaking it to the app", () => { + const factory = workerFactory(); + const w = createSandboxedWorker(ZK_URL, { origin: "same-origin", createWorker: factory.create }); + const seen = []; + w.onmessage = (event) => seen.push(event.data); + + factory.workers[0].emit({ [INTERNAL_CHANNEL]: BOOT_MESSAGE, lockdown: { applied: true } }); + expect(w.isBooted).toBe(true); + expect(seen).toHaveLength(0); + + factory.workers[0].emit({ type: "progress", id: 1, action: "log", message: "Executing witness" }); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ type: "progress", message: "Executing witness" }); + w.terminate(); + }); + + it("drops malformed worker output and records why", () => { + const factory = workerFactory(); + const w = createSandboxedWorker(ZK_URL, { origin: "same-origin", createWorker: factory.create }); + const seen = []; + w.onmessage = (event) => seen.push(event.data); + + factory.workers[0].emit({ type: "done", id: 1, action: "proveComplete", proof: "base64?" }); + factory.workers[0].emit({ type: "unknown", id: 2 }); + expect(seen).toHaveLength(0); + expect(w.violations.map((v) => v.code)).toEqual(["outbound-schema", "outbound-schema"]); + expect(w.violations[0]).toMatchObject({ boundary: "zk", direction: "outbound", messageId: 1 }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("outbound-schema")); + w.terminate(); + }); + + it("sanitizes workers without a dedicated protocol", () => { + const factory = workerFactory(); + const w = createSandboxedWorker("/assets/canvas-worker-9f.js", { + origin: "same-origin", + createWorker: factory.create, + }); + const seen = []; + w.onmessage = (event) => seen.push(event.data); + + w.postMessage({ type: "init", width: 100, height: 80, dpr: 2 }); + expect(factory.workers[0].sent[0].data).toMatchObject({ type: "init" }); + + const poisoned = JSON.parse('{"type":"overlays","__proto__":{"x":1}}'); + w.postMessage(poisoned); + expect(factory.workers[0].sent).toHaveLength(1); + + factory.workers[0].emit({ type: "stats", stats: { fps: 60 } }); + factory.workers[0].emit({ type: "stats", stats: JSON.parse('{"__proto__":{"fps":0}}') }); + expect(seen).toHaveLength(1); + expect(w.violations.map((v) => v.code)).toEqual(["inbound-schema", "outbound-schema"]); + w.terminate(); + }); + + it("supports addEventListener/removeEventListener and terminate", () => { + const factory = workerFactory(); + const w = createSandboxedWorker(ZK_URL, { origin: "same-origin", createWorker: factory.create }); + const seen = []; + const listener = (event) => seen.push(event.data); + w.addEventListener("message", listener); + factory.workers[0].emit({ id: 1, action: "isProverReadyResult", ready: true }); + expect(seen).toHaveLength(1); + + w.removeEventListener("message", listener); + factory.workers[0].emit({ id: 2, action: "isProverReadyResult", ready: false }); + expect(seen).toHaveLength(1); + + const errors = []; + w.addEventListener("error", (event) => errors.push(event.message)); + factory.workers[0].fail("worker script failed"); + expect(errors[0]).toContain("worker script failed"); + + w.terminate(); + expect(factory.workers[0].terminated).toBe(true); + w.postMessage({ id: 9, action: "prove" }); + expect(factory.workers[0].sent).toHaveLength(0); + }); +}); + +describe("SandboxedWorker (opaque iframe transport)", () => { + it("creates the worker inside the frame and flushes queued messages", async () => { + const frame = makeFrame(); + const w = createSandboxedWorker(ZK_URL, { + createFrame: () => frame, + connectTimeoutMs: 500, + bootTimeoutMs: 500, + }); + expect(w.originMode).toBe("opaque"); + w.postMessage({ id: 1, action: "isProverReady" }); + expect(frame.posts).toHaveLength(0); // held until the frame connects + + await tick(); + expect(frame.createdWith.name).toBe("helphone-worker"); + expect(frame.createdWith.bootstrap).toContain("installWorkerLockdown"); + expect(frame.createdWith.bootstrap).toContain(ZK_URL); + expect(frame.posts).toHaveLength(1); + expect(frame.posts[0].data).toMatchObject({ id: 1, action: "isProverReady" }); + + frame.emit({ [INTERNAL_CHANNEL]: BOOT_MESSAGE, lockdown: { applied: true } }); + expect(w.isBooted).toBe(true); + expect(w.violations).toHaveLength(0); + w.terminate(); + expect(frame.terminated).toBe(true); + }); + + it("relays both directions and forwards transferables", async () => { + const frame = makeFrame(); + const w = createSandboxedWorker(ZK_URL, { createFrame: () => frame, connectTimeoutMs: 500, bootTimeoutMs: 500 }); + const seen = []; + w.onmessage = (event) => seen.push(event.data); + + const proofBuffer = new ArrayBuffer(8); + w.postMessage({ id: 1, type: "prove", inputs: { a: 1 } }, [proofBuffer]); + await tick(); + expect(frame.posts[0].transfer).toEqual([proofBuffer]); + + frame.emit({ type: "progress", id: 1, action: "log", message: "Generating proof" }); + expect(seen).toEqual([{ type: "progress", id: 1, action: "log", message: "Generating proof" }]); + + frame.emit({ type: "progress", id: 1, action: "log" }); + expect(seen).toHaveLength(1); + expect(w.violations.map((v) => v.code)).toEqual(["outbound-schema"]); + w.terminate(); + }); + + it("degrades to a same-origin worker when the frame never connects", async () => { + const factory = workerFactory(); + const frame = makeFrame({ neverReady: true }); + const w = createSandboxedWorker(ZK_URL, { + createFrame: () => frame, + createWorker: factory.create, + connectTimeoutMs: 10, + bootTimeoutMs: 500, + }); + w.postMessage({ id: 1, action: "warmProver" }); + expect(w.originMode).toBe("opaque"); + + await tick(40); + expect(w.originMode).toBe("same-origin"); + expect(frame.terminated).toBe(true); + expect(factory.workers).toHaveLength(1); + expect(factory.workers[0].sent[0].data).toMatchObject({ id: 1, action: "warmProver" }); + expect(w.violations.map((v) => v.code)).toEqual(["frame-timeout"]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("degraded to same-origin")); + w.terminate(); + }); + + it("degrades when the frame connects but the worker never boots", async () => { + const factory = workerFactory(); + const frame = makeFrame(); + const w = createSandboxedWorker(ZK_URL, { + createFrame: () => frame, + createWorker: factory.create, + connectTimeoutMs: 500, + bootTimeoutMs: 10, + }); + await tick(40); + expect(w.originMode).toBe("same-origin"); + expect(w.violations.map((v) => v.code)).toEqual(["boot-failure"]); + w.terminate(); + }); + + it("degrades when the bootstrap cannot import the worker module", async () => { + const factory = workerFactory(); + const frame = makeFrame(); + const w = createSandboxedWorker(ZK_URL, { + createFrame: () => frame, + createWorker: factory.create, + connectTimeoutMs: 500, + bootTimeoutMs: 500, + }); + await tick(); + frame.emit({ [INTERNAL_CHANNEL]: BOOT_ERROR_MESSAGE, message: "CORS policy blocked the module" }); + expect(w.originMode).toBe("same-origin"); + expect(w.violations.map((v) => v.code)).toEqual(["boot-failure"]); + expect(w.violations[0].reason).toContain("CORS policy"); + w.terminate(); + }); + + it("never degrades when the caller demanded an opaque origin", async () => { + const errors = []; + const frame = makeFrame({ neverReady: true }); + const w = createSandboxedWorker(ZK_URL, { + origin: "opaque", + createFrame: () => frame, + connectTimeoutMs: 10, + bootTimeoutMs: 10, + onViolation: () => {}, + }); + w.onerror = (event) => errors.push(event.message); + await tick(40); + expect(w.originMode).toBe("opaque"); + expect(errors).toHaveLength(1); + expect(w.violations.map((v) => v.code)).toEqual(["frame-timeout"]); + }); + + it("reports a frame that fails outright", async () => { + const errors = []; + const frame = makeFrame({ createError: "frame script blocked" }); + const w = createSandboxedWorker(ZK_URL, { + origin: "opaque", + createFrame: () => frame, + connectTimeoutMs: 500, + bootTimeoutMs: 500, + onViolation: () => {}, + }); + w.onerror = (event) => errors.push(event.message); + await tick(10); + expect(errors[0]).toContain("frame script blocked"); + expect(w.violations.map((v) => v.code)).toContain("boot-failure"); + }); + + it("queues at most 64 messages while connecting", async () => { + const frame = makeFrame({ neverReady: true }); + const w = createSandboxedWorker(ZK_URL, { + createFrame: () => frame, + createWorker: workerFactory().create, + connectTimeoutMs: 500, + bootTimeoutMs: 500, + }); + for (let i = 0; i < 70; i++) w.postMessage({ id: i, action: "isProverReady" }); + expect(w.violations.map((v) => v.code)).toContain("queue-overflow"); + w.terminate(); + }); +}); + +// --------------------------------------------------------------------------- +// Frame document + environment probe +// --------------------------------------------------------------------------- + +describe("buildSandboxFrameHtml", () => { + it("relays messages and re-transfers transferables", () => { + const html = buildSandboxFrameHtml(); + expect(html).toContain('data.kind === "create"'); + expect(html).toContain('data.kind === "message"'); + expect(html).toContain('data.kind === "terminate"'); + expect(html).toContain("new Worker("); + expect(html).toContain("__collect"); + expect(html).not.toContain("nonce="); + }); + + it("carries the embedding document's CSP nonce", () => { + expect(buildSandboxFrameHtml("abc+DEF/123=")).toContain('nonce="abc+DEF/123="'); + // A nonce must never break out of the attribute. + expect(buildSandboxFrameHtml('">')).not.toContain("alert(1)"); + }); +}); + +describe("prepareWorkerSandbox", () => { + it("reports what this environment can do", () => { + vi.stubGlobal("Worker", undefined); + const support = prepareWorkerSandbox(document); + expect(support).toEqual({ opaqueOrigin: false, blobUrls: expect.any(Boolean) }); + vi.unstubAllGlobals(); + }); +}); + +// --------------------------------------------------------------------------- +// Build integration: the Vite plugin +// --------------------------------------------------------------------------- + +const SPAWN_SOURCE = + 'export const spawn = () => new Worker(new URL("../workers/x.js", import.meta.url), { type: "module" });\n'; + +describe("workerSandboxVitePlugin", () => { + it("is wired into the Vite config as a post plugin", async () => { + const mod = await import("../vite.config.ts"); + const configFactory = mod.default; + const config = + typeof configFactory === "function" ? configFactory({ command: "serve", mode: "test" }) : configFactory; + const plugins = config.plugins.flat(Infinity).filter(Boolean); + const plugin = plugins.find((p) => p.name === "helphone:worker-sandbox"); + expect(plugin).toBeTruthy(); + // 'post' places it after vite:worker-import-meta-url and before import + // analysis, so the worker chunk URL survives while the constructor swaps. + expect(plugin.enforce).toBe("post"); + }, 30000); + + it("skips the rewrite while tests run and applies it otherwise", async () => { + const { workerSandboxVitePlugin } = await import("../plugins/vite-plugin-worker-sandbox.js"); + const plugin = workerSandboxVitePlugin(); + plugin.configResolved({ root: "/repo" }); + + expect(plugin.transform(SPAWN_SOURCE, "/repo/src/lib/example.js")).toBeNull(); + + const saved = process.env.VITEST; + delete process.env.VITEST; + try { + const out = plugin.transform(SPAWN_SOURCE, "/repo/src/lib/example.js"); + expect(out.code).toContain("createSandboxedWorker(new URL("); + expect(out.code).toContain('new URL("../workers/x.js", import.meta.url)'); + expect(plugin.transform(SPAWN_SOURCE, "/repo/test/example.js")).toBeNull(); + expect(plugin.transform(SPAWN_SOURCE, "/repo/src/workers/example.js")).toBeNull(); + expect(plugin.transform(SPAWN_SOURCE, "/repo/node_modules/lib/example.js")).toBeNull(); + expect(plugin.transform("const x = 1;\n", "/repo/src/lib/example.js")).toBeNull(); + expect(plugin.transform(SPAWN_SOURCE, "/repo/src/lib/example.css")).toBeNull(); + } finally { + process.env.VITEST = saved; + } + }); + + it("answers Origin: null fetches with ACAO, but never the API", async () => { + const { workerSandboxVitePlugin } = await import("../plugins/vite-plugin-worker-sandbox.js"); + const plugin = workerSandboxVitePlugin(); + let middleware; + plugin.configureServer({ middlewares: { use: (fn) => (middleware = fn) } }); + + const run = (req) => { + const headers = {}; + middleware(req, { setHeader: (k, v) => (headers[k] = v) }, () => {}); + return headers; + }; + + expect(run({ headers: { origin: "null" }, method: "GET", url: "/assets/zk-worker.js" })).toEqual({ + "Access-Control-Allow-Origin": "null", + Vary: "Origin", + }); + expect(run({ headers: { origin: "null" }, method: "GET", url: "/api/health" })).toEqual({}); + expect(run({ headers: { origin: "https://evil.test" }, method: "GET", url: "/x.js" })).toEqual({}); + expect(run({ headers: {}, method: "GET", url: "/x.js" })).toEqual({}); + expect(run({ headers: { origin: "null" }, method: "POST", url: "/x.js" })).toEqual({}); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index b82559d4..2fac0853 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,7 @@ import { VitePWA } from "vite-plugin-pwa"; import { visualizer } from "rollup-plugin-visualizer"; import { envFirewallVitePlugin } from "./scripts/security/env_firewall.js"; import deadcodePruner from "./plugins/vite-plugin-deadcode-pruner.js"; +import { workerSandboxVitePlugin } from "./plugins/vite-plugin-worker-sandbox.js"; export default defineConfig(({ mode }) => ({ html: { @@ -24,6 +25,9 @@ export default defineConfig(({ mode }) => ({ // #626: fails the build if static output contains leaked secrets. envFirewallVitePlugin(), deadcodePruner(), + // #worker-sandbox: launch every Web Worker from a sandboxed (null-origin) + // blob URL; runs after vite:worker-import-meta-url (enforce: 'post'). + workerSandboxVitePlugin(), visualizer({ open: false, filename: 'dist/stats.html',