From 0fe906c538d4af9f76b41a520edb5024fa3f974b Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Mon, 23 Mar 2026 13:21:04 +0530 Subject: [PATCH 01/35] feat: Add X OAuth 2.0 provider --- app/config/oAuthProviders.php | 11 ++ app/controllers/api/account.php | 48 ++++++- src/Appwrite/Auth/OAuth2/X.php | 236 ++++++++++++++++++++++++++++++++ 3 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 src/Appwrite/Auth/OAuth2/X.php diff --git a/app/config/oAuthProviders.php b/app/config/oAuthProviders.php index e6acd08c54..cda6459519 100644 --- a/app/config/oAuthProviders.php +++ b/app/config/oAuthProviders.php @@ -376,6 +376,17 @@ return [ 'mock' => false, 'class' => 'Appwrite\\Auth\\OAuth2\\Wordpress', ], + 'x' => [ + 'name' => 'X', + 'developers' => 'https://docs.x.com/fundamentals/authentication/oauth-2-0/authorization-code', + 'icon' => 'icon-twitter', + 'enabled' => true, + 'sandbox' => false, + 'form' => false, + 'beta' => false, + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\X', + ], 'yahoo' => [ 'name' => 'Yahoo', 'developers' => 'https://developer.yahoo.com/oauth2/guide/flows_authcode/', diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 6d33b45f0b..d6f4561d65 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1375,10 +1375,25 @@ Http::get('/v1/account/sessions/oauth2/:provider') 'token' => false, ], $scopes); + $loginURL = $oauth2->getLoginURL(); + + if ($provider === 'x' && \method_exists($oauth2, 'getPKCEVerifier')) { + $response->addCookie( + 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, + $oauth2->getPKCEVerifier(), + \time() + 300, + '/', + Config::getParam('cookieDomain'), + ('https' === $protocol), + true, + Response::COOKIE_SAMESITE_LAX + ); + } + $response ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') ->addHeader('Pragma', 'no-cache') - ->redirect($oauth2->getLoginURL()); + ->redirect($loginURL); }); Http::get('/v1/account/sessions/oauth2/callback/:provider/:projectId') @@ -1511,6 +1526,20 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') /** @var Appwrite\Auth\OAuth2 $oauth2 */ $oauth2 = new $className($appId, $appSecret, $callback); + if ($provider === 'x' && \method_exists($oauth2, 'setPKCEVerifier')) { + $oauth2->setPKCEVerifier($request->getCookie('a_oauth2_pkce_' . $project->getId() . '_' . $provider, '')); + $response->addCookie( + 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, + '', + \time() - 3600, + '/', + Config::getParam('cookieDomain'), + ('https' === $protocol), + true, + Response::COOKIE_SAMESITE_LAX + ); + } + if (!empty($state)) { try { $state = \array_merge($defaultState, $oauth2->parseState($state)); @@ -2079,10 +2108,25 @@ Http::get('/v1/account/tokens/oauth2/:provider') 'token' => true, ], $scopes); + $loginURL = $oauth2->getLoginURL(); + + if ($provider === 'x' && \method_exists($oauth2, 'getPKCEVerifier')) { + $response->addCookie( + 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, + $oauth2->getPKCEVerifier(), + \time() + 300, + '/', + Config::getParam('cookieDomain'), + ('https' === $protocol), + true, + Response::COOKIE_SAMESITE_LAX + ); + } + $response ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') ->addHeader('Pragma', 'no-cache') - ->redirect($oauth2->getLoginURL()); + ->redirect($loginURL); }); Http::post('/v1/account/tokens/magic-url') diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php new file mode 100644 index 0000000000..b309f90c0f --- /dev/null +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -0,0 +1,236 @@ + 'code', + 'client_id' => $this->appID, + 'redirect_uri' => $this->callback, + 'scope' => \implode(' ', $this->getScopes()), + 'state' => \json_encode($this->state), + 'code_challenge' => $this->getCodeChallenge(), + 'code_challenge_method' => 'S256', + ]); + } + + /** + * @return string + */ + public function getPKCEVerifier(): string + { + if (empty($this->pkceVerifier)) { + $this->pkceVerifier = $this->base64UrlEncode(\random_bytes(32)); + } + + return $this->pkceVerifier; + } + + /** + * @param string $pkceVerifier + * + * @return void + */ + public function setPKCEVerifier(string $pkceVerifier): void + { + $this->pkceVerifier = $pkceVerifier; + } + + /** + * @param string $code + * + * @return array + */ + protected function getTokens(string $code): array + { + if (empty($this->tokens)) { + if (empty($this->pkceVerifier)) { + throw new Exception(\json_encode([ + 'error' => 'invalid_request', + 'error_description' => 'Missing PKCE verifier.', + ]), 400); + } + + $headers = [ + 'Authorization: Basic ' . \base64_encode($this->appID . ':' . $this->appSecret), + 'Content-Type: application/x-www-form-urlencoded', + ]; + + $this->tokens = \json_decode($this->request( + 'POST', + 'https://api.x.com/2/oauth2/token', + $headers, + \http_build_query([ + 'code' => $code, + 'client_id' => $this->appID, + 'grant_type' => 'authorization_code', + 'redirect_uri' => $this->callback, + 'code_verifier' => $this->getPKCEVerifier(), + ]) + ), true); + } + + return $this->tokens; + } + + /** + * @param string $refreshToken + * + * @return array + */ + public function refreshTokens(string $refreshToken): array + { + $headers = [ + 'Authorization: Basic ' . \base64_encode($this->appID . ':' . $this->appSecret), + 'Content-Type: application/x-www-form-urlencoded', + ]; + + $this->tokens = \json_decode($this->request( + 'POST', + 'https://api.x.com/2/oauth2/token', + $headers, + \http_build_query([ + 'client_id' => $this->appID, + 'refresh_token' => $refreshToken, + 'grant_type' => 'refresh_token', + ]) + ), true); + + if (empty($this->tokens['refresh_token'])) { + $this->tokens['refresh_token'] = $refreshToken; + } + + return $this->tokens; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserID(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['data']['id'] ?? ''; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserEmail(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['data']['confirmed_email'] ?? ''; + } + + /** + * Check if the OAuth email is verified. + * + * X returns a confirmed email only when the app has email access enabled + * and the authenticated user has a confirmed email address. + * + * @param string $accessToken + * + * @return bool + */ + public function isEmailVerified(string $accessToken): bool + { + return !empty($this->getUserEmail($accessToken)); + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserName(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['data']['name'] ?? ''; + } + + /** + * @param string $accessToken + * + * @return array + */ + protected function getUser(string $accessToken): array + { + if (empty($this->user)) { + $this->user = \json_decode($this->request( + 'GET', + 'https://api.x.com/2/users/me?user.fields=confirmed_email', + ['Authorization: Bearer ' . \urlencode($accessToken)] + ), true); + } + + return $this->user; + } + + /** + * @return string + */ + private function getCodeChallenge(): string + { + return $this->base64UrlEncode(\hash('sha256', $this->getPKCEVerifier(), true)); + } + + /** + * @param string $value + * + * @return string + */ + private function base64UrlEncode(string $value): string + { + return \rtrim(\strtr(\base64_encode($value), '+/', '-_'), '='); + } +} From 8218f36d340095aa7fb6cb40c3f225698c745673 Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Mon, 23 Mar 2026 13:32:06 +0530 Subject: [PATCH 02/35] code rabbit comment --- src/Appwrite/Auth/OAuth2/X.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php index b309f90c0f..105c404d1f 100644 --- a/src/Appwrite/Auth/OAuth2/X.php +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -209,7 +209,7 @@ class X extends OAuth2 $this->user = \json_decode($this->request( 'GET', 'https://api.x.com/2/users/me?user.fields=confirmed_email', - ['Authorization: Bearer ' . \urlencode($accessToken)] + ['Authorization: Bearer ' . $accessToken] ), true); } From dc48bb35efb52489b130b0a7de84400d9dba5a16 Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Mon, 23 Mar 2026 17:49:42 +0530 Subject: [PATCH 03/35] added pkce to base --- app/controllers/api/account.php | 6 ++-- src/Appwrite/Auth/OAuth2.php | 53 ++++++++++++++++++++++++++++++ src/Appwrite/Auth/OAuth2/Etsy.php | 29 ++++++----------- src/Appwrite/Auth/OAuth2/X.php | 54 ++++++------------------------- 4 files changed, 75 insertions(+), 67 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index d6f4561d65..3d156d7d81 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1377,7 +1377,7 @@ Http::get('/v1/account/sessions/oauth2/:provider') $loginURL = $oauth2->getLoginURL(); - if ($provider === 'x' && \method_exists($oauth2, 'getPKCEVerifier')) { + if ($oauth2->usesPKCE()) { $response->addCookie( 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, $oauth2->getPKCEVerifier(), @@ -1526,7 +1526,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') /** @var Appwrite\Auth\OAuth2 $oauth2 */ $oauth2 = new $className($appId, $appSecret, $callback); - if ($provider === 'x' && \method_exists($oauth2, 'setPKCEVerifier')) { + if ($oauth2->usesPKCE()) { $oauth2->setPKCEVerifier($request->getCookie('a_oauth2_pkce_' . $project->getId() . '_' . $provider, '')); $response->addCookie( 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, @@ -2110,7 +2110,7 @@ Http::get('/v1/account/tokens/oauth2/:provider') $loginURL = $oauth2->getLoginURL(); - if ($provider === 'x' && \method_exists($oauth2, 'getPKCEVerifier')) { + if ($oauth2->usesPKCE()) { $response->addCookie( 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, $oauth2->getPKCEVerifier(), diff --git a/src/Appwrite/Auth/OAuth2.php b/src/Appwrite/Auth/OAuth2.php index 9358c89547..5e884b7bad 100644 --- a/src/Appwrite/Auth/OAuth2.php +++ b/src/Appwrite/Auth/OAuth2.php @@ -31,6 +31,11 @@ abstract class OAuth2 */ protected array $scopes; + /** + * @var string + */ + protected string $pkceVerifier = ''; + /** * OAuth2 constructor. * @@ -105,6 +110,14 @@ abstract class OAuth2 */ abstract public function getUserName(string $accessToken): string; + /** + * @return bool + */ + public function usesPKCE(): bool + { + return false; + } + /** * @param $scope * @@ -128,6 +141,36 @@ abstract class OAuth2 return $this->scopes; } + /** + * @return string + */ + public function getPKCEVerifier(): string + { + if (empty($this->pkceVerifier)) { + $this->pkceVerifier = $this->base64UrlEncode(\random_bytes(32)); + } + + return $this->pkceVerifier; + } + + /** + * @param string $pkceVerifier + * + * @return void + */ + public function setPKCEVerifier(string $pkceVerifier): void + { + $this->pkceVerifier = $pkceVerifier; + } + + /** + * @return string + */ + protected function getPKCEChallenge(): string + { + return $this->base64UrlEncode(\hash('sha256', $this->getPKCEVerifier(), true)); + } + /** * @param string $code * @@ -214,4 +257,14 @@ abstract class OAuth2 return (string)$response; } + + /** + * @param string $value + * + * @return string + */ + protected function base64UrlEncode(string $value): string + { + return \rtrim(\strtr(\base64_encode($value), '+/', '-_'), '='); + } } diff --git a/src/Appwrite/Auth/OAuth2/Etsy.php b/src/Appwrite/Auth/OAuth2/Etsy.php index 7ff16fcb78..122838078d 100644 --- a/src/Appwrite/Auth/OAuth2/Etsy.php +++ b/src/Appwrite/Auth/OAuth2/Etsy.php @@ -34,23 +34,6 @@ class Etsy extends OAuth2 "profile_r", ]; - /** - * @var string - */ - private string $pkce = ''; - - /** - * @return string - */ - private function getPKCE(): string - { - if (empty($this->pkce)) { - $this->pkce = \bin2hex(\random_bytes(rand(43, 128))); - } - - return $this->pkce; - } - /** * @return string */ @@ -59,6 +42,14 @@ class Etsy extends OAuth2 return 'etsy'; } + /** + * @return bool + */ + public function usesPKCE(): bool + { + return true; + } + /** * @return string */ @@ -70,7 +61,7 @@ class Etsy extends OAuth2 'response_type' => 'code', 'state' => \json_encode($this->state), 'scope' => $this->scopes, - 'code_challenge' => $this->getPKCE(), + 'code_challenge' => $this->getPKCEChallenge(), 'code_challenge_method' => 'S256', ]); } @@ -94,7 +85,7 @@ class Etsy extends OAuth2 'client_id' => $this->appID, 'redirect_uri' => $this->callback, 'code' => $code, - 'code_verifier' => $this->getPKCE(), + 'code_verifier' => $this->getPKCEVerifier(), ]) ), true); } diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php index 105c404d1f..0cf5689626 100644 --- a/src/Appwrite/Auth/OAuth2/X.php +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -29,11 +29,6 @@ class X extends OAuth2 'offline.access', ]; - /** - * @var string - */ - private string $pkceVerifier = ''; - /** * @return string */ @@ -42,6 +37,14 @@ class X extends OAuth2 return 'x'; } + /** + * @return bool + */ + public function usesPKCE(): bool + { + return true; + } + /** * @return string */ @@ -53,33 +56,11 @@ class X extends OAuth2 'redirect_uri' => $this->callback, 'scope' => \implode(' ', $this->getScopes()), 'state' => \json_encode($this->state), - 'code_challenge' => $this->getCodeChallenge(), + 'code_challenge' => $this->getPKCEChallenge(), 'code_challenge_method' => 'S256', ]); } - /** - * @return string - */ - public function getPKCEVerifier(): string - { - if (empty($this->pkceVerifier)) { - $this->pkceVerifier = $this->base64UrlEncode(\random_bytes(32)); - } - - return $this->pkceVerifier; - } - - /** - * @param string $pkceVerifier - * - * @return void - */ - public function setPKCEVerifier(string $pkceVerifier): void - { - $this->pkceVerifier = $pkceVerifier; - } - /** * @param string $code * @@ -216,21 +197,4 @@ class X extends OAuth2 return $this->user; } - /** - * @return string - */ - private function getCodeChallenge(): string - { - return $this->base64UrlEncode(\hash('sha256', $this->getPKCEVerifier(), true)); - } - - /** - * @param string $value - * - * @return string - */ - private function base64UrlEncode(string $value): string - { - return \rtrim(\strtr(\base64_encode($value), '+/', '-_'), '='); - } } From 85703d29e1638a134ae8aa20d66bb97d51d46134 Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Mon, 23 Mar 2026 19:07:36 +0530 Subject: [PATCH 04/35] addressed greptile suggestions --- app/controllers/api/account.php | 14 ++++++++++++-- src/Appwrite/Auth/OAuth2/X.php | 1 + 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 5a62c9bf21..d6ccc06a52 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1397,12 +1397,17 @@ Http::get('/v1/account/sessions/oauth2/:provider') 'token' => false, ], $scopes); + $pkceVerifier = ''; + if ($oauth2->usesPKCE()) { + $pkceVerifier = $oauth2->getPKCEVerifier(); + } + $loginURL = $oauth2->getLoginURL(); if ($oauth2->usesPKCE()) { $response->addCookie( 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, - $oauth2->getPKCEVerifier(), + $pkceVerifier, \time() + 300, '/', Config::getParam('cookieDomain'), @@ -2135,12 +2140,17 @@ Http::get('/v1/account/tokens/oauth2/:provider') 'token' => true, ], $scopes); + $pkceVerifier = ''; + if ($oauth2->usesPKCE()) { + $pkceVerifier = $oauth2->getPKCEVerifier(); + } + $loginURL = $oauth2->getLoginURL(); if ($oauth2->usesPKCE()) { $response->addCookie( 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, - $oauth2->getPKCEVerifier(), + $pkceVerifier, \time() + 300, '/', Config::getParam('cookieDomain'), diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php index 0cf5689626..31eeecbb1d 100644 --- a/src/Appwrite/Auth/OAuth2/X.php +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -24,6 +24,7 @@ class X extends OAuth2 * @var array */ protected array $scopes = [ + 'tweet.read', 'users.read', 'users.email', 'offline.access', From 614db7388eff389ac34f9379c573d127ec49f0fc Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Thu, 26 Mar 2026 17:40:28 +0530 Subject: [PATCH 05/35] fix: push --- .env | 8 +-- app/controllers/api/account.php | 116 ++++++++++-------------------- app/controllers/mock.php | 41 +++++++++++ src/Appwrite/Auth/OAuth2.php | 53 -------------- src/Appwrite/Auth/OAuth2/Etsy.php | 29 +++++--- src/Appwrite/Auth/OAuth2/X.php | 21 ------ 6 files changed, 100 insertions(+), 168 deletions(-) diff --git a/.env b/.env index 9abfa756e1..1b1a55d6f4 100644 --- a/.env +++ b/.env @@ -9,7 +9,7 @@ _APP_CONSOLE_WHITELIST_EMAILS= _APP_CONSOLE_SESSION_ALERTS=enabled _APP_CONSOLE_WHITELIST_IPS= _APP_CONSOLE_COUNTRIES_DENYLIST=AQ -_APP_CONSOLE_HOSTNAMES=localhost,appwrite.io,*.appwrite.io +_APP_CONSOLE_HOSTNAMES=localhost,appwrite.io,*.appwrite.io,posted-costumes-alphabetical-census.trycloudflare.com _APP_CONSOLE_SCHEMA=appwriteio _APP_MIGRATION_HOST=appwrite _APP_SYSTEM_EMAIL_NAME=Appwrite @@ -25,8 +25,8 @@ _APP_OPTIONS_FORCE_HTTPS=disabled _APP_OPTIONS_ROUTER_FORCE_HTTPS=disabled _APP_OPENSSL_KEY_V1=your-secret-key _APP_DNS=172.16.238.100 # CoreDNS -_APP_DOMAIN=appwrite.test -_APP_CONSOLE_DOMAIN=localhost +_APP_DOMAIN=posted-costumes-alphabetical-census.trycloudflare.com +_APP_CONSOLE_DOMAIN=posted-costumes-alphabetical-census.trycloudflare.com _APP_CONSOLE_TRUSTED_PROJECTS=trusted-project,another-trusted-project _APP_DOMAIN_FUNCTIONS=functions.localhost _APP_DOMAIN_SITES=sites.localhost,rebranded.localhost @@ -143,6 +143,6 @@ _APP_WEBHOOK_MAX_FAILED_ATTEMPTS=10 _APP_PROJECT_REGIONS=default _APP_FUNCTIONS_CREATION_ABUSE_LIMIT=5000 _APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main -_APP_TRUSTED_HEADERS=x-forwarded-for +_APP_TRUSTED_HEADERS=x-forwarded-for,x-forwarded-proto,x-forwarded-host,x-forwarded-port _APP_POOL_ADAPTER=stack _APP_WORKER_SCREENSHOTS_ROUTER=http://appwrite diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index d6ccc06a52..72347eaf9d 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1341,12 +1341,13 @@ Http::get('/v1/account/sessions/oauth2/:provider') ->inject('project') ->inject('platform') ->action(function (string $provider, string $success, string $failure, array $scopes, Request $request, Response $response, Document $project, array $platform) use ($oauthDefaultSuccess, $oauthDefaultFailure) { - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $port = $request->getPort(); + $protocol = $request->getProtocol(); + $port = (string) $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ($protocol === 'https' && $port !== '443') { - $callbackBase .= ':' . $port; - } elseif ($protocol === 'http' && $port !== '80') { + if ( + $port !== '' + && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) + ) { $callbackBase .= ':' . $port; } @@ -1397,26 +1398,8 @@ Http::get('/v1/account/sessions/oauth2/:provider') 'token' => false, ], $scopes); - $pkceVerifier = ''; - if ($oauth2->usesPKCE()) { - $pkceVerifier = $oauth2->getPKCEVerifier(); - } - $loginURL = $oauth2->getLoginURL(); - if ($oauth2->usesPKCE()) { - $response->addCookie( - 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, - $pkceVerifier, - \time() + 300, - '/', - Config::getParam('cookieDomain'), - ('https' === $protocol), - true, - Response::COOKIE_SAMESITE_LAX - ); - } - $response ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') ->addHeader('Pragma', 'no-cache') @@ -1438,12 +1421,13 @@ Http::get('/v1/account/sessions/oauth2/callback/:provider/:projectId') ->inject('request') ->inject('response') ->action(function (string $projectId, string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response) { - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $port = $request->getPort(); + $protocol = $request->getProtocol(); + $port = (string) $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ($protocol === 'https' && $port !== '443') { - $callbackBase .= ':' . $port; - } elseif ($protocol === 'http' && $port !== '80') { + if ( + $port !== '' + && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) + ) { $callbackBase .= ':' . $port; } @@ -1474,12 +1458,13 @@ Http::post('/v1/account/sessions/oauth2/callback/:provider/:projectId') ->inject('request') ->inject('response') ->action(function (string $projectId, string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response) { - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $port = $request->getPort(); + $protocol = $request->getProtocol(); + $port = (string) $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ($protocol === 'https' && $port !== '443') { - $callbackBase .= ':' . $port; - } elseif ($protocol === 'http' && $port !== '80') { + if ( + $port !== '' + && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) + ) { $callbackBase .= ':' . $port; } @@ -1526,12 +1511,13 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->inject('proofForToken') ->inject('authorization') ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) use ($oauthDefaultSuccess) { - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $port = $request->getPort(); + $protocol = $request->getProtocol(); + $port = (string) $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ($protocol === 'https' && $port !== '443') { - $callbackBase .= ':' . $port; - } elseif ($protocol === 'http' && $port !== '80') { + if ( + $port !== '' + && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) + ) { $callbackBase .= ':' . $port; } @@ -1553,20 +1539,6 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') /** @var Appwrite\Auth\OAuth2 $oauth2 */ $oauth2 = new $className($appId, $appSecret, $callback); - if ($oauth2->usesPKCE()) { - $oauth2->setPKCEVerifier($request->getCookie('a_oauth2_pkce_' . $project->getId() . '_' . $provider, '')); - $response->addCookie( - 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, - '', - \time() - 3600, - '/', - Config::getParam('cookieDomain'), - ('https' === $protocol), - true, - Response::COOKIE_SAMESITE_LAX - ); - } - if (!empty($state)) { try { $state = \array_merge($defaultState, $oauth2->parseState($state)); @@ -2082,12 +2054,13 @@ Http::get('/v1/account/tokens/oauth2/:provider') ->inject('project') ->inject('platform') ->action(function (string $provider, string $success, string $failure, array $scopes, Request $request, Response $response, Document $project, array $platform) use ($oauthDefaultSuccess, $oauthDefaultFailure) { - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $port = $request->getPort(); + $protocol = $request->getProtocol(); + $port = (string) $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ($protocol === 'https' && $port !== '443') { - $callbackBase .= ':' . $port; - } elseif ($protocol === 'http' && $port !== '80') { + if ( + $port !== '' + && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) + ) { $callbackBase .= ':' . $port; } @@ -2117,12 +2090,13 @@ Http::get('/v1/account/tokens/oauth2/:provider') } $host = $platform['consoleHostname'] ?? ''; - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; - $port = $request->getPort(); + $protocol = $request->getProtocol(); + $port = (string) $request->getPort(); $redirectBase = $protocol . '://' . $host; - if ($protocol === 'https' && $port !== '443') { - $redirectBase .= ':' . $port; - } elseif ($protocol === 'http' && $port !== '80') { + if ( + $port !== '' + && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) + ) { $redirectBase .= ':' . $port; } @@ -2140,26 +2114,8 @@ Http::get('/v1/account/tokens/oauth2/:provider') 'token' => true, ], $scopes); - $pkceVerifier = ''; - if ($oauth2->usesPKCE()) { - $pkceVerifier = $oauth2->getPKCEVerifier(); - } - $loginURL = $oauth2->getLoginURL(); - if ($oauth2->usesPKCE()) { - $response->addCookie( - 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, - $pkceVerifier, - \time() + 300, - '/', - Config::getParam('cookieDomain'), - ('https' === $protocol), - true, - Response::COOKIE_SAMESITE_LAX - ); - } - $response ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') ->addHeader('Pragma', 'no-cache') diff --git a/app/controllers/mock.php b/app/controllers/mock.php index 712d4b7742..0e0fe8b821 100644 --- a/app/controllers/mock.php +++ b/app/controllers/mock.php @@ -11,10 +11,12 @@ use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; use Utopia\Http\Http; use Utopia\Locale\Locale; use Utopia\System\System; +use Utopia\Validator\Boolean; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; use Utopia\VCS\Adapter\Git\GitHub; @@ -219,6 +221,45 @@ Http::post('/v1/mock/api-key-unprefixed') ->dynamic($key, Response::MODEL_KEY); }); +Http::post('/v1/mock/tests/projects/:projectId/oauth2/x') + ->desc('Enable X OAuth2 provider for a project') + ->groups(['mock', 'api', 'projects']) + ->label('scope', 'public') + ->label('docs', false) + ->param('projectId', '', new UID(), 'Project ID.') + ->param('appId', '', new Text(256), 'Provider app ID.') + ->param('secret', '', new Text(512), 'Provider secret.') + ->param('enabled', true, new Boolean(), 'Provider enabled status.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('authorization') + ->action(function (string $projectId, string $appId, string $secret, bool $enabled, Response $response, Database $dbForPlatform, Authorization $authorization) { + $isDevelopment = System::getEnv('_APP_ENV', 'development') === 'development'; + + if (!$isDevelopment) { + throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); + } + + $project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId)); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $providers = $project->getAttribute('oAuthProviders', []); + $providers['xAppid'] = $appId; + $providers['xSecret'] = $secret; + $providers['xEnabled'] = $enabled; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'oAuthProviders' => $providers, + ]))); + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $response->dynamic($project, Response::MODEL_PROJECT); + }); + Http::get('/v1/mock/github/callback') ->desc('Create installation document using GitHub installation id') ->groups(['mock', 'api', 'vcs']) diff --git a/src/Appwrite/Auth/OAuth2.php b/src/Appwrite/Auth/OAuth2.php index 5e884b7bad..9358c89547 100644 --- a/src/Appwrite/Auth/OAuth2.php +++ b/src/Appwrite/Auth/OAuth2.php @@ -31,11 +31,6 @@ abstract class OAuth2 */ protected array $scopes; - /** - * @var string - */ - protected string $pkceVerifier = ''; - /** * OAuth2 constructor. * @@ -110,14 +105,6 @@ abstract class OAuth2 */ abstract public function getUserName(string $accessToken): string; - /** - * @return bool - */ - public function usesPKCE(): bool - { - return false; - } - /** * @param $scope * @@ -141,36 +128,6 @@ abstract class OAuth2 return $this->scopes; } - /** - * @return string - */ - public function getPKCEVerifier(): string - { - if (empty($this->pkceVerifier)) { - $this->pkceVerifier = $this->base64UrlEncode(\random_bytes(32)); - } - - return $this->pkceVerifier; - } - - /** - * @param string $pkceVerifier - * - * @return void - */ - public function setPKCEVerifier(string $pkceVerifier): void - { - $this->pkceVerifier = $pkceVerifier; - } - - /** - * @return string - */ - protected function getPKCEChallenge(): string - { - return $this->base64UrlEncode(\hash('sha256', $this->getPKCEVerifier(), true)); - } - /** * @param string $code * @@ -257,14 +214,4 @@ abstract class OAuth2 return (string)$response; } - - /** - * @param string $value - * - * @return string - */ - protected function base64UrlEncode(string $value): string - { - return \rtrim(\strtr(\base64_encode($value), '+/', '-_'), '='); - } } diff --git a/src/Appwrite/Auth/OAuth2/Etsy.php b/src/Appwrite/Auth/OAuth2/Etsy.php index 122838078d..7ff16fcb78 100644 --- a/src/Appwrite/Auth/OAuth2/Etsy.php +++ b/src/Appwrite/Auth/OAuth2/Etsy.php @@ -34,6 +34,23 @@ class Etsy extends OAuth2 "profile_r", ]; + /** + * @var string + */ + private string $pkce = ''; + + /** + * @return string + */ + private function getPKCE(): string + { + if (empty($this->pkce)) { + $this->pkce = \bin2hex(\random_bytes(rand(43, 128))); + } + + return $this->pkce; + } + /** * @return string */ @@ -42,14 +59,6 @@ class Etsy extends OAuth2 return 'etsy'; } - /** - * @return bool - */ - public function usesPKCE(): bool - { - return true; - } - /** * @return string */ @@ -61,7 +70,7 @@ class Etsy extends OAuth2 'response_type' => 'code', 'state' => \json_encode($this->state), 'scope' => $this->scopes, - 'code_challenge' => $this->getPKCEChallenge(), + 'code_challenge' => $this->getPKCE(), 'code_challenge_method' => 'S256', ]); } @@ -85,7 +94,7 @@ class Etsy extends OAuth2 'client_id' => $this->appID, 'redirect_uri' => $this->callback, 'code' => $code, - 'code_verifier' => $this->getPKCEVerifier(), + 'code_verifier' => $this->getPKCE(), ]) ), true); } diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php index 31eeecbb1d..331e011270 100644 --- a/src/Appwrite/Auth/OAuth2/X.php +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -38,17 +38,6 @@ class X extends OAuth2 return 'x'; } - /** - * @return bool - */ - public function usesPKCE(): bool - { - return true; - } - - /** - * @return string - */ public function getLoginURL(): string { return 'https://x.com/i/oauth2/authorize?' . \http_build_query([ @@ -57,8 +46,6 @@ class X extends OAuth2 'redirect_uri' => $this->callback, 'scope' => \implode(' ', $this->getScopes()), 'state' => \json_encode($this->state), - 'code_challenge' => $this->getPKCEChallenge(), - 'code_challenge_method' => 'S256', ]); } @@ -70,13 +57,6 @@ class X extends OAuth2 protected function getTokens(string $code): array { if (empty($this->tokens)) { - if (empty($this->pkceVerifier)) { - throw new Exception(\json_encode([ - 'error' => 'invalid_request', - 'error_description' => 'Missing PKCE verifier.', - ]), 400); - } - $headers = [ 'Authorization: Basic ' . \base64_encode($this->appID . ':' . $this->appSecret), 'Content-Type: application/x-www-form-urlencoded', @@ -91,7 +71,6 @@ class X extends OAuth2 'client_id' => $this->appID, 'grant_type' => 'authorization_code', 'redirect_uri' => $this->callback, - 'code_verifier' => $this->getPKCEVerifier(), ]) ), true); } From 113c266881d47bdad55c72c8eac99c1f2761e75f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 26 Mar 2026 14:43:38 +0100 Subject: [PATCH 06/35] Fix audit resource ID missing --- .../Platform/Modules/Project/Http/Project/Variables/Delete.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php index ac47ec3dbb..e4694bb3cc 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php @@ -35,7 +35,7 @@ class Delete extends Base ->label('scope', 'project.write') ->label('event', 'variables.[variableId].delete') ->label('audits.event', 'project.variable.delete') - ->label('audits.resource', 'project.variable/{response.$id}') + ->label('audits.resource', 'project.variable/{request.variableId}') ->label('sdk', new Method( namespace: 'project', group: 'variables', From cbfdd2783401526783b69aa506631c7e227d20d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 26 Mar 2026 15:00:20 +0100 Subject: [PATCH 07/35] Public keys Apis --- app/controllers/api/projects.php | 287 ------------------ .../Project/Http/Project/Keys/Create.php | 121 ++++++++ .../Project/Http/Project/Keys/Delete.php | 90 ++++++ .../Modules/Project/Http/Project/Keys/Get.php | 76 +++++ .../Project/Http/Project/Keys/Update.php | 112 +++++++ .../Project/Http/Project/Keys/XList.php | 129 ++++++++ .../Modules/Project/Services/Http.php | 12 + 7 files changed, 540 insertions(+), 287 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 2fc20ba83f..5b81327ee4 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -12,21 +12,16 @@ use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Template\Template; -use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Database\Validator\Queries\Keys; use Appwrite\Utopia\Response; use PHPMailer\PHPMailer\PHPMailer; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Database\Exception\Duplicate; -use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; -use Utopia\Database\Validator\Datetime as DatetimeValidator; -use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; use Utopia\Emails\Validator\Email; use Utopia\Http\Http; @@ -769,288 +764,6 @@ Http::delete('/v1/projects/:projectId') $response->noContent(); }); -// Keys - -Http::post('/v1/projects/:projectId/keys') - ->desc('Create key') - ->groups(['api', 'projects']) - ->label('scope', 'keys.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'keys', - name: 'createKey', - description: '/docs/references/projects/create-key.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_KEY, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - // TODO: When migrating to Platform API, mark keyId required for consistency - ->param('keyId', 'unique()', fn (Database $dbForPlatform) => new CustomId($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', true, ['dbForPlatform'])->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') - ->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $keyId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) { - $keyId = $keyId == 'unique()' ? ID::unique() : $keyId; - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $key = new Document([ - '$id' => $keyId, - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'resourceInternalId' => $project->getSequence(), - 'resourceId' => $project->getId(), - 'resourceType' => 'projects', - 'name' => $name, - 'scopes' => $scopes, - 'expire' => $expire, - 'sdks' => [], - 'accessedAt' => null, - 'secret' => API_KEY_STANDARD . '_' . \bin2hex(\random_bytes(128)), - ]); - - try { - $key = $dbForPlatform->createDocument('keys', $key); - } catch (Duplicate) { - throw new Exception(Exception::KEY_ALREADY_EXISTS); - } - - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($key, Response::MODEL_KEY); - }); - -Http::get('/v1/projects/:projectId/keys') - ->desc('List keys') - ->groups(['api', 'projects']) - ->label('scope', 'keys.read') - ->label('sdk', new Method( - namespace: 'projects', - group: 'keys', - name: 'listKeys', - description: '/docs/references/projects/list-keys.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_KEY_LIST, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('queries', [], new Keys(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Keys::ALLOWED_ATTRIBUTES), true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, array $queries, bool $includeTotal, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - // Backwards compatibility - if (\count(Query::getByType($queries, [Query::TYPE_LIMIT])) === 0) { - $queries[] = Query::limit(5000); - } - - $queries[] = Query::equal('resourceType', ['projects']); - $queries[] = Query::equal('resourceInternalId', [$project->getSequence()]); - - $cursor = Query::getCursorQueries($queries, false); - $cursor = \reset($cursor); - - if ($cursor !== false) { - $validator = new Cursor(); - if (!$validator->isValid($cursor)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $keyId = $cursor->getValue(); - $cursorDocument = $dbForPlatform->getDocument('keys', $keyId); - - if ($cursorDocument->isEmpty()) { - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Key '{$keyId}' for the 'cursor' value not found."); - } - - $cursor->setValue($cursorDocument); - } - - $filterQueries = Query::groupByType($queries)['filters']; - - $keys = $dbForPlatform->find('keys', $queries); - - $response->dynamic(new Document([ - 'keys' => $keys, - 'total' => $includeTotal ? $dbForPlatform->count('keys', $filterQueries, APP_LIMIT_COUNT) : 0, - ]), Response::MODEL_KEY_LIST); - }); - -Http::get('/v1/projects/:projectId/keys/:keyId') - ->desc('Get key') - ->groups(['api', 'projects']) - ->label('scope', 'keys.read') - ->label('sdk', new Method( - namespace: 'projects', - group: 'keys', - name: 'getKey', - description: '/docs/references/projects/get-key.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_KEY, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key unique ID.', false, ['dbForPlatform']) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $keyId, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $key = $dbForPlatform->findOne('keys', [ - Query::equal('$id', [$keyId]), - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$project->getSequence()]), - ]); - - if ($key->isEmpty()) { - throw new Exception(Exception::KEY_NOT_FOUND); - } - - $response->dynamic($key, Response::MODEL_KEY); - }); - -Http::put('/v1/projects/:projectId/keys/:keyId') - ->desc('Update key') - ->groups(['api', 'projects']) - ->label('scope', 'keys.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'keys', - name: 'updateKey', - description: '/docs/references/projects/update-key.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_KEY, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key unique ID.', false, ['dbForPlatform']) - ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') - ->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $keyId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $key = $dbForPlatform->findOne('keys', [ - Query::equal('$id', [$keyId]), - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$project->getSequence()]), - ]); - - if ($key->isEmpty()) { - throw new Exception(Exception::KEY_NOT_FOUND); - } - - $key - ->setAttribute('name', $name) - ->setAttribute('scopes', $scopes) - ->setAttribute('expire', $expire); - - $dbForPlatform->updateDocument('keys', $key->getId(), $key); - - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); - - $response->dynamic($key, Response::MODEL_KEY); - }); - -Http::delete('/v1/projects/:projectId/keys/:keyId') - ->desc('Delete key') - ->groups(['api', 'projects']) - ->label('scope', 'keys.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'keys', - name: 'deleteKey', - description: '/docs/references/projects/delete-key.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key unique ID.', false, ['dbForPlatform']) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $keyId, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $key = $dbForPlatform->findOne('keys', [ - Query::equal('$id', [$keyId]), - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$project->getSequence()]), - ]); - - if ($key->isEmpty()) { - throw new Exception(Exception::KEY_NOT_FOUND); - } - - $dbForPlatform->deleteDocument('keys', $key->getId()); - - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); - - $response->noContent(); - }); - // JWT Keys Http::post('/v1/projects/:projectId/jwts') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php new file mode 100644 index 0000000000..cd24be7a2f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php @@ -0,0 +1,121 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/keys') + ->httpAlias('/v1/projects/:projectId/keys') + ->desc('Create project key') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'keys.[keyId].create') + ->label('audits.event', 'project.key.create') + ->label('audits.resource', 'project.key/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'createKey', + description: <<param('keyId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') + ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + /** + * @param array $scopes + */ + public function action( + string $keyId, + string $name, + array $scopes, + ?string $expire, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ) { + $keyId = ($keyId == 'unique()') ? ID::unique() : $keyId; + + // TODO: If authorized as API key, verify scopes and expiry is OK + + $key = new Document([ + '$id' => $keyId, + '$permissions' => [], + 'resourceInternalId' => $project->getSequence(), + 'resourceId' => $project->getId(), + 'resourceType' => 'projects', + 'name' => $name, + 'scopes' => $scopes, + 'expire' => $expire, + 'sdks' => [], + 'accessedAt' => null, + 'secret' => API_KEY_STANDARD . '_' . \bin2hex(\random_bytes(128)), + ]); + + try { + $key = $authorization->skip(fn () => $dbForPlatform->createDocument('keys', $key)); + } catch (DuplicateException) { + throw new Exception(Exception::KEY_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('keyId', $key->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($key, Response::MODEL_KEY); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php new file mode 100644 index 0000000000..5970b2d7aa --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php @@ -0,0 +1,90 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/project/keys/:keyId') + ->httpAlias('/v1/projects/:projectId/keys/:keyId') + ->desc('Delete project key') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'keys.[keyId].delete') + ->label('audits.event', 'project.key.delete') + ->label('audits.resource', 'project.key/{request.keyId}') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'deleteKey', + description: <<param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform']) + ->inject('response') + ->inject('dbForPlatform') + ->inject('queueForEvents') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $keyId, + Response $response, + Database $dbForPlatform, + Event $queueForEvents, + Document $project, + Authorization $authorization, + ) { + $key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId)); + + if ($key->isEmpty() || $key->getAttribute('resourceType', '') !== 'projects' || $key->getAttribute('resourceInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::KEY_NOT_FOUND); + } + + if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('keys', $key->getId()))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove document from DB'); + }; + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('keyId', $key->getId()); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php new file mode 100644 index 0000000000..377d398ad0 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php @@ -0,0 +1,76 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/keys/:keyId') + ->httpAlias('/v1/projects/:projectId/keys/:keyId') + ->desc('Get project key') + ->groups(['api', 'project']) + ->label('scope', 'project.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'getKey', + description: <<param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform']) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $keyId, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ) { + $key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId)); + + if ($key->isEmpty() || $key->getAttribute('resourceType', '') !== 'projects' || $key->getAttribute('resourceInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::KEY_NOT_FOUND); + } + + // TODO: If authorized as api key, hide secret of key + + $response->dynamic($key, Response::MODEL_KEY); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php new file mode 100644 index 0000000000..2bde57a6f5 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php @@ -0,0 +1,112 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/keys/:keyId') + ->httpAlias('/v1/projects/:projectId/keys/:keyId') + ->desc('Update project key') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'keys.[keyId].update') + ->label('audits.event', 'project.key.update') + ->label('audits.resource', 'project.key/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'updateKey', + description: <<param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') + ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $keyId, + string $name, + array $scopes, + ?string $expire, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ) { + $key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId)); + + if ($key->isEmpty() || $key->getAttribute('resourceType', '') !== 'projects' || $key->getAttribute('resourceInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::KEY_NOT_FOUND); + } + + // TODO: If authorized as API key, verify scopes and expiry is OK + + $updates = new Document([ + 'name' => $name, + 'scopes' => $scopes, + 'expire' => $expire, + ]); + + try { + $key = $authorization->skip(fn () => $dbForPlatform->updateDocument('keys', $key->getId(), $updates)); + } catch (Duplicate $th) { + throw new Exception(Exception::KEY_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('keyId', $key->getId()); + + // TODO: If authorized as api key, hide secret of key + + $response->dynamic($key, Response::MODEL_KEY); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php new file mode 100644 index 0000000000..2a1e3f9f1f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php @@ -0,0 +1,129 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/keys') + ->httpAlias('/v1/projects/:projectId/keys') + ->desc('List project keys') + ->groups(['api', 'project']) + ->label('scope', 'project.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'listKeys', + description: <<param('queries', [], new Keys(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Keys::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('project') + ->inject('response') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + /** + * @param array $queries + */ + public function action( + array $queries, + bool $includeTotal, + Document $project, + Response $response, + Database $dbForPlatform, + Authorization $authorization, + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + // Backwards compatibility + if (\count(Query::getByType($queries, [Query::TYPE_LIMIT])) === 0) { + $queries[] = Query::limit(5000); + } + + $queries[] = Query::equal('resourceType', ['projects']); + $queries[] = Query::equal('resourceInternalId', [$project->getSequence()]); + + $cursor = Query::getCursorQueries($queries, false); + $cursor = \reset($cursor); + + if ($cursor !== false) { + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $keyId = $cursor->getValue(); + $cursorDocument = $authorization->skip(fn () => $dbForPlatform->findOne('keys', [ + Query::equal('$id', [$keyId]), + Query::equal('resourceType', ['projects']), + Query::equal('resourceInternalId', [$project->getSequence()]), + ])); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Key '{$keyId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $keys = $authorization->skip(fn () => $dbForPlatform->find('keys', $queries)); + $total = $includeTotal ? $authorization->skip(fn () => $dbForPlatform->count('keys', $filterQueries, APP_LIMIT_COUNT)) : 0; + } catch (OrderException $e) { + 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."); + } + + // TODO: API keys cannot see secrets + + $response->dynamic(new Document([ + 'keys' => $keys, + 'total' => $total, + ]), Response::MODEL_KEY_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 949fb2bcd9..8b2ed87f4b 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -3,6 +3,11 @@ namespace Appwrite\Platform\Modules\Project\Services; use Appwrite\Platform\Modules\Project\Http\Init; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Update as UpdateKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\XList as ListKeys; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; @@ -25,5 +30,12 @@ class Http extends Service $this->addAction(GetVariable::getName(), new GetVariable()); $this->addAction(DeleteVariable::getName(), new DeleteVariable()); $this->addAction(UpdateVariable::getName(), new UpdateVariable()); + + // Keys + $this->addAction(CreateKey::getName(), new CreateKey()); + $this->addAction(ListKeys::getName(), new ListKeys()); + $this->addAction(GetKey::getName(), new GetKey()); + $this->addAction(DeleteKey::getName(), new DeleteKey()); + $this->addAction(UpdateKey::getName(), new UpdateKey()); } } From eb097a037b29d71a2cccba48c71119d8d7c93c90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 26 Mar 2026 15:35:40 +0100 Subject: [PATCH 08/35] Finish security todos --- src/Appwrite/Auth/Key.php | 16 +++++++++--- .../Project/Http/Project/Keys/Create.php | 19 +++++++++++++- .../Modules/Project/Http/Project/Keys/Get.php | 9 ++++++- .../Project/Http/Project/Keys/Update.php | 25 ++++++++++++++++--- .../Project/Http/Project/Keys/XList.php | 11 +++++++- 5 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/Appwrite/Auth/Key.php b/src/Appwrite/Auth/Key.php index 8f645f6f08..888f55f926 100644 --- a/src/Appwrite/Auth/Key.php +++ b/src/Appwrite/Auth/Key.php @@ -22,6 +22,7 @@ class Key protected array $scopes, protected string $name, protected bool $expired = false, + protected ?string $expire = null, protected array $disabledMetrics = [], protected bool $hostnameOverride = false, protected bool $bannerDisabled = false, @@ -71,6 +72,11 @@ class Key return $this->expired; } + public function getExpire(): ?string + { + return $this->expire; + } + public function getDisabledMetrics(): array { return $this->disabledMetrics; @@ -176,6 +182,7 @@ class Key $scopes, $name, $expired, + DateTime::addSeconds(new \DateTime(), 86400), // Max possible JWT expiry $disabledMetrics, $hostnameOverride, $bannerDisabled, @@ -210,7 +217,8 @@ class Key $role, $scopes, $name, - $expired + $expired, + $expire, ); case API_KEY_ACCOUNT: $key = $user->find( @@ -244,7 +252,8 @@ class Key $role, $scopes, $name, - $expired + $expired, + $expire, ); return $key; @@ -280,7 +289,8 @@ class Key $role, $scopes, $name, - $expired + $expired, + $expire, ); return $key; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php index cd24be7a2f..52a5fdc323 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Keys; +use Appwrite\Auth\Key; use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; @@ -69,6 +70,7 @@ class Create extends Base ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('apiKey') ->callback($this->action(...)); } @@ -85,10 +87,25 @@ class Create extends Base Database $dbForPlatform, Document $project, Authorization $authorization, + ?Key $apiKey, ) { $keyId = ($keyId == 'unique()') ? ID::unique() : $keyId; - // TODO: If authorized as API key, verify scopes and expiry is OK + $isProjectApiKey = $apiKey !== null && !empty($apiKey->getProjectId()); + + if ($isProjectApiKey) { + if (!empty(\array_diff($scopes ?? [], $apiKey->getScopes()))) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'New API key cannot exceed scopes of currently authenticated API key.'); + } + + if (\is_null($expire) && !\is_null($apiKey->getExpire())) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'New API key must have expiry set, because currently authenticated API key has an expiry.'); + } + + if (!\is_null($expire) && $expire > $apiKey->getExpire()) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'New API key expiry must be sooner than currently authenticated API key expiry.'); + } + } $key = new Document([ '$id' => $keyId, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php index 377d398ad0..1939a513a6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Keys; +use Appwrite\Auth\Key; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; @@ -53,6 +54,7 @@ class Get extends Base ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('apiKey') ->callback($this->action(...)); } @@ -62,6 +64,7 @@ class Get extends Base Database $dbForPlatform, Document $project, Authorization $authorization, + ?Key $apiKey, ) { $key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId)); @@ -69,7 +72,11 @@ class Get extends Base throw new Exception(Exception::KEY_NOT_FOUND); } - // TODO: If authorized as api key, hide secret of key + $isProjectApiKey = $apiKey !== null && !empty($apiKey->getProjectId()); + + if ($isProjectApiKey) { + $key->setAttribute('secret', ''); + } $response->dynamic($key, Response::MODEL_KEY); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php index 2bde57a6f5..5b8abab621 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Keys; +use Appwrite\Auth\Key; use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; @@ -67,6 +68,7 @@ class Update extends Base ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('apiKey') ->callback($this->action(...)); } @@ -80,6 +82,7 @@ class Update extends Base Database $dbForPlatform, Document $project, Authorization $authorization, + ?Key $apiKey, ) { $key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId)); @@ -87,7 +90,21 @@ class Update extends Base throw new Exception(Exception::KEY_NOT_FOUND); } - // TODO: If authorized as API key, verify scopes and expiry is OK + $isProjectApiKey = $apiKey !== null && !empty($apiKey->getProjectId()); + + if ($isProjectApiKey) { + if (!empty(\array_diff($scopes ?? [], $apiKey->getScopes()))) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Updated API key cannot exceed scopes of currently authenticated API key.'); + } + + if (\is_null($expire) && !\is_null($apiKey->getExpire())) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Updated API key must have expiry set, because currently authenticated API key has an expiry.'); + } + + if (!\is_null($expire) && $expire > $apiKey->getExpire()) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Updated API key expiry must be sooner than currently authenticated API key expiry.'); + } + } $updates = new Document([ 'name' => $name, @@ -97,7 +114,7 @@ class Update extends Base try { $key = $authorization->skip(fn () => $dbForPlatform->updateDocument('keys', $key->getId(), $updates)); - } catch (Duplicate $th) { + } catch (Duplicate) { throw new Exception(Exception::KEY_ALREADY_EXISTS); } @@ -105,7 +122,9 @@ class Update extends Base $queueForEvents->setParam('keyId', $key->getId()); - // TODO: If authorized as api key, hide secret of key + if ($isProjectApiKey) { + $key->setAttribute('secret', ''); + } $response->dynamic($key, Response::MODEL_KEY); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php index 2a1e3f9f1f..e54c668c5f 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Keys; +use Appwrite\Auth\Key; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; @@ -59,6 +60,7 @@ class XList extends Base ->inject('response') ->inject('dbForPlatform') ->inject('authorization') + ->inject('apiKey') ->callback($this->action(...)); } @@ -72,6 +74,7 @@ class XList extends Base Response $response, Database $dbForPlatform, Authorization $authorization, + ?Key $apiKey, ) { try { $queries = Query::parseQueries($queries); @@ -119,7 +122,13 @@ class XList extends Base 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."); } - // TODO: API keys cannot see secrets + $isProjectApiKey = $apiKey !== null && !empty($apiKey->getProjectId()); + + if ($isProjectApiKey) { + foreach ($keys as $key) { + $key->setAttribute('secret', ''); + } + } $response->dynamic(new Document([ 'keys' => $keys, From 8113854a8921e3356739a3e3d2dc0afcd0209931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 26 Mar 2026 15:37:57 +0100 Subject: [PATCH 09/35] AI review fixes --- .../Platform/Modules/Project/Http/Project/Keys/Create.php | 4 ++-- .../Platform/Modules/Project/Http/Project/Keys/Update.php | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php index 52a5fdc323..447d2ae8b2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php @@ -75,12 +75,12 @@ class Create extends Base } /** - * @param array $scopes + * @param array|null $scopes */ public function action( string $keyId, string $name, - array $scopes, + ?array $scopes, ?string $expire, Response $response, QueueEvent $queueForEvents, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php index 5b8abab621..d372f418af 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php @@ -72,10 +72,13 @@ class Update extends Base ->callback($this->action(...)); } + /** + * @param array|null $scopes + */ public function action( string $keyId, string $name, - array $scopes, + ?array $scopes, ?string $expire, Response $response, QueueEvent $queueForEvents, From 7f4d5f692dc9ac8d1458d809690f0ea7bb260e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 26 Mar 2026 15:44:50 +0100 Subject: [PATCH 10/35] Fix non-expiry key actions --- .../Platform/Modules/Project/Http/Project/Keys/Create.php | 2 +- .../Platform/Modules/Project/Http/Project/Keys/Update.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php index 447d2ae8b2..0ab914af90 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php @@ -102,7 +102,7 @@ class Create extends Base throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'New API key must have expiry set, because currently authenticated API key has an expiry.'); } - if (!\is_null($expire) && $expire > $apiKey->getExpire()) { + if (!\is_null($expire) && !\is_null($apiKey->getExpire()) && $expire > $apiKey->getExpire()) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'New API key expiry must be sooner than currently authenticated API key expiry.'); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php index d372f418af..d3ac67fc76 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php @@ -104,7 +104,7 @@ class Update extends Base throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Updated API key must have expiry set, because currently authenticated API key has an expiry.'); } - if (!\is_null($expire) && $expire > $apiKey->getExpire()) { + if (!\is_null($expire) && !\is_null($apiKey->getExpire()) && $expire > $apiKey->getExpire()) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Updated API key expiry must be sooner than currently authenticated API key expiry.'); } } From 7371b68418968b060f3813c3c7fb6af18fe68443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 26 Mar 2026 15:51:03 +0100 Subject: [PATCH 11/35] Fix tests compatibility --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 3f84529943..276f29e2fe 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3158,6 +3158,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', ], $this->getHeaders()), [ 'name' => 'Key Custom', 'scopes' => ['teams.read', 'teams.write'], @@ -3243,6 +3244,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', ], $this->getHeaders()), [ 'name' => 'Key Test 2', 'scopes' => ['users.read'], @@ -3618,6 +3620,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', ], $this->getHeaders()), [ 'name' => 'Key For Deletion', 'scopes' => ['teams.read', 'teams.write'], From 43db2ddf6e66e65a2bd969a9b3f451d826e7acf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 26 Mar 2026 16:24:28 +0100 Subject: [PATCH 12/35] Fix default null --- .../Platform/Modules/Project/Http/Project/Keys/Create.php | 2 +- .../Platform/Modules/Project/Http/Project/Keys/Update.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php index 0ab914af90..76b7a864f1 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php @@ -114,7 +114,7 @@ class Create extends Base 'resourceId' => $project->getId(), 'resourceType' => 'projects', 'name' => $name, - 'scopes' => $scopes, + 'scopes' => $scopes ?? [], 'expire' => $expire, 'sdks' => [], 'accessedAt' => null, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php index d3ac67fc76..702f9a7983 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php @@ -111,7 +111,7 @@ class Update extends Base $updates = new Document([ 'name' => $name, - 'scopes' => $scopes, + 'scopes' => $scopes ?? [], 'expire' => $expire, ]); From fe994703744c7a975b719505c4bb4f1584c46605 Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Mon, 30 Mar 2026 16:09:42 +0530 Subject: [PATCH 13/35] revert test env change --- .env | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.env b/.env index 1b1a55d6f4..9abfa756e1 100644 --- a/.env +++ b/.env @@ -9,7 +9,7 @@ _APP_CONSOLE_WHITELIST_EMAILS= _APP_CONSOLE_SESSION_ALERTS=enabled _APP_CONSOLE_WHITELIST_IPS= _APP_CONSOLE_COUNTRIES_DENYLIST=AQ -_APP_CONSOLE_HOSTNAMES=localhost,appwrite.io,*.appwrite.io,posted-costumes-alphabetical-census.trycloudflare.com +_APP_CONSOLE_HOSTNAMES=localhost,appwrite.io,*.appwrite.io _APP_CONSOLE_SCHEMA=appwriteio _APP_MIGRATION_HOST=appwrite _APP_SYSTEM_EMAIL_NAME=Appwrite @@ -25,8 +25,8 @@ _APP_OPTIONS_FORCE_HTTPS=disabled _APP_OPTIONS_ROUTER_FORCE_HTTPS=disabled _APP_OPENSSL_KEY_V1=your-secret-key _APP_DNS=172.16.238.100 # CoreDNS -_APP_DOMAIN=posted-costumes-alphabetical-census.trycloudflare.com -_APP_CONSOLE_DOMAIN=posted-costumes-alphabetical-census.trycloudflare.com +_APP_DOMAIN=appwrite.test +_APP_CONSOLE_DOMAIN=localhost _APP_CONSOLE_TRUSTED_PROJECTS=trusted-project,another-trusted-project _APP_DOMAIN_FUNCTIONS=functions.localhost _APP_DOMAIN_SITES=sites.localhost,rebranded.localhost @@ -143,6 +143,6 @@ _APP_WEBHOOK_MAX_FAILED_ATTEMPTS=10 _APP_PROJECT_REGIONS=default _APP_FUNCTIONS_CREATION_ABUSE_LIMIT=5000 _APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main -_APP_TRUSTED_HEADERS=x-forwarded-for,x-forwarded-proto,x-forwarded-host,x-forwarded-port +_APP_TRUSTED_HEADERS=x-forwarded-for _APP_POOL_ADAPTER=stack _APP_WORKER_SCREENSHOTS_ROUTER=http://appwrite From 9da4f19d4f742dcd01de90215cadae0d6cf918e1 Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Wed, 1 Apr 2026 12:11:40 +0530 Subject: [PATCH 14/35] fix: pkce flow --- app/controllers/mock.php | 41 ------------ src/Appwrite/Auth/OAuth2/X.php | 118 ++++++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 42 deletions(-) diff --git a/app/controllers/mock.php b/app/controllers/mock.php index 0e0fe8b821..712d4b7742 100644 --- a/app/controllers/mock.php +++ b/app/controllers/mock.php @@ -11,12 +11,10 @@ use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; -use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; use Utopia\Http\Http; use Utopia\Locale\Locale; use Utopia\System\System; -use Utopia\Validator\Boolean; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; use Utopia\VCS\Adapter\Git\GitHub; @@ -221,45 +219,6 @@ Http::post('/v1/mock/api-key-unprefixed') ->dynamic($key, Response::MODEL_KEY); }); -Http::post('/v1/mock/tests/projects/:projectId/oauth2/x') - ->desc('Enable X OAuth2 provider for a project') - ->groups(['mock', 'api', 'projects']) - ->label('scope', 'public') - ->label('docs', false) - ->param('projectId', '', new UID(), 'Project ID.') - ->param('appId', '', new Text(256), 'Provider app ID.') - ->param('secret', '', new Text(512), 'Provider secret.') - ->param('enabled', true, new Boolean(), 'Provider enabled status.', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('authorization') - ->action(function (string $projectId, string $appId, string $secret, bool $enabled, Response $response, Database $dbForPlatform, Authorization $authorization) { - $isDevelopment = System::getEnv('_APP_ENV', 'development') === 'development'; - - if (!$isDevelopment) { - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); - } - - $project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId)); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $providers = $project->getAttribute('oAuthProviders', []); - $providers['xAppid'] = $appId; - $providers['xSecret'] = $secret; - $providers['xEnabled'] = $enabled; - - $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ - 'oAuthProviders' => $providers, - ]))); - - $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - Http::get('/v1/mock/github/callback') ->desc('Create installation document using GitHub installation id') ->groups(['mock', 'api', 'vcs']) diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php index 331e011270..8a1ab49ef2 100644 --- a/src/Appwrite/Auth/OAuth2/X.php +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -3,6 +3,8 @@ namespace Appwrite\Auth\OAuth2; use Appwrite\Auth\OAuth2; +use Appwrite\OpenSSL\OpenSSL; +use Utopia\System\System; // Reference Material // https://docs.x.com/fundamentals/authentication/oauth-2-0/authorization-code @@ -10,6 +12,8 @@ use Appwrite\Auth\OAuth2; class X extends OAuth2 { + private const PKCE_STATE_KEY = '_pkce'; + /** * @var array */ @@ -30,6 +34,11 @@ class X extends OAuth2 'offline.access', ]; + /** + * @var string + */ + private string $pkceVerifier = ''; + /** * @return string */ @@ -40,12 +49,17 @@ class X extends OAuth2 public function getLoginURL(): string { + $state = $this->state; + $state[self::PKCE_STATE_KEY] = $this->encryptPKCEVerifier($this->getPKCEVerifier()); + return 'https://x.com/i/oauth2/authorize?' . \http_build_query([ 'response_type' => 'code', 'client_id' => $this->appID, 'redirect_uri' => $this->callback, 'scope' => \implode(' ', $this->getScopes()), - 'state' => \json_encode($this->state), + 'state' => $this->base64UrlEncode(\json_encode($state, JSON_THROW_ON_ERROR)), + 'code_challenge' => $this->getPKCEChallenge(), + 'code_challenge_method' => 'S256', ]); } @@ -71,6 +85,7 @@ class X extends OAuth2 'client_id' => $this->appID, 'grant_type' => 'authorization_code', 'redirect_uri' => $this->callback, + 'code_verifier' => $this->getPKCEVerifier(), ]) ), true); } @@ -177,4 +192,105 @@ class X extends OAuth2 return $this->user; } + public function parseState(string $state) + { + $decoded = $this->base64UrlDecode($state); + if ($decoded === false) { + return null; + } + + $state = \json_decode($decoded, true); + + if (!\is_array($state)) { + return $state; + } + + $pkce = $state[self::PKCE_STATE_KEY] ?? null; + + if (\is_array($pkce)) { + $this->pkceVerifier = $this->decryptPKCEVerifier($pkce); + } + + unset($state[self::PKCE_STATE_KEY]); + + return $state; + } + + private function getPKCEVerifier(): string + { + if ($this->pkceVerifier === '') { + $this->pkceVerifier = $this->base64UrlEncode(\random_bytes(64)); + } + + return $this->pkceVerifier; + } + + private function getPKCEChallenge(): string + { + return $this->base64UrlEncode(\hash('sha256', $this->getPKCEVerifier(), true)); + } + + private function encryptPKCEVerifier(string $verifier): array + { + $iv = OpenSSL::randomPseudoBytes(OpenSSL::cipherIVLength(OpenSSL::CIPHER_AES_128_GCM)); + $key = $this->getPKCEStateKey(); + $tag = null; + + $data = OpenSSL::encrypt($verifier, OpenSSL::CIPHER_AES_128_GCM, $key, OPENSSL_RAW_DATA, $iv, $tag); + + return [ + 'data' => $this->base64UrlEncode($data), + 'iv' => \bin2hex($iv), + 'tag' => \bin2hex($tag), + ]; + } + + private function decryptPKCEVerifier(array $payload): string + { + $data = $payload['data'] ?? ''; + $iv = $payload['iv'] ?? ''; + $tag = $payload['tag'] ?? ''; + + if ($data === '' || $iv === '' || $tag === '') { + return ''; + } + + $decodedData = $this->base64UrlDecode($data); + $decodedIv = \hex2bin($iv); + $decodedTag = \hex2bin($tag); + + if ($decodedData === false || $decodedIv === false || $decodedTag === false) { + return ''; + } + + return OpenSSL::decrypt( + $decodedData, + OpenSSL::CIPHER_AES_128_GCM, + $this->getPKCEStateKey(), + OPENSSL_RAW_DATA, + $decodedIv, + $decodedTag + ) ?: ''; + } + + private function getPKCEStateKey(): string + { + return System::getEnv('_APP_OPENSSL_KEY_V1'); + } + + private function base64UrlEncode(string $value): string + { + return \rtrim(\strtr(\base64_encode($value), '+/', '-_'), '='); + } + + private function base64UrlDecode(string $value): string|false + { + $padding = \strlen($value) % 4; + if ($padding > 0) { + $value .= \str_repeat('=', 4 - $padding); + } + + return \base64_decode(\strtr($value, '-_', '+/'), true); + } + } From c7a022ba43d58cc6ff583525e39078c21e15e29b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 8 Apr 2026 09:54:57 +0200 Subject: [PATCH 15/35] Simplify after discussions --- src/Appwrite/Auth/Key.php | 16 +++---------- .../Project/Http/Project/Keys/Create.php | 19 --------------- .../Modules/Project/Http/Project/Keys/Get.php | 9 -------- .../Project/Http/Project/Keys/Update.php | 23 ------------------- .../Project/Http/Project/Keys/XList.php | 11 --------- 5 files changed, 3 insertions(+), 75 deletions(-) diff --git a/src/Appwrite/Auth/Key.php b/src/Appwrite/Auth/Key.php index 888f55f926..8f645f6f08 100644 --- a/src/Appwrite/Auth/Key.php +++ b/src/Appwrite/Auth/Key.php @@ -22,7 +22,6 @@ class Key protected array $scopes, protected string $name, protected bool $expired = false, - protected ?string $expire = null, protected array $disabledMetrics = [], protected bool $hostnameOverride = false, protected bool $bannerDisabled = false, @@ -72,11 +71,6 @@ class Key return $this->expired; } - public function getExpire(): ?string - { - return $this->expire; - } - public function getDisabledMetrics(): array { return $this->disabledMetrics; @@ -182,7 +176,6 @@ class Key $scopes, $name, $expired, - DateTime::addSeconds(new \DateTime(), 86400), // Max possible JWT expiry $disabledMetrics, $hostnameOverride, $bannerDisabled, @@ -217,8 +210,7 @@ class Key $role, $scopes, $name, - $expired, - $expire, + $expired ); case API_KEY_ACCOUNT: $key = $user->find( @@ -252,8 +244,7 @@ class Key $role, $scopes, $name, - $expired, - $expire, + $expired ); return $key; @@ -289,8 +280,7 @@ class Key $role, $scopes, $name, - $expired, - $expire, + $expired ); return $key; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php index 76b7a864f1..1532e6accb 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Keys; -use Appwrite\Auth\Key; use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; @@ -70,7 +69,6 @@ class Create extends Base ->inject('dbForPlatform') ->inject('project') ->inject('authorization') - ->inject('apiKey') ->callback($this->action(...)); } @@ -87,26 +85,9 @@ class Create extends Base Database $dbForPlatform, Document $project, Authorization $authorization, - ?Key $apiKey, ) { $keyId = ($keyId == 'unique()') ? ID::unique() : $keyId; - $isProjectApiKey = $apiKey !== null && !empty($apiKey->getProjectId()); - - if ($isProjectApiKey) { - if (!empty(\array_diff($scopes ?? [], $apiKey->getScopes()))) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'New API key cannot exceed scopes of currently authenticated API key.'); - } - - if (\is_null($expire) && !\is_null($apiKey->getExpire())) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'New API key must have expiry set, because currently authenticated API key has an expiry.'); - } - - if (!\is_null($expire) && !\is_null($apiKey->getExpire()) && $expire > $apiKey->getExpire()) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'New API key expiry must be sooner than currently authenticated API key expiry.'); - } - } - $key = new Document([ '$id' => $keyId, '$permissions' => [], diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php index 1939a513a6..8fd534c9ec 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Keys; -use Appwrite\Auth\Key; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; @@ -54,7 +53,6 @@ class Get extends Base ->inject('dbForPlatform') ->inject('project') ->inject('authorization') - ->inject('apiKey') ->callback($this->action(...)); } @@ -64,7 +62,6 @@ class Get extends Base Database $dbForPlatform, Document $project, Authorization $authorization, - ?Key $apiKey, ) { $key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId)); @@ -72,12 +69,6 @@ class Get extends Base throw new Exception(Exception::KEY_NOT_FOUND); } - $isProjectApiKey = $apiKey !== null && !empty($apiKey->getProjectId()); - - if ($isProjectApiKey) { - $key->setAttribute('secret', ''); - } - $response->dynamic($key, Response::MODEL_KEY); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php index 702f9a7983..1902d8bfad 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Keys; -use Appwrite\Auth\Key; use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; @@ -68,7 +67,6 @@ class Update extends Base ->inject('dbForPlatform') ->inject('project') ->inject('authorization') - ->inject('apiKey') ->callback($this->action(...)); } @@ -85,7 +83,6 @@ class Update extends Base Database $dbForPlatform, Document $project, Authorization $authorization, - ?Key $apiKey, ) { $key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId)); @@ -93,22 +90,6 @@ class Update extends Base throw new Exception(Exception::KEY_NOT_FOUND); } - $isProjectApiKey = $apiKey !== null && !empty($apiKey->getProjectId()); - - if ($isProjectApiKey) { - if (!empty(\array_diff($scopes ?? [], $apiKey->getScopes()))) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Updated API key cannot exceed scopes of currently authenticated API key.'); - } - - if (\is_null($expire) && !\is_null($apiKey->getExpire())) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Updated API key must have expiry set, because currently authenticated API key has an expiry.'); - } - - if (!\is_null($expire) && !\is_null($apiKey->getExpire()) && $expire > $apiKey->getExpire()) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Updated API key expiry must be sooner than currently authenticated API key expiry.'); - } - } - $updates = new Document([ 'name' => $name, 'scopes' => $scopes ?? [], @@ -125,10 +106,6 @@ class Update extends Base $queueForEvents->setParam('keyId', $key->getId()); - if ($isProjectApiKey) { - $key->setAttribute('secret', ''); - } - $response->dynamic($key, Response::MODEL_KEY); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php index e54c668c5f..9a9f515b6c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Keys; -use Appwrite\Auth\Key; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; @@ -60,7 +59,6 @@ class XList extends Base ->inject('response') ->inject('dbForPlatform') ->inject('authorization') - ->inject('apiKey') ->callback($this->action(...)); } @@ -74,7 +72,6 @@ class XList extends Base Response $response, Database $dbForPlatform, Authorization $authorization, - ?Key $apiKey, ) { try { $queries = Query::parseQueries($queries); @@ -122,14 +119,6 @@ class XList extends Base 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."); } - $isProjectApiKey = $apiKey !== null && !empty($apiKey->getProjectId()); - - if ($isProjectApiKey) { - foreach ($keys as $key) { - $key->setAttribute('secret', ''); - } - } - $response->dynamic(new Document([ 'keys' => $keys, 'total' => $total, From eef2a7abdff76abface24a7fc815d0302219f43f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 8 Apr 2026 10:01:52 +0200 Subject: [PATCH 16/35] Fix scopes --- app/config/scopes/organization.php | 14 -------------- app/config/scopes/project.php | 16 ++++++++++++++++ .../Modules/Project/Http/Project/Keys/Create.php | 2 +- .../Modules/Project/Http/Project/Keys/Delete.php | 2 +- .../Modules/Project/Http/Project/Keys/Get.php | 2 +- .../Modules/Project/Http/Project/Keys/Update.php | 2 +- .../Modules/Project/Http/Project/Keys/XList.php | 2 +- src/Appwrite/Platform/Workers/Migrations.php | 6 +++++- tests/e2e/Scopes/ProjectCustom.php | 6 +++++- 9 files changed, 31 insertions(+), 21 deletions(-) diff --git a/app/config/scopes/organization.php b/app/config/scopes/organization.php index 8d85662652..228a1437f2 100644 --- a/app/config/scopes/organization.php +++ b/app/config/scopes/organization.php @@ -3,13 +3,6 @@ // List of scopes for organization (teams) API keys return [ - "platforms.read" => [ - "description" => 'Access to read project\'s platforms', - ], - "platforms.write" => [ - "description" => - 'Access to create, update, and delete project\'s platforms', - ], "projects.read" => [ "description" => 'Access to read organization\'s projects', ], @@ -17,13 +10,6 @@ return [ "description" => "Access to create, update, and delete projects in organization", ], - "keys.read" => [ - "description" => 'Access to read project\'s API keys', - ], - "keys.write" => [ - "description" => - "Access to create, update, and delete project\'s API keys", - ], "devKeys.read" => [ "description" => 'Access to read project\'s development keys', ], diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index f5d8461aff..6c7f75c08e 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -188,4 +188,20 @@ return [ // List of publicly visible scopes "description" => "Access to update project\'s information", ], + "keys.read" => [ + "description" => + "Access to read project\'s keys", + ], + "keys.write" => [ + "description" => + "Access to create, update, and delete project\'s keys", + ], + "platforms.read" => [ + "description" => + "Access to read project\'s platforms", + ], + "platforms.write" => [ + "description" => + "Access to create, update, and delete project\'s platforms", + ], ]; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php index 1532e6accb..59d2c1db49 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php @@ -41,7 +41,7 @@ class Create extends Base ->httpAlias('/v1/projects/:projectId/keys') ->desc('Create project key') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'keys.write') ->label('event', 'keys.[keyId].create') ->label('audits.event', 'project.key.create') ->label('audits.resource', 'project.key/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php index 5970b2d7aa..c5da673e22 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php @@ -34,7 +34,7 @@ class Delete extends Base ->httpAlias('/v1/projects/:projectId/keys/:keyId') ->desc('Delete project key') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'keys.write') ->label('event', 'keys.[keyId].delete') ->label('audits.event', 'project.key.delete') ->label('audits.resource', 'project.key/{request.keyId}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php index 8fd534c9ec..e43c669e4f 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php @@ -32,7 +32,7 @@ class Get extends Base ->httpAlias('/v1/projects/:projectId/keys/:keyId') ->desc('Get project key') ->groups(['api', 'project']) - ->label('scope', 'project.read') + ->label('scope', 'keys.read') ->label('sdk', new Method( namespace: 'project', group: 'keys', diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php index 1902d8bfad..8759faacc1 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php @@ -39,7 +39,7 @@ class Update extends Base ->httpAlias('/v1/projects/:projectId/keys/:keyId') ->desc('Update project key') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'keys.write') ->label('event', 'keys.[keyId].update') ->label('audits.event', 'project.key.update') ->label('audits.resource', 'project.key/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php index 9a9f515b6c..d243e6f2c3 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php @@ -37,7 +37,7 @@ class XList extends Base ->httpAlias('/v1/projects/:projectId/keys') ->desc('List project keys') ->groups(['api', 'project']) - ->label('scope', 'project.read') + ->label('scope', 'keys.read') ->label('sdk', new Method( namespace: 'project', group: 'keys', diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 2534899f67..43f5c97ba6 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -379,7 +379,11 @@ class Migrations extends Action 'webhooks.read', 'webhooks.write', 'project.read', - 'project.write' + 'project.write', + 'keys.read', + 'keys.write', + 'platforms.read', + 'platforms.write', ] ]); diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index b7037267c5..10641019f0 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -164,7 +164,11 @@ trait ProjectCustom 'webhooks.read', 'webhooks.write', 'project.read', - 'project.write' + 'project.write', + 'keys.read', + 'keys.write', + 'platforms.read', + 'platforms.write', ], ]); From a8c2491fbbec5d608ed275a218ba1426ddfdfe03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 8 Apr 2026 10:17:48 +0200 Subject: [PATCH 17/35] Fix platform scopes --- app/controllers/api/projects.php | 5 ----- .../Project/Http/Project/Platforms/Android/Create.php | 2 +- .../Project/Http/Project/Platforms/Android/Update.php | 2 +- .../Modules/Project/Http/Project/Platforms/Apple/Create.php | 2 +- .../Modules/Project/Http/Project/Platforms/Apple/Update.php | 2 +- .../Modules/Project/Http/Project/Platforms/Delete.php | 2 +- .../Platform/Modules/Project/Http/Project/Platforms/Get.php | 2 +- .../Modules/Project/Http/Project/Platforms/Linux/Create.php | 2 +- .../Modules/Project/Http/Project/Platforms/Linux/Update.php | 2 +- .../Modules/Project/Http/Project/Platforms/Web/Create.php | 2 +- .../Modules/Project/Http/Project/Platforms/Web/Update.php | 2 +- .../Project/Http/Project/Platforms/Windows/Create.php | 2 +- .../Project/Http/Project/Platforms/Windows/Update.php | 2 +- .../Modules/Project/Http/Project/Platforms/XList.php | 2 +- 14 files changed, 13 insertions(+), 18 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 386e2b9f14..dac6ed456a 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -5,7 +5,6 @@ use Appwrite\Auth\Validator\MockNumber; use Appwrite\Event\Delete; use Appwrite\Event\Mail; use Appwrite\Extend\Exception; -use Appwrite\Network\Platform; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; @@ -18,10 +17,6 @@ use PHPMailer\PHPMailer\PHPMailer; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; -use Utopia\Database\Query; use Utopia\Database\Validator\UID; use Utopia\Emails\Validator\Email; use Utopia\Http\Http; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Create.php index e33e531017..accc6d5b35 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Create.php @@ -35,7 +35,7 @@ class Create extends Action ->setHttpPath('/v1/project/platforms/android') ->desc('Create project Android platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].create') ->label('audits.event', 'project.platform.create') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Update.php index cd12f2da74..3ff958e814 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Update.php @@ -33,7 +33,7 @@ class Update extends Action ->setHttpPath('/v1/project/platforms/android/:platformId') ->desc('Update project Android platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].update') ->label('audits.event', 'project.platform.update') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Create.php index 4054face8e..0843bf9a0c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Create.php @@ -35,7 +35,7 @@ class Create extends Action ->setHttpPath('/v1/project/platforms/apple') ->desc('Create project Apple platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].create') ->label('audits.event', 'project.platform.create') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Update.php index 95d67be26c..0295075f19 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Update.php @@ -33,7 +33,7 @@ class Update extends Action ->setHttpPath('/v1/project/platforms/apple/:platformId') ->desc('Update project Apple platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].update') ->label('audits.event', 'project.platform.update') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php index 907046d27e..4b58766751 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php @@ -33,7 +33,7 @@ class Delete extends Action ->httpAlias('/v1/projects/:projectId/platforms/:platformId') ->desc('Delete project platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].delete') ->label('audits.event', 'project.platform.delete') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php index c5f4b8fc81..de086b13a2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php @@ -32,7 +32,7 @@ class Get extends Action ->httpAlias('/v1/projects/:projectId/platforms/:platformId') ->desc('Get project platform') ->groups(['api', 'project']) - ->label('scope', 'project.read') + ->label('scope', 'platforms.read') ->label('sdk', new Method( namespace: 'project', group: 'platforms', diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Create.php index ae568740b8..472b41cace 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Create.php @@ -35,7 +35,7 @@ class Create extends Action ->setHttpPath('/v1/project/platforms/linux') ->desc('Create project Linux platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].create') ->label('audits.event', 'project.platform.create') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Update.php index 92674d2276..9c1f715c33 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Update.php @@ -33,7 +33,7 @@ class Update extends Action ->setHttpPath('/v1/project/platforms/linux/:platformId') ->desc('Update project Linux platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].update') ->label('audits.event', 'project.platform.update') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php index f16c0af3fa..6794901c47 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php @@ -43,7 +43,7 @@ class Create extends Action ->httpAlias('/v1/projects/:projectId/platforms') ->desc('Create project web platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].create') ->label('audits.event', 'project.platform.create') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php index 3677466452..1e1f1b5ac1 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php @@ -35,7 +35,7 @@ class Update extends Action ->httpAlias('/v1/projects/:projectId/platforms/:platformId') ->desc('Update project web platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].update') ->label('audits.event', 'project.platform.update') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Create.php index a7e583cadb..58be45d03b 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Create.php @@ -35,7 +35,7 @@ class Create extends Action ->setHttpPath('/v1/project/platforms/windows') ->desc('Create project Windows platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].create') ->label('audits.event', 'project.platform.create') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Update.php index 43d6c65d44..5cfb6ee7ea 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Update.php @@ -33,7 +33,7 @@ class Update extends Action ->setHttpPath('/v1/project/platforms/windows/:platformId') ->desc('Update project Windows platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].update') ->label('audits.event', 'project.platform.update') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php index 14a67418ee..2953adb4c2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php @@ -36,7 +36,7 @@ class XList extends Action ->httpAlias('/v1/projects/:projectId/platforms') ->desc('List project platforms') ->groups(['api', 'project']) - ->label('scope', 'project.read') + ->label('scope', 'platforms.read') ->label('sdk', new Method( namespace: 'project', group: 'platforms', From a9fd82e406ce49c7b982a954587dd17cfffb4089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 8 Apr 2026 10:32:20 +0200 Subject: [PATCH 18/35] New tests --- composer.lock | 152 ++-- tests/e2e/Services/Project/KeysBase.php | 809 ++++++++++++++++++ .../Project/KeysConsoleClientTest.php | 14 + .../Services/Project/KeysCustomServerTest.php | 14 + 4 files changed, 905 insertions(+), 84 deletions(-) create mode 100644 tests/e2e/Services/Project/KeysBase.php create mode 100644 tests/e2e/Services/Project/KeysConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/KeysCustomServerTest.php diff --git a/composer.lock b/composer.lock index 813dfe3c1d..90e8a09ab2 100644 --- a/composer.lock +++ b/composer.lock @@ -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", @@ -4325,16 +4325,16 @@ }, { "name": "utopia-php/http", - "version": "0.34.16", + "version": "0.34.18", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "2b4021ba3f9d476264ce9fd6703d6c79de9add7f" + "reference": "c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/2b4021ba3f9d476264ce9fd6703d6c79de9add7f", - "reference": "2b4021ba3f9d476264ce9fd6703d6c79de9add7f", + "url": "https://api.github.com/repos/utopia-php/http/zipball/c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc", + "reference": "c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc", "shasum": "" }, "require": { @@ -4373,9 +4373,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.34.16" + "source": "https://github.com/utopia-php/http/tree/0.34.18" }, - "time": "2026-03-20T10:39:07+00:00" + "time": "2026-04-07T08:06:39+00:00" }, { "name": "utopia-php/image", @@ -4696,16 +4696,16 @@ }, { "name": "utopia-php/platform", - "version": "0.12.0", + "version": "0.12.1", "source": { "type": "git", "url": "https://github.com/utopia-php/platform.git", - "reference": "068ee46228f0c3972e6b569f2c86b6c80fe583d8" + "reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/platform/zipball/068ee46228f0c3972e6b569f2c86b6c80fe583d8", - "reference": "068ee46228f0c3972e6b569f2c86b6c80fe583d8", + "url": "https://api.github.com/repos/utopia-php/platform/zipball/2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc", + "reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc", "shasum": "" }, "require": { @@ -4741,9 +4741,9 @@ ], "support": { "issues": "https://github.com/utopia-php/platform/issues", - "source": "https://github.com/utopia-php/platform/tree/0.12.0" + "source": "https://github.com/utopia-php/platform/tree/0.12.1" }, - "time": "2026-03-31T14:44:23+00:00" + "time": "2026-04-08T04:11:31+00:00" }, { "name": "utopia-php/pools", @@ -5502,16 +5502,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.14.0", + "version": "1.17.6", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "7e7e257b10a8c1384a237e7d8d73452e2108901e" + "reference": "8888a9fd11260d389874424268ecbe0d956eb550" }, "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/8888a9fd11260d389874424268ecbe0d956eb550", + "reference": "8888a9fd11260d389874424268ecbe0d956eb550", "shasum": "" }, "require": { @@ -5547,22 +5547,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.17.6" }, - "time": "2026-03-26T12:50:11+00:00" + "time": "2026-04-08T05:37:23+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": { @@ -5586,7 +5586,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", @@ -5630,7 +5630,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": [ { @@ -5642,7 +5642,7 @@ "type": "paypal" } ], - "time": "2026-03-09T14:33:17+00:00" + "time": "2026-03-29T15:46:14+00:00" }, { "name": "czproject/git-php", @@ -6258,11 +6258,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.44", + "version": "2.1.46", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4a88c083c668b2c364a425c9b3171b2d9ea5d218", - "reference": "4a88c083c668b2c364a425c9b3171b2d9ea5d218", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", + "reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", "shasum": "" }, "require": { @@ -6307,7 +6307,7 @@ "type": "github" } ], - "time": "2026-03-25T17:34:21+00:00" + "time": "2026-04-01T09:25:14+00:00" }, { "name": "phpunit/php-code-coverage", @@ -6657,16 +6657,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.14", + "version": "12.5.17", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0" + "reference": "85b62adab1a340982df64e66daa4a4435eb5723b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/85b62adab1a340982df64e66daa4a4435eb5723b", + "reference": "85b62adab1a340982df64e66daa4a4435eb5723b", "shasum": "" }, "require": { @@ -6688,7 +6688,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", @@ -6735,31 +6735,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.17" }, "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-04-08T03:04:19+00:00" }, { "name": "sebastian/cli-parser", @@ -6832,16 +6816,16 @@ }, { "name": "sebastian/comparator", - "version": "7.1.4", + "version": "7.1.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" + "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c284f55811f43d555e51e8e5c166ac40d3e33c63", + "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63", "shasum": "" }, "require": { @@ -6900,7 +6884,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.5" }, "funding": [ { @@ -6920,7 +6904,7 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:28:48+00:00" + "time": "2026-04-08T04:43:00+00:00" }, { "name": "sebastian/complexity", @@ -7744,16 +7728,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": { @@ -7810,7 +7794,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.7" + "source": "https://github.com/symfony/console/tree/v8.0.8" }, "funding": [ { @@ -7830,7 +7814,7 @@ "type": "tidelift" } ], - "time": "2026-03-06T14:06:22+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/polyfill-ctype", @@ -8164,16 +8148,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": { @@ -8205,7 +8189,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": [ { @@ -8225,20 +8209,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": { @@ -8295,7 +8279,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.6" + "source": "https://github.com/symfony/string/tree/v8.0.8" }, "funding": [ { @@ -8315,7 +8299,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "textalk/websocket", diff --git a/tests/e2e/Services/Project/KeysBase.php b/tests/e2e/Services/Project/KeysBase.php new file mode 100644 index 0000000000..04310a913f --- /dev/null +++ b/tests/e2e/Services/Project/KeysBase.php @@ -0,0 +1,809 @@ +createKey( + ID::unique(), + 'My API Key', + ['users.read', 'users.write'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertNotEmpty($key['body']['$id']); + $this->assertSame('My API Key', $key['body']['name']); + $this->assertSame(['users.read', 'users.write'], $key['body']['scopes']); + $this->assertNotEmpty($key['body']['secret']); + $this->assertSame('', $key['body']['expire']); + $this->assertSame('', $key['body']['accessedAt']); + $this->assertSame([], $key['body']['sdks']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($key['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($key['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getKey($key['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($key['body']['$id'], $get['body']['$id']); + $this->assertSame('My API Key', $get['body']['name']); + $this->assertSame(['users.read', 'users.write'], $get['body']['scopes']); + + // Verify via LIST + $list = $this->listKeys(null, true); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['keys'])); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testCreateKeyWithExpire(): void + { + $expire = '2030-01-01T00:00:00.000+00:00'; + + $key = $this->createKey( + ID::unique(), + 'Expiring Key', + ['users.read'], + $expire, + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame($expire, $key['body']['expire']); + + // Verify via GET + $get = $this->getKey($key['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($expire, $get['body']['expire']); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testCreateKeyWithNullScopes(): void + { + $key = $this->createKey( + ID::unique(), + 'Null Scopes Key', + null, + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame([], $key['body']['scopes']); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testCreateKeyWithoutAuthentication(): void + { + $response = $this->createKey( + ID::unique(), + 'No Auth Key', + ['users.read'], + null, + false + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateKeyInvalidId(): void + { + $key = $this->createKey( + '!invalid-id!', + 'Invalid ID Key', + ['users.read'], + ); + + $this->assertSame(400, $key['headers']['status-code']); + } + + public function testCreateKeyMissingName(): void + { + $response = $this->createKey( + ID::unique(), + null, + ['users.read'], + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateKeyInvalidScope(): void + { + $response = $this->createKey( + ID::unique(), + 'Invalid Scope Key', + ['invalid.scope'], + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateKeyDuplicateId(): void + { + $keyId = ID::unique(); + + $key = $this->createKey( + $keyId, + 'Key Dup 1', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + + // Attempt to create with same ID + $duplicate = $this->createKey( + $keyId, + 'Key Dup 2', + ['users.write'], + ); + + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('key_already_exists', $duplicate['body']['type']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testCreateKeyCustomId(): void + { + $customId = 'my-custom-key-id'; + + $key = $this->createKey( + $customId, + 'Custom ID Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame($customId, $key['body']['$id']); + + // Verify via GET + $get = $this->getKey($customId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($customId, $get['body']['$id']); + + // Cleanup + $this->deleteKey($customId); + } + + // ========================================================================= + // Update key tests + // ========================================================================= + + public function testUpdateKey(): void + { + $key = $this->createKey( + ID::unique(), + 'Original Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Update name, scopes, and expire + $expire = '2031-06-15T12:00:00.000+00:00'; + $updated = $this->updateKey($keyId, 'Updated Key', ['users.write', 'databases.read'], $expire); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($keyId, $updated['body']['$id']); + $this->assertSame('Updated Key', $updated['body']['name']); + $this->assertSame(['users.write', 'databases.read'], $updated['body']['scopes']); + $this->assertSame($expire, $updated['body']['expire']); + + // Verify update persisted via GET + $get = $this->getKey($keyId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('Updated Key', $get['body']['name']); + $this->assertSame(['users.write', 'databases.read'], $get['body']['scopes']); + $this->assertSame($expire, $get['body']['expire']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyName(): void + { + $key = $this->createKey( + ID::unique(), + 'Name Before', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $updated = $this->updateKey($keyId, 'Name After', ['users.read']); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame('Name After', $updated['body']['name']); + $this->assertSame(['users.read'], $updated['body']['scopes']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyScopes(): void + { + $key = $this->createKey( + ID::unique(), + 'Scopes Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $updated = $this->updateKey($keyId, 'Scopes Key', ['databases.read', 'databases.write']); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame(['databases.read', 'databases.write'], $updated['body']['scopes']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeySetExpire(): void + { + $key = $this->createKey( + ID::unique(), + 'No Expire Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame('', $key['body']['expire']); + $keyId = $key['body']['$id']; + + $expire = '2032-12-31T23:59:59.000+00:00'; + $updated = $this->updateKey($keyId, 'No Expire Key', ['users.read'], $expire); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($expire, $updated['body']['expire']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyRemoveExpire(): void + { + $key = $this->createKey( + ID::unique(), + 'Expire Key', + ['users.read'], + '2030-01-01T00:00:00.000+00:00', + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Remove expire by setting to null + $updated = $this->updateKey($keyId, 'Expire Key', ['users.read'], null); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame('', $updated['body']['expire']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyWithoutAuthentication(): void + { + $key = $this->createKey( + ID::unique(), + 'Auth Update Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Attempt update without authentication + $response = $this->updateKey($keyId, 'Updated Name', ['users.read'], null, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyNotFound(): void + { + $updated = $this->updateKey('non-existent-id', 'New Name', ['users.read']); + + $this->assertSame(404, $updated['headers']['status-code']); + $this->assertSame('key_not_found', $updated['body']['type']); + } + + public function testUpdateKeyInvalidScope(): void + { + $key = $this->createKey( + ID::unique(), + 'Invalid Scope Update', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $updated = $this->updateKey($keyId, 'Invalid Scope Update', ['invalid.scope']); + + $this->assertSame(400, $updated['headers']['status-code']); + + // Cleanup + $this->deleteKey($keyId); + } + + // ========================================================================= + // Get key tests + // ========================================================================= + + public function testGetKey(): void + { + $key = $this->createKey( + ID::unique(), + 'Get Test Key', + ['users.read', 'databases.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $get = $this->getKey($keyId); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($keyId, $get['body']['$id']); + $this->assertSame('Get Test Key', $get['body']['name']); + $this->assertSame(['users.read', 'databases.read'], $get['body']['scopes']); + $this->assertNotEmpty($get['body']['secret']); + $this->assertSame('', $get['body']['expire']); + $this->assertSame('', $get['body']['accessedAt']); + $this->assertSame([], $get['body']['sdks']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($get['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($get['body']['$updatedAt'])); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testGetKeyNotFound(): void + { + $get = $this->getKey('non-existent-id'); + + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('key_not_found', $get['body']['type']); + } + + public function testGetKeyWithoutAuthentication(): void + { + $key = $this->createKey( + ID::unique(), + 'Auth Get Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Attempt GET without authentication + $response = $this->getKey($keyId, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deleteKey($keyId); + } + + // ========================================================================= + // List keys tests + // ========================================================================= + + public function testListKeys(): void + { + // Create multiple keys + $key1 = $this->createKey( + ID::unique(), + 'List Key Alpha', + ['users.read'], + ); + $this->assertSame(201, $key1['headers']['status-code']); + + $key2 = $this->createKey( + ID::unique(), + 'List Key Beta', + ['databases.read'], + ); + $this->assertSame(201, $key2['headers']['status-code']); + + $key3 = $this->createKey( + ID::unique(), + 'List Key Gamma', + ['users.write'], + ); + $this->assertSame(201, $key3['headers']['status-code']); + + // List all + $list = $this->listKeys(null, true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(3, $list['body']['total']); + $this->assertGreaterThanOrEqual(3, \count($list['body']['keys'])); + $this->assertIsArray($list['body']['keys']); + + // Verify structure of returned keys + foreach ($list['body']['keys'] as $key) { + $this->assertArrayHasKey('$id', $key); + $this->assertArrayHasKey('$createdAt', $key); + $this->assertArrayHasKey('$updatedAt', $key); + $this->assertArrayHasKey('name', $key); + $this->assertArrayHasKey('scopes', $key); + $this->assertArrayHasKey('secret', $key); + $this->assertArrayHasKey('expire', $key); + $this->assertArrayHasKey('accessedAt', $key); + $this->assertArrayHasKey('sdks', $key); + } + + // Cleanup + $this->deleteKey($key1['body']['$id']); + $this->deleteKey($key2['body']['$id']); + $this->deleteKey($key3['body']['$id']); + } + + public function testListKeysWithLimit(): void + { + $key1 = $this->createKey( + ID::unique(), + 'Limit Key 1', + ['users.read'], + ); + $this->assertSame(201, $key1['headers']['status-code']); + + $key2 = $this->createKey( + ID::unique(), + 'Limit Key 2', + ['users.write'], + ); + $this->assertSame(201, $key2['headers']['status-code']); + + // List with limit 1 + $list = $this->listKeys([ + Query::limit(1)->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertCount(1, $list['body']['keys']); + $this->assertGreaterThanOrEqual(2, $list['body']['total']); + + // Cleanup + $this->deleteKey($key1['body']['$id']); + $this->deleteKey($key2['body']['$id']); + } + + public function testListKeysWithoutTotal(): void + { + $key = $this->createKey( + ID::unique(), + 'No Total Key', + ['users.read'], + ); + $this->assertSame(201, $key['headers']['status-code']); + + // List with total=false + $list = $this->listKeys(null, false); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertSame(0, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['keys'])); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testListKeysCursorPagination(): void + { + $key1 = $this->createKey( + ID::unique(), + 'Cursor Key 1', + ['users.read'], + ); + $this->assertSame(201, $key1['headers']['status-code']); + + $key2 = $this->createKey( + ID::unique(), + 'Cursor Key 2', + ['users.write'], + ); + $this->assertSame(201, $key2['headers']['status-code']); + + // Get first page with limit 1 + $page1 = $this->listKeys([ + Query::limit(1)->toString(), + ], true); + + $this->assertSame(200, $page1['headers']['status-code']); + $this->assertCount(1, $page1['body']['keys']); + $cursorId = $page1['body']['keys'][0]['$id']; + + // Get next page using cursor + $page2 = $this->listKeys([ + Query::limit(1)->toString(), + Query::cursorAfter(new Document(['$id' => $cursorId]))->toString(), + ], true); + + $this->assertSame(200, $page2['headers']['status-code']); + $this->assertCount(1, $page2['body']['keys']); + $this->assertNotEquals($cursorId, $page2['body']['keys'][0]['$id']); + + // Cleanup + $this->deleteKey($key1['body']['$id']); + $this->deleteKey($key2['body']['$id']); + } + + public function testListKeysWithoutAuthentication(): void + { + $response = $this->listKeys(null, null, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testListKeysInvalidCursor(): void + { + $list = $this->listKeys([ + Query::cursorAfter(new Document(['$id' => 'non-existent-id']))->toString(), + ], true); + + $this->assertSame(400, $list['headers']['status-code']); + } + + // ========================================================================= + // Delete key tests + // ========================================================================= + + public function testDeleteKey(): void + { + $key = $this->createKey( + ID::unique(), + 'Delete Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Verify it exists + $get = $this->getKey($keyId); + $this->assertSame(200, $get['headers']['status-code']); + + // Delete + $delete = $this->deleteKey($keyId); + $this->assertSame(204, $delete['headers']['status-code']); + $this->assertEmpty($delete['body']); + + // Verify it no longer exists + $get = $this->getKey($keyId); + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('key_not_found', $get['body']['type']); + } + + public function testDeleteKeyNotFound(): void + { + $delete = $this->deleteKey('non-existent-id'); + + $this->assertSame(404, $delete['headers']['status-code']); + $this->assertSame('key_not_found', $delete['body']['type']); + } + + public function testDeleteKeyWithoutAuthentication(): void + { + $key = $this->createKey( + ID::unique(), + 'Delete Auth Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Attempt DELETE without authentication + $response = $this->deleteKey($keyId, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Verify it still exists + $get = $this->getKey($keyId); + $this->assertSame(200, $get['headers']['status-code']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testDeleteKeyRemovedFromList(): void + { + $key = $this->createKey( + ID::unique(), + 'Delete List Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Get list count before delete + $listBefore = $this->listKeys(null, true); + $this->assertSame(200, $listBefore['headers']['status-code']); + $countBefore = $listBefore['body']['total']; + + // Delete + $delete = $this->deleteKey($keyId); + $this->assertSame(204, $delete['headers']['status-code']); + + // Get list count after delete + $listAfter = $this->listKeys(null, true); + $this->assertSame(200, $listAfter['headers']['status-code']); + $this->assertSame($countBefore - 1, $listAfter['body']['total']); + + // Verify the deleted key is not in the list + $ids = \array_column($listAfter['body']['keys'], '$id'); + $this->assertNotContains($keyId, $ids); + } + + public function testDeleteKeyDoubleDelete(): void + { + $key = $this->createKey( + ID::unique(), + 'Double Delete Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // First delete succeeds + $delete = $this->deleteKey($keyId); + $this->assertSame(204, $delete['headers']['status-code']); + + // Second delete returns 404 + $delete = $this->deleteKey($keyId); + $this->assertSame(404, $delete['headers']['status-code']); + $this->assertSame('key_not_found', $delete['body']['type']); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * @param array|null $scopes + */ + protected function createKey(string $keyId, ?string $name, ?array $scopes = null, ?string $expire = null, bool $authenticated = true, bool $sendScopes = true): mixed + { + $params = [ + 'keyId' => $keyId, + ]; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($sendScopes) { + $params['scopes'] = $scopes; + } + + if ($expire !== null) { + $params['expire'] = $expire; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_POST, '/project/keys', $headers, $params); + } + + /** + * @param array|null $scopes + */ + protected function updateKey(string $keyId, ?string $name = null, ?array $scopes = null, ?string $expire = null, bool $authenticated = true): mixed + { + $params = []; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($scopes !== null) { + $params['scopes'] = $scopes; + } + + if ($expire !== null) { + $params['expire'] = $expire; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PUT, '/project/keys/' . $keyId, $headers, $params); + } + + protected function getKey(string $keyId, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_GET, '/project/keys/' . $keyId, $headers); + } + + /** + * @param array|null $queries + */ + protected function listKeys(?array $queries, ?bool $total, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_GET, '/project/keys', $headers, [ + 'queries' => $queries, + 'total' => $total, + ]); + } + + protected function deleteKey(string $keyId, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_DELETE, '/project/keys/' . $keyId, $headers); + } +} diff --git a/tests/e2e/Services/Project/KeysConsoleClientTest.php b/tests/e2e/Services/Project/KeysConsoleClientTest.php new file mode 100644 index 0000000000..ad6ed28b77 --- /dev/null +++ b/tests/e2e/Services/Project/KeysConsoleClientTest.php @@ -0,0 +1,14 @@ + Date: Wed, 8 Apr 2026 10:34:18 +0200 Subject: [PATCH 19/35] Fix failing tests --- tests/e2e/Services/Project/KeysBase.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/e2e/Services/Project/KeysBase.php b/tests/e2e/Services/Project/KeysBase.php index 04310a913f..fda5ef377f 100644 --- a/tests/e2e/Services/Project/KeysBase.php +++ b/tests/e2e/Services/Project/KeysBase.php @@ -698,20 +698,17 @@ trait KeysBase /** * @param array|null $scopes */ - protected function createKey(string $keyId, ?string $name, ?array $scopes = null, ?string $expire = null, bool $authenticated = true, bool $sendScopes = true): mixed + protected function createKey(string $keyId, ?string $name, ?array $scopes = null, ?string $expire = null, bool $authenticated = true): mixed { $params = [ 'keyId' => $keyId, + 'scopes' => $scopes, ]; if ($name !== null) { $params['name'] = $name; } - if ($sendScopes) { - $params['scopes'] = $scopes; - } - if ($expire !== null) { $params['expire'] = $expire; } From f880b6e8c343f47d6cb00a5c0096716c3db8d8e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 8 Apr 2026 10:52:20 +0200 Subject: [PATCH 20/35] Fix failing tests --- src/Appwrite/Utopia/Request/Filters/V21.php | 9 +++++++++ tests/e2e/Services/Projects/ProjectsBase.php | 1 + .../e2e/Services/Projects/ProjectsConsoleClientTest.php | 6 +++--- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Utopia/Request/Filters/V21.php b/src/Appwrite/Utopia/Request/Filters/V21.php index 60ab49255e..357f00cfdc 100644 --- a/src/Appwrite/Utopia/Request/Filters/V21.php +++ b/src/Appwrite/Utopia/Request/Filters/V21.php @@ -71,6 +71,9 @@ class V21 extends Filter case 'webhooks.create': $content = $this->fillWebhookid($content); break; + case 'project.createKey': + $content = $this->fillKeyId($content); + break; case 'project.createVariable': $content = $this->fillVariableId($content); break; @@ -122,6 +125,12 @@ class V21 extends Filter return $content; } + protected function fillKeyId(array $content): array + { + $content['keyId'] = $content['keyId'] ?? 'unique()'; + return $content; + } + protected function fillVariableId(array $content): array { $content['variableId'] = $content['variableId'] ?? 'unique()'; diff --git a/tests/e2e/Services/Projects/ProjectsBase.php b/tests/e2e/Services/Projects/ProjectsBase.php index 01e86a86ba..d42c5feda3 100644 --- a/tests/e2e/Services/Projects/ProjectsBase.php +++ b/tests/e2e/Services/Projects/ProjectsBase.php @@ -120,6 +120,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Key Test', 'scopes' => ['teams.read', 'teams.write'], diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index c6040690a7..e0f94b64cc 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3161,7 +3161,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.0', + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Key Custom', 'scopes' => ['teams.read', 'teams.write'], @@ -3247,7 +3247,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.0', + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Key Test 2', 'scopes' => ['users.read'], @@ -3623,7 +3623,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.0', + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Key For Deletion', 'scopes' => ['teams.read', 'teams.write'], From 91d8519940044031a5dd95768309f77089bb09e4 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 8 Apr 2026 21:31:34 +1200 Subject: [PATCH 21/35] refactor(databases): restructure list response cache and clarify ttl description --- .../Databases/Http/Databases/Action.php | 67 +++++++++++++++++++ .../Http/Databases/Collections/Action.php | 23 ++----- .../Databases/Collections/Documents/XList.php | 39 +++-------- .../Http/TablesDB/Tables/Rows/XList.php | 2 +- 4 files changed, 82 insertions(+), 49 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php index 60449aeab6..7893a70753 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php @@ -7,9 +7,13 @@ use Appwrite\Platform\Action as AppwriteAction; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Operator; +use Utopia\Database\Query; class Action extends AppwriteAction { + public const LIST_CACHE_FIELD_DOCUMENTS = 'documents'; + public const LIST_CACHE_FIELD_TOTAL = 'total'; + private string $context = DATABASE_TYPE_LEGACY; public function getDatabaseType(): string @@ -101,4 +105,67 @@ class Action extends AppwriteAction return $data; } + + /** + * Stable Redis key for a collection's cached list responses. + * + * All variations (schema × roles × queries) for a single collection live as + * fields inside this one Redis hash, so purging every cached entry for a + * collection is a single O(1) DEL regardless of how many variations have + * been cached. + */ + protected function getListCacheKey(Database $dbForProject, string $collectionId): string + { + return \sprintf( + '%s-cache:%s:%s:%s:collection:%s', + $dbForProject->getCacheName(), + $dbForProject->getAdapter()->getHostname(), + $dbForProject->getNamespace(), + $dbForProject->getTenant(), + $collectionId, + ); + } + + /** + * Hash field for a single variation of a cached list response. + * + * Scoped by the collection schema (attributes + indexes), the caller's + * authorization roles, the exact query set, and the field type — so users + * with different permissions never share entries. + * + * @param Document $collection Collection document (for schema hash) + * @param array $roles Caller authorization roles + * @param array $queries Queries for this list call + * @param string $type LIST_CACHE_FIELD_DOCUMENTS or LIST_CACHE_FIELD_TOTAL + */ + protected function getListCacheField(Document $collection, array $roles, array $queries, string $type): string + { + $schemaHash = \md5( + \json_encode($collection->getAttribute('attributes', [])) + . \json_encode($collection->getAttribute('indexes', [])) + ); + + $serialized = \array_map( + static fn ($query) => $query instanceof Query ? $query->toArray() : $query, + $queries, + ); + + return \sprintf( + '%s:%s:%s:%s', + $schemaHash, + \md5(\json_encode($roles)), + \md5(\json_encode($serialized)), + $type, + ); + } + + /** + * Purge every cached list response for a collection. + * + * One DEL on the collection's Redis hash, clearing all variations at once. + */ + protected function purgeListCache(Database $dbForProject, string $collectionId): bool + { + return $dbForProject->getCache()->purge($this->getListCacheKey($dbForProject, $collectionId)); + } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php index 2f541936a8..4afab449c0 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php @@ -3,34 +3,29 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections; use Appwrite\Extend\Exception; +use Appwrite\Platform\Modules\Databases\Http\Databases\Action as DatabasesAction; use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Platform\Action as UtopiaAction; -use Utopia\Platform\Scope\HTTP; -abstract class Action extends UtopiaAction +abstract class Action extends DatabasesAction { /** * The current API context (either 'table' or 'collection'). */ private ?string $context = COLLECTIONS; - private ?string $databaseType = LEGACY; - /** * Get the response model used in the SDK and HTTP responses. */ abstract protected function getResponseModel(): string; - public function setHttpPath(string $path): UtopiaAction + public function setHttpPath(string $path): self { if (\str_contains($path, '/tablesdb')) { $this->context = TABLES; - $this->databaseType = TABLESDB; - } elseif (\str_contains($path, '/vectorsdb')) { - $this->databaseType = VECTORSDB; } - return parent::setHttpPath($path); + parent::setHttpPath($path); + return $this; } /** @@ -41,14 +36,6 @@ abstract class Action extends UtopiaAction return $this->context; } - /** - * Get the current API database type. - */ - protected function getDatabaseType(): string - { - return $this->databaseType; - } - /** * Get the key used in event parameters (e.g., 'collectionId' or 'tableId'). */ diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index b3046fe22d..716638ab14 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -72,7 +72,7 @@ class XList extends Action ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject']) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true) + ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for caching list responses that include a select query. Responses are stored in an in-memory key-value cache, keyed per project, collection, schema version (attributes and indexes), caller authorization roles, and the exact query — so users with different permissions never share cached entries. Schema changes invalidate cached entries automatically; document writes do not, so choose a TTL you are comfortable serving as stale data. Set to 0 to disable caching. Must be between 0 and 86400 (24 hours).', true) ->inject('response') ->inject('dbForProject') ->inject('user') @@ -136,32 +136,12 @@ class XList extends Action } elseif (! empty($selectQueries)) { if ((int)$ttl > 0) { - $serializedQueries = []; - foreach ($queries as $query) { - $serializedQueries[] = $query instanceof Query ? $query->toArray() : $query; - } - - $hostname = $dbForProject->getAdapter()->getHostname(); + $cacheKey = $this->getListCacheKey($dbForProject, $collectionId); $roles = $dbForProject->getAuthorization()->getRoles(); - $schemaHash = \md5(\json_encode($collection->getAttribute('attributes', [])) . \json_encode($collection->getAttribute('indexes', []))); - $cacheKeyBase = \sprintf( - '%s-cache-%s:%s:%s:collection:%s:%s:user:%s:%s', - $dbForProject->getCacheName(), - $hostname, - $dbForProject->getNamespace(), - $dbForProject->getTenant(), - $collectionId, - $schemaHash, - \md5(\json_encode($roles)), - \md5(\json_encode($serializedQueries)) - ); + $documentsField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_DOCUMENTS); - $documentsCacheKey = $cacheKeyBase . ':documents'; - $totalCacheKey = $cacheKeyBase . ':total'; - - $documentsCacheHit = $totalDocumentsCacheHit = false; - - $cachedDocuments = $dbForProject->getCache()->load($documentsCacheKey, $ttl); + $documentsCacheHit = false; + $cachedDocuments = $dbForProject->getCache()->load($cacheKey, $ttl, $documentsField); if ($cachedDocuments !== null && $cachedDocuments !== false && @@ -177,24 +157,23 @@ class XList extends Action $documentsArray = \array_map(function ($doc) { return $doc->getArrayCopy(); }, $documents); - $dbForProject->getCache()->save($documentsCacheKey, $documentsArray); + $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField); } if ($includeTotal) { - $cachedTotal = $dbForProject->getCache()->load($totalCacheKey, $ttl); + $totalField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_TOTAL); + $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField); if ($cachedTotal !== null && $cachedTotal !== false) { $total = $cachedTotal; - $totalDocumentsCacheHit = true; } else { $total = $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT); - $dbForProject->getCache()->save($totalCacheKey, $total); + $dbForProject->getCache()->save($cacheKey, $total, $totalField); } } else { $total = 0; } $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); - } else { // has selects, allow relationship on documents $documents = $dbForDatabases->find($collectionTableId, $queries); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php index ca83b10aae..617081439d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php @@ -57,7 +57,7 @@ class XList extends DocumentXList ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject']) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true) + ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for caching list responses that include a select query. Responses are stored in an in-memory key-value cache, keyed per project, table, schema version (columns and indexes), caller authorization roles, and the exact query — so users with different permissions never share cached entries. Schema changes invalidate cached entries automatically; row writes do not, so choose a TTL you are comfortable serving as stale data. Set to 0 to disable caching. Must be between 0 and 86400 (24 hours).', true) ->inject('response') ->inject('dbForProject') ->inject('user') From c6f8599c75a2294e4f363c091481908ff4b450cc Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 8 Apr 2026 21:31:38 +1200 Subject: [PATCH 22/35] feat(databases): add purge parameter to updateCollection and updateTable --- .../Databases/Http/Databases/Collections/Update.php | 7 ++++++- .../Modules/Databases/Http/TablesDB/Tables/Update.php | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php index 1142f38aa9..800df6d044 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php @@ -68,6 +68,7 @@ class Update extends Action ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->param('purge', false, new Boolean(true), 'When true, purge all cached list responses for this collection as part of the update. Use this to force readers to see fresh data immediately instead of waiting for the cache TTL to expire.', true) ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') @@ -76,7 +77,7 @@ class Update extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, ?string $name, ?array $permissions, bool $documentSecurity, bool $enabled, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, ?string $name, ?array $permissions, bool $documentSecurity, bool $enabled, bool $purge, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Authorization $authorization): void { $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { @@ -117,6 +118,10 @@ class Update extends Action ->setParam('databaseId', $databaseId) ->setParam($this->getEventsParamKey(), $collection->getId()); + if ($purge) { + $this->purgeListCache($dbForProject, $collectionId); + } + $this->addRowBytesInfo($collection, $dbForProject); $response->dynamic($collection, $this->getResponseModel()); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php index 88b16d57f0..d10380a0e8 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php @@ -60,6 +60,7 @@ class Update extends CollectionUpdate ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('rowSecurity', false, new Boolean(true), 'Enables configuring permissions for individual rows. A user needs one of row or table-level permissions to access a row. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('enabled', true, new Boolean(), 'Is table enabled? When set to \'disabled\', users cannot access the table but Server SDKs with and API key can still read and write to the table. No data is lost when this is toggled.', true) + ->param('purge', false, new Boolean(true), 'When true, purge all cached list responses for this table as part of the update. Use this to force readers to see fresh data immediately instead of waiting for the cache TTL to expire.', true) ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') From 939092726cb4b395fb83bf54b4ed9b4420f6bdb9 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 8 Apr 2026 21:31:42 +1200 Subject: [PATCH 23/35] test(databases): add regression for purge=true list cache invalidation --- .../e2e/Services/Databases/DatabasesBase.php | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index 628928914f..cc06da37ef 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -3527,6 +3527,110 @@ trait DatabasesBase $this->assertEquals('miss', $documents3['headers']['x-appwrite-cache']); } + public function testListDocumentsCachePurgedByUpdate(): void + { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } + $data = $this->setupDocuments(); + $databaseId = $data['databaseId']; + $docIds = $data['documentIds']; + + // Use different select queries from other cache tests to avoid cache key collision. + $queries = [ + Query::equal('$id', $docIds)->toString(), + Query::select(['title', 'tagline', '$id'])->toString(), + Query::orderAsc('$createdAt')->toString(), + ]; + + // 1. First request populates the cache. + $documents1 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 300, + ]); + + $this->assertEquals(200, $documents1['headers']['status-code']); + $this->assertEquals('miss', $documents1['headers']['x-appwrite-cache']); + + // 2. Same request hits cache. + $documents2 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 300, + ]); + + $this->assertEquals(200, $documents2['headers']['status-code']); + $this->assertEquals('hit', $documents2['headers']['x-appwrite-cache']); + + // 3. Update the collection/table with purge=true to invalidate all cached list responses. + $update = $this->client->call(Client::METHOD_PUT, $this->getContainerUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'name' => 'Movies', + 'enabled' => true, + $this->getSecurityParam() => true, + 'purge' => true, + ]); + + $this->assertEquals(200, $update['headers']['status-code']); + + // 4. Same request should now miss cache because purge=true cleared the hash. + $documents3 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 300, + ]); + + $this->assertEquals(200, $documents3['headers']['status-code']); + $this->assertEquals('miss', $documents3['headers']['x-appwrite-cache']); + + // 5. Re-reading without purge should hit the freshly populated cache. + $documents4 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 300, + ]); + + $this->assertEquals(200, $documents4['headers']['status-code']); + $this->assertEquals('hit', $documents4['headers']['x-appwrite-cache']); + + // 6. Update without purge=true must NOT invalidate the cache. + $update2 = $this->client->call(Client::METHOD_PUT, $this->getContainerUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'name' => 'Movies', + 'enabled' => true, + $this->getSecurityParam() => true, + ]); + + $this->assertEquals(200, $update2['headers']['status-code']); + + $documents5 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 300, + ]); + + $this->assertEquals(200, $documents5['headers']['status-code']); + $this->assertEquals('hit', $documents5['headers']['x-appwrite-cache']); + } + public function testGetDocument(): void { $data = $this->getDocumentsList(); From b1ce71e6b0d812a2c6ef51f34405f7693830a691 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 8 Apr 2026 21:35:49 +1200 Subject: [PATCH 24/35] (chore): fmt --- tests/e2e/Services/Realtime/RealtimeCustomClientTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index 15ea260ab5..9c768f00d1 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -5305,7 +5305,7 @@ class RealtimeCustomClientTest extends Scope $actorsId = $actors['body']['$id']; //Test Attribute Create - + $scoreAttr = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/integer', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -5350,7 +5350,7 @@ class RealtimeCustomClientTest extends Scope ], $this->getHeaders()), [ 'value' => 5 ]); - + $this->assertEquals(200, $increment['headers']['status-code']); $response = json_decode($client->receive(), true); @@ -5361,7 +5361,7 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('timestamp', $response['data']); $this->assertCount(8, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']); - + $this->assertNotEmpty($response['data']['payload']); $this->assertIsArray($response['data']['payload']); $this->assertArrayHasKey('$id', $response['data']['payload']); @@ -5394,7 +5394,7 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('timestamp', $response['data']); $this->assertCount(8, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']); - + $this->assertNotEmpty($response['data']['payload']); $this->assertIsArray($response['data']['payload']); $this->assertArrayHasKey('$id', $response['data']['payload']); From 6fa0724404c9c70623934010f3fec141763a9fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 8 Apr 2026 11:52:12 +0200 Subject: [PATCH 25/35] Mark project response format sub-formats as public --- src/Appwrite/Utopia/Response/Model/AuthProvider.php | 5 ----- src/Appwrite/Utopia/Response/Model/DevKey.php | 5 ----- 2 files changed, 10 deletions(-) diff --git a/src/Appwrite/Utopia/Response/Model/AuthProvider.php b/src/Appwrite/Utopia/Response/Model/AuthProvider.php index 0171a3c152..2b8f962cd0 100644 --- a/src/Appwrite/Utopia/Response/Model/AuthProvider.php +++ b/src/Appwrite/Utopia/Response/Model/AuthProvider.php @@ -7,11 +7,6 @@ use Appwrite\Utopia\Response\Model; class AuthProvider extends Model { - /** - * @var bool - */ - protected bool $public = false; - public function __construct() { $this diff --git a/src/Appwrite/Utopia/Response/Model/DevKey.php b/src/Appwrite/Utopia/Response/Model/DevKey.php index b8da6c0cfc..45434cde3b 100644 --- a/src/Appwrite/Utopia/Response/Model/DevKey.php +++ b/src/Appwrite/Utopia/Response/Model/DevKey.php @@ -7,11 +7,6 @@ use Appwrite\Utopia\Response\Model; class DevKey extends Model { - /** - * @var bool - */ - protected bool $public = false; - public function __construct() { $this From a144968d705f895bed0272365540c1dca52a4b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 8 Apr 2026 12:08:32 +0200 Subject: [PATCH 26/35] Fix formatting --- tests/e2e/Services/Realtime/RealtimeCustomClientTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index 15ea260ab5..9c768f00d1 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -5305,7 +5305,7 @@ class RealtimeCustomClientTest extends Scope $actorsId = $actors['body']['$id']; //Test Attribute Create - + $scoreAttr = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/integer', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -5350,7 +5350,7 @@ class RealtimeCustomClientTest extends Scope ], $this->getHeaders()), [ 'value' => 5 ]); - + $this->assertEquals(200, $increment['headers']['status-code']); $response = json_decode($client->receive(), true); @@ -5361,7 +5361,7 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('timestamp', $response['data']); $this->assertCount(8, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']); - + $this->assertNotEmpty($response['data']['payload']); $this->assertIsArray($response['data']['payload']); $this->assertArrayHasKey('$id', $response['data']['payload']); @@ -5394,7 +5394,7 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('timestamp', $response['data']); $this->assertCount(8, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']); - + $this->assertNotEmpty($response['data']['payload']); $this->assertIsArray($response['data']['payload']); $this->assertArrayHasKey('$id', $response['data']['payload']); From c5b8ed9cc1d92af07a9d48e37fc4bde1aec6c8ec Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 8 Apr 2026 23:02:34 +1200 Subject: [PATCH 27/35] feat(databases): cache list responses without requiring a select query --- .../Databases/Collections/Documents/XList.php | 90 +++++++++---------- .../Http/TablesDB/Tables/Rows/XList.php | 2 +- .../e2e/Services/Databases/DatabasesBase.php | 47 ++++++++++ 3 files changed, 91 insertions(+), 48 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index 716638ab14..97588630d5 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -72,7 +72,7 @@ class XList extends Action ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject']) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for caching list responses that include a select query. Responses are stored in an in-memory key-value cache, keyed per project, collection, schema version (attributes and indexes), caller authorization roles, and the exact query — so users with different permissions never share cached entries. Schema changes invalidate cached entries automatically; document writes do not, so choose a TTL you are comfortable serving as stale data. Set to 0 to disable caching. Must be between 0 and 86400 (24 hours).', true) + ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for caching list responses. Responses are stored in an in-memory key-value cache, keyed per project, collection, schema version (attributes and indexes), caller authorization roles, and the exact query — so users with different permissions never share cached entries. Schema changes invalidate cached entries automatically; document writes do not, so choose a TTL you are comfortable serving as stale data. Set to 0 to disable caching. Must be between 0 and 86400 (24 hours).', true) ->inject('response') ->inject('dbForProject') ->inject('user') @@ -127,63 +127,59 @@ class XList extends Action } try { - $selectQueries = Query::groupByType($queries)['selections'] ?? []; + $hasSelects = ! empty(Query::groupByType($queries)['selections'] ?? []); $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); + // When there are no select queries, relationship loading is skipped on the + // underlying find() to avoid pulling related documents the caller did not ask for. + $find = $hasSelects + ? fn () => $dbForDatabases->find($collectionTableId, $queries) + : fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries)); + // Use transaction-aware document retrieval if transactionId is provided if ($transactionId !== null) { $documents = $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries); $total = $includeTotal ? $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries) : 0; - } elseif (! empty($selectQueries)) { + } elseif ((int)$ttl > 0) { + $cacheKey = $this->getListCacheKey($dbForProject, $collectionId); + $roles = $dbForProject->getAuthorization()->getRoles(); + $documentsField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_DOCUMENTS); - if ((int)$ttl > 0) { - $cacheKey = $this->getListCacheKey($dbForProject, $collectionId); - $roles = $dbForProject->getAuthorization()->getRoles(); - $documentsField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_DOCUMENTS); + $documentsCacheHit = false; + $cachedDocuments = $dbForProject->getCache()->load($cacheKey, $ttl, $documentsField); - $documentsCacheHit = false; - $cachedDocuments = $dbForProject->getCache()->load($cacheKey, $ttl, $documentsField); - - if ($cachedDocuments !== null && - $cachedDocuments !== false && - \is_array($cachedDocuments)) { - $documents = \array_map(function ($doc) { - return new Document($doc); - }, $cachedDocuments); - $documentsCacheHit = true; - } else { - $documents = $dbForDatabases->find($collectionTableId, $queries); - - // Convert Document objects to arrays for caching - $documentsArray = \array_map(function ($doc) { - return $doc->getArrayCopy(); - }, $documents); - $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField); - } - - if ($includeTotal) { - $totalField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_TOTAL); - $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField); - if ($cachedTotal !== null && $cachedTotal !== false) { - $total = $cachedTotal; - } else { - $total = $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT); - $dbForProject->getCache()->save($cacheKey, $total, $totalField); - } - } else { - $total = 0; - } - - $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); + if ($cachedDocuments !== null && + $cachedDocuments !== false && + \is_array($cachedDocuments)) { + $documents = \array_map(function ($doc) { + return new Document($doc); + }, $cachedDocuments); + $documentsCacheHit = true; } else { - // has selects, allow relationship on documents - $documents = $dbForDatabases->find($collectionTableId, $queries); - $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; + $documents = $find(); + + // Convert Document objects to arrays for caching + $documentsArray = \array_map(function ($doc) { + return $doc->getArrayCopy(); + }, $documents); + $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField); } + if ($includeTotal) { + $totalField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_TOTAL); + $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField); + if ($cachedTotal !== null && $cachedTotal !== false) { + $total = $cachedTotal; + } else { + $total = $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT); + $dbForProject->getCache()->save($cacheKey, $total, $totalField); + } + } else { + $total = 0; + } + + $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); } else { - // has no selects, disable relationship loading on documents - /* @type Document[] $documents */ - $documents = $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries)); + $documents = $find(); $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; } } catch (OrderException $e) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php index 617081439d..91c62aea05 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php @@ -57,7 +57,7 @@ class XList extends DocumentXList ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject']) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for caching list responses that include a select query. Responses are stored in an in-memory key-value cache, keyed per project, table, schema version (columns and indexes), caller authorization roles, and the exact query — so users with different permissions never share cached entries. Schema changes invalidate cached entries automatically; row writes do not, so choose a TTL you are comfortable serving as stale data. Set to 0 to disable caching. Must be between 0 and 86400 (24 hours).', true) + ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for caching list responses. Responses are stored in an in-memory key-value cache, keyed per project, table, schema version (columns and indexes), caller authorization roles, and the exact query — so users with different permissions never share cached entries. Schema changes invalidate cached entries automatically; row writes do not, so choose a TTL you are comfortable serving as stale data. Set to 0 to disable caching. Must be between 0 and 86400 (24 hours).', true) ->inject('response') ->inject('dbForProject') ->inject('user') diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index cc06da37ef..2c5e587fc2 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -3527,6 +3527,53 @@ trait DatabasesBase $this->assertEquals('miss', $documents3['headers']['x-appwrite-cache']); } + public function testListDocumentsCachedWithoutSelectQuery(): void + { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } + $data = $this->setupDocuments(); + $databaseId = $data['databaseId']; + $docIds = $data['documentIds']; + + // No Query::select(...) at all — ttl alone should enable caching. + $queries = [ + Query::equal('$id', $docIds)->toString(), + Query::orderAsc('releaseYear')->toString(), + ]; + + // 1. First request populates the cache. + $documents1 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 60, + ]); + + $this->assertEquals(200, $documents1['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $documents1['headers']); + $this->assertEquals('miss', $documents1['headers']['x-appwrite-cache']); + + // 2. Same request hits cache — proves the gate is ttl > 0, not the presence of a select query. + $documents2 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'ttl' => 60, + ]); + + $this->assertEquals(200, $documents2['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $documents2['headers']); + $this->assertEquals('hit', $documents2['headers']['x-appwrite-cache']); + $this->assertSame( + $documents1['body'][$this->getRecordResource()], + $documents2['body'][$this->getRecordResource()] + ); + } + public function testListDocumentsCachePurgedByUpdate(): void { if (!$this->getSupportForAttributes()) { From 990a32dd9ea7bfa9f5b2e1c4ff245929e267d351 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 8 Apr 2026 23:31:55 +1200 Subject: [PATCH 28/35] Update src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../Databases/Http/Databases/Collections/Documents/XList.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index 97588630d5..c35eebaea2 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -170,7 +170,7 @@ class XList extends Action if ($cachedTotal !== null && $cachedTotal !== false) { $total = $cachedTotal; } else { - $total = $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT); + $total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT); $dbForProject->getCache()->save($cacheKey, $total, $totalField); } } else { From 3f725c6be93a63b87454cd0845ad09d34930ed2f Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Wed, 8 Apr 2026 17:44:49 +0530 Subject: [PATCH 29/35] changes --- app/controllers/api/account.php | 70 +++++++++++++++------------------ 1 file changed, 31 insertions(+), 39 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 72347eaf9d..8fcb2e6abe 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1341,13 +1341,12 @@ Http::get('/v1/account/sessions/oauth2/:provider') ->inject('project') ->inject('platform') ->action(function (string $provider, string $success, string $failure, array $scopes, Request $request, Response $response, Document $project, array $platform) use ($oauthDefaultSuccess, $oauthDefaultFailure) { - $protocol = $request->getProtocol(); - $port = (string) $request->getPort(); + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; + $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ( - $port !== '' - && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) - ) { + if ($protocol === 'https' && $port !== '443') { + $callbackBase .= ':' . $port; + } elseif ($protocol === 'http' && $port !== '80') { $callbackBase .= ':' . $port; } @@ -1398,12 +1397,10 @@ Http::get('/v1/account/sessions/oauth2/:provider') 'token' => false, ], $scopes); - $loginURL = $oauth2->getLoginURL(); - $response ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') ->addHeader('Pragma', 'no-cache') - ->redirect($loginURL); + ->redirect($oauth2->getLoginURL()); }); Http::get('/v1/account/sessions/oauth2/callback/:provider/:projectId') @@ -1421,13 +1418,12 @@ Http::get('/v1/account/sessions/oauth2/callback/:provider/:projectId') ->inject('request') ->inject('response') ->action(function (string $projectId, string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response) { - $protocol = $request->getProtocol(); - $port = (string) $request->getPort(); + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; + $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ( - $port !== '' - && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) - ) { + if ($protocol === 'https' && $port !== '443') { + $callbackBase .= ':' . $port; + } elseif ($protocol === 'http' && $port !== '80') { $callbackBase .= ':' . $port; } @@ -1458,13 +1454,12 @@ Http::post('/v1/account/sessions/oauth2/callback/:provider/:projectId') ->inject('request') ->inject('response') ->action(function (string $projectId, string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response) { - $protocol = $request->getProtocol(); - $port = (string) $request->getPort(); + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; + $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ( - $port !== '' - && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) - ) { + if ($protocol === 'https' && $port !== '443') { + $callbackBase .= ':' . $port; + } elseif ($protocol === 'http' && $port !== '80') { $callbackBase .= ':' . $port; } @@ -1511,13 +1506,12 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->inject('proofForToken') ->inject('authorization') ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) use ($oauthDefaultSuccess) { - $protocol = $request->getProtocol(); - $port = (string) $request->getPort(); + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; + $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ( - $port !== '' - && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) - ) { + if ($protocol === 'https' && $port !== '443') { + $callbackBase .= ':' . $port; + } elseif ($protocol === 'http' && $port !== '80') { $callbackBase .= ':' . $port; } @@ -2054,13 +2048,12 @@ Http::get('/v1/account/tokens/oauth2/:provider') ->inject('project') ->inject('platform') ->action(function (string $provider, string $success, string $failure, array $scopes, Request $request, Response $response, Document $project, array $platform) use ($oauthDefaultSuccess, $oauthDefaultFailure) { - $protocol = $request->getProtocol(); - $port = (string) $request->getPort(); + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; + $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ( - $port !== '' - && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) - ) { + if ($protocol === 'https' && $port !== '443') { + $callbackBase .= ':' . $port; + } elseif ($protocol === 'http' && $port !== '80') { $callbackBase .= ':' . $port; } @@ -2090,13 +2083,12 @@ Http::get('/v1/account/tokens/oauth2/:provider') } $host = $platform['consoleHostname'] ?? ''; - $protocol = $request->getProtocol(); - $port = (string) $request->getPort(); + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; + $port = $request->getPort(); $redirectBase = $protocol . '://' . $host; - if ( - $port !== '' - && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) - ) { + if ($protocol === 'https' && $port !== '443') { + $redirectBase .= ':' . $port; + } elseif ($protocol === 'http' && $port !== '80') { $redirectBase .= ':' . $port; } From e4d1178e714a78ef887675e1200ec5e6e9a4320f Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Wed, 8 Apr 2026 17:56:37 +0530 Subject: [PATCH 30/35] simplified code --- src/Appwrite/Auth/OAuth2/X.php | 72 ++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 26 deletions(-) diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php index 8a1ab49ef2..e161cf41b4 100644 --- a/src/Appwrite/Auth/OAuth2/X.php +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -71,15 +71,10 @@ class X extends OAuth2 protected function getTokens(string $code): array { if (empty($this->tokens)) { - $headers = [ - 'Authorization: Basic ' . \base64_encode($this->appID . ':' . $this->appSecret), - 'Content-Type: application/x-www-form-urlencoded', - ]; - - $this->tokens = \json_decode($this->request( + $this->tokens = $this->decodeJsonObject($this->request( 'POST', 'https://api.x.com/2/oauth2/token', - $headers, + $this->tokenEndpointHeaders(), \http_build_query([ 'code' => $code, 'client_id' => $this->appID, @@ -87,7 +82,7 @@ class X extends OAuth2 'redirect_uri' => $this->callback, 'code_verifier' => $this->getPKCEVerifier(), ]) - ), true); + )); } return $this->tokens; @@ -100,21 +95,16 @@ class X extends OAuth2 */ public function refreshTokens(string $refreshToken): array { - $headers = [ - 'Authorization: Basic ' . \base64_encode($this->appID . ':' . $this->appSecret), - 'Content-Type: application/x-www-form-urlencoded', - ]; - - $this->tokens = \json_decode($this->request( + $this->tokens = $this->decodeJsonObject($this->request( 'POST', 'https://api.x.com/2/oauth2/token', - $headers, + $this->tokenEndpointHeaders(), \http_build_query([ 'client_id' => $this->appID, 'refresh_token' => $refreshToken, 'grant_type' => 'refresh_token', ]) - ), true); + )); if (empty($this->tokens['refresh_token'])) { $this->tokens['refresh_token'] = $refreshToken; @@ -182,38 +172,62 @@ class X extends OAuth2 protected function getUser(string $accessToken): array { if (empty($this->user)) { - $this->user = \json_decode($this->request( + $this->user = $this->decodeJsonObject($this->request( 'GET', 'https://api.x.com/2/users/me?user.fields=confirmed_email', ['Authorization: Bearer ' . $accessToken] - ), true); + )); } return $this->user; } - public function parseState(string $state) + /** + * @return array|null + */ + public function parseState(string $state): ?array { $decoded = $this->base64UrlDecode($state); if ($decoded === false) { return null; } - $state = \json_decode($decoded, true); + $parsed = \json_decode($decoded, true); - if (!\is_array($state)) { - return $state; + if (!\is_array($parsed)) { + return null; } - $pkce = $state[self::PKCE_STATE_KEY] ?? null; + $pkce = $parsed[self::PKCE_STATE_KEY] ?? null; if (\is_array($pkce)) { $this->pkceVerifier = $this->decryptPKCEVerifier($pkce); } - unset($state[self::PKCE_STATE_KEY]); + unset($parsed[self::PKCE_STATE_KEY]); - return $state; + return $parsed; + } + + /** + * @return list + */ + private function tokenEndpointHeaders(): array + { + return [ + 'Authorization: Basic ' . \base64_encode($this->appID . ':' . $this->appSecret), + 'Content-Type: application/x-www-form-urlencoded', + ]; + } + + /** + * @return array + */ + private function decodeJsonObject(string $json): array + { + $decoded = \json_decode($json, true); + + return \is_array($decoded) ? $decoded : []; } private function getPKCEVerifier(): string @@ -275,7 +289,13 @@ class X extends OAuth2 private function getPKCEStateKey(): string { - return System::getEnv('_APP_OPENSSL_KEY_V1'); + $key = System::getEnv('_APP_OPENSSL_KEY_V1', ''); + + if ($key === '') { + throw new \RuntimeException('X OAuth2 requires _APP_OPENSSL_KEY_V1 to encrypt PKCE state.'); + } + + return $key; } private function base64UrlEncode(string $value): string From e6cfedd34063f34495dc0197c188a9a26a15ffdd Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Wed, 8 Apr 2026 18:27:36 +0530 Subject: [PATCH 31/35] addressed greptile comment --- src/Appwrite/Auth/OAuth2/X.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php index e161cf41b4..a2c6f81312 100644 --- a/src/Appwrite/Auth/OAuth2/X.php +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -252,6 +252,10 @@ class X extends OAuth2 $data = OpenSSL::encrypt($verifier, OpenSSL::CIPHER_AES_128_GCM, $key, OPENSSL_RAW_DATA, $iv, $tag); + if ($data === false || $tag === null) { + throw new \RuntimeException('Failed to encrypt PKCE verifier.'); + } + return [ 'data' => $this->base64UrlEncode($data), 'iv' => \bin2hex($iv), From 44a37e9e20d0065f3fa74056c4be4b0ffa9516e1 Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Wed, 8 Apr 2026 18:41:42 +0530 Subject: [PATCH 32/35] Use Exception for X OAuth2 PKCE encryption errors Align with other OAuth2 adapters that throw base Exception for configuration and crypto failures instead of RuntimeException. Made-with: Cursor --- src/Appwrite/Auth/OAuth2/X.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php index a2c6f81312..d12ce25b33 100644 --- a/src/Appwrite/Auth/OAuth2/X.php +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -253,7 +253,7 @@ class X extends OAuth2 $data = OpenSSL::encrypt($verifier, OpenSSL::CIPHER_AES_128_GCM, $key, OPENSSL_RAW_DATA, $iv, $tag); if ($data === false || $tag === null) { - throw new \RuntimeException('Failed to encrypt PKCE verifier.'); + throw new \Exception('Failed to encrypt PKCE verifier.'); } return [ @@ -296,7 +296,7 @@ class X extends OAuth2 $key = System::getEnv('_APP_OPENSSL_KEY_V1', ''); if ($key === '') { - throw new \RuntimeException('X OAuth2 requires _APP_OPENSSL_KEY_V1 to encrypt PKCE state.'); + throw new \Exception('X OAuth2 requires _APP_OPENSSL_KEY_V1 to encrypt PKCE state.'); } return $key; From 3880f181b3be642f7b8687beaec7169d0767f1c4 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 9 Apr 2026 01:19:18 +1200 Subject: [PATCH 33/35] fix(databases): propagate purge parameter to documentsdb updateCollection --- .../Modules/Databases/Http/DocumentsDB/Collections/Update.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php index 052970fec4..3acedc0379 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php @@ -58,6 +58,7 @@ class Update extends CollectionUpdate ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->param('purge', false, new Boolean(true), 'When true, purge all cached list responses for this collection as part of the update. Use this to force readers to see fresh data immediately instead of waiting for the cache TTL to expire.', true) ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') From e2d7dd837d27f8b0a0875f7cca823eddb60bbb5b Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:12:03 +0100 Subject: [PATCH 34/35] fix: use cURL cookie engine instead of parse_str for RFC 6265 compliance parse_str() URL-decodes cookie values, causing the test client to behave differently from real clients (Dart, Swift) which store values verbatim per RFC 6265. This masked a production bug where base64 session values containing %3D%3D would fail to decode on real devices. Replaces the manual Set-Cookie header parsing with cURL's built-in cookie engine (CURLOPT_COOKIEFILE='') and reads cookies via CURLINFO_COOKIELIST, which stores and returns values verbatim without any decoding. Co-Authored-By: Claude Sonnet 4.6 --- tests/e2e/Client.php | 29 +++++++---------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/tests/e2e/Client.php b/tests/e2e/Client.php index 758133c4c0..d170d56fe4 100644 --- a/tests/e2e/Client.php +++ b/tests/e2e/Client.php @@ -219,7 +219,8 @@ class Client curl_setopt($ch, CURLOPT_HTTPHEADER, $formattedHeaders); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0); curl_setopt($ch, CURLOPT_TIMEOUT, 120); - curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders, &$cookies) { + curl_setopt($ch, CURLOPT_COOKIEFILE, ''); // enable in-memory RFC 6265 cookie engine + curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders) { $len = strlen($header); $header = explode(':', $header, 2); @@ -227,12 +228,6 @@ class Client return $len; } - if (strtolower(trim($header[0])) == 'set-cookie') { - $parsed = $this->parseCookie((string)trim($header[1])); - $name = array_key_first($parsed); - $cookies[$name] = $parsed[$name]; - } - $responseHeaders[strtolower(trim($header[0]))] = trim($header[1]); return $len; @@ -259,6 +254,11 @@ class Client $responseType = $responseHeaders['content-type'] ?? ''; $responseStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE); + foreach (curl_getinfo($ch, CURLINFO_COOKIELIST) as $line) { + $parts = explode("\t", $line); + $cookies[$parts[5]] = $parts[6] ?? ''; + } + if ($decode && $method !== self::METHOD_HEAD) { $strpos = strpos($responseType, ';'); $strpos = \is_bool($strpos) ? \strlen($responseType) : $strpos; @@ -309,21 +309,6 @@ class Client ]; } - /** - * Parse Cookie String - * - * @param string $cookie - * @return array - */ - public function parseCookie(string $cookie): array - { - $cookies = []; - - parse_str(strtr($cookie, ['&' => '%26', '+' => '%2B', ';' => '&']), $cookies); - - return $cookies; - } - /** * Flatten params array to PHP multiple format * From 84dc921d41acd89a17e843680cfdc3c0c7f2b065 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:58:57 +0100 Subject: [PATCH 35/35] fix: replace utopia-php/framework with http, fix RFC 6265 cookie handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit utopia-php/framework was the old name for utopia-php/http. Replacing it with utopia-php/http 0.34.19 which fixes getCookie() to use Swoole's native cookie store (populated via php_raw_url_decode) instead of re-parsing the raw Cookie header without URL-decoding. This fixes a production auth bug where Swoole's setcookie() URL-encodes base64 session values (+ → %2B, / → %2F, = → %3D) in Set-Cookie headers. RFC 6265 clients (Dart, Swift) reflect these verbatim; the old getCookie() returned %2B/%2F/%3D to base64_decode() which produced corrupted output, rejecting valid sessions. Also updates the e2e test client to use cURL's built-in RFC 6265 cookie engine (CURLOPT_COOKIEFILE) instead of parse_str() which silently URL-decoded values, masking the bug in tests. Adds a cookie roundtrip assertion to testCreateAccountSession. Co-Authored-By: Claude Sonnet 4.6 --- composer.json | 2 +- composer.lock | 80 +++---------------- .../Account/AccountCustomClientTest.php | 10 +++ 3 files changed, 24 insertions(+), 68 deletions(-) diff --git a/composer.json b/composer.json index d3474361e2..4ad1ae6120 100644 --- a/composer.json +++ b/composer.json @@ -67,7 +67,7 @@ "utopia-php/emails": "0.6.*", "utopia-php/dns": "1.6.*", "utopia-php/dsn": "0.2.1", - "utopia-php/framework": "0.34.*", + "utopia-php/http": "0.34.*", "utopia-php/fetch": "0.5.*", "utopia-php/image": "0.8.*", "utopia-php/locale": "0.8.*", diff --git a/composer.lock b/composer.lock index 90e8a09ab2..164b3a036f 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": "e9c38bbebc60849e70e3640aaa4422cd", + "content-hash": "4fb974e9843f6104e40396e7cad4a833", "packages": [ { "name": "adhocore/jwt", @@ -4269,72 +4269,18 @@ }, "time": "2025-12-18T16:25:10+00:00" }, - { - "name": "utopia-php/framework", - "version": "0.34.18", - "source": { - "type": "git", - "url": "https://github.com/utopia-php/http.git", - "reference": "c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc", - "reference": "c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc", - "shasum": "" - }, - "require": { - "ext-swoole": "*", - "php": ">=8.2", - "utopia-php/compression": "0.1.*", - "utopia-php/di": "0.3.*", - "utopia-php/servers": "0.3.*", - "utopia-php/telemetry": "0.2.*", - "utopia-php/validators": "0.2.*" - }, - "require-dev": { - "doctrine/instantiator": "^1.5", - "laravel/pint": "1.*", - "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "1.*", - "phpunit/phpunit": "^9.5.25", - "swoole/ide-helper": "4.8.3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Utopia\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A simple, light and advanced PHP HTTP framework", - "keywords": [ - "framework", - "http", - "php", - "upf" - ], - "support": { - "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.34.18" - }, - "time": "2026-04-07T08:06:39+00:00" - }, { "name": "utopia-php/http", - "version": "0.34.18", + "version": "0.34.19", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc" + "reference": "995c119f31866cacd42d63b1f922bf86eabb396c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc", - "reference": "c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc", + "url": "https://api.github.com/repos/utopia-php/http/zipball/995c119f31866cacd42d63b1f922bf86eabb396c", + "reference": "995c119f31866cacd42d63b1f922bf86eabb396c", "shasum": "" }, "require": { @@ -4373,9 +4319,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.34.18" + "source": "https://github.com/utopia-php/http/tree/0.34.19" }, - "time": "2026-04-07T08:06:39+00:00" + "time": "2026-04-08T10:23:17+00:00" }, { "name": "utopia-php/image", @@ -5502,16 +5448,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.17.6", + "version": "1.17.7", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "8888a9fd11260d389874424268ecbe0d956eb550" + "reference": "291471d04c3f0e7b9fcc46668a6255a4c0f2947e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/8888a9fd11260d389874424268ecbe0d956eb550", - "reference": "8888a9fd11260d389874424268ecbe0d956eb550", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/291471d04c3f0e7b9fcc46668a6255a4c0f2947e", + "reference": "291471d04c3f0e7b9fcc46668a6255a4c0f2947e", "shasum": "" }, "require": { @@ -5547,9 +5493,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.6" + "source": "https://github.com/appwrite/sdk-generator/tree/1.17.7" }, - "time": "2026-04-08T05:37:23+00:00" + "time": "2026-04-08T08:51:05+00:00" }, { "name": "brianium/paratest", diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index ee1bb31ede..951ab179b3 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -802,6 +802,16 @@ class AccountCustomClientTest extends Scope $sessionId = $response['body']['$id']; $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; + $accountResponse = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ])); + + $this->assertEquals(200, $accountResponse['headers']['status-code']); + $this->assertEquals($email, $accountResponse['body']['email']); + // apiKey is only available in custom client test $apiKey = $this->getProject()['apiKey']; if (!empty($apiKey)) {