From a783e56b774795c1f4bd8a6088dfcce96a307c5f Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Fri, 25 Sep 2026 17:11:19 +0200 Subject: ci: port the Cloudflare Workers docs pipeline to circinus (slug 1.5) (#2208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: port Cloudflare Workers docs pipeline files to circinus (verbatim) Copies the branch-agnostic half of the docs.vyos.io Cloudflare Workers pipeline from `rolling` at 8cb568bf, byte-identical: - .github/workflows/docs-build.yml - workers/ (entire tree) - scripts/docs_gates/ - docker/im-convert.sh - docs/_static/js/version-picker.js, js/pagefind-wrapper.js, css/version-picker.css (new files, no circinus counterpart) - docs/_templates/breadcrumbs.html, searchbox.html (new files) docs-build.yml already triggers on push to [rolling, circinus, sagitta] and resolves `circinus` -> worker vyos-docs-v15-en / slug 1.5 from workers/matrix.json; the files simply did not exist on this branch, so slug 1.5 still serves the bootstrap placeholder. workers/versions.json + workers/matrix.json are deliberately identical across all three branches and must be kept in sync. Advances: IS-572 * ci: wire circinus docs build into the Cloudflare Workers pipeline Hand-merges the CF-specific hunks onto circinus's own docker/Dockerfile and docs/conf.py rather than clobbering them with rolling's versions β€” circinus keeps its own content-driven history in both files. docker/Dockerfile: - imagemagick + librsvg2-bin (sphinx.ext.imgconverter backend) and poppler-utils (pdfinfo, used by docs-build.yml's PDF page-count completeness check), installed --no-install-recommends - install docker/im-convert.sh as /usr/local/bin/im-convert docs/conf.py: - enable sphinx.ext.imgconverter + image_converter = 'im-convert' so the LaTeX/PDF builder stops silently dropping .webp/.svg images - register js/version-picker.js + css/version-picker.css unconditionally (degrades silently on ReadTheDocs) - _vyos_cf_build gate off the raw DOCS_VERSION_SLUG env var; only CF builds load js/pagefind-wrapper.js, and html_context['vyos_cf_build'] lets _templates/searchbox.html fall back to the stock Sphinx searchbox via the "!" bang-include on RTD The RTD path stays the default in both files: circinus continues building on ReadTheDocs until RTD sunset, and every CF feature activates only when DOCS_VERSION_SLUG is present. .readthedocs.yml is untouched. circinus keeps its own version/release/html_title/source_suffix and its hardcoded html_baseurl (its CF slug is also `1.5`, so rolling's DOCS_VERSION_SLUG/READTHEDOCS_VERSION resolution block is a no-op here and was deliberately not ported). Advances: IS-572 * ci: IS-572: re-sync ported Cloudflare Workers pipeline files with rolling The category-1 files in this port are byte-identical copies from `rolling`. `rolling` has since moved: [vyos-documentation#2209](https://github.com/vyos/vyos-documentation/pull/2209) merged as `3a1c6c30`, thirteen rounds of hardening on exactly these files. Re-take all 14 category-1 paths from `origin/rolling` via `git checkout origin/rolling -- `, so byte-identity holds by construction rather than by hand-editing: .github/workflows/docs-build.yml scripts/docs_gates/{gates,parity,smoke,test_gates,test_parity,test_smoke}.py workers/.gitignore workers/apex/src/{index,special,uagate}.ts workers/apex/test/{router,uagate}.test.ts workers/apex/ua-policy.json Thirteen of the fourteen carry [vyos-documentation#2209](https://github.com/vyos/vyos-documentation/pull/2209) exactly β€” the pre-change tree was byte-identical to `3a1c6c30^` for those paths. `workers/.gitignore` additionally picks up the one-line `test-results/` entry from [vyos-documentation#2212](https://github.com/vyos/vyos-documentation/pull/2212); inert on circinus, since only the deliberately-unported `apex-deploy.yml` writes that directory. Deliberate exclusions are unchanged: `docs-canary-qa.yml` (cron runs on the default branch only, so it is not ported even though [vyos-documentation#2209](https://github.com/vyos/vyos-documentation/pull/2209) touched it on `rolling`), `apex-deploy.yml`, and the `docs-preview-*` workflows. `docs/conf.py` stays hand-merged and circinus-specific, with its ReadTheDocs fallback intact. πŸ€– Generated by [robots](https://vyos.io) --- .github/workflows/docs-build.yml | 813 ++++++++ docker/Dockerfile | 18 + docker/im-convert.sh | 68 + docs/_static/css/version-picker.css | 11 + docs/_static/js/pagefind-wrapper.js | 69 + docs/_static/js/version-picker.js | 193 ++ docs/_templates/breadcrumbs.html | 5 + docs/_templates/searchbox.html | 13 + docs/conf.py | 48 + scripts/docs_gates/__init__.py | 0 scripts/docs_gates/conftest.py | 55 + scripts/docs_gates/critical-pages.txt | 9 + scripts/docs_gates/gates.py | 96 + scripts/docs_gates/parity.py | 253 +++ scripts/docs_gates/smoke.py | 236 +++ scripts/docs_gates/test_gates.py | 122 ++ scripts/docs_gates/test_parity.py | 340 +++ scripts/docs_gates/test_smoke.py | 483 +++++ workers/.gitignore | 4 + workers/PLAN.md | 5 + workers/apex/assets/404.html | 23 + workers/apex/assets/503.html | 24 + workers/apex/assets/apple-touch-icon.png | Bin 0 -> 4128 bytes workers/apex/assets/favicon.ico | Bin 0 -> 766 bytes workers/apex/assets/robots.txt | 3 + workers/apex/assets/root.html | 30 + workers/apex/src/dispatch.ts | 15 + workers/apex/src/index.ts | 561 +++++ workers/apex/src/manifest.ts | 55 + workers/apex/src/redirects.ts | 38 + workers/apex/src/special.ts | 60 + workers/apex/src/uagate.ts | 48 + workers/apex/test/dispatch.test.ts | 29 + workers/apex/test/manifest.test.ts | 135 ++ workers/apex/test/redirects.test.ts | 54 + workers/apex/test/router.test.ts | 1067 ++++++++++ workers/apex/test/uagate.test.ts | 102 + workers/apex/ua-policy.json | 5 + workers/apex/wrangler.jsonc | 39 + workers/bootstrap.sh | 29 + workers/branch/src/index.ts | 83 + workers/branch/test/content.test.ts | 278 +++ workers/branch/wrangler.legacy.jsonc | 16 + workers/branch/wrangler.rolling.jsonc | 16 + workers/branch/wrangler.v14.jsonc | 16 + workers/branch/wrangler.v15.jsonc | 16 + workers/matrix.json | 5 + workers/package-lock.json | 2843 ++++++++++++++++++++++++++ workers/package.json | 16 + workers/picker-test/pagefind-wrapper.test.ts | 42 + workers/picker-test/picker.test.ts | 156 ++ workers/preview/src/index.ts | 61 + workers/preview/test/preview.test.ts | 93 + workers/preview/wrangler.jsonc | 11 + workers/versions.json | 19 + workers/vitest.config.ts | 14 + 56 files changed, 8843 insertions(+) create mode 100644 .github/workflows/docs-build.yml create mode 100644 docker/im-convert.sh create mode 100644 docs/_static/css/version-picker.css create mode 100644 docs/_static/js/pagefind-wrapper.js create mode 100644 docs/_static/js/version-picker.js create mode 100644 docs/_templates/breadcrumbs.html create mode 100644 docs/_templates/searchbox.html create mode 100644 scripts/docs_gates/__init__.py create mode 100644 scripts/docs_gates/conftest.py create mode 100644 scripts/docs_gates/critical-pages.txt create mode 100644 scripts/docs_gates/gates.py create mode 100644 scripts/docs_gates/parity.py create mode 100644 scripts/docs_gates/smoke.py create mode 100644 scripts/docs_gates/test_gates.py create mode 100644 scripts/docs_gates/test_parity.py create mode 100644 scripts/docs_gates/test_smoke.py create mode 100644 workers/.gitignore create mode 100644 workers/PLAN.md create mode 100644 workers/apex/assets/404.html create mode 100644 workers/apex/assets/503.html create mode 100644 workers/apex/assets/apple-touch-icon.png create mode 100644 workers/apex/assets/favicon.ico create mode 100644 workers/apex/assets/robots.txt create mode 100644 workers/apex/assets/root.html create mode 100644 workers/apex/src/dispatch.ts create mode 100644 workers/apex/src/index.ts create mode 100644 workers/apex/src/manifest.ts create mode 100644 workers/apex/src/redirects.ts create mode 100644 workers/apex/src/special.ts create mode 100644 workers/apex/src/uagate.ts create mode 100644 workers/apex/test/dispatch.test.ts create mode 100644 workers/apex/test/manifest.test.ts create mode 100644 workers/apex/test/redirects.test.ts create mode 100644 workers/apex/test/router.test.ts create mode 100644 workers/apex/test/uagate.test.ts create mode 100644 workers/apex/ua-policy.json create mode 100644 workers/apex/wrangler.jsonc create mode 100755 workers/bootstrap.sh create mode 100644 workers/branch/src/index.ts create mode 100644 workers/branch/test/content.test.ts create mode 100644 workers/branch/wrangler.legacy.jsonc create mode 100644 workers/branch/wrangler.rolling.jsonc create mode 100644 workers/branch/wrangler.v14.jsonc create mode 100644 workers/branch/wrangler.v15.jsonc create mode 100644 workers/matrix.json create mode 100644 workers/package-lock.json create mode 100644 workers/package.json create mode 100644 workers/picker-test/pagefind-wrapper.test.ts create mode 100644 workers/picker-test/picker.test.ts create mode 100644 workers/preview/src/index.ts create mode 100644 workers/preview/test/preview.test.ts create mode 100644 workers/preview/wrangler.jsonc create mode 100644 workers/versions.json create mode 100644 workers/vitest.config.ts diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml new file mode 100644 index 00000000..8855b8d9 --- /dev/null +++ b/.github/workflows/docs-build.yml @@ -0,0 +1,813 @@ +name: Docs build + deploy (Cloudflare Workers) + +on: + push: + branches: [rolling, circinus, sagitta] + workflow_dispatch: + inputs: + skip_pdf: + description: "SKIP_PDF: carry forward last-good PDF from registry (broken-LaTeX escape hatch)" + type: boolean + default: false + +concurrency: + group: docs-build-${{ github.ref_name }} + # false (not true): a cancel mid-promote would leave production unverified and the + # registry pointer stale. Queueing instead is safe because BOTH check_head guards + # (Β§7.1) skip a run whose SHA is no longer the branch tip β€” the first before the + # candidate deploy, the second after the smoke gate and immediately before promote. + # A single up-front check was NOT enough: it says nothing about the run that is + # already past it, and the candidate deploy plus the smoke gate (480s deadline) give + # a push minutes of room to land in between. + cancel-in-progress: false + +permissions: + contents: read + +env: + REGISTRY_BUCKET: vyos-docs-artifacts + +jobs: + build-deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Resolve matrix entry + id: matrix + run: | + set -eu + entry=$(jq -r --arg b "${{ github.ref_name }}" '.[$b] // empty' workers/matrix.json) + [ -n "$entry" ] || { echo "branch not in matrix"; exit 1; } + echo "worker=$(echo "$entry" | jq -r .worker)" >> "$GITHUB_OUTPUT" + echo "slug=$(echo "$entry" | jq -r .slug)" >> "$GITHUB_OUTPUT" + echo "pdf=$(jq -r --arg s "$(echo "$entry" | jq -r .slug)" \ + '.versions[] | select(.slug==$s) | .pdf // empty' workers/versions.json)" >> "$GITHUB_OUTPUT" + + # DOCS_CF_LIVE is the pre/post-cutover switch, and four later steps in this job + # branch on it: the SKIP_PDF carry-forward staleness check, the candidate-reset + # staleness check, the production hostname purge, and the post-promote probe. + # Every one of those comparisons is written against the literal string "true", so + # ANY other value β€” an unset variable, "True", "yes", a typo β€” silently takes the + # branch that skips the check, skips the purge and skips the probe, after which the + # registry pointer publishes an unpurged, never-verified deployment on a green run. + # Validate the value ONCE, here, at the top of the only job that reads it: the four + # comparisons downstream are then safe by construction rather than each re-deriving + # the same three-line guard. Placed before the docker build so a misconfigured + # variable costs seconds, not a full build. A future job that reads this variable + # needs its own gate β€” this one covers build-deploy only. + - name: Validate DOCS_CF_LIVE + env: + DOCS_CF_LIVE: ${{ vars.DOCS_CF_LIVE }} + run: | + set -eu + case "$DOCS_CF_LIVE" in + true|false) + echo "DOCS_CF_LIVE=$DOCS_CF_LIVE" + ;; + "") + echo "::error::repository variable DOCS_CF_LIVE is unset or empty β€” it must be set to exactly 'true' or 'false'" + exit 1 + ;; + *) + echo "::error::repository variable DOCS_CF_LIVE is '$DOCS_CF_LIVE' β€” it must be exactly 'true' or 'false' (lowercase, no quotes)" + exit 1 + ;; + esac + + # Build image in-workflow from docker/Dockerfile (plan v4.1: digest pin dropped β€” + # no published ghcr.io image exists; the checked-out commit IS the pin, and the + # buildx GHA cache makes repeat builds cheap). + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build docs image (buildx GHA cache) + uses: docker/build-push-action@v6 + with: + context: docker/ + load: true + tags: docs-build:local + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build HTML + PDF in in-workflow-built container + env: + # pdflatex hard-errors ("! LaTeX Error: Unicode character ... not + # set up for use with LaTeX") on the Devanagari etymology text in + # docs/introducing/history.md, and separately cannot embed some + # pre-existing .webp images (no BoundingBox). latexmk's force mode + # (-f) plus non-interactive pdflatex (-interaction=nonstopmode) + # makes it skip both classes of per-glyph/per-image failure and + # finish the document instead of halting β€” this is how ReadTheDocs + # has always built this project's PDF (verified against the live + # docs.vyos.io PDF: same Devanagari glyphs blanked, same ~4 images + # embedded out of 2000+ pages, rather than a failed build). + # latexmk still exits non-zero in force mode even when it produces + # a complete PDF, so success below is verified by checking the + # produced artifact directly (exact filename + page count), not the + # command's exit code. A bare existence/size check would let a + # truncated-but-large PDF (LaTeX aborting partway through) pass, so + # the completeness signal is pdfinfo's page count instead of file + # size. The Β§14 retry-once is gated on artifact ABSENCE: latexmk -f + # exits non-zero even on a fully-produced PDF, so an unconditional + # `|| make latexpdf` would double the ~10-min LaTeX step on every + # forced success. Each `|| true` only swallows that expected + # non-zero exit; the validation below is the real success signal. + LATEXMKOPTS: -f + LATEXOPTS: -interaction=nonstopmode + run: | + set -eu + docker run --rm -v "$PWD:/src" -w /src \ + -e DOCS_VERSION_SLUG="${{ steps.matrix.outputs.slug }}" \ + -e DOCS_VERSION_BRANCH="${{ github.ref_name }}" \ + -e LATEXMKOPTS \ + -e LATEXOPTS \ + docs-build:local bash -c ' + set -e + cd docs && make html + if [ "${{ inputs.skip_pdf }}" != "true" ]; then + pdf=_build/latex/VyOS.pdf + make latexpdf || true + # retry ONLY when no artifact was produced (Β§14 LaTeX flakiness) β€” + # latexmk -f exits non-zero even on success, so exit code cannot gate this + [ -f "$pdf" ] || make latexpdf || true + [ -f "$pdf" ] || { echo "$pdf not produced β€” build genuinely failed"; exit 1; } + # Page-count floor as a completeness signal (a truncated forced-mode + # run can still leave behind a file that exists and is large). rolling + # is verified ~2000+ pages; 1.4/1.5 page counts have not been + # individually verified, so 1000 is a conservative floor intended to + # clear all three release branches without masking a real truncation. + pages=$(pdfinfo "$pdf" 2>/dev/null | awk "/^Pages:/{print \$2}") + case "$pages" in + ""|*[!0-9]*) pages=0 ;; + esac + [ "$pages" -ge 1000 ] || { echo "$pdf has $pages pages (<1000) β€” build genuinely failed"; exit 1; } + fi' + + - name: Assemble artifact (nest under en//, Β§7.1) + run: | + set -eu + slug='${{ steps.matrix.outputs.slug }}' + mkdir -p "dist/assets/en/$slug" + cp -r docs/_build/html/. "dist/assets/en/$slug/" + npx --yes pagefind@1.5.2 --site "dist/assets/en/$slug" + + # Must precede the FIRST `npx wrangler` invocation in this job. Without + # workers/node_modules, `npx` silently downloads whatever wrangler the registry + # serves today, so the SKIP_PDF carry-forward and the previous-build metadata fetch + # below would run on an unpinned version while every later step β€” including the + # rollback path β€” runs the lockfile-pinned one. Unconditional on purpose: the + # metadata fetch below runs on every build, long before the check_head guard that + # used to gate this install. + - name: Install workers deps + run: cd workers && npm ci + + - name: PDF into artifact (build or registry carry-forward) + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_DOCS }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + DOCS_CF_LIVE: ${{ vars.DOCS_CF_LIVE }} + run: | + set -eu + slug='${{ steps.matrix.outputs.slug }}' + dest="dist/assets/en/$slug/vyos-documentation.pdf" + if [ "${{ inputs.skip_pdf }}" = "true" ]; then + # Β§5 + Β§7.1.4: SKIP_PDF refuses to run when registry is stale vs production. + # Registry layout is pointer-indirected (Β§7.1.4 atomicity): resolve + # $slug/latest.json β†’ sha, then read the sha-scoped generation. + cd workers && npx wrangler r2 object get "$REGISTRY_BUCKET/$slug/latest.json" --file /tmp/latest.json --remote && cd .. + reg_sha=$(jq -r .sha /tmp/latest.json) + if [ "$DOCS_CF_LIVE" = "true" ]; then + prod_sha=$(curl -sI "https://docs.vyos.io/en/$slug/" | tr -d '\r' | awk -F': ' 'tolower($1)=="x-docs-build"{print $2}') + [ "$reg_sha" = "$prod_sha" ] || { echo "registry stale ($reg_sha != $prod_sha) β€” SKIP_PDF refused"; exit 1; } + else + echo "DOCS_CF_LIVE=false β€” pre-cutover: trusting registry meta without production comparison" + fi + cd workers && npx wrangler r2 object get "$REGISTRY_BUCKET/$slug/$reg_sha/pdf" --file "../$dest" --remote && cd .. + else + cp docs/_build/latex/*.pdf "$dest" + fi + + - name: Fetch previous-build metadata (for count-delta gate) + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_DOCS }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -eo pipefail + set -u + slug='${{ steps.matrix.outputs.slug }}' + cd workers + # Missing-object (no pointer yet, or the pointed-at generation's meta.json is + # gone) is the legitimate bootstrap case β€” this slug has never shipped before, + # so proceed with no previous-meta.json (the count-delta gate below no-ops when + # the file is absent). Any OTHER failure (auth, network, transient API error) + # must FAIL the workflow β€” silently swallowing those would bypass the + # count-collapse gate instead of just skipping it on a genuinely first deploy. + fetch_optional() { + set +e + out=$(npx wrangler r2 object get "$1" --file "$2" --remote 2>&1) + rc=$? + set -e + echo "$out" + [ "$rc" -eq 0 ] && return 0 + if echo "$out" | grep -qiE 'does not exist|not found|no such key|404'; then + return 1 + fi + echo "::error::registry fetch of $1 failed for a reason other than 'missing object' β€” failing the build (see spec Β§7.1 count-delta gate)" + exit "$rc" + } + if fetch_optional "$REGISTRY_BUCKET/$slug/latest.json" /tmp/latest.json; then + reg_sha=$(jq -r .sha /tmp/latest.json) + if [ -z "$reg_sha" ] || [ "$reg_sha" = "null" ]; then + echo "::error::registry pointer latest.json is malformed (no .sha)" + exit 1 + fi + if ! fetch_optional "$REGISTRY_BUCKET/$slug/$reg_sha/meta.json" ../previous-meta.json; then + echo "::notice::registry pointer names $reg_sha but its meta.json is missing β€” treating as bootstrap" + fi + else + echo "::notice::no registry entry yet for $slug β€” treating as bootstrap (no previous-meta)" + fi + + - name: Sanity gates (deploy blockers, Β§7.1) + run: | + python -m scripts.docs_gates.gates \ + --artifact dist/assets --slug '${{ steps.matrix.outputs.slug }}' \ + --versions workers/versions.json \ + $( [ -f previous-meta.json ] && echo --previous-meta previous-meta.json ) + + # Exit 78 was the "neutral" status ONLY in the deprecated Actions v1 runtime. On the + # current runtime any non-zero exit fails the step and the job, so this guard's own + # "skipping deploy" message was a lie: every legitimate branch-moved race β€” the exact + # situation `cancel-in-progress: false` deliberately creates β€” surfaced as a red run. + # That erodes the guard's value as an alarm. Publish an output instead and gate the + # deploy steps on it, so a superseded run ends green-and-skipped. This is the FIRST + # of two checkpoints: it covers the candidate deploy and the smoke gate, which touch + # nothing production-facing. PROMOTE, the post-promote probe and the registry pointer + # publish gate on the post-smoke re-check further down, NOT on this output. + - name: check_head guard (Β§7.1) + id: check_head + env: + REF_NAME: ${{ github.ref_name }} + BUILT_SHA: ${{ github.sha }} + run: | + set -euo pipefail + remote=$(git ls-remote origin "refs/heads/$REF_NAME" | cut -f1) + # pipefail AND the emptiness check are both needed: `cut` exits 0 on empty input, so + # a failed ls-remote would otherwise leave $remote empty and be misread as "branch + # moved" β€” silently skipping a deploy that should have run. An unresolvable tip is + # an infrastructure failure, not a skip signal, so it fails the job loudly. + [ -n "$remote" ] || { echo "::error::could not resolve the remote tip of $REF_NAME"; exit 1; } + if [ "$remote" = "$BUILT_SHA" ]; then + echo "current=true" >> "$GITHUB_OUTPUT" + else + echo "current=false" >> "$GITHUB_OUTPUT" + echo "::notice::branch moved (tip is now $remote, this run built $BUILT_SHA) β€” skipping deploy, promote and registry steps" + fi + + - name: Deploy CANDIDATE + if: steps.check_head.outputs.current == 'true' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_DOCS }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + cd workers && npx wrangler deploy --config branch/wrangler.$( \ + case '${{ steps.matrix.outputs.slug }}' in rolling) echo rolling;; 1.5) echo v15;; 1.4) echo v14;; esac \ + ).jsonc \ + --name '${{ steps.matrix.outputs.worker }}-candidate' \ + --var DOCS_BUILD_SHA:'${{ github.sha }}' --var DOCS_ENV:canary + + - name: Pre-traffic smoke via canary apex (Β§7.1.2) + id: smoke + if: steps.check_head.outputs.current == 'true' + env: + # CF Access service token goes through the environment, NEVER through argv: a + # secret on the command line is readable from the process table for the life of + # the process and is captured verbatim by `set -x` traces and crash dumps. + # These environment variables are the ONLY supported way to pass the token: the + # --access-id / --access-secret flags were removed, and argparse now rejects them + # outright rather than ignoring them and leaving every probe to 403. + CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }} + CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }} + run: | + set -eu + # NOTE: never use the GHA "cond AND format(...)" expression trick for optional + # args here β€” an empty/false result renders the literal string "false" into the + # shell. (Any double-curly GHA expression written literally in this script gets + # template-expanded by GitHub Actions before the shell ever runs it, so such an + # example cannot even be spelled out in a comment here.) Plain shell instead: + pdf_arg="" + if [ -n "${{ steps.matrix.outputs.pdf }}" ]; then + pdf_arg="--pdf ${{ steps.matrix.outputs.pdf }}" + fi + python -m scripts.docs_gates.smoke \ + --host docs-next.vyos.io --slug '${{ steps.matrix.outputs.slug }}' \ + --expect-sha '${{ github.sha }}' \ + $pdf_arg + + - name: Candidate reset on smoke failure (Β§7.1.3) + if: failure() && steps.check_head.outputs.current == 'true' && steps.smoke.conclusion == 'failure' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_DOCS }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + DOCS_CF_LIVE: ${{ vars.DOCS_CF_LIVE }} + run: | + set -eu + slug='${{ steps.matrix.outputs.slug }}' + cd workers + # Registry layout is pointer-indirected (Β§7.1.4 atomicity): resolve + # $slug/latest.json β†’ sha, then read the sha-scoped generation. + npx wrangler r2 object get "$REGISTRY_BUCKET/$slug/latest.json" --file /tmp/latest.json --remote \ + || { echo "::warning::no registry entry yet (first deploys) β€” candidate reset skipped"; exit 0; } + reg_sha=$(jq -r .sha /tmp/latest.json) + if [ "$DOCS_CF_LIVE" = "true" ]; then + prod_sha=$(curl -sI "https://docs.vyos.io/en/$slug/" | tr -d '\r' | awk -F': ' 'tolower($1)=="x-docs-build"{print $2}') + [ "$reg_sha" = "$prod_sha" ] || { echo "::warning::registry stale β€” candidate reset SKIPPED (manual registry-repair needed)"; exit 0; } + fi + npx wrangler r2 object get "$REGISTRY_BUCKET/$slug/$reg_sha/tar.zst" --file /tmp/lastgood.tar.zst --remote + rm -rf ../dist/assets && mkdir -p ../dist/assets + tar --zstd -xf /tmp/lastgood.tar.zst -C ../dist/assets + npx wrangler deploy --config branch/wrangler.$(case "$slug" in rolling) echo rolling;; 1.5) echo v15;; 1.4) echo v14;; esac).jsonc \ + --name '${{ steps.matrix.outputs.worker }}-candidate' \ + --var DOCS_BUILD_SHA:"$reg_sha" --var DOCS_ENV:canary + + # TOCTOU re-check. The guard above evaluated the tip ONCE, before the candidate + # deploy and before the smoke gate (which alone budgets a 480s deadline), so a push + # landing in that window left this run promoting a superseded SHA to production and + # publishing a registry pointer naming it. `cancel-in-progress: false` does not help: + # it makes the SECOND run queue and skip, while the run already past the first check + # sails on. Re-resolve the tip now that smoke has passed, immediately before the + # first step that touches production, and gate PROMOTE on THIS output instead of the + # first one. The steps downstream of PROMOTE chain off PROMOTE's own `outcome` + # rather than reading this output again β€” a strictly stronger predicate, since + # `outcome` is 'skipped' exactly when this check says the branch moved AND is + # 'success' only once the production deploy has actually completed. + # The probe in particular must move with PROMOTE: gated on the first check it would + # still run after a skipped promote, see production serving the (correct) previous + # SHA, read that as a promote failure and roll back a production nobody touched. + # Residual window: this check β†’ `wrangler deploy` is a single step boundary, seconds + # rather than minutes. It is not zero β€” Cloudflare offers no compare-and-swap on + # deploy β€” so the guard narrows the race, it does not eliminate it. + # The candidate deployed above needs no cleanup on the moved-branch path: candidate + # Workers carry no route, and the queued run for the new tip overwrites the candidate + # with its own deploy. + # Moved branch ends the run GREEN-and-skipped, matching the convention the first + # guard's redesign established (see the exit-78 note above): a legitimate branch race + # is not an operator-actionable failure, production and the registry pointer are + # untouched, and the queued run promotes the new tip. Failing red here would + # reintroduce exactly the false alarm that redesign removed. + - name: check_head re-guard after smoke (Β§7.1) + id: check_head_2 + if: steps.check_head.outputs.current == 'true' + env: + REF_NAME: ${{ github.ref_name }} + BUILT_SHA: ${{ github.sha }} + run: | + set -euo pipefail + remote=$(git ls-remote origin "refs/heads/$REF_NAME" | cut -f1) + # Same hardening as the first guard, and for the same reason: `cut` exits 0 on + # empty input, so pipefail AND the emptiness check are both needed β€” otherwise a + # failed ls-remote leaves $remote empty and is misread as "branch moved", turning + # an infrastructure failure into a silent skip of a promote that should have run. + [ -n "$remote" ] || { echo "::error::could not resolve the remote tip of $REF_NAME"; exit 1; } + if [ "$remote" = "$BUILT_SHA" ]; then + echo "current=true" >> "$GITHUB_OUTPUT" + else + echo "current=false" >> "$GITHUB_OUTPUT" + echo "::notice::branch moved during build/smoke (tip is now $remote, this run built $BUILT_SHA) β€” skipping promote, post-promote probe and registry pointer publish" + fi + + # This step ENDS at `wrangler deploy`. Everything that used to follow it in the same + # step β€” the hostname purge, the tarball, the three registry uploads β€” moved to the + # next step, because a failure in any of them failed THIS step, and the post-promote + # probe carried an implicit success() (its `if:` named no status function), so it was + # skipped. A transient purge 5xx or one failed upload therefore left the new version + # LIVE on the production Worker, never probed and never rolled back. + # The split is preferred over flagging `deployed=true` from inside the shell: the + # invariant then lives in the `if:` conditions where it is auditable statically, and + # nothing depends on outputs written by a step that went on to fail. + # Residual window it does NOT close: if `wrangler deploy` itself errors AFTER the + # deployment has taken effect, this step fails, the probe is skipped and production + # is unverified. Cloudflare offers no way to distinguish that from a deploy that + # never landed; the run is red either way, so it is an operator-visible state. + - name: PROMOTE (capture rollback id β†’ deploy, Β§7.1.4) + id: promote + if: steps.check_head_2.outputs.current == 'true' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_DOCS }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CF_ZONE_ID: ${{ vars.CF_ZONE_ID_VYOS_IO }} + run: | + set -eu + slug='${{ steps.matrix.outputs.slug }}' + worker='${{ steps.matrix.outputs.worker }}' + cd workers + # Rollback target, captured BEFORE the deploy below replaces production. + # `wrangler deployments list --json` (verified against wrangler 4.123.0's + # versionsDeploymentsListHandler) dumps the raw deployment objects sorted + # ASCENDING by created_on, so the CURRENT deployment is the LAST entry β€” NOT + # `.[0]`. Each entry looks like: + # {"id":"","created_on":"","author_email":…, + # "versions":[{"version_id":"","percentage":100}, …]} + # `wrangler rollback` takes a VERSION id (it resolves its positional through + # fetchVersion() and redeploys that version at 100%), never a deployment id, so + # we extract versions[].version_id β€” not the deployment's own `id`. + # SPLIT TRAFFIC: a deployment may spread traffic over several versions, and one + # with no single 100% version is not a safe rollback target. That is a state to + # REPORT, not to route around β€” see the extraction below. The reference point is + # wrangler's own fetchDefaultRollbackVersionId() minus its .shift(): wrangler + # picks AFTER deploying so it must skip the current deployment, whereas we + # capture BEFORE, so for us the newest entry is the good one we want back. + # FIRST-DEPLOY BOOTSTRAP: the production Worker does not exist before the very + # first promote, so `deployments list` fails. That is a valid state: rollback_id + # stays empty, the deploy CREATES the Worker, and the post-promote step knows an + # empty id means "nothing to roll back to". EVERY OTHER failure of that command β€” + # auth, network, an unparseable payload β€” must fail the step LOUDLY: an empty + # rollback_id promotes with the auto-rollback silently disarmed while the run + # still looks healthy, which is strictly worse than a red build. + # + # Telling the two apart cannot use the exit status: in wrangler 4.123.0 every + # error path ends in a single uncaught throw, so the process exits 1 regardless of + # cause, and a failed command writes nothing machine-readable to stdout. The only + # discriminator is the Cloudflare API error code wrangler renders into its stderr + # note as `... [code: NNNNN]`. Codes 10007 (script not found) and 10090 (legacy + # environment not found) are exactly the pair wrangler's own isWorkerNotFoundError() + # treats as "this Worker does not exist"; authentication failures carry 9106/10000 + # (its AUTHENTICATION_ERROR_CODES) and connectivity failures never reach the API + # so they carry no code at all β€” neither can be mistaken for the bootstrap case. + # The code was read out of the shipped 4.123.0 bundle, NOT observed against a live + # missing Worker, so re-verify this signature whenever wrangler is bumped; an + # unrecognised future spelling costs one loud red run, never a silent disarm. + # The note is esbuild-formatted (ANSI colour, wrapped to the terminal width), so + # strip escapes and collapse whitespace before matching. + rollback_id="" + list_err=$(mktemp) + if deps=$(npx wrangler deployments list --name "$worker" --json 2>"$list_err"); then + # Validate the shape up front and fail on anything else, rather than coercing a + # non-array to [] and reporting "no rollback target" for malformed output. + if ! printf '%s' "$deps" | jq -e 'type == "array"' >/dev/null 2>&1; then + echo "::error::wrangler deployments list --json for $worker did not return a JSON array β€” refusing to promote with the rollback target undetermined" + exit 1 + fi + # ONLY THE CURRENT (newest) DEPLOYMENT is a legitimate rollback target. An + # earlier spelling of this expression flattened `versions[]` across ALL + # deployments and took the first 100%-traffic id found scanning newestβ†’oldest, + # so it reached BACKWARDS: a current deployment deliberately serving A/B at + # 90/10 resolved to some older deployment's 100% version, and a probe failure + # would then "roll back" to it and destroy the split that was set on purpose. + # Take the current deployment's single 100%-traffic version, or refuse. + # + # The verdict is discriminated inside jq and reported out here so that a + # MALFORMED record fails the step instead of masquerading as a determinate + # state. It used to masquerade: `select(.percentage == 100) | .version_id` on + # `{"percentage":100}` yields null, `null // empty` yields empty, and empty was + # read as "split traffic" β€” i.e. an unparseable API response promoted with the + # auto-rollback silently disarmed. The three good states stay distinguishable: + # no deployments at all β†’ notice; a real traffic split on the CURRENT + # deployment β†’ loud warning + promote unprotected; anything unparseable β†’ red. + # + # `percentage` is required to be NUMERIC for the same reason: the API returns + # numbers, and a missing field or a string "100" slips past a `== 100` test, so + # a deployment that is actually at 100% would be reported as split and promote + # with the rollback disarmed. A percentage this step cannot read leaves it + # unable to tell "100%" from "a split", and guessing wrong destroys live + # traffic β€” so it fails red rather than guess. It must also be IN RANGE: a + # current deployment reporting both 100 and 101 yields exactly one + # 100%-traffic match, so the numeric check alone would classify plainly + # impossible data as a determinate "ok" and arm a rollback selected from it. + # Out-of-range therefore routes to the malformed path, never to "split:". + # + # Validation scope: `created_on` is checked on EVERY record because it decides + # which record is "current", so one unusable value corrupts the selection. + # `versions` / `version_id` are checked on the current record only β€” it is the + # only record this step reads, and failing on an older record's shape would + # block promotes over data with no bearing on the rollback target. + verdict=$(printf '%s' "$deps" | jq -r ' + def bad($why): "malformed:" + $why; + if length == 0 then "empty:" + elif any(.[]; type != "object") then + bad("a deployment entry is not an object") + elif any(.[]; (.created_on | type) != "string") then + bad("a deployment entry has no string created_on to order by") + else + (sort_by(.created_on) | last) as $cur + | if ($cur.versions | type) != "array" then + bad("the current deployment has no versions array") + elif ($cur.versions | length) == 0 then + bad("the current deployment has an empty versions array") + elif any($cur.versions[]; type != "object") then + bad("the current deployment has a non-object versions entry") + elif any($cur.versions[]; (.percentage | type) != "number") then + bad("the current deployment has a versions entry with a non-numeric percentage") + elif any($cur.versions[]; .percentage < 0 or .percentage > 100) then + bad("the current deployment has a versions entry with an out-of-range percentage") + else + [$cur.versions[] | select(.percentage == 100)] as $full + | if ($full | length) == 0 then "split:" + elif ($full | length) > 1 then + bad("the current deployment reports more than one 100%-traffic version") + elif (($full[0].version_id | type) != "string") or ($full[0].version_id == "") then + bad("the current deployment 100%-traffic version has no usable version_id") + else "ok:" + $full[0].version_id + end + end + end + ') || { echo "::error::could not evaluate the deployments payload for $worker β€” refusing to promote with the rollback target undetermined"; exit 1; } + case "${verdict%%:*}" in + ok) + rollback_id=${verdict#*:} + echo "rollback target for $worker: version $rollback_id" + ;; + empty) + echo "::notice::$worker has no deployments yet β€” nothing to roll back to; rollback disabled for this run" + ;; + split) + # Determinate state, not a swallowed failure: `wrangler rollback` takes one + # version id, so a split-traffic deployment has no single-version target to + # offer it. Warn loudly and promote unprotected rather than block a promote + # on a traffic split someone set deliberately. + echo "::warning::$worker's current deployment has no single 100%-traffic version (split traffic) β€” no safe rollback target; rollback disabled for this run" + ;; + *) + echo "::error::wrangler deployments list --json for $worker returned a record this step cannot trust (${verdict#*:}) β€” refusing to promote with the rollback target undetermined" + exit 1 + ;; + esac + else + cat "$list_err" >&2 + esc=$(printf '\033') + if sed "s/${esc}\\[[0-9;]*m//g" "$list_err" | tr -s '[:space:]' ' ' \ + | grep -qE '\[code: (10007|10090)\]'; then + echo "::notice::production Worker $worker does not exist yet (API code 10007/10090) β€” first-deploy bootstrap; rollback disabled for this run" + else + echo "::error::wrangler deployments list --name $worker failed for a reason other than 'Worker does not exist' (see stderr above) β€” refusing to promote with the auto-rollback disarmed" + exit 1 + fi + fi + rm -f "$list_err" + # rollback_id came out of Cloudflare's API (`wrangler deployments list --json`), + # so it is untrusted text, and this is the last point before it becomes a step + # output. Two distinct exposures, only one of which careful quoting can close: + # * a NEWLINE in the value injects arbitrary ADDITIONAL step outputs into + # $GITHUB_OUTPUT. No shell is involved, so no amount of care at the consumer + # end helps; this check is the only place that can stop it. + # * shell metacharacters at the consumers, handled separately by passing the + # value through `env:` instead of a template expansion (see the finalizer). + # The accepted character class is deliberately WIDER than the observed format. + # wrangler 4.123.0 performs no client-side validation of a version id β€” it + # interpolates the string straight into the API URL path β€” so the canonical + # UUID shape below is what Cloudflare's own bundled SDK documents, not something + # the CLI enforces. Hard-failing on anything but a UUID would turn a Cloudflare + # format change into a blocked promote; hard-failing on anything outside + # [A-Za-z0-9._-] cannot, and still certainly excludes newlines, quotes, + # whitespace and every shell metacharacter. A non-UUID that is within the safe + # set is therefore a warning, not an error. + # EMPTY STAYS LEGITIMATE: it is the no-target state of all three benign paths + # above (first deploy, no deployments, split traffic) and the finalizer's -z + # branch depends on it. The malformed value is never echoed back. + if [ -n "$rollback_id" ]; then + case "$rollback_id" in + *[!A-Za-z0-9._-]*) + echo "::error::wrangler deployments list --json for $worker returned a version_id containing characters no Worker version id may contain β€” refusing to promote with an untrustworthy rollback target" + exit 1 + ;; + esac + if [ "${#rollback_id}" -gt 128 ]; then + echo "::error::wrangler deployments list --json for $worker returned a ${#rollback_id}-character version_id (max 128) β€” refusing to promote with an untrustworthy rollback target" + exit 1 + fi + if ! printf '%s' "$rollback_id" \ + | grep -qE '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'; then + echo "::warning::$worker's rollback target '$rollback_id' is within the safe character set but is not the canonical UUID shape Cloudflare documents for version_id β€” accepted, but worth a look" + fi + fi + echo "rollback_id=$rollback_id" >> "$GITHUB_OUTPUT" + npx wrangler deploy --config branch/wrangler.$(case "$slug" in rolling) echo rolling;; 1.5) echo v15;; 1.4) echo v14;; esac).jsonc \ + --name "$worker" --var DOCS_BUILD_SHA:'${{ github.sha }}' --var DOCS_ENV:production + + # Gated on PROMOTE's OUTCOME, not on check_head_2 directly: `outcome` is 'skipped' + # exactly when the branch moved (PROMOTE carries the check_head_2 condition and its + # own implicit success()), and 'success' only once the production deploy completed. + # A failure in this step no longer suppresses the probe below β€” that is the whole + # point of the split. + - name: Purge + registry generation upload (Β§7.1.4) + id: promote_publish + if: steps.promote.outcome == 'success' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_DOCS }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CF_ZONE_ID: ${{ vars.CF_ZONE_ID_VYOS_IO }} + DOCS_CF_LIVE: ${{ vars.DOCS_CF_LIVE }} + run: | + set -eu + slug='${{ steps.matrix.outputs.slug }}' + sha='${{ github.sha }}' + # hostname-scoped purge (Β§3.3; all-plans since 2025-04), retried once for the same + # reason the registry uploads below are. This purge is the one post-deploy failure + # whose knock-on effect is asymmetric: the probe measures the EDGE, so a purge that + # never lands leaves the edge serving the previous generation and the probe reads + # that as a failed promote β€” rolling back a deploy that was in fact fine. One retry + # removes the transient-5xx bulk of that class at no cost. What remains (a purge + # API that is genuinely down) still ends in a rollback, deliberately: see the probe + # step's note. + purge_hostname() { + curl -sf -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \ + -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" \ + --data '{"hosts":["docs.vyos.io"]}' + } + if [ "$DOCS_CF_LIVE" = "true" ]; then + purge_hostname || { sleep 5; purge_hostname; } + else + echo "DOCS_CF_LIVE=false β€” pre-cutover: skipping production hostname purge" + fi + # registry generation upload β€” SHA-scoped immutable objects (Β§7.1.4). The + # $slug/latest.json POINTER is deliberately NOT published here: it moves to a + # dedicated step AFTER the post-promote probe/auto-rollback below, so a probe + # failure (and the rollback it triggers) leaves the pointer on the previous + # good generation instead of advancing it to a generation that just got rolled + # back. Uploading straight to $slug/last-good.* let a concurrent reader observe + # a half-written generation (e.g. new tar.zst, still-old meta.json) mid upload. + # Uploading under $slug//* first β€” a key no prior generation ever reused β€” + # keeps readers of the OLD pointer seeing a full, untouched old generation. + tar --zstd -cf lastgood.tar.zst -C dist/assets . + page_count=$(find "dist/assets/en/$slug" -name '*.html' | wc -l | tr -d ' ') + printf '{"sha":"%s","page_count":%s}' "$sha" "$page_count" > lastgood.meta.json + printf '{"sha":"%s"}' "$sha" > latest.json + cd workers + upload_generation() { + npx wrangler r2 object put "$REGISTRY_BUCKET/$slug/$sha/tar.zst" --file ../lastgood.tar.zst --remote && + npx wrangler r2 object put "$REGISTRY_BUCKET/$slug/$sha/pdf" --file "../dist/assets/en/$slug/vyos-documentation.pdf" --remote && + npx wrangler r2 object put "$REGISTRY_BUCKET/$slug/$sha/meta.json" --file ../lastgood.meta.json --remote + } + if ! (upload_generation || upload_generation); then + echo "::error::registry generation upload failed twice β€” REGISTRY STALE (see spec Β§7.1.4)" + exit 1 + fi + + # THE VERIFICATION FINALIZER. Once `wrangler deploy` has succeeded, production has + # changed and this step must run whatever happens afterwards β€” so the condition is + # deliberately NOT the bare `steps.check_head_2...` form, which would pick up an + # implicit success() and be skipped by a failed purge or a failed registry upload, + # leaving production live and unverified. + # `!cancelled()` and not `always()`: on a cancelled run the deploy may not even have + # finished, and firing an auto-rollback while the operator is tearing the run down is + # the wrong reflex. `!cancelled()` counts as a status function, so it also suppresses + # the implicit success() β€” which is exactly what lets this run after a failed + # promote_publish. + # `steps.promote.outcome == 'success'` keeps both older guarantees intact: 'skipped' + # on the moved-branch path (so the probe cannot see production serving the correct + # previous SHA and read it as a promote failure), and never 'success' unless the + # rollback_id below was captured by a step that ran to completion. + # + # DELIBERATE: reaching here after a FAILED purge can roll back a deploy that was + # itself fine β€” the Worker took the new version, the edge kept serving the old one, + # and this probe measures the edge. That is the intended trade. The alternative, + # exiting without a rollback whenever the purge failed, leaves an UNVERIFIED version + # live on production, which is precisely the state this whole gate exists to prevent; + # a rollback instead returns production to the last version known to serve correctly, + # which is also what the stale edge is already serving, and the run is red either way + # so an operator looks at it. The transient half of that class is retried away in the + # purge step; what is left is a purge API that is down, and a known-good production + # is the right place to be sitting while it is. + - name: Post-promote probe + auto-rollback (Β§7.1.5) + id: probe + if: ${{ !cancelled() && steps.promote.outcome == 'success' }} + env: + # API-derived (Cloudflare `deployments list`), so it is bound here rather than + # expanded into the script text below: a template expansion is textual + # substitution into the shell source, an env var is not. Validated at capture. + ROLLBACK_ID: ${{ steps.promote.outputs.rollback_id }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_DOCS }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CF_ZONE_ID: ${{ vars.CF_ZONE_ID_VYOS_IO }} + DOCS_CF_LIVE: ${{ vars.DOCS_CF_LIVE }} + run: | + set -eu + # pipefail: probe_once below is a pipeline whose FIRST command is the curl that can + # fail. Without it the pipeline's status is awk's, and a transport failure would be + # indistinguishable from a missing header. + set -o pipefail + if [ "$DOCS_CF_LIVE" != "true" ]; then + echo "DOCS_CF_LIVE=false β€” pre-cutover: docs.vyos.io still serves RTD; skipping production probe" + exit 0 + fi + slug='${{ steps.matrix.outputs.slug }}' + # VERIFICATION REQUIRES BOTH A SUCCESSFUL STATUS AND THE BUILT SHA. `withDocsHeaders` + # (workers/branch/src/index.ts) sets X-Docs-Build UNCONDITIONALLY β€” error responses + # carry it too, which that function itself acknowledges by special-casing + # `status >= 400` for Cache-Control. Matching the header alone therefore accepted a + # production answering 500 with the new SHA as a verified promote, after which the + # registry pointer published for a generation that was serving errors. + # + # `--fail` is deliberately NOT used: it collapses every 4xx/5xx into curl exit 22 and + # discards the status line β€” the one value needed to tell a broken new version (500) + # apart from a transport failure. Parsing the status explicitly supersedes it. + # + # probe_once prints " " for the FINAL response block. Resetting on every + # status line means 1xx interim responses (e.g. Cloudflare Early Hints) contribute + # nothing β€” only the last block's headers are read. Duplicate X-Docs-Build headers + # with IDENTICAL values state the same fact and are accepted; CONFLICTING values are + # untrustworthy and surface as "", which can never equal the built SHA. + probe_once() { + curl -sSI --max-time 20 "https://docs.vyos.io/en/$slug/" \ + | tr -d '\r' \ + | awk ' + /^HTTP\// { code = $2; n = 0; v = ""; next } + tolower($0) ~ /^x-docs-build:/ { + val = $0 + sub(/^[^:]*:[ \t]*/, "", val) + sub(/[ \t]+$/, "", val) + n++ + if (n == 1) { v = val } else if (val != v) { v = "" } + } + END { printf "%s %s\n", (code == "" ? "" : code), (n == 0 ? "" : v) } + ' + } + # Retry loop: the purge above is asynchronous, so an immediate probe can still + # observe a stale edge response and trigger a false rollback. Poll up to 6 times + # (60s budget) before concluding the promote genuinely failed. A non-200 or a + # transport failure on an EARLY attempt is retried for the same reason a stale SHA + # is β€” only the FINAL attempt's verdict decides. + code="" + got="" + verified=false + for attempt in 1 2 3 4 5 6; do + if out=$(probe_once); then + code=${out%% *} + got=${out#* } + else + # curl itself failed (DNS, TLS, connection refused, --max-time exceeded). + code="" + got="" + fi + if [ "$code" = "200" ] && [ "$got" = '${{ github.sha }}' ]; then + verified=true + break + fi + if [ "$attempt" -lt 6 ]; then + echo "::notice::post-promote probe attempt $attempt/6 unverified (status $code, X-Docs-Build $got) β€” retrying in 10s" + sleep 10 + fi + done + if [ "$verified" != "true" ]; then + # Same action for every unverified outcome β€” a rollback to the last version known + # to serve correctly β€” but distinguishable diagnoses, because "200 with the wrong + # SHA" (stale edge / promote never took) and "500 with the right SHA" (the new + # version is live and broken) point an operator at completely different causes. + if [ "$code" = "200" ]; then + reason="production answered 200 but served X-Docs-Build $got, not ${{ github.sha }}" + elif [ "$code" = "" ]; then + reason="production could not be reached at all after 6 attempts (see curl stderr above)" + else + reason="production answered status $code carrying X-Docs-Build $got β€” the deployed version is live but not serving successfully" + fi + if [ -z "$ROLLBACK_ID" ]; then + echo "::error::post-promote probe failed ($reason) but PROMOTE captured no rollback target (first deploy, or no 100%-traffic version) β€” investigate manually" + exit 1 + fi + # rollback_id is a Worker VERSION id (see the PROMOTE capture above); + # `wrangler rollback ` redeploys that version at 100% traffic. + echo "::error::post-promote probe failed ($reason) β€” rolling back to version $ROLLBACK_ID" + cd workers && npx wrangler rollback "$ROLLBACK_ID" \ + --name '${{ steps.matrix.outputs.worker }}' \ + --message 'auto-rollback: docs-build post-promote probe failed' --yes + curl -sf -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \ + -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" \ + --data '{"hosts":["docs.vyos.io"]}' + exit 1 + fi + + # The IMPLICIT success() on this condition is load-bearing and must stay implicit: + # naming no status function is what makes this step skip whenever ANY earlier step + # failed β€” the probe (so a rolled-back generation never becomes the pointer target) + # and the purge/upload step (so a pointer never names a generation that was not + # fully uploaded). Adding always()/!cancelled() here would publish over a rollback. + # + # KNOWN GAP: because that success() is implicit, a failed `promote_publish` skips + # this step even when the probe went on to verify production serving this SHA β€” and + # a terminal failure here has the same effect. Either way production and + # $slug/latest.json disagree, and nothing in this job reconciles them. Tracked in + # https://vyos.dev/T9237. + - name: Publish registry pointer (Β§7.1.4) + id: publish_pointer + if: steps.check_head_2.outputs.current == 'true' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_DOCS }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -eu + slug='${{ steps.matrix.outputs.slug }}' + sha='${{ github.sha }}' + cd workers + # Runs only after the post-promote probe/auto-rollback step above has passed (or + # was a pre-cutover no-op). Repointing $slug/latest.json is the sole remaining + # step of the Β§7.1.4 atomic-publish sequence β€” the sha-scoped generation objects + # were already uploaded in PROMOTE. If this fails, the sha-scoped objects landed + # under $slug/$sha/ but latest.json still points at the previous generation. + publish_pointer() { + npx wrangler r2 object put "$REGISTRY_BUCKET/$slug/latest.json" --file ../latest.json --remote + } + if ! (publish_pointer || publish_pointer); then + echo "::error::registry pointer publish failed twice β€” sha-scoped objects landed under $slug/$sha/ but latest.json still points at the previous generation (see spec Β§7.1.4)" + exit 1 + fi diff --git a/docker/Dockerfile b/docker/Dockerfile index fee5c91c..8598b808 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -27,6 +27,16 @@ RUN apt-get update && apt-get install -y \ curl \ dos2unix +# PDF-build toolchain extras, installed with --no-install-recommends (Trivy +# DS-0029) on a separate line β€” the texlive line above deliberately keeps +# recommends, which carry font packages LaTeX needs: +# - imagemagick + librsvg2-bin: sphinx.ext.imgconverter via docker/im-convert.sh +# - poppler-utils: pdfinfo for the docs-build workflow's page-count validation +RUN apt-get update && apt-get install -y --no-install-recommends \ + imagemagick \ + librsvg2-bin \ + poppler-utils + RUN pip3 install --break-system-packages \ Sphinx \ sphinx-rtd-theme \ @@ -54,4 +64,12 @@ COPY entrypoint.sh /usr/local/bin/entrypoint.sh # "no such file or directory" RUN dos2unix /usr/local/bin/entrypoint.sh +# sphinx.ext.imgconverter's `image_converter` command (docs/conf.py sets +# image_converter = 'im-convert'). Routes .svg sources to rsvg-convert +# directly, since Debian's imagemagick package is built --without-rsvg and +# its built-in SVG coder can't handle embedded base64 raster +# elements. See docker/im-convert.sh for the full rationale. +COPY im-convert.sh /usr/local/bin/im-convert +RUN dos2unix /usr/local/bin/im-convert && chmod +x /usr/local/bin/im-convert + ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/docker/im-convert.sh b/docker/im-convert.sh new file mode 100644 index 00000000..7930b8be --- /dev/null +++ b/docker/im-convert.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Wrapper invoked by sphinx.ext.imgconverter as the `image_converter` command +# (docs/conf.py sets image_converter = 'im-convert'). +# +# Sphinx calls it as: im-convert "[0]" "" (the "[0]" frame-index +# suffix is ImageMagick syntax for "first frame/page", appended unconditionally +# by sphinx/ext/imgconverter.py). +# +# Debian's `imagemagick` package is built --without-rsvg (LGPL licensing), so +# its own SVG coder is a minimal libxml2-based renderer rather than a wrapper +# around librsvg. That built-in coder cannot handle SVGs with an embedded +# base64-encoded raster element (common in diagrams exported from +# draw.io/diagrams.net): it fails with +# "convert-im6.q16: unable to open image `image/png;base64,...'" +# because it tries to open the data-URI payload as if it were a file path. +# `rsvg-convert` (from librsvg2-bin) handles the same file correctly. +# +# Route .svg sources through rsvg-convert directly; everything else (webp, +# gif, pdf, ...) goes through ImageMagick's `convert` as before β€” webp support +# is compiled into this ImageMagick build, so that path needs no delegate. +# +# Output size is constrained: the docs pipeline's per-file size gate +# (scripts/docs_gates/gates.py) enforces the Cloudflare Workers 25 MiB +# asset cap on the built PDF, and naive full-resolution truecolor PNG +# conversion inflates it to ~183 MiB (the ~170 source .webp files are +# lossy-compressed; decoded to truecolor PNG they explode ~6x). The raster +# policy below (cap at 1000px on the long edge β€” only shrinking, never +# enlarging β€” plus non-dithered 128-color palette quantization and max PNG +# compression) brings the summed image payload to ~10 MiB (~15 MiB final +# PDF). Sphinx's imgconverter fixes the destination extension to .png (from +# the conversion rule's target mimetype) and pdflatex picks its decoder by +# extension, so switching photographic sources to JPEG is not available +# here β€” resolution + palette are the levers. 1000px at the PDF's ~6.3in +# text width is ~158 dpi; spot-checked legible on screenshots, diagrams, +# and photos alike. +set -euo pipefail + +# sphinx.ext.imgconverter's is_available() probes the converter with a +# single-arg call (`im-convert -version`) before ever doing a real +# conversion. Delegate straight to ImageMagick's own -version so the probe +# succeeds β€” is_available()'s result is cached at the class level for the +# whole build, so a failed probe here silently disables ALL conversion +# (webp included), not just SVG. +if [ "$#" -lt 2 ]; then + exec convert "$@" +fi + +src=$1 +dst=$2 + +# Strip ImageMagick's trailing frame-index suffix, e.g. +# "/path/to/file.svg[0]" -> "/path/to/file.svg". +plain_src=${src%\[*\]} + +case "$plain_src" in + *.svg|*.SVG) + exec rsvg-convert -o "$dst" "$plain_src" + ;; + *) + # See the size-cap rationale in the header comment. `1000x1000>` + # only shrinks images larger than 1000px on either edge; `+dither` + # disables dithering (IM6 semantics: -dither enables, +dither + # disables), which quantizes flat UI colors cleanly AND compresses + # far better than dithered output; `-quality 95` for PNG means + # zlib level 9 + adaptive row filtering. + exec convert "$src" -resize '1000x1000>' -strip +dither -colors 128 -quality 95 "$dst" + ;; +esac diff --git a/docs/_static/css/version-picker.css b/docs/_static/css/version-picker.css new file mode 100644 index 00000000..16ce16c3 --- /dev/null +++ b/docs/_static/css/version-picker.css @@ -0,0 +1,11 @@ +.vyos-version-picker-item { list-style: none; } +#vyos-version-picker { display: flex; align-items: center; gap: .5em; padding: .4em 0; font-size: .9em; } +#vyos-version-picker select { max-width: 14em; padding: .15em .3em; } +#vyos-version-picker select:focus { outline: 2px solid #ffae12; outline-offset: 1px; } +.vyos-pdf-link { font-weight: 600; } +.vyos-version-banner { position: relative; padding: .6em 2.2em .6em 1em; font-size: .95em; line-height: 1.4; } +.vyos-banner-dev { background: #e7f2fa; color: #2a6496; } +.vyos-banner-newer-lts { background: #fff6e5; color: #6b5900; } +.vyos-banner-eol { background: #fdecea; color: #8a1f11; } +.vyos-version-banner a { text-decoration: underline; color: inherit; font-weight: 600; } +.vyos-banner-dismiss { position: absolute; right: .5em; top: .35em; background: none; border: 0; font-size: 1.2em; cursor: pointer; color: inherit; } diff --git a/docs/_static/js/pagefind-wrapper.js b/docs/_static/js/pagefind-wrapper.js new file mode 100644 index 00000000..a4acadf8 --- /dev/null +++ b/docs/_static/js/pagefind-wrapper.js @@ -0,0 +1,69 @@ +/* Version-scoped Pagefind loader. Derives its base from the current URL so the + * same file works on production, canary, and /pr-/ previews (Β§9). */ +(function (window) { + 'use strict'; + + function basePathFor(pathname) { + var m = pathname.match(/^((\/pr-\d+)?\/[a-z]{2}(?:_[A-Z]{2})?\/[^/]+\/)/); + if (!m) return null; + return { base: m[1], prefix: m[2] || '' }; + } + + function prefixResultUrl(url, prefix) { + return prefix ? prefix + url : url; + } + + function assetUrlsFor(base) { + return { + css: base + 'pagefind/pagefind-ui.css', + js: base + 'pagefind/pagefind-ui.js', + }; + } + + function init() { + var mount = document.getElementById('vyos-search'); + if (!mount) return; + var ctx = basePathFor(window.location.pathname); + if (!ctx) return; + var assets = assetUrlsFor(ctx.base); + /* Stylesheet first so the UI never renders unstyled. CSS load failure is + * cosmetic only β€” no error handler on the link. */ + var l = document.createElement('link'); + l.rel = 'stylesheet'; + l.href = assets.css; + document.head.appendChild(l); + var s = document.createElement('script'); + s.src = assets.js; + s.onerror = function () { + /* Per-version Pagefind assets missing (index not built yet, preview + * namespace, transient failure) β€” show a visible notice instead of + * leaving #vyos-search a silent empty div. */ + var p = document.createElement('p'); + p.className = 'vyos-search-unavailable'; + p.textContent = 'Search is temporarily unavailable for this version.'; + mount.appendChild(p); + }; + s.onload = function () { + /* global PagefindUI */ + new window.PagefindUI({ + element: '#vyos-search', + baseUrl: ctx.base, + bundlePath: ctx.base + 'pagefind/', + processResult: function (result) { + result.url = prefixResultUrl(result.url, ctx.prefix); + return result; + }, + }); + }; + document.head.appendChild(s); + } + + window.VyOSSearch = { + basePathFor: basePathFor, + prefixResultUrl: prefixResultUrl, + assetUrlsFor: assetUrlsFor, + init: init, + }; + if (typeof document !== 'undefined' && document.addEventListener) + document.addEventListener('DOMContentLoaded', init); +})(window); diff --git a/docs/_static/js/version-picker.js b/docs/_static/js/version-picker.js new file mode 100644 index 00000000..84f68fd6 --- /dev/null +++ b/docs/_static/js/version-picker.js @@ -0,0 +1,193 @@ +/* VyOS docs version picker + status banner + language scaffold. + * Vanilla JS, no dependencies, no build step. Degrades silently when + * /versions.json is unreachable (docs stay fully readable). */ +(function (window) { + 'use strict'; + + // Deliberately does not match /pr-/ preview prefixes β€” previews are single-version, + // so the picker has nothing to switch between and stays hidden there by design. + // The (?!\.\.?\/) guard rejects the dot-segments "." and ".." as slugs, which would + // otherwise escape the /// tree once the browser normalizes the URL. + function parseLocation(pathname) { + var m = pathname.match(/^\/([a-z]{2}(?:_[A-Z]{2})?)\/(?!\.\.?\/)([A-Za-z0-9._-]+)\/(.*)$/); + if (!m) return null; + return { lang: m[1], slug: m[2], rest: m[3] }; + } + + // Contract: versions.json lists versions newest-first, so the first 'lts' entry found + // here is the newest LTS β€” callers rely on that ordering rather than comparing versions. + function newestLts(manifest) { + for (var i = 0; i < manifest.versions.length; i++) + if (manifest.versions[i].status === 'lts') return manifest.versions[i].slug; + return null; + } + + function bannerFor(slug, manifest) { + var entry = null, i; + for (i = 0; i < manifest.versions.length; i++) + if (manifest.versions[i].slug === slug) entry = manifest.versions[i]; + if (!entry) return null; + var newest = newestLts(manifest); + if (entry.status === 'dev') return { kind: 'dev', newest: newest }; + if (entry.status === 'eol') return { kind: 'eol', newest: newest }; + if (entry.status === 'lts' && newest && newest !== slug) + return { kind: 'newer-lts', newest: newest }; + return null; + } + + /* Normalize one path segment: each well-formed %HH run is decoded and re-encoded on its + * own β€” per run, not per segment, so one malformed escape cannot double-encode the valid + * ones beside it (location.pathname hands escapes back verbatim, and blind re-encoding + * would turn %2E into %252E and miss on the HEAD probe). Literal spans, including a bare + * '%', always go through encodeURIComponent, so taint neutralization holds unconditionally; + * a run decoding to invalid UTF-8 is kept verbatim, being already pure %HH text. + * Decoding cannot resurrect a dot-segment: the URL parser resolves '.' / '..' and their + * percent-encoded forms during navigation, so pathname never presents them. */ + function encodeSegment(seg) { + var out = ''; + var re = /(?:%[0-9A-Fa-f]{2})+/g; + var last = 0; + var m; + while ((m = re.exec(seg))) { + out += encodeURIComponent(seg.slice(last, m.index)); + try { out += encodeURIComponent(decodeURIComponent(m[0])); } catch (e) { out += m[0]; } + last = m.index + m[0].length; + } + return out + encodeURIComponent(seg.slice(last)); + } + + /* Percent-encode a multi-segment path one segment at a time, so the '/' separators + * survive. Real docs paths are plain ASCII sphinx slugs (letters/digits/-/_/./html), + * where this is a no-op β€” it exists to keep DOM-derived text (location.pathname, + * select.value) from reaching a location.href sink unescaped. */ + function encodePath(rest) { + return rest.split('/').map(function (seg) { return encodeSegment(seg); }).join('/'); + } + + function targetUrlFor(loc, targetSlug) { + return '/' + encodeURIComponent(loc.lang) + '/' + encodeURIComponent(targetSlug) + + '/' + encodePath(loc.rest); + } + + /* Full navigation URL for a version switch: same path on the target version + * with the current query string + fragment re-attached, so deep links + * (?highlight=…, #section) survive the switch (Β§4 URL-stability contract). */ + function navUrlFor(loc, targetSlug, search, hash) { + return targetUrlFor(loc, targetSlug) + (search || '') + (hash || ''); + } + + /* Mirror of targetUrlFor for the language switch: swaps the lang segment while + * keeping the current version slug and path. */ + function langUrlFor(loc, langCode) { + return '/' + encodeURIComponent(langCode) + '/' + encodeURIComponent(loc.slug) + + '/' + encodePath(loc.rest); + } + + /* ---- DOM layer (no execution at import time) ---- */ + function bannerText(b, manifest) { + if (b.kind === 'dev') return 'You are reading the development (rolling) docs.'; + if (b.kind === 'eol') return 'This VyOS version is end-of-life; these docs are frozen. See the ' + b.newest + ' (LTS) docs.'; + return 'A newer LTS (' + b.newest + ') is available.'; + } + + function init() { + var anchor = document.getElementById('vyos-version-picker'); + if (!anchor) return; + var loc = parseLocation(window.location.pathname); + if (!loc) return; + + fetch('/versions.json', { headers: { accept: 'application/json' } }) + .then(function (r) { if (!r.ok) throw new Error('versions.json ' + r.status); return r.json(); }) + .then(function (manifest) { + renderPicker(anchor, loc, manifest); + renderLang(anchor, loc, manifest); + renderBanner(loc, manifest); + }) + .catch(function () { /* silent degradation (Β§4) */ }); + } + + function renderPicker(anchor, loc, manifest) { + var label = document.createElement('label'); + label.setAttribute('for', 'vyos-version-select'); + label.textContent = 'Version: '; + var sel = document.createElement('select'); + sel.id = 'vyos-version-select'; + manifest.versions.forEach(function (v) { + var o = document.createElement('option'); + o.value = v.slug; o.textContent = v.label; o.selected = v.slug === loc.slug; + sel.appendChild(o); + }); + sel.addEventListener('change', function () { + var search = window.location.search, hash = window.location.hash; + var target = navUrlFor(loc, sel.value, search, hash); + var fallback = '/' + encodeURIComponent(loc.lang) + '/' + encodeURIComponent(sel.value) + + '/' + (search || '') + (hash || ''); + fetch(targetUrlFor(loc, sel.value), { method: 'HEAD' }) + .then(function (r) { + window.location.href = (r.status === 404) ? fallback : target; + }) + .catch(function () { window.location.href = fallback; }); + }); + anchor.appendChild(label); + anchor.appendChild(sel); + + var entry = manifest.versions.filter(function (v) { return v.slug === loc.slug; })[0]; + if (entry && entry.pdf) { + var a = document.createElement('a'); + a.href = entry.pdf; a.className = 'vyos-pdf-link'; a.textContent = 'PDF'; + anchor.appendChild(a); + } + } + + function renderLang(anchor, loc, manifest) { + if (!manifest.languages || manifest.languages.length <= 1) return; // scaffold: hidden while en-only (Β§4) + var sel = document.createElement('select'); + sel.id = 'vyos-lang-select'; + sel.setAttribute('aria-label', 'Language'); + manifest.languages.forEach(function (l) { + var o = document.createElement('option'); + o.value = l.code; o.textContent = l.label; o.selected = l.code === loc.lang; + sel.appendChild(o); + }); + sel.addEventListener('change', function () { + window.location.href = langUrlFor(loc, sel.value) + + window.location.search + window.location.hash; + }); + anchor.appendChild(sel); + } + + function renderBanner(loc, manifest) { + var b = bannerFor(loc.slug, manifest); + if (!b) return; + var key = 'vyos-banner-dismissed-' + loc.slug; + try { if (window.localStorage.getItem(key)) return; } catch (e) { /* private mode */ } + var div = document.createElement('div'); + div.className = 'vyos-version-banner vyos-banner-' + b.kind; + div.setAttribute('role', b.kind === 'eol' ? 'alert' : 'note'); + var span = document.createElement('span'); + span.textContent = bannerText(b, manifest); + div.appendChild(span); + if (b.newest && b.kind !== 'dev') { + var link = document.createElement('a'); + link.href = '/' + loc.lang + '/' + b.newest + '/'; + link.textContent = ' Switch to ' + b.newest + '.'; + div.appendChild(link); + } + var x = document.createElement('button'); + x.textContent = 'Γ—'; x.className = 'vyos-banner-dismiss'; + x.setAttribute('aria-label', 'Dismiss'); + x.addEventListener('click', function () { + try { window.localStorage.setItem(key, '1'); } catch (e) { /* ignore */ } + div.remove(); + }); + div.appendChild(x); + document.body.insertBefore(div, document.body.firstChild); + } + + window.VyOSVersionPicker = { + parseLocation: parseLocation, bannerFor: bannerFor, encodePath: encodePath, + targetUrlFor: targetUrlFor, navUrlFor: navUrlFor, langUrlFor: langUrlFor, init: init, + }; + if (typeof document !== 'undefined' && document.addEventListener) + document.addEventListener('DOMContentLoaded', init); +})(window); diff --git a/docs/_templates/breadcrumbs.html b/docs/_templates/breadcrumbs.html new file mode 100644 index 00000000..1df3c859 --- /dev/null +++ b/docs/_templates/breadcrumbs.html @@ -0,0 +1,5 @@ +{%- extends "sphinx_rtd_theme/breadcrumbs.html" %} +{% block breadcrumbs_aside %} +
  • +{{ super() }} +{% endblock %} diff --git a/docs/_templates/searchbox.html b/docs/_templates/searchbox.html new file mode 100644 index 00000000..5fdd378f --- /dev/null +++ b/docs/_templates/searchbox.html @@ -0,0 +1,13 @@ +{#- Full override (sphinx_rtd_theme/layout.html does a plain {% include "searchbox.html" %}, + not a named block, so there is nothing to {% extends %} + {% block %} here). Only mounts + the Pagefind UI on CF-Workers builds (conf.py sets html_context['vyos_cf_build'] from + DOCS_VERSION_SLUG) β€” ReadTheDocs runs plain Sphinx with no Pagefind step, so the mount's + assets would 404 there. On RTD (and any other non-CF build) fall through to the theme's + stock server-side search form via Sphinx's "!" bang-prefix (forces resolution from the + theme, bypassing this override, per sphinx/jinja2glue.py). #} +{% if vyos_cf_build %} + + +{% else %} +{% include "!searchbox.html" %} +{% endif %} diff --git a/docs/conf.py b/docs/conf.py index ece118e4..36075621 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -46,6 +46,18 @@ extensions = ['sphinx.ext.intersphinx', 'sphinx.ext.todo', 'sphinx.ext.ifconfig', 'sphinx.ext.graphviz', + # LaTeX-only: converts image formats the LaTeX/PDF builder can't + # embed natively (webp, svg, ...) to PNG at build time. The + # LaTeX builder's supported_image_types is ['application/pdf', + # 'image/png', 'image/jpeg'] β€” without this, unsupported + # images (nearly all of ours are .webp) are silently dropped + # from the PDF output. No effect on the HTML builder, which + # supports webp/svg natively in the browser; imgconverter + # only fires post-transforms when the active builder's + # supported_image_types doesn't already cover the source + # format. See `image_converter` below + docker/im-convert.sh + # for the conversion command this depends on. + 'sphinx.ext.imgconverter', 'notfound.extension', 'autosectionlabel', 'myst_parser', @@ -55,6 +67,20 @@ extensions = ['sphinx.ext.intersphinx', 'sphinx_sitemap', ] +# sphinx.ext.imgconverter: use a thin wrapper (docker/im-convert.sh, installed +# on PATH as `im-convert`) instead of ImageMagick's `convert` directly. +# Debian's `imagemagick` package is built --without-rsvg, so its built-in SVG +# coder (a minimal libxml2-based renderer, not a librsvg wrapper) can't +# rasterize SVGs that embed a base64 raster element β€” common in +# diagrams exported from draw.io/diagrams.net β€” and fails with "unable to +# open image `image/png;base64,...'". The wrapper routes .svg sources to +# `rsvg-convert` (from librsvg2-bin) directly and everything else (webp, +# gif, pdf, ...) through ImageMagick's `convert` as usual. If `im-convert` +# isn't on PATH (e.g. a build environment other than docker/Dockerfile), +# imgconverter's own `is_available()` check logs a warning and skips +# conversion rather than failing the build. +image_converter = 'im-convert' + # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -122,6 +148,27 @@ html_static_path = ['_static'] html_extra_path = ['_html_extra'] +# Version picker + status banner + language scaffold (docs/_static/js/version-picker.js, +# docs/_static/css/version-picker.css). Appended rather than assigned in case a later +# addition to this file defines these lists first. Registered unconditionally: it degrades +# silently on ReadTheDocs (fetch of /versions.json fails there, so nothing renders). +# globals().get(...) (not a bare `html_js_files` reference guarded by `'html_js_files' in +# dir()`) avoids a static-analysis F821 (possibly-undefined name) while keeping the same +# runtime behavior: append to an existing list if one was already defined, else start fresh. +html_js_files = [*globals().get('html_js_files', []), 'js/version-picker.js'] +html_css_files = [*globals().get('html_css_files', []), 'css/version-picker.css'] + +# CF-Workers builds inject DOCS_VERSION_SLUG (docs-build.yml); ReadTheDocs builds +# (until sunset) run plain Sphinx with no Pagefind step, so the Pagefind wrapper +# script + the searchbox.html override (docs/_templates/searchbox.html) must only +# activate for CF builds β€” otherwise RTD visitors hit a 404ing search mount. +_vyos_cf_build = bool(os.environ.get('DOCS_VERSION_SLUG')) +if _vyos_cf_build: + html_js_files = [*html_js_files, 'js/pagefind-wrapper.js'] + +# circinus keeps building on ReadTheDocs until RTD sunset and its CF slug is also +# `1.5`, so the baseurl is identical under both builders β€” the rolling branch's +# DOCS_VERSION_SLUG/READTHEDOCS_VERSION resolution block is deliberately not ported. html_baseurl = 'https://docs.vyos.io/en/1.5/' _rtd_version_type = os.environ.get('READTHEDOCS_VERSION_TYPE', '') @@ -139,6 +186,7 @@ html_context = { 'conf_py_path': '/docs/', 'gtm_id': os.environ.get('GTM_ID', ''), 'cookiebot_id': os.environ.get('COOKIEBOT_ID', ''), + 'vyos_cf_build': _vyos_cf_build, } # sphinx-sitemap: baseurl already includes /en/1.5/, so skip lang+version diff --git a/scripts/docs_gates/__init__.py b/scripts/docs_gates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scripts/docs_gates/conftest.py b/scripts/docs_gates/conftest.py new file mode 100644 index 00000000..38d6127e --- /dev/null +++ b/scripts/docs_gates/conftest.py @@ -0,0 +1,55 @@ +"""Shared pytest fixtures for docs_gates tests. + +Provides a real local HTTP server (stdlib http.server, no TLS) used to exercise the +_NoRedirect opener pattern (parity.py / smoke.py) end-to-end over an actual network +round trip, rather than only unit-testing the handler class in isolation. +""" +from __future__ import annotations + +import threading +from collections.abc import Iterator +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +REDIRECT_PATH = "/redirect-me" +REDIRECT_LOCATION = "https://example.invalid/target" + + +class _RedirectHandler(BaseHTTPRequestHandler): + """301+Location for REDIRECT_PATH; 200 for anything else.""" + + def do_GET(self) -> None: # noqa: N802 β€” stdlib handler method name + self._respond() + + def do_HEAD(self) -> None: # noqa: N802 + self._respond() + + def _respond(self) -> None: + if self.path == REDIRECT_PATH: + self.send_response(301) + self.send_header("Location", REDIRECT_LOCATION) + self.end_headers() + else: + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.end_headers() + if self.command == "GET": + self.wfile.write(b"ok") + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 β€” quiet test output + pass + + +@pytest.fixture +def redirect_http_server() -> Iterator[str]: + """Starts the server on 127.0.0.1 (ephemeral port); yields 'host:port'.""" + server = HTTPServer(("127.0.0.1", 0), _RedirectHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"127.0.0.1:{server.server_address[1]}" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) diff --git a/scripts/docs_gates/critical-pages.txt b/scripts/docs_gates/critical-pages.txt new file mode 100644 index 00000000..1bd1a171 --- /dev/null +++ b/scripts/docs_gates/critical-pages.txt @@ -0,0 +1,9 @@ +# Paths relative to en// that must exist in every deployable build. +# Verified 2026-07-10 against a real `sphinx-build -b html docs docs/_build/html-verify` +# of the `rolling` tree (Python 3.12 venv; coverage.md excluded β€” pre-existing, +# unrelated CfgcmdList/HTML5Translator crash, not touched by this task). +index.html +installation/index.html +configuration/index.html +cli.html +search.html diff --git a/scripts/docs_gates/gates.py b/scripts/docs_gates/gates.py new file mode 100644 index 00000000..7b5cbf1d --- /dev/null +++ b/scripts/docs_gates/gates.py @@ -0,0 +1,96 @@ +"""Deploy-blocking sanity gates (spec Β§7.1). + +Gates: file-count vs plan cap (<= 80% of 100k), per-file < 25 MiB, index.html + +critical-page presence, page-count delta vs previous deploy, canonical URLs must +start with the https://docs.vyos.io/en// prefix, declared PDF present, +Pagefind non-empty. +Exit 0 = deployable; exit 1 = blocked (one line per failed gate on stderr). +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +FILE_CAP = 100_000 +CAP_FRACTION = 0.8 +MAX_FILE = 25 * 1024 * 1024 +COUNT_DELTA_MIN_RATIO = 0.5 # new build must have >= 50% of previous page count +CANONICAL_RE = re.compile(r' None: + msgs.append(msg) + print(f"GATE-FAIL: {msg}", file=sys.stderr) + + +def run(artifact: Path, slug: str, versions: Path, previous_meta: Path | None, + critical: list[str]) -> int: + fails: list[str] = [] + root = artifact / "en" / slug + manifest = json.loads(versions.read_text()) + entry = next((v for v in manifest["versions"] if v["slug"] == slug), None) + if entry is None: + _fail(fails, f"slug {slug} not in versions.json") + return 1 + + files = [p for p in artifact.rglob("*") if p.is_file()] + if len(files) > FILE_CAP * CAP_FRACTION: + _fail(fails, f"file count {len(files)} > 80% of {FILE_CAP} cap") + for p in files: + if p.stat().st_size > MAX_FILE: + _fail(fails, f"{p.relative_to(artifact)} exceeds 25 MiB") + + for rel in ["index.html", *critical]: + if not (root / rel).is_file(): + _fail(fails, f"critical page missing: en/{slug}/{rel}") + + pagefind = root / "pagefind" + if not pagefind.is_dir() or not any(pagefind.iterdir()): + _fail(fails, "Pagefind index missing or empty") + + if entry.get("pdf"): + expected = artifact / entry["pdf"].lstrip("/") + if not expected.is_file(): + _fail(fails, f"declared PDF missing: {entry['pdf']}") + + pages = [p for p in root.rglob("*.html")] + if previous_meta is not None and previous_meta.is_file(): + prev = json.loads(previous_meta.read_text()) + if prev.get("page_count") and len(pages) < prev["page_count"] * COUNT_DELTA_MIN_RATIO: + _fail(fails, f"page count collapsed: {len(pages)} vs previous {prev['page_count']}") + + want = f"https://docs.vyos.io/en/{slug}/" + for p in pages: + m = CANONICAL_RE.search(p.read_text(errors="ignore")) + if m is None: + _fail(fails, f"missing canonical link in en/{slug}/{p.relative_to(root)}") + break # one example is enough to block + if not m.group(1).startswith(want): + _fail(fails, f"bad canonical in en/{slug}/{p.relative_to(root)}: {m.group(1)}") + break # one example is enough to block + + return 1 if fails else 0 + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--artifact", type=Path, required=True) + ap.add_argument("--slug", required=True) + ap.add_argument("--versions", type=Path, required=True) + ap.add_argument("--previous-meta", type=Path, default=None) + ap.add_argument("--critical-list", type=Path, + default=Path("scripts/docs_gates/critical-pages.txt")) + a = ap.parse_args() + # Strip BEFORE the comment test: an indented " # note" line is a comment, not a page + # every deployable build must contain β€” the unstripped test turned it into a live entry, + # and a comment can never exist as a file, so it would block the deploy as a missing page. + lines = (line.strip() for line in a.critical_list.read_text().splitlines()) + critical = [line for line in lines if line and not line.startswith("#")] + return run(a.artifact, a.slug, a.versions, a.previous_meta, critical) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/docs_gates/parity.py b/scripts/docs_gates/parity.py new file mode 100644 index 00000000..c30536db --- /dev/null +++ b/scripts/docs_gates/parity.py @@ -0,0 +1,253 @@ +"""URL-parity corpus + alias assertions (spec Β§11). + +Modes: + --sitemap-host X pull per-version sitemaps from X (RTD pre-cutover) + --probe-host Y probe every URL on Y (canary via Access, or production) +Fails (exit 1) on any status mismatch (non-200 for corpus rows; wrong +Location for alias rows). +""" +from __future__ import annotations + +import argparse +import dataclasses +import json +import os +import re +import sys +import urllib.parse +import urllib.request +from pathlib import Path + +SITEMAP_LOC = re.compile(r"([^<]+)") + +# Sitemap sweep covers only the versions THIS repo builds on CF (rolling/1.5/1.4). +# 1.3/1.2 have NO RTD sitemaps (spec Β§15a.5) β€” their parity is the legacy snapshot +# repo's crawl-inventory job. Their alias/PDF redirect rows below stay in scope. +DEFAULT_SLUGS = "rolling,1.5,1.4" + +ALIASES = [("latest", "rolling"), ("stable", "1.5"), ("lts", "1.5"), + ("circinus", "1.5"), ("sagitta", "1.4"), ("equuleus", "1.3"), ("crux", "1.2")] + + +def urls_from_sitemap(xml: str) -> list[str]: + out = [] + for loc in SITEMAP_LOC.findall(xml): + path = re.sub(r"^https?://[^/]+", "", loc) + if path.startswith("/en/"): + out.append(path) + return out + + +def alias_corpus() -> list[tuple[str, int, str]]: + rows = [(f"/en/{a}/", 301, f"/en/{s}/") for a, s in ALIASES] + # 1.2 excluded: never had an RTD PDF artifact (Phase-0 finding, spec Β§15a) β€” pdf: null + rows += [(f"/_/downloads/en/{s}/pdf/", 301, f"/en/{s}/vyos-documentation.pdf") + for s in ["rolling", "1.5", "1.4", "1.3"]] + rows.append(("/_/downloads/en/latest/pdf/", 301, "/en/rolling/vyos-documentation.pdf")) + return rows + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """The parity checker must SEE 301s, not follow them (alias assertions).""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401 + return None + + +_OPENER = urllib.request.build_opener(_NoRedirect) + +# Overridable in tests (monkeypatched to "http") so fetch() can be exercised end-to-end +# against a real local http.server instead of requiring TLS for a unit test. +_SCHEME = "https" + + +# The port each scheme already implies, so `host` and `host:443` are not two origins. +_DEFAULT_PORTS = {"https": 443, "http": 80} + + +def _authority(value: str) -> tuple[str, str | None, int | None]: + """The normalized ORIGIN of a full URL or of a bare `host[:port]` argument. + + An origin is scheme + host + port, and all three are returned: a credential scoped to an + https host must not match a plaintext http URL. Dropping the scheme made + `Access("p.invalid", ...)` apply to `http://p.invalid/`, so the ONE choke point that + decides whether to attach the service token would have attached it to a cleartext + request. Nothing constructs such a URL today β€” every URL in this module is built from + _SCHEME, so a single run is single-scheme β€” but the scope of a credential should not + depend on that staying true. + + Two spellings of one origin still have to compare equal, because the two sides of this + comparison come from different places: one is a URL this module built, the other is + whatever an operator typed after --probe-host. Comparing (hostname, port) verbatim made + `p.invalid` and `p.invalid:443` distinct, so spelling the default port out cost the + credential its own scope β€” post-cutover, where --sitemap-host and --probe-host name the + same Access-gated host, that silently 403'd every sitemap fetch and the sweep then + reported an empty corpus as a pass. Normalized here: + + * case β€” `scheme` and `hostname` are already lowercased by urlsplit; kept explicit for + the reader. + * the root label's trailing dot β€” `p.invalid.` names the same host as `p.invalid`. + * the scheme's default port β†’ None, so `:443` under https (or `:80` under http) is not + a separate authority. Folded against the origin's OWN scheme, so http `:80` and + https `:443` stay the distinct origins they are. + * a bare argument carries no scheme, so it is read under _SCHEME β€” the scheme every + URL in this module is built with. + + Deliberately NOT normalized: IDN/punycode equivalence (`ΓΌnΓ―code.example` against its + `xn--` form). Both hosts here are ASCII literals passed by CI, idna encoding carries its + own failure modes, and the safe direction for a credential-scoping test is to leave a + Unicode spelling not matching its punycode one rather than to guess an equivalence. + """ + parts = urllib.parse.urlsplit(value if "://" in value else f"//{value}") + scheme = (parts.scheme or _SCHEME).lower() + host = parts.hostname.lower() if parts.hostname else None + if host and host.endswith("."): + host = host[:-1] + port = parts.port + if port is not None and port == _DEFAULT_PORTS.get(scheme): + port = None + return scheme, host, port + + +@dataclasses.dataclass(frozen=True) +class Access: + """A CF Access service token BOUND TO THE ONE HOST it may be presented to. + + The binding is the point. This run talks to two hosts that are not the same party: + --probe-host is our Access-gated canary, while --sitemap-host is (pre-cutover) + docs.vyos.io, still served by ReadTheDocs. Credentials modelled as a bare + (id, secret) tuple carry no notion of destination, so a single `if access:` test in + the request builder sent our service token to BOTH β€” handing it to a third party on + every nightly sitemap fetch. Pairing the secret with its host makes the destination + check part of the credential rather than a rule each call site has to remember. + """ + + host: str + client_id: str + # repr=False: the default dataclass repr renders every field, so a failed assertion, a + # debug print or any exception that interpolates an Access would put the service token + # verbatim into CI logs β€” which are durable and, for this repo, world-readable. The id + # stays: it names WHICH token without being the credential, and losing it would make a + # scoping failure much harder to read. Secret is fetched via the attribute, never shown. + client_secret: str = dataclasses.field(repr=False) + + def applies_to(self, url: str) -> bool: + """True only for a URL whose ORIGIN is this credential's host (see _authority).""" + return _authority(url) == _authority(self.host) + + +def build_request(url: str, access: Access | None, + method: str = "HEAD") -> urllib.request.Request: + """The ONE place that attaches CF Access credentials to a request. + + Every outbound request in this module goes through here, and the attach decision is + made PER DESTINATION, never per run. Two failure modes meet at this function and only + a host-scoped single choke point closes both: + + * Credential leak. The sitemap host and the probe host are different parties + pre-cutover; an unscoped `if access:` mailed our service token to ReadTheDocs + once a night. `Access.applies_to()` makes that structurally impossible. + * Split-brain. The sitemap fetch used to build its own bare Request, so pointing + --sitemap-host at the Access-gated canary 403'd every sitemap while the probe + requests worked. Post-cutover both flags name the same host, and because the + scoping test is on the URL rather than on which caller asked, that configuration + still gets credentialed sitemap fetches with no extra wiring. + """ + req = urllib.request.Request(url, method=method) + if access is not None and access.applies_to(url): + req.add_header("CF-Access-Client-Id", access.client_id) + req.add_header("CF-Access-Client-Secret", access.client_secret) + return req + + +def fetch(host: str, path: str, access: Access | None, method: str = "HEAD"): + req = build_request(f"{_SCHEME}://{host}{path}", access, method) + try: + with _OPENER.open(req, timeout=30) as r: + return r.status, r.headers.get("Location") + except urllib.error.HTTPError as e: # 3xx land here with the no-redirect handler + return e.code, e.headers.get("Location") + except Exception: # noqa: BLE001 β€” DNS blip/timeout fails THIS probe, not the run + return 0, None + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--sitemap-host", required=True) + ap.add_argument("--probe-host", required=True) + ap.add_argument("--slugs", default=DEFAULT_SLUGS) + ap.add_argument("--report", type=Path, default=Path("parity-report.json")) + a = ap.parse_args() + # CF Access service-token credentials are read ONLY from the environment. They were + # also accepted as --access-id/--access-secret flags; that is removed rather than + # merely discouraged, because a value passed in argv is readable from the process table + # for the lifetime of the process and is captured verbatim by `set -x` traces, crash + # dumps and CI process listings. No call site used the flags (both workflows export the + # env vars), so there is nothing to migrate and no ergonomic loss worth the exposure. + # Access itself stays OPTIONAL: the sitemap host may be a public origin needing no token. + access_id = os.environ.get("CF_ACCESS_CLIENT_ID", "") + access_secret = os.environ.get("CF_ACCESS_CLIENT_SECRET", "") + if bool(access_id) != bool(access_secret): + # Half a service token is never usable β€” every probe would 403 and the run would + # report a wholly misleading "parity broken". Names only, never the values. + print("CF Access needs BOTH an id and a secret, or neither " + "(CF_ACCESS_CLIENT_ID, CF_ACCESS_CLIENT_SECRET)", file=sys.stderr) + return 2 + # Bound to the PROBE host, and to nothing else. --probe-host is the host we own and + # gate with Access; --sitemap-host is whatever currently publishes the truth sitemaps, + # which pre-cutover is ReadTheDocs. Should the two flags name the same host β€” the + # post-cutover configuration β€” build_request() credentials the sitemap fetch too, + # because the test is on the destination and not on the call site. + access = Access(a.probe_host, access_id, access_secret) if access_id else None + failures: list[dict] = [] + checked = 0 + + for slug in a.slugs.split(","): + # ONE request per sitemap. This used to probe the status with fetch() and then fetch + # the whole document a second time β€” two full GETs of a multi-thousand-URL sitemap per + # slug β€” and the body fetch hard-coded "https://", so the _SCHEME override (the hook + # the tests use to drive this path against a local plain-HTTP server) was ignored. + # Two things the single-call rewrite must NOT lose: + # 1. The discarded pre-check asserted status == 200 exactly. _OPENER raises + # HTTPError for non-2xx (3xx included β€” it refuses to follow redirects), but it + # RETURNS normally for any other 2xx, so a sitemap answering 204/206 would yield + # an empty corpus and the gate would pass having probed nothing. The explicit + # status check below restores that strictness. + # 2. CF Access credentials WHEN β€” and only when β€” the sitemap host is the host the + # token belongs to. A bare Request here 403'd a --sitemap-host pointed at the + # Access-gated canary; an unconditionally credentialed one posted the token to + # ReadTheDocs. build_request() decides per destination and settles both. + try: + with _OPENER.open(build_request( + f"{_SCHEME}://{a.sitemap_host}/en/{slug}/sitemap.xml", access, "GET"), + timeout=30) as r: + if r.status != 200: + failures.append({"path": f"/en/{slug}/sitemap.xml", + "reason": f"sitemap status {r.status}"}) + continue + urls = urls_from_sitemap(r.read().decode()) + except Exception as e: # noqa: BLE001 β€” record per-slug, keep sweeping; report ALWAYS written + failures.append({"path": f"/en/{slug}/sitemap.xml", + "reason": f"sitemap fetch error: {e}"}) + continue + for path in urls: + checked += 1 + st, _ = fetch(a.probe_host, path, access) + if st != 200: + failures.append({"path": path, "reason": f"status {st}"}) + + for path, want_status, want_loc in alias_corpus(): + checked += 1 + st, loc = fetch(a.probe_host, path, access) + if st != want_status or (loc or "") != want_loc: + failures.append({"path": path, "reason": f"got {st} β†’ {loc}, want {want_status} β†’ {want_loc}"}) + + a.report.write_text(json.dumps({"checked": checked, "failures": failures}, indent=2)) + for f in failures: + print(f"PARITY-FAIL {f['path']}: {f['reason']}", file=sys.stderr) + print(f"checked={checked} failures={len(failures)}") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/docs_gates/smoke.py b/scripts/docs_gates/smoke.py new file mode 100644 index 00000000..6c526d98 --- /dev/null +++ b/scripts/docs_gates/smoke.py @@ -0,0 +1,236 @@ +"""Scoped pre-traffic smoke + post-promote probe (spec Β§7.1 steps 2/5). + +Probes ONE version's pages plus apex special paths through a host, presenting a +CF Access service token. Header contract (Β§3.3): content probes assert +X-Docs-Build == --expect-sha; apex probes assert X-Apex-Build presence only. + +Phase-2 obligation (authorized addition, not in the original spec text): the +version's index.html probe also asserts the response body carries the +`#vyos-search` mount div (docs/_templates/searchbox.html), guarding the +Pagefind gate's silent-degrade failure mode β€” a build that forgot to set +DOCS_VERSION_SLUG would otherwise ship stock RTD search without CI noticing. +""" +from __future__ import annotations + +import argparse +import dataclasses +import json +import os +import sys +import time +import urllib.request +from pathlib import Path + +APEX_PATHS = ["/versions.json", "/healthz", "/robots.txt", "/sitemap.xml"] +SEARCH_MOUNT_MARKER = 'id="vyos-search"' + +# Explicit UA so the gate never depends on a Cloudflare edge exemption for the default +# Python-urllib UA. The Browser Integrity Check blocked that UA until a skip rule was added; +# the gate must not silently rely on that rule surviving. +USER_AGENT = "vyos-docs-smoke/1.0 (+https://github.com/vyos/vyos-documentation)" + +# Round-based retry (module-level so tests can shrink them). A freshly-deployed worker version +# can lose a propagation race: for a few minutes a probe may be served by the PREVIOUS version +# (wrong status / stale X-Docs-Build). Rather than fail-fast, each round re-probes ONLY the +# still-failing probes β€” this preserves the full per-probe failure enumeration (diagnostic +# value) while adding at most (MAX_ROUNDS - 1) inter-round sleeps. Envelope widened to 5 rounds +# x 30s after an observed propagation wave outlasted 3 rounds x 20s (a path still stale at +# round 3): 4 x 30s = 2 min now covers the observed 1-2+ min waves. The green path still costs +# zero extra time (no retries), and DEADLINE_SECONDS=480 still bounds the worst case. +MAX_ROUNDS = 5 +RETRY_SLEEP_SECONDS = 30 +DEADLINE_SECONDS = 480 +# Per-socket-op timeout (connect + read), capped down to the remaining deadline budget on each +# probe so a probe that starts late cannot overshoot DEADLINE_SECONDS. Pages are small, so this +# socket-op timeout also bounds body reads adequately β€” no separate body-read deadline is needed. +PROBE_TIMEOUT_SECONDS = 30 + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """Probes assert an EXACT status per-path (200 or 404) β€” following a 3xx would + silently swap the probed status for whatever the redirect target returns, + masking an accidental redirect where a direct 200/404 was expected. Mirrors + parity.py's _NoRedirect/_OPENER pattern.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401 + return None + + +_OPENER = urllib.request.build_opener(_NoRedirect) + + +@dataclasses.dataclass +class Probe: + path: str + expect_status: int + assert_docs_build: bool + assert_apex_build: bool + assert_search_mount: bool = False + + +def probe_plan(slug: str, pdf: str | None, critical: list[str]) -> list[Probe]: + # `critical` may itself list "index.html" (it does in critical-pages.txt); drop it so the + # index page is probed exactly once β€” as plan[0], the sole search-mount probe below. + critical = [rel for rel in critical if rel != "index.html"] + plan = [Probe(f"/en/{slug}/{rel}", 200, True, False) for rel in ["index.html", *critical]] + plan.append(Probe(f"/en/{slug}/pagefind/pagefind.js", 200, True, False)) + if pdf: + # assert_docs_build=False: the PDF is the ONE content path that can legitimately be + # answered by the apex Worker instead of a branch content Worker. 1.3's PDF (29.2 MiB) + # exceeds the 25 MiB static-asset cap, so apex serves it straight from R2 (spec Β§5) and + # that response carries only etag / accept-ranges / content-type / content-length β€” + # X-Docs-Build is a content-Worker header apex never sets on it. Asserting it made the + # probe structurally unpassable for 1.3 (observed nightly: "detail=status+docs-build"). + # The build SHA is still gated for this version: every HTML probe above asserts it. + plan.append(Probe(pdf, 200, False, False)) + plan.append(Probe(f"/en/{slug}/definitely-missing-page-xyz.html", 404, False, False)) + plan += [Probe(p, 200, False, True) for p in APEX_PATHS] + plan[0].assert_search_mount = True # plan[0] is always /en//index.html + return plan + + +def docs_build_ok(header_value: str | None, expect_sha: str) -> bool: + """SKIP sentinel (nightly sweep): header presence only; otherwise exact match.""" + if expect_sha == "SKIP": + return header_value is not None + return header_value == expect_sha + + +def search_mount_present(html: str) -> bool: + return SEARCH_MOUNT_MARKER in html + + +def _probe_once(host: str, probe: Probe, expect_sha: str, access_id: str, access_secret: str, + timeout: float = PROBE_TIMEOUT_SECONDS, + ) -> tuple[bool, int | None, str | None, str | None]: + """One probe attempt. Returns (ok, status, docs_build, detail). `detail` names the failed + check(s) β€” "status" / "docs-build" / "apex-build" / "search-mount" joined by "+", or the + transport error text β€” and is None when ok. `timeout` is the per-socket-op deadline (connect + + read); run() caps it to the remaining budget so a late probe can't overshoot the overall + deadline. ANY exception in the open OR body-read path (including a transport error DURING + HTTPError.read()) is contained and yields (False, None, None, ): a retryable + failure, never a traceback. The HTTPError response stream is always closed β€” it owns a + socket, so a bare e.read() would leak it.""" + req = urllib.request.Request(f"https://{host}{probe.path}", method="GET") + req.add_header("CF-Access-Client-Id", access_id) + req.add_header("CF-Access-Client-Secret", access_secret) + req.add_header("User-Agent", USER_AGENT) + try: + try: + with _OPENER.open(req, timeout=timeout) as resp: + status, headers, body = resp.status, resp.headers, resp.read() + except urllib.error.HTTPError as e: # non-2xx still carries headers/body + with e: # HTTPError is file-like and owns the response socket β€” always close it + status, headers, body = e.code, e.headers, e.read() + except Exception as e: # noqa: BLE001 β€” open OR read failure β†’ retryable probe result + return False, None, None, str(e) + reasons: list[str] = [] + if status != probe.expect_status: + reasons.append("status") + if probe.assert_docs_build and not docs_build_ok(headers.get("X-Docs-Build"), expect_sha): + reasons.append("docs-build") + if probe.assert_apex_build and not headers.get("X-Apex-Build"): + reasons.append("apex-build") + if probe.assert_search_mount and not search_mount_present( + body.decode("utf-8", errors="replace")): + reasons.append("search-mount") + detail = "+".join(reasons) if reasons else None + return not reasons, status, headers.get("X-Docs-Build"), detail + + +def run(host: str, slug: str, expect_sha: str, access_id: str, access_secret: str, + pdf: str | None, critical: list[str]) -> int: + """Probe the whole plan, then re-probe ONLY the still-failing probes each round (up to + MAX_ROUNDS, one RETRY_SLEEP_SECONDS between rounds). A probe passing in ANY round passes; + a single propagation blip served by the previous worker version cannot fail the gate. + DEADLINE_SECONDS bounds total wall-clock β€” on breach, unresolved probes count as failed.""" + plan = probe_plan(slug, pdf, critical) + start = time.monotonic() + deadline = start + DEADLINE_SECONDS # absolute β€” a hard upper bound on total wall-clock + pending = list(plan) # probes not yet passed + detail_by_path: dict[str, str] = {} # last failure detail per path, for logging + deadline_hit = False + + def _remaining() -> float: + return deadline - time.monotonic() + + for round_num in range(1, MAX_ROUNDS + 1): + if not pending: + break + still_failing: list[Probe] = [] + unprobed: list[Probe] = [] + for i, probe in enumerate(pending): + remaining = _remaining() # checked before each probe + if remaining < 1: # < 1s budget: don't start a probe that could overshoot + deadline_hit = True + unprobed = pending[i:] # not reached this round β†’ still unresolved + break + ok, status, docs_build, detail = _probe_once( + host, probe, expect_sha, access_id, access_secret, + timeout=min(PROBE_TIMEOUT_SECONDS, max(1, remaining))) + if ok: + continue + still_failing.append(probe) + detail_by_path[probe.path] = ( + f"status={status} docs-build={docs_build} detail={detail}") + pending = still_failing + unprobed + if deadline_hit or not pending or round_num == MAX_ROUNDS: + break + remaining = _remaining() # checked before the inter-round sleep + if remaining <= 0: # no budget left β†’ deadline path (skip the sleep) + deadline_hit = True + break + for probe in pending: + print(f"SMOKE-RETRY {probe.path}: round {round_num} " + f"{detail_by_path[probe.path]}", file=sys.stderr) + time.sleep(min(RETRY_SLEEP_SECONDS, remaining)) # never sleep past the deadline + + if deadline_hit: + print("SMOKE-DEADLINE: overall deadline reached β€” remaining probes counted as failed", + file=sys.stderr) + for probe in pending: + print(f"SMOKE-FAIL {probe.path}: {detail_by_path.get(probe.path, 'unresolved')}", + file=sys.stderr) + failures = len(pending) + print(json.dumps({"failures": failures})) + return 1 if failures else 0 + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--host", required=True) + ap.add_argument("--slug", required=True) + ap.add_argument("--expect-sha", required=True) + ap.add_argument("--pdf", default=None) + ap.add_argument("--critical-list", type=Path, + default=Path("scripts/docs_gates/critical-pages.txt")) + a = ap.parse_args() + # CF Access service-token credentials are read ONLY from the environment. They were also + # accepted as --access-id/--access-secret flags; that is removed rather than merely + # discouraged, because a value passed in argv publishes it in the process command line β€” + # readable from the process table for the lifetime of the process, and captured verbatim + # by `set -x` shell traces, crash dumps and process-listing tooling. No call site used + # the flags (docs-build.yml and docs-canary-qa.yml both export the env vars), so there is + # nothing to migrate. Neither value nor its length is ever echoed. + access_id = os.environ.get("CF_ACCESS_CLIENT_ID", "") + access_secret = os.environ.get("CF_ACCESS_CLIENT_SECRET", "") + missing = [name for name, value in ( + ("CF_ACCESS_CLIENT_ID", access_id), + ("CF_ACCESS_CLIENT_SECRET", access_secret)) if not value] + if missing: + # The canary host is Access-gated, so an empty credential would turn every probe into + # an indistinguishable 403 β€” fail loudly on the cause instead. Names only, no values. + print(f"missing CF Access credentials: {', '.join(missing)}", file=sys.stderr) + return 2 + # Strip BEFORE the comment test: an indented " # note" line is a comment, not a page that + # every deployable build must contain (it would fail the probe as a missing critical page). + # read_text() (rather than a bare open().read()) closes the handle deterministically, + # matching gates.py; the bare form leaked the descriptor until GC on any interpreter + # without CPython's refcounting. + lines = (line.strip() for line in a.critical_list.read_text().splitlines()) + critical = [line for line in lines if line and not line.startswith("#")] + return run(a.host, a.slug, a.expect_sha, access_id, access_secret, a.pdf, critical) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/docs_gates/test_gates.py b/scripts/docs_gates/test_gates.py new file mode 100644 index 00000000..e65be3af --- /dev/null +++ b/scripts/docs_gates/test_gates.py @@ -0,0 +1,122 @@ +import json +import sys +from pathlib import Path + +import pytest +from scripts.docs_gates import gates + + +@pytest.fixture() +def artifact(tmp_path: Path) -> Path: + root = tmp_path / "en" / "rolling" + root.mkdir(parents=True) + for i in range(50): + (root / f"p{i}.html").write_text( + f'x' + ) + (root / "index.html").write_text( + '' + ) + (root / "vyos-documentation.pdf").write_bytes(b"%PDF-1.4 fake") + (root / "pagefind").mkdir() + (root / "pagefind" / "pagefind.js").write_text("// index") + (root / "installation").mkdir() + (root / "installation" / "index.html").write_text( + '' + ) + return tmp_path + + +def versions_arg(tmp_path: Path) -> Path: + """Hermetic stand-in for workers/versions.json β€” writes a fixture-local + manifest with the minimal schema the gates consume (mirrors the real + file's shape for the rolling entry) so tests never depend on, or break + from, edits to the repo file.""" + p = tmp_path / "versions.json" + p.write_text(json.dumps({ + "schema_version": 2, + "default_lang": "en", + "default_version": "rolling", + "languages": [{"code": "en", "label": "English"}], + "versions": [ + {"slug": "rolling", "label": "Rolling (development)", "status": "dev", + "binding": "DOCS_ROLLING", "aliases": ["latest"], + "pdf": "/en/rolling/vyos-documentation.pdf"}, + ], + })) + return p + + +@pytest.fixture() +def versions(tmp_path: Path) -> Path: + return versions_arg(tmp_path) + + +def test_pass_on_good_artifact(artifact: Path, versions: Path): + rc = gates.run(artifact=artifact, slug="rolling", versions=versions, + previous_meta=None, critical=["index.html", "installation/index.html"]) + assert rc == 0 + + +def test_fail_on_missing_critical_page(artifact: Path, versions: Path): + (artifact / "en/rolling/installation/index.html").unlink() + rc = gates.run(artifact=artifact, slug="rolling", versions=versions, + previous_meta=None, critical=["index.html", "installation/index.html"]) + assert rc == 1 + + +def test_fail_on_count_collapse(artifact: Path, versions: Path, tmp_path: Path): + meta = tmp_path / "meta.json" + meta.write_text(json.dumps({"sha": "old", "page_count": 5000})) # previous build 100x bigger + rc = gates.run(artifact=artifact, slug="rolling", versions=versions, + previous_meta=meta, critical=["index.html"]) + assert rc == 1 + + +def test_fail_on_alias_canonical(artifact: Path, versions: Path): + (artifact / "en/rolling/bad.html").write_text( + '' + ) + rc = gates.run(artifact=artifact, slug="rolling", versions=versions, + previous_meta=None, critical=["index.html"]) + assert rc == 1 + + +def test_fail_on_missing_canonical(artifact: Path, versions: Path): + (artifact / "en/rolling/nocanon.html").write_text( + 'no canonical link at all' + ) + rc = gates.run(artifact=artifact, slug="rolling", versions=versions, + previous_meta=None, critical=["index.html"]) + assert rc == 1 + + +def test_fail_on_oversize_file(artifact: Path, versions: Path): + big = artifact / "en/rolling/huge.bin" + big.write_bytes(b"\0" * (26 * 1024 * 1024)) # > 25 MiB + rc = gates.run(artifact=artifact, slug="rolling", versions=versions, + previous_meta=None, critical=["index.html"]) + assert rc == 1 + + +def test_fail_when_declared_pdf_missing(artifact: Path, versions: Path): + (artifact / "en/rolling/vyos-documentation.pdf").unlink() + rc = gates.run(artifact=artifact, slug="rolling", versions=versions, + previous_meta=None, critical=["index.html"]) + assert rc == 1 + + +def test_critical_list_strips_before_testing_for_comments(monkeypatch, tmp_path, artifact): + # An INDENTED comment used to survive the `line.startswith("#")` test (applied to the + # UNSTRIPPED line) and become a live critical-page entry. A comment can never exist as a + # file, so it would block every deploy with "critical page missing: en/rolling/ # ...". + crit = tmp_path / "critical.txt" + crit.write_text("# leading comment\n # indented comment\n\n index.html \n") + seen: list[str] = [] + monkeypatch.setattr(gates, "run", + lambda art, slug, versions, prev, critical: seen.extend(critical) or 0) + monkeypatch.setattr(sys, "argv", [ + "gates", "--artifact", str(artifact), "--slug", "rolling", + "--versions", str(versions_arg(tmp_path)), "--critical-list", str(crit)]) + assert gates.main() == 0 + assert seen == ["index.html"] diff --git a/scripts/docs_gates/test_parity.py b/scripts/docs_gates/test_parity.py new file mode 100644 index 00000000..053eb79f --- /dev/null +++ b/scripts/docs_gates/test_parity.py @@ -0,0 +1,340 @@ +import json +import sys +import urllib.error +import urllib.request + +import pytest + +from scripts.docs_gates import parity +from scripts.docs_gates.conftest import REDIRECT_LOCATION, REDIRECT_PATH + + +def test_sitemap_url_extraction(): + xml = ('' + "https://docs.vyos.io/en/1.5/a.html" + "https://docs.vyos.io/en/1.5/b/") + assert parity.urls_from_sitemap(xml) == ["/en/1.5/a.html", "/en/1.5/b/"] + + +def test_alias_corpus_includes_pdf_and_alias_rows(): + rows = parity.alias_corpus() + assert ("/en/latest/", 301, "/en/rolling/") in rows + assert ("/_/downloads/en/1.5/pdf/", 301, "/en/1.5/vyos-documentation.pdf") in rows + + +def test_fetch_never_follows_redirects_unit(): + # unit-level sanity check on the handler class in isolation (kept alongside the + # end-to-end test below, which is what actually proves the opener is wired up) + handler = parity._NoRedirect() + assert handler.redirect_request(None, None, 301, "Moved", {}, "https://x/") is None + + +def test_fetch_never_follows_redirects(redirect_http_server, monkeypatch): + # End-to-end: real local HTTP server returns a 301, exercised through the PUBLIC + # fetch() entrypoint (not just the handler class) β€” proves _OPENER is actually + # wired into fetch() and surfaces (301, Location) instead of following it. + monkeypatch.setattr(parity, "_SCHEME", "http") + status, location = parity.fetch(redirect_http_server, REDIRECT_PATH, None, "GET") + assert status == 301 + assert location == REDIRECT_LOCATION + + +def test_default_slugs_scoped_to_cf_built_versions(): + # 1.3/1.2 have NO RTD sitemaps (spec Β§15a.5); legacy parity belongs to the + # snapshot repo's crawl-inventory job, not this sweep + assert parity.DEFAULT_SLUGS == "rolling,1.5,1.4" + + +def test_fetch_records_transport_error_as_status_zero(monkeypatch): + # a DNS blip / timeout must fail the single probe, not abort the whole run + class _Boom: + def open(self, req, timeout=None): + raise urllib.error.URLError("dns blip") + + monkeypatch.setattr(parity, "_OPENER", _Boom()) + assert parity.fetch("host.invalid", "/en/rolling/", None) == (0, None) + + +def test_main_always_writes_report_on_transport_errors(tmp_path, monkeypatch): + # sitemap status probe says 200, but the body fetch raises mid-sweep: + # the run must record per-slug failures, keep going, and STILL write the report + report = tmp_path / "parity-report.json" + monkeypatch.setattr(parity, "fetch", lambda *a, **k: (200, None)) + + def _boom(*a, **k): + raise urllib.error.URLError("timed out") + + monkeypatch.setattr(parity._OPENER, "open", _boom) + monkeypatch.setattr(sys, "argv", ["parity", "--sitemap-host", "sitemap.invalid", + "--probe-host", "probe.invalid", + "--report", str(report)]) + rc = parity.main() + assert rc == 1 + data = json.loads(report.read_text()) + assert data["failures"] # report written despite transport errors + assert any("sitemap" in f["reason"] for f in data["failures"]) + + +# --- CF Access credentials come from the ENVIRONMENT ONLY. The --access-id/--access-secret +# flags were REMOVED: an argv-passed secret is readable from the process table and captured +# by `set -x` traces. Access stays OPTIONAL here β€” the sitemap host may be public β€” but HALF +# a service token is never usable, so an id/secret mismatch is rejected outright. --- + +def _parity_argv(monkeypatch, tmp_path, *extra): + monkeypatch.setattr(sys, "argv", ["parity", "--sitemap-host", "s.invalid", + "--probe-host", "p.invalid", "--slugs", "rolling", + "--report", str(tmp_path / "r.json"), *extra]) + + +def test_access_credentials_default_from_environment(monkeypatch, tmp_path): + _parity_argv(monkeypatch, tmp_path) + monkeypatch.setenv("CF_ACCESS_CLIENT_ID", "env-id") + monkeypatch.setenv("CF_ACCESS_CLIENT_SECRET", "env-secret") + seen: list[parity.Access | None] = [] + + def _probe(host, path, access, method="HEAD"): + seen.append(access) + return 200, None + + monkeypatch.setattr(parity, "fetch", _probe) + monkeypatch.setattr(parity._OPENER, "open", + lambda *a, **k: _sitemap_response("")) + parity.main() + # scoped to --probe-host, which is the only host the token may ever be presented to + assert parity.Access("p.invalid", "env-id", "env-secret") in seen + + +def test_half_a_service_token_is_rejected(monkeypatch, tmp_path, capsys): + _parity_argv(monkeypatch, tmp_path) + monkeypatch.setenv("CF_ACCESS_CLIENT_ID", "only-an-id") + monkeypatch.delenv("CF_ACCESS_CLIENT_SECRET", raising=False) + monkeypatch.setattr(parity, "fetch", lambda *a, **k: (_ for _ in ()).throw( + AssertionError("must not probe with half a token"))) + assert parity.main() == 2 + assert "CF_ACCESS_CLIENT_SECRET" in capsys.readouterr().err + + +def test_secret_bearing_flags_are_rejected_not_silently_ignored(monkeypatch, tmp_path): + # The flags are GONE, not deprecated. argparse must reject them outright so an operator + # reaching for the old muscle-memory invocation gets an error instead of a run that + # silently ignores the credential they passed and then 403s on every probe. + for flag, value in (("--access-id", "an-id"), ("--access-secret", "a-secret")): + _parity_argv(monkeypatch, tmp_path, flag, value) + monkeypatch.setenv("CF_ACCESS_CLIENT_ID", "env-id") + monkeypatch.setenv("CF_ACCESS_CLIENT_SECRET", "env-secret") + with pytest.raises(SystemExit) as exc: + parity.main() + assert exc.value.code == 2 + + +# --- The sitemap used to be fetched TWICE per slug (a status probe via fetch(), then the +# body via a second GET) and the body fetch hard-coded "https://", ignoring _SCHEME. --- + +class _CountingSitemap: + """Records the Request objects the opener is handed, so a test can assert both the URL + (once per slug, honouring _SCHEME) and the CF Access headers actually attached to it.""" + + def __init__(self, status: int = 200) -> None: + self.requests: list[urllib.request.Request] = [] + self.status = status + + @property + def urls(self) -> list[str]: + return [r.full_url for r in self.requests] + + def __call__(self, req, *a, **k): + self.requests.append(req) + return _sitemap_response( + 'http://h/en/rolling/a.html', + status=self.status) + + +def _sitemap_response(body: str, status: int = 200): + class _R: + def __init__(self): + self.status = status + + def read(self): + return body.encode() + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + return _R() + + +def test_sitemap_fetched_once_per_slug_and_honours_the_scheme_override(monkeypatch, tmp_path): + _parity_argv(monkeypatch, tmp_path) + monkeypatch.delenv("CF_ACCESS_CLIENT_ID", raising=False) + monkeypatch.delenv("CF_ACCESS_CLIENT_SECRET", raising=False) + monkeypatch.setattr(parity, "_SCHEME", "http") + counter = _CountingSitemap() + monkeypatch.setattr(parity._OPENER, "open", counter) + monkeypatch.setattr(parity, "fetch", lambda *a, **k: (200, None)) + parity.main() + assert counter.urls == ["http://s.invalid/en/rolling/sitemap.xml"] # once, and NOT https + + +_ACCESS_HEADERS = ("Cf-access-client-id", "Cf-access-client-secret") # urllib capitalises + + +def test_sitemap_host_that_is_not_the_probe_host_gets_NO_access_headers(monkeypatch, tmp_path): + # THE credential-scoping assertion, and the inverse of what this test used to demand. + # Pre-cutover the two flags name different parties: --sitemap-host is docs.vyos.io, + # still served by ReadTheDocs, while --probe-host is our Access-gated canary. Crediting + # every outbound request "because the run holds a token" handed our CF Access service + # token to a host we do not control, once every night. + _parity_argv(monkeypatch, tmp_path) + monkeypatch.setenv("CF_ACCESS_CLIENT_ID", "env-id") + monkeypatch.setenv("CF_ACCESS_CLIENT_SECRET", "env-secret") + counter = _CountingSitemap() + monkeypatch.setattr(parity._OPENER, "open", counter) + monkeypatch.setattr(parity, "fetch", lambda *a, **k: (200, None)) + parity.main() + assert len(counter.requests) == 1 + req = counter.requests[0] + assert req.full_url.startswith("https://s.invalid/") # the third-party host + for header in _ACCESS_HEADERS: + assert req.get_header(header) is None + + +def test_sitemap_host_equal_to_the_probe_host_IS_credentialed(monkeypatch, tmp_path): + # The other direction, and the reason the scoping lives inside build_request() rather + # than at each call site: post-cutover both flags name the same Access-gated host and + # that sitemap fetch must still carry the token. A bare Request here (the shape before + # round 2) 403'd every sitemap, and the sweep then reported an empty corpus as a pass. + monkeypatch.setattr(sys, "argv", ["parity", "--sitemap-host", "p.invalid", + "--probe-host", "p.invalid", "--slugs", "rolling", + "--report", str(tmp_path / "r.json")]) + monkeypatch.setenv("CF_ACCESS_CLIENT_ID", "env-id") + monkeypatch.setenv("CF_ACCESS_CLIENT_SECRET", "env-secret") + counter = _CountingSitemap() + monkeypatch.setattr(parity._OPENER, "open", counter) + monkeypatch.setattr(parity, "fetch", lambda *a, **k: (200, None)) + parity.main() + req = counter.requests[0] + assert req.get_header("Cf-access-client-id") == "env-id" + assert req.get_header("Cf-access-client-secret") == "env-secret" + + +def test_build_request_attaches_the_token_to_its_own_host_and_to_nothing_else(): + # build_request() in isolation: one Access object, many destinations. + access = parity.Access("p.invalid", "an-id", "a-secret") + own = parity.build_request("https://P.Invalid/en/rolling/", access) # case-insensitive + assert own.get_header("Cf-access-client-id") == "an-id" + assert own.get_header("Cf-access-client-secret") == "a-secret" + for other in ("https://s.invalid/en/rolling/", # a different host entirely + "https://p.invalid.evil.example/en/", # suffix-extended lookalike + "https://notp.invalid/en/", # prefix-extended lookalike + "https://p.invalid:8443/en/rolling/"): # same name, different authority + for header in _ACCESS_HEADERS: + assert parity.build_request(other, access).get_header(header) is None + for header in _ACCESS_HEADERS: # no token configured at all + assert parity.build_request("https://p.invalid/", None).get_header(header) is None + + +# --- The scoping test compares ORIGINS, not spellings. `p.invalid` and `p.invalid:443` are +# the same HTTPS origin, and so is the trailing-dot FQDN form; comparing (hostname, port) +# verbatim made all three distinct. The case that matters is post-cutover, where BOTH flags +# name the same host: write either one with an explicit `:443` and the sitemap fetch silently +# lost its token and 403'd β€” reverting the keeper case two tests up. --- + +def test_equivalent_spellings_of_one_origin_all_get_the_token(): + for host, url in (("p.invalid", "https://p.invalid:443/en/rolling/"), # default port explicit + ("p.invalid:443", "https://p.invalid/en/rolling/"), # ...and the reverse + ("p.invalid:443", "https://p.invalid:443/en/"), # explicit on both + ("p.invalid.", "https://p.invalid/en/rolling/"), # trailing-dot FQDN + ("p.invalid", "https://p.invalid./en/rolling/"), # ...and the reverse + ("P.INVALID.:443", "https://p.invalid/en/")): # every axis at once + req = parity.build_request(url, parity.Access(host, "an-id", "a-secret")) + assert req.get_header("Cf-access-client-id") == "an-id", f"{host} vs {url}" + assert req.get_header("Cf-access-client-secret") == "a-secret", f"{host} vs {url}" + + +def test_normalization_does_not_widen_the_scope_to_a_different_origin(): + # The inverse pin: normalizing the default port and the trailing dot must not smear the + # comparison into matching anything else. A non-default port stays a distinct origin in + # BOTH directions, and a trailing dot on a lookalike is still a lookalike. + for host, url in (("p.invalid", "https://s.invalid:443/en/"), # different host, :443 + ("p.invalid:8443", "https://p.invalid/en/"), # non-default on the cred + ("p.invalid", "https://p.invalid:8443/en/"), # non-default on the URL + ("p.invalid.", "https://p.invalid.evil.example./en/")): # dotted lookalike + for header in _ACCESS_HEADERS: + req = parity.build_request(url, parity.Access(host, "an-id", "a-secret")) + assert req.get_header(header) is None, f"{host} vs {url}" + + +def test_the_default_port_that_normalizes_is_the_one_for_the_scheme_in_use(monkeypatch): + # A bare `host[:port]` argument carries no scheme, so the default it is compared against + # is the scheme every URL in this module is built with (_SCHEME) β€” not a hard-coded 443. + # Under the http override the tests use, 80 is the default and 443 is a real distinct port. + monkeypatch.setattr(parity, "_SCHEME", "http") + token = parity.Access("p.invalid:80", "an-id", "a-secret") + assert parity.build_request("http://p.invalid/en/", token).get_header( + "Cf-access-client-id") == "an-id" + assert parity.build_request("http://p.invalid:443/en/", token).get_header( + "Cf-access-client-id") is None + + +def test_probe_host_written_with_an_explicit_port_still_credentials_its_own_sitemap( + monkeypatch, tmp_path): + # The end-to-end shape of the bug: post-cutover both flags name the same host, but one + # of them spells the default port out. Before origin normalization the sitemap request + # went out bare, 403'd behind Access, and the sweep reported an empty corpus as a pass. + monkeypatch.setattr(sys, "argv", ["parity", "--sitemap-host", "p.invalid", + "--probe-host", "p.invalid:443", "--slugs", "rolling", + "--report", str(tmp_path / "r.json")]) + monkeypatch.setenv("CF_ACCESS_CLIENT_ID", "env-id") + monkeypatch.setenv("CF_ACCESS_CLIENT_SECRET", "env-secret") + counter = _CountingSitemap() + monkeypatch.setattr(parity._OPENER, "open", counter) + monkeypatch.setattr(parity, "fetch", lambda *a, **k: (200, None)) + parity.main() + req = counter.requests[0] + assert req.get_header("Cf-access-client-id") == "env-id" + assert req.get_header("Cf-access-client-secret") == "env-secret" + + +def test_a_plaintext_http_url_never_gets_an_https_scoped_token(): + # An ORIGIN is scheme + host + port. Comparing only (host, port) left the transport out + # of the credential's scope, so a token bound to an https host also applied to the + # cleartext http URL of the same name β€” the choke point would have attached the service + # token to a request that puts it on the wire in plaintext. `http://p.invalid:80/` is the + # sharp case: 80 folds to None under http, so the authority-only comparison matched the + # https-scoped ("p.invalid", None) exactly. + access = parity.Access("p.invalid", "an-id", "a-secret") # bare host β†’ _SCHEME (https) + for url in ("http://p.invalid/en/rolling/", "http://p.invalid:80/en/rolling/"): + for header in _ACCESS_HEADERS: + assert parity.build_request(url, access).get_header(header) is None, url + # control, same test: its own scheme still gets the token + assert parity.build_request("https://p.invalid/en/rolling/", access).get_header( + "Cf-access-client-id") == "an-id" + + +def test_the_service_token_is_not_rendered_by_repr(): + # The default dataclass repr renders every field. A failed assertion, a debug print or an + # exception that interpolates an Access would then put the token into CI output, which is + # durable. str() delegates to __repr__, so it covers f-string interpolation too. + access = parity.Access("p.invalid", "an-id", "sekrit-must-not-be-rendered") + for rendered in (repr(access), str(access), f"{access}"): + assert "sekrit-must-not-be-rendered" not in rendered + assert access.client_secret == "sekrit-must-not-be-rendered" # still readable as a field + assert "an-id" in repr(access) # the id is NOT the credential; keep it for diagnosis + + +def test_non_200_sitemap_is_a_failure_not_an_empty_corpus(monkeypatch, tmp_path): + # _OPENER only raises for non-2xx. A sitemap answering 204 (or any other 2xx) returned + # normally with an empty/irrelevant body, so the corpus came back empty and the parity + # gate PASSED having probed nothing at all β€” the exact silent-degrade the discarded + # exact-200 pre-check existed to prevent. + _parity_argv(monkeypatch, tmp_path) + monkeypatch.delenv("CF_ACCESS_CLIENT_ID", raising=False) + monkeypatch.delenv("CF_ACCESS_CLIENT_SECRET", raising=False) + report = tmp_path / "r.json" + monkeypatch.setattr(parity._OPENER, "open", _CountingSitemap(status=204)) + monkeypatch.setattr(parity, "fetch", lambda *a, **k: (200, None)) + assert parity.main() == 1 + data = json.loads(report.read_text()) + assert any(f["reason"] == "sitemap status 204" for f in data["failures"]) diff --git a/scripts/docs_gates/test_smoke.py b/scripts/docs_gates/test_smoke.py new file mode 100644 index 00000000..790f953d --- /dev/null +++ b/scripts/docs_gates/test_smoke.py @@ -0,0 +1,483 @@ +from __future__ import annotations + +import io +import sys +import urllib.error +import urllib.request +from email.message import Message +from urllib.parse import urlsplit + +import pytest + +from scripts.docs_gates import smoke +from scripts.docs_gates.conftest import REDIRECT_LOCATION, REDIRECT_PATH + + +def test_probe_plan_scoped_to_slug(): + plan = smoke.probe_plan("1.5", pdf="/en/1.5/vyos-documentation.pdf", + critical=["index.html", "cli/index.html"]) + urls = [p.path for p in plan] + assert "/en/1.5/index.html" in urls + assert "/en/1.5/cli/index.html" in urls + assert "/en/1.5/vyos-documentation.pdf" in urls + assert "/en/1.5/definitely-missing-page-xyz.html" in urls # 404-status probe + assert "/versions.json" in urls and "/healthz" in urls # apex specials + assert not any(u.startswith("/en/rolling/") for u in urls) # scoped (Β§7.1.2) + + +def test_assertions_follow_header_contract(): + plan = smoke.probe_plan("1.5", pdf=None, critical=["index.html"]) + content = next(p for p in plan if p.path == "/en/1.5/index.html") + assert content.expect_status == 200 and content.assert_docs_build is True + apex = next(p for p in plan if p.path == "/versions.json") + assert apex.assert_docs_build is False and apex.assert_apex_build is True + missing = next(p for p in plan if "definitely-missing" in p.path) + assert missing.expect_status == 404 + + +def test_skip_sentinel_relaxes_sha_to_presence_only(): + # expect_sha == "SKIP" (nightly sweep, Task 3.5): header must be PRESENT but any value passes + assert smoke.docs_build_ok("anything", expect_sha="SKIP") is True + assert smoke.docs_build_ok(None, expect_sha="SKIP") is False + assert smoke.docs_build_ok("abc", expect_sha="abc") is True + assert smoke.docs_build_ok("abc", expect_sha="def") is False + + +# --- Phase-2 obligation (authorized addition, not in the original brief): the +# index.html probe must assert the CF-built HTML carries the `#vyos-search` +# mount div, so CI catches a build that silently forgot to set +# DOCS_VERSION_SLUG (which would ship stock RTD search instead of Pagefind). --- + +def test_probe_plan_asserts_search_mount_on_index_only(): + plan = smoke.probe_plan("1.5", pdf=None, critical=["index.html", "cli/index.html"]) + index = next(p for p in plan if p.path == "/en/1.5/index.html") + assert index.assert_search_mount is True + other_content = [p for p in plan if p.path != "/en/1.5/index.html" and p.assert_docs_build] + assert other_content and all(p.assert_search_mount is False for p in other_content) + + +# --- CodeRabbit finding: smoke's probe requests must mirror parity.py's _NoRedirect +# opener β€” an exact-status probe (200/404) that silently followed a 3xx would report +# whatever the redirect target returns instead of the redirect itself. --- + +def test_opener_observes_redirect_directly_not_followed(redirect_http_server): + # End-to-end: a real local HTTP server returns a 301, opened through smoke.py's + # module-level _OPENER (the exact object `run()` uses) β€” proves it's actually + # wired to refuse the redirect, matching run()'s HTTPError-catch handling of 3xx. + req = urllib.request.Request(f"http://{redirect_http_server}{REDIRECT_PATH}", method="GET") + try: + smoke._OPENER.open(req, timeout=5) + raise AssertionError("expected HTTPError for a 301 with the no-redirect opener") + except urllib.error.HTTPError as e: + assert e.code == 301 + assert e.headers.get("Location") == REDIRECT_LOCATION + + +def test_search_mount_present(): + assert smoke.search_mount_present('') is True + assert smoke.search_mount_present('no search here') is False + + +# --- Hardening: explicit UA + round-based retry with an overall deadline. Mock at the _OPENER +# boundary (the object _probe_once() opens through, mirroring the redirect test above which +# drives smoke._OPENER directly). run()-level round tests inject a small plan via probe_plan and +# a path-keyed opener; sleeps are spied (or constants shrunk) so nothing wall-clocks. --- + + +class _FakeResp: + """Stand-in for what _OPENER.open() yields: a context manager exposing .status / + .headers / .read().""" + + def __init__(self, status: int, headers: dict[str, str], body: bytes = b""): + self.status = status + self.headers = headers + self._body = body + + def read(self) -> bytes: + return self._body + + def __enter__(self) -> "_FakeResp": + return self + + def __exit__(self, *exc: object) -> bool: + return False + + +class _FakeOpener: + """Yields queued responses in order; an Exception item is raised (models a transport error, + or a non-2xx delivered as HTTPError). Records each opened Request + the timeout it was + called with, for assertions.""" + + def __init__(self, responses: list[object]): + self._responses = list(responses) + self.calls: list[urllib.request.Request] = [] + self.timeouts: list[float | None] = [] + + def open(self, req: urllib.request.Request, timeout: float | None = None) -> object: + self.calls.append(req) + self.timeouts.append(timeout) + item = self._responses.pop(0) + if isinstance(item, Exception): + raise item + return item + + +class _RecordingBody(io.BytesIO): + """HTTPError.fp stand-in: records close() (so tests can assert the response stream is + closed) and can optionally raise on read (a transport error DURING body read). urllib's + addbase binds read through to fp and closes fp on close(), so overriding them here is what + e.read() / closing e actually hit.""" + + def __init__(self, data: bytes = b"", raise_on_read: bool = False): + super().__init__(data) + self.close_calls = 0 + self._raise_on_read = raise_on_read + + def read(self, *args: object) -> bytes: # noqa: D401 + if self._raise_on_read: + raise OSError("reset during body read") + return super().read(*args) + + def close(self) -> None: + self.close_calls += 1 + super().close() + + +def _http_error_read_boom(code: int) -> urllib.error.HTTPError: + return urllib.error.HTTPError( + "https://host.invalid/x", code, "msg", Message(), _RecordingBody(raise_on_read=True)) + + +class _PathOpener: + """Opener keyed by request path: each path maps to a queue consumed one item per probe of + that path (item = _FakeResp, or an Exception that is raised). Records probed paths in order, + so round scoping / retry counts are assertable across rounds that re-probe only failures.""" + + def __init__(self, by_path: dict[str, list[object]]): + self._by_path = {p: list(v) for p, v in by_path.items()} + self.calls: list[str] = [] + self.timeouts: list[float | None] = [] + + def open(self, req: urllib.request.Request, timeout: float | None = None) -> object: + path = urlsplit(req.full_url).path + self.calls.append(path) + self.timeouts.append(timeout) + item = self._by_path[path].pop(0) + if isinstance(item, Exception): + raise item + return item + + +def _plan(paths: list[str]) -> list[smoke.Probe]: + """Minimal status-only plan (no build/search assertions) for run() round tests.""" + return [smoke.Probe(p, 200, assert_docs_build=False, assert_apex_build=False) for p in paths] + + +def test_probe_sends_explicit_user_agent(monkeypatch): + opener = _FakeOpener([_FakeResp(200, {"X-Docs-Build": "sha1"})]) + monkeypatch.setattr(smoke, "_OPENER", opener) + probe = smoke.Probe("/en/1.5/index.html", 200, assert_docs_build=True, assert_apex_build=False) + ok, *_ = smoke._probe_once("host.example", probe, "sha1", "cf-id", "cf-secret") + assert ok is True + req = opener.calls[0] + assert req.get_header("User-agent") == smoke.USER_AGENT # urllib capitalizes the key + assert req.get_header("Cf-access-client-id") == "cf-id" # CF-Access headers still sent + + +def test_transport_error_recovers_in_second_round(monkeypatch): + monkeypatch.setattr(smoke, "probe_plan", + lambda slug, pdf, critical: _plan(["/en/rolling/index.html"])) + monkeypatch.setattr(smoke, "RETRY_SLEEP_SECONDS", 0) + opener = _PathOpener({ + "/en/rolling/index.html": [OSError("dns hiccup"), _FakeResp(200, {})], + }) + monkeypatch.setattr(smoke, "_OPENER", opener) + assert smoke.run("host", "rolling", "sha", "id", "sec", None, []) == 0 + # round 1 transport error, round 2 success β€” the same path is re-probed + assert opener.calls == ["/en/rolling/index.html", "/en/rolling/index.html"] + + +def test_httperror_read_crash_is_contained_as_failure(monkeypatch): + # agy-critical: a transport error DURING e.read() must not escape as a traceback. + monkeypatch.setattr(smoke, "_OPENER", _FakeOpener([_http_error_read_boom(502)])) + probe = smoke.Probe("/en/rolling/index.html", 200, + assert_docs_build=False, assert_apex_build=False) + ok, status, docs_build, detail = smoke._probe_once("host", probe, "sha", "id", "sec") + assert ok is False + assert status is None and docs_build is None + assert detail is not None and "reset during body read" in detail + + +def test_sleeps_once_per_inter_round_gap_not_per_probe(monkeypatch): + monkeypatch.setattr(smoke, "probe_plan", + lambda slug, pdf, critical: _plan(["/a", "/b", "/c"])) + sleeps: list[float] = [] + monkeypatch.setattr(smoke.time, "sleep", lambda s: sleeps.append(s)) + # /a and /b fail round 1 then pass round 2; /c passes round 1 + opener = _PathOpener({ + "/a": [_FakeResp(500, {}), _FakeResp(200, {})], + "/b": [_FakeResp(500, {}), _FakeResp(200, {})], + "/c": [_FakeResp(200, {})], + }) + monkeypatch.setattr(smoke, "_OPENER", opener) + assert smoke.run("host", "rolling", "sha", "id", "sec", None, []) == 0 + assert sleeps == [smoke.RETRY_SLEEP_SECONDS] # ONE inter-round sleep despite 2 failing probes + + +def test_second_round_reprobes_only_the_failed_path(monkeypatch): + monkeypatch.setattr(smoke, "probe_plan", + lambda slug, pdf, critical: _plan(["/a", "/b", "/c"])) + monkeypatch.setattr(smoke.time, "sleep", lambda s: None) + opener = _PathOpener({ + "/a": [_FakeResp(200, {})], # passes round 1 + "/b": [_FakeResp(500, {}), _FakeResp(200, {})], # fails r1, passes r2 + "/c": [_FakeResp(200, {})], # passes round 1 + }) + monkeypatch.setattr(smoke, "_OPENER", opener) + assert smoke.run("host", "rolling", "sha", "id", "sec", None, []) == 0 + assert opener.calls == ["/a", "/b", "/c", "/b"] # only the failed path is re-probed + + +def test_run_reports_failure_count_and_nonzero_exit(monkeypatch, capsys): + monkeypatch.setattr(smoke, "probe_plan", lambda slug, pdf, critical: _plan(["/a", "/b"])) + monkeypatch.setattr(smoke, "MAX_ROUNDS", 1) # single round, no retries + monkeypatch.setattr(smoke, "_OPENER", _PathOpener({ + "/a": [_FakeResp(200, {})], + "/b": [_FakeResp(500, {})], + })) + assert smoke.run("host", "rolling", "sha", "id", "sec", None, []) == 1 + assert '{"failures": 1}' in capsys.readouterr().out + + +def test_run_reports_clean_and_zero_exit(monkeypatch, capsys): + monkeypatch.setattr(smoke, "probe_plan", lambda slug, pdf, critical: _plan(["/a", "/b"])) + monkeypatch.setattr(smoke, "_OPENER", _PathOpener({ + "/a": [_FakeResp(200, {})], + "/b": [_FakeResp(200, {})], + })) + assert smoke.run("host", "rolling", "sha", "id", "sec", None, []) == 0 + assert '{"failures": 0}' in capsys.readouterr().out + + +def test_deadline_counts_unresolved_as_failed(monkeypatch, capsys): + monkeypatch.setattr(smoke, "probe_plan", lambda slug, pdf, critical: _plan(["/a", "/b"])) + monkeypatch.setattr(smoke, "DEADLINE_SECONDS", 0) # trips before the first probe + opener = _PathOpener({"/a": [_FakeResp(200, {})], "/b": [_FakeResp(200, {})]}) + monkeypatch.setattr(smoke, "_OPENER", opener) + assert smoke.run("host", "rolling", "sha", "id", "sec", None, []) == 1 + captured = capsys.readouterr() + assert "SMOKE-DEADLINE" in captured.err + assert '{"failures": 2}' in captured.out + assert opener.calls == [] # deadline reached before any probe ran + + +def test_probe_plan_dedups_index_html(): + plan = smoke.probe_plan("rolling", None, ["index.html", "cli.html"]) + index_probes = [p for p in plan if p.path == "/en/rolling/index.html"] + assert len(index_probes) == 1 # not duplicated by the critical list + assert index_probes[0].assert_search_mount is True # still the single search-mount probe + assert sum(1 for p in plan if p.path == "/en/rolling/cli.html") == 1 # cli.html preserved + + +# --- CR round 2: close the HTTPError response stream (it owns a socket) + name the failed +# assertion in retry/fail logs. --- + +def test_httperror_response_is_closed(monkeypatch): + # HTTPError owns the response socket; the expected-404 path must close it, not leak. + body = _RecordingBody(b"not found") + monkeypatch.setattr(smoke, "_OPENER", _FakeOpener([ + urllib.error.HTTPError("https://host.invalid/x", 404, "msg", Message(), body)])) + probe = smoke.Probe("/en/rolling/missing.html", 404, + assert_docs_build=False, assert_apex_build=False) + ok, *_ = smoke._probe_once("host", probe, "sha", "id", "sec") + assert ok is True # 404 expected β†’ passes + assert body.close_calls >= 1 # response stream closed (no socket leak / ResourceWarning) + + +def test_httperror_response_is_closed_even_when_read_raises(monkeypatch): + body = _RecordingBody(raise_on_read=True) + monkeypatch.setattr(smoke, "_OPENER", _FakeOpener([ + urllib.error.HTTPError("https://host.invalid/x", 502, "msg", Message(), body)])) + probe = smoke.Probe("/en/rolling/index.html", 200, + assert_docs_build=False, assert_apex_build=False) + ok, _, _, detail = smoke._probe_once("host", probe, "sha", "id", "sec") + assert ok is False + assert detail is not None and "reset during body read" in detail + assert body.close_calls >= 1 # close still attempted despite the read raising + + +def test_apex_build_failure_is_named_in_logs(monkeypatch, capsys): + # 200 but no X-Apex-Build: without a named detail the log read "status=200 docs-build=None". + probe = smoke.Probe("/versions.json", 200, assert_docs_build=False, assert_apex_build=True) + monkeypatch.setattr(smoke, "probe_plan", lambda slug, pdf, critical: [probe]) + monkeypatch.setattr(smoke, "MAX_ROUNDS", 1) + monkeypatch.setattr(smoke, "_OPENER", _PathOpener({"/versions.json": [_FakeResp(200, {})]})) + assert smoke.run("host", "rolling", "sha", "id", "sec", None, []) == 1 + err = capsys.readouterr().err + assert "SMOKE-FAIL /versions.json:" in err + assert "detail=apex-build" in err # names the failed check + assert "status=200" in err # existing fields preserved + + +def test_search_mount_failure_is_named_in_logs(monkeypatch, capsys): + # 200 + correct build, but the HTML lacks the #vyos-search mount div. + probe = smoke.Probe("/en/rolling/index.html", 200, assert_docs_build=True, + assert_apex_build=False, assert_search_mount=True) + monkeypatch.setattr(smoke, "probe_plan", lambda slug, pdf, critical: [probe]) + monkeypatch.setattr(smoke, "MAX_ROUNDS", 1) + monkeypatch.setattr(smoke, "_OPENER", _PathOpener({ + "/en/rolling/index.html": [_FakeResp(200, {"X-Docs-Build": "goodsha"}, + b"no search")]})) + assert smoke.run("host", "rolling", "goodsha", "id", "sec", None, []) == 1 + assert "detail=search-mount" in capsys.readouterr().err + + +def test_probe_once_detail_joins_multiple_failed_checks(monkeypatch): + # wrong status AND missing X-Apex-Build β†’ detail "status+apex-build" + monkeypatch.setattr(smoke, "_OPENER", _FakeOpener([_FakeResp(500, {})])) + probe = smoke.Probe("/versions.json", 200, assert_docs_build=False, assert_apex_build=True) + ok, _, _, detail = smoke._probe_once("host", probe, "sha", "id", "sec") + assert ok is False + assert detail == "status+apex-build" + + +# --- Adversarial round: DEADLINE_SECONDS is a HARD bound β€” the per-probe socket timeout and the +# inter-round sleep are both capped to the remaining budget so neither can overshoot it. --- + +def test_probe_timeout_capped_to_remaining_budget(monkeypatch): + monkeypatch.setattr(smoke, "probe_plan", lambda slug, pdf, critical: _plan(["/a"])) + monkeypatch.setattr(smoke, "DEADLINE_SECONDS", 5) # << PROBE_TIMEOUT_SECONDS (30) + opener = _PathOpener({"/a": [_FakeResp(200, {})]}) + monkeypatch.setattr(smoke, "_OPENER", opener) + smoke.run("host", "rolling", "sha", "id", "sec", None, []) + assert opener.timeouts, "the probe recorded the timeout it was opened with" + t = opener.timeouts[0] + assert 1 <= t <= smoke.DEADLINE_SECONDS # capped DOWN to the ~5s remaining budget + assert t < smoke.PROBE_TIMEOUT_SECONDS # NOT the full 30s socket timeout + + +def test_inter_round_sleep_capped_to_remaining_budget(monkeypatch): + monkeypatch.setattr(smoke, "probe_plan", lambda slug, pdf, critical: _plan(["/a"])) + monkeypatch.setattr(smoke, "DEADLINE_SECONDS", 10) # << RETRY_SLEEP_SECONDS (30) + sleeps: list[float] = [] + monkeypatch.setattr(smoke.time, "sleep", lambda s: sleeps.append(s)) + opener = _PathOpener({"/a": [_FakeResp(500, {}), _FakeResp(200, {})]}) # fail r1, pass r2 + monkeypatch.setattr(smoke, "_OPENER", opener) + assert smoke.run("host", "rolling", "sha", "id", "sec", None, []) == 0 + assert len(sleeps) == 1 + assert 0 < sleeps[0] <= smoke.DEADLINE_SECONDS # capped to the ~10s remaining budget + assert sleeps[0] < smoke.RETRY_SLEEP_SECONDS # NOT the full 30s sleep + + +# --- The PDF probe was structurally unpassable: it asserted X-Docs-Build, but 1.3's PDF is +# served by the APEX Worker straight from R2 (spec Β§5, 29.2 MiB > the 25 MiB asset cap) and +# that path sets only etag / accept-ranges / content-type / content-length. Observed nightly: +# "/en/1.3/vyos-documentation.pdf: status=206 docs-build=None detail=status+docs-build". --- + +def test_pdf_probe_does_not_assert_docs_build(): + plan = smoke.probe_plan("1.3", pdf="/en/1.3/vyos-documentation.pdf", critical=["index.html"]) + pdf = next(p for p in plan if p.path.endswith(".pdf")) + assert pdf.assert_docs_build is False # apex's R2 path legitimately never sets it + assert pdf.assert_apex_build is False # nor does the content Worker set X-Apex-Build + # ...but the build SHA is still gated for this version, via the HTML probes: + assert next(p for p in plan if p.path.endswith("/index.html")).assert_docs_build is True + + +def test_pdf_probe_still_demands_an_exact_200(): + # The 206 seen alongside the docs-build failure was an apex defect (a 206 answered to a + # request carrying no Range header), fixed in workers/apex/src/index.ts β€” NOT something + # this gate should learn to tolerate. + plan = smoke.probe_plan("1.3", pdf="/en/1.3/vyos-documentation.pdf", critical=[]) + assert next(p for p in plan if p.path.endswith(".pdf")).expect_status == 200 + + +# --- CF Access credentials: env by default (argv publishes secrets to the process table), +# flags as a manual fallback, and an empty value is rejected rather than sent as a blank +# header (every probe would then 403 and the report would blame the wrong thing). --- + +def _argv(monkeypatch, tmp_path, *extra): + crit = tmp_path / "critical.txt" + crit.write_text("index.html\n") + monkeypatch.setattr(sys, "argv", ["smoke", "--host", "h", "--slug", "rolling", + "--expect-sha", "SKIP", + "--critical-list", str(crit), *extra]) + return crit + + +def test_access_credentials_default_from_environment(monkeypatch, tmp_path): + _argv(monkeypatch, tmp_path) + monkeypatch.setenv("CF_ACCESS_CLIENT_ID", "env-id") + monkeypatch.setenv("CF_ACCESS_CLIENT_SECRET", "env-secret") + seen: dict[str, str] = {} + monkeypatch.setattr(smoke, "run", + lambda host, slug, sha, aid, asec, pdf, critical: + seen.update(id=aid, secret=asec) or 0) + assert smoke.main() == 0 + assert seen == {"id": "env-id", "secret": "env-secret"} + + +def test_secret_bearing_flags_are_rejected_not_silently_ignored(monkeypatch, tmp_path): + # --access-id/--access-secret are GONE, not deprecated: an argv-passed secret is readable + # from the process table and captured verbatim by `set -x` traces. argparse must reject + # them so the old muscle-memory invocation errors out instead of silently ignoring the + # credential the operator passed and then 403ing on every probe. + for flag, value in (("--access-id", "an-id"), ("--access-secret", "a-secret")): + _argv(monkeypatch, tmp_path, flag, value) + monkeypatch.setenv("CF_ACCESS_CLIENT_ID", "env-id") + monkeypatch.setenv("CF_ACCESS_CLIENT_SECRET", "env-secret") + monkeypatch.setattr(smoke, "run", lambda *a, **k: pytest.fail("must not probe")) + with pytest.raises(SystemExit) as exc: + smoke.main() + assert exc.value.code == 2 + + +def test_critical_list_is_read_through_a_path_without_resource_warnings(monkeypatch, tmp_path): + # `open(a.critical_list).read()` left the descriptor to be closed by GC; --critical-list + # is now `type=Path` and read via Path.read_text(), which closes deterministically. + # HONEST SCOPE: this is not a strict regression test for the close itself β€” CPython's + # refcounting also closes the bare-open form immediately, so no ResourceWarning fires + # either way and this test passes against the pre-fix source (verified). What it DOES + # pin is the argparse `type=Path` change (a str would have no .read_text()) plus + # warning-free reading on interpreters without refcounting, e.g. PyPy, where the + # bare-open form genuinely leaks until GC. + import warnings + + crit = _argv(monkeypatch, tmp_path) + monkeypatch.setenv("CF_ACCESS_CLIENT_ID", "id") + monkeypatch.setenv("CF_ACCESS_CLIENT_SECRET", "sec") + seen: list[str] = [] + monkeypatch.setattr(smoke, "run", + lambda host, slug, sha, aid, asec, pdf, critical: + seen.extend(critical) or 0) + with warnings.catch_warnings(): + warnings.simplefilter("error", ResourceWarning) + assert smoke.main() == 0 + assert seen == ["index.html"] + assert crit.exists() + + +def test_missing_access_credentials_fail_loudly_without_probing(monkeypatch, tmp_path, capsys): + _argv(monkeypatch, tmp_path) + monkeypatch.delenv("CF_ACCESS_CLIENT_ID", raising=False) + monkeypatch.delenv("CF_ACCESS_CLIENT_SECRET", raising=False) + monkeypatch.setattr(smoke, "run", lambda *a, **k: pytest.fail("must not probe")) + assert smoke.main() == 2 + err = capsys.readouterr().err + assert "CF_ACCESS_CLIENT_ID" in err and "CF_ACCESS_CLIENT_SECRET" in err + + +def test_critical_list_strips_before_testing_for_comments(monkeypatch, tmp_path): + # An INDENTED comment used to survive the `line.startswith("#")` test (applied to the + # unstripped line) and become a live critical page β€” which can never exist as a file. + crit = tmp_path / "critical.txt" + crit.write_text("# leading comment\n # indented comment\n\n cli.html \n") + monkeypatch.setenv("CF_ACCESS_CLIENT_ID", "id") + monkeypatch.setenv("CF_ACCESS_CLIENT_SECRET", "sec") + monkeypatch.setattr(sys, "argv", ["smoke", "--host", "h", "--slug", "rolling", + "--expect-sha", "SKIP", "--critical-list", str(crit)]) + seen: list[str] = [] + monkeypatch.setattr(smoke, "run", + lambda host, slug, sha, aid, asec, pdf, critical: + seen.extend(critical) or 0) + assert smoke.main() == 0 + assert seen == ["cli.html"] diff --git a/workers/.gitignore b/workers/.gitignore new file mode 100644 index 00000000..4b240bc1 --- /dev/null +++ b/workers/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.wrangler/ +dist/ +test-results/ diff --git a/workers/PLAN.md b/workers/PLAN.md new file mode 100644 index 00000000..c64cd382 --- /dev/null +++ b/workers/PLAN.md @@ -0,0 +1,5 @@ +# Cloudflare account entitlements (docs.vyos.io) + +- Plan: Workers Paid (pay-as-you-go); zone Enterprise on vyos.io ← from Phase-0 item 0.2; update on change +- Static-assets file cap in force: 100,000/version (Paid) β€” sanity gates assume this (scripts/docs_gates/limits.py) +- Plan-dependent features NOT assumed by config: Logpush, Bot Management, Observability retention diff --git a/workers/apex/assets/404.html b/workers/apex/assets/404.html new file mode 100644 index 00000000..6beae739 --- /dev/null +++ b/workers/apex/assets/404.html @@ -0,0 +1,23 @@ + + + + +Page not found β€” VyOS Documentation + + + + +
    +

    404

    +

    This page doesn't exist (or moved). Try the latest VyOS documentation.

    +
    + + diff --git a/workers/apex/assets/503.html b/workers/apex/assets/503.html new file mode 100644 index 00000000..60fdc6cf --- /dev/null +++ b/workers/apex/assets/503.html @@ -0,0 +1,24 @@ + + + + +Service unavailable β€” VyOS Documentation + + + + +
    +

    503

    +

    This version of the documentation is temporarily unavailable. Please try again shortly, or visit + vyos.io in the meantime.

    +
    + + diff --git a/workers/apex/assets/apple-touch-icon.png b/workers/apex/assets/apple-touch-icon.png new file mode 100644 index 00000000..82b8fb6b Binary files /dev/null and b/workers/apex/assets/apple-touch-icon.png differ diff --git a/workers/apex/assets/favicon.ico b/workers/apex/assets/favicon.ico new file mode 100644 index 00000000..42e8969e Binary files /dev/null and b/workers/apex/assets/favicon.ico differ diff --git a/workers/apex/assets/robots.txt b/workers/apex/assets/robots.txt new file mode 100644 index 00000000..6f35aa9f --- /dev/null +++ b/workers/apex/assets/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Allow: / +Sitemap: https://docs.vyos.io/sitemap.xml diff --git a/workers/apex/assets/root.html b/workers/apex/assets/root.html new file mode 100644 index 00000000..4b266fbf --- /dev/null +++ b/workers/apex/assets/root.html @@ -0,0 +1,30 @@ + + + + +VyOS Documentation + + + + + + +
    +

    VyOS Documentation

    +

    Redirecting to the latest documentation…

    +
    + + diff --git a/workers/apex/src/dispatch.ts b/workers/apex/src/dispatch.ts new file mode 100644 index 00000000..9ad1f15d --- /dev/null +++ b/workers/apex/src/dispatch.ts @@ -0,0 +1,15 @@ +export function resolveVersion( + pathname: string, + dispatch: Map, +): { slug: string; binding: string } | null { + const m = pathname.match(/^\/en\/([^/]+)\//); + if (!m) return null; + const binding = dispatch.get(m[1]); + return binding ? { slug: m[1], binding } : null; +} + +export function bindingGuard(env: Record, binding: string): Fetcher | null { + const b = env[binding]; + if (b && typeof (b as Fetcher).fetch === "function") return b as Fetcher; + return null; +} diff --git a/workers/apex/src/index.ts b/workers/apex/src/index.ts new file mode 100644 index 00000000..f97b4bc5 --- /dev/null +++ b/workers/apex/src/index.ts @@ -0,0 +1,561 @@ +import { loadManifest, buildDispatch } from "./manifest"; +import { resolveVersion, bindingGuard } from "./dispatch"; +import { redirectFor } from "./redirects"; +import { specialPathFor } from "./special"; +import { uaVerdict } from "./uagate"; +import policy from "../ua-policy.json"; + +export interface ApexEnv extends Record { + ASSETS: Fetcher; + APEX_BUILD_SHA: string; + DOCS_ENV: "production" | "canary"; + DOCS_KB?: Fetcher; + // Β§5 apex PDF fallback β€” R2 bucket holding oversized legacy PDFs excluded from the + // content Worker's own asset tree (currently just the 1.3 PDF). Optional so the + // binding-guard path (503, not a crash) exercises on envs that omit it. + DOCS_PDFS?: R2Bucket; +} + +const manifest = loadManifest(); +const dispatch = buildDispatch(manifest); + +// Security headers only β€” safe on content pass-through (never touches +// Cache-Control or X-Docs-Build, which the content Worker owns). +function securityHeaders(resp: Response): Response { + const out = new Response(resp.body, resp); + out.headers.set("X-Content-Type-Options", "nosniff"); + out.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + out.headers.set("Content-Security-Policy-Report-Only", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'"); + return out; +} + +const DEFAULT_CACHE_CLASS = "public, max-age=0, s-maxage=300, must-revalidate"; + +function apexHeaders(resp: Response, env: ApexEnv, cacheClass: string = DEFAULT_CACHE_CLASS): Response { + const out = securityHeaders(resp); + out.headers.set("X-Apex-Build", env.APEX_BUILD_SHA); + // Β§3.3 cache contract applies to apex-owned responses too. Error responses (4xx/5xx) + // must never carry the s-maxage cache class β€” the cache key excludes User-Agent, so a + // cached UA-gate 403 or themed 404/503 would poison the edge for every visitor for the + // full s-maxage window. Mirrors the branch worker's withDocsHeaders() precedence. + // `cacheClass` lets a specific caller (e.g. the Β§5 PDF R2 fallback) apply a + // differently-classed success cache-control; canary/error still always win. + out.headers.set( + "Cache-Control", + env.DOCS_ENV === "canary" || out.status >= 400 ? "no-store" : cacheClass, + ); + return out; +} + +// R2's `R2Range` is a three-shape union β€” `{offset, length?}`, `{length}` (offset implicitly 0) +// and `{suffix}` (the trailing N bytes) β€” so `"offset" in range` is NOT a safe way to read it: +// the two offset-less shapes would fall through to the full-object 200 branch and be served +// with a Content-Length claiming the whole object while the body held only a slice. workerd is +// observed to normalize every shape to `{offset, length}` before it reaches us, but the type +// admits the others, so resolve all three to concrete byte bounds, clamped to the object size. +// Exported for direct unit testing. +export function resolveRange( + range: { offset?: number; length?: number; suffix?: number }, + size: number, +): { start: number; length: number } { + if (typeof range.suffix === "number") { + const suffix = Math.min(Math.max(range.suffix, 0), size); // a suffix past the start is the whole object + return { start: size - suffix, length: suffix }; + } + const start = Math.min(Math.max(range.offset ?? 0, 0), size); + // The trailing Math.max(_, 0) keeps the documented "clamped to the object size" contract + // total: without it a negative `range.length` would pass straight through Math.min and + // yield a negative length (and so a negative Content-Length). A real R2 binding cannot + // produce that β€” see classifyRangeHeader's note on observed R2 behaviour β€” but this + // function is exported and unit-tested as a standalone utility over the R2Range union, so + // it should not have a documented invariant its own signature can violate. Deliberately + // NOT guarding non-finite inputs: NaN bounds are unreachable from the binding and the + // guard would be untestable-in-anger dead weight. + const length = Math.max(Math.min(range.length ?? size - start, size - start), 0); + return { start, length }; +} + +// A single `bytes=` range-spec. Anything with a comma is a multi-range and deliberately +// fails to match. The whitespace class is `\s`, which is DELIBERATELY wider than the ` ` +// (ASCII space) that R2's own parser accepts β€” see classifyRangeHeader's contract note on +// why the two grammars are allowed to disagree. +const SINGLE_BYTE_RANGE = /^\s*bytes\s*=\s*(\d*)\s*-\s*(\d*)\s*$/i; + +/** + * Numeric comparison of two non-empty digit strings, without going through Number(). + * + * A range-spec's positions are unbounded digit strings, and Number() silently rounds + * anything above 2^53: `Number("9007199254740993") === Number("9007199254740992")`, which + * collapsed `bytes=9007199254740993-9007199254740992` β€” an invalid spec (last < first) + * that Β§14.1.2 says to IGNORE, so 200 β€” into an apparently-valid one that then read as + * unsatisfiable and answered 416. Comparing normalised digit strings by length and then + * lexically is exact at every magnitude. + */ +function cmpDigits(a: string, b: string): number { + const x = a.replace(/^0+(?=\d)/, ""); + const y = b.replace(/^0+(?=\d)/, ""); + if (x.length !== y.length) return x.length - y.length; + return x < y ? -1 : x > y ? 1 : 0; +} + +export type RangeIntent = + | { kind: "ignored" } + | { kind: "unsatisfiable" } + | { kind: "single"; start: number; length: number }; + +/** + * What the client's Range header ASKS FOR, judged against the representation length. + * + * This exists because R2 does not tell us. Probed against a real R2 binding under + * vitest-pool-workers, `get(key, {range: })` signals "I ignored your Range" by + * returning the WHOLE object with `range = {offset: 0, length: size}` β€” the byte-for-byte + * same shape it returns for a legitimately-satisfied whole-object range like `bytes=0-`. + * It does this for every unsatisfiable spec (`bytes=10-` / `bytes=99-` / `bytes=-0` on a + * 10-byte object), every malformed one (`bytes=abc`, `bytes=-`, `bytes=5-2`), multi-ranges + * (`bytes=0-1,4-5`) and unknown units (`items=0-5`). It does NOT throw for any of them and + * it never returns a zero/negative length except for a genuinely zero-length object. + * (The object-literal form `get(key, {range: {offset: 99}})` DOES throw + * "The requested range is not satisfiable (10039)" β€” but this Worker passes Headers, so + * that path is unreachable here.) + * + * Trusting `obj.range` alone therefore answered `Range: bytes=99-` with + * `206 + Content-Range: bytes 0-9/10` and the FULL body β€” a 206 that does not correspond to + * the request (RFC 9110 Β§15.3.7). That is actively dangerous for the resuming downloader + * this range forwarding exists to serve: a client resuming at byte 99 would append bytes + * 0-9 to its partial file and silently corrupt it. Re-deriving intent from the client's own + * header is the only way to separate the three cases. + * + * Satisfiability follows RFC 9110 Β§14.1.2 verbatim: an int-range is satisfiable iff + * first-pos < length; a suffix-range iff suffix-length is non-zero (so on a zero-length + * representation, a non-zero suffix-range is the ONLY satisfiable form). An invalid spec + * (last-pos < first-pos) MUST be ignored rather than rejected, hence "ignored", not + * "unsatisfiable". + * + * The `single` verdict carries the CONCRETE byte bounds the client asked for, clamped the + * way Β§14.1.2 clamps them. That is what makes this classifier safe to disagree with R2's + * parser. The two grammars are not identical and cannot be kept identical: R2's accepts + * only ASCII space around the tokens (miniflare's `/^ *(\d+)? *- *(\d+)? *$/`) while this + * one accepts `\s`, so `Range: bytes=2-4` parses here and is ignored there. When + * a caller compares these bounds against the bytes R2 actually handed back, any such + * divergence β€” this one, or the next one a parser change introduces β€” degrades to a plain + * 200 instead of a 206 whose Content-Range describes a body the client did not ask for. + * Chasing byte-for-byte grammar parity would put the guarantee back in the hands of two + * regexes staying in sync, which is the coupling that produced the bug. + */ +export function classifyRangeHeader(header: string, size: number): RangeIntent { + const m = SINGLE_BYTE_RANGE.exec(header); + if (!m) return { kind: "ignored" }; // multi-range, unknown unit, or unparseable + const [, firstRaw, lastRaw] = m; + if (firstRaw === "") { + if (lastRaw === "") return { kind: "ignored" }; // bare "bytes=-" is malformed + // Β§14.1.2: suffix-length 0 is unsatisfiable; a suffix past the start is the whole object. + if (cmpDigits(lastRaw, "0") <= 0) return { kind: "unsatisfiable" }; + const suffix = Math.min(Number(lastRaw), size); + return { kind: "single", start: size - suffix, length: suffix }; + } + // Β§14.1.2: an invalid spec (last-pos < first-pos) is ignored, not rejected. + if (lastRaw !== "" && cmpDigits(lastRaw, firstRaw) < 0) return { kind: "ignored" }; + if (cmpDigits(firstRaw, String(size)) >= 0) return { kind: "unsatisfiable" }; + const first = Number(firstRaw); // < size, so within safe-integer range + const last = lastRaw === "" ? size - 1 : Math.min(Number(lastRaw), size - 1); + return { kind: "single", start: first, length: last - first + 1 }; +} + +/** A quoted entity-tag list (`"a", W/"b"`) or `*`, compared per RFC 9110 Β§8.8.3.2. */ +function etagListMatches(list: string, etag: string, compare: "strong" | "weak"): boolean { + const items = list.split(",").map((s) => s.trim()).filter((s) => s !== ""); + if (items.includes("*")) return true; // "*" matches iff a representation exists β€” one does + const weaken = (t: string) => t.replace(/^W\//, ""); + // Strong comparison: neither side may be weak (Β§8.8.3.2). + if (compare === "strong" && etag.startsWith("W/")) return false; + return items.some((t) => + compare === "strong" ? t === etag : weaken(t) === weaken(etag), + ); +} + +/** + * `uploaded <= `, at seconds granularity, or null when the date is unparseable + * (Β§13.1.3: an invalid date MUST be ignored, which callers map to "precondition passes"). + * Seconds granularity matches R2's own comparison, which the Headers form of `onlyIf` + * selects β€” evaluating at millisecond precision here would disagree with the binding that + * produced the failure we are trying to name. + */ +function uploadedAtOrBefore(uploaded: Date | undefined, httpDate: string): boolean | null { + const at = Date.parse(httpDate); + if (Number.isNaN(at) || !uploaded) return null; + return Math.floor(uploaded.getTime() / 1000) <= Math.floor(at / 1000); +} + +/** + * The conditional headers R2 is allowed to see, filtered to those RFC 9110 Β§13.2.2 says + * actually apply to THIS request. + * + * R2 ANDs together every validator it is handed; Β§13.2.2 instead defines a precedence in + * which a lower-ranked validator is not evaluated at all. Forwarding `request.headers` + * wholesale therefore let R2 fail a request on a validator the RFC says to ignore β€” most + * visibly `If-Modified-Since` on a non-GET/HEAD method, which Β§13.2.2 step 4 does not + * evaluate, but which R2 evaluated anyway and answered with a body-less object that this + * Worker could only turn into a 412. Filtering at the source means a body-less result now + * always corresponds to a precondition that genuinely applies. + * + * The METHOD decides this before any header does. Β§13.2.1: "a server MUST ignore the + * conditional request header fields defined by this specification when received with a + * request method that does not involve the selection or modification of a selected + * representation, such as CONNECT, OPTIONS, or TRACE." Filtering by validator applicability + * alone still handed those methods' conditionals to R2, so an OPTIONS carrying a stale + * `If-Match` a client had left lying around was refused 412 where the same request without + * it succeeded. Returning an empty set here is what "ignore" means at this layer: R2 is + * given nothing to evaluate, so it cannot answer body-less, so preconditionStatus() β€” which + * is only ever reached from a body-less result β€” is unreachable for these methods too. + */ +function applicablePreconditions(h: Headers, method: string): Headers { + const out = new Headers(); + if (method === "OPTIONS" || method === "TRACE" || method === "CONNECT") return out; + const isGetOrHead = method === "GET" || method === "HEAD"; + const ifMatch = h.get("if-match"); + const ifNoneMatch = h.get("if-none-match"); + if (ifMatch !== null) out.set("if-match", ifMatch); + else { + const ius = h.get("if-unmodified-since"); // Β§13.2.2 step 2: only when If-Match is absent + if (ius !== null) out.set("if-unmodified-since", ius); + } + if (ifNoneMatch !== null) out.set("if-none-match", ifNoneMatch); + else if (isGetOrHead) { + const ims = h.get("if-modified-since"); // step 4: only when If-None-Match is absent, GET/HEAD only + if (ims !== null) out.set("if-modified-since", ims); + } + return out; +} + +/** + * Which status a FAILED `onlyIf` owes the client, decided by re-evaluating the request's + * conditionals against the object's own validators in RFC 9110 Β§13.2.2 order. + * + * R2 reports THAT a precondition failed and never WHICH one. Inferring from header + * PRESENCE cannot be right in both directions, which is how `If-Match: "x"` + + * `If-None-Match: "x"` on a matching object β€” If-Match satisfied, If-None-Match failed, + * so Β§13.1.2 owes a 304 β€” came back 412 purely because an If-Match header was present. + * Evaluating the validators removes the guess: presence selects which check runs, the + * comparison decides the answer. + */ +export function preconditionStatus( + h: Headers, isGetOrHead: boolean, etag: string, uploaded: Date | undefined, +): 304 | 412 { + const ifMatch = h.get("if-match"); + if (ifMatch !== null) { + if (!etagListMatches(ifMatch, etag, "strong")) return 412; // Β§13.2.2 step 1 + } else { + const ius = h.get("if-unmodified-since"); // step 2 + if (ius !== null && uploadedAtOrBefore(uploaded, ius) === false) return 412; + } + const ifNoneMatch = h.get("if-none-match"); + if (ifNoneMatch !== null) { + // Β§13.1.2: a failed If-None-Match is 304 for GET/HEAD and 412 for every other method. + if (etagListMatches(ifNoneMatch, etag, "weak")) return isGetOrHead ? 304 : 412; + } else if (isGetOrHead) { + const ims = h.get("if-modified-since"); // step 4 + if (ims !== null && uploadedAtOrBefore(uploaded, ims) === true) return 304; + } + // R2 refused for a reason this evaluation could not reproduce (a validator comparison + // that differs at the margins, say). 412 is the safe answer: a 304 would assert a cache + // validity we have not established. + return 412; +} + +/** + * RFC 9110 Β§13.1.5 If-Range: does the client's validator still describe this object? + * + * R2 cannot answer this β€” its `R2Conditional` carries only etagMatches / + * etagDoesNotMatch / uploadedBefore / uploadedAfter, so an `If-Range` in the forwarded + * Headers is silently dropped and the range is applied unconditionally. For an object + * whose ETag has moved on, that answered `Range: bytes=100-` + `If-Range: "old"` with + * bytes 100+ of the NEW representation under a 206 β€” a resuming downloader then appends + * the new tail to its old prefix and silently corrupts the file, which is the precise + * failure this range forwarding exists to avoid. + * + * Β§13.1.5 requires a STRONG validator, so a weak entity-tag never matches. The date form + * likewise never matches here: it must compare against Last-Modified, and this Worker + * does not emit one, so no client can hold a date validator for this resource that we + * could honour β€” treating it as a mismatch (serve the complete representation) is both + * correct and the safe direction. + */ +function ifRangeMatches(value: string, etag: string): boolean { + const v = value.trim(); + if (!v.startsWith('"')) return false; // weak tag or HTTP-date β†’ not a strong match + return etagListMatches(v, etag, "strong"); +} + +/** + * Abandon a body stream this Worker has decided not to send. + * + * R2 hands back a body on paths whose response carries none. An unsatisfiable Range gets + * the COMPLETE object (29.2 MiB for the 1.3 PDF) and is answered 416 with a null body; a + * stale `If-Range` gets the sliced range and is answered from a re-read. Dropping the + * reference leaves the stream open until GC collects it, holding the connection; cancelling + * releases it now and aborts the transfer rather than draining it. Failures are swallowed β€” + * this is cleanup on a path whose response is already decided, and a stream that is already + * closed or errored is exactly the state we wanted. + */ +async function discardBody(body: ReadableStream | null | undefined): Promise { + try { + await body?.cancel(); + } catch { + /* already closed or errored β€” nothing left to release */ + } +} + +async function themed(env: ApexEnv, status: 404 | 503): Promise { + const page = await env.ASSETS.fetch(new Request(`https://apex.internal/${status}.html`)); + return apexHeaders(new Response(page.body, { status, headers: { "content-type": "text/html; charset=utf-8" } }), env); +} + +export default { + async fetch(request: Request, env: ApexEnv): Promise { + const url = new URL(request.url); + + // 1. UA gate β€” production only (Β§3.2.1) + if (env.DOCS_ENV === "production") { + const v = uaVerdict(request.headers.get("user-agent") ?? "", policy); + if (v === "block") return apexHeaders(new Response("Forbidden", { status: 403 }), env); + if (v === "log") console.log(JSON.stringify({ event: "ua-log", ua: request.headers.get("user-agent"), path: url.pathname })); + } + + // 2. Special paths (Β§3.2.2) + const special = await specialPathFor(request, manifest, env as never); + if (special) return apexHeaders(special, env); + + // 2b. Legacy PDF R2 fallback (spec Β§5) β€” the 1.3 PDF (29.2 MiB) exceeds the 25 MiB + // static-asset cap and is excluded from the content Worker's own build. MUST run + // before version dispatch (step 6): the legacy Worker's asset tree lacks this file, + // so unconditional dispatch would 404 on the exact path the PDF 301 (Β§3.2.4) targets. + const pdfVersion = manifest.versions.find((v) => v.pdf_r2_key && v.pdf === url.pathname); + if (pdfVersion) { + const bucket = env.DOCS_PDFS; + if (!bucket) { + console.log(JSON.stringify({ event: "binding-missing", binding: "DOCS_PDFS" })); + return themed(env, 503); + } + const method = request.method.toUpperCase(); + const isGetOrHead = method === "GET" || method === "HEAD"; + // Β§14.2: "GET is the only method for which range handling is defined" β€” a Range on + // any other method MUST be ignored. Reading it as null here suppresses the whole + // partial-content path in one place: R2 is never asked to slice, and the 206/416 + // branches below are unreachable. Gating only the R2 forward would leave the + // response side still seeing a Range header and answering a HEAD or a POST with a + // 416 or a Content-Range. + const rangeHeader = method === "GET" ? request.headers.get("range") : null; + const onlyIf = applicablePreconditions(request.headers, method); + + let raw: R2ObjectBody | R2Object | null; + try { + // Forward Range + the APPLICABLE conditionals so a resumed download or a client + // with a fresh cached copy doesn't have to re-pull the full 29.2 MiB object. + raw = await bucket.get(pdfVersion.pdf_r2_key!, { + ...(rangeHeader !== null ? { range: request.headers } : {}), + onlyIf, + }); + } catch (e) { + console.log(JSON.stringify({ event: "binding-error", binding: "DOCS_PDFS", error: String(e) })); + return themed(env, 503); + } + if (!raw) return themed(env, 404); + + // Distinct (longer) cache class from apex's default control-response class β€” this + // is effectively content, just not content the legacy content Worker can serve. + // 304/206 are both <400 so apexHeaders() still applies this class, not "no-store". + const pdfCacheClass = "public, max-age=300, s-maxage=600, must-revalidate"; + const pdfHeaders: Record = { + etag: raw.httpEtag, + "accept-ranges": "bytes", + }; + + // A FAILED onlyIf precondition makes R2 hand back a body-less R2Object β€” just the + // validators, no content β€” and never says which validator failed. Because `onlyIf` + // was filtered to the conditionals Β§13.2.2 actually applies to this request, a + // body-less result here always means a precondition that genuinely applies failed; + // preconditionStatus() re-evaluates them against the object's own validators, in + // Β§13.2.2 order, to decide between 304 and 412. + if (!("body" in raw) || !raw.body) { + const status = preconditionStatus( + request.headers, isGetOrHead, raw.httpEtag, raw.uploaded); + return apexHeaders(new Response(null, { status, headers: pdfHeaders }), env, pdfCacheClass); + } + let obj = raw as R2ObjectBody; + pdfHeaders["content-type"] = "application/pdf"; + + // Β§13.1.5 If-Range, which R2 cannot evaluate (see ifRangeMatches). A failed validator + // means the client's partial copy is stale, so the Range is ignored ENTIRELY and the + // complete representation is served β€” including for a spec that would otherwise be + // unsatisfiable, since the 416 branch below must not fire on a range we have decided + // not to honour. R2 has already applied the range at this point, so the whole object + // has to be re-read; that costs one extra R2 read on the rare stale-resume path and + // nothing at all on the common one. + // + // The re-read carries the SAME `onlyIf`. Β§13.2.1 requires preconditions to hold for the + // representation ultimately selected, and the two reads need not see one object: a bare + // re-get answered `Range` + stale `If-Range` + `If-Match: "A"` with a 200 carrying + // object B, whose ETag the client had explicitly excluded, whenever the key was + // rewritten in between. That rewrite is not hypothetical β€” the legacy snapshot repo's + // deploy workflow re-uploads this exact key on its `force_pdf_refresh` input. Re-sending + // the conditionals ties the verdict to the bytes actually served, because R2 evaluates + // `onlyIf` against the very object it returns; the body-less outcome that produces is + // not a gap in this path but the correct answer, resolved by preconditionStatus() + // exactly as on the first read. When no conditionals were sent, `onlyIf` is empty and a + // body-less result cannot occur, so the common path is untouched. + // + // A head() before the get() would also expose the validators, and would avoid opening + // this slice stream at all β€” but it would put a second round-trip on the path where + // If-Range MATCHES, which is the normal resumed download, in exchange for tidying the + // rare one where it does not. It would also widen the window this shape keeps narrow: + // the object whose validators decide the verdict is the one R2 returns from the same + // call. So the get-then-re-read stays, and the slice we are abandoning is cancelled + // rather than left to GC. + const ifRange = rangeHeader !== null ? request.headers.get("if-range") : null; + let rangeApplies = rangeHeader !== null; + if (ifRange !== null && !ifRangeMatches(ifRange, obj.httpEtag)) { + rangeApplies = false; + await discardBody(obj.body); + let full: R2ObjectBody | R2Object | null; + try { + full = await bucket.get(pdfVersion.pdf_r2_key!, { onlyIf }); + } catch (e) { + console.log(JSON.stringify({ event: "binding-error", binding: "DOCS_PDFS", error: String(e) })); + return themed(env, 503); + } + if (!full) return themed(env, 404); // deleted between the two reads + if (!("body" in full) || !full.body) { + // The key was rewritten between the two reads and the request's preconditions do + // not hold for the new representation. Same treatment as a first-read failure, on + // the new object's validators β€” never the old ones, which describe a representation + // this response is not about. + const status = preconditionStatus( + request.headers, isGetOrHead, full.httpEtag, full.uploaded); + return apexHeaders(new Response(null, { + status, headers: { etag: full.httpEtag, "accept-ranges": "bytes" }, + }), env, pdfCacheClass); + } + obj = full as R2ObjectBody; + pdfHeaders.etag = obj.httpEtag; + } + + // A satisfied Range request. R2 echoes the actually-served byte range on `obj.range` β€” + // but it does so for FULL gets too: against a real R2 binding under workerd, a get() + // whose forwarded Headers carry NO Range header still comes back with + // `range = {offset: 0, length: obj.size}`. Keying the 206 off `obj.range` alone therefore + // turned every plain GET of the 1.3 PDF into a 206 β€” which is exactly what the nightly + // canary sweep observed (`/en/1.3/vyos-documentation.pdf: status=206`) β€” and RFC 9110 + // Β§15.3.7 only permits a 206 in answer to a request that actually carried a Range header. + // So: gate on the REQUEST first, then normalize whatever shape R2 handed back β€” and + // then CHECK that the two agree before promising a 206, because "R2 sliced it" and + // "R2 handed back everything" are the same shape on the wire. + // + // What R2 actually handed back, as concrete bounds. `obj.range` is absent only if a + // binding declines to report one, in which case the body is the complete object. + const actual = obj.range + ? resolveRange(obj.range, obj.size) + : { start: 0, length: obj.size }; + const servedWhole = actual.start === 0 && actual.length === obj.size; + + if (rangeApplies && rangeHeader !== null) { + const intent = classifyRangeHeader(rangeHeader, obj.size); + if (intent.kind === "unsatisfiable") { + // Β§14.2: "the server SHOULD send a 416"; Β§15.5.17: a 416 to a byte-range request + // SHOULD carry `Content-Range: bytes */`. Deliberately no + // content-type β€” there is no PDF payload on this response. 416 is >= 400 so + // apexHeaders() forces no-store, which is right: the verdict depends on the + // request's Range header and the cache key does not include it. + // R2 answers an unsatisfiable Range with the COMPLETE object, so the body being + // dropped here is the whole 29.2 MiB one β€” the largest abandoned stream on any + // path through this handler. + await discardBody(obj.body); + return apexHeaders( + new Response(null, { + status: 416, + headers: { etag: pdfHeaders.etag, "accept-ranges": "bytes", + "content-range": `bytes */${obj.size}` }, + }), + env, + pdfCacheClass, + ); + } + // A 206 is owed only when the bytes R2 selected are the bytes the client asked for. + // `length === 0` means a zero-length representation (the only way R2 yields it) β€” + // e.g. a non-zero suffix-range, which Β§14.1.2 calls satisfiable, against an empty + // object. No valid Content-Range exists for an empty selection (Β§14.4 forbids a + // last-pos below the first-pos), so a 206 is unrepresentable. Fall through to the + // 200: Β§15.5.17's own note records that servers are free to ignore Range and answer + // with the complete representation, which for an empty object is exactly this body. + if (intent.kind === "single" && intent.length > 0 && + actual.start === intent.start && actual.length === intent.length) { + pdfHeaders["content-range"] = + `bytes ${intent.start}-${intent.start + intent.length - 1}/${obj.size}`; + pdfHeaders["content-length"] = String(intent.length); + return apexHeaders(new Response(obj.body, { status: 206, headers: pdfHeaders }), env, pdfCacheClass); + } + // Otherwise no 206 is owed: either the spec was one Β§14.1.2 says to ignore + // (multi-range / malformed / unknown unit), or R2 declined a spec that this parser + // accepted β€” the grammar divergence classifyRangeHeader documents. Both leave R2 + // having returned the complete object, so fall through to the 200 below. + } + + // Serve what R2 actually handed back, described truthfully. `servedWhole` is the + // normal case and the only one a 200 can describe; a partial body under a 200 would + // ship a Content-Length that contradicts it. A partial body reaching HERE β€” past the + // agreement check above β€” means R2 sliced to bounds the request did not ask for, and + // there is no honest success response left: a 200 would misstate the length, and a + // 206 would answer with a range the client never requested, which Β§14.4 does not + // permit (Content-Range on a 206 describes the selected range, and no range was + // selected). This used to ship that illegal 206. + // + // So: drop the slice and fail. Re-reading the object for a clean 200 is the other + // option Codex offered, but it means a second conditional read with its own + // object-rewritten-between-reads handling β€” a copy of the If-Range block above, or a + // refactor of that live and currently-correct path β€” bought for a branch that cannot + // execute under today's workerd (round 3 established empirically that the Headers + // form ignores multi-range and returns the whole object). The log line is the part + // that earns its keep: it is the alarm that R2's range semantics have moved, and it + // is what would justify writing that re-read for real. + if (!servedWhole) { + console.log(JSON.stringify({ + event: "r2-range-divergence", path: url.pathname, size: obj.size, + served: `${actual.start}+${actual.length}`, requested: rangeHeader ?? null, + })); + await discardBody(obj.body); + return themed(env, 503); + } + pdfHeaders["content-length"] = String(obj.size); + return apexHeaders(new Response(obj.body, { status: 200, headers: pdfHeaders }), env, pdfCacheClass); + } + + // 3+4. Trailing-slash + alias/codename/PDF 301s (Β§3.2.3-4) + const redir = redirectFor(url, manifest); + if (redir) return apexHeaders(redir, env); + + // 5. /kb seam (Β§3.2.5) + if (url.pathname.startsWith("/kb/") || url.pathname === "/kb") { + if (env.DOCS_KB) return securityHeaders(await env.DOCS_KB.fetch(request)); + return themed(env, 404); + } + + // 6. Version dispatch (Β§3.2.6) + const hit = resolveVersion(url.pathname, dispatch); + if (hit) { + const fetcher = bindingGuard(env, hit.binding); + if (!fetcher) { // 7. runtime binding guard (Β§3.2.7) + console.log(JSON.stringify({ event: "binding-missing", binding: hit.binding })); + return themed(env, 503); + } + try { + const resp = await fetcher.fetch(request); + if (resp.status === 404) return themed(env, 404); + return securityHeaders(resp); // Β§3.3: security headers at apex; cache + X-Docs-Build stay content-owned + } catch (e) { + console.log(JSON.stringify({ event: "binding-error", binding: hit.binding, error: String(e) })); + return themed(env, 503); + } + } + + // 7. Fallback + return themed(env, 404); + }, +} satisfies ExportedHandler; diff --git a/workers/apex/src/manifest.ts b/workers/apex/src/manifest.ts new file mode 100644 index 00000000..396f29ed --- /dev/null +++ b/workers/apex/src/manifest.ts @@ -0,0 +1,55 @@ +import raw from "../../versions.json"; + +export interface VersionEntry { + slug: string; label: string; + status: "dev" | "lts" | "eol"; + binding: string; aliases: string[]; + pdf: string | null; + // R2 object key for the apex PDF fallback (spec Β§5) β€” set only on versions whose PDF + // exceeds the 25 MiB static-asset cap and is therefore absent from the content + // Worker's own asset tree (currently just 1.3). Optional; most versions omit it. + pdf_r2_key?: string; +} +export interface Manifest { + schema_version: number; + default_lang: string; default_version: string; + languages: { code: string; label: string }[]; + versions: VersionEntry[]; +} + +// Split out from loadManifest() so tests can validate synthetic manifests without +// touching the real ../../versions.json import (which loadManifest() is hardwired to). +export function validateManifest(m: Manifest): Manifest { + if (m.schema_version !== 2) throw new Error(`versions.json schema_version ${m.schema_version} != 2`); + const slugs = new Set(); + for (const v of m.versions) { + if (!/^DOCS_[A-Z0-9_]+$/.test(v.binding)) throw new Error(`bad binding for ${v.slug}`); + if (!["dev", "lts", "eol"].includes(v.status)) throw new Error(`bad status for ${v.slug}`); + if (slugs.has(v.slug)) throw new Error(`duplicate slug: ${v.slug}`); + // pdf_r2_key names an R2 fallback for the exact `pdf` URL β€” a null pdf has no URL + // for the fallback to ever be reached at, so the pairing is nonsensical. + if (v.pdf_r2_key && v.pdf === null) throw new Error(`pdf_r2_key set but pdf is null for ${v.slug}`); + slugs.add(v.slug); + } + // Separate pass: an alias must not collide with ANY canonical slug (not just an + // earlier one), so `slugs` must be fully populated before this check runs. + const aliases = new Set(); + for (const v of m.versions) { + for (const alias of v.aliases) { + if (slugs.has(alias)) throw new Error(`alias ${alias} (on ${v.slug}) collides with a canonical slug`); + if (aliases.has(alias)) throw new Error(`duplicate alias: ${alias}`); + aliases.add(alias); + } + } + if (!m.versions.some((v) => v.slug === m.default_version)) + throw new Error(`default_version ${m.default_version} not in versions[]`); + return m; +} + +export function loadManifest(): Manifest { + return validateManifest(raw as Manifest); +} + +export function buildDispatch(m: Manifest): Map { + return new Map(m.versions.map((v) => [v.slug, v.binding])); +} diff --git a/workers/apex/src/redirects.ts b/workers/apex/src/redirects.ts new file mode 100644 index 00000000..835b50f7 --- /dev/null +++ b/workers/apex/src/redirects.ts @@ -0,0 +1,38 @@ +import type { Manifest } from "./manifest"; + +function aliasMap(m: Manifest): Map { + const map = new Map(); + for (const v of m.versions) for (const a of v.aliases) map.set(a, v.slug); + return map; +} + +function r301(pathAndQuery: string): Response { + return new Response(null, { status: 301, headers: { Location: pathAndQuery } }); +} + +export function redirectFor(url: URL, m: Manifest): Response | null { + const { pathname, search } = url; // never touch url.hash β€” fragments don't reach the server + + // RTD PDF URLs: /_/downloads/en//pdf/* β†’ /en//vyos-documentation.pdf + const pdf = pathname.match(/^\/_\/downloads\/en\/([^/]+)\/pdf(?:\/|$)/); + if (pdf) { + const slug = aliasMap(m).get(pdf[1]) ?? pdf[1]; + const entry = m.versions.find((v) => v.slug === slug); + // pdf: null means no PDF artifact exists for this version β€” don't 301 into a dead-end 404. + // The manifest's pdf value is the source of truth (not a hardcoded filename) so a future + // R2-fallback path for 1.3 (or any other version) can move the target without a code change. + if (entry && entry.pdf !== null) return r301(`${entry.pdf}${search}`); + } + + // Alias / codename prefixes: /en//* β†’ /en//* + const seg = pathname.match(/^\/en\/([^/]+)(\/.*)?$/); + if (seg) { + const [, first, rest = ""] = seg; + const target = aliasMap(m).get(first); + if (target) return r301(`/en/${target}${rest || "/"}${search}`); + // Trailing-slash normalization on bare version roots: /en/ β†’ /en// + if (rest === "" && m.versions.some((v) => v.slug === first)) + return r301(`/en/${first}/${search}`); + } + return null; +} diff --git a/workers/apex/src/special.ts b/workers/apex/src/special.ts new file mode 100644 index 00000000..15e693b1 --- /dev/null +++ b/workers/apex/src/special.ts @@ -0,0 +1,60 @@ +import type { Manifest } from "./manifest"; +import { bindingGuard } from "./dispatch"; + +// Static assets (404.html, 503.html, robots.txt, favicons, root) live in the apex +// ASSETS binding; versions.json is served from the imported manifest so the body +// always matches the dispatch map. +export async function specialPathFor( + request: Request, + m: Manifest, + env: Record & { ASSETS: Fetcher }, +): Promise { + const url = new URL(request.url); + const p = url.pathname; + + if (p === "/") // default-version redirect (Β§3.2.2) β€” query preserved, like alias redirects + return new Response(null, { status: 301, headers: { Location: `/en/${m.default_version}/${url.search}` } }); + + if (p === "/versions.json") + return new Response(JSON.stringify(m), { + headers: { "content-type": "application/json; charset=utf-8" }, + }); + + if (p === "/healthz") + return new Response(JSON.stringify({ status: "ok", versions: m.versions.length }), { + headers: { "content-type": "application/json" }, + }); + + if (p === "/sitemap.xml") { + // Origin comes from the REQUEST, never a hard-coded production hostname: this same Worker + // also serves the canary origin (docs-next.vyos.io, DOCS_ENV=canary), and a canary sitemap + // index whose entries pointed at docs.vyos.io would send any checker that follows it + // straight to production β€” a candidate tree with broken or missing per-version sitemaps + // would then pass its own sitemap check by silently grading production instead of itself. + const entries = m.versions + .map((v) => `${url.origin}/en/${v.slug}/sitemap.xml`) + .join(""); + return new Response( + `${entries}`, + { headers: { "content-type": "application/xml" } }, + ); + } + + if (p === "/llms.txt") { // direct body, not a redirect (Β§3.2.2) + const def = m.versions.find((v) => v.slug === m.default_version)!; + const b = bindingGuard(env, def.binding); + if (!b) // do NOT fall through β€” /llms.txt with a missing binding is a 503, not a 404 + return new Response("service unavailable", { status: 503, headers: { "content-type": "text/plain" } }); + // Forward the ORIGINAL request (method + conditional-GET headers), just retargeted + // to the versioned path β€” mirrors the robots.txt/favicon pass-through below. + const target = new URL(`/en/${def.slug}/llms.txt`, url); + return b.fetch(new Request(target, request)); + } + + if (p === "/robots.txt" || p === "/favicon.ico" || /^\/apple-touch-icon.*\.png$/.test(p)) + // Pass the ORIGINAL request through (not a re-synthesized `new Request(url)`) so + // conditional-GET headers (If-None-Match / If-Modified-Since) reach ASSETS intact. + return env.ASSETS.fetch(request); + + return null; +} diff --git a/workers/apex/src/uagate.ts b/workers/apex/src/uagate.ts new file mode 100644 index 00000000..4a407e51 --- /dev/null +++ b/workers/apex/src/uagate.ts @@ -0,0 +1,48 @@ +export interface UaPolicy { + allow: string[]; + log: string[]; + block: string[]; +} + +export type UaVerdict = "allow" | "block" | "log"; + +/** + * The LONGEST entry in `list` occurring in the (already-lowercased) UA, lowercased, or null. + * Longest rather than first-hit so the containment test in uaVerdict() compares against the + * most specific entry a multi-token UA matched, not an arbitrary earlier one. + */ +function bestMatch(lowerUa: string, list: string[]): string | null { + return list.reduce((best, entry) => { + const needle = entry.toLowerCase(); + if (!lowerUa.includes(needle)) return best; + return best === null || needle.length > best.length ? needle : best; + }, null); +} + +export function uaVerdict(ua: string, policy: UaPolicy): UaVerdict { + const lowerUa = ua.toLowerCase(); + // Explicit blocks take precedence β€” a request-controlled UA string that spoofs an + // allow-listed substring (e.g. "Googlebot EvilScraper") must not be able to bypass a + // block entry just by also matching the allow list. + if (bestMatch(lowerUa, policy.block) !== null) return "block"; + + // A log match WINS over any competing allow match, unconditionally. `log` is a telemetry + // verdict, not a denial (the request is served either way), so resolving a contest the + // wrong way is asymmetric: choosing `allow` loses the ua-log event permanently, while + // choosing `log` costs one log line. A UA presenting BOTH an allow token and a log token + // (e.g. "GPTBot/1.0 DuckDuckBot") is exactly the shape worth recording. + // + // There used to be a carve-out here: a matched allow entry that strictly CONTAINED the + // matched log entry won, so a policy could express a narrow allow exception inside a + // broader log entry (log "Foo", allow "Foo-Search"). It is gone, for two reasons. It was + // spoofable β€” containment was tested between the two matched ENTRIES, never against the + // UA's own token structure, so a caller writing "Bytespider/2.0 Bytespider-Search/1.0" + // matched both entries as independent tokens and bought itself `allow`, and the UA + // string is entirely request-controlled. And it bought nothing: no entry pair in + // ua-policy.json takes that branch. The pair the shipped policy does depend on runs the + // OTHER way β€” Apple ships "Applebot" (search, allow) and "Applebot-Extended" (AI + // training, log), where the log entry is the longer one, so there is no containment and + // log wins regardless. Losing the carve-out costs a future narrow-allow vendor variant + // nothing worse than being logged as well as served. + return bestMatch(lowerUa, policy.log) === null ? "allow" : "log"; // unknown UAs fail open +} diff --git a/workers/apex/test/dispatch.test.ts b/workers/apex/test/dispatch.test.ts new file mode 100644 index 00000000..9a61169d --- /dev/null +++ b/workers/apex/test/dispatch.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { loadManifest, buildDispatch } from "../src/manifest"; +import { resolveVersion, bindingGuard } from "../src/dispatch"; + +const dispatch = buildDispatch(loadManifest()); + +describe("version dispatch (Β§3.2.6)", () => { + it("resolves /en//... to its binding, path untouched", () => { + expect(resolveVersion("/en/rolling/cli/index.html", dispatch)) + .toEqual({ slug: "rolling", binding: "DOCS_ROLLING" }); + expect(resolveVersion("/en/1.5/", dispatch)) + .toEqual({ slug: "1.5", binding: "DOCS_V15" }); + }); + it("returns null for unknown version or non-version paths", () => { + expect(resolveVersion("/en/9.9/x", dispatch)).toBeNull(); + expect(resolveVersion("/kb/article", dispatch)).toBeNull(); + }); +}); + +describe("runtime binding guard (Β§3.2.7)", () => { + it("returns the fetcher when binding exists", () => { + const fake = { fetch: async () => new Response("ok") }; + expect(bindingGuard({ DOCS_ROLLING: fake } as never, "DOCS_ROLLING")).toBe(fake); + }); + it("returns null (β†’ themed 503) when binding missing or not a Fetcher", () => { + expect(bindingGuard({} as never, "DOCS_ROLLING")).toBeNull(); + expect(bindingGuard({ DOCS_ROLLING: 42 } as never, "DOCS_ROLLING")).toBeNull(); + }); +}); diff --git a/workers/apex/test/manifest.test.ts b/workers/apex/test/manifest.test.ts new file mode 100644 index 00000000..f5653360 --- /dev/null +++ b/workers/apex/test/manifest.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; +import { loadManifest, buildDispatch, validateManifest, type Manifest } from "../src/manifest"; +// The workers pool has no real filesystem (node:fs readFileSync is an unimplemented +// stub β€” see @cloudflare/vitest-pool-workers/dist/worker/lib/node/fs.mjs); import the +// config as a Vite `?raw` asset instead so the file content is inlined at bundle time. +// eslint-disable-next-line import/no-unresolved +import wranglerJsonc from "../wrangler.jsonc?raw"; +// eslint-disable-next-line import/no-unresolved +import rootHtml from "../assets/root.html?raw"; + +describe("versions.json v2 manifest (Β§3.4)", () => { + it("loads and validates schema_version 2 with 5 versions", () => { + const m = loadManifest(); + expect(m.schema_version).toBe(2); + expect(m.versions).toHaveLength(5); + expect(m.default_version).toBe("rolling"); + }); + it("every version carries a binding; statuses in dev|lts|eol", () => { + const m = loadManifest(); + for (const v of m.versions) { + expect(v.binding).toMatch(/^DOCS_[A-Z0-9_]+$/); + expect(["dev", "lts", "eol"]).toContain(v.status); + } + }); + it("dispatch map: every version slug maps to its binding (shared legacy included)", () => { + const m = loadManifest(); + const d = buildDispatch(m); + expect(d.size).toBe(m.versions.length); + for (const v of m.versions) { + expect(d.get(v.slug)).toBe(v.binding); + } + // aliases are redirect-layer concerns, never dispatch keys + for (const v of m.versions) { + for (const a of v.aliases) { + expect(d.has(a)).toBe(false); + } + } + }); + it("only 1.3 carries pdf_r2_key (Β§5 apex PDF R2 fallback)", () => { + const m = loadManifest(); + const withKey = m.versions.filter((v) => v.pdf_r2_key); + expect(withKey).toHaveLength(1); + expect(withKey[0]).toMatchObject({ + slug: "1.3", + pdf: "/en/1.3/vyos-documentation.pdf", + pdf_r2_key: "legacy/1.3/vyos-documentation.pdf", + }); + }); +}); + +function baseManifest(): Manifest { + return { + schema_version: 2, + default_lang: "en", + default_version: "rolling", + languages: [{ code: "en", label: "English" }], + versions: [ + { slug: "rolling", label: "Rolling", status: "dev", binding: "DOCS_ROLLING", + aliases: ["latest"], pdf: null }, + { slug: "1.5", label: "1.5", status: "lts", binding: "DOCS_V15", + aliases: ["stable", "lts"], pdf: null }, + ], + }; +} + +describe("validateManifest β€” duplicate/ambiguous slug + alias rejection", () => { + it("rejects a duplicate slug", () => { + const m = baseManifest(); + m.versions.push({ ...m.versions[0], binding: "DOCS_ROLLING2" }); + expect(() => validateManifest(m)).toThrow(/duplicate slug: rolling/); + }); + + it("rejects a duplicate alias across two versions", () => { + const m = baseManifest(); + m.versions[1].aliases.push("latest"); // "latest" already aliases rolling + expect(() => validateManifest(m)).toThrow(/duplicate alias: latest/); + }); + + it("rejects an alias that collides with a canonical slug", () => { + const m = baseManifest(); + m.versions[0].aliases.push("1.5"); // "1.5" is a real slug + expect(() => validateManifest(m)).toThrow(/alias 1\.5 .* collides with a canonical slug/); + }); + + it("accepts a well-formed manifest unchanged", () => { + const m = baseManifest(); + const snapshot = structuredClone(m); + expect(validateManifest(m)).toBe(m); + expect(m).toEqual(snapshot); // validation must not mutate the manifest + }); + + it("accepts pdf_r2_key when pdf is set", () => { + const m = baseManifest(); + m.versions[1].pdf = "/en/1.5/vyos-documentation.pdf"; + m.versions[1].pdf_r2_key = "legacy/1.5/vyos-documentation.pdf"; + expect(validateManifest(m)).toBe(m); + }); + + it("rejects pdf_r2_key set on a version whose pdf is null (no URL for the fallback to be reached at)", () => { + const m = baseManifest(); + m.versions[0].pdf_r2_key = "legacy/rolling/vyos-documentation.pdf"; // pdf stays null + expect(() => validateManifest(m)).toThrow(/pdf_r2_key set but pdf is null for rolling/); + }); +}); + +it("every versions.json binding exists in BOTH apex wrangler envs (Β§3.4 gate a)", () => { + const raw = wranglerJsonc.replace(/\/\/.*$/gm, ""); // strip line comments + const cfg = JSON.parse(raw); + const bindings = new Set(buildDispatch(loadManifest()).values()); + for (const envName of ["canary", "production"]) { + const services = new Set((cfg.env[envName].services as { binding: string }[]).map((s) => s.binding)); + for (const b of bindings) expect(services, `${envName} missing ${b}`).toContain(b); + } +}); + +it("root.html's hardcoded default-version references stay congruent with the manifest", () => { + // root.html is a static fallback shell (never templated at build time β€” see the + // comment in the file) so its `/en//` references must be hand-kept in sync + // with the manifest's default_version. This is the drift guard: it fails loudly if + // someone bumps default_version without also updating the static asset. + const expected = `/en/${loadManifest().default_version}/`; + // Strip HTML comments first β€” the explanatory comment in root.html mentions the + // literal placeholder text "/en//", which is documentation, not + // a real markup reference, and must not be asserted against the manifest. + // Applied repeatedly to a fixpoint: a single pass can splice two partial comments + // into a new one (CodeQL js/incomplete-multi-character-sanitization). + let withoutComments = rootHtml; + for (let prev = ""; prev !== withoutComments; ) { + prev = withoutComments; + withoutComments = withoutComments.replace(//g, ""); + } + const refs = withoutComments.match(/\/en\/[^/"'\s]+\//g) ?? []; + expect(refs.length).toBeGreaterThan(0); + for (const ref of refs) expect(ref).toBe(expected); +}); diff --git a/workers/apex/test/redirects.test.ts b/workers/apex/test/redirects.test.ts new file mode 100644 index 00000000..5df03197 --- /dev/null +++ b/workers/apex/test/redirects.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { loadManifest } from "../src/manifest"; +import { redirectFor } from "../src/redirects"; + +const m = loadManifest(); +const loc = (u: string) => { + const r = redirectFor(new URL(u), m); + return r ? { status: r.status, location: r.headers.get("Location") } : null; +}; + +describe("alias + codename 301s (Β§3.2.4)", () => { + it("maps every alias, preserving path + query", () => { + expect(loc("https://docs.vyos.io/en/latest/cli/index.html?x=1")) + .toEqual({ status: 301, location: "/en/rolling/cli/index.html?x=1" }); + expect(loc("https://docs.vyos.io/en/stable/")).toEqual({ status: 301, location: "/en/1.5/" }); + expect(loc("https://docs.vyos.io/en/lts/a")).toEqual({ status: 301, location: "/en/1.5/a" }); + expect(loc("https://docs.vyos.io/en/circinus/a")).toEqual({ status: 301, location: "/en/1.5/a" }); + expect(loc("https://docs.vyos.io/en/sagitta/a")).toEqual({ status: 301, location: "/en/1.4/a" }); + expect(loc("https://docs.vyos.io/en/equuleus/a")).toEqual({ status: 301, location: "/en/1.3/a" }); + expect(loc("https://docs.vyos.io/en/crux/a")).toEqual({ status: 301, location: "/en/1.2/a" }); + }); + it("RTD PDF URLs β†’ PDF path taken from the manifest (source of truth, Β§3.4)", () => { + const v15pdf = m.versions.find((v) => v.slug === "1.5")!.pdf; + const rollingPdf = m.versions.find((v) => v.slug === "rolling")!.pdf; + expect(loc("https://docs.vyos.io/_/downloads/en/1.5/pdf/")) + .toEqual({ status: 301, location: v15pdf }); + expect(loc("https://docs.vyos.io/_/downloads/en/latest/pdf/")) + .toEqual({ status: 301, location: rollingPdf }); + }); + it("RTD PDF URLs for a pdf:null version β†’ no redirect (no dead-end 301)", () => { + expect(loc("https://docs.vyos.io/_/downloads/en/crux/pdf/")).toBeNull(); + expect(loc("https://docs.vyos.io/_/downloads/en/1.2/pdf/")).toBeNull(); + }); + it("RTD PDF URLs preserve the query string, appended to the manifest's pdf value", () => { + const v14pdf = m.versions.find((v) => v.slug === "1.4")!.pdf; + expect(loc("https://docs.vyos.io/_/downloads/en/sagitta/pdf/?x=1")) + .toEqual({ status: 301, location: `${v14pdf}?x=1` }); + }); + it("does not match /pdf-notes (only an exact /pdf segment)", () => { + expect(loc("https://docs.vyos.io/_/downloads/en/rolling/pdf-notes")).toBeNull(); + }); + it("trailing-slash normalization on bare version roots (Β§3.2.3)", () => { + expect(loc("https://docs.vyos.io/en/1.5")).toEqual({ status: 301, location: "/en/1.5/" }); + expect(loc("https://docs.vyos.io/en/rolling?q=1")).toEqual({ status: 301, location: "/en/rolling/?q=1" }); + }); + it("never emits a fragment in Location (Β§3.2.6)", () => { + const r = loc("https://docs.vyos.io/en/latest/page.html#section"); + expect(r!.location).not.toContain("#"); + }); + it("returns null for canonical paths (no redirect loop)", () => { + expect(loc("https://docs.vyos.io/en/rolling/")).toBeNull(); + expect(loc("https://docs.vyos.io/en/1.5/cli/")).toBeNull(); + }); +}); diff --git a/workers/apex/test/router.test.ts b/workers/apex/test/router.test.ts new file mode 100644 index 00000000..b5f965fc --- /dev/null +++ b/workers/apex/test/router.test.ts @@ -0,0 +1,1067 @@ +import { describe, it, expect, vi } from "vitest"; +import worker, { resolveRange, classifyRangeHeader } from "../src/index"; + +function makeEnv(overrides: Record = {}) { + const html = (body: string, status = 200) => + new Response(body, { status, headers: { "content-type": "text/html", "X-Docs-Build": "sha-content" } }); + const fetcher = (tag: string) => ({ + fetch: async (req: Request) => { + const p = new URL(req.url).pathname; + if (p.endsWith("/missing.html")) return html("nope", 404); + return html(`${tag}:${p}`); + }, + }); + return { + DOCS_ROLLING: fetcher("rolling"), DOCS_V15: fetcher("v15"), + DOCS_V14: fetcher("v14"), DOCS_LEGACY: fetcher("legacy"), + ASSETS: { fetch: async () => new Response("

    404

    ", { status: 200, headers: { "content-type": "text/html" } }) }, + APEX_BUILD_SHA: "apex-sha", DOCS_ENV: "canary", + ...overrides, + } as never; +} +const get = (path: string, env = makeEnv(), ua = "vitest") => + worker.fetch(new Request(`https://docs-next.vyos.io${path}`, { headers: { "user-agent": ua } }), env); + +describe("apex router (Β§3.2 order)", () => { + it("/ β†’ 301 default version", async () => { + const r = await get("/"); + expect(r.status).toBe(301); + expect(r.headers.get("Location")).toBe("/en/rolling/"); + expect(r.headers.get("X-Apex-Build")).toBe("apex-sha"); + }); + it("/ redirect preserves the query string (like alias redirects)", async () => { + const r = await get("/?ref=email"); + expect(r.status).toBe(301); + expect(r.headers.get("Location")).toBe("/en/rolling/?ref=email"); + }); + it("/versions.json served from manifest with X-Apex-Build", async () => { + const r = await get("/versions.json"); + expect(r.status).toBe(200); + const body = await r.json() as { schema_version: number }; + expect(body.schema_version).toBe(2); + expect(r.headers.get("X-Apex-Build")).toBe("apex-sha"); + }); + it("dispatches /en/rolling/x to the binding with original path", async () => { + const r = await get("/en/rolling/cli/index.html"); + expect(await r.text()).toBe("rolling:/en/rolling/cli/index.html"); + expect(r.headers.get("X-Docs-Build")).toBe("sha-content"); + }); + it("content responses get apex security headers, content-owned headers untouched (Β§3.3)", async () => { + const r = await get("/en/rolling/cli/index.html"); + expect(r.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(r.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin"); + expect(r.headers.get("X-Docs-Build")).toBe("sha-content"); // not overwritten + expect(r.headers.get("X-Apex-Build")).toBeNull(); // apex build header is apex-paths-only + }); + it("alias 301 before dispatch", async () => { + const r = await get("/en/latest/cli/"); + expect(r.status).toBe(301); + expect(r.headers.get("Location")).toBe("/en/rolling/cli/"); + }); + it("binding 404 β†’ themed 404 with real 404 status (Β§3.2.7)", async () => { + const r = await get("/en/rolling/missing.html"); + expect(r.status).toBe(404); + expect(r.headers.get("X-Apex-Build")).toBe("apex-sha"); + }); + it("missing binding degrades to themed 503, not a crash", async () => { + const env = makeEnv({ DOCS_ROLLING: undefined }); + const r = await get("/en/rolling/", env); + expect(r.status).toBe(503); + }); + it("/kb/* β†’ themed 404 while seam unbound; dispatches when DOCS_KB present", async () => { + expect((await get("/kb/article")).status).toBe(404); + const env = makeEnv({ DOCS_KB: { fetch: async () => new Response("kb!") } }); + const r = await get("/kb/article", env); + expect(await r.text()).toBe("kb!"); + // /kb passthrough gets the same security headers as any other content response. + expect(r.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(r.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin"); + }); + it("unknown path β†’ themed 404 + security headers", async () => { + const r = await get("/nope"); + expect(r.status).toBe(404); + expect(r.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(r.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin"); + }); + it("UA gate blocks only in production env", async () => { + const prodEnv = makeEnv({ DOCS_ENV: "production" }); + // policy.block is empty at launch β†’ craft env-independent check via log class: + const r = await get("/en/rolling/", prodEnv, "GPTBot/1.0"); + expect(r.status).toBe(200); // log-only, not blocked + }); + it("apex responses carry Β§3.3 cache headers (no-store canary; revalidate production)", async () => { + expect((await get("/versions.json")).headers.get("Cache-Control")).toBe("no-store"); + const prod = await get("/versions.json", makeEnv({ DOCS_ENV: "production" })); + expect(prod.headers.get("Cache-Control")).toBe("public, max-age=0, s-maxage=300, must-revalidate"); + }); + it("error responses stay no-store in production β€” cache key excludes UA, so a cached 4xx/5xx would poison the edge for everyone", async () => { + const prodEnv = makeEnv({ DOCS_ENV: "production" }); + + // themed 404 (unknown path) + const notFound = await get("/nope", prodEnv); + expect(notFound.status).toBe(404); + expect(notFound.headers.get("Cache-Control")).toBe("no-store"); + + // themed 503 (missing runtime binding) + const svcUnavailable = await get("/en/rolling/", makeEnv({ DOCS_ENV: "production", DOCS_ROLLING: undefined })); + expect(svcUnavailable.status).toBe(503); + expect(svcUnavailable.headers.get("Cache-Control")).toBe("no-store"); + + // UA-block 403 β€” force a block entry via a policy override, since the shipped + // ua-policy.json block list is empty at launch. + vi.resetModules(); + vi.doMock("../ua-policy.json", () => ({ + default: { allow: [], log: [], block: ["EvilScraper"] }, + })); + try { + const { default: freshWorker } = await import("../src/index"); + const blocked = await freshWorker.fetch( + new Request("https://docs-next.vyos.io/en/rolling/", { headers: { "user-agent": "EvilScraper/1.0" } }), + prodEnv, + ); + expect(blocked.status).toBe(403); + expect(blocked.headers.get("Cache-Control")).toBe("no-store"); + } finally { + vi.doUnmock("../ua-policy.json"); + vi.resetModules(); + } + }); + it("/sitemap.xml index entries use the REQUEST origin, not a hard-coded docs.vyos.io", async () => { + // This Worker serves the canary origin too. A canary sitemap index pointing at production + // would send any checker that follows it to docs.vyos.io, so a candidate tree with broken + // per-version sitemaps would pass by silently grading production instead of itself. + const canary = await get("/sitemap.xml"); + const canaryBody = await canary.text(); + expect(canaryBody).toContain("https://docs-next.vyos.io/en/rolling/sitemap.xml"); + expect(canaryBody).not.toContain("docs.vyos.io"); + + const prod = await worker.fetch( + new Request("https://docs.vyos.io/sitemap.xml", { headers: { "user-agent": "vitest" } }), + makeEnv({ DOCS_ENV: "production" }), + ); + expect(await prod.text()).toContain("https://docs.vyos.io/en/1.5/sitemap.xml"); + }); + it("/llms.txt with missing default binding β†’ 503, never 404", async () => { + const env = makeEnv({ DOCS_ROLLING: undefined }); + expect((await get("/llms.txt", env)).status).toBe(503); + }); + it("/llms.txt forwards the ORIGINAL request (method + conditional-GET headers preserved)", async () => { + let seenMethod: string | null = null; + let seenIfNoneMatch: string | null = null; + const env = makeEnv({ + DOCS_ROLLING: { + fetch: async (req: Request) => { + seenMethod = req.method; + seenIfNoneMatch = req.headers.get("if-none-match"); + return new Response("llms body", { headers: { "content-type": "text/plain" } }); + }, + }, + }); + const r = await worker.fetch( + new Request("https://docs-next.vyos.io/llms.txt", { + method: "HEAD", + headers: { "user-agent": "vitest", "if-none-match": '"xyz"' }, + }), + env, + ); + expect(r.status).toBe(200); + expect(seenMethod).toBe("HEAD"); + expect(seenIfNoneMatch).toBe('"xyz"'); + }); + it("robots.txt passthrough forwards the ORIGINAL request (conditional-GET headers preserved)", async () => { + let seenIfNoneMatch: string | null = null; + const env = makeEnv({ + ASSETS: { + fetch: async (req: Request) => { + seenIfNoneMatch = req.headers.get("if-none-match"); + return new Response("User-agent: *", { headers: { "content-type": "text/plain" } }); + }, + }, + }); + const r = await worker.fetch( + new Request("https://docs-next.vyos.io/robots.txt", { + headers: { "user-agent": "vitest", "if-none-match": '"abc123"' }, + }), + env, + ); + expect(r.status).toBe(200); + expect(seenIfNoneMatch).toBe('"abc123"'); + }); + + it("UA gate in production tolerates a missing User-Agent header (no crash, fail-open)", async () => { + const prodEnv = makeEnv({ DOCS_ENV: "production" }); + const r = await worker.fetch( + new Request("https://docs-next.vyos.io/en/rolling/", { headers: {} }), + prodEnv, + ); + expect(r.status).toBe(200); + }); + + describe("legacy PDF R2 fallback (spec Β§5) β€” runs before version dispatch", () => { + // Fake R2Bucket mock following the preview-worker precedent (apex/preview/test/preview.test.ts). + // Simulates R2's onlyIf (If-None-Match β†’ body-less R2Object) + range (echoes the + // satisfied byte range on `.range`) behavior closely enough to exercise index.ts's + // handling of both without needing the real R2 binding. + const ETAG = '"pdf-etag-1"'; + const UPLOADED = new Date("2026-01-15T10:00:00Z"); + const BEFORE_UPLOAD = "Wed, 14 Jan 2026 10:00:00 GMT"; + const AFTER_UPLOAD = "Fri, 16 Jan 2026 10:00:00 GMT"; + // R2 hands back a ReadableStream, not a string, and the difference is exactly what makes + // an abandoned body observable: a stream the Worker neither sends nor cancels stays open + // holding its connection. A string-bodied mock cannot see that class of bug at all, so + // bodies here are real streams that record their own cancellation. `highWaterMark: 0` + // keeps `pull` from running until something actually reads, so a stream cancelled before + // any read still reaches its `cancel()` algorithm rather than being already closed. + function bodyStream(text: string, sink?: { cancelled: string[] }) { + return new ReadableStream({ + pull(c) { + c.enqueue(new TextEncoder().encode(text)); + c.close(); + }, + cancel() { + sink?.cancelled.push(text); + }, + }, { highWaterMark: 0 }); + } + + function r2Env( + objects: Record, + overrides: Record = {}, + sink?: { cancelled: string[] }, + ) { + return makeEnv({ + DOCS_PDFS: { + get: async (key: string, options?: { onlyIf?: Headers; range?: Headers }) => { + const hit = objects[key]; + if (!hit) return null; + const etag = hit.etag ?? ETAG; + const size = hit.body.length; + + const uploaded = hit.uploaded ?? UPLOADED; + const secs = (d: number) => Math.floor(d / 1000); // R2 compares at seconds granularity + + // R2 returns a body-less R2Object whenever an onlyIf precondition FAILS, and it + // never says which one did β€” that ambiguity is exactly what index.ts has to + // resolve by re-evaluating the request's conditionals against these validators. + // R2 ANDs every validator it is handed and knows nothing about the request + // METHOD; index.ts is what filters the set down to the ones RFC 9110 Β§13.2.2 + // says apply, so this mock deliberately evaluates whatever it is given. + const bodyless = { httpEtag: etag, size, uploaded }; + const ifNoneMatch = options?.onlyIf?.get?.("if-none-match"); + if (ifNoneMatch && (ifNoneMatch === "*" || ifNoneMatch.split(",").some( + (t) => t.trim().replace(/^W\//, "") === etag.replace(/^W\//, "")))) { + return bodyless; // If-None-Match matched β†’ "not modified" + } + const ifMatch = options?.onlyIf?.get?.("if-match"); + if (ifMatch && ifMatch !== "*" && !ifMatch.split(",").some( + (t) => t.trim() === etag)) { + return bodyless; // If-Match failed β†’ precondition failed + } + const ifUnmodifiedSince = options?.onlyIf?.get?.("if-unmodified-since"); + if (ifUnmodifiedSince && secs(uploaded.getTime()) > secs(Date.parse(ifUnmodifiedSince))) { + return bodyless; // object is newer than the client's copy + } + const ifModifiedSince = options?.onlyIf?.get?.("if-modified-since"); + if (ifModifiedSince && secs(uploaded.getTime()) <= secs(Date.parse(ifModifiedSince))) { + return bodyless; // not modified since the client's copy + } + + // The whole-object result. R2 returns this shape for a plain un-ranged get AND + // β€” critically β€” for every Range header it declines to honour. Both verified + // against a real R2 binding under @cloudflare/vitest-pool-workers. + const whole = { + httpEtag: etag, size, uploaded, body: bodyStream(hit.body, sink), + range: { offset: 0, length: size }, + }; + + const rangeHeader = options?.range?.get?.("range"); + if (!rangeHeader) return whole; + + // Range parsing mirrors R2's OWN grammar rather than being merely "strict": + // miniflare src/workers/shared/range.ts uses /^ *bytes *=/i for the prefix and + // /^ *(\d+)? *- *(\d+)? *$/ per comma-separated spec β€” ASCII SPACE ONLY, never + // \s. index.ts's classifier accepts \s, so the two grammars genuinely disagree + // on a tab. Reproducing R2's grammar here is what makes the tab-separated-Range + // test a real divergence rather than an artefact of a lazily-strict mock. + const prefix = / *bytes *=/i.exec(rangeHeader); + if (!prefix || prefix.index !== 0) return whole; // unknown unit β†’ ignored + const specs = rangeHeader.substring(prefix[0].length).split(","); + if (specs.length !== 1) return whole; // multi-range β†’ ignored + const m = /^ *(\d+)? *- *(\d+)? *$/.exec(specs[0]); + if (!m) return whole; // unparseable (a tab lands here, exactly as in R2) + const [, startRaw, endRaw] = m; + + if (startRaw !== undefined && endRaw !== undefined) { + const offset = Number(startRaw); + const last = Number(endRaw); + // Observed R2: an int-range with first-pos >= size, or an invalid spec with + // last < first, is IGNORED β€” R2 hands back the complete object rather than + // throwing or returning a zero-length range. (`bytes=10-20` and `bytes=5-2` + // on a 10-byte object both returned `{offset: 0, length: 10}` + the full body.) + if (offset >= size || last < offset) return whole; + const length = Math.min(last, size - 1) - offset + 1; // last-pos clamps to EOF + return { + httpEtag: etag, size, uploaded, + body: bodyStream(hit.body.slice(offset, offset + length), sink), + range: { offset, length }, + }; + } + if (startRaw !== undefined) { // open-ended `bytes=5-` + const offset = Number(startRaw); + if (offset >= size) return whole; // unsatisfiable β†’ ignored + return { + httpEtag: etag, size, uploaded, + body: bodyStream(hit.body.slice(offset), sink), + range: { offset, length: size - offset }, + }; + } + if (endRaw !== undefined) { + // Suffix form. R2Range is a three-shape union and this arm deliberately + // returns the RAW `{suffix}` shape rather than pre-normalizing to + // `{offset, length}` β€” that is what exercises index.ts's resolveRange(). + // (workerd itself normalizes, but the type admits this shape.) + const n = Number(endRaw); + // miniflare: a suffix >= length yields no ranges, and `bytes=-0` is skipped β€” + // both leave R2 serving the complete object rather than rejecting. + if (n === 0 || n >= size) return whole; + return { + httpEtag: etag, size, uploaded, + body: bodyStream(hit.body.slice(size - n), sink), + range: { suffix: n }, + }; + } + return whole; // bare `bytes=-` + }, + } as unknown as R2Bucket, + ...overrides, + }); + } + + it("served-from-R2 200: content-type application/pdf, PDF cache class, X-Apex-Build present, etag + accept-ranges", async () => { + const env = r2Env( + { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }, + { DOCS_ENV: "production" }, + ); + const r = await get("/en/1.3/vyos-documentation.pdf", env); + expect(r.status).toBe(200); + expect(await r.text()).toBe("PDF-BYTES"); + expect(r.headers.get("content-type")).toBe("application/pdf"); + expect(r.headers.get("Cache-Control")).toBe("public, max-age=300, s-maxage=600, must-revalidate"); + expect(r.headers.get("X-Apex-Build")).toBe("apex-sha"); + expect(r.headers.get("etag")).toBe(ETAG); + expect(r.headers.get("accept-ranges")).toBe("bytes"); + expect(r.headers.get("content-length")).toBe("9"); + }); + + it("If-None-Match matching R2's etag β†’ 304, no body, etag present, PDF cache class", async () => { + const env = r2Env( + { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }, + { DOCS_ENV: "production" }, + ); + const r = await worker.fetch( + new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", { + headers: { "user-agent": "vitest", "if-none-match": ETAG }, + }), + env, + ); + expect(r.status).toBe(304); + expect(await r.text()).toBe(""); + expect(r.headers.get("etag")).toBe(ETAG); + expect(r.headers.get("Cache-Control")).toBe("public, max-age=300, s-maxage=600, must-revalidate"); + expect(r.headers.get("content-type")).toBeNull(); + }); + + it("Range: bytes=0-3 β†’ 206 + Content-Range + partial body, PDF cache class", async () => { + const env = r2Env( + { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }, // length 9 + { DOCS_ENV: "production" }, + ); + const r = await worker.fetch( + new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", { + headers: { "user-agent": "vitest", range: "bytes=0-3" }, + }), + env, + ); + expect(r.status).toBe(206); + expect(await r.text()).toBe("PDF-"); + expect(r.headers.get("content-range")).toBe("bytes 0-3/9"); + expect(r.headers.get("content-length")).toBe("4"); + expect(r.headers.get("etag")).toBe(ETAG); + expect(r.headers.get("Cache-Control")).toBe("public, max-age=300, s-maxage=600, must-revalidate"); + }); + + it("canary env still forces no-store on the PDF response (canary rule wins)", async () => { + const env = r2Env({ "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }); // default DOCS_ENV: canary + const r = await get("/en/1.3/vyos-documentation.pdf", env); + expect(r.status).toBe(200); + expect(r.headers.get("Cache-Control")).toBe("no-store"); + }); + + it("R2 miss β†’ themed 404, no-store, never falls through to DOCS_LEGACY dispatch", async () => { + const env = r2Env({}, { DOCS_ENV: "production" }); // bucket present but empty + const r = await get("/en/1.3/vyos-documentation.pdf", env); + expect(r.status).toBe(404); + expect(r.headers.get("Cache-Control")).toBe("no-store"); + expect(await r.text()).not.toContain("legacy:"); // not the DOCS_LEGACY fetcher's echoed tag + }); + + it("R2 get() throwing β†’ themed 503, no-store", async () => { + const env = makeEnv({ + DOCS_ENV: "production", + DOCS_PDFS: { get: async () => { throw new Error("R2 unavailable"); } } as unknown as R2Bucket, + }); + const r = await get("/en/1.3/vyos-documentation.pdf", env); + expect(r.status).toBe(503); + expect(r.headers.get("Cache-Control")).toBe("no-store"); + }); + + it("DOCS_PDFS binding missing β†’ themed 503, not a crash", async () => { + const env = makeEnv({ DOCS_ENV: "production" }); // no DOCS_PDFS at all + const r = await get("/en/1.3/vyos-documentation.pdf", env); + expect(r.status).toBe(503); + }); + + it("other versions' PDF paths never touch DOCS_PDFS β€” fall through to normal dispatch", async () => { + // env carries a DOCS_PDFS bucket that would 500 if queried at all, proving the + // rolling/1.5/1.4 PDF paths (no pdf_r2_key on those manifest entries) skip it entirely. + const env = r2Env( + {}, + { + DOCS_ENV: "production", + DOCS_PDFS: { get: async () => { throw new Error("must not be called for non-1.3 PDFs"); } } as unknown as R2Bucket, + }, + ); + const r = await get("/en/rolling/vyos-documentation.pdf", env); + expect(r.status).toBe(200); + expect(await r.text()).toBe("rolling:/en/rolling/vyos-documentation.pdf"); + }); + + it("1.2 (pdf: null, no pdf_r2_key) falls through to normal dispatch unaffected", async () => { + const env = r2Env({}, { DOCS_ENV: "production" }); + const r = await get("/en/1.2/vyos-documentation.pdf", env); + expect(r.status).toBe(200); + expect(await r.text()).toBe("legacy:/en/1.2/vyos-documentation.pdf"); + }); + + // --- Regression: a plain GET must never answer 206. R2 reports a whole-object `range` + // on un-ranged gets, so keying the 206 off `obj.range` alone made EVERY plain GET of the + // 1.3 PDF a 206 with a Content-Range β€” which is what the nightly canary sweep observed + // ("/en/1.3/vyos-documentation.pdf: status=206") and what RFC 9110 Β§15.3.7 forbids. --- + + it("plain GET (no Range header) β†’ 200, never 206, even though R2 echoes a whole-object range", async () => { + const env = r2Env( + { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }, + { DOCS_ENV: "production" }, + ); + const r = await get("/en/1.3/vyos-documentation.pdf", env); + expect(r.status).toBe(200); + expect(r.headers.get("content-range")).toBeNull(); + expect(r.headers.get("content-length")).toBe("9"); + expect(await r.text()).toBe("PDF-BYTES"); + }); + + it("suffix Range (bytes=-4) β†’ 206 with the last 4 bytes and a matching Content-Range/Length", async () => { + // The `{suffix}` R2Range shape used to miss the `"offset" in range` guard entirely and + // fall through to the 200 branch, where content-length claimed the WHOLE object size + // while the body held only the tail β€” a corrupt download for any resumed fetch. + const env = r2Env( + { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }, // length 9 + { DOCS_ENV: "production" }, + ); + const r = await worker.fetch( + new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", { + headers: { "user-agent": "vitest", range: "bytes=-4" }, + }), + env, + ); + expect(r.status).toBe(206); + expect(await r.text()).toBe("YTES"); + expect(r.headers.get("content-range")).toBe("bytes 5-8/9"); + expect(r.headers.get("content-length")).toBe("4"); + }); + + it("failed If-Match β†’ 412, not 304 (a 304 would tell the client its stale copy is current)", async () => { + const env = r2Env( + { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }, + { DOCS_ENV: "production" }, + ); + const r = await worker.fetch( + new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", { + headers: { "user-agent": "vitest", "if-match": '"some-other-etag"' }, + }), + env, + ); + expect(r.status).toBe(412); + expect(await r.text()).toBe(""); + // 412 is an error response, so the Β§3.3 precedence forces no-store over the PDF class. + expect(r.headers.get("Cache-Control")).toBe("no-store"); + }); + + // --- RFC 9110 Β§13.2.2 precondition PRECEDENCE. R2 reports THAT an onlyIf precondition + // failed, never WHICH one, so index.ts re-derives it from the request's own headers. + // Testing the not-modified family first got the ordering backwards. --- + + async function conditional(headers: Record, method = "GET") { + const env = r2Env( + { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }, + { DOCS_ENV: "production" }, + ); + return worker.fetch( + new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", { + method, + headers: { "user-agent": "vitest", ...headers }, + }), + env, + ); + } + + it("If-None-Match alone (matching) β†’ 304", async () => { + expect((await conditional({ "if-none-match": '"pdf-etag-1"' })).status).toBe(304); + }); + + it("If-Match + If-None-Match together β†’ 412: If-Match takes strict precedence", async () => { + // The failing precondition here is If-Match. Answering 304 (because If-None-Match is + // also present) would tell the client its stale copy is still current, when the + // higher-precedence check it asked for actually failed. Β§13.2.2 steps 1 and 3. + const r = await conditional({ + "if-match": '"stale-etag"', + "if-none-match": '"pdf-etag-1"', + }); + expect(r.status).toBe(412); + expect(r.headers.get("Cache-Control")).toBe("no-store"); + }); + + it("If-Unmodified-Since β†’ 412, never 304 (Β§13.2.2 step 2 outranks the 304 family)", async () => { + const r = await conditional({ + "if-unmodified-since": "Wed, 01 Jan 2020 00:00:00 GMT", + "if-none-match": '"pdf-etag-1"', + }); + expect(r.status).toBe(412); + }); + + it("a failed If-None-Match on a non-GET/HEAD method β†’ 412, not 304", async () => { + // Β§13.1.2: on a false If-None-Match the origin MUST answer "304 ... if the request + // method is GET or HEAD or 412 ... for all other request methods". Nothing upstream + // restricts the method, so this path is reachable and 304 would be an invalid answer. + const r = await conditional({ "if-none-match": '"pdf-etag-1"' }, "POST"); + expect(r.status).toBe(412); + }); + + it("HEAD keeps the 304 (it is one of the two methods Β§13.1.2 allows it for)", async () => { + expect((await conditional({ "if-none-match": '"pdf-etag-1"' }, "HEAD")).status).toBe(304); + }); + + // --- RFC 9110 Β§14.1.2 / Β§15.5.17 range satisfiability. R2 signals "I ignored your + // Range" by returning the WHOLE object β€” the same shape as a satisfied whole-object + // range β€” so index.ts re-derives intent from the client's own header. --- + + async function ranged(rangeHeader: string, body = "PDF-BYTES") { + const env = r2Env( + { "legacy/1.3/vyos-documentation.pdf": { body } }, + { DOCS_ENV: "production" }, + ); + return worker.fetch( + new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", { + headers: { "user-agent": "vitest", range: rangeHeader }, + }), + env, + ); + } + + it("Range past EOF β†’ 416 + Content-Range: bytes */size, NOT a 206 serving the whole body", async () => { + // The pre-fix path trusted obj.range, and R2 answers an unsatisfiable range with the + // complete object β€” so this returned `206 Content-Range: bytes 0-8/9` plus all 9 + // bytes. A client resuming at byte 99 would have appended bytes 0-8 to its partial + // file and silently corrupted the download. + const r = await ranged("bytes=99-"); // body is 9 bytes + expect(r.status).toBe(416); + expect(r.headers.get("content-range")).toBe("bytes */9"); + expect(await r.text()).toBe(""); + expect(r.headers.get("Cache-Control")).toBe("no-store"); // 416 >= 400 + }); + + it("Range starting exactly at EOF β†’ 416 (Β§14.1.2: satisfiable iff first-pos < length)", async () => { + const r = await ranged("bytes=9-"); + expect(r.status).toBe(416); + expect(r.headers.get("content-range")).toBe("bytes */9"); + }); + + it("closed Range wholly past EOF β†’ 416", async () => { + const r = await ranged("bytes=20-30"); + expect(r.status).toBe(416); + }); + + it("suffix-length 0 β†’ 416 (Β§14.1.2 names it unsatisfiable)", async () => { + const r = await ranged("bytes=-0"); + expect(r.status).toBe(416); + expect(r.headers.get("content-range")).toBe("bytes */9"); + }); + + it("multi-range β†’ 200 with the complete body, not a single-range 206 that misdescribes it", async () => { + // R2 ignores multi-ranges and returns the whole object. Stamping + // `Content-Range: bytes 0-8/9` on it would claim a single partial covering + // everything, in answer to a request for two disjoint sub-ranges. + const r = await ranged("bytes=0-1,4-5"); + expect(r.status).toBe(200); + expect(r.headers.get("content-range")).toBeNull(); + expect(await r.text()).toBe("PDF-BYTES"); + }); + + it("malformed Range β†’ 200 with the complete body (Β§14.1.2: invalid spec is ignored)", async () => { + for (const bad of ["bytes=abc", "bytes=-", "bytes=5-2", "items=0-5"]) { + const r = await ranged(bad); + expect(r.status, `Range: ${bad}`).toBe(200); + expect(r.headers.get("content-range"), `Range: ${bad}`).toBeNull(); + } + }); + + it("a satisfiable whole-object Range still gets a real 206", async () => { + // The 416/200 guards must not swallow the legitimate case: `bytes=0-` IS satisfiable + // (first-pos 0 < 9), so it keeps its 206 even though the payload is the whole object. + const r = await ranged("bytes=0-"); + expect(r.status).toBe(206); + expect(r.headers.get("content-range")).toBe("bytes 0-8/9"); + expect(await r.text()).toBe("PDF-BYTES"); + }); + + // --- The classifier's grammar and R2's grammar are NOT the same grammar, and the + // Worker no longer assumes they are: it checks the bounds it derived against the bytes + // R2 actually returned before promising a 206. --- + + it("tab-separated Range β†’ 200 with the whole body, never a 206 for bytes nobody sliced", async () => { + // R2 parses ranges with ASCII space only (/^ *bytes *=/i + /^ *(\d+)? *- *(\d+)? *$/); + // the classifier's \s also accepts a tab. So this header says "single, bytes 2-4" + // here and "unparseable, serve everything" to R2 β€” and trusting the classifier alone + // shipped `206 Content-Range: bytes 0-8/9` carrying all 9 bytes in answer to a + // request for 3. Same lying-206 class as the unsatisfiable case above. + const r = await ranged("bytes=2\t-\t4"); + expect(r.status).toBe(200); + expect(r.headers.get("content-range")).toBeNull(); + expect(r.headers.get("content-length")).toBe("9"); + expect(await r.text()).toBe("PDF-BYTES"); + }); + + it("LEADING whitespace is stripped before either parser sees it, so only INNER whitespace diverges", async () => { + // Worth pinning because it bounds the divergence surface. `Headers` strips the + // optional whitespace around a field value (RFC 9110 Β§5.5), so "\tbytes=2-4" arrives + // as "bytes=2-4" and both grammars accept it β€” the 206 here is correct, not a + // regression. Only whitespace INSIDE the value (the test above) can reach the two + // parsers intact and be read differently by them. + const r = await ranged("\tbytes=2-4"); + expect(r.status).toBe(206); + expect(r.headers.get("content-range")).toBe("bytes 2-4/9"); + }); + + it("space-separated Range stays a 206 β€” R2 accepts spaces, so the bounds still agree", async () => { + // The degrade must be driven by actual disagreement, not by giving up on whitespace. + const r = await ranged("bytes = 2-4"); + expect(r.status).toBe(206); + expect(r.headers.get("content-range")).toBe("bytes 2-4/9"); + expect(await r.text()).toBe("F-B"); + }); + + it("positions above 2^53: an invalid spec is ignored (200), not read as unsatisfiable (416)", async () => { + // Number() rounds 9007199254740993 down to ...992, so `last < first` read as false and + // this invalid spec was promoted to "unsatisfiable" β†’ 416. Β§14.1.2 says ignore it. + const r = await ranged("bytes=9007199254740993-9007199254740992"); + expect(r.status).toBe(200); + expect(await r.text()).toBe("PDF-BYTES"); + }); + + it("a partial body the Worker did not ask for is a 503, never a 206 for bytes nobody requested", async () => { + // Belt and braces for a future R2 whose grammar accepts something this classifier + // calls "ignored". The body in hand is a slice of bounds the request never named: + // a 200 would ship Content-Length: 9 over 3 bytes, and a 206 would carry + // `Content-Range: bytes 2-4/9` in answer to `bytes=0-1,4-5` β€” a selected range the + // client did not select, which Β§14.4 does not permit. Neither is honest, so the + // divergence is surfaced as a failure (plus the r2-range-divergence log line) rather + // than dressed up as a success. Unreachable under today's workerd, which ignores + // multi-range and returns the whole object. + const env = makeEnv({ + DOCS_ENV: "production", + DOCS_PDFS: { + get: async () => ({ + httpEtag: ETAG, size: 9, uploaded: UPLOADED, + body: "F-B", range: { offset: 2, length: 3 }, + }), + } as unknown as R2Bucket, + }); + const r = await worker.fetch( + new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", { + headers: { "user-agent": "vitest", range: "bytes=0-1,4-5" }, // classifier: ignored + }), + env, + ); + expect(r.status).toBe(503); + expect(r.headers.get("content-range")).toBeNull(); + }); + + // --- Β§14.2: "GET is the only method for which range handling is defined." --- + + it("HEAD + Range β†’ 200, no Content-Range: Range is ignored on every method but GET", async () => { + const env = r2Env( + { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }, + { DOCS_ENV: "production" }, + ); + const r = await worker.fetch( + new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", { + method: "HEAD", + headers: { "user-agent": "vitest", range: "bytes=0-3" }, + }), + env, + ); + expect(r.status).toBe(200); + expect(r.headers.get("content-range")).toBeNull(); + expect(r.headers.get("content-length")).toBe("9"); + }); + + it("POST + an unsatisfiable Range β†’ 200, not 416: the header is ignored, not judged", async () => { + const env = r2Env( + { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }, + { DOCS_ENV: "production" }, + ); + const r = await worker.fetch( + new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", { + method: "POST", + headers: { "user-agent": "vitest", range: "bytes=99-" }, + }), + env, + ); + expect(r.status).toBe(200); + expect(r.headers.get("content-range")).toBeNull(); + }); + + // --- Β§13.2.1: "a server MUST ignore the conditional request header fields defined by + // this specification when received with a request method that does not involve the + // selection or modification of a selected representation, such as CONNECT, OPTIONS, or + // TRACE." Only OPTIONS is testable through worker.fetch() β€” TRACE and CONNECT are + // forbidden methods in the fetch spec and `new Request` refuses to construct them. --- + + it("OPTIONS ignores conditionals entirely: neither a failing nor a matching one is judged", async () => { + // A stale If-Match reached R2 as an onlyIf, came back body-less, and this Worker had + // no reading of that but 412 β€” so an OPTIONS carrying a conditional a client had left + // lying around was refused where the same request without it succeeded. + const options = (headers: Record) => worker.fetch( + new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", { + method: "OPTIONS", + headers: { "user-agent": "vitest", ...headers }, + }), + r2Env({ "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }, + { DOCS_ENV: "production" }), + ); + expect((await options({ "if-match": '"stale-etag"' })).status).toBe(200); // not 412 + expect((await options({ "if-unmodified-since": BEFORE_UPLOAD })).status).toBe(200); // not 412 + expect((await options({ "if-none-match": ETAG })).status).toBe(200); // not 304/412 + }); + + // --- RFC 9110 Β§13.1.5 If-Range. R2's R2Conditional carries only etagMatches / + // etagDoesNotMatch / uploadedBefore / uploadedAfter, so an If-Range in the forwarded + // Headers is silently DROPPED and the range applied unconditionally. --- + + async function withIfRange(ifRange: string, rangeHeader: string) { + const env = r2Env( + { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }, + { DOCS_ENV: "production" }, + ); + return worker.fetch( + new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", { + headers: { "user-agent": "vitest", range: rangeHeader, "if-range": ifRange }, + }), + env, + ); + } + + // --- Abandoned body streams. R2 hands back a body on paths whose response carries + // none; a stream that is neither sent nor cancelled holds its connection until GC. --- + + it("an unsatisfiable Range cancels the whole-object body it answers 416 without", async () => { + // The largest abandoned stream on any path here: R2 answers an unsatisfiable Range + // with the COMPLETE object, which for the 1.3 PDF is 29.2 MiB, and the 416 sends none + // of it. Cancelling aborts the transfer rather than draining or leaking it. + const sink = { cancelled: [] as string[] }; + const env = r2Env( + { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }, + { DOCS_ENV: "production" }, sink, + ); + const r = await worker.fetch( + new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", { + headers: { "user-agent": "vitest", range: "bytes=99-" }, + }), + env, + ); + expect(r.status).toBe(416); + expect(sink.cancelled).toEqual(["PDF-BYTES"]); + }); + + it("a stale If-Range cancels the sliced body it discards before re-reading", async () => { + const sink = { cancelled: [] as string[] }; + const env = r2Env( + { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }, + { DOCS_ENV: "production" }, sink, + ); + const r = await worker.fetch( + new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", { + headers: { "user-agent": "vitest", range: "bytes=4-", "if-range": '"stale-etag"' }, + }), + env, + ); + expect(r.status).toBe(200); + expect(await r.text()).toBe("PDF-BYTES"); + expect(sink.cancelled).toEqual(["BYTES"]); // the abandoned slice, not the served body + }); + + it("a served body is NEVER cancelled β€” the cleanup must not reach the response path", async () => { + const sink = { cancelled: [] as string[] }; + const env = r2Env( + { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }, + { DOCS_ENV: "production" }, sink, + ); + const r = await worker.fetch( + new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", { + headers: { "user-agent": "vitest", range: "bytes=4-" }, // satisfiable, honoured + }), + env, + ); + expect(r.status).toBe(206); + expect(await r.text()).toBe("BYTES"); + expect(sink.cancelled).toEqual([]); + }); + + it("If-Range matching the current ETag β†’ the range is honoured, 206", async () => { + const r = await withIfRange(ETAG, "bytes=4-"); + expect(r.status).toBe(206); + expect(r.headers.get("content-range")).toBe("bytes 4-8/9"); + expect(await r.text()).toBe("BYTES"); + }); + + it("If-Range naming a STALE ETag β†’ 200 with the complete new representation", async () => { + // The corruption case. R2 cannot evaluate If-Range, so it applied the range anyway and + // this returned bytes 4+ of the NEW object under a 206 β€” a resuming downloader then + // appends the new tail to its old prefix and silently produces a broken PDF. Β§13.1.5 + // requires the failed validator to yield the complete representation instead. + const r = await withIfRange('"stale-etag"', "bytes=4-"); + expect(r.status).toBe(200); + expect(r.headers.get("content-range")).toBeNull(); + expect(r.headers.get("content-length")).toBe("9"); + expect(await r.text()).toBe("PDF-BYTES"); + }); + + it("a stale If-Range wins over an unsatisfiable spec β†’ 200, not 416", async () => { + // Ordering matters: a Range being ignored entirely (Β§13.1.5) is decided before + // satisfiability (Β§14.1.2) is ever judged, so no 416 may escape here. + const r = await withIfRange('"stale-etag"', "bytes=99-"); + expect(r.status).toBe(200); + expect(await r.text()).toBe("PDF-BYTES"); + }); + + it("a WEAK If-Range validator never matches (Β§13.1.5 requires a strong one)", async () => { + const r = await withIfRange('W/"pdf-etag-1"', "bytes=4-"); + expect(r.status).toBe(200); + expect(await r.text()).toBe("PDF-BYTES"); + }); + + it("an HTTP-date If-Range never matches β€” this Worker emits no Last-Modified to compare against", async () => { + const r = await withIfRange(AFTER_UPLOAD, "bytes=4-"); + expect(r.status).toBe(200); + expect(await r.text()).toBe("PDF-BYTES"); + }); + + it("an object rewritten between the two If-Range reads is still judged against the request's preconditions", async () => { + // Β§13.2.1 requires preconditions to hold for the representation ULTIMATELY SELECTED. + // The re-read after a stale If-Range was a BARE get(), dropping every other + // precondition the request carried, so: If-Match passes on read 1, the key is + // rewritten, and the bare read 2 then answered 200 with the very representation the + // client's If-Match excluded. The key does get rewritten β€” `force_pdf_refresh: true` + // in the legacy snapshot repo's deploy workflow re-uploads it. + const KEY = "legacy/1.3/vyos-documentation.pdf"; + const objects: Record = { + [KEY]: { body: "PDF-BYTES", etag: ETAG }, + }; + // Borrow the shared mock, then wrap it so the object changes BETWEEN the two reads. + type Bucket = { get: (key: string, options?: unknown) => Promise }; + const inner = (r2Env(objects) as unknown as { DOCS_PDFS: Bucket }).DOCS_PDFS; + let reads = 0; + const env = r2Env(objects, { + DOCS_ENV: "production", + DOCS_PDFS: { + get: async (key: string, options?: unknown) => { + const result = await inner.get(key, options); + if (++reads === 1) objects[key].etag = '"pdf-etag-2"'; // rewritten mid-flight + return result; + }, + }, + }); + const r = await worker.fetch( + new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", { + headers: { + "user-agent": "vitest", + range: "bytes=4-", + "if-range": '"stale-etag"', // fails β†’ forces the whole-object re-read + "if-match": ETAG, // satisfied on read 1, violated by read 2's object + }, + }), + env, + ); + expect(reads).toBe(2); + expect(r.status).toBe(412); + expect(await r.text()).toBe(""); + // The validator reported is the one belonging to the object the verdict was reached on. + expect(r.headers.get("etag")).toBe('"pdf-etag-2"'); + expect(r.headers.get("content-type")).not.toBe("application/pdf"); + }); + + // --- Β§13.2.2 preconditions, decided by EVALUATING the validators rather than by + // guessing from which headers are present. --- + + it("If-Match satisfied + If-None-Match satisfied on a GET β†’ 304, not 412", async () => { + // The inverse of the If-Match-fails case above, and the one presence-inference got + // wrong: If-Match matches (so step 1 passes) while If-None-Match also matches (so + // step 3 FAILS) β€” Β§13.1.2 owes a 304. Seeing an If-Match header at all returned 412. + const r = await conditional({ + "if-match": '"pdf-etag-1"', + "if-none-match": '"pdf-etag-1"', + }); + expect(r.status).toBe(304); + expect(await r.text()).toBe(""); + }); + + it("If-Modified-Since alone on a non-GET/HEAD β†’ 200: Β§13.2.2 step 4 never evaluates it", async () => { + // R2 ANDs every validator it is handed and knows nothing about the method, so + // forwarding the raw headers made it fail the request on a validator the RFC says to + // ignore β€” and the only answer left was a 412. Filtering the conditionals down to the + // applicable set means the request simply proceeds. + const r = await conditional({ "if-modified-since": AFTER_UPLOAD }, "POST"); + expect(r.status).toBe(200); + expect(await r.text()).toBe("PDF-BYTES"); + }); + + it("If-Modified-Since alone on a GET IS evaluated β†’ 304", async () => { + // The other side of the filter: dropping the header for non-GET must not drop it here. + expect((await conditional({ "if-modified-since": AFTER_UPLOAD })).status).toBe(304); + }); + + it("a satisfied If-Unmodified-Since serves the object; a failed one is 412", async () => { + expect((await conditional({ "if-unmodified-since": AFTER_UPLOAD })).status).toBe(200); + expect((await conditional({ "if-unmodified-since": BEFORE_UPLOAD })).status).toBe(412); + }); + + it("If-Match: * matches any existing representation (Β§13.1.1)", async () => { + expect((await conditional({ "if-match": "*" })).status).toBe(200); + }); + + it("If-None-Match: * on an existing representation fails β†’ 304 on a GET", async () => { + expect((await conditional({ "if-none-match": "*" })).status).toBe(304); + }); + + it("If-None-Match matches WEAKLY (Β§13.1.2 mandates the weak comparison)", async () => { + // A weak tag from the client must still match a strong stored tag, or every + // revalidation from a cache that weakened the tag re-downloads 29.2 MiB. + expect((await conditional({ "if-none-match": 'W/"pdf-etag-1"' })).status).toBe(304); + }); + + it("If-None-Match honours a comma-separated tag list", async () => { + const r = await conditional({ "if-none-match": '"other", "pdf-etag-1"' }); + expect(r.status).toBe(304); + }); + + it("If-Match compares STRONGLY: a weak tag from the client never satisfies it", async () => { + expect((await conditional({ "if-match": 'W/"pdf-etag-1"' })).status).toBe(412); + }); + }); + + describe("resolveRange (R2Range is a three-shape union)", () => { + it("resolves offset+length, length-only, and suffix forms, clamped to the object size", () => { + expect(resolveRange({ offset: 2, length: 3 }, 10)).toEqual({ start: 2, length: 3 }); + expect(resolveRange({ offset: 4 }, 10)).toEqual({ start: 4, length: 6 }); // to end of object + expect(resolveRange({ length: 4 }, 10)).toEqual({ start: 0, length: 4 }); // offset defaults to 0 + expect(resolveRange({ suffix: 3 }, 10)).toEqual({ start: 7, length: 3 }); // trailing bytes + expect(resolveRange({ suffix: 99 }, 10)).toEqual({ start: 0, length: 10 }); // suffix past start clamps + expect(resolveRange({ offset: 8, length: 99 }, 10)).toEqual({ start: 8, length: 2 }); // length clamps + }); + + it("never returns a negative length, so Content-Length can never go negative", () => { + // A real R2 binding cannot produce these, but resolveRange is exported and its + // contract says "clamped to the object size" β€” that must hold for every input the + // R2Range type admits, not just the ones observed in practice. + expect(resolveRange({ offset: 0, length: -5 }, 10)).toEqual({ start: 0, length: 0 }); + expect(resolveRange({ offset: 10, length: 5 }, 10)).toEqual({ start: 10, length: 0 }); + expect(resolveRange({ offset: 99 }, 10)).toEqual({ start: 10, length: 0 }); + expect(resolveRange({ suffix: -3 }, 10)).toEqual({ start: 10, length: 0 }); + expect(resolveRange({ offset: 0, length: 0 }, 0)).toEqual({ start: 0, length: 0 }); + }); + }); + + describe("classifyRangeHeader (Β§14.1.2 satisfiability, re-derived from the client's header)", () => { + // R2 cannot tell us: it answers unsatisfiable, malformed, multi-range and unknown-unit + // Range headers identically β€” with the complete object β€” which is also exactly what a + // satisfied whole-object range looks like. Verified against a real R2 binding. + it("single satisfiable byte ranges β†’ single, with the concrete bounds they select", () => { + // The bounds are the point: they are what the Worker compares against the bytes R2 + // actually returned before it will promise a 206. + const cases: Array<[string, number, number]> = [ + ["bytes=0-", 0, 10], ["bytes=5-", 5, 5], ["bytes=0-0", 0, 1], + ["bytes=-3", 7, 3], ["bytes=0-9", 0, 10], + ["bytes=5-99", 5, 5], // last-pos clamps to EOF + ["bytes=9-", 9, 1], + ["bytes=-99", 0, 10], // suffix past the start is the whole object + ]; + for (const [h, start, length] of cases) { + expect(classifyRangeHeader(h, 10), h).toEqual({ kind: "single", start, length }); + } + }); + + it("unsatisfiable ranges β†’ unsatisfiable", () => { + for (const h of ["bytes=10-", "bytes=99-", "bytes=10-20", "bytes=-0"]) { + // first-pos >= length, or suffix-length 0 + expect(classifyRangeHeader(h, 10), h).toEqual({ kind: "unsatisfiable" }); + } + }); + + it("multi-range, malformed and unknown-unit β†’ ignored (Β§14.1.2: an invalid spec is ignored)", () => { + for (const h of ["bytes=0-1,4-5", "bytes=abc", "bytes=-", "items=0-5", "bytes=5-2", ""]) { + expect(classifyRangeHeader(h, 10), h).toEqual({ kind: "ignored" }); + } + }); + + it("tolerates the case and whitespace variation R2 itself accepts", () => { + // R2 honours all three of these, so misreading them as "ignored" would downgrade a + // legitimate 206 to a 200. + expect(classifyRangeHeader("BYTES=0-5", 10)).toEqual({ kind: "single", start: 0, length: 6 }); + expect(classifyRangeHeader("bytes = 0-5", 10)).toEqual({ kind: "single", start: 0, length: 6 }); + expect(classifyRangeHeader("bytes=0-5 ", 10)).toEqual({ kind: "single", start: 0, length: 6 }); + }); + + it("zero-length representation: only a non-zero suffix-range is satisfiable", () => { + // Β§14.1.2 states this case explicitly. `bytes=0-` fails first-pos < length (0 < 0). + expect(classifyRangeHeader("bytes=0-", 0)).toEqual({ kind: "unsatisfiable" }); + // Satisfiable, but it selects zero bytes β€” no Content-Range can describe an empty + // selection (Β§14.4), so the caller's `length > 0` guard sends it to a 200. + expect(classifyRangeHeader("bytes=-5", 0)).toEqual({ kind: "single", start: 0, length: 0 }); + }); + + it("positions above 2^53 compare exactly β€” an invalid spec stays ignored, not 416", () => { + // Number() rounds both of these to 9007199254740992, so `last < first` read as false + // and the spec was promoted from "invalid, ignore it" (Β§14.1.2 β†’ 200) to + // "unsatisfiable" (β†’ 416). Digit-string comparison is exact at any magnitude. + expect(classifyRangeHeader("bytes=9007199254740993-9007199254740992", 10)) + .toEqual({ kind: "ignored" }); + // ...while a genuinely huge first-pos is still unsatisfiable. + expect(classifyRangeHeader("bytes=9007199254740993-", 10)).toEqual({ kind: "unsatisfiable" }); + // Leading zeros normalise rather than inflating the digit count. + expect(classifyRangeHeader("bytes=00000005-00000002", 10)).toEqual({ kind: "ignored" }); + expect(classifyRangeHeader("bytes=0000000002-0000000005", 10)) + .toEqual({ kind: "single", start: 2, length: 4 }); + }); + + it("accepts whitespace R2's own parser rejects β€” the divergence the bounds check absorbs", () => { + // R2 parses ranges with `/^ *bytes *=/i` + `/^ *(\d+)? *- *(\d+)? *$/` (ASCII space + // only; miniflare src/workers/shared/range.ts). This classifier's `\s` accepts a tab + // too, so the two grammars genuinely disagree here. That is tolerated by design: the + // Worker checks these bounds against the bytes R2 returned, so a spec R2 declined + // degrades to a 200 rather than to a 206 describing a body nobody asked for. The + // end-to-end proof is the "tab-separated Range" test below. + expect(classifyRangeHeader("bytes=2\t-\t4", 10)).toEqual({ kind: "single", start: 2, length: 3 }); + expect(classifyRangeHeader("\tbytes=2-4", 10)).toEqual({ kind: "single", start: 2, length: 3 }); + }); + }); +}); diff --git a/workers/apex/test/uagate.test.ts b/workers/apex/test/uagate.test.ts new file mode 100644 index 00000000..1989f847 --- /dev/null +++ b/workers/apex/test/uagate.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { uaVerdict } from "../src/uagate"; +import policy from "../ua-policy.json"; + +describe("UA gate (Β§3.2.1) β€” ships log-only for AI crawlers", () => { + it("search engines always allowed", () => { + expect(uaVerdict("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", policy)).toBe("allow"); + expect(uaVerdict("Mozilla/5.0 (compatible; bingbot/2.0)", policy)).toBe("allow"); + }); + it("AI-training crawlers are log-only initially", () => { + expect(uaVerdict("GPTBot/1.0", policy)).toBe("log"); + expect(uaVerdict("CCBot/2.0", policy)).toBe("log"); + }); + it("named abusers blocked", () => { + expect(uaVerdict("EvilScraper/0.1", { ...policy, block: ["EvilScraper"] })).toBe("block"); + }); + it("unknown UA β†’ allow (fail-open for humans)", () => { + expect(uaVerdict("Mozilla/5.0 (X11; Linux x86_64) Firefox/128.0", policy)).toBe("allow"); + }); + it("Applebot is allowed but Applebot-Extended is logged β€” most-specific match wins", () => { + // Apple's AI-training crawler token CONTAINS the search crawler's, so plain + // substring matching with a fixed allow-before-log precedence let the allow entry + // swallow it: the AI crawler was allowed AND never logged, unlike every other AI + // crawler in the log list. + expect(uaVerdict("Mozilla/5.0 (compatible; Applebot/0.1; +http://www.apple.com/go/applebot)", policy)).toBe("allow"); + expect(uaVerdict("Mozilla/5.0 (compatible; Applebot-Extended/0.1)", policy)).toBe("log"); + }); + it("Google-Extended is not a UA token β€” it must not sit in the UA policy at all", () => { + // Google-Extended is a robots.txt user-agent control token; it never appears in a + // User-Agent header, so an entry for it could only ever be dead weight. + // Compared case-INSENSITIVELY on both sides: bestMatch() lowercases every policy entry + // before matching, so "google-extended" would be functionally identical to the token + // this guard exists to keep out β€” but toContain() compares primitives by strict + // equality, so a lowercase variant would sail past a case-sensitive assertion and + // quietly restore the entry. Match the matcher's own case semantics. + const entries = [...policy.allow, ...policy.log, ...policy.block].map((e) => e.toLowerCase()); + expect(entries).not.toContain("google-extended"); + }); + it("block takes precedence over allow on a UA matching both lists", () => { + const dualMatch = { ...policy, allow: ["Googlebot"], block: ["Googlebot EvilScraper"] }; + expect(uaVerdict("Mozilla/5.0 (compatible; Googlebot EvilScraper/1.0)", dualMatch)).toBe("block"); + }); + + // --- allow-vs-log contests. Pinned verdicts for the four UAs that distinguish every + // candidate rule, so a future tweak to the precedence cannot silently drop telemetry. --- + + it("a UA carrying BOTH a log token and a longer allow token is logged, not allowed", () => { + // "GPTBot/1.0 DuckDuckBot" matches allow "DuckDuckBot" (11 chars) and log "GPTBot" (6). + // Under the longest-match rule the longer ALLOW needle won and the ua-log event never + // fired; under the original allow-before-log rule it also won. A UA presenting two + // different crawlers' tokens is precisely the shape worth recording, and `log` costs + // nothing but a log line β€” the request is served either way. + expect(uaVerdict("GPTBot/1.0 DuckDuckBot", policy)).toBe("log"); + }); + + it("pinned verdicts for the four discriminating UAs", () => { + expect(uaVerdict("Mozilla/5.0 (compatible; Applebot/0.1)", policy)).toBe("allow"); + expect(uaVerdict("Mozilla/5.0 (compatible; Applebot-Extended/0.1)", policy)).toBe("log"); + expect(uaVerdict("GPTBot/1.0 DuckDuckBot", policy)).toBe("log"); + expect(uaVerdict("Mozilla/5.0 (compatible; Googlebot/2.1)", policy)).toBe("allow"); + }); + + it("a narrow allow entry no longer overrides a matched log entry β€” log wins outright", () => { + // This branch used to return "allow" when the matched allow entry strictly CONTAINED + // the matched log entry, so a policy could carve a narrow allow out of a broad log + // entry. Removed as spoofable (see the next test). Both rows are now "log", which is + // the safe verdict β€” the request is still served either way; only telemetry differs. + const carveOut = { allow: ["Bytespider-Search"], log: ["Bytespider"], block: [] }; + expect(uaVerdict("Bytespider-Search/1.0", carveOut)).toBe("log"); + expect(uaVerdict("Bytespider/1.0", carveOut)).toBe("log"); + }); + + it("the removed carve-out was spoofable by quoting both tokens independently", () => { + // The concrete bypass. Containment was tested between the two matched ENTRIES, never + // against the UA's own token structure, so a request-controlled string naming both + // tokens separately matched allow "Bytespider-Search" and log "Bytespider", satisfied + // the containment test, and bought the AI crawler an `allow`. It must be logged. + const carveOut = { allow: ["Bytespider-Search"], log: ["Bytespider"], block: [] }; + expect(uaVerdict("Bytespider/2.0 Bytespider-Search/1.0", carveOut)).toBe("log"); + }); + + it("dropping the carve-out leaves every SHIPPED-policy verdict unchanged", () => { + // The vendor pair the shipped policy actually depends on runs the other way round: log + // "Applebot-Extended" is LONGER than allow "Applebot", so the allow entry never + // contained the log entry and log already won. No pair in ua-policy.json took the + // removed branch, so its removal is behaviour-preserving for what we ship. + expect(uaVerdict("Mozilla/5.0 (compatible; Applebot/0.1)", policy)).toBe("allow"); + expect(uaVerdict("Mozilla/5.0 (compatible; Applebot-Extended/0.1)", policy)).toBe("log"); + }); + + it("an entry present in BOTH lists resolves to log, not allow", () => { + // Equality is not containment. Listing the same token twice is an authoring error, and + // `log` is the resolution that cannot lose data. + const contradictory = { allow: ["CCBot"], log: ["CCBot"], block: [] }; + expect(uaVerdict("CCBot/2.0", contradictory)).toBe("log"); + }); + + it("block still short-circuits ahead of the allow-vs-log contest", () => { + const all3 = { allow: ["DuckDuckBot"], log: ["GPTBot"], block: ["EvilScraper"] }; + expect(uaVerdict("GPTBot/1.0 DuckDuckBot EvilScraper", all3)).toBe("block"); + }); +}); diff --git a/workers/apex/ua-policy.json b/workers/apex/ua-policy.json new file mode 100644 index 00000000..f0021be5 --- /dev/null +++ b/workers/apex/ua-policy.json @@ -0,0 +1,5 @@ +{ + "allow": ["Googlebot", "bingbot", "DuckDuckBot", "YandexBot", "Applebot", "UptimeRobot"], + "log": ["GPTBot", "CCBot", "ClaudeBot", "Applebot-Extended", "Bytespider", "PerplexityBot", "meta-externalagent"], + "block": [] +} diff --git a/workers/apex/wrangler.jsonc b/workers/apex/wrangler.jsonc new file mode 100644 index 00000000..71c831f3 --- /dev/null +++ b/workers/apex/wrangler.jsonc @@ -0,0 +1,39 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "vyos-docs-apex", + "main": "src/index.ts", + "compatibility_date": "2026-07-01", + "workers_dev": false, + "preview_urls": false, + "assets": { "directory": "assets", "binding": "ASSETS", "run_worker_first": true }, + "env": { + "canary": { + // Routes are MANAGED MANUALLY (v4.2 amendment): the account-owned CI token has no + // Workers Routes group, and β€” decisive β€” docs.vyos.io is ALREADY orange-cloud, so a + // config-managed production route would capture live traffic on first env deploy. + // Canary route (docs-next.vyos.io/* β†’ this worker, env canary) is created once by the + // operator/dashboard (Task 3.6 step 2c); the production route IS the cutover (Task 6.2). + "vars": { "APEX_BUILD_SHA": "dev", "DOCS_ENV": "canary" }, + "services": [ + { "binding": "DOCS_ROLLING", "service": "vyos-docs-rolling-en-candidate" }, + { "binding": "DOCS_V15", "service": "vyos-docs-v15-en-candidate" }, + { "binding": "DOCS_V14", "service": "vyos-docs-v14-en-candidate" }, + { "binding": "DOCS_LEGACY", "service": "vyos-docs-legacy-candidate" } + ], + // Β§5 apex PDF fallback: same bucket as production β€” there's only one copy of the + // (single, immutable) oversized 1.3 PDF artifact, not a per-env candidate/live pair. + "r2_buckets": [{ "binding": "DOCS_PDFS", "bucket_name": "vyos-docs-artifacts" }] + }, + "production": { + // production route deliberately ABSENT β€” created manually at cutover (Task 6.2); see canary note above + "vars": { "APEX_BUILD_SHA": "dev", "DOCS_ENV": "production" }, + "services": [ + { "binding": "DOCS_ROLLING", "service": "vyos-docs-rolling-en" }, + { "binding": "DOCS_V15", "service": "vyos-docs-v15-en" }, + { "binding": "DOCS_V14", "service": "vyos-docs-v14-en" }, + { "binding": "DOCS_LEGACY", "service": "vyos-docs-legacy" } + ], + "r2_buckets": [{ "binding": "DOCS_PDFS", "bucket_name": "vyos-docs-artifacts" }] + } + } +} diff --git a/workers/bootstrap.sh b/workers/bootstrap.sh new file mode 100755 index 00000000..a7454f9f --- /dev/null +++ b/workers/bootstrap.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# One-time bootstrap: deploy placeholder versions of every service-binding +# target so the apex Worker (whose config binds all of them) can deploy. +# Requires CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID in the environment. +set -euo pipefail +cd "$(dirname "$0")" + +mkdir -p ../dist/assets +for slug in rolling 1.5 1.4 1.3 1.2; do + mkdir -p "../dist/assets/en/$slug" + printf 'bootstrap placeholder β€” real build pending' \ + > "../dist/assets/en/$slug/index.html" +done + +deploy() { # config-suffix worker-name + for suffix in "" "-candidate"; do + npx wrangler deploy --config "branch/wrangler.$1.jsonc" \ + --name "$2$suffix" \ + --var DOCS_BUILD_SHA:bootstrap \ + --var DOCS_ENV:$( [ -n "$suffix" ] && echo canary || echo production ) + done +} + +npm ci +deploy rolling vyos-docs-rolling-en +deploy v15 vyos-docs-v15-en +deploy v14 vyos-docs-v14-en +deploy legacy vyos-docs-legacy +echo "bootstrap complete β€” all 8 binding targets exist; apex can now deploy" diff --git a/workers/branch/src/index.ts b/workers/branch/src/index.ts new file mode 100644 index 00000000..47ef3ad6 --- /dev/null +++ b/workers/branch/src/index.ts @@ -0,0 +1,83 @@ +export interface Env { + ASSETS: Fetcher; // static assets binding + DOCS_BUILD_SHA: string; // injected at deploy + DOCS_ENV: "production" | "canary"; +} + +export type CacheClass = "page" | "asset"; + +// Binary/media asset extensions (case-insensitive, so ".PDF" also matches) get the longer +// asset cache class, alongside the Sphinx /_static/ (theme) + /_images/ (figure) trees. +const ASSET_EXT_RE = /\.(pdf|png|jpe?g|webp|svg|gif|ico|woff2?|ttf|otf|eot)$/i; + +export function classifyPath(path: string): CacheClass { + if ( + path.includes("/_static/") || + path.includes("/_images/") || + ASSET_EXT_RE.test(path) + ) + return "asset"; + return "page"; // HTML, versions.json, sitemaps, robots/llms, pagefind index +} + +export function cacheHeaderFor(cls: CacheClass): string { + return cls === "asset" + ? "public, max-age=300, s-maxage=600, must-revalidate" + : "public, max-age=0, s-maxage=300, must-revalidate"; +} + +export function withDocsHeaders( + resp: Response, + path: string, + env: Pick, +): Response { + const out = new Response(resp.body, resp); + out.headers.set("X-Docs-Build", env.DOCS_BUILD_SHA); + out.headers.set( + "Cache-Control", + // Error responses (4xx/5xx) must never carry the page/asset cache class β€” a + // cached 404 would poison the edge for the full s-maxage window. + env.DOCS_ENV === "canary" || out.status >= 400 + ? "no-store" + : cacheHeaderFor(classifyPath(path)), + ); + return out; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + // With assets html_handling "none", the runtime serves explicit .html URLs directly + // but does NOT map a directory URL ("/foo/") to its index.html β€” the worker must map + // trailing-slash URLs to index.html itself to preserve ReadTheDocs URL parity. + if (url.pathname.endsWith("/")) { + const mapped = new URL(url); + mapped.pathname = url.pathname + "index.html"; + const resp = await env.ASSETS.fetch(new Request(mapped, request)); + return withDocsHeaders(resp, url.pathname, env); + } + // Bare extensionless path (no trailing slash, no "." in the last segment): it may be a + // real directory whose slashed form RTD 301-redirects to ("/foo" β†’ "/foo/"), or a + // file-like path with no matching asset (e.g. "/cli", whose real asset is "cli.html") + // that RTD 404s. A dot heuristic can't separate them, so probe the assets binding for + // "/index.html": 200 β†’ 301 to the slashed form; anything else β†’ fall through to + // the exact-path fetch (404 for "/cli", matching live RTD). + const lastSegment = url.pathname.slice(url.pathname.lastIndexOf("/") + 1); + if (lastSegment !== "" && !lastSegment.includes(".")) { + const probe = new URL(url); + probe.pathname = url.pathname + "/index.html"; + const probeResp = await env.ASSETS.fetch(new Request(probe, { method: "GET" })); + probeResp.body?.cancel(); // existence check only β€” release the probe body stream + if (probeResp.status === 200) { + const location = url.pathname + "/" + url.search; // preserve query; no fragment + return withDocsHeaders( + new Response(null, { status: 301, headers: { Location: location } }), + url.pathname, + env, + ); + } + } + const resp = await env.ASSETS.fetch(request); + return withDocsHeaders(resp, url.pathname, env); + }, +} satisfies ExportedHandler; diff --git a/workers/branch/test/content.test.ts b/workers/branch/test/content.test.ts new file mode 100644 index 00000000..6d04c35b --- /dev/null +++ b/workers/branch/test/content.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect } from "vitest"; +import worker, { classifyPath, cacheHeaderFor, withDocsHeaders, type Env } from "../src/index"; +// The workers pool has no real filesystem; import the branch wrangler configs as Vite `?raw` +// assets (pattern from apex/test/manifest.test.ts) so their content is inlined at bundle time +// for the html_handling congruence pin at the bottom of this file. +// eslint-disable-next-line import/no-unresolved +import wranglerRolling from "../wrangler.rolling.jsonc?raw"; +// eslint-disable-next-line import/no-unresolved +import wranglerV15 from "../wrangler.v15.jsonc?raw"; +// eslint-disable-next-line import/no-unresolved +import wranglerV14 from "../wrangler.v14.jsonc?raw"; +// eslint-disable-next-line import/no-unresolved +import wranglerLegacy from "../wrangler.legacy.jsonc?raw"; + +describe("cache classes (Β§3.3)", () => { + it("HTML + config class β†’ max-age=0, s-maxage=300", () => { + for (const p of ["/en/rolling/index.html", "/en/rolling/versions.json", + "/en/rolling/sitemap.xml", "/en/rolling/pagefind/pagefind.js"]) { + expect(cacheHeaderFor(classifyPath(p))) + .toBe("public, max-age=0, s-maxage=300, must-revalidate"); + } + }); + it("PDF + _static class β†’ max-age=300, s-maxage=600", () => { + for (const p of ["/en/rolling/vyos-documentation.pdf", "/en/rolling/_static/css/theme.css"]) { + expect(cacheHeaderFor(classifyPath(p))) + .toBe("public, max-age=300, s-maxage=600, must-revalidate"); + } + }); +}); + +describe("response headers", () => { + it("adds X-Docs-Build and cache-control; canary forces no-store", () => { + const base = new Response("ok", { headers: { "content-type": "text/html" } }); + const prod = withDocsHeaders(base, "/en/rolling/index.html", + { DOCS_BUILD_SHA: "abc123", DOCS_ENV: "production" }); + expect(prod.headers.get("X-Docs-Build")).toBe("abc123"); + expect(prod.headers.get("Cache-Control")).toBe("public, max-age=0, s-maxage=300, must-revalidate"); + const canary = withDocsHeaders(base, "/en/rolling/index.html", + { DOCS_BUILD_SHA: "abc123", DOCS_ENV: "canary" }); + expect(canary.headers.get("Cache-Control")).toBe("no-store"); + }); +}); + +describe("default fetch entrypoint", () => { + const makeEnv = (docsEnv: Env["DOCS_ENV"], seen: string[]): Env => ({ + ASSETS: { + fetch: async (req: Request) => { + seen.push(req.url); + return new Response("", { headers: { "content-type": "text/html" } }); + }, + } as unknown as Fetcher, + DOCS_BUILD_SHA: "testsha", + DOCS_ENV: docsEnv, + }); + + it("serves assets verbatim with docs headers; path is byte-stable", async () => { + const seen: string[] = []; + const url = "https://docs.vyos.io/en/rolling/index.html"; + const resp = await worker.fetch(new Request(url), makeEnv("production", seen)); + expect(resp.headers.get("X-Docs-Build")).toBe("testsha"); + expect(resp.headers.get("Cache-Control")) + .toBe("public, max-age=0, s-maxage=300, must-revalidate"); + expect(seen).toEqual([url]); // original request URL reached ASSETS unmodified + }); + + it("canary env forces no-store on the fetch path too", async () => { + const seen: string[] = []; + const resp = await worker.fetch( + new Request("https://docs.vyos.io/en/rolling/index.html"), + makeEnv("canary", seen), + ); + expect(resp.headers.get("Cache-Control")).toBe("no-store"); + expect(resp.headers.get("X-Docs-Build")).toBe("testsha"); + }); + + it("4xx/5xx responses are never cached, even in production", async () => { + const env: Env = { + ASSETS: { + fetch: async () => new Response("nope", { status: 404, headers: { "content-type": "text/html" } }), + } as unknown as Fetcher, + DOCS_BUILD_SHA: "testsha", + DOCS_ENV: "production", + }; + const resp = await worker.fetch( + new Request("https://docs.vyos.io/en/rolling/missing.html"), + env, + ); + expect(resp.status).toBe(404); + expect(resp.headers.get("Cache-Control")).toBe("no-store"); + }); +}); + +describe("directory-index mapping (html_handling \"none\", Β§3.2.3 amended 2026-07-22)", () => { + // NOTE: these tests mock the ASSETS binding β€” they exercise the WORKER's directory + // mapping + bare-directory probe logic, NOT the wrangler `html_handling` config (which + // only exists at deploy time). The config-level backstop is the smoke gate, + // scripts/docs_gates/smoke.py; the raw-string congruence test at the bottom of this file + // is the unit-level pin against a silent revert. + // + // Assets binding stub emulating html_handling:"none": exact-path lookups only β€” no + // extension inference and no directory auto-index. A bare "/foo/" resolves only because + // the worker rewrites it to "/foo/index.html" first; a bare "/foo" 301s only because the + // worker probes "/foo/index.html" and finds it β€” exactly the behavior under test. + const ASSET_MAP: Record = { + "/en/rolling/index.html": "root index", + "/en/rolling/cli.html": "cli page", + "/en/rolling/guide/index.html": "guide index", + "/en/rolling/installation/index.html": "installation index", + }; + + const makeAssetsEnv = (docsEnv: Env["DOCS_ENV"], seen: string[]): Env => ({ + ASSETS: { + fetch: async (req: Request) => { + seen.push(req.url); + const body = ASSET_MAP[new URL(req.url).pathname]; + return body === undefined + ? new Response("not found", { status: 404, headers: { "content-type": "text/html" } }) + : new Response(body, { headers: { "content-type": "text/html" } }); + }, + } as unknown as Fetcher, + DOCS_BUILD_SHA: "testsha", + DOCS_ENV: docsEnv, + }); + + it("trailing-slash directory URL is mapped to index.html β†’ 200 + index content", async () => { + const seen: string[] = []; + const resp = await worker.fetch( + new Request("https://docs.vyos.io/en/rolling/"), + makeAssetsEnv("production", seen), + ); + expect(resp.status).toBe(200); + expect(await resp.text()).toBe("root index"); + // worker rewrote "/en/rolling/" β†’ "/en/rolling/index.html" before hitting ASSETS + expect(seen).toEqual(["https://docs.vyos.io/en/rolling/index.html"]); + // 200 still carries the build stamp + the page cache class + expect(resp.headers.get("X-Docs-Build")).toBe("testsha"); + expect(resp.headers.get("Cache-Control")) + .toBe("public, max-age=0, s-maxage=300, must-revalidate"); + }); + + it("explicit .html URL is served directly β€” 200, never a 3xx redirect", async () => { + const seen: string[] = []; + const resp = await worker.fetch( + new Request("https://docs.vyos.io/en/rolling/cli.html"), + makeAssetsEnv("production", seen), + ); + expect(resp.status).toBe(200); + expect(resp.status).toBeLessThan(300); // no 307/308 for explicit .html paths + expect(await resp.text()).toBe("cli page"); + // passed through unmodified β€” the worker never rewrites explicit-file paths + expect(seen).toEqual(["https://docs.vyos.io/en/rolling/cli.html"]); + expect(resp.headers.get("X-Docs-Build")).toBe("testsha"); + expect(resp.headers.get("Cache-Control")) + .toBe("public, max-age=0, s-maxage=300, must-revalidate"); + }); + + it("explicit nested /folder/index.html is served directly β†’ 200", async () => { + const seen: string[] = []; + const resp = await worker.fetch( + new Request("https://docs.vyos.io/en/rolling/guide/index.html"), + makeAssetsEnv("production", seen), + ); + expect(resp.status).toBe(200); + expect(await resp.text()).toBe("guide index"); + expect(seen).toEqual(["https://docs.vyos.io/en/rolling/guide/index.html"]); + expect(resp.headers.get("X-Docs-Build")).toBe("testsha"); + expect(resp.headers.get("Cache-Control")) + .toBe("public, max-age=0, s-maxage=300, must-revalidate"); + }); + + it("extensionless file-like path (no dir behind it) probes, misses, then 404s", async () => { + const seen: string[] = []; + const resp = await worker.fetch( + new Request("https://docs.vyos.io/en/rolling/cli"), + makeAssetsEnv("production", seen), + ); + expect(resp.status).toBe(404); + // probe "/cli/index.html" misses (no such dir) β†’ fall through to the exact-path fetch of + // "/cli", which also misses (real asset is cli.html); html_handling:"none" never infers it. + expect(seen).toEqual([ + "https://docs.vyos.io/en/rolling/cli/index.html", + "https://docs.vyos.io/en/rolling/cli", + ]); + // a 404 must never carry a cacheable page/asset class + expect(resp.headers.get("Cache-Control")).toBe("no-store"); + }); + + it("directory mapping preserves the original query string", async () => { + const seen: string[] = []; + const resp = await worker.fetch( + new Request("https://docs.vyos.io/en/rolling/?q=foo"), + makeAssetsEnv("production", seen), + ); + expect(resp.status).toBe(200); + expect(seen).toEqual(["https://docs.vyos.io/en/rolling/index.html?q=foo"]); + }); + + it("bare directory path (real dir behind it) β†’ 301 to the slashed form, query preserved", async () => { + const seen: string[] = []; + const resp = await worker.fetch( + new Request("https://docs.vyos.io/en/rolling/installation?q=x"), + makeAssetsEnv("production", seen), + ); + expect(resp.status).toBe(301); + expect(resp.headers.get("Location")).toBe("/en/rolling/installation/?q=x"); + expect(resp.headers.get("X-Docs-Build")).toBe("testsha"); + // only the index.html probe reached ASSETS β€” the redirect short-circuits the passthrough + expect(seen).toEqual(["https://docs.vyos.io/en/rolling/installation/index.html?q=x"]); + }); + + it("trailing-slash mapping preserves request method + conditional/range headers", async () => { + // Codex: pins that `new Request(mapped, request)` carries method + headers to ASSETS, + // so conditional-GET (304) and Range (206) still work on directory URLs. + let captured: Request | undefined; + const env: Env = { + ASSETS: { + fetch: async (req: Request) => { + captured = req; + return new Response(null, { headers: { "content-type": "text/html" } }); + }, + } as unknown as Fetcher, + DOCS_BUILD_SHA: "testsha", + DOCS_ENV: "production", + }; + await worker.fetch( + new Request("https://docs.vyos.io/en/rolling/", { + method: "HEAD", + headers: { "If-None-Match": '"abc"', Range: "bytes=0-0" }, + }), + env, + ); + expect(captured?.url).toBe("https://docs.vyos.io/en/rolling/index.html"); + expect(captured?.method).toBe("HEAD"); + expect(captured?.headers.get("If-None-Match")).toBe('"abc"'); + expect(captured?.headers.get("Range")).toBe("bytes=0-0"); + }); +}); + +describe("asset cache-class classification (Β§3.3, extended)", () => { + it("images, fonts, and the /_images/ + /_static/ trees get the asset class", () => { + for (const p of [ + "/en/rolling/_images/diagram.png", + "/en/rolling/_static/fonts/roboto.woff2", + "/en/rolling/logo.svg", + "/en/rolling/photo.jpeg", + "/en/rolling/hero.webp", // webp added to ASSET_EXT_RE (standalone path) + "/en/rolling/font.otf", // otf added to ASSET_EXT_RE (standalone path) + "/en/rolling/icon.ico", + "/en/rolling/vyos-documentation.pdf", + "/en/rolling/vyos-documentation.PDF", // .pdf folded into the case-insensitive regex + ]) { + expect(classifyPath(p)).toBe("asset"); + } + }); + it("HTML pages and data files stay the page class", () => { + for (const p of [ + "/en/rolling/index.html", + "/en/rolling/cli.html", + "/en/rolling/versions.json", + "/en/rolling/sitemap.xml", + ]) { + expect(classifyPath(p)).toBe("page"); + } + }); +}); + +describe("wrangler config congruence β€” html_handling pinned to \"none\"", () => { + // Unit tests mock ASSETS and never exercise the real wrangler html_handling config; this + // raw-string assertion is the unit-level pin against a silent revert to + // "auto-trailing-slash" (which reintroduces the 307-on-explicit-.html smoke failure). The + // deploy-time smoke gate (scripts/docs_gates/smoke.py) is the runtime-level backstop. + it("all four branch wrangler envs set html_handling \"none\"", () => { + for (const raw of [wranglerRolling, wranglerV15, wranglerV14, wranglerLegacy]) { + expect(raw).toContain('"html_handling": "none"'); + expect(raw).not.toContain("auto-trailing-slash"); + } + }); +}); diff --git a/workers/branch/wrangler.legacy.jsonc b/workers/branch/wrangler.legacy.jsonc new file mode 100644 index 00000000..aef5144e --- /dev/null +++ b/workers/branch/wrangler.legacy.jsonc @@ -0,0 +1,16 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "vyos-docs-legacy", + "main": "src/index.ts", + "compatibility_date": "2026-07-01", + "workers_dev": false, + "preview_urls": false, + "assets": { + "directory": "../../dist/assets", // populated by CI (Task 3.2); html_handling per Β§3.2.3 + "binding": "ASSETS", + "html_handling": "none", // "none" + worker index-mapping: RTD parity β€” explicit .html URLs must serve 200, never 307 (Β§3.2.3 amended 2026-07-22) + "not_found_handling": "404-page", + "run_worker_first": true + }, + "vars": { "DOCS_BUILD_SHA": "dev", "DOCS_ENV": "production" } +} diff --git a/workers/branch/wrangler.rolling.jsonc b/workers/branch/wrangler.rolling.jsonc new file mode 100644 index 00000000..7a6f646c --- /dev/null +++ b/workers/branch/wrangler.rolling.jsonc @@ -0,0 +1,16 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "vyos-docs-rolling-en", + "main": "src/index.ts", + "compatibility_date": "2026-07-01", + "workers_dev": false, + "preview_urls": false, + "assets": { + "directory": "../../dist/assets", // populated by CI (Task 3.2); html_handling per Β§3.2.3 + "binding": "ASSETS", + "html_handling": "none", // "none" + worker index-mapping: RTD parity β€” explicit .html URLs must serve 200, never 307 (Β§3.2.3 amended 2026-07-22) + "not_found_handling": "404-page", + "run_worker_first": true + }, + "vars": { "DOCS_BUILD_SHA": "dev", "DOCS_ENV": "production" } +} diff --git a/workers/branch/wrangler.v14.jsonc b/workers/branch/wrangler.v14.jsonc new file mode 100644 index 00000000..fe758dd9 --- /dev/null +++ b/workers/branch/wrangler.v14.jsonc @@ -0,0 +1,16 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "vyos-docs-v14-en", + "main": "src/index.ts", + "compatibility_date": "2026-07-01", + "workers_dev": false, + "preview_urls": false, + "assets": { + "directory": "../../dist/assets", // populated by CI (Task 3.2); html_handling per Β§3.2.3 + "binding": "ASSETS", + "html_handling": "none", // "none" + worker index-mapping: RTD parity β€” explicit .html URLs must serve 200, never 307 (Β§3.2.3 amended 2026-07-22) + "not_found_handling": "404-page", + "run_worker_first": true + }, + "vars": { "DOCS_BUILD_SHA": "dev", "DOCS_ENV": "production" } +} diff --git a/workers/branch/wrangler.v15.jsonc b/workers/branch/wrangler.v15.jsonc new file mode 100644 index 00000000..f2a0d7a4 --- /dev/null +++ b/workers/branch/wrangler.v15.jsonc @@ -0,0 +1,16 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "vyos-docs-v15-en", + "main": "src/index.ts", + "compatibility_date": "2026-07-01", + "workers_dev": false, + "preview_urls": false, + "assets": { + "directory": "../../dist/assets", // populated by CI (Task 3.2); html_handling per Β§3.2.3 + "binding": "ASSETS", + "html_handling": "none", // "none" + worker index-mapping: RTD parity β€” explicit .html URLs must serve 200, never 307 (Β§3.2.3 amended 2026-07-22) + "not_found_handling": "404-page", + "run_worker_first": true + }, + "vars": { "DOCS_BUILD_SHA": "dev", "DOCS_ENV": "production" } +} diff --git a/workers/matrix.json b/workers/matrix.json new file mode 100644 index 00000000..31ece863 --- /dev/null +++ b/workers/matrix.json @@ -0,0 +1,5 @@ +{ + "rolling": { "worker": "vyos-docs-rolling-en", "slug": "rolling" }, + "circinus": { "worker": "vyos-docs-v15-en", "slug": "1.5" }, + "sagitta": { "worker": "vyos-docs-v14-en", "slug": "1.4" } +} diff --git a/workers/package-lock.json b/workers/package-lock.json new file mode 100644 index 00000000..150edc15 --- /dev/null +++ b/workers/package-lock.json @@ -0,0 +1,2843 @@ +{ + "name": "vyos-docs-workers", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vyos-docs-workers", + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.21.3", + "typescript": "^5.5.0", + "vitest": "~4.1.0", + "wrangler": "^4.123.0" + }, + "engines": { + "node": ">=20" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "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/vitest-pool-workers": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.21.3.tgz", + "integrity": "sha512-jCoGRQ6FlsP5adp1GJISvITOGdTHTpsWOq3KkSGIfeDY/5RKjTUagDwaXjpqBXY5gAk6id/QbgnGuCTAg6lSTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cjs-module-lexer": "1.2.3", + "esbuild": "0.28.1", + "miniflare": "5.20260811.1-alpha", + "wrangler": "4.123.0", + "zod": "4.4.3" + }, + "peerDependencies": { + "@vitest/runner": "^4.1.0", + "@vitest/snapshot": "^4.1.0", + "vitest": "^4.1.0" + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260811.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260811.1.tgz", + "integrity": "sha512-i5jqz+ywtOefr0AJbiAc8qxBLfSim/B0WJG7aW3B+pWnoVfMJdUQvi+BWcFKZJ0MoCci3KadTx6g31VfuEEqpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260811.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260811.1.tgz", + "integrity": "sha512-NoOUM/nvaDdm2Onlnz33FikWjtatzulNtvwvy4xs0IrHaTCHwC0c8NwIt6s+AI13FkDs02/vm2I3GTPLCT9+hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260811.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260811.1.tgz", + "integrity": "sha512-sdYq2jL1AD1supa3fsi5O4zTB28wSjvTHj7Migh6/ts8EROPdvrSwv+rdGHhv8HJNAz/wbIAY3wZsi1Rw4uUIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260811.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260811.1.tgz", + "integrity": "sha512-RIRv4shbu1kg05sD+DHTpSFCNnb5Dl2SkPDMUykqZa508tkPqe7VVw7gO0Q5msTBGyL0FfFrLuRxwwfA8u5Sow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260811.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260811.1.tgz", + "integrity": "sha512-g6VquwjASlYAibcNW/0E6Zszht4qLkmnXOGwIjjRHl2A0Qz48kVeMcGvyH6eA0G9U3OzZojjYFpP+YeyQmmdjw==", + "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/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "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.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "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.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "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.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "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.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "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.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "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.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "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.1" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "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.1" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "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.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "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.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "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.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "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.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "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.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "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.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "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.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "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.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "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/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "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/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "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/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "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/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", + "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "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/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "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/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/miniflare": { + "version": "5.20260811.1-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260811.1-alpha.tgz", + "integrity": "sha512-DtOG0BeanIxs2sH0smFvExZD89cBQwGckbHiFkRJrrNAUu3NGClZkUxqu+zy7HYfKBAgq935EMY49vIPm3JVdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.29.0", + "workerd": "1.20260811.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.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/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "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/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "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/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/workerd": { + "version": "1.20260811.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260811.1.tgz", + "integrity": "sha512-kh+FFm55JQ4ssxhHZV9VPdMQq3D1nHxNJgwxMtWGD4dGppJvLySdguTRDKgeNTvgq6heSz+6TTXyPSDGj8Yllw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260811.1", + "@cloudflare/workerd-darwin-arm64": "1.20260811.1", + "@cloudflare/workerd-linux-64": "1.20260811.1", + "@cloudflare/workerd-linux-arm64": "1.20260811.1", + "@cloudflare/workerd-windows-64": "1.20260811.1" + } + }, + "node_modules/wrangler": { + "version": "4.123.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.123.0.tgz", + "integrity": "sha512-VXo2I1oa0x9aGAKIFPRSQPqTh0RBY5Ktl44YOhNmsJQFUdJKDA2vVTU6Xj+FC2koll6orJqWZN8jbXVIk9O67Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260811.1-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260811.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.20260811.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" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/workers/package.json b/workers/package.json new file mode 100644 index 00000000..ed750083 --- /dev/null +++ b/workers/package.json @@ -0,0 +1,16 @@ +{ + "name": "vyos-docs-workers", + "private": true, + "type": "module", + "engines": { "node": ">=20" }, + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.21.3", + "typescript": "^5.5.0", + "vitest": "~4.1.0", + "wrangler": "^4.123.0" + } +} diff --git a/workers/picker-test/pagefind-wrapper.test.ts b/workers/picker-test/pagefind-wrapper.test.ts new file mode 100644 index 00000000..75e8be50 --- /dev/null +++ b/workers/picker-test/pagefind-wrapper.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +// The workers pool has no real filesystem (node:fs readFileSync is an unimplemented +// stub β€” see @cloudflare/vitest-pool-workers/dist/worker/lib/node/fs.mjs, and confirmed +// empirically here: "readFileSync() is not yet implemented in Workers"). Same constraint +// documented in picker.test.ts and apex/test/manifest.test.ts; import as Vite `?raw` asset +// instead so content is inlined at bundle time β€” no runtime filesystem access needed. +// eslint-disable-next-line import/no-unresolved +import src from "../../docs/_static/js/pagefind-wrapper.js?raw"; + +const ns: Record = {}; +new Function("window", src)(ns as never); +const W = (ns as never as { VyOSSearch: Record }).VyOSSearch; + +describe("basePathFor (Β§9)", () => { + it("production/canary version path", () => { + expect(W.basePathFor("/en/rolling/search.html")) + .toEqual({ base: "/en/rolling/", prefix: "" }); + }); + it("PR preview path keeps /pr-/ prefix and reports it", () => { + expect(W.basePathFor("/pr-42/en/1.5/search.html")) + .toEqual({ base: "/pr-42/en/1.5/", prefix: "/pr-42" }); + }); + it("prefixes result URLs in previews", () => { + expect(W.prefixResultUrl("/en/1.5/cli/index.html", "/pr-42")).toBe("/pr-42/en/1.5/cli/index.html"); + expect(W.prefixResultUrl("/en/1.5/cli/index.html", "")).toBe("/en/1.5/cli/index.html"); + }); +}); + +describe("assetUrlsFor (css + js pair under the version base)", () => { + it("production/canary base", () => { + expect(W.assetUrlsFor("/en/rolling/")).toEqual({ + css: "/en/rolling/pagefind/pagefind-ui.css", + js: "/en/rolling/pagefind/pagefind-ui.js", + }); + }); + it("PR preview base keeps the /pr-/ prefix", () => { + expect(W.assetUrlsFor("/pr-42/en/1.5/")).toEqual({ + css: "/pr-42/en/1.5/pagefind/pagefind-ui.css", + js: "/pr-42/en/1.5/pagefind/pagefind-ui.js", + }); + }); +}); diff --git a/workers/picker-test/picker.test.ts b/workers/picker-test/picker.test.ts new file mode 100644 index 00000000..560fd0d2 --- /dev/null +++ b/workers/picker-test/picker.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from "vitest"; +// The workers pool has no real filesystem (node:fs readFileSync is an unimplemented +// stub β€” see @cloudflare/vitest-pool-workers/dist/worker/lib/node/fs.mjs, and confirmed +// empirically here: "readFileSync() is not yet implemented in Workers"). Same constraint +// documented in apex/test/manifest.test.ts; import as Vite `?raw` / native JSON assets +// instead so content is inlined at bundle time β€” no runtime filesystem access needed. +// eslint-disable-next-line import/no-unresolved +import src from "../../docs/_static/js/version-picker.js?raw"; +// eslint-disable-next-line import/no-unresolved +import manifest from "../versions.json"; + +// Evaluate the plain script and grab its namespace (no DOM access at module scope allowed). +const ns: Record = {}; +new Function("window", src)(ns as never); +const P = (ns as never as { VyOSVersionPicker: Record }).VyOSVersionPicker; + +describe("parseLocation", () => { + it("extracts lang/slug/rest from a docs path", () => { + expect(P.parseLocation("/en/1.5/cli/index.html")) + .toEqual({ lang: "en", slug: "1.5", rest: "cli/index.html" }); + }); + it("returns null off the version tree (e.g. previews without prefix knowledge)", () => { + expect(P.parseLocation("/kb/x")).toBeNull(); + }); + + // The slug segment is restricted to the sphinx slug charset so hostile text can never + // reach URL construction as a version identifier (CodeQL js/xss-through-dom). + it("accepts real version slugs", () => { + expect(P.parseLocation("/en/rolling/index.html")).toMatchObject({ slug: "rolling" }); + expect(P.parseLocation("/en/1.5/cli/index.html")).toMatchObject({ slug: "1.5" }); + }); + it("rejects a slug carrying markup metacharacters", () => { + expect(P.parseLocation("/en/foo/page.html")).toBeNull(); + }); + it("rejects a slug carrying a quote or a space", () => { + expect(P.parseLocation('/en/foo"bar/page.html')).toBeNull(); + expect(P.parseLocation("/en/foo bar/page.html")).toBeNull(); + }); + // "." and ".." are the only normalizing dot-segments: as a slug they would walk out of + // the /// tree once the browser resolves the URL. Interior dots are fine. + it("rejects the dot-segments '.' and '..' as slugs, keeping dotted version slugs", () => { + expect(P.parseLocation("/en/../index.html")).toBeNull(); + expect(P.parseLocation("/en/./index.html")).toBeNull(); + expect(P.parseLocation("/en/1.5/index.html")).toMatchObject({ slug: "1.5" }); + }); +}); + +describe("bannerFor (Β§4)", () => { + it("dev β†’ info banner", () => { + expect(P.bannerFor("rolling", manifest)).toMatchObject({ kind: "dev" }); + }); + it("newest lts β†’ no banner; older lts β†’ newer-lts notice naming 1.5", () => { + expect(P.bannerFor("1.5", manifest)).toBeNull(); + expect(P.bannerFor("1.4", manifest)).toMatchObject({ kind: "newer-lts", newest: "1.5" }); + }); + it("eol β†’ warning linking newest LTS", () => { + expect(P.bannerFor("1.3", manifest)).toMatchObject({ kind: "eol", newest: "1.5" }); + }); +}); + +describe("targetUrlFor", () => { + it("same path on target version", () => { + expect(P.targetUrlFor({ lang: "en", slug: "1.5", rest: "cli/index.html" }, "1.4")) + .toBe("/en/1.4/cli/index.html"); + }); +}); + +describe("navUrlFor (query + fragment preserved across version switch)", () => { + const loc = { lang: "en", slug: "1.4", rest: "quick-start.html" }; + it("neither β†’ bare target path", () => { + expect(P.navUrlFor(loc, "1.5", "", "")).toBe("/en/1.5/quick-start.html"); + }); + it("query-only", () => { + expect(P.navUrlFor(loc, "1.5", "?ref=x", "")) + .toBe("/en/1.5/quick-start.html?ref=x"); + }); + it("hash-only", () => { + expect(P.navUrlFor(loc, "1.5", "", "#section-3")) + .toBe("/en/1.5/quick-start.html#section-3"); + }); + it("both, in query-then-hash order", () => { + expect(P.navUrlFor(loc, "1.5", "?ref=x", "#section-3")) + .toBe("/en/1.5/quick-start.html?ref=x#section-3"); + }); +}); + +/* DOM-text sources (select.value, location.pathname) reach a location.href sink, so every + * path component is percent-encoded at construction time (CodeQL js/xss-through-dom). */ +describe("URL construction percent-encodes hostile path components", () => { + const loc = { lang: "en", slug: "1.5", rest: "cli/index.html" }; + // Characters that could break out of a path segment or introduce a URL scheme. + const HOSTILE = ['"', "'", "<", ">", " ", ":"]; + + it("is a no-op on legitimate sphinx slugs β€” URLs byte-identical to pre-hardening", () => { + expect(P.targetUrlFor(loc, "1.4")).toBe("/en/1.4/cli/index.html"); + expect(P.targetUrlFor(loc, "rolling")).toBe("/en/rolling/cli/index.html"); + expect(P.targetUrlFor({ lang: "en", slug: "1.4", rest: "" }, "1.5")).toBe("/en/1.5/"); + expect(P.langUrlFor(loc, "de")).toBe("/de/1.5/cli/index.html"); + }); + + it("encodePath keeps '/' separators while encoding each segment", () => { + expect(P.encodePath("cli/index.html")).toBe("cli/index.html"); + expect(P.encodePath('a b/c"d/e.html')).toBe("a%20b/c%22d/e.html"); + }); + + // location.pathname returns well-formed escapes verbatim, so encoding blindly would + // double-encode them (%2E -> %252E) and break the deep link on the HEAD probe. + it("encodePath normalizes pre-existing escapes instead of double-encoding", () => { + expect(P.encodePath("index%2Ehtml")).toBe("index.html"); + expect(P.encodePath("a%20b/c.html")).toBe("a%20b/c.html"); + expect(P.encodePath("a%2Fb/c")).toBe("a%2Fb/c"); // encoded slash stays in its segment + }); + + it("encodePath degrades safely on a malformed escape (no throw)", () => { + const url = P.encodePath("100%zz/x"); + expect(url).toBe("100%25zz/x"); + for (const c of HOSTILE) expect(url).not.toContain(c); + }); + + // Normalization runs per %HH run, not per segment: a whole-segment decode throws on the + // malformed escape and then double-encodes the valid one beside it (a%2520b%25zz). + it("encodePath normalizes each escape run independently in a mixed-validity segment", () => { + const url = P.encodePath("a%20b%zz/x"); + expect(url).toBe("a%20b%25zz/x"); + for (const c of HOSTILE) expect(url).not.toContain(c); + }); + + it("encodePath keeps an invalid-UTF-8 escape run verbatim (already pure %HH text)", () => { + const url = P.encodePath("x%E0%A4y.html"); + expect(url).toBe("x%E0%A4y.html"); + for (const c of HOSTILE) expect(url).not.toContain(c); + }); + + it("targetUrlFor encodes a markup-injecting target slug", () => { + const url = P.targetUrlFor(loc, '">'); + expect(url).toBe("/en/%22%3E%3Cimg%20src%3Dx%3E/cli/index.html"); + for (const c of HOSTILE) expect(url).not.toContain(c); + }); + + it("targetUrlFor kills the colon in a javascript:-shaped slug", () => { + expect(P.targetUrlFor(loc, "javascript:alert(1)")) + .toBe("/en/javascript%3Aalert(1)/cli/index.html"); + }); + + it("targetUrlFor encodes a hostile lang segment", () => { + const url = P.targetUrlFor({ lang: 'en">