diff --git a/app/config/sdks.php b/app/config/sdks.php
index e89265b05e..cbfe76dce4 100644
--- a/app/config/sdks.php
+++ b/app/config/sdks.php
@@ -320,6 +320,26 @@ return [
'repoBranch' => 'main',
'changelog' => \realpath(__DIR__ . '/../../docs/sdks/claude-plugin/CHANGELOG.md'),
],
+ [
+ 'key' => 'codex-plugin',
+ 'name' => 'CodexPlugin',
+ 'version' => '0.1.0',
+ 'url' => 'https://github.com/appwrite/codex-plugin.git',
+ 'enabled' => true,
+ 'beta' => false,
+ 'dev' => false,
+ 'hidden' => false,
+ 'spec' => 'static',
+ 'family' => APP_SDK_PLATFORM_STATIC,
+ 'prism' => 'codex-plugin',
+ 'source' => \realpath(__DIR__ . '/../sdks/static-codex-plugin'),
+ 'gitUrl' => 'git@github.com:appwrite/codex-plugin.git',
+ 'gitRepoName' => 'codex-plugin',
+ 'gitUserName' => 'appwrite',
+ 'gitBranch' => 'dev',
+ 'repoBranch' => 'main',
+ 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/codex-plugin/CHANGELOG.md'),
+ ],
],
],
diff --git a/composer.json b/composer.json
index 55106d33d1..7d68a838f8 100644
--- a/composer.json
+++ b/composer.json
@@ -51,7 +51,7 @@
"ext-sockets": "*",
"appwrite/php-runtimes": "0.20.*",
"appwrite/php-clamav": "2.0.*",
- "utopia-php/abuse": "1.2.*",
+ "utopia-php/abuse": "1.3.*",
"utopia-php/agents": "1.2.*",
"utopia-php/analytics": "0.15.*",
"utopia-php/audit": "2.2.*",
diff --git a/composer.lock b/composer.lock
index fb32342cd7..0aa58ca03e 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": "4ef65b015dba97e91f6571b061787653",
+ "content-hash": "d58736fec3028d1f9aedd055e6d82684",
"packages": [
{
"name": "adhocore/jwt",
@@ -69,25 +69,25 @@
},
{
"name": "appwrite/appwrite",
- "version": "19.1.0",
+ "version": "23.1.0",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-for-php.git",
- "reference": "8738e812062f899c85b2598eef43d6a247f08a56"
+ "reference": "2f275921f10ceb7cff99f2d463f7328b296234fa"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/8738e812062f899c85b2598eef43d6a247f08a56",
- "reference": "8738e812062f899c85b2598eef43d6a247f08a56",
+ "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/2f275921f10ceb7cff99f2d463f7328b296234fa",
+ "reference": "2f275921f10ceb7cff99f2d463f7328b296234fa",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
- "php": ">=7.1.0"
+ "php": ">=8.2.0"
},
"require-dev": {
- "mockery/mockery": "^1.6.12",
+ "mockery/mockery": "1.6.12",
"phpunit/phpunit": "^10"
},
"type": "library",
@@ -100,14 +100,14 @@
"license": [
"BSD-3-Clause"
],
- "description": "Appwrite is an open-source self-hosted backend server that abstract and simplify complex and repetitive development tasks behind a very simple REST API",
+ "description": "Appwrite is an open-source self-hosted backend server that abstracts and simplifies complex and repetitive development tasks behind a very simple REST API",
"support": {
"email": "team@appwrite.io",
"issues": "https://github.com/appwrite/sdk-for-php/issues",
- "source": "https://github.com/appwrite/sdk-for-php/tree/19.1.0",
+ "source": "https://github.com/appwrite/sdk-for-php/tree/23.1.0",
"url": "https://appwrite.io/support"
},
- "time": "2025-12-18T08:07:43+00:00"
+ "time": "2026-05-08T13:44:58+00:00"
},
{
"name": "appwrite/php-clamav",
@@ -3359,24 +3359,24 @@
},
{
"name": "utopia-php/abuse",
- "version": "1.2.3",
+ "version": "1.3.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/abuse.git",
- "reference": "53f4274939353522ba331f55bcff6e6011ffc56c"
+ "reference": "5d7efbe5c6b0cf7d06003114fd86e24ba785582f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/abuse/zipball/53f4274939353522ba331f55bcff6e6011ffc56c",
- "reference": "53f4274939353522ba331f55bcff6e6011ffc56c",
+ "url": "https://api.github.com/repos/utopia-php/abuse/zipball/5d7efbe5c6b0cf7d06003114fd86e24ba785582f",
+ "reference": "5d7efbe5c6b0cf7d06003114fd86e24ba785582f",
"shasum": ""
},
"require": {
- "appwrite/appwrite": "19.*",
+ "appwrite/appwrite": "23.*",
"ext-curl": "*",
"ext-pdo": "*",
"ext-redis": "*",
- "php": ">=8.0",
+ "php": ">=8.2",
"utopia-php/database": "5.*"
},
"require-dev": {
@@ -3405,9 +3405,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/abuse/issues",
- "source": "https://github.com/utopia-php/abuse/tree/1.2.3"
+ "source": "https://github.com/utopia-php/abuse/tree/1.3.0"
},
- "time": "2026-04-29T11:19:08+00:00"
+ "time": "2026-05-11T08:07:02+00:00"
},
{
"name": "utopia-php/agents",
@@ -3614,16 +3614,16 @@
},
{
"name": "utopia-php/cache",
- "version": "1.0.1",
+ "version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/cache.git",
- "reference": "05ceba981436a4022553f7aaa2a05fa049d0f71c"
+ "reference": "d36f9050c39c02e09a7763389c9e71258e74af1f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/cache/zipball/05ceba981436a4022553f7aaa2a05fa049d0f71c",
- "reference": "05ceba981436a4022553f7aaa2a05fa049d0f71c",
+ "url": "https://api.github.com/repos/utopia-php/cache/zipball/d36f9050c39c02e09a7763389c9e71258e74af1f",
+ "reference": "d36f9050c39c02e09a7763389c9e71258e74af1f",
"shasum": ""
},
"require": {
@@ -3660,9 +3660,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/cache/issues",
- "source": "https://github.com/utopia-php/cache/tree/1.0.1"
+ "source": "https://github.com/utopia-php/cache/tree/1.0.2"
},
- "time": "2026-03-12T03:39:09+00:00"
+ "time": "2026-05-08T11:40:20+00:00"
},
{
"name": "utopia-php/cli",
@@ -4541,24 +4541,24 @@
},
{
"name": "utopia-php/migration",
- "version": "1.10.2",
+ "version": "1.11.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
- "reference": "211d01b90ccab9729029151c6c61f543bd755c2e"
+ "reference": "0fca44f40ad07bf2d56e9396afa6fa6d9b098ef1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/migration/zipball/211d01b90ccab9729029151c6c61f543bd755c2e",
- "reference": "211d01b90ccab9729029151c6c61f543bd755c2e",
+ "url": "https://api.github.com/repos/utopia-php/migration/zipball/0fca44f40ad07bf2d56e9396afa6fa6d9b098ef1",
+ "reference": "0fca44f40ad07bf2d56e9396afa6fa6d9b098ef1",
"shasum": ""
},
"require": {
- "appwrite/appwrite": "19.*",
+ "appwrite/appwrite": "23.*",
"ext-curl": "*",
"ext-openssl": "*",
"halaxa/json-machine": "^1.2",
- "php": ">=8.1",
+ "php": ">=8.2",
"utopia-php/database": "5.*",
"utopia-php/dsn": "0.2.*",
"utopia-php/storage": "2.*"
@@ -4590,9 +4590,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/migration/issues",
- "source": "https://github.com/utopia-php/migration/tree/1.10.2"
+ "source": "https://github.com/utopia-php/migration/tree/1.11.0"
},
- "time": "2026-05-08T06:25:47+00:00"
+ "time": "2026-05-11T08:13:06+00:00"
},
{
"name": "utopia-php/mongo",
@@ -5476,16 +5476,16 @@
"packages-dev": [
{
"name": "appwrite/sdk-generator",
- "version": "1.27.5",
+ "version": "1.28.1",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-generator.git",
- "reference": "9faa38b48d422f3da764a719712905c83b3922cb"
+ "reference": "009118ccda8ccece2b9fc043c158cb1dd3efaa88"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/9faa38b48d422f3da764a719712905c83b3922cb",
- "reference": "9faa38b48d422f3da764a719712905c83b3922cb",
+ "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/009118ccda8ccece2b9fc043c158cb1dd3efaa88",
+ "reference": "009118ccda8ccece2b9fc043c158cb1dd3efaa88",
"shasum": ""
},
"require": {
@@ -5521,9 +5521,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.27.5"
+ "source": "https://github.com/appwrite/sdk-generator/tree/1.28.1"
},
- "time": "2026-05-05T12:09:40+00:00"
+ "time": "2026-05-08T13:24:33+00:00"
},
{
"name": "brianium/paratest",
diff --git a/src/Appwrite/Auth/OAuth2/Google.php b/src/Appwrite/Auth/OAuth2/Google.php
index 79894c2422..1166a313c6 100644
--- a/src/Appwrite/Auth/OAuth2/Google.php
+++ b/src/Appwrite/Auth/OAuth2/Google.php
@@ -55,7 +55,7 @@ class Google extends OAuth2
'state' => \json_encode($this->state),
'response_type' => 'code',
'access_type' => 'offline',
- 'prompt' => 'consent'
+ 'prompt' => $this->getPrompt()
]);
}
@@ -72,7 +72,7 @@ class Google extends OAuth2
'https://oauth2.googleapis.com/token?' . \http_build_query([
'code' => $code,
'client_id' => $this->appID,
- 'client_secret' => $this->appSecret,
+ 'client_secret' => $this->getClientSecret(),
'redirect_uri' => $this->callback,
'scope' => null,
'grant_type' => 'authorization_code'
@@ -95,7 +95,7 @@ class Google extends OAuth2
'https://oauth2.googleapis.com/token?' . \http_build_query([
'refresh_token' => $refreshToken,
'client_id' => $this->appID,
- 'client_secret' => $this->appSecret,
+ 'client_secret' => $this->getClientSecret(),
'grant_type' => 'refresh_token'
])
), true);
@@ -177,4 +177,54 @@ class Google extends OAuth2
return $this->user;
}
+
+ /**
+ * Extracts the Client Secret from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getClientSecret(): string
+ {
+ $secret = $this->getAppSecret();
+
+ return $secret['clientSecret'] ?? $this->appSecret;
+ }
+
+ /**
+ * Extracts the prompt values from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getPrompt(): string
+ {
+ $secret = $this->getAppSecret();
+ $prompt = $secret['prompt'] ?? [];
+
+ if (empty($prompt)) {
+ $prompt = ['consent'];
+ }
+
+ return \implode(' ', $prompt);
+ }
+
+ /**
+ * Decode the JSON stored in appSecret.
+ * Falls back to treating the raw string as the client secret for backwards compatibility.
+ *
+ * @return array
+ */
+ protected function getAppSecret(): array
+ {
+ try {
+ $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR);
+ } catch (\Throwable $th) {
+ return ['clientSecret' => $this->appSecret];
+ }
+
+ if (!\is_array($secret)) {
+ return ['clientSecret' => $this->appSecret];
+ }
+
+ return $secret;
+ }
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php
index 8e1895e8f6..294a6712a9 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php
@@ -36,10 +36,10 @@ class Create extends Action
{
// TODO: use database worker for for creating the v2 schema if not present
// it is considered that the v2 metadata schema is already created during server start in the http.php
- return self::constructDatabaseDSNFromProjectDatabase($this->getDatabaseType(), $project->getAttribute('region'), $project->getAttribute('database'));
+ return $this->constructDatabaseDSNFromProjectDatabase($this->getDatabaseType(), $project->getAttribute('region'), $project->getAttribute('database'));
}
- public static function constructDatabaseDSNFromProjectDatabase(string $databasetype, $region, ?string $dsn = null): string
+ private function constructDatabaseDSNFromProjectDatabase(string $databasetype, $region, ?string $dsn = null): string
{
$databases = [];
$databaseKeys = [];
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php
index 9b985f4aed..2a061d09ce 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php
@@ -3,8 +3,22 @@
namespace Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Google;
use Appwrite\Auth\OAuth2\Google;
+use Appwrite\Event\Event as QueueEvent;
+use Appwrite\Extend\Exception;
+use Appwrite\Platform\Action;
use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Base;
+use Appwrite\SDK\AuthType;
+use Appwrite\SDK\Method;
+use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
+use Utopia\Database\Database;
+use Utopia\Database\Document;
+use Utopia\Database\Validator\Authorization;
+use Utopia\Validator\ArrayList;
+use Utopia\Validator\Boolean;
+use Utopia\Validator\Nullable;
+use Utopia\Validator\Text;
+use Utopia\Validator\WhiteList;
class Update extends Base
{
@@ -52,4 +66,118 @@ class Update extends Base
{
return 'GOCSPX-2k8gsR0000000000000000VNahJj';
}
+
+ public static function getParameters(): array
+ {
+ return \array_merge(parent::getParameters(), [
+ [
+ '$id' => 'prompt',
+ 'name' => 'Prompt',
+ 'example' => '["consent"]',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('prompt', null, new Nullable(new ArrayList(new WhiteList(['none', 'consent', 'select_account'], true), 3)), 'Array of Google OAuth2 prompt values. If "none" is included, it must be the only element. "none" means: don\'t display any authentication or consent screens. Must not be specified with other values. "consent" means: prompt the user for consent. "select_account" means: prompt the user to select an account.', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'prompt' => $decoded['prompt'] ?? ['consent'],
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because Google
+ * takes an additional optional `prompt` parameter. The method is named
+ * differently to avoid an LSP-incompatible override of Base::action().
+ */
+ public function handle(
+ ?string $clientId,
+ ?string $clientSecret,
+ ?array $prompt,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ if ($prompt !== null) {
+ if (empty($prompt)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Prompt array cannot be empty.');
+ }
+
+ if (\in_array('none', $prompt) && \count($prompt) > 1) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When "none" is used as a prompt value, it must be the only element in the array.');
+ }
+ }
+
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = $this->decodeStoredSecret($project);
+
+ // Backwards compatibility: secrets stored before the prompt feature
+ // were saved as plain strings. Treat the raw value as clientSecret.
+ if (!empty($storedRaw) && empty($existing)) {
+ $existing = ['clientSecret' => $storedRaw];
+ }
+
+ $encodedSecret = \json_encode([
+ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
+ 'prompt' => $prompt ?? ($existing['prompt'] ?? ['consent']),
+ ]);
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
+
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
}
diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php
index b1580f0e68..fbf965bd00 100644
--- a/src/Appwrite/Platform/Tasks/SDKs.php
+++ b/src/Appwrite/Platform/Tasks/SDKs.php
@@ -7,6 +7,7 @@ use Appwrite\SDK\Language\Android;
use Appwrite\SDK\Language\Apple;
use Appwrite\SDK\Language\ClaudePlugin;
use Appwrite\SDK\Language\CLI;
+use Appwrite\SDK\Language\CodexPlugin;
use Appwrite\SDK\Language\CursorPlugin;
use Appwrite\SDK\Language\Dart;
use Appwrite\SDK\Language\Deno;
@@ -455,6 +456,9 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
case 'claude-plugin':
$config = new ClaudePlugin();
break;
+ case 'codex-plugin':
+ $config = new CodexPlugin();
+ break;
default:
throw new \Exception('Language "' . $language['key'] . '" not supported');
}
diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php
index 82a9aba91f..b4b8893b62 100644
--- a/src/Appwrite/Platform/Workers/Migrations.php
+++ b/src/Appwrite/Platform/Workers/Migrations.php
@@ -9,7 +9,6 @@ use Appwrite\Event\Message\Usage as UsageMessage;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Realtime;
use Appwrite\Extend\Exception;
-use Appwrite\Platform\Modules\Databases\Http\Databases\Create as DatabaseCreate;
use Appwrite\Template\Template;
use Appwrite\Usage\Context;
use Utopia\Compression\Compression;
@@ -285,8 +284,6 @@ class Migrations extends Action
$options = $migration->getAttribute('options', []);
$credentials = $migration->getAttribute('credentials');
- $destinationProject = $this->project;
-
return match ($destination) {
DestinationAppwrite::getName() => new DestinationAppwrite(
$this->project->getId(),
@@ -296,11 +293,7 @@ class Migrations extends Action
$this->getDatabasesDB,
Config::getParam('collections', [])['databases']['collections'],
OnDuplicate::tryFrom($options['onDuplicate'] ?? '') ?? OnDuplicate::Fail,
- fn (ResourceDatabase $resource): string => DatabaseCreate::constructDatabaseDSNFromProjectDatabase(
- $resource->getType() ?: 'legacy',
- $destinationProject->getAttribute('region'),
- $destinationProject->getAttribute('database'),
- ),
+ $this->resolveDestinationDatabaseDsn(...),
),
DestinationCSV::getName() => new DestinationCSV(
$this->deviceForFiles,
@@ -324,6 +317,19 @@ class Migrations extends Action
};
}
+ /**
+ * Legacy / tablesdb databases route to the destination project's DSN (same as a fresh
+ * Databases create), while documentsdb / vectorsdb keep the source DSN — the dedicated-DB
+ * backfill that would re-point them is not run during migrations.
+ */
+ private function resolveDestinationDatabaseDsn(ResourceDatabase $resource): string
+ {
+ return match ($resource->getType()) {
+ DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB => (string) $resource->getDatabase(),
+ default => (string) $this->project->getAttribute('database', ''),
+ };
+ }
+
/**
* @throws AuthorizationException
* @throws Structure
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Google.php b/src/Appwrite/Utopia/Response/Model/OAuth2Google.php
index 3dbc892631..ebef9aecf7 100644
--- a/src/Appwrite/Utopia/Response/Model/OAuth2Google.php
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Google.php
@@ -25,6 +25,20 @@ class OAuth2Google extends OAuth2Base
return 'GOCSPX-2k8gsR0000000000000000VNahJj';
}
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->addRule('prompt', [
+ 'type' => self::TYPE_ENUM,
+ 'description' => 'Google OAuth2 prompt values.',
+ 'default' => ['consent'],
+ 'example' => ['consent'],
+ 'array' => true,
+ 'enum' => ['none', 'consent', 'select_account'],
+ ]);
+ }
+
/**
* Get Name
*
diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php
index 4dc0174e50..8741ecff6c 100644
--- a/src/Appwrite/Vcs/Comment.php
+++ b/src/Appwrite/Vcs/Comment.php
@@ -50,6 +50,8 @@ class Comment
protected string $statePrefix = '[appwrite]: #';
+ protected ?string $tip = null;
+
/**
* @var mixed[] $builds
*/
@@ -81,7 +83,14 @@ class Comment
public function generateComment(): string
{
- $json = \json_encode($this->builds);
+ if ($this->tip === null) {
+ $this->tip = $this->tips[\array_rand($this->tips)];
+ }
+
+ $json = \json_encode([
+ 'builds' => $this->builds,
+ 'tip' => $this->tip,
+ ]);
$text = $this->statePrefix . \base64_encode($json) . "\n\n";
@@ -226,8 +235,7 @@ class Comment
$i++;
}
- $tip = $this->tips[array_rand($this->tips)];
- $text .= "\n
\n\n> [!TIP]\n> $tip\n\n";
+ $text .= "\n
\n\n> [!TIP]\n> {$this->tip}\n\n";
return $text;
}
@@ -252,8 +260,15 @@ class Comment
$json = \base64_decode($state);
- $builds = \json_decode($json, true);
- $this->builds = \is_array($builds) ? $builds : [];
+ $data = \json_decode($json, true);
+
+ if (\is_array($data) && \array_key_exists('builds', $data)) {
+ $this->builds = \is_array($data['builds']) ? $data['builds'] : [];
+ $this->tip = $data['tip'] ?? null;
+ } else {
+ // Backward compatibility with old state format (builds array only)
+ $this->builds = \is_array($data) ? $data : [];
+ }
return $this;
}
diff --git a/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php b/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php
index abe4d4968b..a7cad1c0f7 100644
--- a/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php
+++ b/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php
@@ -10,7 +10,6 @@ use Tests\E2E\Scopes\SideConsole;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
-use Utopia\Database\Query;
class DatabasesConsoleClientTest extends Scope
{
@@ -258,55 +257,4 @@ class DatabasesConsoleClientTest extends Scope
$this->assertIsArray($response['body']['documents']);
}
- #[Depends('testCreateCollection')]
- public function testGetCollectionLogs(array $data)
- {
- $databaseId = $data['databaseId'];
- /**
- * Test for SUCCESS
- */
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()));
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertIsNumeric($logs['body']['total']);
-
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'queries' => [Query::limit(1)->toString()]
- ]);
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertLessThanOrEqual(1, count($logs['body']['logs']));
- $this->assertIsNumeric($logs['body']['total']);
-
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'queries' => [Query::offset(1)->toString()]
- ]);
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertIsNumeric($logs['body']['total']);
-
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'queries' => [Query::offset(1)->toString(), Query::limit(1)->toString()]
- ]);
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertLessThanOrEqual(1, count($logs['body']['logs']));
- $this->assertIsNumeric($logs['body']['total']);
- }
}
diff --git a/tests/e2e/Services/Databases/VectorsDBConsoleClientTest.php b/tests/e2e/Services/Databases/VectorsDBConsoleClientTest.php
index 80c2bc9d5c..238d197158 100644
--- a/tests/e2e/Services/Databases/VectorsDBConsoleClientTest.php
+++ b/tests/e2e/Services/Databases/VectorsDBConsoleClientTest.php
@@ -10,7 +10,6 @@ use Tests\E2E\Scopes\SideConsole;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
-use Utopia\Database\Query;
class VectorsDBConsoleClientTest extends Scope
{
@@ -258,55 +257,4 @@ class VectorsDBConsoleClientTest extends Scope
$this->assertIsArray($response['body']['documents']);
}
- #[Depends('testCreateCollection')]
- public function testGetCollectionLogs(array $data)
- {
- $databaseId = $data['databaseId'];
- /**
- * Test for SUCCESS
- */
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()));
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertIsNumeric($logs['body']['total']);
-
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'queries' => [Query::limit(1)->toString()]
- ]);
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertLessThanOrEqual(1, count($logs['body']['logs']));
- $this->assertIsNumeric($logs['body']['total']);
-
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'queries' => [Query::offset(1)->toString()]
- ]);
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertIsNumeric($logs['body']['total']);
-
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'queries' => [Query::offset(1)->toString(), Query::limit(1)->toString()]
- ]);
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertLessThanOrEqual(1, count($logs['body']['logs']));
- $this->assertIsNumeric($logs['body']['total']);
- }
}
diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php
index 47d92a2a58..5959a584ea 100644
--- a/tests/e2e/Services/Project/OAuth2Base.php
+++ b/tests/e2e/Services/Project/OAuth2Base.php
@@ -2564,6 +2564,159 @@ trait OAuth2Base
]);
}
+ // =========================================================================
+ // Update Google (clientId + clientSecret + optional prompt)
+ // =========================================================================
+
+ /**
+ * Default prompt MUST run before any other Google test that sets a custom
+ * prompt value. The global resetProjectOAuth2() only clears Amazon state,
+ * so Google state leaks across tests in the same class. Running this first
+ * guarantees the stored JSON blob has no pre-existing "prompt" key.
+ */
+ public function testUpdateOAuth2GoogleDefaultPrompt(): void
+ {
+ // When prompt is omitted and nothing is stored, the default is ['consent'].
+ $response = $this->updateOAuth2('google', [
+ 'clientId' => 'google-default-client',
+ 'clientSecret' => 'google-default-secret',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(['consent'], $response['body']['prompt']);
+
+ // Cleanup
+ $this->updateOAuth2('google', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2Google(): void
+ {
+ $response = $this->updateOAuth2('google', [
+ 'clientId' => '120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com',
+ 'clientSecret' => 'GOCSPX-2k8gsR0000000000000000VNahJj',
+ 'prompt' => ['select_account'],
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('google', $response['body']['$id']);
+ $this->assertSame('120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com', $response['body']['clientId']);
+ $this->assertSame(['select_account'], $response['body']['prompt']);
+
+ // Cleanup
+ $this->updateOAuth2('google', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2GooglePartialPreservesPrompt(): void
+ {
+ // Seed clientSecret + prompt.
+ $this->updateOAuth2('google', [
+ 'clientId' => 'google-seed-client',
+ 'clientSecret' => 'google-seed-secret',
+ 'prompt' => ['consent', 'select_account'],
+ 'enabled' => false,
+ ]);
+
+ // Update only clientId.
+ $response = $this->updateOAuth2('google', [
+ 'clientId' => 'google-rotated-client',
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('google-rotated-client', $response['body']['clientId']);
+ $this->assertSame(['consent', 'select_account'], $response['body']['prompt']);
+
+ // Cleanup
+ $this->updateOAuth2('google', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2GooglePromptNoneAloneRejected(): void
+ {
+ $response = $this->updateOAuth2('google', [
+ 'clientId' => 'whatever',
+ 'clientSecret' => 'whatever',
+ 'prompt' => ['none', 'consent'],
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+ }
+
+ public function testUpdateOAuth2GooglePromptEmptyArrayRejected(): void
+ {
+ $response = $this->updateOAuth2('google', [
+ 'clientId' => 'whatever',
+ 'clientSecret' => 'whatever',
+ 'prompt' => [],
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+ }
+
+ public function testUpdateOAuth2GooglePromptNoneAloneAccepted(): void
+ {
+ $response = $this->updateOAuth2('google', [
+ 'clientId' => '120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com',
+ 'clientSecret' => 'GOCSPX-2k8gsR0000000000000000VNahJj',
+ 'prompt' => ['none'],
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(['none'], $response['body']['prompt']);
+
+ // Cleanup
+ $this->updateOAuth2('google', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2GoogleEnableAndReadBack(): void
+ {
+ $update = $this->updateOAuth2('google', [
+ 'clientId' => 'google-enable-client',
+ 'clientSecret' => 'google-enable-secret',
+ 'prompt' => ['select_account'],
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(200, $update['headers']['status-code']);
+ $this->assertTrue($update['body']['enabled']);
+
+ // GET must hide clientSecret while keeping clientId and prompt.
+ $get = $this->getOAuth2Provider('google');
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertTrue($get['body']['enabled']);
+ $this->assertSame('google-enable-client', $get['body']['clientId']);
+ $this->assertSame(['select_account'], $get['body']['prompt']);
+ $this->assertSame('', $get['body']['clientSecret']);
+
+ // Cleanup
+ $this->updateOAuth2('google', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
// =========================================================================
// Smoke test: every plain (clientId + clientSecret) provider
//
diff --git a/tests/unit/Vcs/CommentTest.php b/tests/unit/Vcs/CommentTest.php
new file mode 100644
index 0000000000..29973089c6
--- /dev/null
+++ b/tests/unit/Vcs/CommentTest.php
@@ -0,0 +1,154 @@
+ 'localhost']);
+ $comment->addBuild(
+ new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']),
+ new Document(['$id' => 'func1', 'name' => 'Test Function']),
+ 'function',
+ 'ready',
+ 'dep1',
+ ['type' => 'logs'],
+ ''
+ );
+
+ $first = $comment->generateComment();
+ $firstTip = $this->extractTip($first);
+
+ $this->assertNotNull($firstTip);
+ $this->assertNotEmpty($firstTip);
+
+ $second = $comment->generateComment();
+ $secondTip = $this->extractTip($second);
+
+ $this->assertEquals($firstTip, $secondTip);
+ }
+
+ public function testTipIsRestoredFromParsedComment(): void
+ {
+ $comment = new Comment(['consoleHostname' => 'localhost']);
+ $comment->addBuild(
+ new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']),
+ new Document(['$id' => 'func1', 'name' => 'Test Function']),
+ 'function',
+ 'ready',
+ 'dep1',
+ ['type' => 'logs'],
+ ''
+ );
+
+ $original = $comment->generateComment();
+ $originalTip = $this->extractTip($original);
+
+ $parsed = new Comment(['consoleHostname' => 'localhost']);
+ $parsed->parseComment($original);
+ $parsed->addBuild(
+ new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']),
+ new Document(['$id' => 'func2', 'name' => 'Another Function']),
+ 'function',
+ 'building',
+ 'dep2',
+ ['type' => 'logs'],
+ ''
+ );
+
+ $regenerated = $parsed->generateComment();
+ $regeneratedTip = $this->extractTip($regenerated);
+
+ $this->assertEquals($originalTip, $regeneratedTip);
+ }
+
+ public function testBackwardCompatibilityWithOldStateFormat(): void
+ {
+ $oldBuilds = [
+ 'project1_func1' => [
+ 'projectName' => 'Test Project',
+ 'projectId' => 'project1',
+ 'region' => 'default',
+ 'resourceName' => 'Test Function',
+ 'resourceId' => 'func1',
+ 'resourceType' => 'function',
+ 'buildStatus' => 'ready',
+ 'deploymentId' => 'dep1',
+ 'action' => ['type' => 'logs'],
+ 'previewUrl' => '',
+ ],
+ ];
+
+ $oldState = '[appwrite]: #' . \base64_encode(\json_encode($oldBuilds)) . "\n\n";
+ $oldState .= "> [!TIP]\n> Old tip that should be ignored\n\n";
+
+ $comment = new Comment(['consoleHostname' => 'localhost']);
+ $comment->parseComment($oldState);
+
+ $new = $comment->generateComment();
+ $newTip = $this->extractTip($new);
+
+ $this->assertNotNull($newTip);
+ $this->assertNotEquals('Old tip that should be ignored', $newTip);
+ $this->assertContains($newTip, $this->getTips());
+ }
+
+ public function testParseOldStateFormatWithOnlyBuilds(): void
+ {
+ $oldBuilds = [
+ 'project1_func1' => [
+ 'projectName' => 'Test Project',
+ 'projectId' => 'project1',
+ 'region' => 'default',
+ 'resourceName' => 'Test Function',
+ 'resourceId' => 'func1',
+ 'resourceType' => 'function',
+ 'buildStatus' => 'ready',
+ 'deploymentId' => 'dep1',
+ 'action' => ['type' => 'logs'],
+ 'previewUrl' => '',
+ ],
+ ];
+
+ $state = '[appwrite]: #' . \base64_encode(\json_encode($oldBuilds)) . "\n\n";
+
+ $comment = new Comment(['consoleHostname' => 'localhost']);
+ $comment->parseComment($state);
+
+ $this->assertEquals(false, $comment->isEmpty());
+
+ $first = $comment->generateComment();
+ $firstTip = $this->extractTip($first);
+
+ $this->assertNotNull($firstTip);
+ $this->assertNotEmpty($firstTip);
+ $this->assertContains($firstTip, $this->getTips());
+
+ $second = $comment->generateComment();
+ $secondTip = $this->extractTip($second);
+
+ $this->assertEquals($firstTip, $secondTip);
+ }
+
+ private function extractTip(string $comment): ?string
+ {
+ if (\preg_match('/> \[!TIP\]\n> (.+)/', $comment, $matches)) {
+ return $matches[1];
+ }
+
+ return null;
+ }
+
+ private function getTips(): array
+ {
+ $reflection = new \ReflectionClass(Comment::class);
+ $property = $reflection->getProperty('tips');
+
+ return $property->getValue(new Comment(['consoleHostname' => 'localhost']));
+ }
+}