From 0b7311cae58329fccb16404d615053a009f2e600 Mon Sep 17 00:00:00 2001 From: James B Date: Wed, 28 May 2025 15:14:34 +0100 Subject: [PATCH 01/17] Github access - switch to Oauth app credentials. And reduce API calls. https://github.com/IATI/validator-services/issues/496 Previously, this app used a BASIC_GITHUB_TOKEN with a personal access token. Also fixes a bug in getFileCommitSha where an error message could contain the wrong code. Was using branchRes var instead of fileRes --- .env.example | 3 +- .github/workflows/develop-func-deploy.yml | 12 +++- .github/workflows/prod-func-deploy.yml | 12 +++- README.md | 5 +- config/config.js | 3 +- utils/utils.js | 70 +++++++++++++---------- 6 files changed, 65 insertions(+), 40 deletions(-) diff --git a/.env.example b/.env.example index 0d68c711..28eb4e37 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,8 @@ NODE_ENV=development APPLICATIONINSIGHTS_CONNECTION_STRING= -BASIC_GITHUB_TOKEN= +GITHUB_OAUTH_APP_CLIENT_ID= +GITHUB_OAUTH_APP_CLIENT_SECRET= REDIS_PORT=6379 REDIS_HOSTNAME=redis # replace with localhost if you run outside docker diff --git a/.github/workflows/develop-func-deploy.yml b/.github/workflows/develop-func-deploy.yml index 48d1fe00..6ae99a0f 100644 --- a/.github/workflows/develop-func-deploy.yml +++ b/.github/workflows/develop-func-deploy.yml @@ -32,7 +32,8 @@ env: ACR_USERNAME: ${{ secrets.ACR_USERNAME }} ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} KV_URI: ${{ secrets.DEV_KV_URI }} - BASIC_GITHUB_TOKEN: ${{ secrets.BASIC_GITHUB_TOKEN }} + GITHUB_OAUTH_APP_CLIENT_ID: ${{ secrets.IATI_GITHUB_OAUTH_APP_CLIENT_ID }} + GITHUB_OAUTH_APP_CLIENT_SECRET: ${{ secrets.IATI_GITHUB_OAUTH_APP_CLIENT_SECRET }} DATASTORE_SERVICES_URL: ${{ secrets.DEV_DATASTORE_SERVICES_URL }} DATASTORE_SERVICES_AUTH_HTTP_HEADER_NAME: ${{ secrets.DEV_DATASTORE_SERVICES_AUTH_HTTP_HEADER_NAME }} DATASTORE_SERVICES_AUTH_HTTP_HEADER_VALUE: ${{ secrets.DEV_DATASTORE_SERVICES_AUTH_HTTP_HEADER_VALUE }} @@ -134,8 +135,13 @@ jobs: "slotSetting": false }, { - "name": "BASIC_GITHUB_TOKEN", - "value": "${{ env.BASIC_GITHUB_TOKEN }}", + "name": "GITHUB_OAUTH_APP_CLIENT_ID", + "value": "${{ env.GITHUB_OAUTH_APP_CLIENT_ID }}", + "slotSetting": false + }, + { + "name": "GITHUB_OAUTH_APP_CLIENT_SECRET", + "value": "${{ env.GITHUB_OAUTH_APP_CLIENT_SECRET }}", "slotSetting": false }, { diff --git a/.github/workflows/prod-func-deploy.yml b/.github/workflows/prod-func-deploy.yml index 59b109db..9bf6665b 100644 --- a/.github/workflows/prod-func-deploy.yml +++ b/.github/workflows/prod-func-deploy.yml @@ -22,7 +22,8 @@ env: ACR_USERNAME: ${{ secrets.ACR_USERNAME }} ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} KV_URI: ${{ secrets.PROD_KV_URI }} - BASIC_GITHUB_TOKEN: ${{ secrets.BASIC_GITHUB_TOKEN }} + GITHUB_OAUTH_APP_CLIENT_ID: ${{ secrets.IATI_GITHUB_OAUTH_APP_CLIENT_ID }} + GITHUB_OAUTH_APP_CLIENT_SECRET: ${{ secrets.IATI_GITHUB_OAUTH_APP_CLIENT_SECRET }} DATASTORE_SERVICES_URL: ${{ secrets.PROD_DATASTORE_SERVICES_URL }} DATASTORE_SERVICES_AUTH_HTTP_HEADER_NAME: ${{ secrets.PROD_DATASTORE_SERVICES_AUTH_HTTP_HEADER_NAME }} DATASTORE_SERVICES_AUTH_HTTP_HEADER_VALUE: ${{ secrets.PROD_DATASTORE_SERVICES_AUTH_HTTP_HEADER_VALUE }} @@ -119,8 +120,13 @@ jobs: "slotSetting": false }, { - "name": "BASIC_GITHUB_TOKEN", - "value": "${{ env.BASIC_GITHUB_TOKEN }}", + "name": "GITHUB_OAUTH_APP_CLIENT_ID", + "value": "${{ env.GITHUB_OAUTH_APP_CLIENT_ID }}", + "slotSetting": false + }, + { + "name": "GITHUB_OAUTH_APP_CLIENT_SECRET", + "value": "${{ env.GITHUB_OAUTH_APP_CLIENT_SECRET }}", "slotSetting": false }, { diff --git a/README.md b/README.md index 4f98b22f..108837b1 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,10 @@ APPLICATIONINSIGHTS_CONNECTION_STRING - Needs to be set for running locally, but will not actually report telemetry to the AppInsights instance in my experience -BASIC_GITHUB_TOKEN +GITHUB_OAUTH_APP_CLIENT_ID +GITHUB_OAUTH_APP_CLIENT_SECRET -- GitHub personal access token. This is needed to pull in the Codelists from the `IATI/IATI-Validator-Codelists` repository. Note that you cannot use a "Personal Access Token (Classic)"; you must generate a fine-grained access token. +- GitHub OAuth app. No special permissions or access needed. Optional, but you may get rate limited very easily if you don't. REDIS_PORT=6379 REDIS_HOSTNAME=redis diff --git a/config/config.js b/config/config.js index 9e7439f6..33fb4c4e 100644 --- a/config/config.js +++ b/config/config.js @@ -11,7 +11,8 @@ const config = { NS_PER_SEC: 1e9, VERSIONS: process.env.VERSIONS || ['2.01', '2.02', '2.03'], MAX_FILESIZE: process.env.MAX_FILESIZE || 60, - BASIC_GITHUB_TOKEN: process.env.BASIC_GITHUB_TOKEN, + GITHUB_OAUTH_APP_CLIENT_ID: process.env.GITHUB_OAUTH_APP_CLIENT_ID, + GITHUB_OAUTH_APP_CLIENT_SECRET: process.env.GITHUB_OAUTH_APP_CLIENT_SECRET, REDIS_PORT: process.env.REDIS_PORT || 6379, REDIS_CACHE_SEC: process.env.REDIS_CACHE_SEC || 86400, REDIS_KEY: process.env.REDIS_KEY, diff --git a/utils/utils.js b/utils/utils.js index 492ff7f8..9e604b3b 100644 --- a/utils/utils.js +++ b/utils/utils.js @@ -6,18 +6,31 @@ import { spawn } from 'child_process'; import redisclient from '../config/redis.js'; import config from '../config/config.js'; -const GITHUB_RAW = 'https://raw.githubusercontent.com'; const GITHUB_API = 'https://api.github.com'; const getFileBySha = async (owner, repo, sha, filePath) => { - // https://raw.githubusercontent.com/IATI/IATI-Validator-Codelists/{sha}/codelist_rules.json - const res = await fetch(`${GITHUB_RAW}/${owner}/${repo}/${sha}/${filePath}`, { - method: 'GET', - headers: { - Accept: 'text/plain', - Authorization: `token ${config.BASIC_GITHUB_TOKEN}`, + const headers = { Accept: "application/vnd.github.raw+json" }; + if ( + config.GITHUB_OAUTH_APP_CLIENT_ID && + config.GITHUB_OAUTH_APP_CLIENT_SECRET + ) { + headers["Authorization"] = + "Basic " + + Buffer.from( + config.GITHUB_OAUTH_APP_CLIENT_ID + + ":" + + config.GITHUB_OAUTH_APP_CLIENT_SECRET, + ).toString("base64"); + } + const res = await fetch( + `${GITHUB_API}/repos/${owner}/${repo}/contents/${filePath}?ref=${sha}`, + { + method: "GET", + headers: headers, }, - }); + ); + // This can be useful to check auth. You should see headers like x-ratelimit-limit, x-ratelimit-remaining + //console.log(res.headers); const body = res.json(); if (res.status !== 200) throw new Error( @@ -27,35 +40,32 @@ const getFileBySha = async (owner, repo, sha, filePath) => { }; const getFileCommitSha = async (owner, repo, branch, filePath) => { - // https://api.github.com/repos/IATI/IATI-Validator-Codelists/branches/version-2.03 - const branchRes = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/branches/${branch}`, { - method: 'GET', - headers: { - Accept: 'application/vnd.github.v3+json', - Authorization: `token ${config.BASIC_GITHUB_TOKEN}`, - }, - }); - const branchBody = await branchRes.json(); - if (branchRes.status !== 200) - throw new Error( - `Error fetching sha from github api. Status: ${branchRes.status} Message: ${branchBody.message} `, - ); - const { sha } = branchBody.commit; - // https://api.github.com/repos/IATI/IATI-Validator-Codelists/commits?sha={sha}&path=codelist_rules.json + const headers = { Accept: "application/vnd.github.v3+json" }; + if ( + config.GITHUB_OAUTH_APP_CLIENT_ID && + config.GITHUB_OAUTH_APP_CLIENT_SECRET + ) { + headers["Authorization"] = + "Basic " + + Buffer.from( + config.GITHUB_OAUTH_APP_CLIENT_ID + + ":" + + config.GITHUB_OAUTH_APP_CLIENT_SECRET, + ).toString("base64"); + } const fileRes = await fetch( - `${GITHUB_API}/repos/${owner}/${repo}/commits?sha=${sha}&path=${filePath}`, + `${GITHUB_API}/repos/${owner}/${repo}/commits?sha=${branch}&path=${filePath}`, { - method: 'GET', - headers: { - Accept: 'application/vnd.github.v3+json', - Authorization: `token ${config.BASIC_GITHUB_TOKEN}`, - }, + method: "GET", + headers: headers, }, ); + // This can be useful to check auth. You should see headers like x-ratelimit-limit, x-ratelimit-remaining + //console.log(fileRes.headers); const fileBody = await fileRes.json(); if (fileRes.status !== 200) throw new Error( - `Error fetching sha from github api. Status: ${branchRes.status} Message: ${fileBody.message} `, + `Error fetching sha from github api. Status: ${fileRes.status} Message: ${fileBody.message} `, ); // sort to get newest commit fileBody.sort( From 98b6c153e47c1b036b33b8fbba37f83abeb073db Mon Sep 17 00:00:00 2001 From: arobson-ods Date: Mon, 17 Aug 2026 10:27:03 +0100 Subject: [PATCH 02/17] docs: correct dev service hostnames and clarify required env vars --- .env.example | 8 +++++--- README.md | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 28eb4e37..4ce921ac 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,20 @@ NODE_ENV=development -APPLICATIONINSIGHTS_CONNECTION_STRING= +APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://localhost/ +# Optional, but without it GitHub rate limiting will usually stop the rulesets +# and codelists downloading at startup. GITHUB_OAUTH_APP_CLIENT_ID= GITHUB_OAUTH_APP_CLIENT_SECRET= REDIS_PORT=6379 REDIS_HOSTNAME=redis # replace with localhost if you run outside docker -VALIDATOR_SERVICES_URL=https://dev-func-validator-services.azurewebsites.net/api +VALIDATOR_SERVICES_URL=https://func-validator-services-dev.azurewebsites.net/api VALIDATOR_SERVICES_KEY_NAME=x-functions-key VALIDATOR_SERVICES_KEY_VALUE= -DATASTORE_SERVICES_URL=https://dev-func-datastore-services.azurewebsites.net/api +DATASTORE_SERVICES_URL=https://func-datastore-services-dev.azurewebsites.net/api DATASTORE_SERVICES_AUTH_HTTP_HEADER_NAME=x-functions-key DATASTORE_SERVICES_AUTH_HTTP_HEADER_VALUE= DATASTORE_SERVICES_IATI_IDENTIFIERS_EXIST_MAX_NUMBER_OF_IDS=5000 diff --git a/README.md b/README.md index 108837b1..b3394ec9 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,12 @@ If it has warnings or errors, you'll see them listed. APPLICATIONINSIGHTS_CONNECTION_STRING -- Needs to be set for running locally, but will not actually report telemetry to the AppInsights instance in my experience +- **Required — the app will not start without it.** `config/appInsights.js` calls + `appInsights.setup(...).start()` at module load. If the value is empty or malformed the SDK throws + `Instrumentation key not found`, the Functions worker fails to load every function that imports it, + and requests get `503 Function host is not running` (or an empty 500) naming no cause. + For local development any syntactically valid connection string works; no telemetry is sent. The + value shipped in `.env.example` is a usable dummy: GITHUB_OAUTH_APP_CLIENT_ID GITHUB_OAUTH_APP_CLIENT_SECRET @@ -109,7 +114,7 @@ VALIDATOR_SERVICES_KEY_VALUE= - URL and API Key for Validator Services, used to get list of Publisher Identifiers -DATASTORE_SERVICES_URL=https://dev-func-datastore-services.azurewebsites.net/api +DATASTORE_SERVICES_URL=https://func-datastore-services-dev.azurewebsites.net/api DATASTORE_SERVICES_AUTH_HTTP_HEADER_NAME=x-functions-key DATASTORE_SERVICES_AUTH_HTTP_HEADER_VALUE= DATASTORE_SERVICES_IATI_IDENTIFIERS_EXIST_MAX_NUMBER_OF_IDS=5000 @@ -117,6 +122,11 @@ DATASTORE_SERVICES_IATI_IDENTIFIERS_EXIST_MAX_NUMBER_OF_IDS=5000 - URL and API Key for datastore services, used by the advisory system to check for the existence of IATI Identifiers in the Datastore +- VALIDATOR_SERVICES_KEY_VALUE and DATASTORE_SERVICES_AUTH_HTTP_HEADER_VALUE are outbound + credentials for Azure Functions. If you are an authorised developer on this + codebase you can get them from the Azure portal for the respective Function Apps. + If you do not have Azure access, ask the team. + ### App config defaults (set in `config/config.js`) ``` @@ -185,7 +195,7 @@ In Docker container - Install newman globally `npm i -g newman` - Edit `function.json` files to set `"authLevel": "anonymous"`, don't forget to change back! - Start function `npm run docker:start` -- Run Tests `npm docker:int:test` +- Run Tests `npm run docker:int:test` ### Modifying/Adding From 20ccfbb9fec9840d9cc7845794a65341d0b7698c Mon Sep 17 00:00:00 2001 From: arobson-ods Date: Mon, 17 Aug 2026 10:35:54 +0100 Subject: [PATCH 03/17] ci: refuse commits that set authLevel to anonymous Running the integration tests against the Docker container requires temporarily setting "authLevel": "anonymous" in all five function.json files, because the container has no AzureWebJobsStorage and so cannot validate function keys. Committing that would leave the deployed endpoints reachable without a key. Adds a .husky/pre-commit check on staged content, and a matching check-auth-level job in develop-func-ci.yml which also gates automerge, so a Dependabot PR cannot merge with it set. The hook alone is insufficient: it can be bypassed with --no-verify and is absent in a fresh clone until npm install has run. --- .github/workflows/develop-func-ci.yml | 26 +++++++++++++++++++++++++- .husky/pre-commit | 25 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/.github/workflows/develop-func-ci.yml b/.github/workflows/develop-func-ci.yml index 47e966a4..81eb77cd 100644 --- a/.github/workflows/develop-func-ci.yml +++ b/.github/workflows/develop-func-ci.yml @@ -12,6 +12,30 @@ on: - main jobs: + # Running the integration tests against the Docker container requires temporarily + # setting "authLevel": "anonymous" in the function.json files (see README), because + # the container has no AzureWebJobsStorage and so cannot validate function keys. + # Committing that would leave the deployed endpoints reachable without a key. + # There is a matching .husky/pre-commit guard, but hooks are skippable with + # --no-verify and are absent in a fresh clone until `npm install` has run. + check-auth-level: + runs-on: ubuntu-latest + steps: + - name: 'Checkout GitHub Action' + uses: actions/checkout@v4 + + - name: 'Reject authLevel anonymous in function.json' + run: | + if grep -il '"authLevel"[[:space:]]*:[[:space:]]*"anonymous"' */function.json > /tmp/hits; then + echo "::error::function.json committed with authLevel anonymous - the deployed endpoint would need no function key" + echo "Offending files:" + cat /tmp/hits + echo "" + echo "Restore with: git checkout -- '*/function.json'" + exit 1 + fi + echo "OK - all function.json files require a function key." + build-test-node: runs-on: ubuntu-latest steps: @@ -48,7 +72,7 @@ jobs: docker build . -t validator-local:latest automerge: - needs: [build-test-node, build-docker] + needs: [check-auth-level, build-test-node, build-docker] runs-on: ubuntu-latest permissions: pull-requests: write diff --git a/.husky/pre-commit b/.husky/pre-commit index d37daa07..dbb75f86 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,29 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" +# Running the integration tests against the Docker container requires temporarily +# setting "authLevel": "anonymous" in the function.json files (see README). The +# container has no AzureWebJobsStorage, so it cannot validate function keys and +# every request 401s otherwise. +# +# Committing that change would leave the deployed endpoints reachable without a +# function key. This guard refuses the commit instead of relying on memory. +# +# Restore with: git checkout -- '*/function.json' +for f in $(git diff --cached --name-only --diff-filter=ACM | grep 'function\.json$'); do + if git show ":$f" | grep -qi '"authLevel"[[:space:]]*:[[:space:]]*"anonymous"'; then + echo "" + echo "ERROR $f is staged with \"authLevel\": \"anonymous\"" + echo "" + echo " That would leave the deployed endpoint unauthenticated." + echo " It is only meant to be set temporarily, to run the" + echo " integration tests against the Docker container." + echo "" + echo " Restore all of them with:" + echo " git checkout -- '*/function.json'" + echo "" + exit 1 + fi +done + npx --no-install lint-staged From c4971657860cd8819db87367e9908d3c6ea0e2a2 Mon Sep 17 00:00:00 2001 From: arobson-ods Date: Tue, 18 Aug 2026 11:10:51 +0100 Subject: [PATCH 04/17] test: add failing schema-validation tests Tests added prior to replacing libxmljs2 - see js-validator-api#574 Adds schema-unit-tests/, the first coverage of the XSD schema path. Extend the rules:test glob so the new directory runs in existing CI with no workflow change. Five cases fail deliberately. They assert the corrected behaviour and go green when libxmljs2 is replaced. Two controls pass either side, guarding against the suite going green by not validating at all. Also adds an HTTP-level case to the newman collection. --- ...alidator-api-tests.postman_collection.json | 81 ++ .../test-files/iati-org-value-sign-only.xml | 17 + package.json | 2 +- .../test-files/act_value_sign_only.xml | 23 + .../test-files/org_bad_datetime.xml | 12 + schema-unit-tests/test-files/org_valid.xml | 17 + .../test-files/org_value_long_decimal.xml | 17 + .../test-files/org_value_plus_only.xml | 17 + .../test-files/org_value_sign_only.xml | 17 + .../org_value_sign_only_multiple.xml | 1168 +++++++++++++++++ schema-unit-tests/test.js | 212 +++ 11 files changed, 1582 insertions(+), 1 deletion(-) create mode 100644 integration-tests/test-files/iati-org-value-sign-only.xml create mode 100644 schema-unit-tests/test-files/act_value_sign_only.xml create mode 100644 schema-unit-tests/test-files/org_bad_datetime.xml create mode 100644 schema-unit-tests/test-files/org_valid.xml create mode 100644 schema-unit-tests/test-files/org_value_long_decimal.xml create mode 100644 schema-unit-tests/test-files/org_value_plus_only.xml create mode 100644 schema-unit-tests/test-files/org_value_sign_only.xml create mode 100644 schema-unit-tests/test-files/org_value_sign_only_multiple.xml create mode 100644 schema-unit-tests/test.js diff --git a/integration-tests/js-validator-api-tests.postman_collection.json b/integration-tests/js-validator-api-tests.postman_collection.json index aff6257c..4f2b56bd 100644 --- a/integration-tests/js-validator-api-tests.postman_collection.json +++ b/integration-tests/js-validator-api-tests.postman_collection.json @@ -1227,6 +1227,87 @@ }, "response": [] }, + { + "name": "iati-org-value-sign-only - expect 0.3.1", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Regression test for js-validator-api#574. A sign-only xs:decimal", + "// (\" - \") was accepted by the old bundled libxml2 (2.9.9),", + "// so the file validated clean while the IATI Dashboard reported it as failing.", + "pm.test(\"Status code is 422\", function () { pm.response.to.have.status(422); });", + "pm.test(\"The file is not valid\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.valid).to.eql(false);", + "});", + "pm.test(\"fileType value is iati-organisations\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.fileType).to.eql('iati-organisations');", + "});", + "pm.test(\"version is 2.03\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.iatiVersion).to.eql('2.03');", + "});", + "pm.test(\"Errors include schema error id 0.3.1\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.errors.map(obj => (obj.id))).to.include('0.3.1');", + "});", + "pm.test(\"The sign-only decimal is named in the error context\", function () {", + " pm.expect(pm.response.text()).to.include(\"is not a valid value of the atomic type 'xs:decimal'\");", + "});", + "pm.test(\"Summary object should have 1 critical error\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.summary.critical).to.eq(1);", + "});" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "followRedirects": false, + "followOriginalHttpMethod": false, + "followAuthorizationHeader": false + }, + "request": { + "method": "POST", + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/xml" + } + ], + "body": { + "mode": "file", + "file": { + "src": "iati-org-value-sign-only.xml" + } + }, + "url": { + "raw": "{{baseURL}}/pub/validate?group=false", + "host": [ + "{{baseURL}}" + ], + "path": [ + "pub", + "validate" + ], + "query": [ + { + "key": "group", + "value": "false" + } + ] + } + }, + "response": [] + }, { "name": "iati-act-root-only - expect 0.3.1", "event": [ diff --git a/integration-tests/test-files/iati-org-value-sign-only.xml b/integration-tests/test-files/iati-org-value-sign-only.xml new file mode 100644 index 00000000..5144e64a --- /dev/null +++ b/integration-tests/test-files/iati-org-value-sign-only.xml @@ -0,0 +1,17 @@ + + + + AA-AAA-123456789 + + Organisation name + + + Organisation name + + + + + - + + + diff --git a/package.json b/package.json index 544a2cc6..e55f03ec 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "start": "func start --javascript", "start:verbose": "func start --javascript --verbose", - "rules:test": "mocha './ruleset-unit-tests/**/test.js'", + "rules:test": "mocha './ruleset-unit-tests/**/test.js' './schema-unit-tests/**/test.js'", "int:test": "newman run integration-tests/js-validator-api-tests.postman_collection.json -e integration-tests/envs/local-function-7071.postman_environment.json --working-dir integration-tests/test-files", "docker:start": "docker compose up --build", "docker:int:test": "newman run integration-tests/js-validator-api-tests.postman_collection.json -e integration-tests/envs/local-docker-function.postman_environment.json --working-dir integration-tests/test-files", diff --git a/schema-unit-tests/test-files/act_value_sign_only.xml b/schema-unit-tests/test-files/act_value_sign_only.xml new file mode 100644 index 00000000..3d5a5b71 --- /dev/null +++ b/schema-unit-tests/test-files/act_value_sign_only.xml @@ -0,0 +1,23 @@ + + + + AA-AAA-123456789-ABC123 + + Example narrative + + + <narrative>Example narrative</narrative> + + + Example narrative + + + + + + + + - + + + diff --git a/schema-unit-tests/test-files/org_bad_datetime.xml b/schema-unit-tests/test-files/org_bad_datetime.xml new file mode 100644 index 00000000..b52315ac --- /dev/null +++ b/schema-unit-tests/test-files/org_bad_datetime.xml @@ -0,0 +1,12 @@ + + + + AA-AAA-123456789 + + Organisation name + + + Organisation name + + + diff --git a/schema-unit-tests/test-files/org_valid.xml b/schema-unit-tests/test-files/org_valid.xml new file mode 100644 index 00000000..c6e2ebd3 --- /dev/null +++ b/schema-unit-tests/test-files/org_valid.xml @@ -0,0 +1,17 @@ + + + + AA-AAA-123456789 + + Organisation name + + + Organisation name + + + + + 1000.00 + + + diff --git a/schema-unit-tests/test-files/org_value_long_decimal.xml b/schema-unit-tests/test-files/org_value_long_decimal.xml new file mode 100644 index 00000000..d5716c4b --- /dev/null +++ b/schema-unit-tests/test-files/org_value_long_decimal.xml @@ -0,0 +1,17 @@ + + + + AA-AAA-123456789 + + Organisation name + + + Organisation name + + + + + 111111111111111111111111111111 + + + diff --git a/schema-unit-tests/test-files/org_value_plus_only.xml b/schema-unit-tests/test-files/org_value_plus_only.xml new file mode 100644 index 00000000..5f05d56d --- /dev/null +++ b/schema-unit-tests/test-files/org_value_plus_only.xml @@ -0,0 +1,17 @@ + + + + AA-AAA-123456789 + + Organisation name + + + Organisation name + + + + + + + + + diff --git a/schema-unit-tests/test-files/org_value_sign_only.xml b/schema-unit-tests/test-files/org_value_sign_only.xml new file mode 100644 index 00000000..5144e64a --- /dev/null +++ b/schema-unit-tests/test-files/org_value_sign_only.xml @@ -0,0 +1,17 @@ + + + + AA-AAA-123456789 + + Organisation name + + + Organisation name + + + + + - + + + diff --git a/schema-unit-tests/test-files/org_value_sign_only_multiple.xml b/schema-unit-tests/test-files/org_value_sign_only_multiple.xml new file mode 100644 index 00000000..264ff8db --- /dev/null +++ b/schema-unit-tests/test-files/org_value_sign_only_multiple.xml @@ -0,0 +1,1168 @@ + + + + AA-AAA-123456789 + + Example narrative + Example narrative + + + Example narrative + Example narrative + + + + 1000.00 + + + 1000.00 + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + - + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + - + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + - + + + + 1000.00 + + + + - + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + 1000.00 + + + + - + + + + 1000.00 + + + + 1000.00 + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + <narrative>Example narrative</narrative> + + + + + + \ No newline at end of file diff --git a/schema-unit-tests/test.js b/schema-unit-tests/test.js new file mode 100644 index 00000000..0cda1890 --- /dev/null +++ b/schema-unit-tests/test.js @@ -0,0 +1,212 @@ +import fs from 'fs/promises'; +import { fileURLToPath } from 'url'; +// eslint-disable-next-line import/no-extraneous-dependencies +import chai from 'chai'; +import libxml from 'libxmljs2'; + +import { validateIATI } from '../services/rulesValidator.js'; + +const { expect } = chai; + +/* + * Schema validation tests. + * + * Tests developed to prior to replacing libxmljs2. See issue: Upgrade version + * of libxmljs2 used to bring in line with Dashboard validation + * https://github.com/IATI/js-validator-api/issues/574 + * + * This upgrade is necessary to fix a defect in libxml2's handling of xs:decimal + * highlighted by the following issues: + * https://github.com/IATI/validator-web/issues/1002 + * https://github.com/IATI/IATI-Dashboard/issues/845 + * + * These exercise the XSD schema path. Nothing in ruleset-unit-tests/ does: 20 of its 24 + * suites go through allRulesResult or testRuleset, which never touch a schema at all, + * and the two that do call validateIATI pass three arguments - leaving its 4th + * parameter (schema) undefined, so the `if (schema)` guards short-circuit. A schema + * regression has therefore been invisible to PR CI. + * + * Run by the same `npm run rules:test` command, so no CI change is needed. + * + * Five of these cases FAIL until libxmljs2 is replaced (js-validator-api#574). That is + * intentional: they assert the corrected behaviour, so they go green with the fix. + * The two CONTROL cases pass either side. + * + * Only 2.03 fixtures are validated, though config.VERSIONS is 2.01, 2.02 and 2.03. The + * defect is in libxml2's handling of xs:decimal, an XSD built-in type, so it behaves the + * same whichever IATI schema references it - checked against all three. What does differ + * between versions is the schema content itself, so the Schema compilation block builds + * all six rather than assuming 2.03 speaks for the rest. + */ + +const DECIMAL_ERROR = "is not a valid value of the atomic type 'xs:decimal'"; +const DATETIME_ERROR = "is not a valid value of the atomic type 'xs:dateTime'"; + +const testMap = [ + { + file: 'org_value_sign_only.xml', + fileType: 'iati-organisations', + what: 'a sign-only decimal " - " is rejected (the defect reported in #1002)', + occurrences: 1, + message: DECIMAL_ERROR, + }, + { + file: 'org_value_plus_only.xml', + fileType: 'iati-organisations', + what: 'a sign-only decimal " + " is rejected', + occurrences: 1, + message: DECIMAL_ERROR, + }, + { + // The same defect in an activity file, so both 2.03 schemas are exercised, + // not just the organisation one. See the Schema compilation block for the + // other four. + file: 'act_value_sign_only.xml', + fileType: 'iati-activities', + what: 'a sign-only decimal is rejected in an activity file too', + occurrences: 1, + message: DECIMAL_ERROR, + }, + { + file: 'org_value_long_decimal.xml', + fileType: 'iati-organisations', + what: 'a 30-digit decimal is accepted (the inverse defect - the old parser rejects it)', + occurrences: 0, + }, + { + // Anonymised copy of the real file that triggered #1002. Adds what the minimal + // fixtures don't: five occurrences of one defect, which validateSchema groups + // by message into a single error carrying five contexts. + file: 'org_value_sign_only_multiple.xml', + fileType: 'iati-organisations', + what: 'repeated sign-only decimals are all reported, with their line numbers', + occurrences: 5, + // One below the file's actual lines (89, 125, 149, 157, 245). validateIATI + // validates each organisation as its own re-wrapped document and reconstructs + // line numbers by offset arithmetic, which lands one low for this fixture. + lines: [88, 124, 148, 156, 244], + message: DECIMAL_ERROR, + }, + { + // Positive control. Guards against the suite going green by not validating at + // all - validateIATI silently skips schema work when `schema` is undefined. + file: 'org_bad_datetime.xml', + fileType: 'iati-organisations', + what: 'CONTROL: an invalid xs:dateTime is still caught', + occurrences: 1, + message: DATETIME_ERROR, + }, + { + // Negative control. + file: 'org_valid.xml', + fileType: 'iati-organisations', + what: 'CONTROL: a valid file reports nothing', + occurrences: 0, + }, +]; + +/* ------------------------------------------------------------------------- + * XML-LIBRARY-COUPLED SECTION - the only two functions that touch libxmljs2. + * Replacing the library (js-validator-api#574) means rewriting these two and + * nothing else in this file: the fixtures, the expectations and the assertions + * are all library-agnostic, which is what makes the red-to-green transition + * meaningful rather than an artefact of rewriting the tests. + * ---------------------------------------------------------------------- */ + +/* + * Reads an XSD and returns whatever object the validator expects. Note this does + * NOT compile it: libxmljs2 defers that to validate(). See assertSchemaUsable. + */ +const loadSchema = async (fileType, version) => { + const xsd = new URL(`../schemas/${version}/${fileType}-schema.xsd`, import.meta.url); + const baseUrl = fileURLToPath(new URL(`../schemas/${version}/`, import.meta.url)); + return libxml.parseXml((await fs.readFile(xsd)).toString(), { baseUrl }); +}; + +/* + * Forces libxml2 to actually compile the schema, which loadSchema alone does not: + * parseXml happily returns a document even when xsd:include cannot be resolved, and + * the failure surfaces only later as "Invalid XSD schema" thrown from validate(). + * Without this the compilation tests below would pass with broken includes. + */ +const assertSchemaUsable = (schema, fileType, version) => { + const probe = libxml.parseXml(`<${fileType} version="${version}" />`); + expect(() => probe.validate(schema)).to.not.throw(); +}; + +/* ------------------------- end library-coupled section ------------------- */ + +// Rule evaluation is not under test here, so an empty ruleset and empty id sets. +const emptyRuleset = {}; +const idSets = { 'ORG-ID': new Set(), 'ORG-ID-PREFIX': new Set() }; + +const runSchemaValidation = async (file, fileType, version) => { + const xml = (await fs.readFile(new URL(`./test-files/${file}`, import.meta.url))).toString(); + const schema = await loadSchema(fileType, version); + const { schemaErrors } = await validateIATI( + emptyRuleset, + xml, + fileType, + idSets, + schema, + false, + false, + ); + return schemaErrors; +}; + +// Errors sharing a message are grouped into one object carrying several contexts, +// so count contexts rather than error objects. +const countOccurrences = (schemaErrors) => + schemaErrors.reduce((total, error) => total + error.context.length, 0); + +const reportedLines = (schemaErrors) => + schemaErrors + .flatMap((error) => error.context.map((context) => context.text)) + .map((text) => { + const match = /At line(?: greater than)?: (\d+)/.exec(text); + return match ? Number(match[1]) : null; + }) + .filter((line) => line !== null); + +describe('Schema compilation', () => { + // getSchema loads six schemas: three versions times two file types. Each pulls in + // iati-common.xsd and xml.xsd, resolved relative to the baseUrl given at compile + // time. The mechanism is identical for all six, but the content is not - + // iati-common.xsd differs per version - so build every one. A schema that fails to + // build is not obvious: see assertSchemaUsable. + ['2.01', '2.02', '2.03'].forEach((version) => { + ['iati-activities', 'iati-organisations'].forEach((fileType) => { + it(`${fileType} ${version} compiles, resolving its includes`, async () => { + const schema = await loadSchema(fileType, version); + assertSchemaUsable(schema, fileType, version); + }); + }); + }); +}); + +describe('Schema validation', () => { + testMap.forEach((test) => { + it(`${test.file}: ${test.what}`, async () => { + const schemaErrors = await runSchemaValidation(test.file, test.fileType, '2.03'); + + expect(countOccurrences(schemaErrors)).to.equal(test.occurrences); + + schemaErrors.forEach((error) => { + expect(error.id).to.equal('0.3.1'); + expect(error.severity).to.equal('critical'); + expect(error.category).to.equal('schema'); + }); + + if (test.message) { + schemaErrors.forEach((error) => { + expect(error.message).to.contain(test.message); + }); + } + + if (test.lines) { + expect(reportedLines(schemaErrors)).to.have.members(test.lines); + } + }); + }); +}); From 40de213eda55f970ca5893cf35f38a00c5991adb Mon Sep 17 00:00:00 2001 From: arobson-ods Date: Wed, 19 Aug 2026 09:59:58 +0100 Subject: [PATCH 05/17] style: fix eslint errors on the github api helpers --- utils/utils.js | 46 ++++++++++++++++------------------------------ 1 file changed, 16 insertions(+), 30 deletions(-) diff --git a/utils/utils.js b/utils/utils.js index 9e604b3b..cf107901 100644 --- a/utils/utils.js +++ b/utils/utils.js @@ -9,28 +9,21 @@ import config from '../config/config.js'; const GITHUB_API = 'https://api.github.com'; const getFileBySha = async (owner, repo, sha, filePath) => { - const headers = { Accept: "application/vnd.github.raw+json" }; - if ( - config.GITHUB_OAUTH_APP_CLIENT_ID && - config.GITHUB_OAUTH_APP_CLIENT_SECRET - ) { - headers["Authorization"] = - "Basic " + - Buffer.from( - config.GITHUB_OAUTH_APP_CLIENT_ID + - ":" + - config.GITHUB_OAUTH_APP_CLIENT_SECRET, - ).toString("base64"); + const headers = { Accept: 'application/vnd.github.raw+json' }; + if (config.GITHUB_OAUTH_APP_CLIENT_ID && config.GITHUB_OAUTH_APP_CLIENT_SECRET) { + headers.Authorization = `Basic ${Buffer.from( + `${config.GITHUB_OAUTH_APP_CLIENT_ID}:${config.GITHUB_OAUTH_APP_CLIENT_SECRET}`, + ).toString('base64')}`; } const res = await fetch( `${GITHUB_API}/repos/${owner}/${repo}/contents/${filePath}?ref=${sha}`, { - method: "GET", - headers: headers, + method: 'GET', + headers, }, ); // This can be useful to check auth. You should see headers like x-ratelimit-limit, x-ratelimit-remaining - //console.log(res.headers); + // console.log(res.headers); const body = res.json(); if (res.status !== 200) throw new Error( @@ -40,28 +33,21 @@ const getFileBySha = async (owner, repo, sha, filePath) => { }; const getFileCommitSha = async (owner, repo, branch, filePath) => { - const headers = { Accept: "application/vnd.github.v3+json" }; - if ( - config.GITHUB_OAUTH_APP_CLIENT_ID && - config.GITHUB_OAUTH_APP_CLIENT_SECRET - ) { - headers["Authorization"] = - "Basic " + - Buffer.from( - config.GITHUB_OAUTH_APP_CLIENT_ID + - ":" + - config.GITHUB_OAUTH_APP_CLIENT_SECRET, - ).toString("base64"); + const headers = { Accept: 'application/vnd.github.v3+json' }; + if (config.GITHUB_OAUTH_APP_CLIENT_ID && config.GITHUB_OAUTH_APP_CLIENT_SECRET) { + headers.Authorization = `Basic ${Buffer.from( + `${config.GITHUB_OAUTH_APP_CLIENT_ID}:${config.GITHUB_OAUTH_APP_CLIENT_SECRET}`, + ).toString('base64')}`; } const fileRes = await fetch( `${GITHUB_API}/repos/${owner}/${repo}/commits?sha=${branch}&path=${filePath}`, { - method: "GET", - headers: headers, + method: 'GET', + headers, }, ); // This can be useful to check auth. You should see headers like x-ratelimit-limit, x-ratelimit-remaining - //console.log(fileRes.headers); + // console.log(fileRes.headers); const fileBody = await fileRes.json(); if (fileRes.status !== 200) throw new Error( From 2bc624c7f956cc999a0d738a7d3a8a422a10118e Mon Sep 17 00:00:00 2001 From: arobson-ods Date: Wed, 19 Aug 2026 16:46:39 +0100 Subject: [PATCH 06/17] fix: replace libxmljs2 with libxml2-wasm to catch missed schema errors Addresses issue #574 Now on libxml2-wasm 0.7.1 (libxml2 2.15.1), pinned exactly: a bump can change the bundled libxml2, so it is excluded from Dependabot and upgraded by hand. Behaviour: - Sign-only decimals are now reported; over-long decimals no longer are. - No currently published dataset changes verdict, tested against all 451 datasets the Dashboard reports as failing. - With ?details=true, errors carry only { line, column }. str1, code, level, domain and int1 are no longer available. Worth knowing when reading the diff: - utils/xmlParse.js restores libxmljs2's throw semantics. libxml2-wasm throws whenever libxml2 records any error, even when it still built a usable document, which would turn schema errors into parse errors. - Documents hold memory outside the JS heap and must be dispose()d. Schemas are compiled once by XsdValidator.fromDoc, not per activity. - getFileInformation moves to utils/iatiFile.js, split so that parsing owns document creation, and so the tests can reach it without importing utils.js and therefore Redis. --- .github/dependabot.yml | 9 + .github/workflows/develop-func-ci.yml | 7 + README.md | 17 + ...alidator-api-tests.postman_collection.json | 34 +- package-lock.json | 699 ++---------------- package.json | 4 +- .../test-files/org_undeclared_ns_prefix.xml | 13 + schema-unit-tests/test.js | 69 +- services/rulesValidator.js | 81 +- services/schemaValidator.js | 75 +- services/validationService.js | 42 +- utils-unit-tests/test.js | 168 +++++ utils/iatiFile.js | 69 ++ utils/utils.js | 67 +- utils/xmlParse.js | 34 + 15 files changed, 620 insertions(+), 768 deletions(-) create mode 100644 schema-unit-tests/test-files/org_undeclared_ns_prefix.xml create mode 100644 utils-unit-tests/test.js create mode 100644 utils/iatiFile.js create mode 100644 utils/xmlParse.js diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e110e605..950b60ad 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,6 +6,15 @@ updates: directory: '/' schedule: interval: 'daily' + ignore: + # Pinned exactly, and upgraded by hand. A libxml2-wasm release can carry a different + # bundled libxml2, which is what decides whether a file is schema valid - and since + # v0.7.0 its libxml2 submodule tracks the maintainer's fork rather than an upstream + # release tag, so a bump also takes whatever else is on that branch. PR CI does not + # run the differential harness that verified verdicts against the Dashboard, and + # dependabot PRs auto-merge once CI is green, so a bump would otherwise land with no + # human reading it. See README "XML Library". + - dependency-name: 'libxml2-wasm' # Enable version updates for GitHub Actions - package-ecosystem: 'github-actions' diff --git a/.github/workflows/develop-func-ci.yml b/.github/workflows/develop-func-ci.yml index 81eb77cd..41de0667 100644 --- a/.github/workflows/develop-func-ci.yml +++ b/.github/workflows/develop-func-ci.yml @@ -58,6 +58,13 @@ jobs: npm i -g npm@^10 npm ci + # Runs before the unit tests because it catches a class they cannot: a source file + # importing a package that is not installed. No unit test imports utils.js - it + # pulls in Redis - so a tree where utils.js and package.json disagree about the XML + # library still reports every test passing. + - name: 'Lint' + run: npx eslint . + - name: 'Run unit tests' run: npm run rules:test diff --git a/README.md b/README.md index b3394ec9..95766be2 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,23 @@ Using files: https://github.com/IATI/IATI-Internal-Wiki#development-process +## XML Library + +XML parsing and XSD schema validation use [`libxml2-wasm`](https://github.com/jameslan/libxml2-wasm), a WebAssembly build of libxml2. `libxml2-wasm` builds its libxml2 from a git submodule, and from v0.7.0 that submodule points at the maintainer's own fork rather than upstream — earlier releases such as v0.6.0 pinned a clean upstream release tag. Version 0.7.1 pins commit `f52e859`, which is the **v2.15.1 release plus two unmerged commits** by the `libxml2-wasm` maintainer adding Windows path handling. It contains all of 2.15.1, but sits on a branch off it, so those two commits are not in 2.15.2 or 2.15.3. + +Those patches touch `uri.c` and `xmlIO.c`, which is the code resolving `xsd:include`, but they do not change behaviour here: every branch they add is guarded by a runtime flag that defaults to off, and `libxml2-wasm` explicitly disables it on any platform other than Windows (`node_modules/libxml2-wasm/lib/libxml2.mjs`). Behaviour was also compared against v0.6.0, which pins the clean upstream v2.14.5 tag, across 426 real published datasets with no disagreement in verdict or error count. + +When upgrading `libxml2-wasm`, check whether the submodule has returned to an upstream tag. While it stays on the fork, each bump also takes whatever else is on that branch. + +It replaced the native `libxmljs2`, which is no longer maintained and still bundles libxml2 2.9.9 from 2019 — old enough to accept values that later versions correctly reject, so the Validator disagreed with the Dashboard on whether a file was schema valid. No release of `libxmljs2` carries a newer libxml2, which is why the library was changed rather than upgraded. + +Two things to know when working with it: + +- Documents and compiled schemas hold memory outside the JS heap and must be `dispose()`d. Whatever creates an `XmlDocument` is responsible for freeing it, normally in a `finally`. +- It throws on a parse failure whenever libxml2 recorded any error at all, even when a usable document was still produced. `libxmljs2` threw only when no document could be built, so problems that do not prevent a tree being built — an undeclared namespace prefix, say — were reported later as schema errors. `utils/xmlParse.js` restores that behaviour; without it such files would be rejected with a file level `0.1.1` instead. + +Note the `xmllint --recover` pre-pass is a separate, system-installed libxml2 (see Prerequisities), so it is generally an older version than the one used for validation. + ## Customised Dependencies ### xpath diff --git a/integration-tests/js-validator-api-tests.postman_collection.json b/integration-tests/js-validator-api-tests.postman_collection.json index 4f2b56bd..d1caaeac 100644 --- a/integration-tests/js-validator-api-tests.postman_collection.json +++ b/integration-tests/js-validator-api-tests.postman_collection.json @@ -405,6 +405,10 @@ "pm.test(\"Raw feedback from the xml2js parser should be displayed in the error message\", function () {", " var jsonData = pm.response.json();", " pm.expect(jsonData.errors.map(obj => (obj.message))).to.include(\"Start tag expected, '<' not found\\n\");", + "});", + "pm.test(\"Error context reports the line of the parse failure\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.errors[0].context[0].text).to.eql(\"At line: 1\");", "});" ], "type": "text/javascript" @@ -473,9 +477,19 @@ " var jsonData = pm.response.json();", " pm.expect(jsonData.errors.map(obj => (obj.message))).to.include(\"Start tag expected, '<' not found\\n\");", "});", - "pm.test(\"Error object contains a details object\", function () {", + "pm.test(\"Details object contains the line and column of the parse failure\", function () {", " var jsonData = pm.response.json();", - " pm.expect(jsonData.errors[0]).to.have.property('details');", + " pm.expect(jsonData.errors[0].details).to.eql({ line: 1, column: 1 });", + "});", + "// The message is deliberately absent from details - it is already on the error itself,", + "// asserted above - so guard against it creeping back in.", + "pm.test(\"Details object does not repeat the message\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.errors[0].details).to.not.have.property('message');", + "});", + "pm.test(\"Error context reports the line of the parse failure\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.errors[0].context[0].text).to.eql(\"At line: 1\");", "});" ], "type": "text/javascript" @@ -551,6 +565,10 @@ "pm.test(\"Raw feedback from the xml2js parser should be displayed in the error message\", function () {", " var jsonData = pm.response.json();", " pm.expect(jsonData.errors.map(obj => (obj.message))).to.include(\"Start tag expected, '<' not found\\n\");", + "});", + "pm.test(\"Error context reports the line of the parse failure\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.errors[0].context[0].text).to.eql(\"At line: 1\");", "});" ], "type": "text/javascript" @@ -626,6 +644,10 @@ "pm.test(\"Summary object should have 1 critical error\", function () {", " var jsonData = pm.response.json();", " pm.expect(jsonData.summary.critical).to.eq(1);", + "});", + "pm.test(\"Error context reports the line of the parse failure\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.errors[0].context[0].text).to.eql(\"At line: 4\");", "});" ], "type": "text/javascript" @@ -1471,6 +1493,10 @@ "pm.test(\"Raw feedback from the xml2js parser should be displayed in the error message\", function () {", " var jsonData = pm.response.json();", " pm.expect(jsonData.errors.map(obj => (obj.message))).to.include(\"Opening and ending tag mismatch: reporting-org line 5 and not-reporting-org\\n\");", + "});", + "pm.test(\"Error context reports the line of the parse failure\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.errors[0].context[0].text).to.eql(\"At line: 7\");", "});" ], "type": "text/javascript" @@ -1538,6 +1564,10 @@ "pm.test(\"Raw feedback from the xml2js parser should be displayed in the error message\", function () {", " var jsonData = pm.response.json();", " pm.expect(jsonData.errors.map(obj => (obj.message))).to.include(\"Opening and ending tag mismatch: reporting-org line 13 and not-reporting-org\\n\");", + "});", + "pm.test(\"Error context reports the line of the parse failure\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.errors[0].context[0].text).to.eql(\"At line: 15\");", "});" ], "type": "text/javascript" diff --git a/package-lock.json b/package-lock.json index 45960800..69bfb455 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "applicationinsights": "^2.7.3", "date-fns": "^2.30.0", "dotenv": "^16.3.1", - "libxmljs2": "^0.32.0", + "libxml2-wasm": "0.7.1", "node-fetch": "^3.3.2", "redis": "^4.4.0", "underscore": "^1.13.6", @@ -241,58 +241,6 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, - "node_modules/@mapbox/node-pre-gyp": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", - "integrity": "sha512-4ySo4CjzStuprMwk35H5pPbkymjv1SF3jGLj6rAHp/xT/RF7TL7bd9CTm1xDY49K2qF7jmR/g7k+SkLETP6opA==", - "dependencies": { - "detect-libc": "^2.0.0", - "https-proxy-agent": "^5.0.0", - "make-dir": "^3.1.0", - "node-fetch": "^2.6.7", - "nopt": "^5.0.0", - "npmlog": "^5.0.1", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.11" - }, - "bin": { - "node-pre-gyp": "bin/node-pre-gyp" - } - }, - "node_modules/@mapbox/node-pre-gyp/node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@microsoft/applicationinsights-web-snippet": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-web-snippet/-/applicationinsights-web-snippet-1.0.1.tgz", @@ -527,11 +475,6 @@ "node": ">=10.0.0" } }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" - }, "node_modules/acorn": { "version": "8.10.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", @@ -627,6 +570,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "engines": { "node": ">=8" } @@ -687,23 +631,6 @@ } } }, - "node_modules/aproba": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", - "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" - }, - "node_modules/are-we-there-yet": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", - "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -869,7 +796,8 @@ "node_modules/balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true }, "node_modules/binary-extensions": { "version": "2.2.0", @@ -880,18 +808,11 @@ "node": ">=8" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1058,14 +979,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "engines": { - "node": ">=10" - } - }, "node_modules/cjs-module-lexer": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", @@ -1202,14 +1115,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "bin": { - "color-support": "bin.js" - } - }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -1239,7 +1144,8 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true }, "node_modules/confusing-browser-globals": { "version": "1.0.10", @@ -1247,11 +1153,6 @@ "integrity": "sha512-gNld/3lySHwuhaVluJUKLePYirM3QNCKzVxqAdhJII9/WXKVX5PURzMVJspS1jTslSqjeuG4KMVTSouit5YPHA==", "dev": true }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" - }, "node_modules/continuation-local-storage": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/continuation-local-storage/-/continuation-local-storage-3.2.1.tgz", @@ -1368,19 +1269,6 @@ "node": ">=0.4.0" } }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" - }, - "node_modules/detect-libc": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", - "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==", - "engines": { - "node": ">=8" - } - }, "node_modules/diagnostic-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/diagnostic-channel/-/diagnostic-channel-1.1.1.tgz", @@ -1460,7 +1348,8 @@ "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true }, "node_modules/es-abstract": { "version": "1.22.1", @@ -2033,11 +1922,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" - }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -2111,21 +1995,11 @@ "node": ">=12.20.0" } }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true }, "node_modules/fsevents": { "version": "2.3.2", @@ -2173,25 +2047,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gauge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", - "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.2", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.1", - "object-assign": "^4.1.1", - "signal-exit": "^3.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.2" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/generic-pool": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", @@ -2265,6 +2120,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -2411,11 +2267,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" - }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -2523,6 +2374,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -2531,7 +2383,8 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true }, "node_modules/internal-slot": { "version": "1.0.5", @@ -2646,6 +2499,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "engines": { "node": ">=8" } @@ -2887,18 +2741,13 @@ "node": ">= 0.8.0" } }, - "node_modules/libxmljs2": { - "version": "0.32.0", - "resolved": "https://registry.npmjs.org/libxmljs2/-/libxmljs2-0.32.0.tgz", - "integrity": "sha512-DuvKfSQZeUzw0A4UWZXfcBpr3VqlcJY1b3aw99PxTiX3T5t1rEO4gSpobNrP9S74LIhyDKaAs/lphuErV+n+7w==", - "hasInstallScript": true, - "dependencies": { - "@mapbox/node-pre-gyp": "^1.0.10", - "bindings": "~1.5.0", - "nan": "~2.17.0" - }, + "node_modules/libxml2-wasm": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/libxml2-wasm/-/libxml2-wasm-0.7.1.tgz", + "integrity": "sha512-aZpJJL/j6T3D+5TmhG4D0ylR3mN6UzmqmBjyb/p+zEAaouG6GpfHiUNUzKR3vKCEoJt/Z2L15XPDCVPuFJIQhg==", + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/lilconfig": { @@ -3169,28 +3018,6 @@ "node": ">=10" } }, - "node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -3245,6 +3072,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -3261,40 +3089,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.4.tgz", - "integrity": "sha512-I9WPbWHCGu8W+6k1ZiGpPu0GkoKBeorkfKNuAFBNS1HNFJvke82sxvI5bzcCNpWPorkOO5QQ+zomzzwRxejXiw==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/mocha": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz", @@ -3478,11 +3272,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, - "node_modules/nan": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz", - "integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==" - }, "node_modules/nanoid": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", @@ -3536,20 +3325,6 @@ "url": "https://opencollective.com/node-fetch" } }, - "node_modules/nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -3586,25 +3361,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/npmlog": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", - "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", - "dependencies": { - "are-we-there-yet": "^2.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^3.0.0", - "set-blocking": "^2.0.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-inspect": { "version": "1.12.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", @@ -3705,6 +3461,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, "dependencies": { "wrappy": "1" } @@ -3757,6 +3514,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -3870,19 +3628,6 @@ "safe-buffer": "^5.1.0" } }, - "node_modules/readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -4043,6 +3788,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, "dependencies": { "glob": "^7.1.3" }, @@ -4095,6 +3841,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, "funding": [ { "type": "github", @@ -4146,11 +3893,6 @@ "randombytes": "^2.1.0" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4194,7 +3936,8 @@ "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true }, "node_modules/slice-ansi": { "version": "5.0.0", @@ -4229,14 +3972,6 @@ "resolved": "https://registry.npmjs.org/stack-chain/-/stack-chain-1.3.7.tgz", "integrity": "sha1-0ZLJ/06moiyUxN1FkXHj8AzqEoU=" }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -4250,6 +3985,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -4308,6 +4044,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -4356,30 +4093,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "engines": { - "node": ">=8" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -4398,11 +4111,6 @@ "node": ">=8.0" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, "node_modules/tsconfig-paths": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", @@ -4547,11 +4255,6 @@ "punycode": "^2.1.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -4568,20 +4271,6 @@ "node": ">= 8" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4632,14 +4321,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, "node_modules/workerpool": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", @@ -4675,7 +4356,8 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true }, "node_modules/xml2js": { "version": "0.6.2", @@ -4932,40 +4614,6 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, - "@mapbox/node-pre-gyp": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", - "integrity": "sha512-4ySo4CjzStuprMwk35H5pPbkymjv1SF3jGLj6rAHp/xT/RF7TL7bd9CTm1xDY49K2qF7jmR/g7k+SkLETP6opA==", - "requires": { - "detect-libc": "^2.0.0", - "https-proxy-agent": "^5.0.0", - "make-dir": "^3.1.0", - "node-fetch": "^2.6.7", - "nopt": "^5.0.0", - "npmlog": "^5.0.1", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.11" - }, - "dependencies": { - "node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "requires": { - "whatwg-url": "^5.0.0" - } - }, - "semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "requires": { - "lru-cache": "^6.0.0" - } - } - } - }, "@microsoft/applicationinsights-web-snippet": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-web-snippet/-/applicationinsights-web-snippet-1.0.1.tgz", @@ -5135,11 +4783,6 @@ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==" }, - "abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" - }, "acorn": { "version": "8.10.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", @@ -5204,7 +4847,8 @@ "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true }, "ansi-styles": { "version": "6.2.1", @@ -5242,20 +4886,6 @@ "diagnostic-channel-publishers": "1.0.7" } }, - "aproba": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", - "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" - }, - "are-we-there-yet": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", - "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", - "requires": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - } - }, "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -5373,7 +5003,8 @@ "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true }, "binary-extensions": { "version": "2.2.0", @@ -5381,18 +5012,11 @@ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "dev": true }, - "bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "requires": { - "file-uri-to-path": "1.0.0" - } - }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5517,11 +5141,6 @@ "readdirp": "~3.6.0" } }, - "chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" - }, "cjs-module-lexer": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", @@ -5621,11 +5240,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==" - }, "colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -5649,7 +5263,8 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true }, "confusing-browser-globals": { "version": "1.0.10", @@ -5657,11 +5272,6 @@ "integrity": "sha512-gNld/3lySHwuhaVluJUKLePYirM3QNCKzVxqAdhJII9/WXKVX5PURzMVJspS1jTslSqjeuG4KMVTSouit5YPHA==", "dev": true }, - "console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" - }, "continuation-local-storage": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/continuation-local-storage/-/continuation-local-storage-3.2.1.tgz", @@ -5739,16 +5349,6 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" }, - "delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" - }, - "detect-libc": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", - "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==" - }, "diagnostic-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/diagnostic-channel/-/diagnostic-channel-1.1.1.tgz", @@ -5810,7 +5410,8 @@ "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true }, "es-abstract": { "version": "1.22.1", @@ -6246,11 +5847,6 @@ "flat-cache": "^3.0.4" } }, - "file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" - }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -6309,18 +5905,11 @@ "fetch-blob": "^3.1.2" } }, - "fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "requires": { - "minipass": "^3.0.0" - } - }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true }, "fsevents": { "version": "2.3.2", @@ -6352,22 +5941,6 @@ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true }, - "gauge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", - "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", - "requires": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.2", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.1", - "object-assign": "^4.1.1", - "signal-exit": "^3.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.2" - } - }, "generic-pool": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", @@ -6417,6 +5990,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -6512,11 +6086,6 @@ "has-symbols": "^1.0.2" } }, - "has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" - }, "he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -6591,6 +6160,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, "requires": { "once": "^1.3.0", "wrappy": "1" @@ -6599,7 +6169,8 @@ "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true }, "internal-slot": { "version": "1.0.5", @@ -6680,7 +6251,8 @@ "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true }, "is-glob": { "version": "4.0.3", @@ -6847,15 +6419,10 @@ "type-check": "~0.4.0" } }, - "libxmljs2": { - "version": "0.32.0", - "resolved": "https://registry.npmjs.org/libxmljs2/-/libxmljs2-0.32.0.tgz", - "integrity": "sha512-DuvKfSQZeUzw0A4UWZXfcBpr3VqlcJY1b3aw99PxTiX3T5t1rEO4gSpobNrP9S74LIhyDKaAs/lphuErV+n+7w==", - "requires": { - "@mapbox/node-pre-gyp": "^1.0.10", - "bindings": "~1.5.0", - "nan": "~2.17.0" - } + "libxml2-wasm": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/libxml2-wasm/-/libxml2-wasm-0.7.1.tgz", + "integrity": "sha512-aZpJJL/j6T3D+5TmhG4D0ylR3mN6UzmqmBjyb/p+zEAaouG6GpfHiUNUzKR3vKCEoJt/Z2L15XPDCVPuFJIQhg==" }, "lilconfig": { "version": "2.1.0", @@ -7039,21 +6606,6 @@ "yallist": "^4.0.0" } }, - "make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "requires": { - "semver": "^6.0.0" - }, - "dependencies": { - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==" - } - } - }, "merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -7093,6 +6645,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "requires": { "brace-expansion": "^1.1.7" } @@ -7103,28 +6656,6 @@ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true }, - "minipass": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.4.tgz", - "integrity": "sha512-I9WPbWHCGu8W+6k1ZiGpPu0GkoKBeorkfKNuAFBNS1HNFJvke82sxvI5bzcCNpWPorkOO5QQ+zomzzwRxejXiw==", - "requires": { - "yallist": "^4.0.0" - } - }, - "minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "requires": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - } - }, - "mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" - }, "mocha": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz", @@ -7254,11 +6785,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, - "nan": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz", - "integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==" - }, "nanoid": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", @@ -7286,14 +6812,6 @@ "formdata-polyfill": "^4.0.10" } }, - "nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "requires": { - "abbrev": "1" - } - }, "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -7317,22 +6835,6 @@ } } }, - "npmlog": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", - "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", - "requires": { - "are-we-there-yet": "^2.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^3.0.0", - "set-blocking": "^2.0.0" - } - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" - }, "object-inspect": { "version": "1.12.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", @@ -7406,6 +6908,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, "requires": { "wrappy": "1" } @@ -7445,7 +6948,8 @@ "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true }, "path-key": { "version": "3.1.1", @@ -7509,16 +7013,6 @@ "safe-buffer": "^5.1.0" } }, - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, "readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -7638,6 +7132,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, "requires": { "glob": "^7.1.3" } @@ -7666,7 +7161,8 @@ "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true }, "safe-regex-test": { "version": "1.0.0", @@ -7698,11 +7194,6 @@ "randombytes": "^2.1.0" } }, - "set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" - }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7737,7 +7228,8 @@ "signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true }, "slice-ansi": { "version": "5.0.0", @@ -7762,14 +7254,6 @@ "resolved": "https://registry.npmjs.org/stack-chain/-/stack-chain-1.3.7.tgz", "integrity": "sha1-0ZLJ/06moiyUxN1FkXHj8AzqEoU=" }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "requires": { - "safe-buffer": "~5.2.0" - } - }, "string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -7780,6 +7264,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "requires": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -7823,6 +7308,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "requires": { "ansi-regex": "^5.0.1" } @@ -7850,26 +7336,6 @@ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" }, - "tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "requires": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "dependencies": { - "minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==" - } - } - }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -7885,11 +7351,6 @@ "is-number": "^7.0.0" } }, - "tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, "tsconfig-paths": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", @@ -8001,11 +7462,6 @@ "punycode": "^2.1.0" } }, - "util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, "uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -8016,20 +7472,6 @@ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==" }, - "webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "requires": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8065,14 +7507,6 @@ "has-tostringtag": "^1.0.0" } }, - "wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "requires": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, "workerpool": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", @@ -8104,7 +7538,8 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true }, "xml2js": { "version": "0.6.2", diff --git a/package.json b/package.json index e55f03ec..4da0e124 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "start": "func start --javascript", "start:verbose": "func start --javascript --verbose", - "rules:test": "mocha './ruleset-unit-tests/**/test.js' './schema-unit-tests/**/test.js'", + "rules:test": "mocha './ruleset-unit-tests/**/test.js' './schema-unit-tests/**/test.js' './utils-unit-tests/**/test.js'", "int:test": "newman run integration-tests/js-validator-api-tests.postman_collection.json -e integration-tests/envs/local-function-7071.postman_environment.json --working-dir integration-tests/test-files", "docker:start": "docker compose up --build", "docker:int:test": "newman run integration-tests/js-validator-api-tests.postman_collection.json -e integration-tests/envs/local-docker-function.postman_environment.json --working-dir integration-tests/test-files", @@ -20,7 +20,7 @@ "applicationinsights": "^2.7.3", "date-fns": "^2.30.0", "dotenv": "^16.3.1", - "libxmljs2": "^0.32.0", + "libxml2-wasm": "0.7.1", "node-fetch": "^3.3.2", "redis": "^4.4.0", "underscore": "^1.13.6", diff --git a/schema-unit-tests/test-files/org_undeclared_ns_prefix.xml b/schema-unit-tests/test-files/org_undeclared_ns_prefix.xml new file mode 100644 index 00000000..4c486e38 --- /dev/null +++ b/schema-unit-tests/test-files/org_undeclared_ns_prefix.xml @@ -0,0 +1,13 @@ + + + + AA-AAA-123456789 + + Organisation name + Undeclared prefix - not fatal to building the tree + + + Organisation name + + + diff --git a/schema-unit-tests/test.js b/schema-unit-tests/test.js index 0cda1890..b116815c 100644 --- a/schema-unit-tests/test.js +++ b/schema-unit-tests/test.js @@ -2,7 +2,8 @@ import fs from 'fs/promises'; import { fileURLToPath } from 'url'; // eslint-disable-next-line import/no-extraneous-dependencies import chai from 'chai'; -import libxml from 'libxmljs2'; +import { XmlDocument, XsdValidator, XmlValidateError } from 'libxml2-wasm'; +import { xmlRegisterFsInputProviders } from 'libxml2-wasm/lib/nodejs.mjs'; import { validateIATI } from '../services/rulesValidator.js'; @@ -28,10 +29,6 @@ const { expect } = chai; * * Run by the same `npm run rules:test` command, so no CI change is needed. * - * Five of these cases FAIL until libxmljs2 is replaced (js-validator-api#574). That is - * intentional: they assert the corrected behaviour, so they go green with the fix. - * The two CONTROL cases pass either side. - * * Only 2.03 fixtures are validated, though config.VERSIONS is 2.01, 2.02 and 2.03. The * defect is in libxml2's handling of xs:decimal, an XSD built-in type, so it behaves the * same whichever IATI schema references it - checked against all three. What does differ @@ -58,9 +55,6 @@ const testMap = [ message: DECIMAL_ERROR, }, { - // The same defect in an activity file, so both 2.03 schemas are exercised, - // not just the organisation one. See the Schema compilation block for the - // other four. file: 'act_value_sign_only.xml', fileType: 'iati-activities', what: 'a sign-only decimal is rejected in an activity file too', @@ -87,6 +81,18 @@ const testMap = [ lines: [88, 124, 148, 156, 244], message: DECIMAL_ERROR, }, + { + // The libraries disagree on when a parse failure is fatal: libxmljs2 threw only + // if no document could be built, libxml2-wasm throws whenever the parser + // recorded anything at all. An undeclared namespace prefix is not fatal to + // building a tree, so it must stay a schema error here rather than becoming a + // file-level parse error. See utils/xmlParse.js, which restores the old rule. + file: 'org_undeclared_ns_prefix.xml', + fileType: 'iati-organisations', + what: 'an undeclared namespace prefix is a schema error, not a parse failure', + occurrences: 1, + message: 'This element is not expected', + }, { // Positive control. Guards against the suite going green by not validating at // all - validateIATI silently skips schema work when `schema` is undefined. @@ -105,37 +111,44 @@ const testMap = [ }, ]; -/* ------------------------------------------------------------------------- - * XML-LIBRARY-COUPLED SECTION - the only two functions that touch libxmljs2. - * Replacing the library (js-validator-api#574) means rewriting these two and - * nothing else in this file: the fixtures, the expectations and the assertions - * are all library-agnostic, which is what makes the red-to-green transition - * meaningful rather than an artefact of rewriting the tests. - * ---------------------------------------------------------------------- */ +// Lets xsd:include / xsd:import resolve from disk. utils.js registers these too, under a +// comment saying it must happen once per process - which holds because this suite +// deliberately does not import utils.js, that being what keeps it free of Redis. If the +// two are ever loaded together, one of these calls has to go. +xmlRegisterFsInputProviders(); /* - * Reads an XSD and returns whatever object the validator expects. Note this does - * NOT compile it: libxmljs2 defers that to validate(). See assertSchemaUsable. + * Reads an XSD and returns a compiled XsdValidator, mirroring what utils.js getSchema + * now caches. Unlike libxmljs2, compilation happens here rather than being deferred to + * validate(), so an unresolvable xsd:include throws at this point. */ const loadSchema = async (fileType, version) => { - const xsd = new URL(`../schemas/${version}/${fileType}-schema.xsd`, import.meta.url); - const baseUrl = fileURLToPath(new URL(`../schemas/${version}/`, import.meta.url)); - return libxml.parseXml((await fs.readFile(xsd)).toString(), { baseUrl }); + const xsdPath = fileURLToPath( + new URL(`../schemas/${version}/${fileType}-schema.xsd`, import.meta.url), + ); + const xsdDoc = XmlDocument.fromBuffer(await fs.readFile(xsdPath), { url: xsdPath }); + try { + return XsdValidator.fromDoc(xsdDoc); + } finally { + xsdDoc.dispose(); + } }; /* - * Forces libxml2 to actually compile the schema, which loadSchema alone does not: - * parseXml happily returns a document even when xsd:include cannot be resolved, and - * the failure surfaces only later as "Invalid XSD schema" thrown from validate(). - * Without this the compilation tests below would pass with broken includes. + * Retained from the libxmljs2 version, where loadSchema could return a document whose + * includes had silently failed to resolve. libxml2-wasm throws during compilation + * instead, so this is now belt-and-braces - it still fails if a schema builds but + * cannot validate. */ const assertSchemaUsable = (schema, fileType, version) => { - const probe = libxml.parseXml(`<${fileType} version="${version}" />`); - expect(() => probe.validate(schema)).to.not.throw(); + const probe = XmlDocument.fromString(`<${fileType} version="${version}" />`); + try { + expect(() => schema.validate(probe)).to.throw(XmlValidateError); + } finally { + probe.dispose(); + } }; -/* ------------------------- end library-coupled section ------------------- */ - // Rule evaluation is not under test here, so an empty ruleset and empty id sets. const emptyRuleset = {}; const idSets = { 'ORG-ID': new Set(), 'ORG-ID-PREFIX': new Set() }; diff --git a/services/rulesValidator.js b/services/rulesValidator.js index 7b0826ca..ace18c79 100644 --- a/services/rulesValidator.js +++ b/services/rulesValidator.js @@ -2,11 +2,12 @@ import { DOMParser } from '@xmldom/xmldom'; import xpath from 'xpath'; import _ from 'underscore'; import { compareAsc, differenceInDays } from 'date-fns'; -import libxml from 'libxmljs2'; +import { XmlValidateError } from 'libxml2-wasm'; import { Readable, Transform } from 'stream'; import { pipeline } from 'stream/promises'; import ruleNameObj from './ruleNameMap.js'; +import { parseXmlString } from '../utils/xmlParse.js'; const select = xpath.useNamespaces({ xml: 'http://www.w3.org/XML/1998/namespace' }); @@ -698,65 +699,85 @@ const standardiseResultFormat = (result, showDetails, xml, lineCount) => { }; }; +const toReportedDetail = ({ col }, line) => ({ line, column: col }); + +/* + * Turns validation into a value. libxml2-wasm throws XmlValidateError where libxmljs2 + * returned false, and the compiled XsdValidator is the receiver rather than the document. + * Confined to here so the grouping below is not nested inside a catch. Anything that is + * not a validation failure is rethrown - a fault in the validator itself must not be + * reported as a schema error in the file. + */ +const collectSchemaErrors = (schema, xmlDoc) => { + try { + schema.validate(xmlDoc); + return []; + } catch (error) { + if (!(error instanceof XmlValidateError)) throw error; + return error.details; + } +}; + const validateSchema = (xmlString, schema, identifier, title, showDetails, lineOffset = 0) => { - let xmlDoc = null; + let xmlDoc; try { - xmlDoc = libxml.parseXml(xmlString); + xmlDoc = parseXmlString(xmlString); } catch (error) { + const line = error.details?.[0]?.line; return [ { id: '0.3.1', category: 'schema', severity: 'critical', message: error.message, - context: [ - { text: `At line ${lineOffset + (error.line === undefined ? 0 : error.line)}` }, - ], + context: [{ text: `At line ${lineOffset + (line === undefined ? 0 : line)}` }], }, ]; } - if (xmlDoc != null && !xmlDoc.validate(schema)) { - const curSchemaErrors = xmlDoc.validationErrors.reduce((acc, error) => { - let errContext; - const errorDetail = error; - if ('line' in errorDetail) { - const lineMax = errorDetail.line >= 65535; - if (lineOffset > 1) { - errorDetail.line += lineOffset; - } - errContext = `At line${lineMax ? ' greater than' : ''}: ${errorDetail.line}${ - lineMax - ? `. Note: The validator cannot display accurate line numbers for schema errors located at a line greater than ${errorDetail.line} for this activity.` - : '' - }`; - } - if (!_.has(acc, error.message)) { - acc[error.message] = { + try { + const curSchemaErrors = collectSchemaErrors(schema, xmlDoc).reduce((acc, detail) => { + // Note the threshold is tested against the raw line but the offset one is + // reported, which is how this has always behaved. + const lineMax = detail.line >= 65535; + const line = lineOffset > 1 ? detail.line + lineOffset : detail.line; + const errContext = `At line${lineMax ? ' greater than' : ''}: ${line}${ + lineMax + ? `. Note: The validator cannot display accurate line numbers for schema errors located at a line greater than ${line} for this activity.` + : '' + }`; + if (!_.has(acc, detail.message)) { + acc[detail.message] = { id: '0.3.1', category: 'schema', severity: 'critical', - message: error.message, + message: detail.message, context: [{ text: errContext }], - ...(showDetails && { details: [{ error: errorDetail }] }), + ...(showDetails && { details: [{ error: toReportedDetail(detail, line) }] }), identifier, title, }; } else { - acc[error.message] = { - ...acc[error.message], - context: [...acc[error.message].context, { text: errContext }], + acc[detail.message] = { + ...acc[detail.message], + context: [...acc[detail.message].context, { text: errContext }], ...(showDetails && { - details: [...acc[error.message].details, { error: errorDetail }], + details: [ + ...acc[detail.message].details, + { error: toReportedDetail(detail, line) }, + ], }), }; } return acc; }, {}); return Object.keys(curSchemaErrors).map((errGroup) => curSchemaErrors[errGroup]); + } finally { + // Off-heap wasm memory, and this runs once per activity - a missing dispose + // grows the heap for the whole length of a large file. + xmlDoc.dispose(); } - return []; }; const fileDefinition = { diff --git a/services/schemaValidator.js b/services/schemaValidator.js index c632cce8..c7270fe2 100644 --- a/services/schemaValidator.js +++ b/services/schemaValidator.js @@ -1,4 +1,6 @@ -import { validateXMLrecover, getFileInformation, getSchema } from '../utils/utils.js'; +import { XmlValidateError } from 'libxml2-wasm'; +import { validateXMLrecover, getSchema } from '../utils/utils.js'; +import { parseFileBody, getFileInformation } from '../utils/iatiFile.js'; // pvt-schema-validate-file-post // Schema Check on full file, happens before full validation to enable the safety valve functionality in unified platform @@ -48,26 +50,21 @@ const schemaValidateFile = async (context, req) => { } let fileType = ''; let version = ''; - let xmlDoc = ''; let isIati = ''; let supportedVersion = ''; + // Undefined rather than '', so the xmlDoc?.dispose() below is a genuine no-op when + // no document was produced. `''?.dispose()` would be a TypeError. + let xmlDoc; // Parse file and get metadata for further checks try { - ({ fileType, version, supportedVersion, xmlDoc, isIati } = getFileInformation(body)); + xmlDoc = parseFileBody(body); + ({ fileType, version, supportedVersion, isIati } = getFileInformation(xmlDoc)); } catch (error) { - context.res = { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { - valid: false, - }, - }; - return; - } + // The parse may have succeeded and the inspection failed, so this path can own a + // document. Unparseable input is simply not valid here, hence 200 either way. + xmlDoc?.dispose(); - // IATI Check - if (!isIati) { context.res = { status: 200, headers: { 'Content-Type': 'application/json' }, @@ -78,19 +75,47 @@ const schemaValidateFile = async (context, req) => { return; } - // Version Check - if (!supportedVersion) { - context.res = { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { - valid: false, - }, - }; - return; + // This scope owns xmlDoc from here: off-heap memory, so the finally below covers the + // two early returns as well as the schema check. See validationService for why that + // is deterministic rather than left to the library's FinalizationRegistry backstop. + let schemaValid; + try { + // IATI Check + if (!isIati) { + context.res = { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { + valid: false, + }, + }; + return; + } + + // Version Check + if (!supportedVersion) { + context.res = { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { + valid: false, + }, + }; + return; + } + + try { + getSchema(fileType, version).validate(xmlDoc); + schemaValid = true; + } catch (error) { + if (!(error instanceof XmlValidateError)) throw error; + schemaValid = false; + } + } finally { + xmlDoc?.dispose(); } - if (!xmlDoc.validate(getSchema(fileType, version))) { + if (!schemaValid) { context.res = { headers: { 'Content-Type': 'application/json' }, body: { valid: false }, diff --git a/services/validationService.js b/services/validationService.js index 652e8844..32d5228b 100644 --- a/services/validationService.js +++ b/services/validationService.js @@ -1,6 +1,6 @@ import _ from 'underscore'; +import { XmlValidateError } from 'libxml2-wasm'; import { - getFileInformation, getRuleset, getSchema, getIdSets, @@ -10,6 +10,7 @@ import { validateXMLrecover, getObjectWithPropertiesAsEnumerable, } from '../utils/utils.js'; +import { parseFileBody, getFileInformation } from '../utils/iatiFile.js'; import { client, getStartTime, getElapsedTime } from '../config/appInsights.js'; import validateCodelists from './codelistValidator.js'; import { validateIATI } from './rulesValidator.js'; @@ -189,21 +190,34 @@ export default async function validate(context, req) { }; } + /* + * This scope owns xmlDoc: it holds memory outside the JS heap, so every path leaving + * between here and the schema check below disposes it first. Three do - the two early + * exits and the schema check's own `finally`. + * + * It is freed at the schema check rather than at the end of the function on purpose: + * codelist, ruleset and advisory validation all re-read the body as a string and + * never touch this document, so holding it that long would keep tens of MiB alive + * for most of the request. That is why one try/finally cannot simply wrap the whole + * function. + */ let xmlDoc; try { + xmlDoc = parseFileBody(body); ({ fileType: state.fileType, version: state.iatiVersion, generatedDateTime: state.generatedDateTime, supportedVersion: state.supportedVersion, isIati: state.isIati, - xmlDoc, - } = getFileInformation(body)); + } = getFileInformation(xmlDoc)); } catch (error) { + xmlDoc?.dispose(); + let errContext; - const { str1, str2, str3, line, level, int1, domain, column } = error; + const [{ line, col } = {}] = error.details ?? []; if (line) { - errContext = `At line: ${error.line}`; + errContext = `At line: ${line}`; } const errors = [ @@ -214,7 +228,7 @@ export default async function validate(context, req) { message: error.message, context: [{ text: errContext }], ...(showDetails && { - details: { str1, str2, str3, line, level, int1, domain, column }, + details: { line, column: col }, }), identifier: 'file', title: 'File level errors', @@ -241,6 +255,9 @@ export default async function validate(context, req) { // Check the XML file root element is or // (it doesn't do anything more than that) if (!state.isIati) { + // Per the contract on the declaration above: dispose before returning. + xmlDoc?.dispose(); + const errors = [ { id: '0.2.1', @@ -269,6 +286,8 @@ export default async function validate(context, req) { // Check that the version is latest IATI version if (!state.supportedVersion) { + xmlDoc?.dispose(); + const errors = [ { id: '0.6.1', @@ -301,8 +320,15 @@ export default async function validate(context, req) { // Check the file validates against the IATI schema const fileSchemaStart = getStartTime(); - state.schemaErrorsPresent = !xmlDoc.validate(getSchema(state.fileType, state.iatiVersion)); - xmlDoc = null; + try { + getSchema(state.fileType, state.iatiVersion).validate(xmlDoc); + state.schemaErrorsPresent = false; + } catch (error) { + if (!(error instanceof XmlValidateError)) throw error; + state.schemaErrorsPresent = true; + } finally { + xmlDoc?.dispose(); + } state.fileSchemaTime = getElapsedTime(fileSchemaStart); // Codelist Validation diff --git a/utils-unit-tests/test.js b/utils-unit-tests/test.js new file mode 100644 index 00000000..4831c287 --- /dev/null +++ b/utils-unit-tests/test.js @@ -0,0 +1,168 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import chai from 'chai'; +import { XmlParseError } from 'libxml2-wasm'; + +import { parseFileBody, getFileInformation } from '../utils/iatiFile.js'; + +const { expect } = chai; + +/* + * Tests for the first step of the validation pipeline: parse the body, then read what the + * pipeline needs off the resulting document. + * + * Fixtures are inline rather than in test-files/, unlike the sibling suites: every case + * here turns on one attribute of the root element, and a one-line string shows that more + * clearly than a file does. + * + */ + +const testMap = [ + { + what: 'the blank document xmllint --recover leaves behind yields no document', + body: '\n', + expected: { + fileType: '', + version: '', + generatedDateTime: '', + supportedVersion: undefined, + isIati: undefined, + }, + expectDocument: false, + }, + { + what: 'an iati-activities file reports its type, version and generated-datetime', + body: '', + expected: { + fileType: 'iati-activities', + version: '2.03', + generatedDateTime: '2026-01-01T00:00:00Z', + supportedVersion: true, + isIati: true, + }, + }, + { + what: 'an iati-organisations file is recognised too', + body: '', + expected: { + fileType: 'iati-organisations', + version: '2.03', + generatedDateTime: '', + supportedVersion: true, + isIati: true, + }, + }, + { + what: 'an absent version attribute is reported as empty, not thrown on', + // The guard behind this is `!= null`, not `!== undefined`: libxmljs2 returned + // undefined for a missing attribute where libxml2-wasm returns null, so the + // stricter check would fall through to .value and throw - surfacing as a bogus + // file-level 0.1.1 "not valid XML" instead of an unsupported-version report. + body: '', + expected: { + fileType: 'iati-activities', + version: '', + generatedDateTime: '', + supportedVersion: '', + isIati: true, + }, + }, + { + what: 'an absent generated-datetime is reported as empty', + body: '', + expected: { + fileType: 'iati-activities', + version: '2.03', + generatedDateTime: '', + supportedVersion: true, + isIati: true, + }, + }, + { + what: 'a version outside config.VERSIONS is flagged unsupported, driving error 0.6.1', + body: '', + expected: { + fileType: 'iati-activities', + version: '1.03', + generatedDateTime: '', + supportedVersion: false, + isIati: true, + }, + }, + { + what: 'a non-IATI root element is not IATI, and reports no file type', + body: '', + expected: { + fileType: '', + version: '2.03', + generatedDateTime: '', + supportedVersion: true, + isIati: false, + }, + }, + { + what: 'a version attribute on a nested element is picked up the same way', + body: '', + expected: { + fileType: '', + version: '2.03', + generatedDateTime: '', + supportedVersion: true, + isIati: false, + }, + }, +]; + +describe('parseFileBody / getFileInformation', () => { + testMap.forEach((test) => { + it(test.what, () => { + let xmlDoc; + try { + xmlDoc = parseFileBody(test.body); + if (test.expectDocument === false) { + expect(xmlDoc).to.equal(undefined); + } else { + expect(xmlDoc).to.not.equal(undefined); + } + expect(getFileInformation(xmlDoc)).to.deep.equal(test.expected); + } finally { + xmlDoc?.dispose(); + } + }); + }); + + it('throws on an unparseable body, carrying the position error 0.1.1 reports', () => { + let thrown; + try { + parseFileBody(''); + } catch (error) { + thrown = error; + } + + expect(thrown).to.be.instanceOf(XmlParseError); + expect(thrown.details).to.be.an('array').with.length.greaterThan(0); + expect(thrown.details[0].line).to.be.a('number'); + expect(thrown.details[0].col).to.be.a('number'); + expect(thrown.message).to.be.a('string').and.not.equal(''); + }); + + it('tolerates an undeclared namespace prefix rather than failing to parse', () => { + // libxml2-wasm throws whenever libxml2 recorded any error, even when a usable + // document was produced; libxmljs2 threw only when no document could be built. + // utils/xmlParse.js restores the older rule, and without it this file would be + // rejected as a file-level 0.1.1 instead of reaching schema validation. The + // schema-error side of this is covered by schema-unit-tests. + let xmlDoc; + try { + xmlDoc = parseFileBody( + '', + ); + expect(xmlDoc).to.not.equal(undefined); + const info = getFileInformation(xmlDoc); + expect(info.isIati).to.equal(true); + expect(info.fileType).to.equal('iati-activities'); + expect(info.version).to.equal('2.03'); + } finally { + xmlDoc?.dispose(); + } + }); +}); diff --git a/utils/iatiFile.js b/utils/iatiFile.js new file mode 100644 index 00000000..5a528e9a --- /dev/null +++ b/utils/iatiFile.js @@ -0,0 +1,69 @@ +import { ParseOption } from 'libxml2-wasm'; + +import config from '../config/config.js'; +import { parseXmlBuffer } from './xmlParse.js'; + +/* + * These two are a standalone module rather than part of utils.js so that they can be unit + * tested: importing utils.js pulls in config/redis.js, which connects at module load, and + * starts the fire-and-forget loader that fetches from GitHub. Same reason xmlParse.js is + * separate. + */ + +/* + * Parses the request body into a document for the caller to inspect and then dispose. The + * caller owns it: documents hold memory outside the JS heap, where libxmljs2's were on it. + * The library does register a FinalizationRegistry backstop, but its own docs say not to + * rely on it - so the caller is responsible for freeing deterministically. + * + * Throws for a body that genuinely cannot be parsed, which callers report as 0.1.1. The + * position they need is on `error.details[0]`, not on the error itself as libxmljs2 had it. + */ +const parseFileBody = (body) => + body.toString() === `\n` + ? undefined + : parseXmlBuffer(Buffer.from(body), ParseOption.XML_PARSE_HUGE); + +/* + * Reports what the validation pipeline needs in order to decide how to proceed: which IATI + * file type this is, which version it declares, and whether that version is one we support. + * + * Reads the document and owns nothing - disposal stays with whoever called parseFileBody. + * Accepts undefined so the blank-document case needs no special handling at the call site. + */ +const getFileInformation = (xmlDoc) => { + let fileType = ''; + let version = ''; + let generatedDateTime = ''; + let supportedVersion; + let isIati; + if (xmlDoc !== undefined) { + const root = xmlDoc.root.name; + + isIati = root === 'iati-activities' || root === 'iati-organisations'; + // set fileType to '' for non IATI files + fileType = isIati ? root : ''; + + // `!= null`, not `!== undefined`: libxmljs2 returned undefined for an absent + // attribute where libxml2-wasm returns null, so the stricter check would fall + // through to .value and throw. + const versionAttr = xmlDoc.get(`/${fileType}/@version`); + if (versionAttr != null) { + version = versionAttr.value; + } + const generatedAttr = xmlDoc.get(`/${fileType}/@generated-datetime`); + if (generatedAttr != null) { + generatedDateTime = generatedAttr.value; + } + supportedVersion = version && config.VERSIONS.includes(version); + } + return { + fileType, + version, + generatedDateTime, + supportedVersion, + isIati, + }; +}; + +export { parseFileBody, getFileInformation }; diff --git a/utils/utils.js b/utils/utils.js index cf107901..4c7704d8 100644 --- a/utils/utils.js +++ b/utils/utils.js @@ -1,4 +1,5 @@ -import libxml from 'libxmljs2'; +import { XmlDocument, XsdValidator } from 'libxml2-wasm'; +import { xmlRegisterFsInputProviders } from 'libxml2-wasm/lib/nodejs.mjs'; import fs from 'fs/promises'; import fetch from 'node-fetch'; import { spawn } from 'child_process'; @@ -6,6 +7,11 @@ import { spawn } from 'child_process'; import redisclient from '../config/redis.js'; import config from '../config/config.js'; +// Lets libxml2 resolve xsd:include / xsd:import from disk when compiling the schemas +// below. Must be called exactly once per process: repeated register/cleanup leaks +// entries from the wasm function table (libxml2-wasm issue #166). +xmlRegisterFsInputProviders(); + const GITHUB_API = 'https://api.github.com'; const getFileBySha = async (owner, repo, sha, filePath) => { @@ -62,41 +68,6 @@ const getFileCommitSha = async (owner, repo, branch, filePath) => { }; // parse xml body to JSON to check the root element, don't attempt to parse if output from xmllint --recover was just blank XML doc -const getFileInformation = (body) => { - let fileType = ''; - let version = ''; - let generatedDateTime = ''; - let supportedVersion; - let isIati; - let xmlDoc; - if (body.toString() !== `\n`) { - xmlDoc = libxml.parseXml(body, { huge: true }); - if (xmlDoc) { - const root = xmlDoc.root().name(); - - isIati = root === 'iati-activities' || root === 'iati-organisations'; - // set fileType to '' for non IATI files - fileType = isIati ? root : ''; - - if (xmlDoc.get(`/${fileType}/@version`) !== undefined) { - version = xmlDoc.get(`/${fileType}/@version`).value(); - } - if (xmlDoc.get(`/${fileType}/@generated-datetime`) !== undefined) { - generatedDateTime = xmlDoc.get(`/${fileType}/@generated-datetime`).value(); - } - supportedVersion = version && config.VERSIONS.includes(version); - } - } - return { - fileType, - version, - generatedDateTime, - supportedVersion, - isIati, - xmlDoc, - }; -}; - const codelistRules = {}; const ruleset = {}; const schemas = {}; @@ -179,10 +150,20 @@ config.VERSIONS.forEach(async (version) => { // load schemas ['iati-activities', 'iati-organisations'].forEach(async (fileType) => { - schemas[`${fileType}-${version}`] = libxml.parseXml( - (await fs.readFile(`schemas/${version}/${fileType}-schema.xsd`)).toString(), - { baseUrl: `./schemas/${version}/` }, - ); + try { + const path = `schemas/${version}/${fileType}-schema.xsd`; + // Parsed strictly rather than through xmlParse.js - a malformed XSD must fail + // here rather than be tolerated. + const xsdDoc = XmlDocument.fromBuffer(await fs.readFile(path), { url: path }); + try { + schemas[`${fileType}-${version}`] = XsdValidator.fromDoc(xsdDoc); + } finally { + // The validator retains what it needs; the source document does not. + xsdDoc.dispose(); + } + } catch (error) { + console.error(`Error loading ${fileType} schema for version ${version}: ${error}`); + } }); // load advisories @@ -240,6 +221,11 @@ const getRulesetCommitSha = (version) => { return ''; }; +/* + * Returns a compiled XsdValidator, not a parsed XSD document as it did under libxmljs2. + * Callers validate with `getSchema(...).validate(doc)`, which THROWS XmlValidateError on + * a schema-invalid document rather than returning false. + */ const getSchema = (fileType, version) => { if ( config.VERSIONS.includes(version) && @@ -465,7 +451,6 @@ export { getIdSets, getOrgIds, getOrgIdPrefixes, - getFileInformation, getVersionCodelistRules, getVersionCodelistCommitSha, getRuleset, diff --git a/utils/xmlParse.js b/utils/xmlParse.js new file mode 100644 index 00000000..b67e8198 --- /dev/null +++ b/utils/xmlParse.js @@ -0,0 +1,34 @@ +/* eslint-disable no-bitwise */ +import { XmlDocument, ParseOption } from 'libxml2-wasm'; + +/* + * libxml2-wasm throws whenever the parser recorded any error, even when it still built + * a usable document. libxmljs2 threw only when no document resulted, so problems that + * are not fatal to building a tree - an undeclared namespace prefix, for instance - + * used to parse through and be reported later as schema errors instead. + * + * Suppressing error collection restores that: with nothing collected, the throw depends + * on whether a document was produced, which is the libxmljs2 rule. Verified to agree + * with libxmljs2 on every integration fixture. + * + * The cost is that the resulting XmlParseError carries no message, line or column, and + * the 0.1.1 and 0.3.1 reports need those. So a failed parse is repeated with collection + * enabled purely to obtain the diagnostic. That second parse only happens for input that + * is genuinely unparseable, which the request is about to be rejected for anyway. + * + */ +const QUIET = ParseOption.XML_PARSE_NOERROR | ParseOption.XML_PARSE_NOWARNING; + +const parseTolerantly = (parse, source, option) => { + try { + return parse(source, { option: option | QUIET }); + } catch { + return parse(source, { option }); + } +}; + +export const parseXmlBuffer = (buffer, option = ParseOption.XML_PARSE_DEFAULT) => + parseTolerantly((source, options) => XmlDocument.fromBuffer(source, options), buffer, option); + +export const parseXmlString = (string, option = ParseOption.XML_PARSE_DEFAULT) => + parseTolerantly((source, options) => XmlDocument.fromString(source, options), string, option); From 28bfa14f69f171fd7bc998dd9a192bf2935eca12 Mon Sep 17 00:00:00 2001 From: arobson-ods Date: Mon, 24 Aug 2026 10:14:52 +0100 Subject: [PATCH 07/17] chore: rename rules:test to unit:test --- .github/workflows/develop-func-ci.yml | 2 +- README.md | 2 +- package.json | 2 +- schema-unit-tests/test.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/develop-func-ci.yml b/.github/workflows/develop-func-ci.yml index 41de0667..bf8b098f 100644 --- a/.github/workflows/develop-func-ci.yml +++ b/.github/workflows/develop-func-ci.yml @@ -66,7 +66,7 @@ jobs: run: npx eslint . - name: 'Run unit tests' - run: npm run rules:test + run: npm run unit:test build-docker: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 95766be2..95c568b1 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ let myEnvVariable = config.ENV_VAR ## Unit Tests -- `npm run rules:test` +- `npm run unit:test` There is a large set of Mocha unit tests for the Rulesets logic in `ruleset-unit-tests`. diff --git a/package.json b/package.json index 4da0e124..ab9f5c5d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "start": "func start --javascript", "start:verbose": "func start --javascript --verbose", - "rules:test": "mocha './ruleset-unit-tests/**/test.js' './schema-unit-tests/**/test.js' './utils-unit-tests/**/test.js'", + "unit:test": "mocha './ruleset-unit-tests/**/test.js' './schema-unit-tests/**/test.js' './utils-unit-tests/**/test.js'", "int:test": "newman run integration-tests/js-validator-api-tests.postman_collection.json -e integration-tests/envs/local-function-7071.postman_environment.json --working-dir integration-tests/test-files", "docker:start": "docker compose up --build", "docker:int:test": "newman run integration-tests/js-validator-api-tests.postman_collection.json -e integration-tests/envs/local-docker-function.postman_environment.json --working-dir integration-tests/test-files", diff --git a/schema-unit-tests/test.js b/schema-unit-tests/test.js index b116815c..7ce4ba75 100644 --- a/schema-unit-tests/test.js +++ b/schema-unit-tests/test.js @@ -27,7 +27,7 @@ const { expect } = chai; * parameter (schema) undefined, so the `if (schema)` guards short-circuit. A schema * regression has therefore been invisible to PR CI. * - * Run by the same `npm run rules:test` command, so no CI change is needed. + * Picked up by the `npm run unit:test` glob, so PR CI runs it with everything else. * * Only 2.03 fixtures are validated, though config.VERSIONS is 2.01, 2.02 and 2.03. The * defect is in libxml2's handling of xs:decimal, an XSD built-in type, so it behaves the From bfbe072f8a1adb60f55c2903bead421e212f6baa Mon Sep 17 00:00:00 2001 From: arobson-ods Date: Mon, 24 Aug 2026 13:19:34 +0100 Subject: [PATCH 08/17] chore: bump version to 2.5.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ab9f5c5d..ac8f5802 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "js-validator-api", - "version": "2.4.3", + "version": "2.5.0", "description": "JavaScript IATI Validator api", "license": "AGPL-3.0-or-later", "type": "module", From 0ba5cca4ba6889cf6881a272d63ed1ebda810259 Mon Sep 17 00:00:00 2001 From: arobson-ods Date: Mon, 24 Aug 2026 13:21:07 +0100 Subject: [PATCH 09/17] chore: add a CHANGELOG --- CHANGELOG.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..5194240b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Added + +### Changed + +### Fixed + +### Removed + +## [2.5.0] - 2026-08-24 + +### Changed + +- Replaced libxmljs2 with libxml2-wasm, moving from libxml2 2.9.9 (2019) to + 2.15.1. libxmljs2 is no longer maintained and no release of it carries a newer + libxml2, so the library was replaced rather than upgraded. +- **`?details=true` responses have changed shape.** Error details are now + `{ line, column }`, where they were the raw libxml2 error struct. See Removed. +- Schemas are now compiled once at startup rather than re-parsed for every + activity, so validation of files with schema errors should be faster. + +### Added + +- Schema unit tests (`schema-unit-tests`) and tests for file parsing and + metadata (`utils-unit-tests`). Neither area had any coverage before. +- A pre-commit hook and CI check that refuse a commit setting + `"authLevel": "anonymous"` in any `function.json`. That value is only meant to + be set temporarily, to run the integration tests against the Docker container. + +### Fixed + +- Schema validation: sign-only decimals such as ` - ` are now + reported; over-long decimals no longer are. This is the divergence with the + IATI Dashboard reported in + [#574](https://github.com/IATI/js-validator-api/issues/574). +- `.env.example`: corrected the dev service hostnames + +### Removed + +- `libxmljs2`, and with it the native build dependency — no node-gyp, + prebuilt binaries or glibc coupling. +- `str1`, `code`, `level`, `domain` and `int1` from the `details` object + returned by `?details=true`. `str1` still appears within the error message; + `level` and `domain` were constant; `str2`, `str3` and `int1` were never + populated. `code`, the numeric libxml2 constraint identifier, has no + replacement — libxml2-wasm does not expose it. From 84b5d91229f9cdd5a6a5375da0bee049082c5ab0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:25:06 +0000 Subject: [PATCH 10/17] build(deps): bump fastify/github-action-merge-dependabot (#576) Bumps [fastify/github-action-merge-dependabot](https://github.com/fastify/github-action-merge-dependabot) from 3.9.1 to 3.15.0. - [Release notes](https://github.com/fastify/github-action-merge-dependabot/releases) - [Commits](https://github.com/fastify/github-action-merge-dependabot/compare/v3.9.1...v3.15.0) --- updated-dependencies: - dependency-name: fastify/github-action-merge-dependabot dependency-version: 3.15.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/develop-func-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/develop-func-ci.yml b/.github/workflows/develop-func-ci.yml index bf8b098f..d35f8105 100644 --- a/.github/workflows/develop-func-ci.yml +++ b/.github/workflows/develop-func-ci.yml @@ -85,6 +85,6 @@ jobs: pull-requests: write contents: write steps: - - uses: fastify/github-action-merge-dependabot@v3.9.1 + - uses: fastify/github-action-merge-dependabot@v3.15.0 with: github-token: ${{secrets.GITHUB_TOKEN}} From f328efe8ba745d045d3103fd8acb9b1a2e032279 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:27:46 +0000 Subject: [PATCH 11/17] build(deps): bump azure/CLI from 1.0.8 to 3.0.0 (#577) Bumps [azure/CLI](https://github.com/azure/cli) from 1.0.8 to 3.0.0. - [Release notes](https://github.com/azure/cli/releases) - [Changelog](https://github.com/Azure/cli/blob/master/ReleaseProcess.md) - [Commits](https://github.com/azure/cli/compare/v1.0.8...v3.0.0) --- updated-dependencies: - dependency-name: azure/CLI dependency-version: 3.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/develop-func-deploy.yml | 2 +- .github/workflows/prod-func-deploy.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/develop-func-deploy.yml b/.github/workflows/develop-func-deploy.yml index 6ae99a0f..2982afa3 100644 --- a/.github/workflows/develop-func-deploy.yml +++ b/.github/workflows/develop-func-deploy.yml @@ -178,7 +178,7 @@ jobs: image: ${{ env.ACR_LOGIN_SERVER }}/func-${{ env.NAME}}-${{ env.STAGE }}:${{ env.TAG }} - name: 'Tag resource for Tech Climate Impact' - uses: 'azure/CLI@v1.0.8' + uses: 'azure/CLI@v3.0.0' with: azcliversion: 2.30.0 inlineScript: | diff --git a/.github/workflows/prod-func-deploy.yml b/.github/workflows/prod-func-deploy.yml index 9bf6665b..54b653d1 100644 --- a/.github/workflows/prod-func-deploy.yml +++ b/.github/workflows/prod-func-deploy.yml @@ -163,7 +163,7 @@ jobs: image: ${{ env.ACR_LOGIN_SERVER }}/func-${{ env.NAME}}-${{ env.STAGE }}:${{ env.TAG }} - name: 'Tag resource for Tech Climate Impact' - uses: 'azure/CLI@v1.0.8' + uses: 'azure/CLI@v3.0.0' with: azcliversion: 2.30.0 inlineScript: | From b40c4c3f65a04bbd803f855d1f1bd13760fae3c5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:29:42 +0000 Subject: [PATCH 12/17] build(deps): bump azure/login from 1.4.7 to 3.0.1 (#580) Bumps [azure/login](https://github.com/azure/login) from 1.4.7 to 3.0.1. - [Release notes](https://github.com/azure/login/releases) - [Commits](https://github.com/azure/login/compare/v1.4.7...v3.0.1) --- updated-dependencies: - dependency-name: azure/login dependency-version: 3.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/develop-func-deploy.yml | 2 +- .github/workflows/prod-func-deploy.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/develop-func-deploy.yml b/.github/workflows/develop-func-deploy.yml index 2982afa3..13fb596a 100644 --- a/.github/workflows/develop-func-deploy.yml +++ b/.github/workflows/develop-func-deploy.yml @@ -52,7 +52,7 @@ jobs: uses: actions/checkout@v4 - name: 'Login via Azure CLI' - uses: azure/login@v1.4.7 + uses: azure/login@v3.0.1 with: creds: ${{ env.AZURE_CREDENTIALS }} # Service Principal credentials saved in GitHub Secrets diff --git a/.github/workflows/prod-func-deploy.yml b/.github/workflows/prod-func-deploy.yml index 54b653d1..da9940ec 100644 --- a/.github/workflows/prod-func-deploy.yml +++ b/.github/workflows/prod-func-deploy.yml @@ -37,7 +37,7 @@ jobs: uses: actions/checkout@v4 - name: 'Login via Azure CLI' - uses: azure/login@v1.4.7 + uses: azure/login@v3.0.1 with: creds: ${{ env.AZURE_CREDENTIALS }} # Service Principal credentials saved in GitHub Secrets From 0930018e2761612d023eed810564d9152a3d8ec8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:32:29 +0000 Subject: [PATCH 13/17] build(deps): bump actions/checkout from 4 to 7 (#579) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/develop-func-ci.yml | 6 +++--- .github/workflows/develop-func-deploy.yml | 4 ++-- .github/workflows/prod-func-deploy.yml | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/develop-func-ci.yml b/.github/workflows/develop-func-ci.yml index d35f8105..53965021 100644 --- a/.github/workflows/develop-func-ci.yml +++ b/.github/workflows/develop-func-ci.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout GitHub Action' - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: 'Reject authLevel anonymous in function.json' run: | @@ -45,7 +45,7 @@ jobs: sudo apt-get install libxml2-utils - name: 'Checkout GitHub Action' - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: 'Setup Node Environment' uses: actions/setup-node@v4 @@ -72,7 +72,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout GitHub Action' - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: 'Build Docker Image' run: | diff --git a/.github/workflows/develop-func-deploy.yml b/.github/workflows/develop-func-deploy.yml index 13fb596a..f06fd662 100644 --- a/.github/workflows/develop-func-deploy.yml +++ b/.github/workflows/develop-func-deploy.yml @@ -49,7 +49,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout GitHub Action' - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: 'Login via Azure CLI' uses: azure/login@v3.0.1 @@ -196,7 +196,7 @@ jobs: run: sleep 180 - name: 'Checkout GitHub Action' - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: 'Install newman' run: | diff --git a/.github/workflows/prod-func-deploy.yml b/.github/workflows/prod-func-deploy.yml index da9940ec..1f758ed9 100644 --- a/.github/workflows/prod-func-deploy.yml +++ b/.github/workflows/prod-func-deploy.yml @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout GitHub Action' - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: 'Login via Azure CLI' uses: azure/login@v3.0.1 @@ -181,7 +181,7 @@ jobs: run: sleep 300 - name: 'Checkout GitHub Action' - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: 'Install newman' run: | From e3bd07f7ebc93ad1ced272642f500ea01b1746b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:34:30 +0000 Subject: [PATCH 14/17] build(deps): bump actions/setup-node from 4 to 7 (#578) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 7. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/develop-func-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/develop-func-ci.yml b/.github/workflows/develop-func-ci.yml index 53965021..3f8cc0bb 100644 --- a/.github/workflows/develop-func-ci.yml +++ b/.github/workflows/develop-func-ci.yml @@ -48,7 +48,7 @@ jobs: uses: actions/checkout@v7 - name: 'Setup Node Environment' - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version-file: '.nvmrc' cache: 'npm' From bc95e46caa74e902f478f1588609ee57d95b4348 Mon Sep 17 00:00:00 2001 From: arobson-ods Date: Mon, 24 Aug 2026 16:30:10 +0100 Subject: [PATCH 15/17] chore: sync package-lock.json version with package.json The 2.5.0 bump updated package.json only. Cosmetic - npm ci accepts the mismatch - but the two should agree --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 69bfb455..e9f88a78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "js-validator-api", - "version": "2.4.3", + "version": "2.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "js-validator-api", - "version": "2.4.3", + "version": "2.5.0", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "dependencies": { From ffd6384473ddf2d485173f40927a88d8999e3dec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:53:47 +0000 Subject: [PATCH 16/17] build(deps): bump azure/docker-login from 1 to 2 (#582) Bumps [azure/docker-login](https://github.com/azure/docker-login) from 1 to 2. - [Release notes](https://github.com/azure/docker-login/releases) - [Commits](https://github.com/azure/docker-login/compare/v1...v2) --- updated-dependencies: - dependency-name: azure/docker-login dependency-version: '2' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/develop-func-deploy.yml | 2 +- .github/workflows/prod-func-deploy.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/develop-func-deploy.yml b/.github/workflows/develop-func-deploy.yml index f06fd662..d2bd6c05 100644 --- a/.github/workflows/develop-func-deploy.yml +++ b/.github/workflows/develop-func-deploy.yml @@ -57,7 +57,7 @@ jobs: creds: ${{ env.AZURE_CREDENTIALS }} # Service Principal credentials saved in GitHub Secrets - name: 'Docker Login' - uses: azure/docker-login@v1 + uses: azure/docker-login@v2 with: login-server: ${{ env.ACR_LOGIN_SERVER }} username: ${{ env.ACR_USERNAME }} diff --git a/.github/workflows/prod-func-deploy.yml b/.github/workflows/prod-func-deploy.yml index 1f758ed9..a1d02ffb 100644 --- a/.github/workflows/prod-func-deploy.yml +++ b/.github/workflows/prod-func-deploy.yml @@ -42,7 +42,7 @@ jobs: creds: ${{ env.AZURE_CREDENTIALS }} # Service Principal credentials saved in GitHub Secrets - name: 'Docker Login' - uses: azure/docker-login@v1 + uses: azure/docker-login@v2 with: login-server: ${{ env.ACR_LOGIN_SERVER }} username: ${{ env.ACR_USERNAME }} From 74950792a2e4c142e1483fa39f9084375fe54549 Mon Sep 17 00:00:00 2001 From: Simon K <6615834+simon-20@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:49:18 +0100 Subject: [PATCH 17/17] docs(deploy): document why workflows get disabled --- AGENTS.md | 3 +++ README.md | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..2c89ff91 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# AGENTS.md + +Dev deployments are often deferred rather than immediate; if a push to `develop` did not deploy, see the "Deploying to dev" section of README.md before investigating. diff --git a/README.md b/README.md index 95c568b1..0841698d 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,22 @@ Using files: https://github.com/IATI/IATI-Internal-Wiki#development-process +### Deploying to dev + +Dev deploys are handled by `.github/workflows/develop-func-deploy.yml`, which triggers on push to `develop` and on a daily 04:19 UTC cron. + +- Dependabot PRs are auto-merged with `GITHUB_TOKEN`. GitHub never triggers workflows on `GITHUB_TOKEN` pushes, so those merges are deployed by the next daily cron, not immediately. +- This is a public repo, so GitHub auto-disables any workflow with a `schedule` trigger after 60 days without repo activity. When that happens the whole deploy workflow **stops responding to all** triggers, including human pushes to `develop`. Pushing again does not re-enable it. +- The workflow will be re-enabled when a commit touches the workflow file itself (e.g. a dependabot action bump), after which the next 04:19 UTC cron deploys. It can also be re-enabled via the GitHub UI, or via this command: + + `gh workflow enable develop-func-deploy.yml` + +- If a merge to `develop` does not deploy, check the workflow state: + + `gh api repos/IATI/js-validator-api/actions/workflows --jq '.workflows[] | "\(.state) \(.path)"'` + + If the deploy workflow is not `active`, use one of the methods detailed above to re-enable the workflow, then do a manual dispatch. + ## XML Library XML parsing and XSD schema validation use [`libxml2-wasm`](https://github.com/jameslan/libxml2-wasm), a WebAssembly build of libxml2. `libxml2-wasm` builds its libxml2 from a git submodule, and from v0.7.0 that submodule points at the maintainer's own fork rather than upstream — earlier releases such as v0.6.0 pinned a clean upstream release tag. Version 0.7.1 pins commit `f52e859`, which is the **v2.15.1 release plus two unmerged commits** by the `libxml2-wasm` maintainer adding Windows path handling. It contains all of 2.15.1, but sits on a branch off it, so those two commits are not in 2.15.2 or 2.15.3.