From 6009198151028e1d42d6f26a76545852d6553c2e Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Fri, 23 Oct 2026 17:43:32 +0800 Subject: [PATCH] [node-maintenance-repair/1] Receive a signed webhook and read the installed repository Add the preview GitHub App receiver for the first infrastructure slice. Verify webhook signatures over original bytes, enforce the 2 MB limit while streaming, and validate delivery and repository identities before a canonical metadata read. Request a Contents-read installation token for exactly the event repository. Keep this slice stateless. It performs no Check writes, workflow dispatch, dependency repair or repository writes. Add unit/native Workers tests, pinned development tooling, a dry-run CI check and operator preview instructions. Validation: 15 Worker tests with 100% line/branch/function coverage; native Workerd smoke; Wrangler dry-run bundle; 1,294 existing repository tests; Ruff check/format and Pyrefly. Live acceptance remains pending. Named GitHub App and Cloudflare targets need G-SETUP authorization, followed by real signed ping/PR deliveries and positive/negative repository-scope evidence. Local mocks and this PR do not establish hosted success. Do not start slice 2 before that proof. Related to #41, which remains parked until its work has been transferred to the smaller replacement PRs. --- .github/workflows/validate.yml | 21 + docs/node-maintenance-operator.md | 38 + repair-app/.gitignore | 5 + repair-app/package-lock.json | 1591 +++++++++++++++++++++++++++++ repair-app/package.json | 14 + repair-app/runtime-smoke.mjs | 75 ++ repair-app/worker.mjs | 162 +++ repair-app/worker.test.mjs | 262 +++++ repair-app/wrangler.jsonc | 12 + 9 files changed, 2180 insertions(+) create mode 100644 docs/node-maintenance-operator.md create mode 100644 repair-app/.gitignore create mode 100644 repair-app/package-lock.json create mode 100644 repair-app/package.json create mode 100644 repair-app/runtime-smoke.mjs create mode 100644 repair-app/worker.mjs create mode 100644 repair-app/worker.test.mjs create mode 100644 repair-app/wrangler.jsonc diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index aedc078..b2cbb1d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -7,6 +7,27 @@ name: Validate shared GitHub Actions permissions: contents: read jobs: + repair-app: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + WRANGLER_SEND_METRICS: 'false' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: '24' + check-latest: true + - run: npm ci --ignore-scripts --no-fund + working-directory: repair-app + - run: npm test + working-directory: repair-app + - run: npm run test:runtime + working-directory: repair-app + - run: npm run check + working-directory: repair-app test: runs-on: ubuntu-latest steps: diff --git a/docs/node-maintenance-operator.md b/docs/node-maintenance-operator.md new file mode 100644 index 0000000..05597d9 --- /dev/null +++ b/docs/node-maintenance-operator.md @@ -0,0 +1,38 @@ +# Maintenance service: checks and recovery + +This reference is for ongoing maintenance of the shared GitHub App and Cloudflare +service. Repository maintainers install the shared App; they do not host it. + +The receiver verifies signed webhook deliveries and reads canonical repository +metadata with a Contents-read token restricted to one repository. Check actions, +dispatch, storage and dependency repairs belong to later slices. + +## Check the receiver locally + +Use Node 24 and run these commands from `repair-app/`: + +```sh +npm ci --ignore-scripts --no-fund +npm test +npm run test:runtime +npm run check +``` + +The unit suite reports coverage for the receiver. The runtime test executes it +in local Cloudflare Workers with synthetic credentials and intercepted GitHub +requests. The final command bundles the preview with `--dry-run`; it does not +deploy. The CI job runs the same checks without App or Cloudflare credentials. + +## Stop deliveries or recover + +Disable the App webhook or uninstall the affected installation to stop new +deliveries. Restore a previous approved Worker version when one exists. This +receiver slice has no request database, dispatched runs or repository writes. + +Webhook processing shares an eight-second budget across input and GitHub reads. +Timed-out requests fail without an automatic retry. After recovery, redeliver +failed deliveries through the GitHub App's webhook delivery controls. + +Keep App credentials in operator storage and Worker secrets. Keep verification +receipts outside the repository, without credentials or webhook payloads. A +local test or dry-run bundle does not replace live delivery evidence. diff --git a/repair-app/.gitignore b/repair-app/.gitignore new file mode 100644 index 0000000..9af7e80 --- /dev/null +++ b/repair-app/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.wrangler/ +.dev.vars* +.env* +npm-debug.log* diff --git a/repair-app/package-lock.json b/repair-app/package-lock.json new file mode 100644 index 0000000..0f395d4 --- /dev/null +++ b/repair-app/package-lock.json @@ -0,0 +1,1591 @@ +{ + "name": "node-dependency-repair-app", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node-dependency-repair-app", + "devDependencies": { + "miniflare": "5.20261019.1-beta", + "wrangler": "4.151.3" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20261019.1.tgz", + "integrity": "sha512-5ZILbAta/fBQvhmAYeCE7dKMMYIr7N9wJEX50fEGLKSIwGtvT0HzSfHNIK/O7cBQ4gHQ2WfIiPClvwcye6E0fA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20261019.1.tgz", + "integrity": "sha512-cww2Ab6lVMxpTiEEjKmPI58p5EMo0vYTZSH+3WkqXJdQgc3ES66GuL247+U2B2YWajhhu2tqj7jySrXneU749g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20261019.1.tgz", + "integrity": "sha512-4++VRi9vkQROYqrpZYOixIJuVDmujkwrT4kszsxNFlvcmsX6Mr2GumvxIN3gIixS3vJUh3Umdtkthqj5X0S3Xw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20261019.1.tgz", + "integrity": "sha512-75PVtJOwfjScQ53sbLtQXI7D8yP1c21t+WEUPaYbqkzaz4ZqfR5wAd+zuWs8G5Q7kOj2SQIvEfY5Sn8Y+UVrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20261019.1.tgz", + "integrity": "sha512-z49fvKTETMCnG6wnMcQwxx9CpryFceiKz04FRjq26EkhCyzRNJ6c3UXhyZz3r5f1CXnnBZc5DcU8ZouPkUJCqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "5.20261019.1-beta", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20261019.1-beta.tgz", + "integrity": "sha512-1n7MN2UqZnPniyYAqNuV1YEAaja66ozKGVCc5ZTmYQhTubnoAwGLccwb+ZWYLOajuw3pOaJ70u//bZMuURlRew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.5", + "undici": "7.29.1", + "workerd": "1.20261019.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20261019.1.tgz", + "integrity": "sha512-UUYRUq/7DmeCl11YriNbcVbjUZVLtt8uuCXFu+nz77hGFf8d4DEVXoCYtnA+MlBatE4+wxoj47DVqzK3xGBAiw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20261019.1", + "@cloudflare/workerd-darwin-arm64": "1.20261019.1", + "@cloudflare/workerd-linux-64": "1.20261019.1", + "@cloudflare/workerd-linux-arm64": "1.20261019.1", + "@cloudflare/workerd-windows-64": "1.20261019.1" + } + }, + "node_modules/wrangler": { + "version": "4.151.3", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.151.3.tgz", + "integrity": "sha512-mzPQLmkAhZUV7CdiAhE+hiIAJlS9xPL6vZe1B8oQFuWxFu7nmQnvHAVjcAcabwSGkXFXH9uspzXwHOFrfbQmqQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.2", + "miniflare": "5.20261019.1-beta", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20261019.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20261019.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/repair-app/package.json b/repair-app/package.json new file mode 100644 index 0000000..3b6badd --- /dev/null +++ b/repair-app/package.json @@ -0,0 +1,14 @@ +{ + "name": "node-dependency-repair-app", + "private": true, + "type": "module", + "scripts": { + "test": "node --test --test-isolation=none --experimental-test-coverage worker.test.mjs", + "test:runtime": "node runtime-smoke.mjs", + "check": "wrangler deploy --env preview --dry-run --outdir .wrangler/dry-run" + }, + "devDependencies": { + "miniflare": "5.20261019.1-beta", + "wrangler": "4.151.3" + } +} diff --git a/repair-app/runtime-smoke.mjs b/repair-app/runtime-smoke.mjs new file mode 100644 index 0000000..dd6b9e4 --- /dev/null +++ b/repair-app/runtime-smoke.mjs @@ -0,0 +1,75 @@ +// Exercise the receiver in Cloudflare's local runtime. All GitHub calls are intercepted. +import assert from 'node:assert/strict'; +import {createHmac, generateKeyPairSync, verify} from 'node:crypto'; +import {Miniflare, convertV4MiniflareOptions} from 'miniflare'; + +const keys = generateKeyPairSync('rsa', {modulusLength: 2048, + privateKeyEncoding: {type: 'pkcs8', format: 'pem'}}); +const secret = 'native-runtime-fixture-secret'; +const calls = []; +let slowGithub = false; +const service = new Miniflare(convertV4MiniflareOptions({ + modules: true, + scriptPath: new URL('./worker.mjs', import.meta.url).pathname, + compatibilityDate: '2026-10-19', + bindings: {APP_ID: '42', APP_PRIVATE_KEY: keys.privateKey, WEBHOOK_SECRET: secret}, + outboundService: async request => { + const url = new URL(request.url); + assert.equal(url.origin, 'https://api.github.com'); + calls.push([request.method, url.pathname]); + if (slowGithub) await new Promise(resolve => setTimeout(resolve, 5_000)); + if (url.pathname === '/app/installations/3/access_tokens') { + assert.equal(request.method, 'POST'); + assert.deepEqual(await request.json(), {repository_ids: [7], permissions: {contents: 'read'}}); + const [header, claims, sig] = request.headers.get('authorization').slice('Bearer '.length).split('.'); + assert.equal(JSON.parse(Buffer.from(header, 'base64url')).alg, 'RS256'); + assert.equal(JSON.parse(Buffer.from(claims, 'base64url')).iss, '42'); + assert.equal(verify('RSA-SHA256', Buffer.from(`${header}.${claims}`), keys.publicKey, Buffer.from(sig, 'base64url')), true); + return Response.json({token: 'ghs_42.native-fixture-token'}); + } + assert.equal(url.pathname, '/repos/fixture/repo'); + assert.equal(request.method, 'GET'); + assert.equal(request.headers.get('authorization'), 'Bearer ghs_42.native-fixture-token'); + return Response.json({id: 7, full_name: 'fixture/repo'}); + }, +})); + +function signed(value, event = 'ping') { + const body = JSON.stringify(value); + return {method: 'POST', body, headers: { + 'x-github-event': event, + 'x-github-delivery': '94e535a5-2a7d-491d-858a-3df51ce211df', + 'x-hub-signature-256': 'sha256=' + createHmac('sha256', secret).update(body).digest('hex'), + }}; +} + +try { + assert.equal((await service.dispatchFetch('https://worker.test/webhook', signed({zen: 'native 🌍'}))).status, 200); + const invalid = signed({zen: 'tampered'}); + invalid.headers['x-hub-signature-256'] = 'sha256=' + '0'.repeat(64); + assert.equal((await service.dispatchFetch('https://worker.test/webhook', invalid)).status, 401); + const body = new ReadableStream({start(controller) { + for (let i = 0; i < 3; i++) controller.enqueue(new Uint8Array(800_000)); + controller.close(); + }}); + assert.equal((await service.dispatchFetch('https://worker.test/webhook', { + method: 'POST', body, duplex: 'half', headers: {'x-hub-signature-256': 'sha256=' + '0'.repeat(64)}, + })).status, 413); + assert.equal(calls.length, 0); + const pr = {action: 'opened', installation: {id: 3}, repository: {id: 7, full_name: 'fixture/repo'}}; + assert.equal((await service.dispatchFetch('https://worker.test/webhook', signed(pr, 'pull_request'))).status, 200); + assert.deepEqual(calls, [['POST', '/app/installations/3/access_tokens'], ['GET', '/repos/fixture/repo']]); + assert.equal((await service.dispatchFetch('https://worker.test/webhook', signed({}, 'check_run'))).status, 200); + assert.equal(calls.length, 2); + slowGithub = true; + const started = performance.now(); + const timedOut = await service.dispatchFetch('https://worker.test/webhook', signed(pr, 'pull_request')); + const elapsed = performance.now() - started; + assert.equal(timedOut.status, 500); + assert.equal(await timedOut.text(), 'Webhook processing failed'); + assert.ok(elapsed >= 7_500 && elapsed < 9_500, `Shared deadline took ${elapsed} ms`); + assert.deepEqual(calls.slice(2), [['POST', '/app/installations/3/access_tokens'], ['GET', '/repos/fixture/repo']]); + console.log('Native Workers runtime passed: signed ping, invalid signature, streamed limit, RSA JWT, scoped read, shared 8-second deadline and no Check/dispatch writes.'); +} finally { + await service.dispose(); +} diff --git a/repair-app/worker.mjs b/repair-app/worker.mjs new file mode 100644 index 0000000..935d847 --- /dev/null +++ b/repair-app/worker.mjs @@ -0,0 +1,162 @@ +const encoder = new TextEncoder(); +const MAX_BODY_BYTES = 2_000_000; +const DELIVERY_ID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/; +const PR_ACTIONS = ['opened', 'edited', 'synchronize', 'reopened']; + +async function readBody(request, signal) { + signal.throwIfAborted(); + if (!request.body) return new Uint8Array(); + const reader = request.body.getReader(); + const cancel = () => { void reader.cancel().catch(() => {}); }; + signal.addEventListener('abort', cancel, {once: true}); + const chunks = []; + let length = 0; + try { + for (;;) { + const {done, value} = await reader.read(); + if (done) break; + length += value.byteLength; + if (length > MAX_BODY_BYTES) { + cancel(); + return null; + } + chunks.push(value); + } + } finally { + signal.removeEventListener('abort', cancel); + reader.releaseLock(); + } + const body = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +export async function verifySignature(secret, body, signature) { + if (!/^sha256=[a-f0-9]{64}$/.test(signature || '')) return false; + const key = await crypto.subtle.importKey('raw', encoder.encode(secret), + {name: 'HMAC', hash: 'SHA-256'}, false, ['verify']); + const bytes = Uint8Array.from(signature.slice(7).match(/../g), part => parseInt(part, 16)); + return crypto.subtle.verify('HMAC', key, bytes, body); +} + +function base64url(value) { + const bytes = typeof value === 'string' ? encoder.encode(value) : new Uint8Array(value); + return btoa(String.fromCharCode(...bytes)).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); +} + +function der(tag, bytes) { + let size = bytes.length; + const length = []; + do { length.unshift(size & 255); size = Math.floor(size / 256); } while (size); + const header = bytes.length < 128 ? [tag, bytes.length] : [tag, 128 | length.length, ...length]; + const result = new Uint8Array(header.length + bytes.length); + result.set(header); + result.set(bytes, header.length); + return result; +} + +function appKey(pem) { + const match = pem.trim().match(/^-----BEGIN (RSA PRIVATE KEY|PRIVATE KEY)-----\s+([A-Za-z0-9+/=\s]+)-----END \1-----$/); + if (!match) throw new Error('Invalid App key'); + let bytes = Uint8Array.from(atob(match[2].replace(/\s/g, '')), c => c.charCodeAt(0)); + if (match[1] === 'RSA PRIVATE KEY') { + // PKCS#8 PrivateKeyInfo: version 0, rsaEncryption + NULL, PKCS#1 octet string. + const prefix = [2, 1, 0, 48, 13, 6, 9, 42, 134, 72, 134, 247, 13, 1, 1, 1, 5, 0]; + const octets = der(4, bytes); + const contents = new Uint8Array(prefix.length + octets.length); + contents.set(prefix); + contents.set(octets, prefix.length); + bytes = der(48, contents); + } + return bytes; +} + +async function appJWT(env) { + const now = Math.floor(Date.now() / 1000); + const header = base64url(JSON.stringify({alg: 'RS256', typ: 'JWT'})); + const claims = base64url(JSON.stringify({iat: now - 60, exp: now + 540, iss: String(env.APP_ID)})); + const key = await crypto.subtle.importKey('pkcs8', appKey(env.APP_PRIVATE_KEY), + {name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256'}, false, ['sign']); + const input = `${header}.${claims}`; + return `${input}.${base64url(await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, encoder.encode(input)))}`; +} + +export async function api(token, path, method = 'GET', body, signal = AbortSignal.timeout(15_000)) { + let response; + try { + signal.throwIfAborted(); + response = await fetch(`https://api.github.com${path}`, { + method, redirect: 'manual', signal, + headers: {Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', + 'User-Agent': 'node-maintenance', 'X-GitHub-Api-Version': '2026-03-10', + 'Content-Type': 'application/json'}, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch { + throw new Error('GitHub request failed'); + } + if (!response.ok) { + const error = new Error(`GitHub request failed (${response.status})`); + error.status = response.status; + throw error; + } + try { return await response.json(); } catch { throw new Error('Invalid GitHub response'); } +} + +export async function installationToken(env, installationID, repositoryID, signal) { + if (![installationID, repositoryID].every(id => Number.isSafeInteger(id) && id > 0)) { + throw new Error('Invalid installation or repository ID'); + } + const result = await api(await appJWT(env), `/app/installations/${installationID}/access_tokens`, 'POST', { + repository_ids: [repositoryID], permissions: {contents: 'read'}, + }, signal); + if (typeof result?.token !== 'string' || !result.token.trim()) throw new Error('Invalid installation token response'); + return result.token; +} + +async function readRepository(payload, env, signal) { + const repository = payload.repository; + if (typeof repository?.full_name !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository.full_name)) { + throw new Error('Invalid repository name'); + } + const token = await installationToken(env, payload.installation?.id, repository.id, signal); + const current = await api(token, `/repos/${repository.full_name}`, 'GET', undefined, signal); + if (current?.id !== repository.id || current.full_name?.toLowerCase() !== repository.full_name.toLowerCase()) { + throw new Error('Repository identity changed'); + } +} + +export default { + async fetch(request, env) { + if (request.method !== 'POST' || new URL(request.url).pathname !== '/webhook') return new Response('Not found', {status: 404}); + if (!['WEBHOOK_SECRET', 'APP_PRIVATE_KEY'].every(name => typeof env[name] === 'string' && env[name]) || + !/^[1-9][0-9]*$/.test(env.APP_ID || '')) return new Response('Not configured', {status: 503}); + if (Number(request.headers.get('content-length')) > MAX_BODY_BYTES) return new Response('Too large', {status: 413}); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 8_000); + try { + const body = await readBody(request, controller.signal); + controller.signal.throwIfAborted(); + if (body === null) return new Response('Too large', {status: 413}); + if (!await verifySignature(env.WEBHOOK_SECRET, body, request.headers.get('x-hub-signature-256'))) return new Response('Invalid signature', {status: 401}); + if (!DELIVERY_ID.test(request.headers.get('x-github-delivery') || '')) throw new Error('Invalid delivery ID'); + const payload = JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(body)); + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('Invalid payload'); + // Slice 1 authenticates and reads metadata only. Later slices own Checks and dispatch. + if (request.headers.get('x-github-event') === 'pull_request' && PR_ACTIONS.includes(payload.action)) { + await readRepository(payload, env, controller.signal); + } + controller.signal.throwIfAborted(); + return new Response('Accepted', {status: 200}); + } catch { + // Keep payloads, credentials and upstream response bodies out of responses and logs. + return new Response('Webhook processing failed', {status: 500}); + } finally { + clearTimeout(timer); + } + }, +}; diff --git a/repair-app/worker.test.mjs b/repair-app/worker.test.mjs new file mode 100644 index 0000000..671745b --- /dev/null +++ b/repair-app/worker.test.mjs @@ -0,0 +1,262 @@ +import assert from 'node:assert/strict'; +import {createHmac, createPrivateKey, generateKeyPairSync, verify} from 'node:crypto'; +import {beforeEach, test} from 'node:test'; +import worker, {api, installationToken, verifySignature} from './worker.mjs'; + +const keys = generateKeyPairSync('rsa', {modulusLength: 2048, + privateKeyEncoding: {type: 'pkcs8', format: 'pem'}}); +const env = {APP_ID: '42', APP_PRIVATE_KEY: keys.privateKey, WEBHOOK_SECRET: 'unit-fixture-secret'}; +const delivery = '94e535a5-2a7d-491d-858a-3df51ce211df'; +const payload = {action: 'opened', installation: {id: 3}, repository: {id: 7, full_name: 'fixture/repo'}}; +const token = 'ghs_42.opaque.installation-token'; +const json = value => new Response(JSON.stringify(value), {headers: {'Content-Type': 'application/json'}}); +const signature = bytes => 'sha256=' + createHmac('sha256', env.WEBHOOK_SECRET).update(bytes).digest('hex'); + +function signed(value, event = 'ping', headers = {}) { + const body = typeof value === 'string' || Buffer.isBuffer(value) ? value : JSON.stringify(value); + return new Request('https://worker.test/webhook', {method: 'POST', body, headers: { + 'x-github-event': event, 'x-github-delivery': delivery, 'x-hub-signature-256': signature(body), ...headers, + }}); +} + +beforeEach(t => { + t.mock.method(globalThis, 'fetch', async () => { throw new Error('Unexpected network request'); }); +}); + +function githubMock(repository = {id: 7, full_name: 'fixture/repo'}, tokenReply = {token}) { + const calls = []; + globalThis.fetch.mock.mockImplementation(async (url, options) => { + const path = new URL(url).pathname; + assert.equal(new URL(url).origin, 'https://api.github.com'); + assert.equal(options.redirect, 'manual'); + assert.ok(options.signal instanceof AbortSignal); + assert.equal(options.headers['X-GitHub-Api-Version'], '2026-03-10'); + calls.push({path, options}); + if (path === '/app/installations/3/access_tokens') return json(tokenReply); + if (path === '/repos/fixture/repo') return json(repository); + throw new Error('Unexpected API path'); + }); + return calls; +} + +test('GitHub documented HMAC vector and malformed signatures', async () => { + // https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries + const body = new TextEncoder().encode('Hello, World!'); + assert.equal(await verifySignature("It's a Secret to Everybody", body, + 'sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17'), true); + for (const header of [null, '', 'sha1=123', 'sha256=00', 'sha256=' + 'g'.repeat(64)]) { + assert.equal(await verifySignature(env.WEBHOOK_SECRET, body, header), false); + } +}); + +test('signed Unicode ping uses original bytes; tampering and missing signatures reject', async () => { + const body = '{ "zen": "hello 🌍" }\r\n'; + assert.equal((await worker.fetch(signed(body), env)).status, 200); + const tampered = signed('{"zen":"hello 🌍"}', 'ping', {'x-hub-signature-256': signature(body)}); + assert.equal((await worker.fetch(tampered, env)).status, 401); + assert.equal((await worker.fetch(signed({}, 'ping', {'x-hub-signature-256': ''}), env)).status, 401); + assert.equal(fetch.mock.callCount(), 0); +}); + +test('only the configured POST webhook route is served', async () => { + for (const [method, path] of [['GET', '/webhook'], ['POST', '/other']]) { + assert.equal((await worker.fetch(new Request(`https://worker.test${path}`, {method}), {})).status, 404); + } + for (const override of [{WEBHOOK_SECRET: ''}, {APP_PRIVATE_KEY: 1}, {APP_ID: '0'}, {APP_ID: ''}]) { + assert.equal((await worker.fetch(signed({}), {...env, ...override})).status, 503); + } + assert.equal(fetch.mock.callCount(), 0); +}); + +test('signed ignored events and unhandled PR actions perform no API calls', async () => { + for (const event of ['ping', 'issues', 'check_run', 'workflow_run']) { + assert.equal((await worker.fetch(signed({}, event), env)).status, 200); + } + assert.equal((await worker.fetch(signed({...payload, action: 'closed'}, 'pull_request'), env)).status, 200); + assert.equal(fetch.mock.callCount(), 0); +}); + +test('invalid delivery IDs, JSON and UTF-8 fail without exposing payload data', async () => { + const requests = [signed({}, 'ping', {'x-github-delivery': ''}), signed({}, 'ping', {'x-github-delivery': 'invalid'}), + signed('private-payload'), signed('null'), signed('[]'), signed('42'), signed(Buffer.from([0xff]))]; + requests.push(new Request('https://worker.test/webhook', {method: 'POST', headers: { + 'x-github-event': 'ping', 'x-github-delivery': delivery, 'x-hub-signature-256': signature(''), + }})); + for (const request of requests) { + const response = await worker.fetch(request, env); + assert.equal(response.status, 500); + assert.equal(await response.text(), 'Webhook processing failed'); + } + assert.equal(fetch.mock.callCount(), 0); +}); + +test('body limit accepts exactly two million bytes and rejects an advertised excess before reading', async () => { + const body = JSON.stringify({zen: 'x'.repeat(2_000_000 - 10)}); + assert.equal(Buffer.byteLength(body), 2_000_000); + assert.equal((await worker.fetch(signed(body), env)).status, 200); + const excessive = signed({}, 'ping', {'content-length': '2000001'}); + assert.equal((await worker.fetch(excessive, env)).status, 413); + assert.equal(excessive.bodyUsed, false); + assert.equal(fetch.mock.callCount(), 0); +}); + +test('oversized streams reject even when cancellation fails or never settles', {timeout: 2_000}, async () => { + for (const cancellation of ['ok', 'reject', 'pending']) { + let pulled = 0, cancelled = false; + const body = new ReadableStream({ + pull(controller) { + pulled++; + controller.enqueue(new Uint8Array(700_000)); + if (pulled === 20) controller.close(); + }, + cancel() { + cancelled = true; + if (cancellation === 'reject') throw new Error('fixture cancellation failure'); + if (cancellation === 'pending') return new Promise(() => {}); + }, + }); + const request = new Request('https://worker.test/webhook', {method: 'POST', body, duplex: 'half', + headers: {'content-length': '1', 'x-hub-signature-256': 'sha256=' + '0'.repeat(64)}}); + assert.equal((await worker.fetch(request, env)).status, 413); + assert.equal(cancelled, true); + assert.ok(pulled < 20); + } + assert.equal(fetch.mock.callCount(), 0); +}); + +test('a failing input stream returns a sanitized processing failure', async () => { + const body = new ReadableStream({start(controller) { controller.error(new Error('private-stream-data')); }}); + const response = await worker.fetch(new Request('https://worker.test/webhook', {method: 'POST', body, duplex: 'half'}), env); + assert.equal(response.status, 500); + assert.equal(await response.text(), 'Webhook processing failed'); +}); + +function shortenWebhookDeadline(t) { + const original = globalThis.setTimeout; + const budgets = []; + t.mock.method(globalThis, 'setTimeout', (callback, delay, ...args) => { + if (delay === 8_000) budgets.push(delay); + return original(callback, delay === 8_000 ? 160 : delay, ...args); + }); + return budgets; +} + +test('the webhook deadline cancels stalled input without waiting for cancellation', {timeout: 2_000}, async t => { + const budgets = shortenWebhookDeadline(t); + let cancelled = false; + const body = new ReadableStream({cancel() { cancelled = true; return new Promise(() => {}); }}); + const response = await worker.fetch(new Request('https://worker.test/webhook', { + method: 'POST', body, duplex: 'half', + }), env); + assert.equal(response.status, 500); + assert.equal(await response.text(), 'Webhook processing failed'); + assert.equal(cancelled, true); + assert.deepEqual(budgets, [8_000]); + assert.equal(fetch.mock.callCount(), 0); +}); + +for (const scenario of ['stalled token', 'slow sequential reads']) { + test(`one webhook deadline bounds ${scenario}`, {timeout: 2_000}, async t => { + const budgets = shortenWebhookDeadline(t); + const signals = []; + fetch.mock.mockImplementation(async (url, options) => { + signals.push(options.signal); + await new Promise((resolve, reject) => { + const timer = scenario === 'stalled token' ? null : setTimeout(resolve, 90); + const abort = () => { clearTimeout(timer); reject(new Error('private upstream timeout')); }; + if (options.signal.aborted) abort(); + else options.signal.addEventListener('abort', abort, {once: true}); + }); + return json(new URL(url).pathname.endsWith('/access_tokens') ? {token} : {id: 7, full_name: 'fixture/repo'}); + }); + const response = await worker.fetch(signed(payload, 'pull_request'), env); + assert.equal(response.status, 500); + assert.equal(await response.text(), 'Webhook processing failed'); + assert.equal(signals.length, scenario === 'stalled token' ? 1 : 2); + assert.ok(signals.every(signal => signal === signals[0] && signal.aborted)); + assert.deepEqual(budgets, [8_000]); + }); +} + +for (const type of ['pkcs8', 'pkcs1']) test(`PR deliveries sign with ${type} and use one repository-scoped read-only token`, async () => { + const calls = githubMock({id: 7, full_name: 'Fixture/Repo'}); + const pem = createPrivateKey(keys.privateKey).export({type, format: 'pem'}).replaceAll('\n', '\r\n'); + assert.equal((await worker.fetch(signed(payload, 'pull_request'), {...env, APP_PRIVATE_KEY: pem})).status, 200); + assert.equal(calls.length, 2); + assert.equal(calls[0].options.method, 'POST'); + assert.deepEqual(JSON.parse(calls[0].options.body), {repository_ids: [7], permissions: {contents: 'read'}}); + const jwt = calls[0].options.headers.Authorization.slice('Bearer '.length); + const [header, claims, sig] = jwt.split('.'); + assert.deepEqual(JSON.parse(Buffer.from(header, 'base64url')), {alg: 'RS256', typ: 'JWT'}); + const decoded = JSON.parse(Buffer.from(claims, 'base64url')); + assert.equal(decoded.iss, '42'); + assert.equal(decoded.exp - decoded.iat, 600); + const now = Math.floor(Date.now() / 1000); + assert.ok(decoded.iat <= now && decoded.iat >= now - 120); + assert.ok(decoded.exp > now && decoded.exp <= now + 600); + assert.equal(verify('RSA-SHA256', Buffer.from(`${header}.${claims}`), keys.publicKey, Buffer.from(sig, 'base64url')), true); + assert.equal(calls[1].path, '/repos/fixture/repo'); + assert.equal(calls[1].options.method, 'GET'); + assert.equal(calls[1].options.headers.Authorization, `Bearer ${token}`); + assert.equal(calls[1].options.body, undefined); +}); + +test('each subscribed PR action is a read-only handshake', async () => { + for (const action of ['edited', 'synchronize', 'reopened']) { + const calls = githubMock(); + assert.equal((await worker.fetch(signed({...payload, action}, 'pull_request'), env)).status, 200); + assert.deepEqual(calls.map(call => [call.options.method, call.path]), [ + ['POST', '/app/installations/3/access_tokens'], ['GET', '/repos/fixture/repo'], + ]); + } +}); + +test('invalid installation/repository IDs and names cannot reach the API', async () => { + for (const id of [0, -1, '3', null, Number.MAX_SAFE_INTEGER + 1]) { + await assert.rejects(installationToken(env, id, 7), /Invalid installation/); + await assert.rejects(installationToken(env, 3, id), /Invalid installation/); + } + const invalid = [{...payload, installation: {}}, {...payload, repository: {id: 7}}, + {...payload, repository: {id: 7, full_name: 'fixture/repo/../other'}}, {...payload, repository: null}]; + for (const value of invalid) assert.equal((await worker.fetch(signed(value, 'pull_request'), env)).status, 500); + assert.equal(fetch.mock.callCount(), 0); +}); + +test('missing, empty or invalid token responses never cause a repository request', async () => { + for (const result of [null, {}, {token: 42}, {token: ''}, {token: ' '}]) { + const calls = githubMock(undefined, result); + assert.equal((await worker.fetch(signed(payload, 'pull_request'), env)).status, 500); + assert.equal(calls.length, 1); + } +}); + +test('canonical repository mismatch and invalid private keys fail closed', async () => { + for (const repository of [null, {id: 8, full_name: 'fixture/repo'}, {id: 7, full_name: 'fixture/other'}]) { + const calls = githubMock(repository); + assert.equal((await worker.fetch(signed(payload, 'pull_request'), env)).status, 500); + assert.equal(calls.length, 2); + } + const calls = githubMock(); + assert.equal((await worker.fetch(signed(payload, 'pull_request'), {...env, APP_PRIVATE_KEY: 'invalid'})).status, 500); + assert.equal(calls.length, 0); +}); + +test('API redirects and error responses are not followed or disclosed', async () => { + for (const status of [302, 403, 500]) { + fetch.mock.mockImplementation(async (_url, options) => { + assert.equal(options.redirect, 'manual'); + return new Response('private-upstream-data', {status, headers: {Location: 'https://other.test'}}); + }); + await assert.rejects(api(token, '/repos/fixture/repo'), error => error.status === status && !error.message.includes('private-upstream-data')); + } +}); + +test('network and invalid upstream JSON errors stay sanitized in the client and receiver', async () => { + fetch.mock.mockImplementation(async () => { throw new Error('private-network-data'); }); + await assert.rejects(api(token, '/repos/fixture/repo'), /^Error: GitHub request failed$/); + const response = await worker.fetch(signed(payload, 'pull_request'), env); + assert.equal(response.status, 500); + assert.equal(await response.text(), 'Webhook processing failed'); + fetch.mock.mockImplementation(async () => new Response('private-invalid-json')); + await assert.rejects(api(token, '/repos/fixture/repo'), /^Error: Invalid GitHub response$/); +}); diff --git a/repair-app/wrangler.jsonc b/repair-app/wrangler.jsonc new file mode 100644 index 0000000..76199fe --- /dev/null +++ b/repair-app/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "replace-with-approved-worker-name", + "main": "worker.mjs", + "compatibility_date": "2026-10-19", + "env": { + "preview": { + "name": "replace-with-approved-preview-worker-name", + "workers_dev": true + } + } +}