From a06ba57384ce58a9f4812ad982b4899abc7387f0 Mon Sep 17 00:00:00 2001 From: Aditya Date: Sun, 5 Apr 2026 23:39:13 +0530 Subject: [PATCH 1/9] fix appwrite auth broken link in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ed83252e2f..88d527f060 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Table of Contents: ## Products -- **[Appwrite Auth](https://appwrite.io/docs/products/authentication)** - Secure user authentication with multiple login methods including email/password, SMS, OAuth, anonymous sessions, and magic links. Includes session management, multi-factor authentication, and user verification flows. +- **[Appwrite Auth](https://appwrite.io/docs/products/auth)** - Secure user authentication with multiple login methods including email/password, SMS, OAuth, anonymous sessions, and magic links. Includes session management, multi-factor authentication, and user verification flows. - **[Appwrite Databases](https://appwrite.io/docs/products/databases)** - Scalable structured data storage with support for databases, tables, and rows. Includes querying, pagination, indexing, and relationships to model complex application data. From f11bd7ce0e6b0ef0045ff5342e338a8eabdea7fa Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 7 Apr 2026 12:45:10 +0530 Subject: [PATCH 2/9] fix: reset SDK dev branch to base branch before pushing The dev branch was being reset to origin/dev, which retains stale commits after squash merges. This caused recurring merge conflicts and inflated PR diffs. Using `checkout -B dev ` ensures dev always starts fresh from the default branch. --- src/Appwrite/Platform/Tasks/SDKs.php | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index e8a69afddb..02ee97fc54 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -639,29 +639,15 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND } catch (\Throwable) { } - // Checkout dev branch (or create if it doesn't exist) + // Create or checkout dev branch from the base branch + // This ensures dev always starts from the latest base branch, + // avoiding history divergence caused by squash merges. try { - $repo->execute('checkout', '-f', $gitBranch); + $repo->execute('checkout', '-B', $gitBranch, $repoBranch); } catch (\Throwable) { $repo->execute('checkout', '-b', $gitBranch); } - // Fetch dev branch, or push to create it on remote - try { - $repo->execute('fetch', 'origin', $gitBranch, '--quiet', '--no-tags', '--depth', '1'); - } catch (\Throwable) { - try { - $repo->execute('push', '-u', 'origin', $gitBranch, '--quiet'); - } catch (\Throwable) { - } - } - - // Sync with remote dev branch - try { - $repo->execute('reset', '--hard', "origin/{$gitBranch}"); - } catch (\Throwable) { - } - // Backup .github before cleaning working tree $githubDir = $target . '/.github'; $githubBackup = \sys_get_temp_dir() . '/.github-backup-' . \getmypid(); From 7864a5b9d10c85720be68a86c3618552ba149430 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 7 Apr 2026 12:52:09 +0530 Subject: [PATCH 3/9] fix: use --force-with-lease on SDK dev branch push After resetting dev to the base branch, the remote dev may have diverged history from squash merges. A plain push would be rejected as non-fast-forward. Using --force-with-lease safely overwrites the remote since we just fetched. --- src/Appwrite/Platform/Tasks/SDKs.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index 02ee97fc54..4725f4095f 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -685,7 +685,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND return true; } - $repo->execute('push', '-u', 'origin', $gitBranch, '--quiet'); + $repo->execute('push', '--force-with-lease', '-u', 'origin', $gitBranch, '--quiet'); } catch (\Throwable $e) { Console::warning(" Git push failed: " . $e->getMessage()); return false; From 9403e4d65de2c950922a8cef74722e58b70c297d Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 7 Apr 2026 17:35:32 +0530 Subject: [PATCH 4/9] Bump utopia-php/framework to 0.34.18 --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index d71f78b35d..de0628e76c 100644 --- a/composer.lock +++ b/composer.lock @@ -4271,16 +4271,16 @@ }, { "name": "utopia-php/framework", - "version": "0.34.17", + "version": "0.34.18", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "d3e4143b8b06d9823d0c29a06dacefa5a1b93677" + "reference": "c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/d3e4143b8b06d9823d0c29a06dacefa5a1b93677", - "reference": "d3e4143b8b06d9823d0c29a06dacefa5a1b93677", + "url": "https://api.github.com/repos/utopia-php/http/zipball/c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc", + "reference": "c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc", "shasum": "" }, "require": { @@ -4319,9 +4319,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.34.17" + "source": "https://github.com/utopia-php/http/tree/0.34.18" }, - "time": "2026-04-06T04:40:23+00:00" + "time": "2026-04-07T08:06:39+00:00" }, { "name": "utopia-php/http", From 7781d377ae4a2238ee82236f859eedfaa67fc619 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:44:24 +0100 Subject: [PATCH 5/9] fix: persist session before purging user cache in email/password login Swap the order of createDocument('sessions') and purgeCachedDocument('users') in the email/password session creation flow. Previously, the cache was purged before the session was written, opening a race window in Swoole's async environment where a concurrent account.get() could re-cache the user with no sessions, causing sessionVerify to fail with a 401. This matches the correct ordering already used by the token-based flows (magic URL, OTP, phone). Co-Authored-By: Claude Sonnet 4.6 --- app/controllers/api/account.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index cbdf11225a..8eb49ea27b 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1103,14 +1103,14 @@ Http::post('/v1/account/sessions/email') ])); } - $dbForProject->purgeCachedDocument('users', $user->getId()); - $session = $dbForProject->createDocument('sessions', $session->setAttribute('$permissions', [ Permission::read(Role::user($user->getId())), Permission::update(Role::user($user->getId())), Permission::delete(Role::user($user->getId())), ])); + $dbForProject->purgeCachedDocument('users', $user->getId()); + $encoded = $store ->setProperty('id', $user->getId()) ->setProperty('secret', $secret) From 6dba407aedf15db79861cb2038adbf918433e21c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 8 Apr 2026 10:10:16 +0530 Subject: [PATCH 6/9] test: add E2E test for email/password session cache race condition Adds testEmailPasswordSessionNotCorruptedByConcurrentRequests which reproduces the cross-worker Redis cache race that caused 401s after login. The test fires a login request, waits for it to reach the cache purge point, then injects concurrent GET /v1/account requests that re-cache a stale user document. Verifies the new session is immediately usable. Fails against the old ordering (purge before create), passes with the fix (create before purge). --- .../Account/AccountCustomClientTest.php | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 107dceaa5e..ee1bb31ede 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -4150,4 +4150,178 @@ class AccountCustomClientTest extends Scope $this->assertEquals(401, $verification3['headers']['status-code']); } + + /** + * Test that a new email/password session is immediately usable even when + * a concurrent request re-populates the user cache between the cache purge + * and session creation. + * + * Regression test for: purging the user cache BEFORE persisting the session + * allows a concurrent request (from a different Swoole worker) to re-cache + * a stale user document that lacks the new session, causing sessionVerify + * to fail with 401 on subsequent requests using the new session. + */ + public function testEmailPasswordSessionNotCorruptedByConcurrentRequests(): void + { + $projectId = $this->getProject()['$id']; + $endpoint = $this->client->getEndpoint(); + + $email = uniqid('race_', true) . getmypid() . '@localhost.test'; + $password = 'password123!'; + + // Create user + $response = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Race Test User', + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + // Login to get session A + $responseA = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertEquals(201, $responseA['headers']['status-code']); + $sessionA = $responseA['cookies']['a_session_' . $projectId]; + + // Verify session A works + $verifyA = $this->client->call(Client::METHOD_GET, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionA, + ]); + $this->assertEquals(200, $verifyA['headers']['status-code']); + + /** + * Race condition scenario: + * 1. Start login B via curl_multi (non-blocking) + * 2. Drive the transfer for ~150ms so login B reaches purgeCachedDocument + * (findOne ~15ms + Argon2 hash verify ~60ms + middleware overhead) + * 3. THEN add GET requests to curl_multi - these hit different workers and + * re-cache a stale user document (without session B) during the window + * between purgeCachedDocument and createDocument + * 4. After all complete, verify session B is usable + */ + for ($attempt = 0; $attempt < 5; $attempt++) { + $loginCookies = []; + + $multi = curl_multi_init(); + + // Start login B first (alone) + $loginHandle = curl_init("{$endpoint}/account/sessions/email"); + curl_setopt_array($loginHandle, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'origin: http://localhost', + 'content-type: application/json', + "x-appwrite-project: {$projectId}", + ], + CURLOPT_POSTFIELDS => \json_encode([ + 'email' => $email, + 'password' => $password, + ]), + CURLOPT_HEADERFUNCTION => function ($curl, $header) use (&$loginCookies) { + if (\stripos($header, 'set-cookie:') === 0) { + $cookiePart = \trim(\substr($header, 11)); + $eqPos = \strpos($cookiePart, '='); + if ($eqPos !== false) { + $name = \substr($cookiePart, 0, $eqPos); + $rest = \substr($cookiePart, $eqPos + 1); + $semiPos = \strpos($rest, ';'); + $loginCookies[$name] = $semiPos !== false + ? \substr($rest, 0, $semiPos) + : $rest; + } + } + return \strlen($header); + }, + ]); + curl_multi_add_handle($multi, $loginHandle); + + // Drive the login transfer forward and wait for the server to start + // processing the login (past hash verification + cache purge). + $deadline = \microtime(true) + 0.15; // 150ms + do { + curl_multi_exec($multi, $active); + curl_multi_select($multi, 0.005); + } while (\microtime(true) < $deadline && $active); + + // NOW add GET requests - they arrive after the cache purge + // but before session creation (which is delayed by the usleep or I/O). + $getHandles = []; + for ($i = 0; $i < 10; $i++) { + $gh = curl_init("{$endpoint}/account"); + curl_setopt_array($gh, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'origin: http://localhost', + 'content-type: application/json', + "x-appwrite-project: {$projectId}", + "cookie: a_session_{$projectId}={$sessionA}", + ], + ]); + curl_multi_add_handle($multi, $gh); + $getHandles[] = $gh; + } + + // Drive all to completion + do { + $status = curl_multi_exec($multi, $active); + if ($active) { + curl_multi_select($multi, 0.05); + } + } while ($active && $status === CURLM_OK); + + $loginStatus = curl_getinfo($loginHandle, CURLINFO_HTTP_CODE); + + curl_multi_remove_handle($multi, $loginHandle); + curl_close($loginHandle); + foreach ($getHandles as $gh) { + curl_multi_remove_handle($multi, $gh); + curl_close($gh); + } + curl_multi_close($multi); + + $this->assertEquals(201, $loginStatus, 'Login for session B should succeed'); + + $sessionBCookie = $loginCookies["a_session_{$projectId}"] ?? null; + $this->assertNotNull($sessionBCookie, 'Session B cookie should be set'); + + // THE CRITICAL CHECK: verify session B is usable immediately + $verifyB = $this->client->call(Client::METHOD_GET, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => "a_session_{$projectId}={$sessionBCookie}", + ]); + + $this->assertEquals( + 200, + $verifyB['headers']['status-code'], + 'Session B must be immediately usable after login. ' + . 'A 401 here means a stale user cache (without the new session) was served. ' + . 'The fix is to create the session document BEFORE purging the user cache.' + ); + + // Clean up session B for next iteration + $this->client->call(Client::METHOD_DELETE, '/account/sessions/current', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => "a_session_{$projectId}={$sessionBCookie}", + ]); + } + } } From dd4a43b78c083137ddc7c2488efeb9e39253bd62 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 8 Apr 2026 10:41:43 +0530 Subject: [PATCH 7/9] fix: throw RuntimeException for unresolved response models in spec generation Spec generation silently produced a fatal error when a response model string could not be resolved to a registered model object. Now throws a clear RuntimeException in both Swagger2 and OpenAPI3 formats, for both single and array model responses. Also adds a CI job to run spec generation on every PR so unresolved models are caught before merge. --- .github/workflows/ci.yml | 20 +++++++++++++++++++ src/Appwrite/Platform/Tasks/Specs.php | 7 ++++++- .../SDK/Specification/Format/OpenAPI3.php | 12 +++++++++++ .../SDK/Specification/Format/Swagger2.php | 12 +++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f44d5eedf3..19a36f0380 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,6 +161,26 @@ jobs: - name: Run PHPStan run: composer analyze -- --no-progress + specs: + name: Checks / Specs + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer:v2 + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --ignore-platform-reqs + + - name: Generate specs + run: _APP_STORAGE_LIMIT=5368709120 php app/cli.php specs --version=latest --git=no + locale: name: Checks / Locale runs-on: ubuntu-latest diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index ebc4f6731a..0953610a69 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -482,7 +482,12 @@ class Specs extends Action ? $specsDir . '/' . $format . '-mocks-' . $platform . '.json' : $specsDir . '/' . $format . '-' . $version . '-' . $platform . '.json'; - $parsedSpecs = $specs->parse(); + try { + $parsedSpecs = $specs->parse(); + } catch (\RuntimeException $e) { + throw new \RuntimeException("Spec generation failed for {$platform} ({$format}): " . $e->getMessage(), 0, $e); + } + $encodedSpecs = \json_encode($parsedSpecs, JSON_PRETTY_PRINT); unset($parsedSpecs); diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 88f577eac6..4284cdd18d 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -278,6 +278,18 @@ class OpenAPI3 extends Format } } + if (\is_string($model)) { + throw new \RuntimeException("Unresolved response model '{$model}' for method '{$sdk->getNamespace()}.{$sdk->getMethodName()}'. Ensure the model is registered."); + } + + if (\is_array($model)) { + foreach ($model as $m) { + if (\is_string($m)) { + throw new \RuntimeException("Unresolved response model '{$m}' for method '{$sdk->getNamespace()}.{$sdk->getMethodName()}'. Ensure the model is registered."); + } + } + } + if (!(\is_array($model)) && $model->isNone()) { $temp['responses'][(string)$response->getCode() ?? '500'] = [ 'description' => in_array($produces, [ diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index f9c79431f0..792cafb159 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -285,6 +285,18 @@ class Swagger2 extends Format } } + if (\is_string($model)) { + throw new \RuntimeException("Unresolved response model '{$model}' for method '{$sdk->getNamespace()}.{$sdk->getMethodName()}'. Ensure the model is registered."); + } + + if (\is_array($model)) { + foreach ($model as $m) { + if (\is_string($m)) { + throw new \RuntimeException("Unresolved response model '{$m}' for method '{$sdk->getNamespace()}.{$sdk->getMethodName()}'. Ensure the model is registered."); + } + } + } + if (!(\is_array($model)) && $model->isNone()) { $temp['responses'][(string)$response->getCode() ?? '500'] = [ 'description' => in_array($produces, [ From f5ab593261cf43664b50f28fa836649440e28c21 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 8 Apr 2026 10:47:37 +0530 Subject: [PATCH 8/9] fix: make Project model public for server SDK spec generation The project.updateLabels route uses AuthType::KEY which makes it available on the server platform, but the Project model had public=false causing it to be filtered out during spec generation. --- src/Appwrite/Utopia/Response/Model/Project.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 5902902e9e..9378ebad86 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -12,7 +12,7 @@ class Project extends Model /** * @var bool */ - protected bool $public = false; + protected bool $public = true; public function __construct() { From 62b6ef06e6d8ba7596c32ade25b33139f7578669 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 8 Apr 2026 10:49:50 +0530 Subject: [PATCH 9/9] fix: add swoole extension to specs CI job --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19a36f0380..6cc5ecdcf1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -172,6 +172,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.3' + extensions: swoole tools: composer:v2 coverage: none