From 14869cc6d6e196295616fa707a7e61f5e50ba1b5 Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 31 Mar 2026 09:59:53 +0300 Subject: [PATCH 01/23] Safe delete shared tables v1 --- src/Appwrite/Platform/Workers/Deletes.php | 24 ++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 716969e67a..35892218d9 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -765,11 +765,29 @@ class Deletes extends Action $databasesToClean )); } elseif ($sharedTablesV1) { + var_dump('================= Delete Shared table v1 =================='); + var_dump($projectCollectionIds); + $metadataDocuments = $dbForProject->find(Database::METADATA, [ + Query::equal('$id', $projectCollectionIds), + Query::limit(PHP_INT_MAX) + ]); + + $queries = [ + Query::orderAsc() + ]; + + foreach ($metadataDocuments as $metadataDoc) { + if (empty($metadataDoc->getTenant())) { + var_dump('=== Found null metadata ==='); + var_dump($metadataDoc->getId()); + var_dump($metadataDoc->getSequence()); + $queries[] = Query::notEqual('$sequence', $metadataDoc->getSequence()); + } + } + $this->deleteByGroup( Database::METADATA, - [ - Query::orderAsc() - ], + $queries, $dbForProject ); } elseif ($sharedTablesV2) { From 2a9e423cb1924f72a00023e4a5b10c859db7539c Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 31 Mar 2026 12:54:33 +0300 Subject: [PATCH 02/23] Temporary disabling deletes from internal collections --- src/Appwrite/Platform/Workers/Deletes.php | 26 +++++++---------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 35892218d9..6cb85ca492 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -765,25 +765,15 @@ class Deletes extends Action $databasesToClean )); } elseif ($sharedTablesV1) { - var_dump('================= Delete Shared table v1 =================='); - var_dump($projectCollectionIds); - $metadataDocuments = $dbForProject->find(Database::METADATA, [ - Query::equal('$id', $projectCollectionIds), - Query::limit(PHP_INT_MAX) - ]); + /** + * Temporary disabling deletes from internal collections + */ + $queries = \array_map( + fn ($id) => Query::notEqual('$id', $id), + $projectCollectionIds + ); - $queries = [ - Query::orderAsc() - ]; - - foreach ($metadataDocuments as $metadataDoc) { - if (empty($metadataDoc->getTenant())) { - var_dump('=== Found null metadata ==='); - var_dump($metadataDoc->getId()); - var_dump($metadataDoc->getSequence()); - $queries[] = Query::notEqual('$sequence', $metadataDoc->getSequence()); - } - } + $queries[] = Query::orderAsc(); $this->deleteByGroup( Database::METADATA, From 2c4770d29b695ba4152a6511c20db2b6264e418d Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 31 Mar 2026 19:18:32 +0300 Subject: [PATCH 03/23] Database 5.3.19 --- composer.lock | 84 +++++++++++++++++++++------------------------------ 1 file changed, 34 insertions(+), 50 deletions(-) diff --git a/composer.lock b/composer.lock index 1441bf06d1..43355afb6e 100644 --- a/composer.lock +++ b/composer.lock @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.17", + "version": "5.3.19", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "cff2b6ed63d3291b74110d086e16ff089fe05993" + "reference": "72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/cff2b6ed63d3291b74110d086e16ff089fe05993", - "reference": "cff2b6ed63d3291b74110d086e16ff089fe05993", + "url": "https://api.github.com/repos/utopia-php/database/zipball/72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691", + "reference": "72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691", "shasum": "" }, "require": { @@ -3903,9 +3903,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.17" + "source": "https://github.com/utopia-php/database/tree/5.3.19" }, - "time": "2026-03-20T01:18:52+00:00" + "time": "2026-03-31T15:52:08+00:00" }, { "name": "utopia-php/detector", @@ -5439,16 +5439,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.14.0", + "version": "1.16.4", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "7e7e257b10a8c1384a237e7d8d73452e2108901e" + "reference": "a56cb7942afbcbbc11a323e274a8798539ebebb9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/7e7e257b10a8c1384a237e7d8d73452e2108901e", - "reference": "7e7e257b10a8c1384a237e7d8d73452e2108901e", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/a56cb7942afbcbbc11a323e274a8798539ebebb9", + "reference": "a56cb7942afbcbbc11a323e274a8798539ebebb9", "shasum": "" }, "require": { @@ -5484,22 +5484,22 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.14.0" + "source": "https://github.com/appwrite/sdk-generator/tree/1.16.4" }, - "time": "2026-03-26T12:50:11+00:00" + "time": "2026-03-31T13:07:14+00:00" }, { "name": "brianium/paratest", - "version": "v7.19.2", + "version": "v7.20.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9" + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", - "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d", "shasum": "" }, "require": { @@ -5523,7 +5523,7 @@ "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.40", + "phpstan/phpstan": "^2.1.44", "phpstan/phpstan-deprecation-rules": "^2.0.4", "phpstan/phpstan-phpunit": "^2.0.16", "phpstan/phpstan-strict-rules": "^2.0.10", @@ -5567,7 +5567,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.19.2" + "source": "https://github.com/paratestphp/paratest/tree/v7.20.0" }, "funding": [ { @@ -5579,7 +5579,7 @@ "type": "paypal" } ], - "time": "2026-03-09T14:33:17+00:00" + "time": "2026-03-29T15:46:14+00:00" }, { "name": "czproject/git-php", @@ -6195,11 +6195,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.44", + "version": "2.1.45", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4a88c083c668b2c364a425c9b3171b2d9ea5d218", - "reference": "4a88c083c668b2c364a425c9b3171b2d9ea5d218", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/f8cdfd9421b7edb7686a2d150a234870464eac70", + "reference": "f8cdfd9421b7edb7686a2d150a234870464eac70", "shasum": "" }, "require": { @@ -6244,7 +6244,7 @@ "type": "github" } ], - "time": "2026-03-25T17:34:21+00:00" + "time": "2026-03-30T13:22:02+00:00" }, { "name": "phpunit/php-code-coverage", @@ -6594,16 +6594,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.14", + "version": "12.5.15", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0" + "reference": "aeb6899ffdbbf4b4ff5e6b6ebb77b35c51bb6d9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/aeb6899ffdbbf4b4ff5e6b6ebb77b35c51bb6d9a", + "reference": "aeb6899ffdbbf4b4ff5e6b6ebb77b35c51bb6d9a", "shasum": "" }, "require": { @@ -6625,7 +6625,7 @@ "sebastian/cli-parser": "^4.2.0", "sebastian/comparator": "^7.1.4", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.3", + "sebastian/environment": "^8.0.4", "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", @@ -6672,31 +6672,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.15" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-18T12:38:40+00:00" + "time": "2026-03-31T06:41:33+00:00" }, { "name": "sebastian/cli-parser", @@ -8435,7 +8419,7 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -8456,5 +8440,5 @@ "platform-dev": { "ext-fileinfo": "*" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } From e42a26fdad247d7ca0f8313b280872fcefe3d5b0 Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 31 Mar 2026 19:20:13 +0300 Subject: [PATCH 04/23] comment --- src/Appwrite/Platform/Workers/Deletes.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 6cb85ca492..750fafbaf8 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -766,7 +766,7 @@ class Deletes extends Action )); } elseif ($sharedTablesV1) { /** - * Temporary disabling deletes from internal collections + * Temporary disabling deletes for internal collections */ $queries = \array_map( fn ($id) => Query::notEqual('$id', $id), From 775d21e5ee2ea5f4383c9ba63e404effcc398fcf Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 5 Apr 2026 10:08:29 +0300 Subject: [PATCH 05/23] lock --- composer.lock | 92 +++++++++++++++++++++++++-------------------------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/composer.lock b/composer.lock index 43355afb6e..711ee8a852 100644 --- a/composer.lock +++ b/composer.lock @@ -161,16 +161,16 @@ }, { "name": "appwrite/php-runtimes", - "version": "0.19.4", + "version": "0.19.5", "source": { "type": "git", "url": "https://github.com/appwrite/runtimes.git", - "reference": "eea9d1b3ca2540eab623b419c8afde09ef406c0b" + "reference": "aa2f7760cd0493c0880209b92df812c9386b3546" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/runtimes/zipball/eea9d1b3ca2540eab623b419c8afde09ef406c0b", - "reference": "eea9d1b3ca2540eab623b419c8afde09ef406c0b", + "url": "https://api.github.com/repos/appwrite/runtimes/zipball/aa2f7760cd0493c0880209b92df812c9386b3546", + "reference": "aa2f7760cd0493c0880209b92df812c9386b3546", "shasum": "" }, "require": { @@ -210,9 +210,9 @@ ], "support": { "issues": "https://github.com/appwrite/runtimes/issues", - "source": "https://github.com/appwrite/runtimes/tree/0.19.4" + "source": "https://github.com/appwrite/runtimes/tree/0.19.5" }, - "time": "2026-02-17T10:04:39+00:00" + "time": "2026-04-01T01:39:23+00:00" }, { "name": "brick/math", @@ -2708,16 +2708,16 @@ }, { "name": "symfony/http-client", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af" + "reference": "01933e626c3de76bea1e22641e205e78f6a34342" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/1010624285470eb60e88ed10035102c75b4ea6af", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af", + "url": "https://api.github.com/repos/symfony/http-client/zipball/01933e626c3de76bea1e22641e205e78f6a34342", + "reference": "01933e626c3de76bea1e22641e205e78f6a34342", "shasum": "" }, "require": { @@ -2785,7 +2785,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.7" + "source": "https://github.com/symfony/http-client/tree/v7.4.8" }, "funding": [ { @@ -2805,7 +2805,7 @@ "type": "tidelift" } ], - "time": "2026-03-05T11:16:58+00:00" + "time": "2026-03-30T12:55:43+00:00" }, { "name": "symfony/http-client-contracts", @@ -5439,16 +5439,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.16.4", + "version": "1.17.1", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "a56cb7942afbcbbc11a323e274a8798539ebebb9" + "reference": "bd6139cbba4d194f1729fe8eb378014d8b6589c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/a56cb7942afbcbbc11a323e274a8798539ebebb9", - "reference": "a56cb7942afbcbbc11a323e274a8798539ebebb9", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/bd6139cbba4d194f1729fe8eb378014d8b6589c5", + "reference": "bd6139cbba4d194f1729fe8eb378014d8b6589c5", "shasum": "" }, "require": { @@ -5484,9 +5484,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.16.4" + "source": "https://github.com/appwrite/sdk-generator/tree/1.17.1" }, - "time": "2026-03-31T13:07:14+00:00" + "time": "2026-04-04T10:24:20+00:00" }, { "name": "brianium/paratest", @@ -6195,11 +6195,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.45", + "version": "2.1.46", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/f8cdfd9421b7edb7686a2d150a234870464eac70", - "reference": "f8cdfd9421b7edb7686a2d150a234870464eac70", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", + "reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", "shasum": "" }, "require": { @@ -6244,7 +6244,7 @@ "type": "github" } ], - "time": "2026-03-30T13:22:02+00:00" + "time": "2026-04-01T09:25:14+00:00" }, { "name": "phpunit/php-code-coverage", @@ -6594,16 +6594,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.15", + "version": "12.5.16", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "aeb6899ffdbbf4b4ff5e6b6ebb77b35c51bb6d9a" + "reference": "b2429f58ae75cae980b5bb9873abe4de6aac8b58" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/aeb6899ffdbbf4b4ff5e6b6ebb77b35c51bb6d9a", - "reference": "aeb6899ffdbbf4b4ff5e6b6ebb77b35c51bb6d9a", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b2429f58ae75cae980b5bb9873abe4de6aac8b58", + "reference": "b2429f58ae75cae980b5bb9873abe4de6aac8b58", "shasum": "" }, "require": { @@ -6672,7 +6672,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.15" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.16" }, "funding": [ { @@ -6680,7 +6680,7 @@ "type": "other" } ], - "time": "2026-03-31T06:41:33+00:00" + "time": "2026-04-03T05:26:42+00:00" }, { "name": "sebastian/cli-parser", @@ -7665,16 +7665,16 @@ }, { "name": "symfony/console", - "version": "v8.0.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a" + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", - "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", + "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7", + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7", "shasum": "" }, "require": { @@ -7731,7 +7731,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.7" + "source": "https://github.com/symfony/console/tree/v8.0.8" }, "funding": [ { @@ -7751,7 +7751,7 @@ "type": "tidelift" } ], - "time": "2026-03-06T14:06:22+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/polyfill-ctype", @@ -8085,16 +8085,16 @@ }, { "name": "symfony/process", - "version": "v8.0.5", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674" + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", - "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", + "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", "shasum": "" }, "require": { @@ -8126,7 +8126,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.5" + "source": "https://github.com/symfony/process/tree/v8.0.8" }, "funding": [ { @@ -8146,20 +8146,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:08:38+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/string", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", "shasum": "" }, "require": { @@ -8216,7 +8216,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.6" + "source": "https://github.com/symfony/string/tree/v8.0.8" }, "funding": [ { @@ -8236,7 +8236,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "textalk/websocket", From 31728c9b72d8953fe80d671a81ddbd762b30ed07 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 6 Apr 2026 09:29:58 +0300 Subject: [PATCH 06/23] Update lock --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 4bf1fc072b..c370e02cd1 100644 --- a/composer.lock +++ b/composer.lock @@ -5439,16 +5439,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.17.1", + "version": "1.17.2", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "bd6139cbba4d194f1729fe8eb378014d8b6589c5" + "reference": "a96a90d95849a44bf940b392925abb2af6813110" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/bd6139cbba4d194f1729fe8eb378014d8b6589c5", - "reference": "bd6139cbba4d194f1729fe8eb378014d8b6589c5", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/a96a90d95849a44bf940b392925abb2af6813110", + "reference": "a96a90d95849a44bf940b392925abb2af6813110", "shasum": "" }, "require": { @@ -5484,9 +5484,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.17.1" + "source": "https://github.com/appwrite/sdk-generator/tree/1.17.2" }, - "time": "2026-04-04T10:24:20+00:00" + "time": "2026-04-05T15:46:09+00:00" }, { "name": "brianium/paratest", From 2807d6cd9a60240a3c5a3bf93a4fa172ee658894 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Mon, 13 Apr 2026 09:57:27 +0100 Subject: [PATCH 07/23] feat: increase default build timeout to 45 minutes Raises _APP_COMPUTE_BUILD_TIMEOUT default from 900s (15 min) to 2700s (45 min) to support longer-running builds. Co-Authored-By: Claude Sonnet 4.6 --- app/config/variables.php | 8 ++++---- src/Appwrite/Vcs/Comment.php | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/config/variables.php b/app/config/variables.php index 7a3ed13049..c834656ff4 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -872,18 +872,18 @@ return [ ], [ 'name' => '_APP_FUNCTIONS_BUILD_TIMEOUT', - 'description' => 'Deprecated since 1.7.0. The maximum number of seconds allowed as a timeout value when building a new function. The default value is 900 seconds.', + 'description' => 'Deprecated since 1.7.0. The maximum number of seconds allowed as a timeout value when building a new function. The default value is 2700 seconds.', 'introduction' => '0.13.0', - 'default' => '900', + 'default' => '2700', 'required' => false, 'question' => '', 'filter' => '' ], [ 'name' => '_APP_COMPUTE_BUILD_TIMEOUT', - 'description' => 'The maximum number of seconds allowed as a timeout value when building a new function or site. The default value is 900 seconds.', + 'description' => 'The maximum number of seconds allowed as a timeout value when building a new function or site. The default value is 2700 seconds.', 'introduction' => '1.7.0', - 'default' => '900', + 'default' => '2700', 'required' => false, 'question' => '', 'filter' => '' diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php index e6d6996748..e9fa460bb4 100644 --- a/src/Appwrite/Vcs/Comment.php +++ b/src/Appwrite/Vcs/Comment.php @@ -22,7 +22,7 @@ class Comment 'Every Git commit and branch gets its own deployment URL automatically', 'Custom domains work with both CNAME for subdomains and NS records for apex domains', 'HTTPS and SSL certificates are handled automatically for all your Sites', - 'Functions can run for up to 15 minutes before timing out', + 'Functions can run for up to 45 minutes before timing out', 'Schedule functions to run as often as every minute with cron expressions', 'Environment variables can be scoped per function or shared across your project', 'Function scopes give you fine-grained control over API permissions', From df5ccc10ad3f0f9edc3d89ad9334c753213c63a1 Mon Sep 17 00:00:00 2001 From: "Luke B. Silver" <22452787+loks0n@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:48:20 +0100 Subject: [PATCH 08/23] Apply suggestion from @greptile-apps[bot] Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/Appwrite/Vcs/Comment.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php index e9fa460bb4..148b29c1d1 100644 --- a/src/Appwrite/Vcs/Comment.php +++ b/src/Appwrite/Vcs/Comment.php @@ -22,7 +22,7 @@ class Comment 'Every Git commit and branch gets its own deployment URL automatically', 'Custom domains work with both CNAME for subdomains and NS records for apex domains', 'HTTPS and SSL certificates are handled automatically for all your Sites', - 'Functions can run for up to 45 minutes before timing out', + 'Function builds can take up to 45 minutes before timing out', 'Schedule functions to run as often as every minute with cron expressions', 'Environment variables can be scoped per function or shared across your project', 'Function scopes give you fine-grained control over API permissions', From 3263133e5f7332e67a1641bba38e52e520270dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Apr 2026 14:50:06 +0200 Subject: [PATCH 09/23] Implement secure webhook interfaces --- .../Platform/Modules/Webhooks/Http/Webhooks/Create.php | 5 ++++- .../Webhooks/Http/Webhooks/Signature/Update.php | 10 +++++++--- .../Platform/Modules/Webhooks/Services/Http.php | 4 ++-- src/Appwrite/Utopia/Response/Model/Webhook.php | 8 +++++++- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php index e60810417c..e192dff2a0 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php @@ -21,6 +21,7 @@ use Utopia\Platform\Scope\HTTP; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Multiple; +use Utopia\Validator\Nullable; use Utopia\Validator\Text; use Utopia\Validator\URL; @@ -68,6 +69,7 @@ class Create extends Action ->param('tls', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) ->param('authUsername', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) ->param('authPassword', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) + ->param('secret', null, new Nullable(new Text(256, 8)), 'Webhook secret key. If not provided, a new key will be generated automatically. Key must be at least 8 characters long, and at max 256 characters.', optional: true) ->inject('response') ->inject('project') ->inject('queueForEvents') @@ -88,6 +90,7 @@ class Create extends Action bool $tls, string $authUsername, string $authPassword, + ?string $secret, Response $response, Document $project, QueueEvent $queueForEvents, @@ -107,7 +110,7 @@ class Create extends Action 'security' => $tls, 'httpUser' => $authUsername, 'httpPass' => $authPassword, - 'signatureKey' => \bin2hex(\random_bytes(64)), + 'signatureKey' => $secret ?? \bin2hex(\random_bytes(64)), 'enabled' => $enabled, ]); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php index e36fa5c2ce..fbff94735e 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php @@ -1,6 +1,6 @@ param('webhookId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook ID.', false, ['dbForPlatform']) + ->param('secret', null, new Nullable(new Text(256, 8)), 'Webhook secret key. If not provided, a new key will be generated automatically. Key must be at least 8 characters long, and at max 256 characters.', optional: true) ->inject('response') ->inject('project') ->inject('queueForEvents') @@ -62,6 +65,7 @@ class Update extends Action public function action( string $webhookId, + ?string $secret, Response $response, Document $project, QueueEvent $queueForEvents, @@ -78,7 +82,7 @@ class Update extends Action } $updates = new Document([ - 'signatureKey' => \bin2hex(\random_bytes(64)), + 'signatureKey' => $secret ?? \bin2hex(\random_bytes(64)), ]); $webhook = $authorization->skip(fn () => $dbForPlatform->updateDocument('webhooks', $webhook->getId(), $updates)); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php b/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php index 4805de6ebc..0e9c39a762 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php @@ -6,7 +6,7 @@ use Appwrite\Platform\Modules\Webhooks\Http\Init; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Create as CreateWebhook; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Delete as DeleteWebhook; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Get as GetWebhook; -use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Signature\Update as UpdateWebhookSignature; +use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Secret\Update as UpdateWebhookSecret; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Update as UpdateWebhook; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\XList as ListWebhooks; use Utopia\Platform\Service; @@ -26,6 +26,6 @@ class Http extends Service $this->addAction(GetWebhook::getName(), new GetWebhook()); $this->addAction(DeleteWebhook::getName(), new DeleteWebhook()); $this->addAction(UpdateWebhook::getName(), new UpdateWebhook()); - $this->addAction(UpdateWebhookSignature::getName(), new UpdateWebhookSignature()); + $this->addAction(UpdateWebhookSecret::getName(), new UpdateWebhookSecret()); } } diff --git a/src/Appwrite/Utopia/Response/Model/Webhook.php b/src/Appwrite/Utopia/Response/Model/Webhook.php index c2a2c183b1..25c2c21d4a 100644 --- a/src/Appwrite/Utopia/Response/Model/Webhook.php +++ b/src/Appwrite/Utopia/Response/Model/Webhook.php @@ -69,12 +69,16 @@ class Webhook extends Model 'default' => '', 'example' => 'password', ]) + /* + Not exposed for security; Secret is currently write-only. + ->addRule('secret', [ 'type' => self::TYPE_STRING, 'description' => 'Signature key which can be used to validate incoming webhook payloads.', 'default' => '', 'example' => 'ad3d581ca230e2b7059c545e5a', ]) + */ ->addRule('enabled', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'Indicates if this webhook is enabled.', @@ -106,7 +110,9 @@ class Webhook extends Model $document->setAttribute('authPassword', $document->getAttribute('httpPass')); $document->removeAttribute('httpPass'); - $document->setAttribute('secret', $document->getAttribute('signatureKey')); + // Would be 'secret', but we want it to be write-only. + // $document->setAttribute('secret', $document->getAttribute('signatureKey')); + // Remove DB-level attribute, just to be sure. $document->removeAttribute('signatureKey'); return $document; From 28d285d5c5025416448beb3f27453f577073789c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Apr 2026 14:53:06 +0200 Subject: [PATCH 10/23] Improved tests for webhook edge cases --- tests/e2e/Services/Webhooks/WebhooksBase.php | 359 ++++++++++++++++++- 1 file changed, 356 insertions(+), 3 deletions(-) diff --git a/tests/e2e/Services/Webhooks/WebhooksBase.php b/tests/e2e/Services/Webhooks/WebhooksBase.php index 82181aa858..fc75a3ede9 100644 --- a/tests/e2e/Services/Webhooks/WebhooksBase.php +++ b/tests/e2e/Services/Webhooks/WebhooksBase.php @@ -708,6 +708,351 @@ trait WebhooksBase $this->deleteWebhook($webhookId); } + public function testSecretRotationZeroDowntime(): void + { + // Create webhook pointing to request-catcher so deliveries are captured + $webhook = $this->createWebhook( + ID::unique(), + 'Rotation Test Webhook', + ['users.*.create'], + null, + 'http://request-catcher-webhook:5000/', + false, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + $originalSecret = $webhook['body']['secret']; + $this->assertNotEmpty($originalSecret); + $this->assertEquals(128, \strlen($originalSecret)); + + // Step 1: Trigger user creation with the original auto-generated secret + $email1 = uniqid() . 'rotation1@localhost.test'; + $user1 = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email1, + 'password' => 'password', + 'name' => 'Rotation User 1', + ]); + + $this->assertEquals(201, $user1['headers']['status-code']); + $userId1 = $user1['body']['$id']; + + // Verify webhook delivery is signed with the original secret + $this->assertEventually(function () use ($userId1, $originalSecret) { + $delivery = $this->getLastRequest(function (array $request) use ($userId1) { + $this->assertStringContainsString( + "users.{$userId1}.create", + $request['headers']['X-Appwrite-Webhook-Events'] ?? '' + ); + }); + + $this->assertNotEmpty($delivery); + $payload = json_encode($delivery['data']); + $url = $delivery['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $originalSecret, true)); + $this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']); + }, 15000, 500); + + // Step 2: Rotate the secret to a known custom value + $newSecret = 'new-key-after-rotation'; + $updated = $this->updateWebhookSecret($webhookId, $newSecret); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($newSecret, $updated['body']['secret']); + $this->assertNotEquals($originalSecret, $updated['body']['secret']); + + // Step 3: Trigger another user creation — should be signed with the new secret + $email2 = uniqid() . 'rotation2@localhost.test'; + $user2 = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email2, + 'password' => 'password', + 'name' => 'Rotation User 2', + ]); + + $this->assertEquals(201, $user2['headers']['status-code']); + $userId2 = $user2['body']['$id']; + + // Verify webhook delivery is signed with the new rotated secret + $this->assertEventually(function () use ($userId2, $newSecret) { + $delivery = $this->getLastRequest(function (array $request) use ($userId2) { + $this->assertStringContainsString( + "users.{$userId2}.create", + $request['headers']['X-Appwrite-Webhook-Events'] ?? '' + ); + }); + + $this->assertNotEmpty($delivery); + $payload = json_encode($delivery['data']); + $url = $delivery['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $newSecret, true)); + $this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']); + }, 15000, 500); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testCreateWebhookWithCustomSecret(): void + { + $customSecret = 'custom-secret-key'; + + // Create webhook with a custom secret pointing to request-catcher + $webhook = $this->createWebhook( + ID::unique(), + 'Custom Secret Webhook', + ['users.*.create'], + null, + 'http://request-catcher-webhook:5000/', + false, + null, + null, + $customSecret + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + $this->assertEquals($customSecret, $webhook['body']['secret']); + + // Trigger user creation to generate a webhook delivery + $email = uniqid() . 'customsecret@localhost.test'; + $user = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => 'password', + 'name' => 'Custom Secret User', + ]); + + $this->assertEquals(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + + // Verify webhook delivery is signed with the custom secret + $this->assertEventually(function () use ($userId, $customSecret) { + $delivery = $this->getLastRequest(function (array $request) use ($userId) { + $this->assertStringContainsString( + "users.{$userId}.create", + $request['headers']['X-Appwrite-Webhook-Events'] ?? '' + ); + }); + + $this->assertNotEmpty($delivery); + $payload = json_encode($delivery['data']); + $url = $delivery['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $customSecret, true)); + $this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']); + }, 15000, 500); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testCreateWebhookSecretMinLength(): void + { + // 7 chars — below minimum of 8 + $webhook = $this->createWebhook( + ID::unique(), + 'Short Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + 'short12' + ); + + $this->assertEquals(400, $webhook['headers']['status-code']); + + // 8 chars — exactly at minimum + $webhook = $this->createWebhook( + ID::unique(), + 'Min Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + 'exact8ch' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertEquals('exact8ch', $webhook['body']['secret']); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + } + + public function testCreateWebhookSecretMaxLength(): void + { + // 256 chars — exactly at maximum + $maxSecret = str_repeat('a', 256); + $webhook = $this->createWebhook( + ID::unique(), + 'Max Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + $maxSecret + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertEquals($maxSecret, $webhook['body']['secret']); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + + // 257 chars — above maximum + $tooLongSecret = str_repeat('a', 257); + $webhook = $this->createWebhook( + ID::unique(), + 'Too Long Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + $tooLongSecret + ); + + $this->assertEquals(400, $webhook['headers']['status-code']); + } + + public function testUpdateWebhookSecretMinLength(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Secret Min Update Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // 7 chars — below minimum of 8 + $updated = $this->updateWebhookSecret($webhookId, 'short12'); + $this->assertEquals(400, $updated['headers']['status-code']); + + // 8 chars — exactly at minimum + $updated = $this->updateWebhookSecret($webhookId, 'exact8ch'); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals('exact8ch', $updated['body']['secret']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testUpdateWebhookSecretMaxLength(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Secret Max Update Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // 256 chars — exactly at maximum + $maxSecret = str_repeat('a', 256); + $updated = $this->updateWebhookSecret($webhookId, $maxSecret); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($maxSecret, $updated['body']['secret']); + + // 257 chars — above maximum + $tooLongSecret = str_repeat('a', 257); + $updated = $this->updateWebhookSecret($webhookId, $tooLongSecret); + $this->assertEquals(400, $updated['headers']['status-code']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testWebhookSecretNotExposedInResponses(): void + { + // Create webhook — secret must not leak in creation response + $webhook = $this->createWebhook( + ID::unique(), + 'Secret Exposure Test', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + 'my-custom-secret' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + $this->assertArrayNotHasKey('secret', $webhook['body']); + $this->assertArrayNotHasKey('signatureKey', $webhook['body']); + + // Get webhook — secret must not leak + $get = $this->getWebhook($webhookId); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertArrayNotHasKey('secret', $get['body']); + $this->assertArrayNotHasKey('signatureKey', $get['body']); + + // List webhooks — secret must not leak + $list = $this->listWebhooks(null, true); + $this->assertEquals(200, $list['headers']['status-code']); + foreach ($list['body']['webhooks'] as $item) { + $this->assertArrayNotHasKey('secret', $item); + $this->assertArrayNotHasKey('signatureKey', $item); + } + + // Update webhook — secret must not leak + $updated = $this->updateWebhook( + $webhookId, + 'Secret Exposure Test Updated', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertArrayNotHasKey('secret', $updated['body']); + $this->assertArrayNotHasKey('signatureKey', $updated['body']); + + // Update webhook secret — secret must not leak + $rotated = $this->updateWebhookSecret($webhookId, 'rotated-secret-key'); + $this->assertEquals(200, $rotated['headers']['status-code']); + $this->assertArrayNotHasKey('secret', $rotated['body']); + $this->assertArrayNotHasKey('signatureKey', $rotated['body']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + // URL validation tests public function testCreateWebhookWithPrivateDomain(): void @@ -1779,7 +2124,7 @@ trait WebhooksBase return $webhook; } - protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $tls, ?string $authUsername, ?string $authPassword): mixed + protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $tls, ?string $authUsername, ?string $authPassword, ?string $secret = null): mixed { $params = [ 'webhookId' => $webhookId, @@ -1800,6 +2145,9 @@ trait WebhooksBase if ($authPassword !== null) { $params['authPassword'] = $authPassword; } + if ($secret !== null) { + $params['secret'] = $secret; + } $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ 'content-type' => 'application/json', @@ -1838,12 +2186,17 @@ trait WebhooksBase return $webhook; } - protected function updateWebhookSecret(string $webhookId): mixed + protected function updateWebhookSecret(string $webhookId, ?string $secret = null): mixed { + $params = []; + if ($secret !== null) { + $params['secret'] = $secret; + } + $webhook = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/secret', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); + ], $this->getHeaders()), $params); return $webhook; } From 9f1ec356d13fe0d52425c7ff74ed82d5d5cfc926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Apr 2026 14:53:25 +0200 Subject: [PATCH 11/23] Formatting fix --- src/Appwrite/Utopia/Response/Model/Webhook.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Response/Model/Webhook.php b/src/Appwrite/Utopia/Response/Model/Webhook.php index 25c2c21d4a..70e8ef9842 100644 --- a/src/Appwrite/Utopia/Response/Model/Webhook.php +++ b/src/Appwrite/Utopia/Response/Model/Webhook.php @@ -71,7 +71,7 @@ class Webhook extends Model ]) /* Not exposed for security; Secret is currently write-only. - + ->addRule('secret', [ 'type' => self::TYPE_STRING, 'description' => 'Signature key which can be used to validate incoming webhook payloads.', From c9fceb870c707cd5442dffe2d2424a89094015a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Apr 2026 14:57:24 +0200 Subject: [PATCH 12/23] Fix folder structure --- .../Webhooks/Http/Webhooks/{Signature => Secret}/Update.php | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/{Signature => Secret}/Update.php (100%) diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php similarity index 100% rename from src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php rename to src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php From 2585518e334041743256bd3a8a10388418900c59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Apr 2026 15:08:13 +0200 Subject: [PATCH 13/23] Fix bugs --- .../Platform/Modules/Webhooks/Http/Webhooks/Get.php | 2 ++ .../Platform/Modules/Webhooks/Http/Webhooks/Update.php | 2 ++ .../Platform/Modules/Webhooks/Http/Webhooks/XList.php | 4 ++++ src/Appwrite/Utopia/Response/Model/Webhook.php | 10 ++-------- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php index ebe6fa7bcb..a42500ca46 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php @@ -72,6 +72,8 @@ class Get extends Action throw new Exception(Exception::WEBHOOK_NOT_FOUND); } + $webhook->removeAttribute('signatureKey'); + $response->dynamic($webhook, Response::MODEL_WEBHOOK); } } diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php index 6085eb74b2..e7b516449e 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php @@ -118,6 +118,8 @@ class Update extends Action $queueForEvents->setParam('webhookId', $webhook->getId()); + $webhook->removeAttribute('signatureKey'); + $response->dynamic($webhook, Response::MODEL_WEBHOOK); } } diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php index 763c0d339b..f0961b541c 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php @@ -120,6 +120,10 @@ class XList extends Action throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } + foreach ($webhooks as $webhook) { + $webhook->removeAttribute('signatureKey'); + } + $response->dynamic(new Document([ 'webhooks' => $webhooks, 'total' => $total, diff --git a/src/Appwrite/Utopia/Response/Model/Webhook.php b/src/Appwrite/Utopia/Response/Model/Webhook.php index 70e8ef9842..6a0197e4a1 100644 --- a/src/Appwrite/Utopia/Response/Model/Webhook.php +++ b/src/Appwrite/Utopia/Response/Model/Webhook.php @@ -69,16 +69,12 @@ class Webhook extends Model 'default' => '', 'example' => 'password', ]) - /* - Not exposed for security; Secret is currently write-only. - ->addRule('secret', [ 'type' => self::TYPE_STRING, - 'description' => 'Signature key which can be used to validate incoming webhook payloads.', + 'description' => 'Signature key which can be used to validate incoming webhook payloads. Only returned on creation and secret rotation.', 'default' => '', 'example' => 'ad3d581ca230e2b7059c545e5a', ]) - */ ->addRule('enabled', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'Indicates if this webhook is enabled.', @@ -110,9 +106,7 @@ class Webhook extends Model $document->setAttribute('authPassword', $document->getAttribute('httpPass')); $document->removeAttribute('httpPass'); - // Would be 'secret', but we want it to be write-only. - // $document->setAttribute('secret', $document->getAttribute('signatureKey')); - // Remove DB-level attribute, just to be sure. + $document->setAttribute('secret', $document->getAttribute('signatureKey')); $document->removeAttribute('signatureKey'); return $document; From db406b0a275bbafa13bd5aee2d66541ff6f2de8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Apr 2026 15:08:20 +0200 Subject: [PATCH 14/23] Fix tests --- tests/e2e/Services/Webhooks/WebhooksBase.php | 48 ++++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/tests/e2e/Services/Webhooks/WebhooksBase.php b/tests/e2e/Services/Webhooks/WebhooksBase.php index fc75a3ede9..f926a97c81 100644 --- a/tests/e2e/Services/Webhooks/WebhooksBase.php +++ b/tests/e2e/Services/Webhooks/WebhooksBase.php @@ -694,10 +694,10 @@ trait WebhooksBase $this->assertEquals(128, \strlen($updated['body']['secret'])); $this->assertNotEquals($originalSecret, $updated['body']['secret']); - // Verify new secret persisted via GET + // Verify secret is not exposed via GET $get = $this->getWebhook($webhookId); $this->assertEquals(200, $get['headers']['status-code']); - $this->assertNotEquals($originalSecret, $get['body']['secret']); + $this->assertEmpty($get['body']['secret']); // Test secret update on non-existent webhook $notFound = $this->updateWebhookSecret('non-existent-id'); @@ -996,7 +996,7 @@ trait WebhooksBase public function testWebhookSecretNotExposedInResponses(): void { - // Create webhook — secret must not leak in creation response + // Create webhook — secret IS returned on creation $webhook = $this->createWebhook( ID::unique(), 'Secret Exposure Test', @@ -1011,24 +1011,24 @@ trait WebhooksBase $this->assertEquals(201, $webhook['headers']['status-code']); $webhookId = $webhook['body']['$id']; - $this->assertArrayNotHasKey('secret', $webhook['body']); + $this->assertEquals('my-custom-secret', $webhook['body']['secret']); $this->assertArrayNotHasKey('signatureKey', $webhook['body']); - // Get webhook — secret must not leak + // Get webhook — secret must not be exposed $get = $this->getWebhook($webhookId); $this->assertEquals(200, $get['headers']['status-code']); - $this->assertArrayNotHasKey('secret', $get['body']); + $this->assertEmpty($get['body']['secret']); $this->assertArrayNotHasKey('signatureKey', $get['body']); - // List webhooks — secret must not leak + // List webhooks — secret must not be exposed $list = $this->listWebhooks(null, true); $this->assertEquals(200, $list['headers']['status-code']); foreach ($list['body']['webhooks'] as $item) { - $this->assertArrayNotHasKey('secret', $item); + $this->assertEmpty($item['secret']); $this->assertArrayNotHasKey('signatureKey', $item); } - // Update webhook — secret must not leak + // Update webhook — secret must not be exposed $updated = $this->updateWebhook( $webhookId, 'Secret Exposure Test Updated', @@ -1040,13 +1040,13 @@ trait WebhooksBase null ); $this->assertEquals(200, $updated['headers']['status-code']); - $this->assertArrayNotHasKey('secret', $updated['body']); + $this->assertEmpty($updated['body']['secret']); $this->assertArrayNotHasKey('signatureKey', $updated['body']); - // Update webhook secret — secret must not leak + // Update webhook secret — secret IS returned on rotation $rotated = $this->updateWebhookSecret($webhookId, 'rotated-secret-key'); $this->assertEquals(200, $rotated['headers']['status-code']); - $this->assertArrayNotHasKey('secret', $rotated['body']); + $this->assertEquals('rotated-secret-key', $rotated['body']['secret']); $this->assertArrayNotHasKey('signatureKey', $rotated['body']); // Cleanup @@ -1228,6 +1228,12 @@ trait WebhooksBase { $customId = 'my-custom-webhook-id'; + // Clean up stale webhook from a previous run if it exists + $existing = $this->getWebhook($customId); + if ($existing['headers']['status-code'] === 200) { + $this->deleteWebhook($customId); + } + $webhook = $this->createWebhook( $customId, 'Custom ID Webhook', @@ -1247,6 +1253,19 @@ trait WebhooksBase $this->assertEquals(200, $get['headers']['status-code']); $this->assertEquals($customId, $get['body']['$id']); + // Ensure duplicate creation fails + $duplicate = $this->createWebhook( + $customId, + 'Duplicate Custom ID Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + $this->assertEquals(409, $duplicate['headers']['status-code']); + // Cleanup $this->deleteWebhook($customId); } @@ -1282,8 +1301,7 @@ trait WebhooksBase $this->assertEquals(true, $get['body']['tls']); $this->assertEquals('myuser', $get['body']['authUsername']); $this->assertEquals('mypass', $get['body']['authPassword']); - $this->assertNotEmpty($get['body']['secret']); - $this->assertEquals(128, \strlen($get['body']['secret'])); + $this->assertEmpty($get['body']['secret']); $this->assertEquals(0, $get['body']['attempts']); $this->assertEquals('', $get['body']['logs']); @@ -1990,7 +2008,7 @@ trait WebhooksBase $this->assertEquals(true, $get['body']['security']); $this->assertEquals('getuser', $get['body']['httpUser']); $this->assertEquals('getpass', $get['body']['httpPass']); - $this->assertNotEmpty($get['body']['signatureKey']); + $this->assertEmpty($get['body']['signatureKey']); // Cleanup $this->deleteWebhook($webhookId); From d52d6c0bf0a1c4964699c4b19cbd3b9d910164bc Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 14 Apr 2026 01:13:32 +1200 Subject: [PATCH 15/23] (fix): reset route to avoid clobbering otel --- src/Appwrite/GraphQL/Resolvers.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 65f8a64d68..3f9ea27e34 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -264,6 +264,8 @@ class Resolvers $response->setContentType(Response::CONTENT_TYPE_NULL); $response->clearSent(); + $originalRoute = $utopia->getRoute(); + try { $route = $utopia->match($request, fresh: true); @@ -274,6 +276,10 @@ class Resolvers } $reject($e); return; + } finally { + if ($originalRoute !== null) { + $utopia->setRoute($originalRoute); + } } $payload = $response->getPayload(); From cc8eb62c83fd5490056bb770c429bee62993b3ba Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 14 Apr 2026 01:15:06 +1200 Subject: [PATCH 16/23] (chore): rename --- src/Appwrite/GraphQL/Resolvers.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 3f9ea27e34..0de97f4953 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -264,7 +264,7 @@ class Resolvers $response->setContentType(Response::CONTENT_TYPE_NULL); $response->clearSent(); - $originalRoute = $utopia->getRoute(); + $original = $utopia->getRoute(); try { $route = $utopia->match($request, fresh: true); @@ -277,8 +277,8 @@ class Resolvers $reject($e); return; } finally { - if ($originalRoute !== null) { - $utopia->setRoute($originalRoute); + if ($original !== null) { + $utopia->setRoute($original); } } From 4b2e22d9da68bb331cf077c92d2b99fa096fa747 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Apr 2026 09:27:14 +0530 Subject: [PATCH 17/23] Fix graphql-php audit vulnerability --- composer.json | 2 +- composer.lock | 54 +++++++++++++++--------- src/Appwrite/GraphQL/Types/Assoc.php | 4 +- src/Appwrite/GraphQL/Types/InputFile.php | 4 +- src/Appwrite/GraphQL/Types/Json.php | 4 +- 5 files changed, 41 insertions(+), 27 deletions(-) diff --git a/composer.json b/composer.json index 4ad1ae6120..5b4a30e150 100644 --- a/composer.json +++ b/composer.json @@ -92,7 +92,7 @@ "chillerlan/php-qrcode": "4.3.*", "adhocore/jwt": "1.1.*", "spomky-labs/otphp": "11.*", - "webonyx/graphql-php": "14.11.*", + "webonyx/graphql-php": "^15.31.5", "league/csv": "9.14.*", "enshrined/svg-sanitize": "0.22.*" }, diff --git a/composer.lock b/composer.lock index 84febe60a8..f3da5433ca 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": "4fb974e9843f6104e40396e7cad4a833", + "content-hash": "36dc376acce480e002a8c3d07aff7628", "packages": [ { "name": "adhocore/jwt", @@ -5381,38 +5381,48 @@ }, { "name": "webonyx/graphql-php", - "version": "v14.11.10", + "version": "v15.31.5", "source": { "type": "git", "url": "https://github.com/webonyx/graphql-php.git", - "reference": "d9c2fdebc6aa01d831bc2969da00e8588cffef19" + "reference": "089c4ef7e112df85788cfe06596278a8f99f4aa9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/d9c2fdebc6aa01d831bc2969da00e8588cffef19", - "reference": "d9c2fdebc6aa01d831bc2969da00e8588cffef19", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/089c4ef7e112df85788cfe06596278a8f99f4aa9", + "reference": "089c4ef7e112df85788cfe06596278a8f99f4aa9", "shasum": "" }, "require": { "ext-json": "*", "ext-mbstring": "*", - "php": "^7.1 || ^8" + "php": "^7.4 || ^8" }, "require-dev": { - "amphp/amp": "^2.3", - "doctrine/coding-standard": "^6.0", - "nyholm/psr7": "^1.2", + "amphp/amp": "^2.6", + "amphp/http-server": "^2.1", + "dms/phpunit-arraysubset-asserts": "dev-master", + "ergebnis/composer-normalize": "^2.28", + "friendsofphp/php-cs-fixer": "3.94.2", + "mll-lab/php-cs-fixer-config": "5.13.0", + "nyholm/psr7": "^1.5", "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "0.12.82", - "phpstan/phpstan-phpunit": "0.12.18", - "phpstan/phpstan-strict-rules": "0.12.9", - "phpunit/phpunit": "^7.2 || ^8.5", - "psr/http-message": "^1.0", - "react/promise": "2.*", - "simpod/php-coveralls-mirror": "^3.0" + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "2.1.46", + "phpstan/phpstan-phpunit": "2.0.16", + "phpstan/phpstan-strict-rules": "2.0.10", + "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", + "psr/http-message": "^1 || ^2", + "react/http": "^1.6", + "react/promise": "^2.0 || ^3.0", + "rector/rector": "^2.0", + "symfony/polyfill-php81": "^1.23", + "symfony/var-exporter": "^5 || ^6 || ^7 || ^8", + "thecodingmachine/safe": "^1.3 || ^2 || ^3", + "ticketswap/phpstan-error-formatter": "1.3.0" }, "suggest": { + "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", "psr/http-message": "To use standard GraphQL server", "react/promise": "To leverage async resolving on React PHP platform" }, @@ -5434,15 +5444,19 @@ ], "support": { "issues": "https://github.com/webonyx/graphql-php/issues", - "source": "https://github.com/webonyx/graphql-php/tree/v14.11.10" + "source": "https://github.com/webonyx/graphql-php/tree/v15.31.5" }, "funding": [ + { + "url": "https://github.com/spawnia", + "type": "github" + }, { "url": "https://opencollective.com/webonyx-graphql-php", "type": "open_collective" } ], - "time": "2023-07-05T14:23:37+00:00" + "time": "2026-04-11T18:06:15+00:00" } ], "packages-dev": [ @@ -8449,5 +8463,5 @@ "platform-dev": { "ext-fileinfo": "*" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/src/Appwrite/GraphQL/Types/Assoc.php b/src/Appwrite/GraphQL/Types/Assoc.php index f76b23dd7a..7205e91573 100644 --- a/src/Appwrite/GraphQL/Types/Assoc.php +++ b/src/Appwrite/GraphQL/Types/Assoc.php @@ -7,8 +7,8 @@ use GraphQL\Language\AST\Node; // https://github.com/webonyx/graphql-php/issues/129#issuecomment-309366803 class Assoc extends Json { - public $name = 'Assoc'; - public $description = 'The `Assoc` scalar type represents associative array values.'; + public string $name = 'Assoc'; + public ?string $description = 'The `Assoc` scalar type represents associative array values.'; public function serialize($value) { diff --git a/src/Appwrite/GraphQL/Types/InputFile.php b/src/Appwrite/GraphQL/Types/InputFile.php index 39fd4e23b3..daa771911b 100644 --- a/src/Appwrite/GraphQL/Types/InputFile.php +++ b/src/Appwrite/GraphQL/Types/InputFile.php @@ -8,8 +8,8 @@ use GraphQL\Type\Definition\ScalarType; class InputFile extends ScalarType { - public $name = 'InputFile'; - public $description = 'The `InputFile` special type represents a file to be uploaded in the same HTTP request as specified by + public string $name = 'InputFile'; + public ?string $description = 'The `InputFile` special type represents a file to be uploaded in the same HTTP request as specified by [graphql-multipart-request-spec](https://github.com/jaydenseric/graphql-multipart-request-spec).'; public function serialize($value) diff --git a/src/Appwrite/GraphQL/Types/Json.php b/src/Appwrite/GraphQL/Types/Json.php index 18d27322a1..627b9081d3 100644 --- a/src/Appwrite/GraphQL/Types/Json.php +++ b/src/Appwrite/GraphQL/Types/Json.php @@ -14,8 +14,8 @@ use GraphQL\Type\Definition\ScalarType; // https://github.com/webonyx/graphql-php/issues/129#issuecomment-309366803 class Json extends ScalarType { - public $name = 'Json'; - public $description = 'The `JSON` scalar type represents JSON values as specified by + public string $name = 'Json'; + public ?string $description = 'The `JSON` scalar type represents JSON values as specified by [ECMA-404](https://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).'; public function serialize($value) From bcfec8d5de3ad7ac8df853d71bc193c8ff0b3f43 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Apr 2026 09:35:25 +0530 Subject: [PATCH 18/23] Align graphql version pinning style --- composer.json | 2 +- composer.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 5b4a30e150..3aa6d157cf 100644 --- a/composer.json +++ b/composer.json @@ -92,7 +92,7 @@ "chillerlan/php-qrcode": "4.3.*", "adhocore/jwt": "1.1.*", "spomky-labs/otphp": "11.*", - "webonyx/graphql-php": "^15.31.5", + "webonyx/graphql-php": "15.31.*", "league/csv": "9.14.*", "enshrined/svg-sanitize": "0.22.*" }, diff --git a/composer.lock b/composer.lock index f3da5433ca..4afe17abff 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": "36dc376acce480e002a8c3d07aff7628", + "content-hash": "f6a87c1012b316e614258f8f57a28e48", "packages": [ { "name": "adhocore/jwt", From efadf17bfe2f01c5d13adcaeb56c4c21acdc9c95 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Apr 2026 10:26:59 +0530 Subject: [PATCH 19/23] Fix GraphQL 15 static analysis --- app/controllers/api/graphql.php | 2 +- src/Appwrite/GraphQL/Promises/Adapter.php | 4 ++-- src/Appwrite/GraphQL/Promises/Adapter/Swoole.php | 6 +++++- src/Appwrite/GraphQL/Types/Assoc.php | 7 ++++++- tests/unit/GraphQL/BuilderTest.php | 4 +++- 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/app/controllers/api/graphql.php b/app/controllers/api/graphql.php index 937380b643..9ec2479749 100644 --- a/app/controllers/api/graphql.php +++ b/app/controllers/api/graphql.php @@ -231,7 +231,7 @@ function execute( $validations = GraphQL::getStandardValidationRules(); if (System::getEnv('_APP_GRAPHQL_INTROSPECTION', 'enabled') === 'disabled') { - $validations[] = new DisableIntrospection(); + $validations[] = new DisableIntrospection(DisableIntrospection::ENABLED); } if (System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled') { diff --git a/src/Appwrite/GraphQL/Promises/Adapter.php b/src/Appwrite/GraphQL/Promises/Adapter.php index 86270f2a8b..1d9cc4557f 100644 --- a/src/Appwrite/GraphQL/Promises/Adapter.php +++ b/src/Appwrite/GraphQL/Promises/Adapter.php @@ -81,8 +81,8 @@ abstract class Adapter implements PromiseAdapter /** * Create a new promise that resolves when all passed in promises resolve. * - * @param array $promisesOrValues + * @param iterable $promisesOrValues * @return GQLPromise */ - abstract public function all(array $promisesOrValues): GQLPromise; + abstract public function all(iterable $promisesOrValues): GQLPromise; } diff --git a/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php b/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php index efe6eb2f50..af6441ad6d 100644 --- a/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php +++ b/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php @@ -35,8 +35,12 @@ class Swoole extends Adapter return new GQLPromise($promise, $this); } - public function all(array $promisesOrValues): GQLPromise + public function all(iterable $promisesOrValues): GQLPromise { + if ($promisesOrValues instanceof \Traversable) { + $promisesOrValues = \iterator_to_array($promisesOrValues); + } + return new GQLPromise(SwoolePromise::all($promisesOrValues), $this); } } diff --git a/src/Appwrite/GraphQL/Types/Assoc.php b/src/Appwrite/GraphQL/Types/Assoc.php index 7205e91573..15bd742d1d 100644 --- a/src/Appwrite/GraphQL/Types/Assoc.php +++ b/src/Appwrite/GraphQL/Types/Assoc.php @@ -3,6 +3,7 @@ namespace Appwrite\GraphQL\Types; use GraphQL\Language\AST\Node; +use GraphQL\Language\AST\StringValueNode; // https://github.com/webonyx/graphql-php/issues/129#issuecomment-309366803 class Assoc extends Json @@ -30,6 +31,10 @@ class Assoc extends Json public function parseLiteral(Node $valueNode, ?array $variables = null) { - return \json_decode($valueNode->value, true); + if ($valueNode instanceof StringValueNode) { + return \json_decode($valueNode->value, true); + } + + return parent::parseLiteral($valueNode, $variables); } } diff --git a/tests/unit/GraphQL/BuilderTest.php b/tests/unit/GraphQL/BuilderTest.php index 3dd1bcadc7..9190ce3e78 100644 --- a/tests/unit/GraphQL/BuilderTest.php +++ b/tests/unit/GraphQL/BuilderTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\GraphQL; use Appwrite\GraphQL\Types\Mapper; use Appwrite\Utopia\Response; +use GraphQL\Type\Definition\NamedType; use PHPUnit\Framework\TestCase; use Swoole\Http\Response as SwooleResponse; @@ -24,6 +25,7 @@ class BuilderTest extends TestCase { $model = $this->response->getModel(Response::MODEL_TABLE); $type = Mapper::model(\ucfirst($model->getType())); - $this->assertEquals('Table', $type->name); + $this->assertInstanceOf(NamedType::class, $type); + $this->assertEquals('Table', $type->name()); } } From 512a7ae2bd0dabf1a0b4e7b3652b5e865986fe77 Mon Sep 17 00:00:00 2001 From: shimon Date: Tue, 14 Apr 2026 13:36:19 +0300 Subject: [PATCH 20/23] feat: enhance function scheduling with tracing support --- .../Platform/Tasks/ScheduleFunctions.php | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php index 88725a190a..f867884801 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php +++ b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php @@ -7,6 +7,8 @@ use Cron\CronExpression; use Utopia\Console; use Utopia\Database\Database; use Utopia\Database\DateTime; +use Utopia\Span\Span; +use Utopia\System\System; /** * ScheduleFunctions @@ -88,7 +90,7 @@ class ScheduleFunctions extends ScheduleBase $scheduleKey = $delayConfig['key']; // Ensure schedule was not deleted if (!\array_key_exists($scheduleKey, $this->schedules)) { - return; + continue; } $schedule = $this->schedules[$scheduleKey]; @@ -102,8 +104,22 @@ class ScheduleFunctions extends ScheduleBase ->setFunction($schedule['resource']) ->setMethod('POST') ->setPath('/') - ->setProject($schedule['project']) - ->trigger(); + ->setProject($schedule['project']); + + $projectDoc = $schedule['project']; + $functionDoc = $schedule['resource']; + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $projectDoc->getId() === $traceProjectId && $functionDoc->getId() === $traceFunctionId) { + Span::init('execution.trace.v1_functions_enqueue'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $projectDoc->getId()); + Span::add('functionId', $functionDoc->getId()); + Span::add('scheduleId', $schedule['$id'] ?? ''); + Span::current()?->finish(); + } + + $queueForFunctions->trigger(); $this->recordEnqueueDelay($delayConfig['nextDate']); } From 70c380fa3695af94e560fc2228a63cd3d19e4876 Mon Sep 17 00:00:00 2001 From: shimon Date: Tue, 14 Apr 2026 13:36:29 +0300 Subject: [PATCH 21/23] feat: add tracing support for execution events in log and worker classes --- app/cli.php | 2 +- src/Appwrite/Bus/Listeners/Log.php | 24 ++++++++- src/Appwrite/Platform/Workers/Executions.php | 16 ++++++ src/Appwrite/Platform/Workers/Functions.php | 56 ++++++++++++++++++++ 4 files changed, 95 insertions(+), 3 deletions(-) diff --git a/app/cli.php b/app/cli.php index 73908510d9..bfa3a02cd1 100644 --- a/app/cli.php +++ b/app/cli.php @@ -346,6 +346,6 @@ $cli $cli->shutdown()->action(fn () => Timer::clearAll()); Runtime::enableCoroutine(SWOOLE_HOOK_ALL); -require_once __DIR__ . '/init/span.php'; +require_once __DIR__ . '/init/ '; run($cli->run(...)); Console::exit($exitCode); diff --git a/src/Appwrite/Bus/Listeners/Log.php b/src/Appwrite/Bus/Listeners/Log.php index 076ed5c02d..585d4b09a7 100644 --- a/src/Appwrite/Bus/Listeners/Log.php +++ b/src/Appwrite/Bus/Listeners/Log.php @@ -7,6 +7,8 @@ use Appwrite\Event\Message\Execution as ExecutionMessage; use Appwrite\Event\Publisher\Execution as ExecutionPublisher; use Utopia\Bus\Listener; use Utopia\Database\Document; +use Utopia\Span\Span; +use Utopia\System\System; class Log extends Listener { @@ -30,9 +32,27 @@ class Log extends Listener public function handle(ExecutionCompleted $event, ExecutionPublisher $publisherForExecutions): void { + $project = new Document($event->project); + $execution = new Document($event->execution); + if ($execution->getAttribute('resourceType', '') === 'functions') { + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + $resourceId = $execution->getAttribute('resourceId', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $resourceId === $traceFunctionId) { + Span::init('execution.trace.v1_executions_enqueue'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $resourceId); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('status', $execution->getAttribute('status', '')); + Span::current()?->finish(); + } + } + $publisherForExecutions->enqueue(new ExecutionMessage( - project: new Document($event->project), - execution: new Document($event->execution), + project: $project, + execution: $execution, )); } } diff --git a/src/Appwrite/Platform/Workers/Executions.php b/src/Appwrite/Platform/Workers/Executions.php index 673e9de791..99e20be035 100644 --- a/src/Appwrite/Platform/Workers/Executions.php +++ b/src/Appwrite/Platform/Workers/Executions.php @@ -7,6 +7,8 @@ use Exception; use Utopia\Database\Database; use Utopia\Platform\Action; use Utopia\Queue\Message; +use Utopia\Span\Span; +use Utopia\System\System; class Executions extends Action { @@ -39,6 +41,20 @@ class Executions extends Action throw new Exception('Missing execution'); } + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + $resourceId = $execution->getAttribute('resourceId', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $executionMessage->project->getId() === $traceProjectId && $resourceId === $traceFunctionId) { + Span::init('execution.trace.executions_worker_upsert'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $executionMessage->project->getId()); + Span::add('functionId', $resourceId); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('resourceType', $execution->getAttribute('resourceType', '')); + Span::current()?->finish(); + } + $dbForProject->upsertDocument('executions', $execution); } } diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index bed28dad1c..0899fbacb4 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -23,6 +23,7 @@ use Utopia\Database\Query; use Utopia\Logger\Log; use Utopia\Platform\Action; use Utopia\Queue\Message; +use Utopia\Span\Span; use Utopia\System\System; class Functions extends Action @@ -115,6 +116,22 @@ class Functions extends Action $log->addTag('projectId', $project->getId()); $log->addTag('type', $type); + if (empty($events) && !$function->isEmpty()) { + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $function->getId() === $traceFunctionId) { + Span::init('execution.trace.functions_worker_dequeue'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $function->getId()); + Span::add('payloadType', $type); + Span::add('queuePid', $message->getPid()); + Span::add('queueName', $message->getQueue()); + Span::add('messageTimestamp', (string) $message->getTimestamp()); + Span::current()?->finish(); + } + } + if (!empty($events)) { $limit = 100; $sum = 100; @@ -304,6 +321,20 @@ class Functions extends Action 'duration' => 0.0, ]); + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $function->getId() === $traceFunctionId) { + Span::init('execution.trace.functions_worker_before_execution_completed_bus_fail'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $function->getId()); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('trigger', $trigger); + Span::add('status', $execution->getAttribute('status', '')); + Span::current()?->finish(); + } + $bus->dispatch(new ExecutionCompleted( execution: $execution->getArrayCopy(), project: $project->getArrayCopy(), @@ -522,6 +553,18 @@ class Functions extends Action $source = $deployment->getAttribute('buildPath', ''); $extension = str_ends_with($source, '.tar') ? 'tar' : 'tar.gz'; $command = $version === 'v2' ? '' : "cp /tmp/code.$extension /mnt/code/code.$extension && nohup helpers/start.sh \"$command\""; + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $functionId === $traceFunctionId) { + Span::init('execution.trace.functions_worker_before_executor'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $functionId); + Span::add('executionId', $executionId); + Span::add('deploymentId', $deployment->getId()); + Span::add('trigger', $trigger); + Span::current()?->finish(); + } $executionResponse = $executor->createExecution( projectId: $project->getId(), deploymentId: $deploymentId, @@ -594,6 +637,19 @@ class Functions extends Action $errorCode = $th->getCode(); } finally { /** Persist final execution status and record usage */ + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $functionId === $traceFunctionId) { + Span::init('execution.trace.functions_worker_before_execution_completed_bus'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $functionId); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('status', $execution->getAttribute('status', '')); + Span::add('trigger', $trigger); + Span::current()?->finish(); + } $bus->dispatch(new ExecutionCompleted( execution: $execution->getArrayCopy(), project: $project->getArrayCopy(), From 6d5968e2eab9b3bf6c5a476fbc1f01ddd3933a30 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Apr 2026 16:51:46 +0530 Subject: [PATCH 22/23] ci: disable functional GraphQL e2e --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2aeae21655..494a9c1424 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -512,7 +512,7 @@ jobs: # Services that rely on sequential test method execution (shared static state) FUNCTIONAL_FLAG="--functional" case "${{ matrix.service }}" in - Databases|TablesDB|Functions|Realtime) FUNCTIONAL_FLAG="" ;; + Databases|TablesDB|Functions|Realtime|GraphQL) FUNCTIONAL_FLAG="" ;; esac docker compose exec -T \ From 71533aaaf39d055e846282b779ac9e68140d22e7 Mon Sep 17 00:00:00 2001 From: Shimon Newman Date: Tue, 14 Apr 2026 15:17:37 +0300 Subject: [PATCH 23/23] Update app/cli.php Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- app/cli.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/cli.php b/app/cli.php index bfa3a02cd1..73908510d9 100644 --- a/app/cli.php +++ b/app/cli.php @@ -346,6 +346,6 @@ $cli $cli->shutdown()->action(fn () => Timer::clearAll()); Runtime::enableCoroutine(SWOOLE_HOOK_ALL); -require_once __DIR__ . '/init/ '; +require_once __DIR__ . '/init/span.php'; run($cli->run(...)); Console::exit($exitCode);