diff --git a/.env.example b/.env.example index d64110098..64e4f3ce4 100644 --- a/.env.example +++ b/.env.example @@ -57,10 +57,30 @@ TLS_PORT=8443 # Optional app-managed supplemental CA bundle for outbound HTTPS. # OUTBOUND_CA_BUNDLE_FILE=/absolute/path/to/outbound-extra-ca.pem +# +# Node's own extra-CA variable is honoured as a fallback for the same purpose. +# NODE_EXTRA_CA_CERTS=/absolute/path/to/outbound-extra-ca.pem + +# Hosts outbound fetches may reach WITHOUT the private-network SSRF check. +# Comma-separated host[:port]; matched exactly and case-normalized against the +# URL host (with port) or hostname (without). No wildcards, no suffix matching: +# example.com does NOT match api.example.com. +# +# WARNING: a listed host short-circuits BOTH assertPublicAddress() and DNS +# resolution, so an entry resolving to a loopback/link-local/private address +# WILL be fetched. Intended for a local fixture or test-harness origin (this is +# what `npm run postman:full` uses); do not use it to force through a public +# host the policy rejected. See DEVELOPMENT.md → Outbound host allowlist. +# OUTBOUND_ALLOWED_HOSTS=127.0.0.1:19090 # Optional rate limiting and logging. # RATE_LIMIT_WINDOW_MS=900000 # RATE_LIMIT_MAX=100 +# +# Status/health routes use their own limiter so probes do not share the main +# API request budget. +# STATUS_RATE_LIMIT_WINDOW_MS=60000 +# STATUS_RATE_LIMIT_MAX=60 # LOG_LEVEL=debug # Optional API access control. When set, scraper and description endpoints @@ -118,7 +138,13 @@ TLS_PORT=8443 # TOGETHER_MAX_TOKENS=160 # TOGETHER_PROMPT=Write concise alt text for this image. -# Development TLS defaults already point to the checked-in localhost certs. -# Uncomment only when overriding them. -# TLS_KEY=../../certs/localhost-key.pem -# TLS_CERT=../../certs/localhost.pem +# TLS is enabled by default. +# In development the app uses certs/localhost*.pem when present (they are NOT +# checked in — certs/ is gitignored); otherwise it generates a temporary +# self-signed localhost certificate in-process. +# Set TLS_ENABLED=false only behind a trusted TLS-terminating platform edge. +# TLS_ENABLED=true +# +# Prefer absolute paths; relative paths resolve against src/infrastructure/. +# TLS_KEY=/absolute/path/to/localhost-key.pem +# TLS_CERT=/absolute/path/to/localhost.pem diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8ce43208..451532839 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,28 +50,27 @@ jobs: node scripts/ci/classify-changed-paths.js --input changed-files.txt + # Runs unconditionally, unlike the other gates' docs-only skips. docs:validate + # now asserts across the repository, not just within the changed files: it + # holds every env var read by config/*.js to being documented. A change that + # adds one without touching any Markdown is exactly the case worth catching, + # and a docs_changed condition would skip precisely that change. docs: name: docs - needs: - - changes runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version-file: .nvmrc + - name: Setup project + uses: ./.github/actions/setup-node-project - name: Validate docs - if: needs.changes.outputs.docs_changed == 'true' run: npm run docs:validate - - name: Skip docs validation when no docs changed - if: needs.changes.outputs.docs_changed != 'true' - run: echo "No docs-only validation needed for this change." + - name: Lint Markdown + run: npm run docs:lint actionlint: name: actionlint diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 000000000..7b5457294 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,29 @@ +{ + // Structure and heading hierarchy are what this gate is for. Three default + // rules are off because they contradict deliberate choices in these docs + // rather than catching mistakes in them: + // + // MD013 line-length prose, tables, and command lines are intentionally + // long; enforcing 80 columns would rewrap every file + // and change nothing about correctness. + // MD033 no-inline-html the README banner is a
/ block, and the + // img is exactly where the alt attribute has to live. + // MD041 first-line-h1 the README opens with that banner, not a heading. + // MD029 ol-prefix the runbook sequences interleave unindented code + // fences, which ends each list and starts the next at + // its own number. That renders correctly (
    ) + // and re-indenting eight fences to satisfy the rule + // would churn the document without fixing anything. + // + // Everything else stays on, including the heading-hierarchy rules this gate + // is for: MD001 increment, MD024 duplicates, MD025 single H1, MD022 spacing. + "config": { + "default": true, + "MD013": false, + "MD033": false, + "MD041": false, + "MD029": false + }, + "globs": ["**/*.md"], + "gitignore": true +} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b34bc13f3..16ae21aa7 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -463,8 +463,38 @@ Public traffic is still end-to-end HTTPS to the client; only the edge→app hop | Variable | Required | Default | Description | | --- | --- | --- | --- | | `OUTBOUND_CA_BUNDLE_FILE` | No | unset | App-managed supplemental PEM bundle used to extend Node trust for outbound HTTPS. | +| `OUTBOUND_ALLOWED_HOSTS` | No | unset | Comma-separated `host[:port]` allowlist for outbound fetches. **Each entry bypasses the private-network SSRF guard** — see [Outbound host allowlist](#outbound-host-allowlist) before setting it. | | `NODE_EXTRA_CA_CERTS` | No | unset | Node-supported extra CA file. This app also accepts it as a fallback. Not validated by Joi. | +#### Outbound host allowlist + +`OUTBOUND_ALLOWED_HOSTS` names hosts that outbound fetches may reach **without +the private-network check**. Read this before setting it: it is the one setting +here that can turn the scraper into an SSRF primitive. + +**Syntax.** A comma-separated list of `host[:port]` entries, validated at boot: + +```dotenv +OUTBOUND_ALLOWED_HOSTS=127.0.0.1:19090,fixtures.internal:8080 +``` + +**Semantics.** Matching is exact and case-normalized against the URL's `host` +(with port) or `hostname` (without). There are **no wildcards and no suffix +matching** — `example.com` does not match `api.example.com`, and +`example.com:8443` does not match `example.com`. Entries are compared verbatim +(`src/infrastructure/outboundUrlPolicy.js`). + +**What it bypasses.** A matching host returns *before* `assertPublicAddress()` +and before DNS resolution, which are the guards that stop the app from being +pointed at loopback, link-local, or private-range addresses. An allowlisted host +that resolves to `169.254.169.254` will be fetched. That is the entire point of +the setting, and the entire risk of it. + +**Use it for** a local fixture or test-harness origin — which is what +`npm run postman:full` does. **Do not use it** to work around a legitimate +rejection of a public host; nothing about the allowlist is scoped to a path, +method, or protocol. + ### Worker, Proxy, and Scraper Runtime Controls | Variable | Required | Default | Description | diff --git a/README.md b/README.md index 46fb34d4f..7f1984dfe 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@
    - +
    # Alt-Text 4 All diff --git a/package-lock.json b/package-lock.json index f3bacf8cd..daf6bf72a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "jest": "^29.7.0", "jest-environment-node": "^30.4.1", "jest-junit": "^16.0.0", + "markdownlint-cli2": "0.18.1", "newman": "^6.2.2", "newman-reporter-allure": "^3.6.0", "nodemon": "^3.1.14", @@ -1490,6 +1491,44 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -1802,6 +1841,19 @@ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -1894,6 +1946,16 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1946,6 +2008,20 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "18.15.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.10.tgz", @@ -1959,6 +2035,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -2947,6 +3030,39 @@ "node": ">=10" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chardet": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.0.0.tgz", @@ -3429,6 +3545,20 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dedent": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", @@ -3508,6 +3638,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/des.js": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", @@ -3528,6 +3668,20 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -4407,6 +4561,23 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4448,6 +4619,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -4891,6 +5072,50 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/globby/node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5329,6 +5554,32 @@ "node": ">= 0.10" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5481,6 +5732,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-document.all": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", @@ -5569,6 +5831,17 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -6998,6 +7271,13 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsprim": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", @@ -7014,6 +7294,33 @@ "verror": "1.10.0" } }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7060,6 +7367,26 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/liquid-json": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/liquid-json/-/liquid-json-0.3.1.tgz", @@ -7139,6 +7466,98 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdownlint": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.38.0.tgz", + "integrity": "sha512-xaSxkaU7wY/0852zGApM8LdlIfGCW8ETZ0Rr62IQtAnUMlMuifsg09vWJcNYeL4f0anvr8Vo4ZQar8jGpV0btQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark": "4.0.2", + "micromark-core-commonmark": "2.0.3", + "micromark-extension-directive": "4.0.0", + "micromark-extension-gfm-autolink-literal": "2.1.0", + "micromark-extension-gfm-footnote": "2.1.0", + "micromark-extension-gfm-table": "2.1.1", + "micromark-extension-math": "3.1.0", + "micromark-util-types": "2.0.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.18.1.tgz", + "integrity": "sha512-/4Osri9QFGCZOCTkfA8qJF+XGjKYERSHkXzxSyS1hd3ZERJGjvsUao2h4wdnvpHp6Tu2Jh/bPHM0FE9JJza6ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "globby": "14.1.0", + "js-yaml": "4.1.0", + "jsonc-parser": "3.3.1", + "markdown-it": "14.1.0", + "markdownlint": "0.38.0", + "markdownlint-cli2-formatter-default": "0.0.5", + "micromatch": "4.0.8" + }, + "bin": { + "markdownlint-cli2": "markdownlint-cli2-bin.mjs" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2-formatter-default": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/markdownlint-cli2-formatter-default/-/markdownlint-cli2-formatter-default-0.0.5.tgz", + "integrity": "sha512-4XKTwQ5m1+Txo2kuQ3Jgpo/KmnG+X90dWt4acufg6HVGadTUG5hzHF/wssp9b5MBYOMCnZ9RMPaU//uHsszF8Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + }, + "peerDependencies": { + "markdownlint-cli2": ">=0.0.4" + } + }, + "node_modules/markdownlint-cli2/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7160,6 +7579,13 @@ "is-buffer": "~1.1.6" } }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -7185,15 +7611,561 @@ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "dev": true, "engines": { "node": ">= 0.6" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7788,6 +8760,26 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -7913,6 +8905,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -8501,6 +9506,16 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.3.tgz", @@ -8558,6 +9573,27 @@ "dev": true, "license": "MIT" }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -8813,6 +9849,17 @@ "node": ">=10" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -8828,6 +9875,30 @@ "node": ">= 18" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -9990,6 +11061,13 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -10044,6 +11122,19 @@ "node": ">=18.17" } }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", diff --git a/package.json b/package.json index 88c217d3b..9ae6a427d 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "verify:action-pins": "node scripts/github/verify-action-pins.js", "doctor:tls": "node scripts/doctor-tls.js", "docs:validate": "node scripts/docs/validate-docs.js", + "docs:lint": "markdownlint-cli2", "dev:test-env": "ENV_FILE=.env.test NODE_ENV=development nodemon src/app.js | pino-pretty -i streams,options", "lint": "eslint . --max-warnings=0", "openapi:generate": "node scripts/generate-openapi-spec.js", @@ -98,6 +99,7 @@ "jest": "^29.7.0", "jest-environment-node": "^30.4.1", "jest-junit": "^16.0.0", + "markdownlint-cli2": "0.18.1", "newman": "^6.2.2", "newman-reporter-allure": "^3.6.0", "nodemon": "^3.1.14", diff --git a/scripts/docs/validate-docs.js b/scripts/docs/validate-docs.js index 43fe11e15..db98ccf67 100644 --- a/scripts/docs/validate-docs.js +++ b/scripts/docs/validate-docs.js @@ -6,10 +6,41 @@ const path = require('node:path'); const ROOT = path.resolve(__dirname, '../..'); +// Runtime configuration modules. Deliberately config/*.js rather than config/**: +// the latter would sweep in config/jest/*.cjs, whose ALLURE_RESULTS_DIR is a +// reporter switch rather than application configuration and has no business in +// .env.example. +const CONFIG_PATHSPEC = 'config/*.js'; + +// Internal working notes. These are never a legitimate reference from published +// documentation, whatever they are named or wherever they are kept. +const PROHIBITED_REFERENCES = [ + { label: '.local/', pattern: /\.local\// }, + { label: 'docs/typecheck-debt.md', pattern: /docs\/typecheck-debt\.md/ }, + { label: 'jobs.md', pattern: /(?:^|[\s(['"/])jobs\.md/ }, +]; + /** * @typedef {{ file: string, line: number, message: string }} DocViolation */ +/** + * @param {string} rootDir + * @param {string} [pathspec] + * @returns {string[]} + */ +function gitLsFiles(rootDir, pathspec) { + const args = ['-C', rootDir, 'ls-files', '-z']; + + if (pathspec) { + args.push('--', pathspec); + } + + return execFileSync('git', args, { encoding: 'utf8' }) + .split('\0') + .filter(Boolean); +} + /** * Enumerates the Markdown files git tracks under `rootDir`. * @@ -24,17 +55,288 @@ const ROOT = path.resolve(__dirname, '../..'); * @returns {string[]} */ function listMarkdownFiles(rootDir = ROOT) { - const stdout = execFileSync('git', ['-C', rootDir, 'ls-files', '-z', '--', '*.md'], { - encoding: 'utf8', - }); - - return stdout - .split('\0') - .filter(Boolean) + return gitLsFiles(rootDir, '*.md') .map((relativePath) => path.join(rootDir, relativePath)) .sort((left, right) => left.localeCompare(right)); } +/** + * Blanks out fenced blocks and inline code spans so illustrative snippets are + * not mistaken for real images, links, or references. Newlines survive, so + * reported line numbers still point at the right source line. + * + * @param {string} content + * @returns {string} + */ +function stripCode(content) { + return content + .replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, ' ')) + .replace(/`[^`\n]*`/g, (span) => span.replace(/[^\n]/g, ' ')); +} + +/** + * GitHub's heading-anchor slug: lowercase, drop everything that is not a word + * character, space, or hyphen, then hyphenate the spaces. + * + * @param {string} heading + * @returns {string} + */ +function slugifyHeading(heading) { + return heading + .trim() + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-'); +} + +/** + * Repeated headings get `-1`, `-2`, ... exactly as GitHub disambiguates them. + * + * @param {string} content + * @returns {Set} + */ +function collectAnchors(content) { + /** @type {Map} */ + const seen = new Map(); + /** @type {Set} */ + const anchors = new Set(); + + stripCode(content).split('\n').forEach((line) => { + const match = /^#{1,6}\s+(.*?)\s*$/.exec(line); + + if (!match) { + return; + } + + const base = slugifyHeading(match[1]); + + if (!base) { + return; + } + + const count = seen.get(base) ?? 0; + + seen.set(base, count + 1); + anchors.add(count === 0 ? base : `${base}-${count}`); + }); + + return anchors; +} + +/** + * Raw `` tags must carry an `alt` attribute. The value may be empty — + * `alt=""` is the honest way to say "decorative" — but it has to be a decision + * someone made rather than an omission. Markdown's `![alt](src)` always carries + * the slot, so only the HTML form can be missing it; both syntaxes appear in + * Markdown files, which is why both are scanned. + * + * @param {string} content + * @param {string} relativePath + * @returns {DocViolation[]} + */ +function checkImageAlt(content, relativePath) { + /** @type {DocViolation[]} */ + const violations = []; + + stripCode(content).split('\n').forEach((line, index) => { + (line.match(/]*>/gi) ?? []).forEach((tag) => { + if (!/\salt\s*=/i.test(tag)) { + violations.push({ + file: relativePath, + line: index + 1, + message: 'Image must have an alt attribute (use alt="" if decorative)', + }); + } + }); + }); + + return violations; +} + +/** + * @param {string} content + * @param {string} relativePath + * @returns {DocViolation[]} + */ +function checkProhibitedReferences(content, relativePath) { + /** @type {DocViolation[]} */ + const violations = []; + + stripCode(content).split('\n').forEach((line, index) => { + PROHIBITED_REFERENCES.forEach(({ label, pattern }) => { + if (pattern.test(line)) { + violations.push({ + file: relativePath, + line: index + 1, + message: `Documentation must not reference internal working notes (${label})`, + }); + } + }); + }); + + return violations; +} + +/** + * @param {string} content + * @returns {Array<{ target: string, line: number }>} + */ +function collectLinkTargets(content) { + /** @type {Array<{ target: string, line: number }>} */ + const links = []; + + stripCode(content).split('\n').forEach((line, index) => { + const inline = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; + let match = inline.exec(line); + + while (match !== null) { + links.push({ target: match[1], line: index + 1 }); + match = inline.exec(line); + } + }); + + return links; +} + +/** + * Internal links must resolve: the target has to be tracked by git, and any + * fragment has to match a heading in it. + * + * Only link *targets* are held to that standard. Prose stays free to name a + * gitignored path — `certs/localhost-key.pem` is documented precisely because it + * is not checked in — so this never inspects prose. + * + * @param {string[]} files + * @param {string} rootDir + * @returns {DocViolation[]} + */ +function checkInternalLinks(files, rootDir) { + const tracked = new Set(gitLsFiles(rootDir)); + /** @type {Map>} */ + const anchorCache = new Map(); + + /** @param {string} absolutePath */ + const anchorsFor = (absolutePath) => { + if (!anchorCache.has(absolutePath)) { + anchorCache.set(absolutePath, collectAnchors(fs.readFileSync(absolutePath, 'utf8'))); + } + + return /** @type {Set} */ (anchorCache.get(absolutePath)); + }; + + return files.flatMap((filePath) => { + const relativePath = path.relative(rootDir, filePath); + const content = fs.readFileSync(filePath, 'utf8'); + + return collectLinkTargets(content).flatMap(({ target, line }) => { + // Anything with a scheme (http:, mailto:) or protocol-relative is external. + if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(target)) { + return []; + } + + const hashIndex = target.indexOf('#'); + const rawPath = hashIndex === -1 ? target : target.slice(0, hashIndex); + const fragment = hashIndex === -1 ? '' : decodeURIComponent(target.slice(hashIndex + 1)); + + if (!rawPath) { + if (anchorsFor(filePath).has(fragment)) { + return []; + } + + return [{ + file: relativePath, + line, + message: `Link points at a heading that does not exist: #${fragment}`, + }]; + } + + const resolved = path.normalize( + path.join(path.dirname(relativePath), decodeURIComponent(rawPath)), + ); + + if (!tracked.has(resolved)) { + return [{ + file: relativePath, + line, + message: `Link points at a path git does not track: ${rawPath}`, + }]; + } + + if (!fragment || !resolved.toLowerCase().endsWith('.md')) { + return []; + } + + if (anchorsFor(path.join(rootDir, resolved)).has(fragment)) { + return []; + } + + return [{ + file: relativePath, + line, + message: `Link points at a heading that does not exist in ${rawPath}: #${fragment}`, + }]; + }); + }); +} + +/** + * Every environment variable the runtime configuration reads must be documented + * in DEVELOPMENT.md — which calls itself the full configuration reference — and + * in .env.example. This is the check that catches a setting becoming a + * production affordance nobody wrote down. + * + * @param {string} rootDir + * @returns {DocViolation[]} + */ +function checkEnvVarCoverage(rootDir) { + const configFiles = gitLsFiles(rootDir, CONFIG_PATHSPEC); + + if (configFiles.length === 0) { + return []; + } + + /** @type {Map} */ + const readBy = new Map(); + + configFiles.forEach((relativePath) => { + const content = fs.readFileSync(path.join(rootDir, relativePath), 'utf8'); + const pattern = /process\.env\.([A-Z0-9_]+)/g; + let match = pattern.exec(content); + + while (match !== null) { + if (!readBy.has(match[1])) { + readBy.set(match[1], relativePath); + } + + match = pattern.exec(content); + } + }); + + const surfaces = [ + { file: 'DEVELOPMENT.md', label: 'the configuration reference' }, + { file: '.env.example', label: 'the environment template' }, + ]; + + return surfaces.flatMap(({ file, label }) => { + const absolutePath = path.join(rootDir, file); + + if (!fs.existsSync(absolutePath)) { + return []; + } + + const content = fs.readFileSync(absolutePath, 'utf8'); + + return [...readBy.entries()] + .filter(([name]) => !new RegExp(`\\b${name}\\b`).test(content)) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, source]) => ({ + file, + line: 1, + message: `${name} is read by ${source} but is undocumented in ${label}`, + })); + }); +} + /** * @param {string} filePath * @param {string} [rootDir] @@ -71,6 +373,9 @@ function validateMarkdownFile(filePath, rootDir = ROOT) { } }); + violations.push(...checkImageAlt(content, relativePath)); + violations.push(...checkProhibitedReferences(content, relativePath)); + return violations; } @@ -79,7 +384,11 @@ function validateDocs({ rootDir = ROOT } = {}) { return { files: files.map((filePath) => path.relative(rootDir, filePath) || filePath), - violations: files.flatMap((filePath) => validateMarkdownFile(filePath, rootDir)), + violations: [ + ...files.flatMap((filePath) => validateMarkdownFile(filePath, rootDir)), + ...checkInternalLinks(files, rootDir), + ...checkEnvVarCoverage(rootDir), + ], }; } @@ -150,9 +459,15 @@ if (require.main === module) { } module.exports = { + checkEnvVarCoverage, + checkImageAlt, + checkInternalLinks, + checkProhibitedReferences, + collectAnchors, listMarkdownFiles, main, parseArgs, + slugifyHeading, validateDocs, validateMarkdownFile, }; diff --git a/src/utils/validateEnvVars.js b/src/utils/validateEnvVars.js index 3f5e62164..af3cab9b6 100644 --- a/src/utils/validateEnvVars.js +++ b/src/utils/validateEnvVars.js @@ -19,6 +19,17 @@ const { RATE_LIMIT_STORE_MODES, } = require('../../config/rateLimitStore'); +// OUTBOUND_ALLOWED_HOSTS entries are matched verbatim against a URL's `host` +// (with port) or `hostname` (without), so the accepted syntax is exactly +// `host[:port]` — no wildcards, no suffix matching, nothing to interpret. +// Validating the shape here means a typo fails at boot rather than silently +// never matching, which for an allowlist would look identical to working. +const HOST_LABEL = '[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?'; +const HOST_ENTRY = `(?:${HOST_LABEL}(?:\\.${HOST_LABEL})*|\\[[0-9A-Fa-f:.]+\\])(?::\\d{1,5})?`; +const OUTBOUND_ALLOWED_HOSTS_PATTERN = new RegExp( + `^\\s*${HOST_ENTRY}(?:\\s*,\\s*${HOST_ENTRY})*\\s*$`, +); + /** * @param {unknown} value * @returns {string[]} @@ -75,6 +86,18 @@ const envVarsSchema = Joi.object({ }), OUTBOUND_CA_BUNDLE_FILE: Joi.string().optional(), + // Every entry here bypasses the private-network SSRF guard — see + // src/infrastructure/outboundUrlPolicy.js and the warning in DEVELOPMENT.md. + OUTBOUND_ALLOWED_HOSTS: Joi.string() + .pattern(OUTBOUND_ALLOWED_HOSTS_PATTERN) + .allow('') + .optional() + .messages({ + 'string.pattern.base': + '"OUTBOUND_ALLOWED_HOSTS" must be a comma-separated list of host[:port] ' + + 'entries (exact matches only — wildcards and suffix patterns are not supported)', + }), + // Scraper HTTP safeguards SCRAPER_REQUEST_TIMEOUT_MS: Joi.number().integer().min(1).optional(), SCRAPER_MAX_REDIRECTS: Joi.number().integer().min(0).optional(), diff --git a/tests/unit/scripts/docs/validateDocs.test.js b/tests/unit/scripts/docs/validateDocs.test.js index e35abfd15..b00b7b0ea 100644 --- a/tests/unit/scripts/docs/validateDocs.test.js +++ b/tests/unit/scripts/docs/validateDocs.test.js @@ -4,8 +4,13 @@ const os = require('node:os'); const path = require('node:path'); const { + checkEnvVarCoverage, + checkImageAlt, + checkProhibitedReferences, + collectAnchors, listMarkdownFiles, parseArgs, + slugifyHeading, validateDocs, validateMarkdownFile, } = require('../../../../scripts/docs/validate-docs'); @@ -144,4 +149,186 @@ describe('Unit | Scripts | Docs | Validate Docs', () => { }); expect(() => parseArgs(['--root'])).toThrow('--root requires a directory argument'); }); + + describe('image alt text', () => { + it('accepts an img with descriptive alt, and an explicit empty alt', () => { + expect(checkImageAlt('A bar chart\n', 'x.md')).toEqual([]); + expect(checkImageAlt('\n', 'x.md')).toEqual([]); + }); + + it('accepts Markdown image syntax, which always carries the alt slot', () => { + expect(checkImageAlt('![A bar chart](a.png)\n', 'x.md')).toEqual([]); + expect(checkImageAlt('![](banner.png)\n', 'x.md')).toEqual([]); + }); + + it('rejects a raw img with no alt attribute', () => { + expect(checkImageAlt('
    \n\n
    \n', 'x.md')) + .toEqual([{ + file: 'x.md', + line: 2, + message: 'Image must have an alt attribute (use alt="" if decorative)', + }]); + }); + + it('ignores an img inside a fenced code block', () => { + expect(checkImageAlt('```html\n\n```\n', 'x.md')).toEqual([]); + }); + }); + + describe('prohibited references', () => { + it('accepts prose naming a gitignored runtime path', () => { + // certs/ is gitignored on purpose, and documenting that the app looks + // there is correct. Only internal working notes are prohibited. + const content = 'the app tries `certs/localhost-key.pem` and certs/localhost.pem\n'; + + expect(checkProhibitedReferences(content, 'x.md')).toEqual([]); + }); + + it.each([ + ['see .local/docs-audit.md for detail', '.local/'], + ['tracked in docs/typecheck-debt.md', 'docs/typecheck-debt.md'], + ['see jobs.md', 'jobs.md'], + ])('rejects a reference to an internal working note (%s)', (line, label) => { + expect(checkProhibitedReferences(`${line}\n`, 'x.md')).toEqual([{ + file: 'x.md', + line: 1, + message: `Documentation must not reference internal working notes (${label})`, + }]); + }); + }); + + describe('internal links and anchors', () => { + it('accepts links to tracked files and existing headings', () => { + const rootDir = createTempDocsRepo(); + + try { + writeDoc(rootDir, 'docs/guide.md', '# Guide\n\n## Deep Section: HTTPS-first\n'); + writeDoc( + rootDir, + 'README.md', + [ + '# Readme', + '', + '## Local Heading', + '', + 'See [the guide](docs/guide.md).', + 'See [a section](docs/guide.md#deep-section-https-first).', + 'See [local](#local-heading).', + 'See [external](https://example.com/x).', + '', + ].join('\n'), + ); + + expect(validateDocs({ rootDir }).violations).toEqual([]); + } finally { + fs.rmSync(rootDir, { force: true, recursive: true }); + } + }); + + it('rejects a link to a path git does not track', () => { + const rootDir = createTempDocsRepo(); + + try { + writeDoc(rootDir, 'notes.md', '# Notes\n', { track: false }); + writeDoc(rootDir, 'README.md', '# Readme\n\nSee [notes](notes.md).\n'); + + expect(validateDocs({ rootDir }).violations).toEqual([{ + file: 'README.md', + line: 3, + message: 'Link points at a path git does not track: notes.md', + }]); + } finally { + fs.rmSync(rootDir, { force: true, recursive: true }); + } + }); + + it('rejects a link to a heading that does not exist', () => { + const rootDir = createTempDocsRepo(); + + try { + writeDoc(rootDir, 'README.md', '# Readme\n\nSee [gone](#no-such-heading).\n'); + + expect(validateDocs({ rootDir }).violations).toEqual([{ + file: 'README.md', + line: 3, + message: 'Link points at a heading that does not exist: #no-such-heading', + }]); + } finally { + fs.rmSync(rootDir, { force: true, recursive: true }); + } + }); + }); + + describe('heading slugs', () => { + it('matches how GitHub slugifies punctuation and spacing', () => { + expect(slugifyHeading('Inbound TLS posture: HTTPS-first vs. edge termination')) + .toBe('inbound-tls-posture-https-first-vs-edge-termination'); + expect(slugifyHeading('Quick Start (Dev)')).toBe('quick-start-dev'); + }); + + it('disambiguates repeated headings the way GitHub does', () => { + expect([...collectAnchors('## Notes\n\n## Notes\n\n## Notes\n')]) + .toEqual(['notes', 'notes-1', 'notes-2']); + }); + }); + + describe('env var coverage', () => { + const seedConfig = (rootDir, body) => { + writeDoc(rootDir, 'config/index.js', body); + }; + + it('accepts a variable documented in both surfaces', () => { + const rootDir = createTempDocsRepo(); + + try { + seedConfig(rootDir, 'module.exports = { a: process.env.SOME_SETTING };\n'); + writeDoc(rootDir, 'DEVELOPMENT.md', '# Dev\n\n| `SOME_SETTING` | No | unset | Does a thing. |\n'); + writeDoc(rootDir, '.env.example', '# SOME_SETTING=1\n'); + + expect(checkEnvVarCoverage(rootDir)).toEqual([]); + } finally { + fs.rmSync(rootDir, { force: true, recursive: true }); + } + }); + + it('rejects a variable missing from each surface independently', () => { + const rootDir = createTempDocsRepo(); + + try { + seedConfig(rootDir, 'module.exports = { a: process.env.SECRET_BYPASS };\n'); + writeDoc(rootDir, 'DEVELOPMENT.md', '# Dev\n\nNothing here.\n'); + writeDoc(rootDir, '.env.example', '# UNRELATED=1\n'); + + expect(checkEnvVarCoverage(rootDir)).toEqual([ + { + file: 'DEVELOPMENT.md', + line: 1, + message: 'SECRET_BYPASS is read by config/index.js but is undocumented in the configuration reference', + }, + { + file: '.env.example', + line: 1, + message: 'SECRET_BYPASS is read by config/index.js but is undocumented in the environment template', + }, + ]); + } finally { + fs.rmSync(rootDir, { force: true, recursive: true }); + } + }); + + it('does not sweep in Jest lane config, whose env is not app configuration', () => { + const rootDir = createTempDocsRepo(); + + try { + writeDoc(rootDir, 'config/jest/jest.base.cjs', 'const d = process.env.ALLURE_RESULTS_DIR;\n'); + writeDoc(rootDir, 'config/index.js', 'module.exports = {};\n'); + writeDoc(rootDir, 'DEVELOPMENT.md', '# Dev\n'); + writeDoc(rootDir, '.env.example', '# nothing\n'); + + expect(checkEnvVarCoverage(rootDir)).toEqual([]); + } finally { + fs.rmSync(rootDir, { force: true, recursive: true }); + } + }); + }); }); diff --git a/tests/unit/utils/validateEnvVars.test.js b/tests/unit/utils/validateEnvVars.test.js index 6b2d83101..07a2e40cf 100644 --- a/tests/unit/utils/validateEnvVars.test.js +++ b/tests/unit/utils/validateEnvVars.test.js @@ -260,6 +260,43 @@ describe('Unit | Utils | Validate Env Vars', () => { expect(() => validateEnvVars()).not.toThrow(); }); + it.each([ + ['127.0.0.1:19090'], + ['localhost'], + ['fixtures.internal:8080,127.0.0.1:19090'], + ['api.example.com'], + ['[::1]:8080'], + [''], + ])('accepts OUTBOUND_ALLOWED_HOSTS=%s', (value) => { + const validateEnvVars = loadValidator({ + overrides: { + REPLICATE_API_TOKEN: 'test-token', + OUTBOUND_ALLOWED_HOSTS: value, + }, + }); + + expect(() => validateEnvVars()).not.toThrow(); + }); + + // Every one of these would previously have been accepted and then silently + // never matched, which for an allowlist is indistinguishable from working. + it.each([ + ['*.example.com'], + ['example.com/path'], + ['https://example.com'], + ['example.com:99999999'], + ['exam ple.com'], + ])('rejects OUTBOUND_ALLOWED_HOSTS=%s', (value) => { + const validateEnvVars = loadValidator({ + overrides: { + REPLICATE_API_TOKEN: 'test-token', + OUTBOUND_ALLOWED_HOSTS: value, + }, + }); + + expect(() => validateEnvVars()).toThrow(/OUTBOUND_ALLOWED_HOSTS/); + }); + it('accepts a non-negative TRUST_PROXY_HOPS override', () => { const validateEnvVars = loadValidator({ overrides: {