From 5b76df1087798ada9a1686e7206a1dca67262d27 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:15:48 +0200 Subject: [PATCH 01/19] chore(deps): bump css-loader from 7.1.4 to 7.1.5 (#3168) Bumps [css-loader](https://github.com/webpack/css-loader) from 7.1.4 to 7.1.5. - [Release notes](https://github.com/webpack/css-loader/releases) - [Changelog](https://github.com/webpack/css-loader/blob/main/CHANGELOG.md) - [Commits](https://github.com/webpack/css-loader/compare/v7.1.4...v7.1.5) --- updated-dependencies: - dependency-name: css-loader dependency-version: 7.1.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 85 +++++++++-------------------------------------- package.json | 2 +- 2 files changed, 17 insertions(+), 70 deletions(-) diff --git a/package-lock.json b/package-lock.json index 39ca5aaadd..11a1ce1ee2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@nextcloud/vue": "^9.9.0", "@vueuse/core": "^14.3.0", "apexcharts": "^7.0.0", - "css-loader": "^7.1.1", + "css-loader": "^7.1.5", "dexie": "^4.4.5", "dompurify": "^3.4.14", "gridstack": "^13.2.0", @@ -2562,7 +2562,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2579,7 +2578,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2596,7 +2594,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2613,7 +2610,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2630,7 +2626,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2647,7 +2642,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2664,7 +2658,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2681,7 +2674,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2698,7 +2690,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2715,7 +2706,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2732,7 +2722,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2749,7 +2738,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2766,7 +2754,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2783,7 +2770,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2800,7 +2786,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2817,7 +2802,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2834,7 +2818,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2851,7 +2834,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2868,7 +2850,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2885,7 +2866,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2902,7 +2882,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2919,7 +2898,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2936,7 +2914,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2953,7 +2930,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2970,7 +2946,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2987,7 +2962,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4460,7 +4434,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5774,7 +5747,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5788,7 +5760,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5802,7 +5773,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5816,7 +5786,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5830,7 +5799,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5844,7 +5812,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5858,7 +5825,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5872,7 +5838,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5886,7 +5851,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5900,7 +5864,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5914,7 +5877,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5928,7 +5890,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5942,7 +5903,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5956,7 +5916,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5970,7 +5929,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5984,7 +5942,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5998,7 +5955,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6012,7 +5968,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6026,7 +5981,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6040,7 +5994,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6054,7 +6007,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6068,7 +6020,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6082,7 +6033,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6096,7 +6046,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6110,7 +6059,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6704,7 +6652,7 @@ "version": "5.10.0", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", @@ -6725,7 +6673,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -7349,7 +7297,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@typescript-eslint/scope-manager": "8.67.0", @@ -7374,7 +7322,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.67.0", @@ -7396,7 +7344,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.67.0", @@ -7414,7 +7362,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7456,7 +7404,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7470,7 +7418,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@typescript-eslint/project-service": "8.67.0", @@ -7498,7 +7446,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7535,7 +7483,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.67.0", @@ -7553,7 +7501,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -10519,9 +10467,9 @@ } }, "node_modules/css-loader": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", - "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.5.tgz", + "integrity": "sha512-Q7iAfQkU2twNBryKX/vGAlE+GAmkF7quhSzAGNK8fBimxk3+tqg245rH52UPb9dioBolBc8rP0ihmHBaDTJQBA==", "license": "MIT", "dependencies": { "icss-utils": "^5.1.0", @@ -12962,7 +12910,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -22732,7 +22679,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18.12" diff --git a/package.json b/package.json index 71ed5a18e2..ab57d19543 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "@nextcloud/vue": "^9.9.0", "@vueuse/core": "^14.3.0", "apexcharts": "^7.0.0", - "css-loader": "^7.1.1", + "css-loader": "^7.1.5", "dexie": "^4.4.5", "dompurify": "^3.4.14", "gridstack": "^13.2.0", From 41e94d63a4752e1b484e01848095f4da9cb893fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:15:56 +0200 Subject: [PATCH 02/19] chore(deps): bump web-token/jwt-library from 4.1.9 to 4.2.1 (#3165) Bumps [web-token/jwt-library](https://github.com/web-token/jwt-library) from 4.1.9 to 4.2.1. - [Commits](https://github.com/web-token/jwt-library/compare/4.1.9...4.2.1) --- updated-dependencies: - dependency-name: web-token/jwt-library dependency-version: 4.2.1 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> --- composer.lock | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/composer.lock b/composer.lock index c1ce99d490..af348eafd7 100644 --- a/composer.lock +++ b/composer.lock @@ -3513,20 +3513,20 @@ }, { "name": "spomky-labs/pki-framework", - "version": "1.6.0", + "version": "1.6.1", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/pki-framework.git", - "reference": "80778a25426288acd2e3a7cde2def41a3d59cddf" + "reference": "5d630ec371652229ad568337519f16e539e5f79d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/80778a25426288acd2e3a7cde2def41a3d59cddf", - "reference": "80778a25426288acd2e3a7cde2def41a3d59cddf", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/5d630ec371652229ad568337519f16e539e5f79d", + "reference": "5d630ec371652229ad568337519f16e539e5f79d", "shasum": "" }, "require": { - "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19", + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20", "ext-mbstring": "*", "php": ">=8.1" }, @@ -3606,7 +3606,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/pki-framework/issues", - "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.6.0" + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.6.1" }, "funding": [ { @@ -3618,7 +3618,7 @@ "type": "patreon" } ], - "time": "2026-08-06T16:21:11+00:00" + "time": "2026-08-30T13:53:49+00:00" }, { "name": "symfony/console", @@ -6002,23 +6002,24 @@ }, { "name": "web-token/jwt-library", - "version": "4.1.9", + "version": "4.2.1", "source": { "type": "git", "url": "https://github.com/web-token/jwt-library.git", - "reference": "cbd52c8e96b835498f0f9c3a70a94d9f0e9e6c69" + "reference": "43f7c7dd6799e3bfeb05276af180be3f9362759e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-token/jwt-library/zipball/cbd52c8e96b835498f0f9c3a70a94d9f0e9e6c69", - "reference": "cbd52c8e96b835498f0f9c3a70a94d9f0e9e6c69", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/43f7c7dd6799e3bfeb05276af180be3f9362759e", + "reference": "43f7c7dd6799e3bfeb05276af180be3f9362759e", "shasum": "" }, "require": { "brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19", "php": ">=8.2", "psr/clock": "^1.0", - "spomky-labs/pki-framework": "^1.2.1" + "spomky-labs/pki-framework": "^1.2.1", + "symfony/deprecation-contracts": "^2.5|^3.0" }, "conflict": { "spomky-labs/jose": "*" @@ -6075,7 +6076,7 @@ ], "support": { "issues": "https://github.com/web-token/jwt-library/issues", - "source": "https://github.com/web-token/jwt-library/tree/4.1.9" + "source": "https://github.com/web-token/jwt-library/tree/4.2.1" }, "funding": [ { @@ -6087,7 +6088,7 @@ "type": "patreon" } ], - "time": "2026-08-26T09:47:42+00:00" + "time": "2026-08-28T12:44:34+00:00" }, { "name": "webonyx/graphql-php", From f97fe935b5e6518b2efecfa5662cd21bb07fd979 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:16:04 +0200 Subject: [PATCH 03/19] chore(deps-dev): bump phpcsstandards/phpcsextra from 1.5.0 to 1.5.1 (#3159) Bumps [phpcsstandards/phpcsextra](https://github.com/PHPCSStandards/PHPCSExtra) from 1.5.0 to 1.5.1. - [Release notes](https://github.com/PHPCSStandards/PHPCSExtra/releases) - [Changelog](https://github.com/PHPCSStandards/PHPCSExtra/blob/develop/CHANGELOG.md) - [Commits](https://github.com/PHPCSStandards/PHPCSExtra/compare/1.5.0...1.5.1) --- updated-dependencies: - dependency-name: phpcsstandards/phpcsextra dependency-version: 1.5.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index af348eafd7..d9608d8930 100644 --- a/composer.lock +++ b/composer.lock @@ -8926,21 +8926,21 @@ }, { "name": "phpcsstandards/phpcsextra", - "version": "1.5.0", + "version": "1.5.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", - "reference": "b598aa890815b8df16363271b659d73280129101" + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/b598aa890815b8df16363271b659d73280129101", - "reference": "b598aa890815b8df16363271b659d73280129101", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/39467533fdb742446d68c1d10ac33d625ee0311c", + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c", "shasum": "" }, "require": { "php": ">=5.4", - "phpcsstandards/phpcsutils": "^1.2.0", + "phpcsstandards/phpcsutils": "^1.2.3", "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" }, "require-dev": { @@ -9004,7 +9004,7 @@ "type": "thanks_dev" } ], - "time": "2025-11-12T23:06:57+00:00" + "time": "2026-07-27T11:13:17+00:00" }, { "name": "phpcsstandards/phpcsutils", From 78758a35e8d6bb2e74ea961f6ba50f07fb40f1fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:16:21 +0200 Subject: [PATCH 04/19] chore(deps): bump @nextcloud/dialogs from 7.4.1 to 7.5.0 (#3156) Bumps [@nextcloud/dialogs](https://github.com/nextcloud-libraries/nextcloud-dialogs) from 7.4.1 to 7.5.0. - [Release notes](https://github.com/nextcloud-libraries/nextcloud-dialogs/releases) - [Changelog](https://github.com/nextcloud-libraries/nextcloud-dialogs/blob/main/CHANGELOG.md) - [Commits](https://github.com/nextcloud-libraries/nextcloud-dialogs/compare/v7.4.1...v7.5.0) --- updated-dependencies: - dependency-name: "@nextcloud/dialogs" dependency-version: 7.5.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> --- package-lock.json | 31 +++++++++---------------------- package.json | 2 +- 2 files changed, 10 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index 11a1ce1ee2..7d1ca42b49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "^2.6.0", "@nextcloud/capabilities": "^1.2.1", - "@nextcloud/dialogs": "^7.4.1", + "@nextcloud/dialogs": "^7.5.0", "@nextcloud/initial-state": "^3.0.0", "@nextcloud/l10n": "^3.4.1", "@nextcloud/router": "^3.1.0", @@ -4506,15 +4506,16 @@ } }, "node_modules/@nextcloud/dialogs": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@nextcloud/dialogs/-/dialogs-7.4.1.tgz", - "integrity": "sha512-M8q1v14rTMdjTyv5HOZrpBpa5M+qnqnjoFCeeohmKvt69xVW9Xag6R5InQhBQh3zfsYEaQ4mg3shn3kKBx9qxg==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@nextcloud/dialogs/-/dialogs-7.5.0.tgz", + "integrity": "sha512-2gmv0V3ObphxRd7ZvXwTsPvn/XSf2vummdXb2LRFpkoSamWbk+41SWv7cnDQPC+Ve9p46WebDuqyG+wbefkhEw==", "license": "AGPL-3.0-or-later", "dependencies": { "@mdi/js": "^7.4.47", "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "^2.6.0", "@nextcloud/browser-storage": "^0.5.0", + "@nextcloud/capabilities": "^1.2.1", "@nextcloud/event-bus": "^3.3.3", "@nextcloud/files": "^4.0.0", "@nextcloud/initial-state": "^3.0.0", @@ -4522,12 +4523,10 @@ "@nextcloud/paths": "^3.1.0", "@nextcloud/router": "^3.1.0", "@nextcloud/sharing": "^0.4.0", - "@nextcloud/vue": "^9.8.2", - "@types/toastify-js": "^1.12.4", - "@vueuse/core": "^14.3.0", - "p-queue": "^9.3.1", - "toastify-js": "^1.12.0", - "vue": "^3.5.39", + "@nextcloud/vue": "^9.9.0", + "@vueuse/core": "^14.4.0", + "p-queue": "^9.3.3", + "vue": "^3.5.41", "webdav": "^5.10.0" }, "engines": { @@ -7187,12 +7186,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/toastify-js": { - "version": "1.12.4", - "resolved": "https://registry.npmjs.org/@types/toastify-js/-/toastify-js-1.12.4.tgz", - "integrity": "sha512-zfZHU4tKffPCnZRe7pjv/eFKzTVHozKewFCKaCjZ4gFinKgJRz/t0bkZiMCXJxPhv/ZoeDGNOeRD09R0kQZ/nw==", - "license": "MIT" - }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -22575,12 +22568,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/toastify-js": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/toastify-js/-/toastify-js-1.12.0.tgz", - "integrity": "sha512-HeMHCO9yLPvP9k0apGSdPUWrUbLnxUKNFzgUoZp1PHCLploIX/4DSQ7V8H25ef+h4iO9n0he7ImfcndnN6nDrQ==", - "license": "MIT" - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/package.json b/package.json index ab57d19543..27f70595a8 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "^2.6.0", "@nextcloud/capabilities": "^1.2.1", - "@nextcloud/dialogs": "^7.4.1", + "@nextcloud/dialogs": "^7.5.0", "@nextcloud/initial-state": "^3.0.0", "@nextcloud/l10n": "^3.4.1", "@nextcloud/router": "^3.1.0", From 419651be1f990f68d6c93fd2d127d4162701d3d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:16:29 +0200 Subject: [PATCH 05/19] chore(deps): bump vue-router from 5.2.0 to 5.3.0 (#3154) Bumps [vue-router](https://github.com/vuejs/router) from 5.2.0 to 5.3.0. - [Release notes](https://github.com/vuejs/router/releases) - [Commits](https://github.com/vuejs/router/compare/v5.2.0...v5.3.0) --- updated-dependencies: - dependency-name: vue-router dependency-version: 5.3.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> --- package-lock.json | 100 ++++------------------------------------------ package.json | 2 +- 2 files changed, 9 insertions(+), 93 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7d1ca42b49..9b1046f94a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,7 @@ "vue-draggable-plus": "^0.6.0", "vue-loading-overlay": "^6.0.6", "vue-material-design-icons": "^5.2.0", - "vue-router": "^5.2.0", + "vue-router": "^5.3.0", "vue3-apexcharts": "~1.8.0", "zod": "^4.4.3" }, @@ -7031,12 +7031,6 @@ "parse5": "^7.0.0" } }, - "node_modules/@types/jsesc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", - "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -15671,6 +15665,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -15710,6 +15705,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -23883,17 +23879,16 @@ } }, "node_modules/vue-router": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.2.0.tgz", - "integrity": "sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.3.0.tgz", + "integrity": "sha512-a2PBXX9yfkS58JzSJALUffgDa09lEzKmCcEnLaepoIr88L+9hEnsgEQkcadJGID+vtHa0gFpVo064zmylA9fcg==", "license": "MIT", "dependencies": { - "@babel/generator": "^8.0.0", "@vue-macros/common": "^3.1.3", "@vue/devtools-api": "^8.1.5", "ast-walker-scope": "^0.9.0", "chokidar": "^5.0.0", - "json5": "^2.2.3", + "confbox": "^0.2.4", "local-pkg": "^1.2.1", "magic-string": "^0.30.21", "mlly": "^1.8.2", @@ -23904,8 +23899,7 @@ "scule": "^1.3.0", "tinyglobby": "^0.2.17", "unplugin": "^3.3.0", - "unplugin-utils": "^0.3.2", - "yaml": "^2.9.0" + "unplugin-utils": "^0.3.2" }, "funding": { "url": "https://github.com/sponsors/posva" @@ -23932,69 +23926,6 @@ } } }, - "node_modules/vue-router/node_modules/@babel/generator": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", - "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^8.0.0", - "@babel/types": "^8.0.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "@types/jsesc": "^2.5.0", - "jsesc": "^3.0.2" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/vue-router/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/vue-router/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", - "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/vue-router/node_modules/@babel/parser": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", - "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", - "license": "MIT", - "dependencies": { - "@babel/types": "^8.0.4" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/vue-router/node_modules/@babel/types": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", - "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.4" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, "node_modules/vue-router/node_modules/@vue/devtools-api": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.2.1.tgz", @@ -24877,21 +24808,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", diff --git a/package.json b/package.json index 27f70595a8..1ecfd238a6 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "vue-draggable-plus": "^0.6.0", "vue-loading-overlay": "^6.0.6", "vue-material-design-icons": "^5.2.0", - "vue-router": "^5.2.0", + "vue-router": "^5.3.0", "vue3-apexcharts": "~1.8.0", "zod": "^4.4.3" }, From 6cf443d62146c80962f42eaf9d6ff88144d2ed25 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:16:37 +0200 Subject: [PATCH 06/19] chore(deps): bump vue from 3.5.41 to 3.5.42 (#3152) Bumps [vue](https://github.com/vuejs/core) from 3.5.41 to 3.5.42. - [Release notes](https://github.com/vuejs/core/releases) - [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md) - [Commits](https://github.com/vuejs/core/compare/v3.5.41...v3.5.42) --- updated-dependencies: - dependency-name: vue dependency-version: 3.5.42 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 170 +++++++++++++++++++++++++++++++--------------- package.json | 4 +- 2 files changed, 118 insertions(+), 56 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9b1046f94a..945e737bff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "path-browserify": "^1.0.1", "pinia": "^3.0.4", "style-loader": "^4.0.0", - "vue": "^3.5.0", + "vue": "^3.5.42", "vue-codemirror6": "^1.6.1", "vue-draggable-plus": "^0.6.0", "vue-loading-overlay": "^6.0.6", @@ -85,9 +85,6 @@ "engines": { "node": "^22.14 || ^24 || >=26", "npm": "^11.0.0" - }, - "peerDependencies": { - "vue": "^3.5.0" } }, "node_modules/@asyncapi/specs": { @@ -7749,48 +7746,6 @@ "rfdc": "^1.4.1" } }, - "node_modules/@vue/reactivity": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", - "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.41" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", - "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.41", - "@vue/shared": "3.5.41" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", - "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.41", - "@vue/runtime-core": "3.5.41", - "@vue/shared": "3.5.41", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", - "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.41", - "@vue/runtime-dom": "3.5.41", - "@vue/shared": "3.5.41" - } - }, "node_modules/@vue/shared": { "version": "3.5.41", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", @@ -23697,16 +23652,16 @@ "peer": true }, "node_modules/vue": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", - "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz", + "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.41", - "@vue/compiler-sfc": "3.5.41", - "@vue/runtime-dom": "3.5.41", - "@vue/server-renderer": "3.5.41", - "@vue/shared": "3.5.41" + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-sfc": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/server-renderer": "3.5.42", + "@vue/shared": "3.5.42" }, "peerDependencies": { "typescript": "*" @@ -23980,6 +23935,113 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vue/node_modules/@vue/compiler-core": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.42.tgz", + "integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.42", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/vue/node_modules/@vue/compiler-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz", + "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/vue/node_modules/@vue/compiler-sfc": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz", + "integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.42", + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-ssr": "3.5.42", + "@vue/shared": "3.5.42", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/vue/node_modules/@vue/compiler-ssr": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz", + "integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/vue/node_modules/@vue/reactivity": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.42.tgz", + "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.42" + } + }, + "node_modules/vue/node_modules/@vue/runtime-core": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.42.tgz", + "integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/vue/node_modules/@vue/runtime-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz", + "integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/runtime-core": "3.5.42", + "@vue/shared": "3.5.42", + "csstype": "^3.2.3" + } + }, + "node_modules/vue/node_modules/@vue/server-renderer": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.42.tgz", + "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/vue/node_modules/@vue/shared": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.42.tgz", + "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==", + "license": "MIT" + }, + "node_modules/vue/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/vue3-apexcharts": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.8.0.tgz", diff --git a/package.json b/package.json index 1ecfd238a6..a13133933b 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ ], "sideEffects": true, "peerDependencies": { - "vue": "^3.5.0" + "vue": "^3.5.42" }, "dependencies": { "@codemirror/lang-json": "^6.0.1", @@ -83,7 +83,7 @@ "path-browserify": "^1.0.1", "pinia": "^3.0.4", "style-loader": "^4.0.0", - "vue": "^3.5.0", + "vue": "^3.5.42", "vue-codemirror6": "^1.6.1", "vue-draggable-plus": "^0.6.0", "vue-loading-overlay": "^6.0.6", From 7e8955cc23ebe4999255bde2087d5a063715569f Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 30 Aug 2026 22:33:49 +0200 Subject: [PATCH 07/19] chore(dependabot): hold back vue-router 5 as well (#3172) vue-router 5 peers `vite: ^7.3.0 || ^8.0.0` and expects a Vite toolchain. These apps build with webpack, which cannot resolve it at all: the build dies on `Can't resolve 'vue-router'` from src and from @nextcloud/vue's own chunks. Adopting it is a Vite migration, not a version bump. Dependabot proposed it across 8 repositories in a single run, and merging any one of them takes that app's build from green to red with no code change that can fix it. versioniq already builds with Vite and is the natural pilot if the fleet does move. Lift this when an app's toolchain can actually take it. --- .github/dependabot.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7a8476c503..786b631768 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -26,6 +26,13 @@ updates: ignore: - dependency-name: "typescript" update-types: ["version-update:semver-major"] + # vue-router 5 peers `vite: ^7.3.0 || ^8.0.0` and expects a Vite + # toolchain. These apps build with webpack, which cannot resolve it at + # all: the build dies on `Can't resolve 'vue-router'`. Adopting it is a + # Vite migration, not a bump. versioniq is already on Vite and is the + # natural pilot. + - dependency-name: "vue-router" + update-types: ["version-update:semver-major"] - dependency-name: "webpack-cli" update-types: ["version-update:semver-major"] - dependency-name: "@babel/core" From 2ce0ca618689ef7cfacd8248b1c858cd376b9216 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 30 Aug 2026 23:15:17 +0200 Subject: [PATCH 08/19] docs(dependabot): correct the vue-router holdback to what was measured (#3173) The comment claimed these apps "cannot resolve it at all". That is not what the evidence shows, and a comment that overstates its case is worse than none: the next person reads it, tries vue-router 5 somewhere it works, and stops trusting the file. Measured across four apps: integriq and zaakafhandelapp fail their build on `Can't resolve 'vue-router'`, while openregister and learniq build clean on 5.3.0. The holdback still stands, for the honest reason rather than the overstated one: a major that breaks some apps and not others cannot be merged unattended, and the difference between them is not yet understood. No behaviour change. The ignore rule is unchanged; only the reasoning is. --- .github/dependabot.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 786b631768..0535df19ae 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -27,8 +27,11 @@ updates: - dependency-name: "typescript" update-types: ["version-update:semver-major"] # vue-router 5 peers `vite: ^7.3.0 || ^8.0.0` and expects a Vite - # toolchain. These apps build with webpack, which cannot resolve it at - # all: the build dies on `Can't resolve 'vue-router'`. Adopting it is a + # toolchain. Measured, not assumed, and the result is SPLIT: integriq and + # zaakafhandelapp fail their build on `Can't resolve 'vue-router'`, while + # openregister and learniq build clean on 5.3.0. Held because a bump that + # breaks some apps and not others cannot be merged unattended, and the + # difference is not yet understood. Adopting it fleet-wide is a # Vite migration, not a bump. versioniq is already on Vite and is the # natural pilot. - dependency-name: "vue-router" From dbe6f6bc0e2421e5dcc87b7d0f4f2bbef12ebdf7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:56:39 +0200 Subject: [PATCH 09/19] chore(deps-dev): bump @typescript-eslint/parser from 8.67.0 to 8.68.0 (#3158) Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.67.0 to 8.68.0. - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.68.0/packages/parser) --- updated-dependencies: - dependency-name: "@typescript-eslint/parser" dependency-version: 8.68.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 201 ++++++++++++++++++++++++++++++++++++++++++---- package.json | 2 +- 2 files changed, 187 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 945e737bff..b2c90f28e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,7 @@ "@stoplight/spectral-cli": "^6.15.0", "@types/jest": "^29.5.12", "@types/node": "^20.17.23", - "@typescript-eslint/parser": "^8.67.0", + "@typescript-eslint/parser": "^8.68.0", "@vue/test-utils": "^2.4.4", "@vue/vue3-jest": "^29.2.6", "axe-core": "^4.10.0", @@ -85,6 +85,9 @@ "engines": { "node": "^22.14 || ^24 || >=26", "npm": "^11.0.0" + }, + "peerDependencies": { + "vue": "^3.5.42" } }, "node_modules/@asyncapi/specs": { @@ -7278,16 +7281,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", - "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz", + "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", "debug": "^4.4.3" }, "engines": { @@ -7302,11 +7305,154 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz", + "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.68.0", + "@typescript-eslint/types": "^8.68.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz", + "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz", + "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz", + "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz", + "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.68.0", + "@typescript-eslint/tsconfig-utils": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz", + "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.68.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@typescript-eslint/project-service": { "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.67.0", @@ -7328,7 +7474,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.67.0", @@ -7346,7 +7492,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7402,7 +7548,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/project-service": "8.67.0", @@ -7430,7 +7576,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7467,7 +7613,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.67.0", @@ -7485,7 +7631,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -23072,6 +23218,31 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/typescript-event-target": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/typescript-event-target/-/typescript-event-target-1.1.2.tgz", diff --git a/package.json b/package.json index a13133933b..5485eaa01e 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "@stoplight/spectral-cli": "^6.15.0", "@types/jest": "^29.5.12", "@types/node": "^20.17.23", - "@typescript-eslint/parser": "^8.67.0", + "@typescript-eslint/parser": "^8.68.0", "@vue/test-utils": "^2.4.4", "@vue/vue3-jest": "^29.2.6", "axe-core": "^4.10.0", From d1578720c34b7bf819d871bda55cadfcb01f54c1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 02:58:12 +0200 Subject: [PATCH 10/19] fix(aggregation): count the objects that have no organisation (#3180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two implementations of the organisation boundary had drifted. The list path builds it with the query builder in MagicOrganizationHandler; the aggregation's native fast path carried a flattened copy — a bare `_organisation = :activeOrg`. SQL `=` never matches NULL, so every object with no organisation was invisible to the aggregation API while the list API returned it. Measured on decidiq/meeting: four meetings listed, exactly one org-less, `count` answered 3, and `filter[lifecycle]=closed` answered 0 for a meeting that plainly exists. Every KPI tile in the fleet reads that endpoint, so each one silently under-reported — a dashboard number that is confidently wrong. The rule now has ONE home. `resolveOrganizationScope()` returns a DECISION (`all` / `none` / `null-only` / `in` / `in-or-null`) and both callers render it: the list path as query-builder expressions, the aggregation as raw SQL. A mode the SQL cannot express is a refusal — `return null`, falling back to the PHP path that goes through MagicMapper — never an approximation. `in-or-null` is the case that was lost: an admin scoped to an organisation also sees org-less rows. Non-admins are unchanged and still cannot, so this widens nothing for a tenant. Verified live after the change: the same endpoint answers 4 and 1, matching the list endpoint exactly. New MagicOrganizationHandlerScopeTest covers all five modes; its regression case was checked to fail against the old flattened rule. The 12 AggregationRunner test files gain the new collaborator, mocked to the single-org scope their fixtures already seed. phpcs, phpmd, psalm and phpstan clean; Db (1483) and Aggregation (313) suites green. The complexity suppressions moved from applyOrganizationFilter to resolveOrganizationScope with the code they were written for. --- .../MagicMapper/MagicOrganizationHandler.php | 151 ++++++++++---- lib/Service/Aggregation/AggregationRunner.php | 59 +++++- .../MagicOrganizationHandlerScopeTest.php | 185 ++++++++++++++++++ ...AggregationJoinAndCompositeGroupByTest.php | 22 +++ .../AggregationRegisterScopeTest.php | 22 +++ .../AggregationRunnerAdhocCacheTest.php | 23 +++ .../AggregationRunnerCumulativeTest.php | 23 +++ ...AggregationRunnerMultiFieldGroupByTest.php | 22 +++ .../AggregationRunnerMultiMetricTest.php | 22 +++ .../AggregationRunnerMultiValueFilterTest.php | 22 +++ .../AggregationRunnerNativeBucketTest.php | 22 +++ .../Aggregation/AggregationRunnerTest.php | 22 +++ .../CrossSchemaAggregationRunnerTest.php | 22 +++ 13 files changed, 575 insertions(+), 42 deletions(-) create mode 100644 tests/Unit/Db/MagicMapper/MagicOrganizationHandlerScopeTest.php diff --git a/lib/Db/MagicMapper/MagicOrganizationHandler.php b/lib/Db/MagicMapper/MagicOrganizationHandler.php index 91cfd930af..4d5cde125c 100644 --- a/lib/Db/MagicMapper/MagicOrganizationHandler.php +++ b/lib/Db/MagicMapper/MagicOrganizationHandler.php @@ -56,6 +56,36 @@ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class MagicOrganizationHandler { + + /** + * Every row is visible — a trusted system context, or an admin with the + * bypass enabled outside SaaS mode. + */ + public const SCOPE_ALL = 'all'; + + /** + * No row is visible — a non-admin with no active organisation. + */ + public const SCOPE_NONE = 'none'; + + /** + * Only rows with NO organisation — an admin with no active organisation. + */ + public const SCOPE_NULL_ONLY = 'null-only'; + + /** + * Rows in the caller's active organisation(s). + */ + public const SCOPE_IN = 'in'; + + /** + * Rows in the caller's active organisation(s), PLUS rows with no + * organisation at all. Admins only. This is the mode the aggregation API + * used to get wrong: it rendered only the `IN` half, and SQL `=` / `IN` + * never match NULL, so org-less rows vanished from every KPI. + */ + public const SCOPE_IN_OR_NULL = 'in-or-null'; + /** * Constructor for MagicOrganizationHandler. * @@ -96,25 +126,96 @@ public function applyOrganizationFilter( IQueryBuilder $qb, bool $adminBypassEnabled = false, ): void { + $scope = $this->resolveOrganizationScope(adminBypassEnabled: $adminBypassEnabled); + + if ($scope['mode'] === self::SCOPE_ALL) { + return; + } + + if ($scope['mode'] === self::SCOPE_NONE) { + $qb->andWhere('1 = 0'); + return; + } + + if ($scope['mode'] === self::SCOPE_NULL_ONLY) { + $qb->andWhere($qb->expr()->isNull('t._organisation')); + return; + } + + // Condition 1: objects belonging to the caller's active organisation(s). + $conditions = []; + $conditions[] = $qb->expr()->in( + 't._organisation', + $qb->createNamedParameter($scope['uuids'], IQueryBuilder::PARAM_STR_ARRAY) + ); + if (count($scope['uuids']) === 1) { + array_pop($conditions); + $conditions[] = $qb->expr()->eq( + 't._organisation', + $qb->createNamedParameter($scope['uuids'][0]) + ); + } + + // Condition 2: objects with no organisation — ONLY for admin users. + if ($scope['mode'] === self::SCOPE_IN_OR_NULL) { + $conditions[] = $qb->expr()->isNull('t._organisation'); + } + + $qb->andWhere($qb->expr()->orX(...$conditions)); + + }//end applyOrganizationFilter() + + /** + * Decide WHICH rows the current caller may see, without building any SQL. + * + * This is the single source of truth for the organisation boundary. It was + * extracted from {@see applyOrganizationFilter()} because a SECOND + * implementation had grown in `AggregationRunner::tryNativeAggregation()`, + * where the whole rule had been flattened to a bare + * `_organisation = :activeOrg`. SQL `=` never matches NULL, so every object + * with no organisation was invisible to the aggregation API while the list + * API returned it — measured 2026-08-30 on decidiq/meeting: four meetings + * listed, one of them org-less, `count` answered 3, and + * `filter[lifecycle]=closed` answered 0 for a meeting that plainly exists. + * Every KPI tile in the fleet reads that endpoint, so each one silently + * under-reported. + * + * Returning a DECISION rather than a query fragment is the point: a caller + * that cannot render one of these modes must refuse to run rather than + * approximate it, and the rule itself now lives in exactly one place. + * + * @param bool $adminBypassEnabled Whether the caller honours the admin bypass (disabled in SaaS mode). + * + * @return array{mode: string, uuids: array} `mode` is one of the SCOPE_* constants. + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * Moved here with the code they were written for: this method IS the + * branchy decision that used to sit inline in applyOrganizationFilter, + * which has carried these three suppressions since it was written. The + * branches are the tenancy rule itself (system context, admin, bypass, + * SaaS, active-org set) and collapsing them would hide it. + */ + public function resolveOrganizationScope(bool $adminBypassEnabled = false): array { $user = $this->userSession->getUser(); // CLI / no-session system context (occ commands, repair steps, cron // jobs, background calculations, system listeners) is a trusted system // operation and must see all org-owned rows. See isSystemContext(). if ($this->isSystemContext(user: $user) === true) { - return; + return ['mode' => self::SCOPE_ALL, 'uuids' => []]; } - // Check if user is admin - admins can see all objects including those with null organization. + // Admins can see all objects, including those with no organisation. $isAdmin = false; if ($user !== null) { $userGroups = $this->groupManager->getUserGroupIds($user); $isAdmin = in_array('admin', $userGroups, true); } - // Check if admin bypass is enabled (disabled in SaaS mode). if ($adminBypassEnabled === true && $isAdmin === true) { - // In SaaS mode, never bypass organisation boundary. + // In SaaS mode, never bypass the organisation boundary. $saasMode = $this->isSaasModeEnabled(); if ($saasMode === true) { $this->logger->debug( @@ -124,49 +225,33 @@ public function applyOrganizationFilter( } if ($saasMode !== true) { - return; + return ['mode' => self::SCOPE_ALL, 'uuids' => []]; } } - // Get the active organization UUID(s) for the current user. $activeOrgUuids = $this->getActiveOrganizationUuids(); if (empty($activeOrgUuids) === true) { - // No active organization - admins can see null-org objects, others get no results. - if ($isAdmin !== true) { - $qb->andWhere('1 = 0'); - return; + // No active organisation: admins still see org-less rows, others see nothing. + $emptyMode = self::SCOPE_NONE; + if ($isAdmin === true) { + $emptyMode = self::SCOPE_NULL_ONLY; } - $qb->andWhere($qb->expr()->isNull('t._organisation')); - return; - }//end if - - // Build conditions for organization filtering. - $conditions = []; - - // Condition 1: Objects belonging to the user's active organization(s). - $conditions[] = $qb->expr()->in( - 't._organisation', - $qb->createNamedParameter($activeOrgUuids, IQueryBuilder::PARAM_STR_ARRAY) - ); - if (count($activeOrgUuids) === 1) { - array_pop($conditions); - $conditions[] = $qb->expr()->eq( - 't._organisation', - $qb->createNamedParameter($activeOrgUuids[0]) - ); + return ['mode' => $emptyMode, 'uuids' => []]; } - // Condition 2: Objects with null organization - ONLY for admin users. + $scopedMode = self::SCOPE_IN; if ($isAdmin === true) { - $conditions[] = $qb->expr()->isNull('t._organisation'); + $scopedMode = self::SCOPE_IN_OR_NULL; } - // Apply OR of all conditions. - $qb->andWhere($qb->expr()->orX(...$conditions)); + return [ + 'mode' => $scopedMode, + 'uuids' => array_values($activeOrgUuids), + ]; - }//end applyOrganizationFilter() + }//end resolveOrganizationScope() /** * Determine whether the current call is a trusted system (CLI/no-session) diff --git a/lib/Service/Aggregation/AggregationRunner.php b/lib/Service/Aggregation/AggregationRunner.php index 3a70d64bba..8c1c2f30b4 100644 --- a/lib/Service/Aggregation/AggregationRunner.php +++ b/lib/Service/Aggregation/AggregationRunner.php @@ -39,6 +39,7 @@ use DateTimeInterface; use DateTimeZone; use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use OCA\OpenRegister\Db\Register; use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\Schema; @@ -141,6 +142,7 @@ class AggregationRunner { * @param PermissionHandler $permissionHandler RBAC verdict on the schema's `list` action. * @param IUserSession $userSession Active session, for the RBAC + cache-key user scope. * @param OrganisationService $organisationService Active-organisation lookup for the cache key. + * @param MagicOrganizationHandler $organizationHandler Owns the organisation-boundary decision the native SQL renders. * @param TranslationHandler $translationHandler Resolves translatable group keys to the negotiated language. * @param LanguageService $languageService Request-scoped language negotiation (Accept-Language / _lang). * @param LoggerInterface|null $logger Optional logger for diagnostics. @@ -164,6 +166,7 @@ public function __construct( private readonly PermissionHandler $permissionHandler, private readonly IUserSession $userSession, private readonly OrganisationService $organisationService, + private readonly MagicOrganizationHandler $organizationHandler, private readonly TranslationHandler $translationHandler, private readonly LanguageService $languageService, private readonly ?LoggerInterface $logger = null, @@ -2511,15 +2514,53 @@ private function tryNativeAggregation( $whereParts[] = "(_deleted IS NULL OR _deleted = 'null' OR _deleted = '')"; } - // SECURITY: mirror MagicRbacHandler's multi-tenancy predicate. The - // native fast path bypasses MagicMapper entirely, so without this - // filter any authed caller could compute aggregates over rows in - // other tenants. Active org of `null` ⇒ no rows (fail-closed). - // Column is `_organisation` — magic tables prefix metadata cols - // with `_` (see MagicMapper::METADATA_PREFIX). - $activeOrg = $this->organisationService->getActiveOrganisation(); - $whereParts[] = $quote . '_organisation' . $quote . ' = ?'; - $bindings[] = $activeOrg?->getUuid() ?? '__no_active_org__'; + // SECURITY: the organisation boundary. This fast path bypasses + // MagicMapper entirely, so it must reproduce the SAME rule the list + // path applies — no wider, and no narrower. + // + // It used to carry its own flattened copy: a bare + // `_organisation = :activeOrg`. That was narrower in a way nobody saw, + // because SQL `=` never matches NULL: every object with no + // organisation was invisible here while the list API returned it. + // Measured 2026-08-30 on decidiq/meeting — four meetings listed, one + // org-less, `count` answered 3, and `filter[lifecycle]=closed` + // answered 0 for a meeting that plainly exists. Every KPI tile in the + // fleet reads this endpoint, so every one of them under-reported. + // + // The rule now has one home: MagicOrganizationHandler decides, and + // both paths render that decision. A mode this SQL cannot express is + // a refusal (`return null` ⇒ the PHP fallback, which goes through + // MagicMapper), never an approximation. + // Column is `_organisation` — magic tables prefix metadata cols with + // `_` (see MagicMapper::METADATA_PREFIX). + $orgScope = $this->organizationHandler->resolveOrganizationScope(); + $orgColumn = $quote . '_organisation' . $quote; + switch ($orgScope['mode']) { + case MagicOrganizationHandler::SCOPE_ALL: + break; + case MagicOrganizationHandler::SCOPE_NONE: + $whereParts[] = '1 = 0'; + break; + case MagicOrganizationHandler::SCOPE_NULL_ONLY: + $whereParts[] = $orgColumn . ' IS NULL'; + break; + case MagicOrganizationHandler::SCOPE_IN: + case MagicOrganizationHandler::SCOPE_IN_OR_NULL: + $placeholders = implode(', ', array_fill(0, count($orgScope['uuids']), '?')); + $inClause = $orgColumn . ' IN (' . $placeholders . ')'; + if ($orgScope['mode'] === MagicOrganizationHandler::SCOPE_IN_OR_NULL) { + $inClause = '(' . $inClause . ' OR ' . $orgColumn . ' IS NULL)'; + } + + $whereParts[] = $inClause; + foreach ($orgScope['uuids'] as $orgUuid) { + $bindings[] = $orgUuid; + } + break; + default: + // An unknown mode is not something to guess at. + return null; + }//end switch foreach ($filter as $f => $v) { $col = $this->sanitizeColumnName(name: (string)$f); diff --git a/tests/Unit/Db/MagicMapper/MagicOrganizationHandlerScopeTest.php b/tests/Unit/Db/MagicMapper/MagicOrganizationHandlerScopeTest.php new file mode 100644 index 0000000000..1e8df4c35d --- /dev/null +++ b/tests/Unit/Db/MagicMapper/MagicOrganizationHandlerScopeTest.php @@ -0,0 +1,185 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db\MagicMapper; + +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; +use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * @covers \OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler + */ +class MagicOrganizationHandlerScopeTest extends TestCase { + + /** + * Build a handler for a caller with the given admin flag and active orgs. + * + * A non-null user is always supplied: `isSystemContext()` treats a NULL + * user under the CLI SAPI as a trusted system context, and PHPUnit runs + * under CLI, so a null user would silently make every case SCOPE_ALL. + * + * @param bool $isAdmin Whether the caller is in the admin group. + * @param array $orgUuids Active organisation UUIDs. + * + * @return MagicOrganizationHandler + */ + private function handler(bool $isAdmin, array $orgUuids): MagicOrganizationHandler { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('someone'); + + $session = $this->createMock(IUserSession::class); + $session->method('getUser')->willReturn($user); + + $groups = $this->createMock(IGroupManager::class); + $groups->method('getUserGroupIds')->willReturn($isAdmin === true ? ['admin'] : ['users']); + + $appConfig = $this->createMock(IAppConfig::class); + // No multitenancy config ⇒ SaaS mode off. + $appConfig->method('getValueString')->willReturn(''); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willThrowException(new \RuntimeException('no service')); + + $handler = $this->getMockBuilder(MagicOrganizationHandler::class) + ->setConstructorArgs([$session, $groups, $appConfig, $container, $this->createMock(LoggerInterface::class)]) + ->onlyMethods(['getActiveOrganizationUuids']) + ->getMock(); + $handler->method('getActiveOrganizationUuids')->willReturn($orgUuids); + + return $handler; + }//end handler() + + /** + * THE REGRESSION. An admin scoped to an organisation must still see rows + * that have no organisation — that is what the aggregation's flattened + * `_organisation = ?` silently dropped. + * + * @return void + */ + public function testAdminWithAnActiveOrgAlsoSeesOrgLessRows(): void { + $scope = $this->handler(true, ['org-a'])->resolveOrganizationScope(); + + $this->assertSame(MagicOrganizationHandler::SCOPE_IN_OR_NULL, $scope['mode']); + $this->assertSame(['org-a'], $scope['uuids']); + }//end testAdminWithAnActiveOrgAlsoSeesOrgLessRows() + + /** + * A non-admin is confined to their own organisation, and org-less rows + * stay invisible to them. Widening this would be a tenant leak. + * + * @return void + */ + public function testNonAdminIsConfinedToTheirOwnOrganisation(): void { + $scope = $this->handler(false, ['org-a'])->resolveOrganizationScope(); + + $this->assertSame(MagicOrganizationHandler::SCOPE_IN, $scope['mode']); + $this->assertSame(['org-a'], $scope['uuids']); + }//end testNonAdminIsConfinedToTheirOwnOrganisation() + + /** + * Several active organisations stay a set — a renderer that collapsed + * them to one would under-report the rest. + * + * @return void + */ + public function testMultipleActiveOrganisationsAreAllCarried(): void { + $scope = $this->handler(false, ['org-a', 'org-b'])->resolveOrganizationScope(); + + $this->assertSame(MagicOrganizationHandler::SCOPE_IN, $scope['mode']); + $this->assertSame(['org-a', 'org-b'], $scope['uuids']); + }//end testMultipleActiveOrganisationsAreAllCarried() + + /** + * An admin with no active organisation sees exactly the org-less rows. + * + * @return void + */ + public function testAdminWithoutAnActiveOrganisationSeesOnlyOrgLessRows(): void { + $scope = $this->handler(true, [])->resolveOrganizationScope(); + + $this->assertSame(MagicOrganizationHandler::SCOPE_NULL_ONLY, $scope['mode']); + $this->assertSame([], $scope['uuids']); + }//end testAdminWithoutAnActiveOrganisationSeesOnlyOrgLessRows() + + /** + * Fail-closed: a non-admin with no active organisation sees nothing at + * all, rather than everything. + * + * @return void + */ + public function testNonAdminWithoutAnActiveOrganisationSeesNothing(): void { + $scope = $this->handler(false, [])->resolveOrganizationScope(); + + $this->assertSame(MagicOrganizationHandler::SCOPE_NONE, $scope['mode']); + }//end testNonAdminWithoutAnActiveOrganisationSeesNothing() + + /** + * The admin bypass lifts the boundary entirely, but only when the caller + * opts into it — the default must stay scoped. + * + * @return void + */ + public function testAdminBypassIsOptInAndLiftsTheBoundary(): void { + $handler = $this->handler(true, ['org-a']); + + $this->assertSame( + MagicOrganizationHandler::SCOPE_ALL, + $handler->resolveOrganizationScope(adminBypassEnabled: true)['mode'] + ); + $this->assertSame( + MagicOrganizationHandler::SCOPE_IN_OR_NULL, + $handler->resolveOrganizationScope()['mode'], + 'the bypass must not leak into the default call' + ); + }//end testAdminBypassIsOptInAndLiftsTheBoundary() + + /** + * The bypass is for admins only. + * + * @return void + */ + public function testAdminBypassDoesNothingForANonAdmin(): void { + $scope = $this->handler(false, ['org-a'])->resolveOrganizationScope(adminBypassEnabled: true); + + $this->assertSame(MagicOrganizationHandler::SCOPE_IN, $scope['mode']); + }//end testAdminBypassDoesNothingForANonAdmin() + +}//end class diff --git a/tests/Unit/Service/Aggregation/AggregationJoinAndCompositeGroupByTest.php b/tests/Unit/Service/Aggregation/AggregationJoinAndCompositeGroupByTest.php index 38370321fb..1d718f98d8 100644 --- a/tests/Unit/Service/Aggregation/AggregationJoinAndCompositeGroupByTest.php +++ b/tests/Unit/Service/Aggregation/AggregationJoinAndCompositeGroupByTest.php @@ -36,6 +36,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use OCA\OpenRegister\Db\MagicMapper; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\Register; @@ -1729,6 +1730,7 @@ private function makeRunner(): AggregationRunner { permissionHandler: $this->permissionHandler, userSession: $this->userSession, organisationService: $this->organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $this->translationHandler, languageService: $this->languageService, ); @@ -1775,4 +1777,24 @@ private function makeRegister(string $slug, array $schemaIds = []): Register { $register->setSchemas($schemaIds); return $register; }//end makeRegister() + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class diff --git a/tests/Unit/Service/Aggregation/AggregationRegisterScopeTest.php b/tests/Unit/Service/Aggregation/AggregationRegisterScopeTest.php index b85214edb2..06ae5b83b4 100644 --- a/tests/Unit/Service/Aggregation/AggregationRegisterScopeTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRegisterScopeTest.php @@ -38,6 +38,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use OCA\OpenRegister\Db\MagicMapper; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\Register; @@ -128,6 +129,7 @@ public function __toString(): string { permissionHandler: $this->permissionHandler, userSession: $this->userSession, organisationService: $this->organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $this->createMock(TranslationHandler::class), languageService: $this->createMock(LanguageService::class) ); @@ -431,4 +433,24 @@ public function testRegisterWithAnEmptySchemaListIsRefusedWithTheRepairCommand() $this->assertStringContainsString('carries no schemas at all', $caught->getMessage()); $this->assertStringContainsString('occ openregister:registers:relink-schemas', $caught->getMessage()); }//end testRegisterWithAnEmptySchemaListIsRefusedWithTheRepairCommand() + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerAdhocCacheTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerAdhocCacheTest.php index 4ce0aab7ac..b236f0a3bf 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerAdhocCacheTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerAdhocCacheTest.php @@ -23,6 +23,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use OCA\OpenRegister\Db\MagicMapper; use OCA\OpenRegister\Db\Register; @@ -113,6 +114,7 @@ protected function setUp(): void { permissionHandler: $this->permissionHandler, userSession: $this->userSession, organisationService: $this->organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $this->translationHandler, languageService: $this->languageService, ); @@ -239,6 +241,7 @@ public function testGroupedTranslatableKeysProjectToNegotiatedLanguage(): void { permissionHandler: $this->permissionHandler, userSession: $this->userSession, organisationService: $this->organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $translationHandler, languageService: $languageService, ); @@ -323,4 +326,24 @@ private function makeRegister(): Register { return $register; }//end makeRegister() + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerCumulativeTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerCumulativeTest.php index 10bd096c67..727423753a 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerCumulativeTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerCumulativeTest.php @@ -39,6 +39,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Platforms\PostgreSQLPlatform; @@ -364,6 +365,7 @@ function () use ($cursor) { permissionHandler: $this->permissionHandler, userSession: $this->userSession, organisationService: $this->organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $this->createMock(TranslationHandler::class), languageService: $this->createMock(LanguageService::class), ); @@ -521,9 +523,30 @@ private function makeRunner(): AggregationRunner { permissionHandler: $this->permissionHandler, userSession: $this->userSession, organisationService: $this->organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $this->createMock(TranslationHandler::class), languageService: $this->createMock(LanguageService::class), ); }//end makeRunner() + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerMultiFieldGroupByTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerMultiFieldGroupByTest.php index ed21dffd08..285e7ff351 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerMultiFieldGroupByTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerMultiFieldGroupByTest.php @@ -30,6 +30,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\SqlitePlatform; use OCA\OpenRegister\Db\MagicMapper; @@ -382,6 +383,7 @@ private function makeRunner(IDBConnection $db, MagicMapper $magicMapper): Aggreg permissionHandler: $permissionHandler, userSession: $userSession, organisationService: $organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $translationHandler, languageService: $languageService, ); @@ -400,4 +402,24 @@ private function makeSchema(): Schema { $schema->setId(1); return $schema; } + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerMultiMetricTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerMultiMetricTest.php index 9aa799056e..44488496ca 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerMultiMetricTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerMultiMetricTest.php @@ -27,6 +27,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\SqlitePlatform; use OCA\OpenRegister\Db\MagicMapper; @@ -414,6 +415,7 @@ private function makeRunner(IDBConnection $db, MagicMapper $magicMapper): Aggreg permissionHandler: $permissionHandler, userSession: $userSession, organisationService: $organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $translationHandler, languageService: $languageService, ); @@ -439,4 +441,24 @@ private function makeSchema(): Schema { ); return $schema; }//end makeSchema() + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerMultiValueFilterTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerMultiValueFilterTest.php index e88db8dc6d..f96454995b 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerMultiValueFilterTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerMultiValueFilterTest.php @@ -41,6 +41,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\SqlitePlatform; use OCA\OpenRegister\Db\MagicMapper; @@ -577,6 +578,7 @@ private function makeRunner(IDBConnection $db, MagicMapper $magicMapper): Aggreg permissionHandler: $permissionHandler, userSession: $userSession, organisationService: $organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $translationHandler, languageService: $languageService, ); @@ -616,4 +618,24 @@ private function makeTagsSchema(): Schema { ); return $schema; }//end makeTagsSchema() + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerNativeBucketTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerNativeBucketTest.php index 9229744ed0..c07f42081b 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerNativeBucketTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerNativeBucketTest.php @@ -23,6 +23,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Platforms\PostgreSQLPlatform; @@ -399,9 +400,30 @@ private function makeRunner(): AggregationRunner { permissionHandler: $this->permissionHandler, userSession: $this->userSession, organisationService: $this->organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $this->createMock(TranslationHandler::class), languageService: $this->createMock(LanguageService::class), ); }//end makeRunner() + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerTest.php index d77ec5b5b8..59b3912496 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerTest.php @@ -27,6 +27,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use OCA\OpenRegister\Db\MagicMapper; use OCA\OpenRegister\Db\Register; use OCA\OpenRegister\Db\RegisterMapper; @@ -118,6 +119,7 @@ private function makeRunner(): AggregationRunner { permissionHandler: $this->permissionHandler, userSession: $this->userSession, organisationService: $this->organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $this->createMock(TranslationHandler::class), languageService: $this->createMock(LanguageService::class) ); @@ -238,4 +240,24 @@ public function testLoadSchemaUnknownRefRethrowsAsNotFoundRuntimeException(): vo $this->privateMethod($runner, 'loadSchema')->invoke($runner, 'does-not-exist'); }//end testLoadSchemaUnknownRefRethrowsAsNotFoundRuntimeException() + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class diff --git a/tests/Unit/Service/Aggregation/CrossSchemaAggregationRunnerTest.php b/tests/Unit/Service/Aggregation/CrossSchemaAggregationRunnerTest.php index db86abf063..4b59107233 100644 --- a/tests/Unit/Service/Aggregation/CrossSchemaAggregationRunnerTest.php +++ b/tests/Unit/Service/Aggregation/CrossSchemaAggregationRunnerTest.php @@ -35,6 +35,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use OCA\OpenRegister\Db\MagicMapper; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\Register; @@ -135,6 +136,7 @@ function (string|int $id, array $schemaIds): ?Schema { permissionHandler: $this->permissionHandler, userSession: $this->userSession, organisationService: $this->organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $this->createMock(TranslationHandler::class), languageService: $this->createMock(LanguageService::class), ); @@ -860,4 +862,24 @@ public function testDerivedMetricCannotReadALaterAlias(): void { ); }//end testDerivedMetricCannotReadALaterAlias() + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class From 62d1b167b975a41c97c7a73d117c43744c7071e7 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 03:26:45 +0200 Subject: [PATCH 11/19] chore(deps): take @conduction/nextcloud-vue 2.25.1 for the KPI card (#3182) 2.25.x makes the canonical KPI card flat and horizontal, so dashboards stop drawing a grey box inside the white card CnWidgetWrapper already draws, and 2.25.1 fixes the narrow-tile step-down that 2.25.0 shipped inert (a source-order bug left KPI values clipped on a three-column tile). The caret range already allowed both; only the lockfile pinned this app back. Lockfile only. --- package-lock.json | 87 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 70 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index b2c90f28e1..6bfd98e85b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2173,9 +2173,9 @@ } }, "node_modules/@conduction/nextcloud-vue": { - "version": "2.24.3", - "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.24.3.tgz", - "integrity": "sha512-Hflys+AxGNNSkDxuMf04RdFzijhWDKtcRJAHtmtYLj6ntwoIT51k0rfRju6S5GuTNF+daJDUv9tHDVVXb9ZIWw==", + "version": "2.25.1", + "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.25.1.tgz", + "integrity": "sha512-c5sY4XnHrfGUIUEatJEIT5jK78k9REj2UCqXylbHiDPkv1HpUx7Sf3XtfDxAd4xPjn/kbO094rNqShK4tPnY7w==", "license": "EUPL-1.2", "dependencies": { "@ckpack/vue-color": "^1.6.0", @@ -2239,7 +2239,7 @@ "eslint-plugin-vue": "^9.21.0 || ^10.0.0", "gridstack": "^12.0.0 || ^13.0.0", "marked": "^12.0.0", - "pinia": "^2.0.0 || ^3.0.0", + "pinia": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.5.0", "vue-eslint-parser": "^9.4.0 || ^10.0.0", "vue-material-design-icons": "^5.0.0" @@ -2562,6 +2562,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2578,6 +2579,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2594,6 +2596,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2610,6 +2613,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2626,6 +2630,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2642,6 +2647,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2658,6 +2664,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2674,6 +2681,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2690,6 +2698,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2706,6 +2715,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2722,6 +2732,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2738,6 +2749,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2754,6 +2766,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2770,6 +2783,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2786,6 +2800,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2802,6 +2817,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2818,6 +2834,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2834,6 +2851,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2850,6 +2868,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2866,6 +2885,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2882,6 +2902,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2898,6 +2919,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2914,6 +2936,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2930,6 +2953,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2946,6 +2970,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2962,6 +2987,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4434,6 +4460,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5746,6 +5773,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5759,6 +5787,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5772,6 +5801,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5785,6 +5815,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5798,6 +5829,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5811,6 +5843,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5824,6 +5857,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5837,6 +5871,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5850,6 +5885,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5863,6 +5899,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5876,6 +5913,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5889,6 +5927,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5902,6 +5941,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5915,6 +5955,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5928,6 +5969,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5941,6 +5983,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5954,6 +5997,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5967,6 +6011,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5980,6 +6025,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5993,6 +6039,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6006,6 +6053,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6019,6 +6067,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6032,6 +6081,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6045,6 +6095,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6058,6 +6109,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6651,7 +6703,7 @@ "version": "5.10.0", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", @@ -6672,7 +6724,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -7284,7 +7336,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz", "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/scope-manager": "8.68.0", @@ -7309,7 +7361,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz", "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.68.0", @@ -7331,7 +7383,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz", "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.68.0", @@ -7349,7 +7401,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz", "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7366,7 +7418,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz", "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7380,7 +7432,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz", "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/project-service": "8.68.0", @@ -7408,7 +7460,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz", "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.68.0", @@ -7426,7 +7478,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -7439,7 +7491,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7534,7 +7586,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -12998,6 +13050,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -22763,7 +22816,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18.12" From ddc7fd72403b9cd35b70b97ade2653e93ad8942e Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 04:33:03 +0200 Subject: [PATCH 12/19] feat(organisation): let an organisation be a federated counterparty, not a tenant (#3183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consuming apps want their partner organisations here, and a ketenpartner obviously IS an organisation: dossiq's partnerOrganization schema is name, slug, oin, contactEmail, groupId, isActive — every field of which Organisation already has. What stopped it was that this table is also the tenant table. Organisation's own docblock says every row "is still a full organisation and still a valid tenant", and `type` cannot carry the distinction because ADR-002 keeps it out of authorization deliberately. 🔴 THE GAP IS DESTRUCTIVE, NOT COSMETIC. Three background jobs enumerate organisations and each selects on `status` alone: TenantUsageSyncJob meters, TenantDeprovisionJob tears down, and TenantPurgeJob PERMANENTLY DELETES THE ROW. So an archived ketenpartner would be indistinguishable from an archived tenant and deleted with it, while the job reported a successful purge. This change therefore leads with tests, not code. TenantJobsScopeTest was written against the OLD behaviour and TWO of its assertions failed when the distinction landed — which is the only evidence that it pins anything. - `isLocalTenant` on Organisation, defaulting to TRUE so every existing row keeps exactly today's meaning. Unlike `type`, this one IS consulted. - `remoteInstanceUrl` — the peer instance a counterparty is a tenant of, the same value FederatedShare.remoteInstanceUrl carries, so a share and the organisation it is with resolve to each other. - `OrganisationMapper::findLocalTenants()`, used by all three jobs. A named method rather than one more filter each caller must remember, because forgetting it is what deletes a partner. 🔴 A NULL COUNTS AS A TENANT, and that detail is the whole migration. The column arrives by migration, so every pre-existing row holds NULL; a plain `= true` would have made EVERY EXISTING TENANT invisible to all three jobs at once — tenants would silently stop being deprovisioned, purged and metered, and each job would report success over an empty list. Asserted against a real database in OrganisationTenantScopeIntegrationTest, because a mocked mapper cannot show it. findAll() is deliberately NOT narrowed: an organisation list that hid counterparties would hide the ketenpartners the federation exists to work with. That is asserted too. Unblocks moving dossiq's partnerOrganization, which could not start until a counterparty could be told from a tenant. Verified: 17787 unit tests pass, PHPCS back to its 46-finding baseline on the touched files (none of them mine), PHPMD and PHPStan clean. --- lib/BackgroundJob/TenantDeprovisionJob.php | 5 +- lib/BackgroundJob/TenantPurgeJob.php | 5 +- lib/BackgroundJob/TenantUsageSyncJob.php | 5 +- lib/Db/Organisation.php | 42 +++ lib/Db/OrganisationMapper.php | 60 ++++ lib/Migration/Version1Date20260831020000.php | 136 +++++++++ .../proposal.md | 90 ++++++ .../specs/organisation-tenancy-scope/spec.md | 67 +++++ ...OrganisationTenantScopeIntegrationTest.php | 186 +++++++++++++ .../BackgroundJob/TenantJobsScopeTest.php | 260 ++++++++++++++++++ 10 files changed, 853 insertions(+), 3 deletions(-) create mode 100644 lib/Migration/Version1Date20260831020000.php create mode 100644 openspec/changes/organisation-as-federated-counterparty/proposal.md create mode 100644 openspec/changes/organisation-as-federated-counterparty/specs/organisation-tenancy-scope/spec.md create mode 100644 tests/Db/OrganisationTenantScopeIntegrationTest.php create mode 100644 tests/Unit/BackgroundJob/TenantJobsScopeTest.php diff --git a/lib/BackgroundJob/TenantDeprovisionJob.php b/lib/BackgroundJob/TenantDeprovisionJob.php index 8c850438f3..db2ce31dbd 100644 --- a/lib/BackgroundJob/TenantDeprovisionJob.php +++ b/lib/BackgroundJob/TenantDeprovisionJob.php @@ -74,7 +74,10 @@ protected function run(mixed $argument): void { $this->logger->info('[TenantDeprovisionJob] Starting deprovisioning check'); try { - $organisations = $this->organisationMapper->findAll( + // Tenant-scoped read, not findAll: a federated counterparty is an + // organisation in this table but NOT a tenant of this installation, and + // selecting on status alone would sweep it up with the tenants. + $organisations = $this->organisationMapper->findLocalTenants( filters: ['status' => TenantLifecycleService::STATUS_DEPROVISIONING] ); } catch (\Exception $e) { diff --git a/lib/BackgroundJob/TenantPurgeJob.php b/lib/BackgroundJob/TenantPurgeJob.php index d978683a15..a9880e1636 100644 --- a/lib/BackgroundJob/TenantPurgeJob.php +++ b/lib/BackgroundJob/TenantPurgeJob.php @@ -92,7 +92,10 @@ protected function run(mixed $argument): void { $cutoffDate->sub(new DateInterval("P{$retentionDays}D")); try { - $organisations = $this->organisationMapper->findAll( + // Tenant-scoped read, not findAll: a federated counterparty is an + // organisation in this table but NOT a tenant of this installation, and + // selecting on status alone would sweep it up with the tenants. + $organisations = $this->organisationMapper->findLocalTenants( filters: ['status' => TenantLifecycleService::STATUS_ARCHIVED] ); } catch (\Exception $e) { diff --git a/lib/BackgroundJob/TenantUsageSyncJob.php b/lib/BackgroundJob/TenantUsageSyncJob.php index 3406310d3c..a11b70890b 100644 --- a/lib/BackgroundJob/TenantUsageSyncJob.php +++ b/lib/BackgroundJob/TenantUsageSyncJob.php @@ -82,7 +82,10 @@ protected function run(mixed $argument): void { $this->logger->debug('[TenantUsageSyncJob] Starting usage sync'); try { - $organisations = $this->organisationMapper->findAll( + // Tenant-scoped read, not findAll: a federated counterparty is an + // organisation in this table but NOT a tenant of this installation, and + // selecting on status alone would sweep it up with the tenants. + $organisations = $this->organisationMapper->findLocalTenants( filters: ['status' => TenantLifecycleService::STATUS_ACTIVE] ); } catch (\Exception $e) { diff --git a/lib/Db/Organisation.php b/lib/Db/Organisation.php index 402320dc88..237c2eb77a 100644 --- a/lib/Db/Organisation.php +++ b/lib/Db/Organisation.php @@ -77,6 +77,10 @@ * @method void setDeprovisionedAt(?DateTime $deprovisionedAt) * @method string|null getType() * @method void setType(?string $type) + * @method bool|null getIsLocalTenant() + * @method void setIsLocalTenant(?bool $isLocalTenant) + * @method string|null getRemoteInstanceUrl() + * @method void setRemoteInstanceUrl(?string $remoteInstanceUrl) * @method string|null getSummary() * @method void setSummary(?string $summary) * @method string|null getOin() @@ -301,6 +305,40 @@ class Organisation extends Entity implements JsonSerializable { */ protected ?string $type = 'organisation'; + /** + * Whether this organisation is a tenant OF THIS INSTALLATION. + * + * 🔴 THIS ONE IS AN AUTHORIZATION AND LIFECYCLE INPUT, unlike `type` above, + * which is deliberately not. It answers a question `type` cannot: a row may + * be a perfectly real organisation — a ketenpartner, a supplier, a + * municipality we exchange cases with — and still not be a tenant here. + * + * It exists because the tenant BACKGROUND JOBS select on `status` alone, + * and one of them (TenantPurgeJob) permanently DELETES what it selects. + * Without this flag an archived counterparty is indistinguishable from an + * archived tenant, and is deleted with it. Every tenant enumeration filters + * on this; see TenantJobsScopeTest. + * + * Defaults to TRUE so every existing row keeps its present meaning. + * + * @var boolean|null True for a tenant of this installation. + */ + protected ?bool $isLocalTenant = true; + + /** + * Base URL of the OpenRegister instance this organisation is a tenant of. + * + * Set only on a counterparty — an organisation that is a tenant somewhere + * ELSE and interacts with us across the federation. It is the same value + * `FederatedShare.remoteInstanceUrl` carries, which is what lets a share + * and the organisation it is with be resolved to each other. + * + * Empty for a local tenant, which is the ordinary case. + * + * @var string|null The peer instance base URL. + */ + protected ?string $remoteInstanceUrl = null; + /** * Short summary for overview pages (OpenCatalogi `summary`). * @@ -501,6 +539,8 @@ public function __construct() { // Identity facet (ADR-022 §3): the statutory identifiers a leaf app // used to keep in its own publisher/vendor record. $this->addType(fieldName: 'type', type: 'string'); + $this->addType(fieldName: 'isLocalTenant', type: 'boolean'); + $this->addType(fieldName: 'remoteInstanceUrl', type: 'string'); $this->addType(fieldName: 'summary', type: 'string'); $this->addType(fieldName: 'oin', type: 'string'); $this->addType(fieldName: 'tooi', type: 'string'); @@ -948,6 +988,8 @@ public function jsonSerialize(): array { 'status' => $this->status ?? 'active', 'environment' => $this->environment ?? 'production', 'type' => $this->type ?? 'organisation', + 'isLocalTenant' => ($this->isLocalTenant ?? true), + 'remoteInstanceUrl' => $this->remoteInstanceUrl, 'summary' => $this->summary, 'oin' => $this->oin, 'tooi' => $this->tooi, diff --git a/lib/Db/OrganisationMapper.php b/lib/Db/OrganisationMapper.php index 263c8512d9..8be70766d3 100644 --- a/lib/Db/OrganisationMapper.php +++ b/lib/Db/OrganisationMapper.php @@ -440,6 +440,66 @@ private function validateUuid(Organisation $organisation): void { * @return array Array of matching organisations */ + /** + * Find organisations that are tenants OF THIS INSTALLATION. + * + * A named method rather than one more filter every caller must remember, + * because forgetting it is destructive: the tenant background jobs select on + * `status` alone and TenantPurgeJob PERMANENTLY DELETES what it selects, so + * a federated counterparty that happens to be archived would be deleted as + * though it were a spent tenant. The name is the guarantee, and it is + * greppable. + * + * 🔴 A NULL counts as a tenant. `is_local_tenant` was added by migration and + * every row written before it holds NULL, so a plain `= true` would make + * every pre-existing tenant invisible to all three jobs at once — tenants + * would stop being deprovisioned, purged and metered, and nothing would + * report it. Only a row explicitly marked false is excluded. + * + * @param int $limit Maximum number of results. + * @param int $offset Number of results to skip. + * @param array $filters Column => value equality filters. + * + * @return Organisation[] + * + * @psalm-return list<\OCA\OpenRegister\Db\Organisation> + * + * @spec openspec/changes/organisation-as-federated-counterparty/specs/organisation-tenancy-scope/spec.md + */ + public function findLocalTenants(int $limit = 50, int $offset = 0, ?array $filters = []): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->orderBy('name', 'ASC') + ->setMaxResults($limit) + ->setFirstResult($offset); + + $qb->andWhere( + $qb->expr()->orX( + $qb->expr()->isNull('is_local_tenant'), + $qb->expr()->eq('is_local_tenant', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)) + ) + ); + + foreach ($filters ?? [] as $filter => $value) { + if ($value === 'IS NOT NULL') { + $qb->andWhere($qb->expr()->isNotNull($filter)); + continue; + } + + if ($value === 'IS NULL') { + $qb->andWhere($qb->expr()->isNull($filter)); + continue; + } + + $qb->andWhere($qb->expr()->eq($filter, $qb->createNamedParameter($value))); + } + + return $this->findEntities(query: $qb); + + }//end findLocalTenants() + /** * Find all organisations with pagination and optional column filters * diff --git a/lib/Migration/Version1Date20260831020000.php b/lib/Migration/Version1Date20260831020000.php new file mode 100644 index 0000000000..f319fb20fb --- /dev/null +++ b/lib/Migration/Version1Date20260831020000.php @@ -0,0 +1,136 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * Give an organisation a way to say it is NOT a tenant of this installation. + * + * `Organisation` already carried a `type` discriminator, and that one is + * deliberately NOT an authorization input (ADR-002 Rule 1: the UUID is the only + * tenant key). So there was no field anything could safely consult to tell a + * tenant from a counterparty — an organisation that is a tenant somewhere ELSE + * and interacts with us across the federation. + * + * 🔴 That gap is destructive, not cosmetic. The three tenant background jobs + * select organisations by `status` alone, and TenantPurgeJob PERMANENTLY + * DELETES what it selects. An archived ketenpartner in the same table is + * indistinguishable from an archived tenant and is deleted with it. + * + * `is_local_tenant` defaults to TRUE so every existing row keeps exactly the + * meaning it has today; only rows explicitly marked otherwise change behaviour. + * + * @category Migration + * @package OCA\OpenRegister\Migration + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/specs/tenant-lifecycle/spec.md#requirement-database-migration-must-add-lifecycle-fields-to-organisation-entity + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use Doctrine\DBAL\Types\Types; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds the counterparty discriminator and its federation link. + * + * @spec openspec/specs/tenant-lifecycle/spec.md#requirement-database-migration-must-add-lifecycle-fields-to-organisation-entity + */ +class Version1Date20260831020000 extends SimpleMigrationStep { + + /** + * The table this migration changes. + */ + private const TABLE = 'openregister_organisations'; + + /** + * The columns this migration adds. + * + * @return array}> The specifications. + */ + private function columnSpecifications(): array { + return [ + [ + 'name' => 'is_local_tenant', + 'type' => Types::BOOLEAN, + 'options' => [ + 'notnull' => false, + 'default' => true, + 'comment' => 'True when this organisation is a tenant of THIS installation; ' + . 'false for a federated counterparty. Unlike `type`, this IS ' + . 'consulted by tenancy and by the purge job.', + ], + ], + [ + 'name' => 'remote_instance_url', + 'type' => Types::STRING, + 'options' => [ + 'notnull' => false, + 'length' => 512, + 'comment' => 'Base URL of the OpenRegister instance a counterparty is a ' + . 'tenant of; matches FederatedShare.remote_instance_url. ' + . 'Empty for a local tenant.', + ], + ], + ]; + + }//end columnSpecifications() + + /** + * Add the columns. + * + * @param IOutput $output Migration output. + * @param Closure $schemaClosure The schema closure. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The changed schema, or null when nothing changed. + * + * @spec openspec/changes/organisation-as-federated-counterparty/specs/organisation-tenancy-scope/spec.md + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /* @var ISchemaWrapper $schema The schema wrapper. */ + $schema = $schemaClosure(); + + // This class sorts AFTER the one that creates the table, so the guard is + // the ordinary "already ran" check and not a permanent skip — the trap + // Version1Date20250102000000 fell into and documents. + if ($schema->hasTable(self::TABLE) === false) { + $output->warning(message: 'openregister_organisations is absent; skipping the counterparty columns'); + + return null; + } + + $table = $schema->getTable(self::TABLE); + $added = []; + + foreach ($this->columnSpecifications() as $column) { + if ($table->hasColumn($column['name']) === true) { + continue; + } + + $table->addColumn($column['name'], $column['type'], $column['options']); + $added[] = $column['name']; + } + + if ($added === []) { + return null; + } + + $output->info(message: 'openregister_organisations: added ' . implode(', ', $added)); + + return $schema; + + }//end changeSchema() + +}//end class diff --git a/openspec/changes/organisation-as-federated-counterparty/proposal.md b/openspec/changes/organisation-as-federated-counterparty/proposal.md new file mode 100644 index 0000000000..c92e68e8d3 --- /dev/null +++ b/openspec/changes/organisation-as-federated-counterparty/proposal.md @@ -0,0 +1,90 @@ +--- +kind: code +--- + +# Proposal: organisation-as-federated-counterparty + +## Summary + +Let an `Organisation` say that it is **not** a tenant of this installation — that +it is a counterparty, a tenant somewhere else, reachable across the federation. +Then make every tenant enumeration consult that, so the tenant background jobs +stop being able to see one. + +## Motivation + +Consuming apps want to put their partner organisations here. dossiq carries a +`partnerOrganization` schema — name, slug, oin, contactEmail, groupId, isActive +— every field of which `Organisation` already has, plus a `type` discriminator +whose values include `collaboration` and `vendor`. A ketenpartner obviously IS +an organisation. + +The reason it cannot simply move is that this table is also the tenant table. +`Organisation`'s own docblock says every row "is still a full organisation and +still a valid tenant", and ADR-002 Rule 1 makes the organisation UUID the only +tenant key. `type` cannot carry the distinction: it is deliberately NOT an +authorization input. + +## 🔴 The gap is destructive, not cosmetic + +Three background jobs enumerate organisations, and each selects on `status` +alone: + +| Job | Selects | Does | +|---|---|---| +| `TenantUsageSyncJob` | `status = active` | meters usage | +| `TenantDeprovisionJob` | `status = deprovisioning` | tears the tenant down | +| `TenantPurgeJob` | `status = archived` | **permanently deletes the row** | + +So the moment ketenpartners live in this table, an archived partner is +indistinguishable from an archived tenant and is deleted with it. Nothing +throws; the job reports a successful purge. + +That is why this change leads with tests. `TenantJobsScopeTest` was written +against the OLD behaviour first and two of its assertions failed when the +distinction landed, which is the only evidence that it pins anything. + +## Scope + +### In Scope + +1. **`isLocalTenant`** on `Organisation`, defaulting to TRUE so every existing + row keeps exactly the meaning it has today. Unlike `type`, this one IS + consulted by tenancy. +2. **`remoteInstanceUrl`** — the peer OpenRegister base URL a counterparty is a + tenant of. It is the same value `FederatedShare.remoteInstanceUrl` carries, + which is what lets a share and the organisation it is with resolve to each + other. +3. **`OrganisationMapper::findLocalTenants()`** — a named method whose contract + is the guarantee, used by all three jobs. + +### Out of Scope + +- **Moving dossiq's `partnerOrganization`.** That is dossiq's change, and it can + only start once this exists. +- **Narrowing `findAll()`.** The admin organisation list must keep showing + counterparties — they are the ketenpartners the federation exists to work + with. + +## The NULL that would have broken every tenant + +`is_local_tenant` arrives by migration, so every row written before it holds +NULL. A plain `is_local_tenant = true` filter would therefore make **every +pre-existing tenant invisible to all three jobs at once** — tenants would +silently stop being deprovisioned, purged and metered, and each job would report +success over an empty list. + +`findLocalTenants()` treats NULL as a tenant. Only a row explicitly marked false +is excluded, and `OrganisationTenantScopeIntegrationTest` asserts exactly that +against a real database, because a mocked mapper cannot show it. + +## Risks + +- 🔴 **A named method can be forgotten.** The next job someone writes could call + `findAll()`. Mitigated by the name being the contract and greppable, and by + the job tests asserting the tenant-scoped path is the one used. A stronger + guard — making `findAll()` itself default to local tenants — was rejected + because it would silently hide counterparties from the admin surface. +- ⚠️ **`isLocalTenant` is an authorization-adjacent input and `type` is not.** + Two discriminators on one entity invites confusion; each one's docblock says + which question it answers and which it does not. diff --git a/openspec/changes/organisation-as-federated-counterparty/specs/organisation-tenancy-scope/spec.md b/openspec/changes/organisation-as-federated-counterparty/specs/organisation-tenancy-scope/spec.md new file mode 100644 index 0000000000..d4736dcc74 --- /dev/null +++ b/openspec/changes/organisation-as-federated-counterparty/specs/organisation-tenancy-scope/spec.md @@ -0,0 +1,67 @@ +# organisation-tenancy-scope Specification + +## Purpose + +Distinguish an organisation that is a tenant of THIS installation from one that +is a counterparty — a tenant elsewhere, reachable across the federation — and +make every tenant enumeration honour the distinction. + +## ADDED Requirements + +### Requirement: REQ-OTS-001 An organisation states whether it is a local tenant + +`Organisation` SHALL carry `isLocalTenant`, defaulting to true, and +`remoteInstanceUrl` naming the peer instance a counterparty belongs to. + +`isLocalTenant` IS an authorization and lifecycle input, unlike `type`, which +ADR-002 keeps out of that role deliberately. + +#### Scenario: Existing rows keep their meaning + +- **GIVEN** an organisation stored before the column existed +- **WHEN** it is read +- **THEN** it is treated as a tenant of this installation + +#### Scenario: A counterparty carries its peer instance + +- **GIVEN** an organisation marked `isLocalTenant: false` with a + `remoteInstanceUrl` +- **WHEN** it is read back +- **THEN** both values round-trip + +### Requirement: REQ-OTS-002 Tenant enumeration excludes counterparties + +`OrganisationMapper` SHALL expose `findLocalTenants()`, which returns only +organisations that are tenants of this installation, and the tenant background +jobs SHALL read through it. + +A NULL `isLocalTenant` SHALL count as a tenant. The column arrives by migration, +so a plain equality filter would make every pre-existing tenant invisible to +every tenant job at once — and each would report success over an empty list. + +#### Scenario: A counterparty is not a tenant + +- **GIVEN** a tenant, a pre-migration row, and a counterparty, all active +- **WHEN** `findLocalTenants()` is asked for active organisations +- **THEN** the tenant and the pre-migration row are returned and the counterparty is not + +#### Scenario: The purge job cannot see a counterparty + +- **GIVEN** the purge job +- **WHEN** it selects archived organisations +- **THEN** it reads through the tenant-scoped path, so a counterparty is never + among the rows it permanently deletes + +### Requirement: REQ-OTS-003 The general listing still shows counterparties + +`findAll()` SHALL keep returning every organisation regardless of tenancy. + +The narrowing is confined to the tenant path on purpose: an organisation list +that hid counterparties would hide the ketenpartners the federation exists to +work with. + +#### Scenario: A counterparty appears in the general listing + +- **GIVEN** a counterparty organisation +- **WHEN** `findAll()` is called +- **THEN** it is returned diff --git a/tests/Db/OrganisationTenantScopeIntegrationTest.php b/tests/Db/OrganisationTenantScopeIntegrationTest.php new file mode 100644 index 0000000000..5fa8aa5a58 --- /dev/null +++ b/tests/Db/OrganisationTenantScopeIntegrationTest.php @@ -0,0 +1,186 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace Db; + +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Db\OrganisationMapper; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Uid\Uuid; + +/** + * What `findLocalTenants()` actually returns, against a real database. + * + * The job-level tests mock the mapper, so they prove the jobs ASK the right + * question. This proves the query ANSWERS it — and in particular the one thing + * a mock cannot show: + * + * 🔴 A NULL `is_local_tenant` must count as a tenant. The column arrives by + * migration, so every row written before it holds NULL. A plain `= true` would + * make every pre-existing tenant invisible to all three tenant jobs at once — + * tenants would silently stop being deprovisioned, purged and metered, and the + * jobs would report success over an empty list. + * + * @group DB + */ +class OrganisationTenantScopeIntegrationTest extends TestCase { + + /** + * The mapper under test. + * + * @var OrganisationMapper + */ + private OrganisationMapper $mapper; + + /** + * Uuids created by this test, dropped in tearDown. + * + * @var array + */ + private array $created = []; + + /** + * Resolve the mapper from the server container. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + $this->mapper = \OC::$server->get(OrganisationMapper::class); + + }//end setUp() + + /** + * Remove everything this test created. + * + * @return void + */ + protected function tearDown(): void { + foreach ($this->created as $uuid) { + try { + $this->mapper->delete($this->mapper->findByUuid($uuid)); + } catch (\Throwable $e) { + // Already gone, or never created; nothing to clean. + } + } + + parent::tearDown(); + + }//end tearDown() + + /** + * Store an organisation. + * + * @param string $name Its name. + * @param boolean|null $isLocal The tenancy flag, or null to leave it unset. + * + * @return string The uuid. + */ + private function make(string $name, ?bool $isLocal): string { + $uuid = (string)Uuid::v4(); + + $org = new Organisation(); + $org->setUuid($uuid); + $org->setName($name . '-' . substr($uuid, 0, 8)); + $org->setSlug('scope-' . substr($uuid, 0, 8)); + $org->setStatus('active'); + if ($isLocal !== null) { + $org->setIsLocalTenant($isLocal); + } + + $this->mapper->insert($org); + $this->created[] = $uuid; + + return $uuid; + + }//end make() + + /** + * The uuids findLocalTenants returns, across pages. + * + * @return array The uuids. + */ + private function tenantUuids(): array { + $out = []; + foreach ($this->mapper->findLocalTenants(limit: 500, filters: ['status' => 'active']) as $org) { + $out[] = (string)$org->getUuid(); + } + + return $out; + + }//end tenantUuids() + + /** + * A counterparty is excluded; a tenant and a pre-migration row are not. + * + * @return void + */ + public function testACounterpartyIsExcludedAndANullIsNot(): void { + $tenant = $this->make('tenant', true); + $legacy = $this->make('legacy', null); + $partner = $this->make('partner', false); + + $found = $this->tenantUuids(); + + $this->assertContains($tenant, $found, 'an explicit tenant is a tenant'); + $this->assertContains( + $legacy, + $found, + 'a row predating the column must still count as a tenant, or every existing tenant silently stops being processed' + ); + $this->assertNotContains($partner, $found, 'a counterparty is not a tenant of this installation'); + + }//end testACounterpartyIsExcludedAndANullIsNot() + + /** + * findAll still returns everything, so the admin surface is unchanged. + * + * The narrowing is deliberately confined to the tenant path: an + * organisation list that hid counterparties would hide the ketenpartners + * the federation exists to work with. + * + * @return void + */ + public function testFindAllStillReturnsCounterparties(): void { + $partner = $this->make('partner', false); + + $found = []; + foreach ($this->mapper->findAll(limit: 500, filters: ['status' => 'active']) as $org) { + $found[] = (string)$org->getUuid(); + } + + $this->assertContains($partner, $found); + + }//end testFindAllStillReturnsCounterparties() + + /** + * A counterparty carries the peer instance it is a tenant of. + * + * @return void + */ + public function testACounterpartyCarriesItsRemoteInstance(): void { + $uuid = (string)Uuid::v4(); + $org = new Organisation(); + $org->setUuid($uuid); + $org->setName('Gemeente Elders-' . substr($uuid, 0, 8)); + $org->setSlug('elders-' . substr($uuid, 0, 8)); + $org->setStatus('active'); + $org->setIsLocalTenant(false); + $org->setRemoteInstanceUrl('https://fed2.example'); + $this->mapper->insert($org); + $this->created[] = $uuid; + + $stored = $this->mapper->findByUuid($uuid); + + $this->assertFalse($stored->getIsLocalTenant()); + $this->assertSame('https://fed2.example', $stored->getRemoteInstanceUrl()); + + }//end testACounterpartyCarriesItsRemoteInstance() + +}//end class diff --git a/tests/Unit/BackgroundJob/TenantJobsScopeTest.php b/tests/Unit/BackgroundJob/TenantJobsScopeTest.php new file mode 100644 index 0000000000..c5d41b1f07 --- /dev/null +++ b/tests/Unit/BackgroundJob/TenantJobsScopeTest.php @@ -0,0 +1,260 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace Unit\BackgroundJob; + +use DateTime; +use OCA\OpenRegister\BackgroundJob\TenantDeprovisionJob; +use OCA\OpenRegister\BackgroundJob\TenantPurgeJob; +use OCA\OpenRegister\BackgroundJob\TenantUsageSyncJob; +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Db\OrganisationMapper; +use OCA\OpenRegister\Db\TenantUsageMapper; +use OCA\OpenRegister\Service\TenantLifecycleService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IAppConfig; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use ReflectionMethod; + +/** + * What the three tenant background jobs consider a tenant. + * + * PINNING TESTS, written before the behaviour changes. They exist because a + * scoping mistake here does not throw: `TenantPurgeJob` PERMANENTLY DELETES the + * organisation entity, and it selects rows by `status` alone. Nothing in + * `Organisation` distinguishes a tenant of THIS installation from a + * counterparty that is a tenant of another one — so once ketenpartners live in + * the same table, an archived partner is indistinguishable from an archived + * tenant and gets deleted with it. + * + * They were written against the OLD behaviour first, and two of them FAILED + * when the distinction landed — which is the point of writing them first. They + * now assert the guarantee instead: the jobs read through + * `findLocalTenants()`, whose name is the contract, and a counterparty is never + * among the rows they act on. + */ +class TenantJobsScopeTest extends TestCase { + + /** + * Build an organisation. + * + * @param string $uuid Its uuid. + * @param string $status Its lifecycle status. + * @param string|null $deprovisionedAt When it was deprovisioned. + * + * @return Organisation The organisation. + */ + private function organisation(string $uuid, string $status, ?string $deprovisionedAt = null): Organisation { + $org = new Organisation(); + $org->setUuid($uuid); + $org->setStatus($status); + $org->setName('Org ' . $uuid); + if ($deprovisionedAt !== null) { + $org->setDeprovisionedAt(new DateTime($deprovisionedAt)); + } + + return $org; + + }//end organisation() + + /** + * Invoke a job's protected run(). + * + * @param object $job The job. + * + * @return void + */ + private function runJob(object $job): void { + $run = new ReflectionMethod($job, 'run'); + $run->setAccessible(true); + $run->invoke($job, null); + + }//end runJob() + + /** + * The purge job reads through the tenant-scoped path. + * + * It still filters by status — that part is unchanged — but it can no longer + * see a counterparty at all, because `findLocalTenants()` excludes one + * before the status filter is applied. The mapper is mocked here, so what + * this pins is that the job ASKS the right question; that the query answers + * it correctly is pinned by OrganisationMapperTenantScopeTest. + * + * @return void + */ + public function testPurgeReadsThroughTheTenantScopedPath(): void { + $seenFilters = []; + $deleted = []; + + $mapper = $this->createMock(OrganisationMapper::class); + $mapper->method('findLocalTenants')->willReturnCallback( + function (int $limit = 50, int $offset = 0, ?array $filters = []) use (&$seenFilters): array { + $seenFilters = ($filters ?? []); + + return [$this->organisation('partner-1', 'archived', '2020-01-01')]; + } + ); + $mapper->method('delete')->willReturnCallback( + static function (Organisation $org) use (&$deleted): Organisation { + $deleted[] = $org->getUuid(); + + return $org; + } + ); + + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueString')->willReturn('30'); + + $job = new TenantPurgeJob( + $this->createMock(ITimeFactory::class), + $mapper, + $this->createMock(TenantUsageMapper::class), + $appConfig, + $this->createMock(LoggerInterface::class), + ); + + $this->runJob($job); + + $this->assertSame(['status' => TenantLifecycleService::STATUS_ARCHIVED], $seenFilters); + $this->assertSame(['partner-1'], $deleted, 'a LOCAL TENANT that is archived is still purged'); + + }//end testPurgeReadsThroughTheTenantScopedPath() + + /** + * The purge job spares a row that has not been deprovisioned. + * + * @return void + */ + public function testPurgeSparesARowWithNoDeprovisionedAt(): void { + $deleted = []; + $mapper = $this->createMock(OrganisationMapper::class); + $mapper->method('findLocalTenants')->willReturn([$this->organisation('org-1', 'archived')]); + $mapper->method('delete')->willReturnCallback( + static function (Organisation $org) use (&$deleted): Organisation { + $deleted[] = $org->getUuid(); + + return $org; + } + ); + + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueString')->willReturn('30'); + + $this->runJob( + new TenantPurgeJob( + $this->createMock(ITimeFactory::class), + $mapper, + $this->createMock(TenantUsageMapper::class), + $appConfig, + $this->createMock(LoggerInterface::class), + ) + ); + + $this->assertSame([], $deleted); + + }//end testPurgeSparesARowWithNoDeprovisionedAt() + + /** + * The purge job spares a row still inside the retention window. + * + * @return void + */ + public function testPurgeSparesARowInsideRetention(): void { + $deleted = []; + $mapper = $this->createMock(OrganisationMapper::class); + $mapper->method('findLocalTenants')->willReturn( + [$this->organisation('org-1', 'archived', (new DateTime())->format('c'))] + ); + $mapper->method('delete')->willReturnCallback( + static function (Organisation $org) use (&$deleted): Organisation { + $deleted[] = $org->getUuid(); + + return $org; + } + ); + + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueString')->willReturn('30'); + + $this->runJob( + new TenantPurgeJob( + $this->createMock(ITimeFactory::class), + $mapper, + $this->createMock(TenantUsageMapper::class), + $appConfig, + $this->createMock(LoggerInterface::class), + ) + ); + + $this->assertSame([], $deleted); + + }//end testPurgeSparesARowInsideRetention() + + /** + * The deprovision job reads through the tenant-scoped path too. + * + * @return void + */ + public function testDeprovisionReadsThroughTheTenantScopedPath(): void { + $seenFilters = []; + $mapper = $this->createMock(OrganisationMapper::class); + $mapper->method('findLocalTenants')->willReturnCallback( + function (int $limit = 50, int $offset = 0, ?array $filters = []) use (&$seenFilters): array { + $seenFilters = ($filters ?? []); + + return []; + } + ); + + $this->runJob( + new TenantDeprovisionJob( + $this->createMock(ITimeFactory::class), + $mapper, + $this->createMock(TenantLifecycleService::class), + $this->createMock(LoggerInterface::class), + ) + ); + + $this->assertSame(['status' => TenantLifecycleService::STATUS_DEPROVISIONING], $seenFilters); + + }//end testDeprovisionReadsThroughTheTenantScopedPath() + + /** + * The usage-sync job does nothing at all without APCu. + * + * Pinned as the honest current behaviour rather than asserting a filter the + * test environment cannot reach: the job returns before it queries anything + * when `apcu_enabled()` is false, which it is here. Asserting the filter + * would have been a test that passes because the code never ran. + * + * @return void + */ + public function testUsageSyncDoesNothingWithoutApcu(): void { + if (function_exists('apcu_enabled') === true && apcu_enabled() === true) { + $this->markTestSkipped('APCu is enabled here, so the early return under test cannot be observed.'); + } + + $mapper = $this->createMock(OrganisationMapper::class); + $mapper->expects($this->never())->method('findLocalTenants'); + + $this->runJob( + new TenantUsageSyncJob( + $this->createMock(ITimeFactory::class), + $mapper, + $this->createMock(TenantUsageMapper::class), + $this->createMock(LoggerInterface::class), + ) + ); + + $this->addToAssertionCount(1); + + }//end testUsageSyncDoesNothingWithoutApcu() + +}//end class From 189d80ca4bab42397031b35068315d71a622aa54 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:53:24 +0200 Subject: [PATCH 13/19] chore(deps): bump minishlink/web-push from 9.0.4 to 11.0.0 (#3160) Bumps [minishlink/web-push](https://github.com/web-push-libs/web-push-php) from 9.0.4 to 11.0.0. - [Release notes](https://github.com/web-push-libs/web-push-php/releases) - [Commits](https://github.com/web-push-libs/web-push-php/compare/v9.0.4...v11.0.0) --- updated-dependencies: - dependency-name: minishlink/web-push dependency-version: 11.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> --- composer.json | 2 +- composer.lock | 68 ++++++++++++++++++++++++++++++--------------------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/composer.json b/composer.json index eb4da314e8..d1c459351f 100644 --- a/composer.json +++ b/composer.json @@ -109,7 +109,7 @@ "elasticsearch/elasticsearch": "^v9.5.0", "guzzlehttp/guzzle": "^7.0", "jwadhams/json-logic-php": "^1.5", - "minishlink/web-push": "^9.0", + "minishlink/web-push": "^11.0", "opis/json-schema": "^2.3", "phpoffice/phpspreadsheet": "^5.0", "phpoffice/phpword": "^1.2", diff --git a/composer.lock b/composer.lock index d9608d8930..a9192b0f00 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "330f182fcbbc729650e13937a5c12eab", + "content-hash": "e8d1e40030308144ae31c190012f7581", "packages": [ { "name": "adbario/php-dot-notation", @@ -119,16 +119,16 @@ }, { "name": "brick/math", - "version": "0.19.1", + "version": "0.20.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce" + "reference": "44d7e16e2e940d9c7dde7076598e25855026dba2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce", - "reference": "a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce", + "url": "https://api.github.com/repos/brick/math/zipball/44d7e16e2e940d9c7dde7076598e25855026dba2", + "reference": "44d7e16e2e940d9c7dde7076598e25855026dba2", "shasum": "" }, "require": { @@ -166,7 +166,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.19.1" + "source": "https://github.com/brick/math/tree/0.20.0" }, "funding": [ { @@ -174,7 +174,7 @@ "type": "github" } ], - "time": "2026-08-08T23:03:16+00:00" + "time": "2026-08-28T20:53:19+00:00" }, { "name": "composer/pcre", @@ -1445,16 +1445,16 @@ }, { "name": "minishlink/web-push", - "version": "v9.0.4", + "version": "v11.0.0", "source": { "type": "git", "url": "https://github.com/web-push-libs/web-push-php.git", - "reference": "f979f40b0017d2f86d82b9f21edbc515d031cc23" + "reference": "f8410afb73486ab895bb4792b879bdca62025b47" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/f979f40b0017d2f86d82b9f21edbc515d031cc23", - "reference": "f979f40b0017d2f86d82b9f21edbc515d031cc23", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/f8410afb73486ab895bb4792b879bdca62025b47", + "reference": "f8410afb73486ab895bb4792b879bdca62025b47", "shasum": "" }, "require": { @@ -1462,21 +1462,33 @@ "ext-json": "*", "ext-mbstring": "*", "ext-openssl": "*", - "guzzlehttp/guzzle": "^7.9.2", - "php": ">=8.1", + "php": ">=8.2", + "php-http/discovery": "^1.19", + "php-http/httplug": "^2.4", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1|^2.0", + "psr/log": "^2.0|^3.0", "spomky-labs/base64url": "^2.0.4", - "symfony/polyfill-php82": "^v1.31.0", - "web-token/jwt-library": "^3.3.0|^4.0.0" + "symfony/polyfill-php83": "^1.33", + "web-token/jwt-library": "^3.4.9|^4.0.6" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^v3.91.3", - "phpstan/phpstan": "^2.1.2", - "phpunit/phpunit": "^10.5.44|^11.5.6", + "friendsofphp/php-cs-fixer": "^v3.92.2", + "guzzlehttp/guzzle": "^7.9.2", + "guzzlehttp/psr7": "^2.7", + "php-http/guzzle7-adapter": "^1.1", + "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.46|^12.5.2", "symfony/polyfill-iconv": "^1.33" }, "suggest": { "ext-bcmath": "Optional for performance.", - "ext-gmp": "Optional for performance." + "ext-gmp": "Optional for performance.", + "php-http/guzzle7-adapter": "Enables concurrent sending via WebPush::flushPooled() if you use Guzzle." }, "type": "library", "autoload": { @@ -1506,9 +1518,9 @@ ], "support": { "issues": "https://github.com/web-push-libs/web-push-php/issues", - "source": "https://github.com/web-push-libs/web-push-php/tree/v9.0.4" + "source": "https://github.com/web-push-libs/web-push-php/tree/v11.0.0" }, - "time": "2025-12-10T14:00:12+00:00" + "time": "2026-07-23T16:10:05+00:00" }, { "name": "nyholm/psr7", @@ -6002,20 +6014,20 @@ }, { "name": "web-token/jwt-library", - "version": "4.2.1", + "version": "4.2.2", "source": { "type": "git", "url": "https://github.com/web-token/jwt-library.git", - "reference": "43f7c7dd6799e3bfeb05276af180be3f9362759e" + "reference": "ae642340ee2ca91ca0c37edd72a9d06302651ca9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-token/jwt-library/zipball/43f7c7dd6799e3bfeb05276af180be3f9362759e", - "reference": "43f7c7dd6799e3bfeb05276af180be3f9362759e", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/ae642340ee2ca91ca0c37edd72a9d06302651ca9", + "reference": "ae642340ee2ca91ca0c37edd72a9d06302651ca9", "shasum": "" }, "require": { - "brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19", + "brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20", "php": ">=8.2", "psr/clock": "^1.0", "spomky-labs/pki-framework": "^1.2.1", @@ -6076,7 +6088,7 @@ ], "support": { "issues": "https://github.com/web-token/jwt-library/issues", - "source": "https://github.com/web-token/jwt-library/tree/4.2.1" + "source": "https://github.com/web-token/jwt-library/tree/4.2.2" }, "funding": [ { @@ -6088,7 +6100,7 @@ "type": "patreon" } ], - "time": "2026-08-28T12:44:34+00:00" + "time": "2026-08-30T13:53:36+00:00" }, { "name": "webonyx/graphql-php", From 726a1b63560274e9f78ce2246f24882688f0dee6 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 05:54:00 +0200 Subject: [PATCH 14/19] feat(dmn): give the fleet one decision-table evaluator (#3186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-065 Decision 6 states this and states why it is not a matter of taste: the fleet built decision tables TWICE, independently, six weeks apart, and neither implementation knew the other existed. openbuild DecisionTableEvaluator 422 lines any collect first PRIORITY unique 2026-06-05 dossiq Service/Dmn/* ~1,070 lines collect first unique 2026-07-15 The newer one is the LESS capable. dossiq's archived change references OpenRegister only as object storage and never mentions openbuild's evaluator, which had been shipping for six weeks. Measured again before writing anything: OpenRegister still had no decision-table service, so nothing had changed. The ADR warned this "must reconcile two dialects rather than simply relocate one". Three places where that was the actual work: THE GRAMMAR IS MOVED, NOT REWRITTEN. UnaryTestEvaluator is dossiq's ExpressionEvaluator, ported verbatim apart from namespace and name. It is the stronger dialect — typed coercion, inclusive and exclusive ranges, set membership, and the quoted-literal escape that lets a rule match a literal "-" instead of reading it as the wildcard. Retyping it would have been a third implementation. Renamed because it does not evaluate expressions in the general sense; it decides whether a value satisfies one unary test, and the old name invited someone to reach for it as a general evaluator. PRIORITY IS OPENBUILD'S, and leaving it out would have made this a capability REGRESSION dressed as a consolidation — openbuild's tables can already use it. The hit-policy list is the union of the two, not the intersection. ANY MEANT DIFFERENT THINGS, AND NEITHER WAS DMN'S. openbuild treated it as `collect` and returned a LIST: a different answer of a different shape. DMN says a table declaring ANY asserts its overlapping rules produce the same output, so a disagreement is a fault in the table. Implemented that way, and the test fails if it degrades to `first`. Retiring either app's copy is deliberately out of scope. Each adopts this in its own change with parity tests over its own shipped tables — openbuild's `any` tables in particular change shape. Deleting a working evaluator on the strength of a new one that has not yet run its data would be the same mistake in the other direction. One thing worth recording: porting the grammar file initially made phpmd fire ExcessiveClassComplexity on code nobody had changed a line of. The cause was that the new class docblock was inserted ABOVE the original, detaching its @SuppressWarnings from the class. The tag is now folded into the single docblock, with a note saying why. Verified: 17800 unit tests pass, PHPCS/PHPMD/PHPStan clean on all three files. Mutation-checked: dropping PRIORITY, degrading ANY to FIRST, and making the priority tie non-deterministic each turn the suite red. --- .../Dmn/DecisionEvaluationException.php | 73 ++++ lib/Service/Dmn/DecisionTableEvaluator.php | 397 +++++++++++++++++ lib/Service/Dmn/UnaryTestEvaluator.php | 408 ++++++++++++++++++ .../proposal.md | 85 ++++ .../specs/shared-decision-tables/spec.md | 91 ++++ .../shared-decision-table-evaluator/tasks.md | 30 ++ .../Dmn/DecisionTableEvaluatorTest.php | 330 ++++++++++++++ 7 files changed, 1414 insertions(+) create mode 100644 lib/Service/Dmn/DecisionEvaluationException.php create mode 100644 lib/Service/Dmn/DecisionTableEvaluator.php create mode 100644 lib/Service/Dmn/UnaryTestEvaluator.php create mode 100644 openspec/changes/shared-decision-table-evaluator/proposal.md create mode 100644 openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md create mode 100644 openspec/changes/shared-decision-table-evaluator/tasks.md create mode 100644 tests/Unit/Service/Dmn/DecisionTableEvaluatorTest.php diff --git a/lib/Service/Dmn/DecisionEvaluationException.php b/lib/Service/Dmn/DecisionEvaluationException.php new file mode 100644 index 0000000000..9636ab379c --- /dev/null +++ b/lib/Service/Dmn/DecisionEvaluationException.php @@ -0,0 +1,73 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @version GIT: + * + * @link https://conduction.nl + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Dmn; + +use RuntimeException; + +/** + * Typed evaluation failure with a stable machine-readable error code. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ +class DecisionEvaluationException extends RuntimeException { + /** + * Constructor. + * + * @param string $errorCode Stable machine-readable error code. + * @param array $details Optional structured details (e.g. offending key/expression). + */ + public function __construct( + private readonly string $errorCode, + private readonly array $details = [], + ) { + parent::__construct(message: $errorCode); + }//end __construct() + + /** + * The stable error code. + * + * @return string + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + public function getErrorCode(): string { + return $this->errorCode; + }//end getErrorCode() + + /** + * Structured details for logging/debugging (never shown raw to end users). + * + * @return array + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + public function getDetails(): array { + return $this->details; + }//end getDetails() +}//end class diff --git a/lib/Service/Dmn/DecisionTableEvaluator.php b/lib/Service/Dmn/DecisionTableEvaluator.php new file mode 100644 index 0000000000..e7633a7645 --- /dev/null +++ b/lib/Service/Dmn/DecisionTableEvaluator.php @@ -0,0 +1,397 @@ + outputs. Never silently defaults: every + * ambiguous or invalid situation surfaces as a typed + * {@see DecisionEvaluationException}. + * + * @category Service + * @package OCA\OpenRegister\Service\Dmn + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @version GIT: + * + * @link https://conduction.nl + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Dmn; + +/** + * Evaluates a decisionTable definition against a runtime inputs map. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ +class DecisionTableEvaluator { + + /** + * Hit policies fully implemented by this engine. + * + * @var string[] + */ + /** + * The hit policies this evaluator implements. + * + * PRIORITY is here because openbuild's evaluator had it and dossiq's did + * not. ADR-065 names that explicitly: consolidating without it would be a + * capability REGRESSION dressed up as a consolidation, since openbuild's + * tables can already use it. This list is the union of the two, not the + * intersection. + * + * @var array + */ + private const IMPLEMENTED_HIT_POLICIES = ['UNIQUE', 'FIRST', 'COLLECT', 'PRIORITY', 'ANY']; + + /** + * Constructor. + * + * The evaluator is a pure, stateless collaborator; the default keeps the + * engine directly constructible (`new DecisionEngine()`) while the + * Nextcloud container autowires the concrete class when resolved via DI. + * + * @param UnaryTestEvaluator $evaluator The rule-cell expression evaluator. + * + * @return void + */ + public function __construct( + private readonly UnaryTestEvaluator $evaluator = new UnaryTestEvaluator(), + ) { + }//end __construct() + + /** + * Evaluate a decision table. + * + * @param array $decisionTable The decision table definition + * (`inputs`, `outputs`, `rules`, `hitPolicy`). + * @param array $inputs Caller-supplied input values, keyed by input name. + * + * @return array{outputs: array, matchedRuleIds: array, hitPolicy: string} + * + * @throws DecisionEvaluationException `unknown_input`, `missing_input`, `type_mismatch`, + * `invalid_expression`, `no_rule_matched`, + * `hit_policy_violation`, `hit_policy_not_implemented`. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + public function evaluate(array $decisionTable, array $inputs): array { + $declaredInputs = self::normaliseFields(fields: ($decisionTable['inputs'] ?? [])); + $declaredOutputs = self::normaliseFields(fields: ($decisionTable['outputs'] ?? [])); + $hitPolicy = strtoupper((string)($decisionTable['hitPolicy'] ?? 'UNIQUE')); + + $rules = []; + if (is_array($decisionTable['rules'] ?? null) === true) { + $rules = $decisionTable['rules']; + } + + if (in_array($hitPolicy, self::IMPLEMENTED_HIT_POLICIES, true) === false) { + throw new DecisionEvaluationException(errorCode: 'hit_policy_not_implemented', details: ['hitPolicy' => $hitPolicy]); + } + + $coercedInputs = $this->resolveInputs(declaredInputs: $declaredInputs, inputs: $inputs); + + $matchedRules = []; + foreach ($rules as $index => $rule) { + if (is_array($rule) === false) { + continue; + } + + if ($this->ruleMatches(rule: $rule, declaredInputs: $declaredInputs, coercedInputs: $coercedInputs, ruleIndex: $index) === true) { + $matchedRules[] = $rule; + } + } + + return $this->applyHitPolicy( + hitPolicy: $hitPolicy, + matchedRules: $matchedRules, + declaredOutputs: $declaredOutputs, + ); + }//end evaluate() + + /** + * Validate the caller's inputs against the declared inputs and coerce + * each to its declared type. + * + * @param array $declaredInputs Declared inputs. + * @param array $inputs Caller-supplied values. + * + * @return array Coerced values keyed by input name. + * + * @throws DecisionEvaluationException `unknown_input`, `missing_input`, `type_mismatch`. + */ + private function resolveInputs(array $declaredInputs, array $inputs): array { + $declaredNames = array_map(static fn (array $input): string => $input['name'], $declaredInputs); + + foreach (array_keys($inputs) as $key) { + if (in_array($key, $declaredNames, true) === false) { + throw new DecisionEvaluationException(errorCode: 'unknown_input', details: ['key' => $key]); + } + } + + $coerced = []; + foreach ($declaredInputs as $declared) { + $name = $declared['name']; + if (array_key_exists($name, $inputs) === false) { + throw new DecisionEvaluationException(errorCode: 'missing_input', details: ['name' => $name]); + } + + $coerced[$name] = $this->evaluator->coerce(value: $inputs[$name], type: $declared['type']); + } + + return $coerced; + }//end resolveInputs() + + /** + * Check whether every input entry on a rule matches the coerced inputs. + * + * @param array $rule The rule row. + * @param array $declaredInputs Declared inputs, positionally aligned. + * @param array $coercedInputs Coerced runtime values, keyed by name. + * @param int|string $ruleIndex Rule position (for error context). + * + * @return bool + * + * @throws DecisionEvaluationException `invalid_expression`/`type_mismatch` (re-thrown with rule context). + */ + private function ruleMatches(array $rule, array $declaredInputs, array $coercedInputs, int|string $ruleIndex): bool { + $entries = []; + if (is_array($rule['inputEntries'] ?? null) === true) { + $entries = $rule['inputEntries']; + } + + foreach ($declaredInputs as $position => $declared) { + $expression = (string)($entries[$position] ?? '-'); + $value = $coercedInputs[$declared['name']]; + + try { + if ($this->evaluator->matches(expression: $expression, value: $value, type: $declared['type']) === false) { + return false; + } + } catch (DecisionEvaluationException $e) { + throw new DecisionEvaluationException( + errorCode: $e->getErrorCode(), + details: array_merge($e->getDetails(), ['ruleId' => ($rule['id'] ?? $ruleIndex), 'input' => $declared['name']]), + ); + } + } + + return true; + }//end ruleMatches() + + /** + * Refuse an ANY table whose matching rules disagree. + * + * @param array> $matchedRules The matching rules. + * @param array $declaredOutputs The declared outputs. + * @param array $matchedIds The matching rule ids, for the error. + * + * @return void + * + * @throws DecisionEvaluationException `hit_policy_violation` when they differ. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + private function assertAllOutputsAgree(array $matchedRules, array $declaredOutputs, array $matchedIds): void { + $first = null; + foreach ($matchedRules as $rule) { + $outputs = []; + foreach ($declaredOutputs as $position => $declared) { + $outputs[$declared['name']] = ($rule['outputEntries'][$position] ?? null); + } + + if ($first === null) { + $first = $outputs; + continue; + } + + if ($outputs !== $first) { + throw new DecisionEvaluationException( + errorCode: 'hit_policy_violation', + details: ['hitPolicy' => 'ANY', 'matchedRuleIds' => $matchedIds], + ); + } + } + + }//end assertAllOutputsAgree() + + /** + * Every matching rule's value, per declared output. + * + * COLLECT is the one policy whose result is a LIST rather than a value, so + * it is built apart from the single-winner path instead of inside it. + * + * @param array> $matchedRules The matching rules. + * @param array $declaredOutputs The declared outputs. + * + * @return array> The collected outputs. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + private function collectOutputs(array $matchedRules, array $declaredOutputs): array { + $outputs = []; + foreach ($declaredOutputs as $position => $declared) { + $outputs[$declared['name']] = array_map( + static fn (array $rule): mixed => ($rule['outputEntries'][$position] ?? null), + $matchedRules, + ); + } + + return $outputs; + + }//end collectOutputs() + + /** + * Which matching rule wins, for the single-winner policies. + * + * PRIORITY takes the highest `priority`; everything else takes the first in + * declaration order. Ties are not an error under PRIORITY — DMN says the + * output with the highest priority is taken, and two rules may legitimately + * share one — so declaration order breaks them, which makes the outcome + * deterministic rather than dependent on array iteration. + * + * @param string $hitPolicy The hit policy. + * @param array> $matchedRules The matching rules, in declaration order. + * + * @return array The winning rule. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + private function selectWinner(string $hitPolicy, array $matchedRules): array { + if ($hitPolicy === 'PRIORITY') { + return $this->highestPriority(matchedRules: $matchedRules); + } + + return $matchedRules[0]; + + }//end selectWinner() + + /** + * The matched rule with the highest `priority`, ties broken by order. + * + * A rule that declares no priority is treated as 0, so a table that mixes + * prioritised and unprioritised rules behaves predictably instead of + * depending on whether the key happens to exist. + * + * @param array> $matchedRules The matching rules, in declaration order. + * + * @return array The winning rule. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + private function highestPriority(array $matchedRules): array { + $winner = $matchedRules[0]; + $best = (int) ($winner['priority'] ?? 0); + + foreach ($matchedRules as $rule) { + $priority = (int) ($rule['priority'] ?? 0); + // STRICTLY greater, so an equal priority leaves the earlier rule in + // place and declaration order is the tie-break. + if ($priority > $best) { + $winner = $rule; + $best = $priority; + } + } + + return $winner; + + }//end highestPriority() + + /** + * Apply the hit policy to the set of matched rules and build the outputs. + * + * @param string $hitPolicy UNIQUE|FIRST|COLLECT. + * @param array> $matchedRules Rules that matched, in declaration order. + * @param array $declaredOutputs Declared outputs, positionally aligned. + * + * @return array{outputs: array, matchedRuleIds: array, hitPolicy: string} + * + * @throws DecisionEvaluationException `no_rule_matched`, `hit_policy_violation`. + */ + private function applyHitPolicy(string $hitPolicy, array $matchedRules, array $declaredOutputs): array { + $matchedIds = []; + foreach ($matchedRules as $position => $rule) { + $matchedIds[] = (string)($rule['id'] ?? $position); + } + + if ($hitPolicy === 'COLLECT') { + return [ + 'outputs' => $this->collectOutputs(matchedRules: $matchedRules, declaredOutputs: $declaredOutputs), + 'matchedRuleIds' => $matchedIds, + 'hitPolicy' => $hitPolicy, + ]; + } + + if (count($matchedRules) === 0) { + throw new DecisionEvaluationException(errorCode: 'no_rule_matched'); + } + + if ($hitPolicy === 'UNIQUE' && count($matchedRules) > 1) { + throw new DecisionEvaluationException(errorCode: 'hit_policy_violation', details: ['matchedRuleIds' => $matchedIds]); + } + + // ANY: every matching rule must agree. DMN says a table declaring ANY + // asserts that overlapping rules produce the SAME output, so a + // disagreement is a fault in the table, not a choice to make silently. + // openbuild's evaluator treated `any` as `collect` and returned a list; + // that is a different answer of a different shape, and consolidating it + // unexamined would have been the quiet regression this whole exercise + // exists to avoid. + if ($hitPolicy === 'ANY') { + $this->assertAllOutputsAgree(matchedRules: $matchedRules, declaredOutputs: $declaredOutputs, matchedIds: $matchedIds); + } + + $winner = $this->selectWinner(hitPolicy: $hitPolicy, matchedRules: $matchedRules); + + $outputs = []; + foreach ($declaredOutputs as $position => $declared) { + $outputs[$declared['name']] = ($winner['outputEntries'][$position] ?? null); + } + + $winnerId = (string)($winner['id'] ?? 0); + + return ['outputs' => $outputs, 'matchedRuleIds' => [$winnerId], 'hitPolicy' => $hitPolicy]; + }//end applyHitPolicy() + + /** + * Normalise a decision table's `inputs`/`outputs` array into a clean + * positional list of `{name, type}`. + * + * @param array $fields Raw `inputs`/`outputs` array. + * + * @return array + */ + private static function normaliseFields(array $fields): array { + $result = []; + foreach ($fields as $field) { + if (is_array($field) === false) { + continue; + } + + $name = (string)($field['name'] ?? ''); + if ($name === '') { + continue; + } + + $type = (string)($field['type'] ?? 'string'); + if (in_array($type, UnaryTestEvaluator::VALID_TYPES, true) === false) { + $type = 'string'; + } + + $result[] = ['name' => $name, 'type' => $type]; + } + + return $result; + }//end normaliseFields() +}//end class diff --git a/lib/Service/Dmn/UnaryTestEvaluator.php b/lib/Service/Dmn/UnaryTestEvaluator.php new file mode 100644 index 0000000000..321ebc5c97 --- /dev/null +++ b/lib/Service/Dmn/UnaryTestEvaluator.php @@ -0,0 +1,408 @@ + X' '>= X' '= X' '!= X' comparison, X coerced to type + * '[A..B]' '(A..B)' '[A..B)' '(A..B]' inclusive/exclusive range + * 'in (a,b,c)' set membership (members may be quoted) + * 'literal' bare-literal equality + * + * @category Service + * @package OCA\OpenRegister\Service\Dmn + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @version GIT: + * + * @link https://conduction.nl + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Dmn; + +use DateTimeImmutable; +use Throwable; + +/** + * DMN unary tests: does one rule cell match one input value. + * + * MOVED, not rewritten. This is dossiq's ExpressionEvaluator, ported verbatim + * apart from its namespace and name, because ADR-065 Decision 6 says decision + * tables consolidate HERE and the fleet had already built the grammar twice — + * openbuild on 2026-06-05 and dossiq on 2026-07-15, six weeks apart, neither + * knowing the other existed. Retyping it would have been a third. + * + * dossiq's is the stronger of the two dialects: it carries typed coercion + * (string / number / boolean / date), inclusive and exclusive ranges, set + * membership and the quoted-literal escape that lets a rule match a literal + * "-" rather than reading it as the wildcard. openbuild's contributes the + * `priority` hit policy, which the table evaluator beside this class takes. + * + * Renamed from ExpressionEvaluator because it does NOT evaluate expressions in + * the general sense: it decides whether a value satisfies one unary test, which + * is a closed, bounded grammar with no code execution in it. The old name + * invited someone to reach for it as a general evaluator. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) — a closed grammar parser is + * branchy by nature; every branch is a fixed, tested form. Carried over with the + * class: adding a second docblock above the original detached this tag from the + * class and the rule fired on a file nobody had changed a line of. + */ +class UnaryTestEvaluator { + + /** + * Declared input/output types this evaluator understands. + * + * @var string[] + */ + public const VALID_TYPES = ['string', 'number', 'boolean', 'date']; + + /** + * Check whether a rule cell expression matches an already-coerced value. + * + * @param string $expression The raw cell text (e.g. `'[0..25000]'`, `'-'`, `'in (a,b)'`). + * @param mixed $value The runtime value, already coerced via {@see coerce()} for `$type`. + * @param string $type One of {@see VALID_TYPES}. + * + * @return bool True when the expression matches the value. + * + * @throws DecisionEvaluationException `invalid_expression` on malformed grammar, + * `type_mismatch` when a literal in the expression + * cannot be coerced to `$type`. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) — one dispatch per grammar form; splitting hides the grammar + * @SuppressWarnings(PHPMD.NPathComplexity) — same: the branches are a flat form-dispatch, not nested logic + */ + public function matches(string $expression, mixed $value, string $type): bool { + $trimmed = trim($expression); + + // Explicit quoted literal — bypasses the wildcard shortcut so a rule + // author can match the literal string "-" by writing `"-"`. + if (strlen($trimmed) >= 2 && $trimmed[0] === '"' && str_ends_with($trimmed, '"') === true) { + $literal = $this->unquote(raw: $trimmed); + return $this->equals(left: $value, right: $this->coerce(value: $literal, type: $type), type: $type); + } + + if ($trimmed === '' || $trimmed === '-') { + return true; + } + + if (preg_match('/^in\s*\((.*)\)$/is', $trimmed, $setMatch) === 1) { + $members = $this->parseSetMembers(inner: $setMatch[1]); + foreach ($members as $member) { + if ($this->equals(left: $value, right: $this->coerce(value: $member, type: $type), type: $type) === true) { + return true; + } + } + + return false; + } + + if (preg_match('/^([\[(])\s*(.*?)\s*\.\.\s*(.*?)\s*([\])])$/s', $trimmed, $rangeMatch) === 1) { + return $this->matchesRange(match: $rangeMatch, value: $value, type: $type); + } + + // Two-character operators BEFORE single-character ones (`<=` before `<`). + foreach (['<=', '>=', '!='] as $operator) { + if (str_starts_with($trimmed, $operator) === true) { + return $this->matchesComparison(operator: $operator, remainder: substr($trimmed, 2), value: $value, type: $type); + } + } + + foreach (['<', '>', '='] as $operator) { + if (str_starts_with($trimmed, $operator) === true) { + return $this->matchesComparison(operator: $operator, remainder: substr($trimmed, 1), value: $value, type: $type); + } + } + + // Bare literal — plain equality. + return $this->equals(left: $value, right: $this->coerce(value: $trimmed, type: $type), type: $type); + }//end matches() + + /** + * Coerce a raw scalar (runtime input or rule-cell literal) to `$type`. + * + * @param mixed $value The raw value. + * @param string $type One of {@see VALID_TYPES}. + * + * @return string|float|bool|int The coerced value (int for `date`, a Unix timestamp). + * + * @throws DecisionEvaluationException `type_mismatch` when coercion fails. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + public function coerce(mixed $value, string $type): string|float|bool|int { + return match ($type) { + 'string' => $this->coerceString(value: $value), + 'number' => $this->coerceNumber(value: $value), + 'boolean' => $this->coerceBoolean(value: $value), + 'date' => $this->coerceDate(value: $value), + default => throw new DecisionEvaluationException(errorCode: 'type_mismatch', details: ['reason' => 'unsupported_type', 'type' => $type]), + }; + }//end coerce() + + /** + * Coerce to string. + * + * @param mixed $value Raw value. + * + * @return string + * + * @throws DecisionEvaluationException `type_mismatch` for non-scalar input. + */ + private function coerceString(mixed $value): string { + if (is_scalar($value) === false) { + throw new DecisionEvaluationException(errorCode: 'type_mismatch', details: ['expected' => 'string']); + } + + return (string)$value; + }//end coerceString() + + /** + * Coerce to a float. + * + * @param mixed $value Raw value. + * + * @return float + * + * @throws DecisionEvaluationException `type_mismatch` for non-numeric input. + */ + private function coerceNumber(mixed $value): float { + if (is_int($value) === true || is_float($value) === true) { + return (float)$value; + } + + if (is_string($value) === true && is_numeric(trim($value)) === true) { + return (float)trim($value); + } + + throw new DecisionEvaluationException(errorCode: 'type_mismatch', details: ['expected' => 'number', 'value' => $value]); + }//end coerceNumber() + + /** + * Coerce to a bool. + * + * @param mixed $value Raw value. + * + * @return bool + * + * @throws DecisionEvaluationException `type_mismatch` for unrecognised input. + */ + private function coerceBoolean(mixed $value): bool { + if (is_bool($value) === true) { + return $value; + } + + if (is_int($value) === true && ($value === 0 || $value === 1)) { + return ($value === 1); + } + + if (is_string($value) === true) { + $lower = strtolower(trim($value)); + if ($lower === 'true') { + return true; + } + + if ($lower === 'false') { + return false; + } + } + + throw new DecisionEvaluationException(errorCode: 'type_mismatch', details: ['expected' => 'boolean', 'value' => $value]); + }//end coerceBoolean() + + /** + * Coerce to a Unix timestamp (int). + * + * @param mixed $value Raw value. + * + * @return int + * + * @throws DecisionEvaluationException `type_mismatch` for unparsable input. + */ + private function coerceDate(mixed $value): int { + if ($value instanceof \DateTimeInterface) { + return $value->getTimestamp(); + } + + if (is_string($value) === true && trim($value) !== '') { + try { + return (new DateTimeImmutable(trim($value)))->getTimestamp(); + } catch (Throwable $e) { + throw new DecisionEvaluationException(errorCode: 'type_mismatch', details: ['expected' => 'date', 'value' => $value]); + } + } + + throw new DecisionEvaluationException(errorCode: 'type_mismatch', details: ['expected' => 'date', 'value' => $value]); + }//end coerceDate() + + /** + * Evaluate a parsed range match against a coerced value. + * + * @param array $match Regex capture groups: [0]=full, [1]=open bracket, [2]=low, [3]=high, [4]=close bracket. + * @param mixed $value The already-coerced runtime value. + * @param string $type Declared type. + * + * @return bool + * + * @throws DecisionEvaluationException `invalid_expression` on a missing bound, `type_mismatch` on an unparsable bound. + */ + private function matchesRange(array $match, mixed $value, string $type): bool { + [, $open, $lowRaw, $highRaw, $close] = $match; + if ($lowRaw === '' || $highRaw === '') { + throw new DecisionEvaluationException(errorCode: 'invalid_expression', details: ['reason' => 'missing_range_bound']); + } + + $low = $this->coerce(value: $lowRaw, type: $type); + $high = $this->coerce(value: $highRaw, type: $type); + + $lowOk = ($value > $low); + if ($open === '[') { + $lowOk = ($value >= $low); + } + + $highOk = ($value < $high); + if ($close === ']') { + $highOk = ($value <= $high); + } + + return ($lowOk === true && $highOk === true); + }//end matchesRange() + + /** + * Evaluate a comparison operator against a coerced value. + * + * @param string $operator One of `< > <= >= = !=`. + * @param string $remainder The raw operand text (before the leading whitespace is trimmed). + * @param mixed $value The already-coerced runtime value. + * @param string $type Declared type. + * + * @return bool + * + * @throws DecisionEvaluationException `invalid_expression` when the operand is empty, `type_mismatch` when it cannot be coerced. + */ + private function matchesComparison(string $operator, string $remainder, mixed $value, string $type): bool { + $operand = trim($remainder); + if ($operand === '') { + throw new DecisionEvaluationException(errorCode: 'invalid_expression', details: ['reason' => 'missing_operand', 'operator' => $operator]); + } + + if (strlen($operand) >= 2 && $operand[0] === '"' && str_ends_with($operand, '"') === true) { + $operand = $this->unquote(raw: $operand); + } + + $coerced = $this->coerce(value: $operand, type: $type); + + return match ($operator) { + '<' => ($value < $coerced), + '<=' => ($value <= $coerced), + '>' => ($value > $coerced), + '>=' => ($value >= $coerced), + '=' => $this->equals(left: $value, right: $coerced, type: $type), + '!=' => ($this->equals(left: $value, right: $coerced, type: $type) === false), + default => throw new DecisionEvaluationException( + errorCode: 'invalid_expression', + details: ['reason' => 'unknown_operator', 'operator' => $operator], + ), + }; + }//end matchesComparison() + + /** + * Type-aware equality. + * + * @param mixed $left Left operand (already coerced). + * @param mixed $right Right operand (already coerced). + * @param string $type Declared type. + * + * @return bool + */ + private function equals(mixed $left, mixed $right, string $type): bool { + if ($type === 'number' || $type === 'date') { + return (abs(((float)$left) - ((float)$right)) < 1.0e-9); + } + + return ($left === $right); + }//end equals() + + /** + * Split the inner text of `in (...)` into raw member strings, respecting + * double-quoted members that may themselves contain commas. + * + * @param string $inner The text between the parentheses. + * + * @return array Raw (still-quoted) member strings. + */ + private function parseSetMembers(string $inner): array { + $members = []; + $buffer = ''; + $inQuotes = false; + $length = strlen($inner); + + for ($i = 0; $i < $length; $i++) { + $char = $inner[$i]; + if ($char === '"') { + $inQuotes = !$inQuotes; + $buffer .= $char; + continue; + } + + if ($char === ',' && $inQuotes === false) { + $members[] = trim($buffer); + $buffer = ''; + continue; + } + + $buffer .= $char; + } + + if (trim($buffer) !== '') { + $members[] = trim($buffer); + } + + return array_map( + function (string $member): string { + if (strlen($member) >= 2 && $member[0] === '"' && str_ends_with($member, '"') === true) { + return $this->unquote(raw: $member); + } + + return $member; + }, + $members, + ); + }//end parseSetMembers() + + /** + * Strip one layer of surrounding double quotes and unescape `\"`. + * + * @param string $raw The quoted raw text, e.g. `'"a b"'`. + * + * @return string + */ + private function unquote(string $raw): string { + $inner = substr($raw, 1, -1); + return str_replace('\\"', '"', $inner); + }//end unquote() +}//end class diff --git a/openspec/changes/shared-decision-table-evaluator/proposal.md b/openspec/changes/shared-decision-table-evaluator/proposal.md new file mode 100644 index 0000000000..ff65ce5597 --- /dev/null +++ b/openspec/changes/shared-decision-table-evaluator/proposal.md @@ -0,0 +1,85 @@ +--- +kind: code +--- + +# Proposal: shared-decision-table-evaluator + +## Summary + +Give the fleet one place to evaluate a DMN decision table. OpenRegister gets the +evaluator; the two apps that each built their own become consumers. + +## Motivation + +ADR-065 Decision 6 states this outright, and states why it is not a matter of +taste: + +> the fleet has already built it twice, independently, and neither +> implementation knows the other exists + +| App | File | Lines | Hit policies | Built | +|---|---|---|---|---| +| openbuild | `DecisionTableEvaluator` | 422 | any, collect, first, **priority**, unique | 2026-06-05 | +| dossiq | `Service/Dmn/*` | ~1,070 | collect, first, unique | 2026-07-15 | + +Six weeks apart, and **the newer one is the less capable**. dossiq's archived +change references OpenRegister only as object storage; it never mentions +openbuild's evaluator, which had been shipping for six weeks. + +The ADR calls that "the ADR's own thesis happening in real time", and it is the +single strongest argument for OpenRegister owning the abstraction: without a +shared home, the third implementation is a matter of time. + +Measured again today, before writing anything: OpenRegister still has no +decision-table service, so nothing has changed since the ADR was written. + +## Scope + +### In Scope + +1. **`UnaryTestEvaluator`** — dossiq's grammar, MOVED rather than rewritten. +2. **`DecisionTableEvaluator`** — the table engine, taking the UNION of the two + dialects' hit policies. +3. **`DecisionEvaluationException`** — the typed error codes, moved with it. + +### Out of Scope + +- **Retiring either app's copy.** Each consumes this in its own change, with + parity tests against its own shipped tables. Deleting a working evaluator on + the strength of a new one that has not yet run its data would be the same + mistake in the other direction. +- **A conformant FEEL implementation.** ADR-065 rules it out: unary tests cover + the overwhelming majority of real tables and full FEEL is not worth the cost. +- **DMN XML interchange.** No package supplies it; the ADR records that as ours + to write later, and it does not block the engine. + +## What "reconcile the dialects" actually meant + +The ADR warned this "must reconcile two dialects rather than simply relocate +one". Three places where that mattered: + +**The grammar is dossiq's, moved verbatim.** It is the stronger of the two — +typed coercion (string / number / boolean / date), inclusive and exclusive +ranges, set membership, and a quoted-literal escape so a rule can match a +literal `"-"` rather than having it read as the wildcard. Retyping it would have +been a third implementation. + +**`PRIORITY` is openbuild's, and its absence would have been a regression.** +dossiq's engine did not implement it; openbuild's tables can already use it. +Consolidating on dossiq's set alone would have silently broken them. + +**`ANY` meant different things, and neither was DMN's.** openbuild treated it as +`collect` and returned a LIST — a different answer of a different shape. DMN says +a table declaring ANY asserts that its overlapping rules produce the same output, +so a disagreement is a fault in the table. It is implemented that way here, and +the test that pins it fails if it degrades to `first`. + +## Risks + +- 🔴 **A consumer's shipped tables may rely on the old ANY.** openbuild's tables + declaring `any` got a list and now get a single value or an error. That is why + retiring the app-side copies is out of scope: each app adopts this with parity + tests over its own data, not on the strength of this PR. +- ⚠️ **`UNIQUE` throws on no match.** Inherited from dossiq deliberately: a + decision table that matched nothing has not decided anything, and returning an + empty output invites the caller to treat it as a decision. diff --git a/openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md b/openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md new file mode 100644 index 0000000000..127540eb20 --- /dev/null +++ b/openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md @@ -0,0 +1,91 @@ +# shared-decision-tables Specification + +## Purpose + +One home for DMN decision-table evaluation, so the fleet stops building it per +app. Per ADR-065 Decision 6. + +## ADDED Requirements + +### Requirement: REQ-SDT-001 One evaluator, the union of both dialects + +OpenRegister SHALL provide a decision-table evaluator implementing at least +`UNIQUE`, `FIRST`, `COLLECT`, `PRIORITY` and `ANY`, and SHALL refuse any hit +policy it does not implement rather than silently treating it as `FIRST`. + +`PRIORITY` is required: openbuild's evaluator has it and dossiq's does not, so +omitting it would make the consolidation a capability regression. + +#### Scenario: An unimplemented policy is refused + +- **WHEN** a table declares a hit policy the evaluator does not implement +- **THEN** it raises `hit_policy_not_implemented` and decides nothing + +#### Scenario: PRIORITY takes the highest + +- **GIVEN** matching rules with priorities 1, 10 and 5 +- **WHEN** the table is evaluated +- **THEN** the rule with priority 10 wins + +#### Scenario: PRIORITY is deterministic on a tie + +- **GIVEN** two matching rules of equal priority +- **THEN** the earlier one in declaration order wins + +#### Scenario: An absent priority counts as zero + +- **GIVEN** one rule declaring no priority and one declaring 3 +- **THEN** the one declaring 3 wins + +### Requirement: REQ-SDT-002 ANY asserts agreement + +Under `ANY` the system SHALL require every matching rule to produce the same +output, and SHALL raise `hit_policy_violation` when they differ. + +A table declaring ANY asserts that its overlapping rules agree; a disagreement +is a fault in the table, not a choice for the engine. openbuild's evaluator +treated `any` as `collect` and returned a list, which is a different answer of a +different shape. + +#### Scenario: Disagreeing rules are refused + +- **GIVEN** two matching rules with different outputs under ANY +- **THEN** evaluation raises `hit_policy_violation` + +#### Scenario: Agreeing rules return the shared output + +- **GIVEN** two matching rules with the same output under ANY +- **THEN** that output is returned + +### Requirement: REQ-SDT-003 The unary-test grammar is preserved intact + +The evaluator SHALL support the grammar it inherits: wildcards, typed equality, +comparison operators, inclusive and exclusive ranges, set membership, and a +quoted literal that escapes the wildcard. + +It SHALL NOT execute arbitrary code for any expression. + +#### Scenario: Ranges keep their boundary semantics + +- **GIVEN** `[0..25000]` and `(25000..100000]` +- **THEN** 25000 matches the first and 25001 the second + +#### Scenario: Set membership matches any listed value + +- **GIVEN** `in (gering, aanzienlijk)` +- **THEN** `aanzienlijk` matches and `ernstig` does not + +### Requirement: REQ-SDT-004 A three-axis matrix is expressible as a table + +A dense lookup over three inputs SHALL evaluate to its single matching rule +under `UNIQUE`. + +This is the shape dossiq's Landelijke Handhavingsstrategie matrix takes: +(ernst x gedrag x actorType) to one intervention. It is a decision table, and it +belongs on this engine rather than in a bespoke matrix service. + +#### Scenario: The LHS matrix shape evaluates + +- **GIVEN** a table over severity, behaviour and actorType +- **WHEN** evaluated with a triple matching exactly one rule +- **THEN** that rule's intervention is returned with its id diff --git a/openspec/changes/shared-decision-table-evaluator/tasks.md b/openspec/changes/shared-decision-table-evaluator/tasks.md new file mode 100644 index 0000000000..a8a45fbe38 --- /dev/null +++ b/openspec/changes/shared-decision-table-evaluator/tasks.md @@ -0,0 +1,30 @@ +# Tasks: shared-decision-table-evaluator + +## Implementation Tasks + +### Task 1: Move the grammar +- **spec_ref**: `openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md#requirement-req-sdt-003-the-unary-test-grammar-is-preserved-intact` +- **files**: `lib/Service/Dmn/UnaryTestEvaluator.php`, `lib/Service/Dmn/DecisionEvaluationException.php` +- **acceptance_criteria**: + - GIVEN the ported grammar THEN ranges, sets, comparisons and the quoted-literal escape behave as they did in dossiq + - GIVEN the move THEN it is a MOVE: the file is dossiq's, renamed and re-namespaced, not retyped +- [x] Implement +- [x] Test + +### Task 2: The consolidated table evaluator +- **spec_ref**: `.../spec.md#requirement-req-sdt-001-one-evaluator-the-union-of-both-dialects` (+ REQ-SDT-002) +- **files**: `lib/Service/Dmn/DecisionTableEvaluator.php` +- **acceptance_criteria**: + - GIVEN the hit policies THEN the list is the UNION of both apps', including openbuild's PRIORITY + - GIVEN PRIORITY THEN the highest wins, ties break by declaration order, and an absent priority is zero + - GIVEN ANY THEN disagreeing rules are refused and agreeing ones return the shared output + - GIVEN an unimplemented policy THEN it is refused, never treated as FIRST +- [x] Implement +- [x] Test — mutation-checked: dropping PRIORITY, degrading ANY to FIRST, and making the tie non-deterministic each turn the suite red + +### Task 3: The consumers adopt it +- **spec_ref**: deferred +- **acceptance_criteria**: + - Deliberately out of scope. openbuild and dossiq each adopt this in their own change, with parity tests over their own shipped tables — openbuild's `any` tables in particular change shape. Deleting a working evaluator on the strength of a new one that has not yet run its data would be the same mistake in the other direction. +- [ ] Implement +- [ ] Test diff --git a/tests/Unit/Service/Dmn/DecisionTableEvaluatorTest.php b/tests/Unit/Service/Dmn/DecisionTableEvaluatorTest.php new file mode 100644 index 0000000000..d2072ee747 --- /dev/null +++ b/tests/Unit/Service/Dmn/DecisionTableEvaluatorTest.php @@ -0,0 +1,330 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace Unit\Service\Dmn; + +use OCA\OpenRegister\Service\Dmn\DecisionEvaluationException; +use OCA\OpenRegister\Service\Dmn\DecisionTableEvaluator; +use PHPUnit\Framework\TestCase; + +/** + * The consolidated decision-table evaluator. + * + * The fleet built decision tables TWICE — openbuild 2026-06-05, dossiq + * 2026-07-15, six weeks apart, neither knowing the other existed — and the + * newer one shipped FEWER hit policies. ADR-065 Decision 6 consolidates them + * here, and says the consolidation must take openbuild's `priority` because + * dropping it would be a capability regression. + * + * So these tests are mostly about the seams between the two dialects: the + * policies that came from one side, and the ones whose meaning differed. + */ +class DecisionTableEvaluatorTest extends TestCase { + + /** + * The evaluator under test. + * + * @return DecisionTableEvaluator The evaluator. + */ + private function evaluator(): DecisionTableEvaluator { + return new DecisionTableEvaluator(); + + }//end evaluator() + + /** + * A table over one string input and one string output. + * + * @param string $hitPolicy The hit policy. + * @param array> $rules The rules. + * + * @return array The table. + */ + private function table(string $hitPolicy, array $rules): array { + return [ + 'hitPolicy' => $hitPolicy, + 'inputs' => [['name' => 'severity', 'type' => 'string']], + 'outputs' => [['name' => 'intervention', 'type' => 'string']], + 'rules' => $rules, + ]; + + }//end table() + + /** + * A single rule. + * + * @param string $id Its id. + * @param string $match The unary test. + * @param string $output The output value. + * @param integer|null $priority Its priority, or null to omit the key. + * + * @return array The rule. + */ + private function rule(string $id, string $match, string $output, ?int $priority = null): array { + $rule = ['id' => $id, 'inputEntries' => [$match], 'outputEntries' => [$output]]; + if ($priority !== null) { + $rule['priority'] = $priority; + } + + return $rule; + + }//end rule() + + /** + * FIRST takes the earliest matching rule. + * + * @return void + */ + public function testFirstTakesTheEarliestMatch(): void { + $table = $this->table('FIRST', [ + $this->rule('a', 'ernstig', 'bestuursdwang'), + $this->rule('b', '-', 'waarschuwing'), + ]); + + $out = $this->evaluator()->evaluate($table, ['severity' => 'ernstig']); + + $this->assertSame('bestuursdwang', $out['outputs']['intervention']); + $this->assertSame(['a'], $out['matchedRuleIds']); + + }//end testFirstTakesTheEarliestMatch() + + /** + * UNIQUE refuses an overlap rather than picking one. + * + * @return void + */ + public function testUniqueRefusesAnOverlap(): void { + $table = $this->table('UNIQUE', [ + $this->rule('a', 'ernstig', 'bestuursdwang'), + $this->rule('b', '-', 'waarschuwing'), + ]); + + $this->expectException(DecisionEvaluationException::class); + + $this->evaluator()->evaluate($table, ['severity' => 'ernstig']); + + }//end testUniqueRefusesAnOverlap() + + /** + * COLLECT returns every matching rule's output. + * + * @return void + */ + public function testCollectReturnsEveryMatch(): void { + $table = $this->table('COLLECT', [ + $this->rule('a', 'ernstig', 'bestuursdwang'), + $this->rule('b', '-', 'waarschuwing'), + ]); + + $out = $this->evaluator()->evaluate($table, ['severity' => 'ernstig']); + + $this->assertSame(['bestuursdwang', 'waarschuwing'], $out['outputs']['intervention']); + + }//end testCollectReturnsEveryMatch() + + /** + * 🔴 PRIORITY is openbuild's capability, and the reason this is a + * consolidation rather than a relocation. dossiq's engine did not have it. + * + * @return void + */ + public function testPriorityTakesTheHighest(): void { + $table = $this->table('PRIORITY', [ + $this->rule('low', '-', 'waarschuwing', 1), + $this->rule('high', 'ernstig', 'bestuursdwang', 10), + $this->rule('mid', '-', 'herstelactie', 5), + ]); + + $out = $this->evaluator()->evaluate($table, ['severity' => 'ernstig']); + + $this->assertSame('bestuursdwang', $out['outputs']['intervention']); + $this->assertSame(['high'], $out['matchedRuleIds']); + + }//end testPriorityTakesTheHighest() + + /** + * PRIORITY breaks a tie by declaration order, deterministically. + * + * @return void + */ + public function testPriorityBreaksTiesByDeclarationOrder(): void { + $table = $this->table('PRIORITY', [ + $this->rule('first', '-', 'waarschuwing', 5), + $this->rule('second', '-', 'herstelactie', 5), + ]); + + $out = $this->evaluator()->evaluate($table, ['severity' => 'gering']); + + $this->assertSame('waarschuwing', $out['outputs']['intervention']); + + }//end testPriorityBreaksTiesByDeclarationOrder() + + /** + * A rule with no priority counts as zero rather than as undefined. + * + * @return void + */ + public function testAnAbsentPriorityCountsAsZero(): void { + $table = $this->table('PRIORITY', [ + $this->rule('unset', '-', 'waarschuwing'), + $this->rule('set', '-', 'bestuursdwang', 3), + ]); + + $out = $this->evaluator()->evaluate($table, ['severity' => 'gering']); + + $this->assertSame('bestuursdwang', $out['outputs']['intervention']); + + }//end testAnAbsentPriorityCountsAsZero() + + /** + * 🔴 ANY requires the matching rules to AGREE. + * + * openbuild's evaluator treated `any` as `collect` and returned a list — + * a different answer of a different shape. DMN says a table declaring ANY + * asserts its overlapping rules produce the same output, so a disagreement + * is a fault in the table rather than a choice to make silently. + * + * @return void + */ + public function testAnyRefusesDisagreeingRules(): void { + $table = $this->table('ANY', [ + $this->rule('a', '-', 'waarschuwing'), + $this->rule('b', '-', 'bestuursdwang'), + ]); + + $this->expectException(DecisionEvaluationException::class); + + $this->evaluator()->evaluate($table, ['severity' => 'gering']); + + }//end testAnyRefusesDisagreeingRules() + + /** + * ANY accepts agreeing rules and returns the shared output. + * + * @return void + */ + public function testAnyAcceptsAgreeingRules(): void { + $table = $this->table('ANY', [ + $this->rule('a', '-', 'waarschuwing'), + $this->rule('b', 'gering', 'waarschuwing'), + ]); + + $out = $this->evaluator()->evaluate($table, ['severity' => 'gering']); + + $this->assertSame('waarschuwing', $out['outputs']['intervention']); + + }//end testAnyAcceptsAgreeingRules() + + /** + * An unimplemented hit policy is refused rather than treated as FIRST. + * + * @return void + */ + public function testAnUnknownHitPolicyIsRefused(): void { + $table = $this->table('OUTPUT-ORDER', [$this->rule('a', '-', 'waarschuwing')]); + + $this->expectException(DecisionEvaluationException::class); + + $this->evaluator()->evaluate($table, ['severity' => 'gering']); + + }//end testAnUnknownHitPolicyIsRefused() + + /** + * The LHS matrix shape: three axes, one intervention, UNIQUE. + * + * This is the table dossiq's Landelijke Handhavingsstrategie becomes — a + * dense (ernst x gedrag x actorType) lookup — expressed in the shared + * vocabulary rather than in a bespoke matrix service. + * + * @return void + */ + public function testTheLhsMatrixShapeEvaluates(): void { + $table = [ + 'hitPolicy' => 'UNIQUE', + 'inputs' => [ + ['name' => 'severity', 'type' => 'string'], + ['name' => 'behaviour', 'type' => 'string'], + ['name' => 'actorType', 'type' => 'string'], + ], + 'outputs' => [['name' => 'intervention', 'type' => 'string']], + 'rules' => [ + [ + 'id' => 'gering-goedwillend-burger', + 'inputEntries' => ['gering', 'goedwillend', 'burger'], + 'outputEntries' => ['waarschuwing'], + ], + [ + 'id' => 'ernstig-crimineel-bedrijf', + 'inputEntries' => ['ernstig', 'crimineel', 'bedrijf'], + 'outputEntries' => ['bestuursdwang'], + ], + ], + ]; + + $out = $this->evaluator()->evaluate( + $table, + ['severity' => 'ernstig', 'behaviour' => 'crimineel', 'actorType' => 'bedrijf'] + ); + + $this->assertSame('bestuursdwang', $out['outputs']['intervention']); + $this->assertSame(['ernstig-crimineel-bedrijf'], $out['matchedRuleIds']); + + }//end testTheLhsMatrixShapeEvaluates() + + /** + * A numeric range still works, so the ported grammar came across intact. + * + * @return void + */ + public function testTheRangeGrammarSurvivedThePort(): void { + $table = [ + 'hitPolicy' => 'FIRST', + 'inputs' => [['name' => 'amount', 'type' => 'number']], + 'outputs' => [['name' => 'band', 'type' => 'string']], + 'rules' => [ + ['id' => 'low', 'inputEntries' => ['[0..25000]'], 'outputEntries' => ['low']], + ['id' => 'high', 'inputEntries' => ['(25000..100000]'], 'outputEntries' => ['high']], + ], + ]; + + $this->assertSame('low', $this->evaluator()->evaluate($table, ['amount' => 25000])['outputs']['band']); + $this->assertSame('high', $this->evaluator()->evaluate($table, ['amount' => 25001])['outputs']['band']); + + }//end testTheRangeGrammarSurvivedThePort() + + /** + * Set membership survived the port too. + * + * @return void + */ + public function testSetMembershipSurvivedThePort(): void { + $table = $this->table('FIRST', [ + $this->rule('set', 'in (gering, aanzienlijk)', 'licht'), + $this->rule('rest', '-', 'zwaar'), + ]); + + $this->assertSame('licht', $this->evaluator()->evaluate($table, ['severity' => 'aanzienlijk'])['outputs']['intervention']); + $this->assertSame('zwaar', $this->evaluator()->evaluate($table, ['severity' => 'ernstig'])['outputs']['intervention']); + + }//end testSetMembershipSurvivedThePort() + + /** + * No matching rule is an error, not an empty answer. + * + * @return void + */ + public function testNoMatchIsAnError(): void { + $table = $this->table('UNIQUE', [$this->rule('a', 'ernstig', 'bestuursdwang')]); + + $this->expectException(DecisionEvaluationException::class); + + $this->evaluator()->evaluate($table, ['severity' => 'gering']); + + }//end testNoMatchIsAnError() + +}//end class From c812b50e3fe8a0e5984fa5fc043f35cc1badcf08 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:07:45 +0200 Subject: [PATCH 15/19] chore(deps-dev): bump jest-environment-jsdom from 29.7.0 to 30.5.0 (#3167) Bumps [jest-environment-jsdom](https://github.com/jestjs/jest/tree/HEAD/packages/jest-environment-jsdom) from 29.7.0 to 30.5.0. - [Release notes](https://github.com/jestjs/jest/releases) - [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/jestjs/jest/commits/v30.5.0/packages/jest-environment-jsdom) --- updated-dependencies: - dependency-name: jest-environment-jsdom dependency-version: 30.5.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 1074 +++++++++++++++++++++++++++++++++------------ package.json | 2 +- 2 files changed, 798 insertions(+), 278 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6bfd98e85b..63b49d8ae6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,7 +66,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-webpack-plugin": "^6.0.0", "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", + "jest-environment-jsdom": "^30.5.0", "jest-transform-stub": "^2.0.0", "postcss": "^8.4.31", "postcss-html": "^1.8.1", @@ -90,6 +90,122 @@ "vue": "^3.5.42" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@asyncapi/specs": { "version": "6.11.1", "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-6.11.1.tgz", @@ -2282,6 +2398,26 @@ "@yr/monotone-cubic-spline": "^1.0.3" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, "node_modules/@csstools/css-calc": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", @@ -3555,6 +3691,244 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.5.0.tgz", + "integrity": "sha512-825vac4Dmysbn2kU7VUQPoKuj/HNUpSTgv98KCByMOSPvHuj1/HpVZeLRsP/itDB2HFiDcoTUrsg8fSu3PxKBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.5.0", + "@jest/fake-timers": "30.5.0", + "@jest/types": "30.5.0", + "@types/node": "*", + "jest-mock": "30.5.0", + "jest-util": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/jsdom": "*", + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/environment": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.0.tgz", + "integrity": "sha512-HUaqexIauIh69IQ4NTuPDEUCB8g8T4TOPSIzQOS18mwI/KEHKQk1j013K2o6ra031szZE2t5jGmVx3xbzdjgKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.5.0", + "@jest/types": "30.5.0", + "@types/node": "*", + "jest-mock": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/expect-utils": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.5.0.tgz", + "integrity": "sha512-5j0ztPxSy3McUJihjkDdCyCfjvT2hxykFTWsgEBZKB8qsw9ALdCiGTpTRH5gnf/d+qI4SflYUJ0dWNbzjQCWbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/fake-timers": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.0.tgz", + "integrity": "sha512-sg8xIbYwe5GdB/vT3/0qrDIpO7Ov9mazHi++M95uynmDKEZ70G1r169AWct73H07VrTZhrz1SJEfLtjYv8tE3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.0", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.5.0", + "jest-mock": "30.5.0", + "jest-util": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/types": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.0.tgz", + "integrity": "sha512-s1N+79S4Yp9ZgklCauZXi+YPJdCdtStNYQT32stuD6EeQaIBGHoUfyj2P0YWy8RmuQfaJboO+ulxEvEheR/POQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.5.0", + "@jest/schemas": "30.5.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-message-util": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.0.tgz", + "integrity": "sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.5.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.5.0", + "picomatch": "^4.0.3", + "pretty-format": "30.5.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-mock": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.0.tgz", + "integrity": "sha512-bP5MHZpkYrV7xpV+yvhl36DPcXoEmTR57Un5EACcdVpMY7mpkDefCBq+V4mhcjE/3rwUajT6OTrcJTN7EwN1BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.5.0", + "@jest/types": "30.5.0", + "@types/node": "*", + "jest-util": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-util": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", + "integrity": "sha512-lzU4aGUWaS+2X/B0CmgheDasfnsVlRfZh/rNQxB9b9s8cSYUq5BcqdQA95ld+KqJXBUVVt1sqnMQ2T3OxIalmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/pretty-format": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", + "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", @@ -3600,6 +3974,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/get-type": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.5.0.tgz", + "integrity": "sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/globals": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", @@ -3616,6 +4000,46 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/pattern": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.5.0.tgz", + "integrity": "sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern/node_modules/jest-regex-util": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.5.0.tgz", + "integrity": "sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/react-is-19": { + "name": "react-is", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@jest/reporters": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", @@ -5057,21 +5481,6 @@ "node": ">= 14" } }, - "node_modules/@npmcli/agent/node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/@npmcli/agent/node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -6811,16 +7220,6 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "license": "MIT" }, - "node_modules/@tootallnate/once": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", - "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -7072,9 +7471,9 @@ } }, "node_modules/@types/jsdom": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", - "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", "dev": true, "license": "MIT", "dependencies": { @@ -8383,14 +8782,6 @@ "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==", "license": "MIT" }, - "node_modules/abab": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", - "deprecated": "Use your platform's native atob() and btoa() methods instead", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/abbrev": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", @@ -8471,17 +8862,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-globals": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", - "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.1.0", - "acorn-walk": "^8.0.2" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -8492,19 +8872,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -10679,33 +11046,20 @@ "node": ">=4" } }, - "node_modules/cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "dev": true, - "license": "MIT" - }, "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", "dependencies": { - "cssom": "~0.3.6" + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" }, "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "dev": true, - "license": "MIT" - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -10825,18 +11179,17 @@ "license": "MIT" }, "node_modules/data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/data-view-buffer": { @@ -11319,20 +11672,6 @@ ], "license": "BSD-2-Clause" }, - "node_modules/domexception": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "license": "MIT", - "dependencies": { - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", @@ -11844,56 +12183,23 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" }, - "node_modules/escodegen/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "devOptional": true, + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/eslint": { @@ -13769,16 +14075,16 @@ } }, "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-encoding": "^2.0.0" + "whatwg-encoding": "^3.1.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/html-entities": { @@ -13882,18 +14188,27 @@ } }, "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">= 6" + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" } }, "node_modules/http-proxy-middleware": { @@ -15237,26 +15552,22 @@ } }, "node_modules/jest-environment-jsdom": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", - "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.5.0.tgz", + "integrity": "sha512-VVHN/G3zrxsQR398jvMalM76ALX6YBAftsYLCGtTeKRmz4f42YJAP05AGpk0VF5SLtoHdkfKunYGfnKPnmcEOA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/jsdom": "^20.0.0", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0", - "jsdom": "^20.0.0" + "@jest/environment": "30.5.0", + "@jest/environment-jsdom-abstract": "30.5.0", + "@types/jsdom": "^21.1.7", + "jsdom": "^26.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "canvas": "^2.5.0" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { "canvas": { @@ -15264,6 +15575,216 @@ } } }, + "node_modules/jest-environment-jsdom/node_modules/@jest/environment": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.0.tgz", + "integrity": "sha512-HUaqexIauIh69IQ4NTuPDEUCB8g8T4TOPSIzQOS18mwI/KEHKQk1j013K2o6ra031szZE2t5jGmVx3xbzdjgKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.5.0", + "@jest/types": "30.5.0", + "@types/node": "*", + "jest-mock": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/expect-utils": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.5.0.tgz", + "integrity": "sha512-5j0ztPxSy3McUJihjkDdCyCfjvT2hxykFTWsgEBZKB8qsw9ALdCiGTpTRH5gnf/d+qI4SflYUJ0dWNbzjQCWbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/fake-timers": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.0.tgz", + "integrity": "sha512-sg8xIbYwe5GdB/vT3/0qrDIpO7Ov9mazHi++M95uynmDKEZ70G1r169AWct73H07VrTZhrz1SJEfLtjYv8tE3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.0", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.5.0", + "jest-mock": "30.5.0", + "jest-util": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/types": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.0.tgz", + "integrity": "sha512-s1N+79S4Yp9ZgklCauZXi+YPJdCdtStNYQT32stuD6EeQaIBGHoUfyj2P0YWy8RmuQfaJboO+ulxEvEheR/POQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.5.0", + "@jest/schemas": "30.5.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-environment-jsdom/node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/jest-environment-jsdom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-environment-jsdom/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-message-util": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.0.tgz", + "integrity": "sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.5.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.5.0", + "picomatch": "^4.0.3", + "pretty-format": "30.5.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-mock": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.0.tgz", + "integrity": "sha512-bP5MHZpkYrV7xpV+yvhl36DPcXoEmTR57Un5EACcdVpMY7mpkDefCBq+V4mhcjE/3rwUajT6OTrcJTN7EwN1BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.5.0", + "@jest/types": "30.5.0", + "@types/node": "*", + "jest-util": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-util": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", + "integrity": "sha512-lzU4aGUWaS+2X/B0CmgheDasfnsVlRfZh/rNQxB9b9s8cSYUq5BcqdQA95ld+KqJXBUVVt1sqnMQ2T3OxIalmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-environment-jsdom/node_modules/pretty-format": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", + "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -15750,44 +16271,38 @@ } }, "node_modules/jsdom": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", - "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "abab": "^2.0.6", - "acorn": "^8.8.1", - "acorn-globals": "^7.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.2", - "decimal.js": "^10.4.2", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.2", - "parse5": "^7.1.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^4.0.0", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0", - "ws": "^8.11.0", - "xml-name-validator": "^4.0.0" + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { - "canvas": "^2.5.0" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { "canvas": { @@ -15795,14 +16310,28 @@ } } }, - "node_modules/jsdom/node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": ">=12" + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" } }, "node_modules/jsep": { @@ -18014,9 +18543,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.24", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", - "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "version": "2.2.25", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.25.tgz", + "integrity": "sha512-kktSgNDIbyEIs/LrE6dtEMnfzxSDpXViFl6c4gFjz/CgtnL4GDCR3wyZXzjvAXsNB8dXU4dAfvIOgLMH2/vwLg==", "dev": true, "license": "MIT" }, @@ -19373,19 +19902,6 @@ "node": ">=10" } }, - "node_modules/psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "funding": { - "url": "https://github.com/sponsors/lupomontero" - } - }, "node_modules/public-encrypt": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", @@ -20259,6 +20775,13 @@ "node": ">= 18" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -22665,6 +23188,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -22730,42 +23273,29 @@ } }, "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "tldts": "^6.1.32" }, "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" + "node": ">=16" } }, "node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", "dependencies": { - "punycode": "^2.1.1" + "punycode": "^2.3.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tree-dump": { @@ -24295,26 +24825,16 @@ "license": "MIT" }, "node_modules/w3c-xmlserializer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", - "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", "dependencies": { - "xml-name-validator": "^4.0.0" + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=14" - } - }, - "node_modules/w3c-xmlserializer/node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/walker": { @@ -24709,9 +25229,9 @@ } }, "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, "license": "MIT", @@ -24719,31 +25239,31 @@ "iconv-lite": "0.6.3" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", "dependencies": { - "tr46": "^3.0.0", + "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/which": { diff --git a/package.json b/package.json index 5485eaa01e..987d6e72bf 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-webpack-plugin": "^6.0.0", "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", + "jest-environment-jsdom": "^30.5.0", "jest-transform-stub": "^2.0.0", "postcss": "^8.4.31", "postcss-html": "^1.8.1", From cdd213906a6e46d3430164ed7509247819b543cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:07:50 +0200 Subject: [PATCH 16/19] chore(deps-dev): bump @types/node from 20.19.43 to 26.4.0 (#3164) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.19.43 to 26.4.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 26.4.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 63b49d8ae6..ac47ce0af9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,7 @@ "@playwright/test": "^1.49.0", "@stoplight/spectral-cli": "^6.15.0", "@types/jest": "^29.5.12", - "@types/node": "^20.17.23", + "@types/node": "^26.4.0", "@typescript-eslint/parser": "^8.68.0", "@vue/test-utils": "^2.4.4", "@vue/vue3-jest": "^29.2.6", @@ -7511,12 +7511,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.43", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", - "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~8.3.0" } }, "node_modules/@types/prop-types": { @@ -23872,9 +23872,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { diff --git a/package.json b/package.json index 987d6e72bf..71e8f90a5f 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "@playwright/test": "^1.49.0", "@stoplight/spectral-cli": "^6.15.0", "@types/jest": "^29.5.12", - "@types/node": "^20.17.23", + "@types/node": "^26.4.0", "@typescript-eslint/parser": "^8.68.0", "@vue/test-utils": "^2.4.4", "@vue/vue3-jest": "^29.2.6", From 8143166442810bb826a44e06daacd01fc7635d03 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 07:21:08 +0200 Subject: [PATCH 17/19] fix(dmn): alias declared column types onto the evaluator's vocabulary (#3191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dmn): alias the declared column types onto the evaluator's vocabulary The fleet's decision tables were authored against two type vocabularies. openbuild's shipped credit table declares `integer`, which is not one of VALID_TYPES, so it fell back to `string`. Measured, that fallback costs nothing for numbers or dates: the unary-test grammar parses its operand and compares numerically, so `>=18` against 25 matches under either type. The numeric and date aliases here are alignment, not a bug fix, and are labelled as such. The boolean alias is a bug fix. A real PHP `true` coerced to a string becomes "1", which does not match a cell reading `true`, so a column declaring `bool` rather than `boolean` silently stopped matching. The added test fails without the alias and passes with it; the numeric case deliberately has no test of its own, because a test that passes either way would be evidence of nothing. Groundwork for buildiq and dossiq consuming this evaluator. * test(dmn): bring the unary-test grammar matrix with the evaluator #3186 moved dossiq's ExpressionEvaluator into OpenRegister as UnaryTestEvaluator but left its 31 grammar tests behind in dossiq. That was fine while dossiq still had its own copy of the class. It stops being fine the moment dossiq deletes that copy: the coverage would leave the fleet entirely, and the shared evaluator would be left with only the indirect exercise it gets through DecisionTableEvaluatorTest. So the tests travel first, and dossiq deletes second. The port is mechanical — namespace and class name — because the two classes differ only in docblocks. 47 tests, 86 assertions, all green. --- lib/Service/Dmn/DecisionTableEvaluator.php | 39 +- .../specs/shared-decision-tables/spec.md | 32 ++ .../Dmn/DecisionTableEvaluatorTest.php | 71 ++++ .../Service/Dmn/UnaryTestEvaluatorTest.php | 364 ++++++++++++++++++ 4 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Service/Dmn/UnaryTestEvaluatorTest.php diff --git a/lib/Service/Dmn/DecisionTableEvaluator.php b/lib/Service/Dmn/DecisionTableEvaluator.php index e7633a7645..7ca2f0a35c 100644 --- a/lib/Service/Dmn/DecisionTableEvaluator.php +++ b/lib/Service/Dmn/DecisionTableEvaluator.php @@ -55,6 +55,42 @@ class DecisionTableEvaluator { */ private const IMPLEMENTED_HIT_POLICIES = ['UNIQUE', 'FIRST', 'COLLECT', 'PRIORITY', 'ANY']; + /** + * Common spellings of the declared column types, mapped onto this + * evaluator's own vocabulary. + * + * The fleet's tables were authored against two different vocabularies: + * openbuild's shipped credit-approval table declares `integer` for + * `applicantAge`, which is not one of {@see UnaryTestEvaluator::VALID_TYPES}, + * so it falls back to `string`. + * + * Measured, that fallback costs nothing for numbers or dates — the unary-test + * grammar parses its operand and compares numerically, so `>=18` against 25 + * matches under either type, and ISO dates compare correctly as strings. The + * numeric and date aliases below are therefore alignment, not a bug fix. + * + * The boolean alias IS a bug fix: a real PHP `true` coerced to a string + * becomes `"1"`, which does not match a cell reading `true`, so a column + * declaring `bool` rather than `boolean` silently stops matching. That is the + * one case where the fallback changes the answer instead of the spelling. + * + * Aliasing is additive: a type this map does not know still falls back to + * `string` exactly as before. + * + * @var array + */ + private const TYPE_ALIASES = [ + 'int' => 'number', + 'integer' => 'number', + 'long' => 'number', + 'float' => 'number', + 'double' => 'number', + 'decimal' => 'number', + 'bool' => 'boolean', + 'datetime' => 'date', + 'timestamp' => 'date', + ]; + /** * Constructor. * @@ -384,7 +420,8 @@ private static function normaliseFields(array $fields): array { continue; } - $type = (string)($field['type'] ?? 'string'); + $type = strtolower((string)($field['type'] ?? 'string')); + $type = (self::TYPE_ALIASES[$type] ?? $type); if (in_array($type, UnaryTestEvaluator::VALID_TYPES, true) === false) { $type = 'string'; } diff --git a/openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md b/openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md index 127540eb20..40945a5a86 100644 --- a/openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md +++ b/openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md @@ -89,3 +89,35 @@ belongs on this engine rather than in a bespoke matrix service. - **GIVEN** a table over severity, behaviour and actorType - **WHEN** evaluated with a triple matching exactly one rule - **THEN** that rule's intervention is returned with its id + +### Requirement: REQ-SDT-005 A declared type is aliased onto the evaluator's own vocabulary + +Decision tables in the fleet were authored against two type vocabularies. openbuild's shipped +credit-approval table declares `integer`; this evaluator's own vocabulary is +`string | number | boolean | date`, and an unrecognised type falls back to `string`. + +Measured, that fallback is harmless for numbers and dates: the unary-test grammar parses its operand +and compares numerically, so `>=18` against 25 matches whether the column was called `number` or +`string`, and ISO dates compare correctly either way. It is NOT harmless for booleans. A real PHP +`true` coerced to a string becomes `"1"`, which does not match a cell reading `true`, so a column +declaring `bool` rather than `boolean` silently stops matching. + +The evaluator SHALL map the common spellings of its types onto them. The boolean alias closes the +silent mismatch above; the numeric and date aliases align the vocabularies so a table means the same +thing in either dialect, and are defensive rather than corrective. + +#### Scenario: A `bool` column matches a real boolean + +- **GIVEN** an input column declaring `bool` and the value `true` +- **WHEN** it is evaluated against a cell reading `true` +- **THEN** the rule matches, where before the value was stringified to `"1"` and did not + +#### Scenario: The alias set covers the spellings actually in use + +- **GIVEN** columns declaring `int`, `integer`, `long`, `float`, `double` or `decimal` +- **THEN** each is evaluated as `number` + +#### Scenario: An unrecognised type still falls back to string + +- **GIVEN** a column declaring a type in neither vocabulary +- **THEN** it is evaluated as `string`, as before this change diff --git a/tests/Unit/Service/Dmn/DecisionTableEvaluatorTest.php b/tests/Unit/Service/Dmn/DecisionTableEvaluatorTest.php index d2072ee747..1c0c1a9d99 100644 --- a/tests/Unit/Service/Dmn/DecisionTableEvaluatorTest.php +++ b/tests/Unit/Service/Dmn/DecisionTableEvaluatorTest.php @@ -327,4 +327,75 @@ public function testNoMatchIsAnError(): void { }//end testNoMatchIsAnError() + /** + * A `bool` column matches a real boolean. + * + * This is the one alias that changes an answer rather than a spelling. + * Without the map `bool` falls back to `string`, PHP `true` is coerced to + * `"1"`, and a cell reading `true` silently stops matching. + * + * @return void + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + public function testABoolColumnMatchesARealBoolean(): void { + $table = [ + 'hitPolicy' => 'FIRST', + 'inputs' => [['name' => 'consented', 'type' => 'bool']], + 'outputs' => [['name' => 'decision', 'type' => 'string']], + 'rules' => [['id' => 'yes', 'inputEntries' => ['true'], 'outputEntries' => ['proceed']]], + ]; + + $result = $this->evaluator()->evaluate($table, ['consented' => true]); + + $this->assertSame('proceed', $result['outputs']['decision']); + + }//end testABoolColumnMatchesARealBoolean() + + /** + * Every numeric spelling in fleet use maps onto `number`. + * + * @return void + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + public function testEveryNumericAliasIsTreatedAsANumber(): void { + foreach (['int', 'integer', 'long', 'float', 'double', 'decimal', 'INTEGER'] as $declared) { + $table = [ + 'hitPolicy' => 'FIRST', + 'inputs' => [['name' => 'score', 'type' => $declared]], + 'outputs' => [['name' => 'band', 'type' => 'string']], + 'rules' => [['id' => 'high', 'inputEntries' => ['>=600'], 'outputEntries' => ['ok']]], + ]; + + $result = $this->evaluator()->evaluate($table, ['score' => 700]); + + $this->assertSame('ok', $result['outputs']['band'], 'type ' . $declared . ' should compare numerically'); + } + + }//end testEveryNumericAliasIsTreatedAsANumber() + + /** + * A type in neither vocabulary still falls back to `string`. + * + * The alias map is additive; it must not turn an unknown type into an error. + * + * @return void + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + public function testAnUnrecognisedTypeStillFallsBackToString(): void { + $table = [ + 'hitPolicy' => 'FIRST', + 'inputs' => [['name' => 'severity', 'type' => 'wingdings']], + 'outputs' => [['name' => 'intervention', 'type' => 'string']], + 'rules' => [['id' => 'a', 'inputEntries' => ['gering'], 'outputEntries' => ['brief']]], + ]; + + $result = $this->evaluator()->evaluate($table, ['severity' => 'gering']); + + $this->assertSame('brief', $result['outputs']['intervention']); + + }//end testAnUnrecognisedTypeStillFallsBackToString() + }//end class diff --git a/tests/Unit/Service/Dmn/UnaryTestEvaluatorTest.php b/tests/Unit/Service/Dmn/UnaryTestEvaluatorTest.php new file mode 100644 index 0000000000..223f894dd2 --- /dev/null +++ b/tests/Unit/Service/Dmn/UnaryTestEvaluatorTest.php @@ -0,0 +1,364 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @link https://conduction.nl + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + +declare(strict_types=1); + +namespace Unit\Service\Dmn; + +use OCA\OpenRegister\Service\Dmn\DecisionEvaluationException; +use OCA\OpenRegister\Service\Dmn\UnaryTestEvaluator; +use PHPUnit\Framework\TestCase; + +/** + * @covers \OCA\OpenRegister\Service\Dmn\UnaryTestEvaluator + * + * @uses \OCA\OpenRegister\Service\Dmn\DecisionEvaluationException + */ +class UnaryTestEvaluatorTest extends TestCase { + + /** + * The evaluator under test. + * + * @var UnaryTestEvaluator + */ + private UnaryTestEvaluator $evaluator; + + /** + * @return void + */ + protected function setUp(): void { + parent::setUp(); + $this->evaluator = new UnaryTestEvaluator(); + }//end setUp() + + // ------------------------------------------------------------------ + // Wildcard + // ------------------------------------------------------------------ + + /** + * @return void + */ + public function testEmptyExpressionIsWildcard(): void { + self::assertTrue($this->evaluator->matches(expression: '', value: 'anything', type: 'string')); + }//end testEmptyExpressionIsWildcard() + + /** + * @return void + */ + public function testDashExpressionIsWildcard(): void { + self::assertTrue($this->evaluator->matches(expression: '-', value: 12345.0, type: 'number')); + }//end testDashExpressionIsWildcard() + + /** + * @return void + */ + public function testQuotedDashIsLiteralNotWildcard(): void { + self::assertTrue($this->evaluator->matches(expression: '"-"', value: '-', type: 'string')); + self::assertFalse($this->evaluator->matches(expression: '"-"', value: 'anything-else', type: 'string')); + }//end testQuotedDashIsLiteralNotWildcard() + + // ------------------------------------------------------------------ + // Comparison operators + // ------------------------------------------------------------------ + + /** + * @return void + */ + public function testLessThan(): void { + self::assertTrue($this->evaluator->matches(expression: '< 10', value: 5.0, type: 'number')); + self::assertFalse($this->evaluator->matches(expression: '< 10', value: 10.0, type: 'number')); + }//end testLessThan() + + /** + * @return void + */ + public function testLessThanOrEqual(): void { + self::assertTrue($this->evaluator->matches(expression: '<= 10', value: 10.0, type: 'number')); + self::assertFalse($this->evaluator->matches(expression: '<= 10', value: 10.5, type: 'number')); + }//end testLessThanOrEqual() + + /** + * @return void + */ + public function testGreaterThan(): void { + self::assertTrue($this->evaluator->matches(expression: '> 10', value: 11.0, type: 'number')); + self::assertFalse($this->evaluator->matches(expression: '> 10', value: 10.0, type: 'number')); + }//end testGreaterThan() + + /** + * @return void + */ + public function testGreaterThanOrEqual(): void { + self::assertTrue($this->evaluator->matches(expression: '>= 10', value: 10.0, type: 'number')); + self::assertFalse($this->evaluator->matches(expression: '>= 10', value: 9.999, type: 'number')); + }//end testGreaterThanOrEqual() + + /** + * @return void + */ + public function testEquals(): void { + self::assertTrue($this->evaluator->matches(expression: '= gold', value: 'gold', type: 'string')); + self::assertFalse($this->evaluator->matches(expression: '= gold', value: 'silver', type: 'string')); + }//end testEquals() + + /** + * @return void + */ + public function testNotEquals(): void { + self::assertTrue($this->evaluator->matches(expression: '!= gold', value: 'silver', type: 'string')); + self::assertFalse($this->evaluator->matches(expression: '!= gold', value: 'gold', type: 'string')); + }//end testNotEquals() + + /** + * @return void + */ + public function testOperatorWithNoOperandIsInvalidExpression(): void { + $this->expectException(DecisionEvaluationException::class); + try { + $this->evaluator->matches(expression: '>= ', value: 1.0, type: 'number'); + } catch (DecisionEvaluationException $e) { + self::assertSame('invalid_expression', $e->getErrorCode()); + throw $e; + } + }//end testOperatorWithNoOperandIsInvalidExpression() + + // ------------------------------------------------------------------ + // Ranges + // ------------------------------------------------------------------ + + /** + * @return void + */ + public function testInclusiveRangeBoundsMatch(): void { + self::assertTrue($this->evaluator->matches(expression: '[0..25000]', value: 0.0, type: 'number')); + self::assertTrue($this->evaluator->matches(expression: '[0..25000]', value: 25000.0, type: 'number')); + self::assertTrue($this->evaluator->matches(expression: '[0..25000]', value: 12500.0, type: 'number')); + self::assertFalse($this->evaluator->matches(expression: '[0..25000]', value: 25000.01, type: 'number')); + }//end testInclusiveRangeBoundsMatch() + + /** + * @return void + */ + public function testExclusiveRangeBoundsDoNotMatch(): void { + self::assertFalse($this->evaluator->matches(expression: '(25000..40000)', value: 25000.0, type: 'number')); + self::assertFalse($this->evaluator->matches(expression: '(25000..40000)', value: 40000.0, type: 'number')); + self::assertTrue($this->evaluator->matches(expression: '(25000..40000)', value: 30000.0, type: 'number')); + }//end testExclusiveRangeBoundsDoNotMatch() + + /** + * @return void + */ + public function testMixedRangeBoundaries(): void { + self::assertFalse($this->evaluator->matches(expression: '(25000..40000]', value: 25000.0, type: 'number')); + self::assertTrue($this->evaluator->matches(expression: '(25000..40000]', value: 40000.0, type: 'number')); + self::assertTrue($this->evaluator->matches(expression: '[25000..40000)', value: 25000.0, type: 'number')); + self::assertFalse($this->evaluator->matches(expression: '[25000..40000)', value: 40000.0, type: 'number')); + }//end testMixedRangeBoundaries() + + /** + * @return void + */ + public function testUnbalancedRangeIsInvalidExpression(): void { + $this->expectException(DecisionEvaluationException::class); + try { + $this->evaluator->matches(expression: '[1..', value: 5.0, type: 'number'); + } catch (DecisionEvaluationException $e) { + // No `..` match at all falls through to bare-literal coercion, + // which for a non-numeric literal like "[1.." on a number type + // surfaces as type_mismatch — still a clear, typed error, never + // a silent match/non-match. + self::assertContains($e->getErrorCode(), ['invalid_expression', 'type_mismatch']); + throw $e; + } + }//end testUnbalancedRangeIsInvalidExpression() + + /** + * @return void + */ + public function testMissingRangeBoundIsInvalidExpression(): void { + $this->expectException(DecisionEvaluationException::class); + try { + $this->evaluator->matches(expression: '[1..]', value: 5.0, type: 'number'); + } catch (DecisionEvaluationException $e) { + self::assertSame('invalid_expression', $e->getErrorCode()); + throw $e; + } + }//end testMissingRangeBoundIsInvalidExpression() + + /** + * @return void + */ + public function testNonNumericRangeBoundIsTypeMismatch(): void { + $this->expectException(DecisionEvaluationException::class); + try { + $this->evaluator->matches(expression: '[abc..100]', value: 5.0, type: 'number'); + } catch (DecisionEvaluationException $e) { + self::assertSame('type_mismatch', $e->getErrorCode()); + throw $e; + } + }//end testNonNumericRangeBoundIsTypeMismatch() + + // ------------------------------------------------------------------ + // Sets + // ------------------------------------------------------------------ + + /** + * @return void + */ + public function testSetMembershipUnquoted(): void { + self::assertTrue($this->evaluator->matches(expression: 'in (gold, silver, bronze)', value: 'silver', type: 'string')); + self::assertFalse($this->evaluator->matches(expression: 'in (gold, silver, bronze)', value: 'platinum', type: 'string')); + }//end testSetMembershipUnquoted() + + /** + * @return void + */ + public function testSetMembershipQuotedWithCommas(): void { + self::assertTrue($this->evaluator->matches(expression: 'in ("a b", "c,d")', value: 'c,d', type: 'string')); + self::assertTrue($this->evaluator->matches(expression: 'in ("a b", "c,d")', value: 'a b', type: 'string')); + }//end testSetMembershipQuotedWithCommas() + + /** + * @return void + */ + public function testSetMembershipCaseInsensitivePrefix(): void { + self::assertTrue($this->evaluator->matches(expression: 'IN (1, 2, 3)', value: 2.0, type: 'number')); + }//end testSetMembershipCaseInsensitivePrefix() + + // ------------------------------------------------------------------ + // Bare literal + // ------------------------------------------------------------------ + + /** + * @return void + */ + public function testBareLiteralString(): void { + self::assertTrue($this->evaluator->matches(expression: 'gold', value: 'gold', type: 'string')); + self::assertFalse($this->evaluator->matches(expression: 'gold', value: 'silver', type: 'string')); + }//end testBareLiteralString() + + /** + * @return void + */ + public function testBareLiteralNumber(): void { + self::assertTrue($this->evaluator->matches(expression: '42', value: 42.0, type: 'number')); + }//end testBareLiteralNumber() + + /** + * @return void + */ + public function testBareLiteralBooleanTrue(): void { + self::assertTrue($this->evaluator->matches(expression: 'true', value: true, type: 'boolean')); + self::assertFalse($this->evaluator->matches(expression: 'true', value: false, type: 'boolean')); + }//end testBareLiteralBooleanTrue() + + /** + * @return void + */ + public function testBareLiteralBooleanFalse(): void { + self::assertTrue($this->evaluator->matches(expression: 'false', value: false, type: 'boolean')); + }//end testBareLiteralBooleanFalse() + + // ------------------------------------------------------------------ + // coerce() — type coercion matrix + // ------------------------------------------------------------------ + + /** + * @return void + */ + public function testCoerceString(): void { + self::assertSame('42', $this->evaluator->coerce(value: 42, type: 'string')); + }//end testCoerceString() + + /** + * @return void + */ + public function testCoerceNumberFromString(): void { + self::assertSame(42.5, $this->evaluator->coerce(value: '42.5', type: 'number')); + }//end testCoerceNumberFromString() + + /** + * @return void + */ + public function testCoerceNumberRejectsNonNumeric(): void { + $this->expectException(DecisionEvaluationException::class); + try { + $this->evaluator->coerce(value: 'not-a-number', type: 'number'); + } catch (DecisionEvaluationException $e) { + self::assertSame('type_mismatch', $e->getErrorCode()); + throw $e; + } + }//end testCoerceNumberRejectsNonNumeric() + + /** + * @return void + */ + public function testCoerceBooleanFromString(): void { + self::assertTrue($this->evaluator->coerce(value: 'true', type: 'boolean')); + self::assertFalse($this->evaluator->coerce(value: 'false', type: 'boolean')); + }//end testCoerceBooleanFromString() + + /** + * @return void + */ + public function testCoerceBooleanRejectsGarbage(): void { + $this->expectException(DecisionEvaluationException::class); + try { + $this->evaluator->coerce(value: 'maybe', type: 'boolean'); + } catch (DecisionEvaluationException $e) { + self::assertSame('type_mismatch', $e->getErrorCode()); + throw $e; + } + }//end testCoerceBooleanRejectsGarbage() + + /** + * @return void + */ + public function testCoerceDateFromIsoString(): void { + $timestamp = $this->evaluator->coerce(value: '2026-01-01T00:00:00+00:00', type: 'date'); + self::assertIsInt($timestamp); + }//end testCoerceDateFromIsoString() + + /** + * @return void + */ + public function testCoerceDateRejectsUnparsable(): void { + $this->expectException(DecisionEvaluationException::class); + try { + $this->evaluator->coerce(value: 'not-a-date', type: 'date'); + } catch (DecisionEvaluationException $e) { + self::assertSame('type_mismatch', $e->getErrorCode()); + throw $e; + } + }//end testCoerceDateRejectsUnparsable() + + /** + * @return void + */ + public function testDateRangeComparison(): void { + $value = $this->evaluator->coerce(value: '2026-06-15', type: 'date'); + self::assertTrue($this->evaluator->matches(expression: '[2026-01-01..2026-12-31]', value: $value, type: 'date')); + self::assertFalse($this->evaluator->matches(expression: '[2027-01-01..2027-12-31]', value: $value, type: 'date')); + }//end testDateRangeComparison() +}//end class From 2680220dfbdb0e6462a90212e9cf864507156dbc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:34:26 +0200 Subject: [PATCH 18/19] chore(release): 2.0.5-unstable.20260831053109 (#3193) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- appinfo/info.xml | 2 +- openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 8c82b5f68f..15deb5804d 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata Vrij en open source onder de EUPL-licentie. ]]> - 1.1.15-unstable.20260830153944 + 2.0.5-unstable.20260831053109 EUPL-1.2 Conduction OpenRegister diff --git a/openapi.json b/openapi.json index f1a9f041fc..0a5968ed65 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "openregister", - "version": "1.1.15-unstable.20260830153944", + "version": "2.0.5-unstable.20260831053109", "description": "Open Register", "license": { "name": "EUPL-1.2" From fe3057345eee860e6fdbea62cea76b17017a7894 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 09:45:17 +0200 Subject: [PATCH 19/19] chore(deps): take @conduction/nextcloud-vue 2.27.0 (#3194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries the two KPI-card fixes this dashboard reads: the canonical card is flat and horizontal (no grey box inside the white card CnWidgetWrapper already draws), and a calendar-aligned date range follows the reader's calendar rather than UTC — "Current month" previously showed a To of 1 September in CEST on 31 August. The caret range already allowed it; only the lockfile pinned this app back. Lockfile only, and npm pruned nothing. --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ac47ce0af9..e6967e9cf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2289,9 +2289,9 @@ } }, "node_modules/@conduction/nextcloud-vue": { - "version": "2.25.1", - "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.25.1.tgz", - "integrity": "sha512-c5sY4XnHrfGUIUEatJEIT5jK78k9REj2UCqXylbHiDPkv1HpUx7Sf3XtfDxAd4xPjn/kbO094rNqShK4tPnY7w==", + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.27.0.tgz", + "integrity": "sha512-64IS6VrAp4c+Hr9AAS9/z6AzznmaPfbO4GrZtmOX57dLCpJORkn5M/wSKpAC6ZG+gUHTJeXDUiabut9bV0kRnQ==", "license": "EUPL-1.2", "dependencies": { "@ckpack/vue-color": "^1.6.0",