diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 7a8476c503..0535df19ae 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -26,6 +26,16 @@ 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. 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"
+ update-types: ["version-update:semver-major"]
- dependency-name: "webpack-cli"
update-types: ["version-update:semver-major"]
- dependency-name: "@babel/core"
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/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 c1ce99d490..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",
@@ -3513,20 +3525,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 +3618,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 +3630,7 @@
"type": "patreon"
}
],
- "time": "2026-08-06T16:21:11+00:00"
+ "time": "2026-08-30T13:53:49+00:00"
},
{
"name": "symfony/console",
@@ -6002,23 +6014,24 @@
},
{
"name": "web-token/jwt-library",
- "version": "4.1.9",
+ "version": "4.2.2",
"source": {
"type": "git",
"url": "https://github.com/web-token/jwt-library.git",
- "reference": "cbd52c8e96b835498f0f9c3a70a94d9f0e9e6c69"
+ "reference": "ae642340ee2ca91ca0c37edd72a9d06302651ca9"
},
"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/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"
+ "spomky-labs/pki-framework": "^1.2.1",
+ "symfony/deprecation-contracts": "^2.5|^3.0"
},
"conflict": {
"spomky-labs/jose": "*"
@@ -6075,7 +6088,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.2"
},
"funding": [
{
@@ -6087,7 +6100,7 @@
"type": "patreon"
}
],
- "time": "2026-08-26T09:47:42+00:00"
+ "time": "2026-08-30T13:53:36+00:00"
},
{
"name": "webonyx/graphql-php",
@@ -8925,21 +8938,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": {
@@ -9003,7 +9016,7 @@
"type": "thanks_dev"
}
],
- "time": "2025-11-12T23:06:57+00:00"
+ "time": "2026-07-27T11:13:17+00:00"
},
{
"name": "phpcsstandards/phpcsutils",
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/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/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/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/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..7ca2f0a35c
--- /dev/null
+++ b/lib/Service/Dmn/DecisionTableEvaluator.php
@@ -0,0 +1,434 @@
+ 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'];
+
+ /**
+ * 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.
+ *
+ * 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 = strtolower((string)($field['type'] ?? 'string'));
+ $type = (self::TYPE_ALIASES[$type] ?? $type);
+ 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/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"
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/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..40945a5a86
--- /dev/null
+++ b/openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md
@@ -0,0 +1,123 @@
+# 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
+
+### 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/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/package-lock.json b/package-lock.json
index 39ca5aaadd..e6967e9cf5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,14 +14,14 @@
"@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",
"@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",
@@ -29,12 +29,12 @@
"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",
"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"
},
@@ -54,8 +54,8 @@
"@playwright/test": "^1.49.0",
"@stoplight/spectral-cli": "^6.15.0",
"@types/jest": "^29.5.12",
- "@types/node": "^20.17.23",
- "@typescript-eslint/parser": "^8.67.0",
+ "@types/node": "^26.4.0",
+ "@typescript-eslint/parser": "^8.68.0",
"@vue/test-utils": "^2.4.4",
"@vue/vue3-jest": "^29.2.6",
"axe-core": "^4.10.0",
@@ -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",
@@ -87,9 +87,125 @@
"npm": "^11.0.0"
},
"peerDependencies": {
- "vue": "^3.5.0"
+ "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",
@@ -2173,9 +2289,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.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",
@@ -2239,7 +2355,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"
@@ -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",
@@ -4533,15 +4957,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",
@@ -4549,12 +4974,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": {
@@ -5058,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",
@@ -6812,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",
@@ -7073,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": {
@@ -7084,12 +7482,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",
@@ -7119,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": {
@@ -7239,12 +7631,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",
@@ -7346,16 +7732,39 @@
}
},
"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==",
"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",
+ "@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": {
+ "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-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==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.68.0",
+ "@typescript-eslint/types": "^8.68.0",
"debug": "^4.4.3"
},
"engines": {
@@ -7365,9 +7774,129 @@
"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"
+ "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==",
+ "dev": 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==",
+ "dev": 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==",
+ "dev": 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==",
+ "dev": 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==",
+ "dev": 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==",
+ "dev": 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==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
}
},
"node_modules/@typescript-eslint/project-service": {
@@ -7814,48 +8343,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",
@@ -8295,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",
@@ -8383,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",
@@ -8404,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",
@@ -10519,9 +10974,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",
@@ -10591,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",
@@ -10737,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": {
@@ -11231,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",
@@ -11775,39 +12202,6 @@
"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/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,
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/eslint": {
"version": "10.9.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz",
@@ -13681,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": {
@@ -13794,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": {
@@ -15138,42 +15541,248 @@
"dev": true,
"license": "MIT",
"dependencies": {
- "@jest/types": "^29.6.3",
- "chalk": "^4.0.0",
- "jest-get-type": "^29.6.3",
- "jest-util": "^29.7.0",
- "pretty-format": "^29.7.0"
+ "@jest/types": "^29.6.3",
+ "chalk": "^4.0.0",
+ "jest-get-type": "^29.6.3",
+ "jest-util": "^29.7.0",
+ "pretty-format": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-environment-jsdom": {
+ "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": "30.5.0",
+ "@jest/environment-jsdom-abstract": "30.5.0",
+ "@types/jsdom": "^21.1.7",
+ "jsdom": "^26.1.0"
+ },
+ "engines": {
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "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": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
- "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==",
+ "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/environment": "^29.7.0",
- "@jest/fake-timers": "^29.7.0",
- "@jest/types": "^29.6.3",
- "@types/jsdom": "^20.0.0",
+ "@jest/types": "30.5.0",
"@types/node": "*",
- "jest-mock": "^29.7.0",
- "jest-util": "^29.7.0",
- "jsdom": "^20.0.0"
+ "chalk": "^4.1.2",
+ "ci-info": "^4.2.0",
+ "graceful-fs": "^4.2.11",
+ "picomatch": "^4.0.3"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "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"
},
- "peerDependencies": {
- "canvas": "^2.5.0"
+ "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"
},
- "peerDependenciesMeta": {
- "canvas": {
- "optional": true
- }
+ "engines": {
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/jest-environment-node": {
@@ -15662,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": {
@@ -15707,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": {
@@ -15731,6 +16348,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"
@@ -15770,6 +16388,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"
@@ -17924,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"
},
@@ -19283,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",
@@ -20169,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",
@@ -22575,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",
@@ -22628,12 +23261,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",
@@ -22646,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": {
@@ -23187,6 +23801,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",
@@ -23233,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": {
@@ -23767,16 +24406,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": "*"
@@ -23949,17 +24588,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",
@@ -23970,8 +24608,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"
@@ -23998,69 +24635,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",
@@ -24115,6 +24689,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",
@@ -24144,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": {
@@ -24558,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",
@@ -24568,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": {
@@ -24943,21 +25614,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 71ed5a18e2..71e8f90a5f 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",
@@ -68,14 +68,14 @@
"@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",
"@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",
@@ -83,12 +83,12 @@
"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",
"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"
},
@@ -108,8 +108,8 @@
"@playwright/test": "^1.49.0",
"@stoplight/spectral-cli": "^6.15.0",
"@types/jest": "^29.5.12",
- "@types/node": "^20.17.23",
- "@typescript-eslint/parser": "^8.67.0",
+ "@types/node": "^26.4.0",
+ "@typescript-eslint/parser": "^8.68.0",
"@vue/test-utils": "^2.4.4",
"@vue/vue3-jest": "^29.2.6",
"axe-core": "^4.10.0",
@@ -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",
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
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
diff --git a/tests/Unit/Service/Dmn/DecisionTableEvaluatorTest.php b/tests/Unit/Service/Dmn/DecisionTableEvaluatorTest.php
new file mode 100644
index 0000000000..1c0c1a9d99
--- /dev/null
+++ b/tests/Unit/Service/Dmn/DecisionTableEvaluatorTest.php
@@ -0,0 +1,401 @@
+
+ * 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()
+
+ /**
+ * 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