From 8b4c90e6033f288f6cc7c25c2d465775e02adc25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 14 May 2026 11:47:46 +0200 Subject: [PATCH 01/34] Introduce organization endpoints --- src/Appwrite/Platform/Appwrite.php | 2 + .../Organization/Http/Projects/Action.php | 11 + .../Organization/Http/Projects/Create.php | 238 ++++++++++++++++++ .../Organization/Http/Projects/Get.php | 79 ++++++ .../Organization/Http/Projects/Update.php | 100 ++++++++ .../Organization/Http/Projects/XList.php | 201 +++++++++++++++ .../Platform/Modules/Organization/Module.php | 14 ++ .../Modules/Organization/Services/Http.php | 22 ++ 8 files changed, 667 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Organization/Http/Projects/Action.php create mode 100644 src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php create mode 100644 src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php create mode 100644 src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php create mode 100644 src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php create mode 100644 src/Appwrite/Platform/Modules/Organization/Module.php create mode 100644 src/Appwrite/Platform/Modules/Organization/Services/Http.php diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index a9cd1a8e2f..e41d508522 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -11,6 +11,7 @@ use Appwrite\Platform\Modules\Databases; use Appwrite\Platform\Modules\Functions; use Appwrite\Platform\Modules\Health; use Appwrite\Platform\Modules\Migrations; +use Appwrite\Platform\Modules\Organization; use Appwrite\Platform\Modules\Project; use Appwrite\Platform\Modules\Projects; use Appwrite\Platform\Modules\Proxy; @@ -42,6 +43,7 @@ class Appwrite extends Platform $this->addModule(new VCS\Module()); $this->addModule(new Webhooks\Module()); $this->addModule(new Migrations\Module()); + $this->addModule(new Organization\Module()); $this->addModule(new Project\Module()); $this->addModule(new Advisor\Module()); } diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Action.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Action.php new file mode 100644 index 0000000000..0160e2aa04 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Action.php @@ -0,0 +1,11 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/organizations/:organizationId/projects') + ->desc('Create project') + ->groups(['api', 'projects']) + ->label('audits.event', 'projects.create') + ->label('audits.resource', 'project/{response.$id}') + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'projects', + name: 'create', + description: '/docs/references/projects/create.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_CREATED, + model: Response::MODEL_PROJECT, + ) + ] + )) + ->param('organizationId', '', new UID(), 'Organization unique ID.') + ->param('projectId', '', new ProjectId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, and hyphen. Can\'t start with a special char. Max length is 36 chars.') + ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') + ->param('region', System::getEnv('_APP_REGION', 'default'), new WhiteList(array_keys(array_filter(Config::getParam('regions'), fn ($config) => !$config['disabled']))), 'Project Region.', true) + ->inject('request') + ->inject('response') + ->inject('dbForPlatform') + ->inject('cache') + ->inject('pools') + ->inject('hooks') + ->callback($this->action(...)); + } + + public function action(string $organizationId, string $projectId, string $name, string $region, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks) + { + $team = $dbForPlatform->getDocument('teams', $organizationId); + + if ($team->isEmpty()) { + throw new Exception(Exception::TEAM_NOT_FOUND); + } + + $allowList = \array_filter(\explode(',', System::getEnv('_APP_PROJECT_REGIONS', ''))); + + if (!empty($allowList) && !\in_array($region, $allowList)) { + throw new Exception(Exception::PROJECT_REGION_UNSUPPORTED, 'Region "' . $region . '" is not supported'); + } + + $auth = Config::getParam('auth', []); + $auths = [ + 'limit' => 0, + 'maxSessions' => 0, + 'passwordHistory' => 0, + 'passwordDictionary' => false, + 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, + 'personalDataCheck' => false, + 'disposableEmails' => false, + 'canonicalEmails' => false, + 'freeEmails' => false, + 'mockNumbers' => [], + 'sessionAlerts' => false, + 'membershipsUserName' => false, + 'membershipsUserEmail' => false, + 'membershipsMfa' => false, + 'membershipsUserId' => false, + 'membershipsUserPhone' => false, + 'invalidateSessions' => true + ]; + + foreach ($auth as $method) { + $auths[$method['key'] ?? ''] = true; + } + + $projectId = ($projectId == 'unique()') ? ID::unique() : $projectId; + + if ($projectId === 'console') { + throw new Exception(Exception::PROJECT_RESERVED_PROJECT, "'console' is a reserved project."); + } + + $databases = Config::getParam('pools-database', []); + + if ($region !== 'default') { + $databaseKeys = System::getEnv('_APP_DATABASE_KEYS', ''); + $keys = explode(',', $databaseKeys); + $databases = array_filter($keys, function ($value) use ($region) { + return str_contains($value, $region); + }); + } + + $databaseOverride = System::getEnv('_APP_DATABASE_OVERRIDE'); + $index = \array_search($databaseOverride, $databases); + if ($index !== false) { + $dsn = $databases[$index]; + } else { + $dsn = $databases[array_rand($databases)]; + } + + // TODO: Temporary until all projects are using shared tables. + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + + if (\in_array($dsn, $sharedTables)) { + $schema = 'appwrite'; + $database = 'appwrite'; + $namespace = System::getEnv('_APP_DATABASE_SHARED_NAMESPACE', ''); + $dsn = $schema . '://' . $dsn . '?database=' . $database; + + if (!empty($namespace)) { + $dsn .= '&namespace=' . $namespace; + } + } + + try { + $project = $dbForPlatform->createDocument('projects', new Document([ + '$id' => $projectId, + '$permissions' => $this->getPermissions($organizationId, $projectId), + 'name' => $name, + 'teamInternalId' => $team->getSequence(), + 'teamId' => $team->getId(), + 'region' => $region, + 'version' => APP_VERSION_STABLE, + 'services' => new \stdClass(), + 'platforms' => null, + 'oAuthProviders' => [], + 'webhooks' => null, + 'keys' => null, + 'auths' => $auths, + 'accessedAt' => DateTime::now(), + 'search' => implode(' ', [$projectId, $name]), + 'database' => $dsn, + 'labels' => [], + 'status' => PROJECT_STATUS_ACTIVE, + ])); + } catch (Duplicate) { + throw new Exception(Exception::PROJECT_ALREADY_EXISTS); + } + + try { + $dsn = new DSN($dsn); + } catch (\InvalidArgumentException) { + // TODO: Temporary until all projects are using shared tables + $dsn = new DSN('mysql://' . $dsn); + } + + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + $projectTables = !\in_array($dsn->getHost(), $sharedTables); + + if ($projectTables) { + $adapter = new DatabasePool($pools->get($dsn->getHost())); + $dbForProject = new Database($adapter, $cache); + $dbForProject + ->setDatabase(APP_DATABASE) + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $project->getSequence()); + + $create = true; + + try { + $dbForProject->create(); + } catch (Duplicate) { + $create = false; + } + + $adapter = new AdapterDatabase($dbForProject); + $audit = new Audit($adapter); + $audit->setup(); + + if ($create) { + /** @var array $collections */ + $collections = Config::getParam('collections', [])['projects'] ?? []; + + foreach ($collections as $key => $collection) { + if (($collection['$collection'] ?? '') !== Database::METADATA) { + continue; + } + + $attributes = \array_map(fn ($attribute) => new Document($attribute), $collection['attributes']); + $indexes = \array_map(fn (array $index) => new Document($index), $collection['indexes']); + + try { + $dbForProject->createCollection($key, $attributes, $indexes); + } catch (Duplicate) { + // Collection already exists + } + } + } + } + + // Hook allowing instant project mirroring during migration + // Outside of migration, hook is not registered and has no effect + $hooks->trigger('afterProjectCreation', [$project, $pools, $cache]); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php new file mode 100644 index 0000000000..012f223ae5 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php @@ -0,0 +1,79 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/organizations/:organizationId/projects/:projectId') + ->desc('Get project') + ->groups(['api', 'project']) + ->label('scope', 'projects.read') + ->label('sdk', new Method( + namespace: 'project', + group: null, + name: 'get', + description: <<param('organizationId', '', new UID(), 'Organization unique ID.') + ->param('projectId', '', new UID(), 'Project unique ID.') + ->inject('response') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action( + string $organizationId, + string $projectId, + Response $response, + Database $dbForPlatform, + ) { + $team = $dbForPlatform->getDocument('teams', $organizationId); + + if ($team->isEmpty()) { + throw new Exception(Exception::TEAM_NOT_FOUND); + } + + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + if ($project->getAttribute('teamInternalId') !== $team->getSequence()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php new file mode 100644 index 0000000000..883e8e4626 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php @@ -0,0 +1,100 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/organizations/:organizationId/projects/:projectId') + ->desc('Update project') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->label('audits.event', 'projects.update') + ->label('audits.resource', 'project/{request.projectId}') + ->label('sdk', new Method( + namespace: 'projects', + group: 'projects', + name: 'update', + description: '/docs/references/projects/update.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PROJECT, + ) + ] + )) + ->param('organizationId', '', new UID(), 'Organization unique ID.') + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') + ->param('description', '', new Text(256), 'Project description. Max length: 256 chars.', true) + ->param('logo', '', new Text(1024), 'Project logo.', true) + ->param('url', '', new URL(), 'Project URL.', true) + ->param('legalName', '', new Text(256), 'Project legal name. Max length: 256 chars.', true) + ->param('legalCountry', '', new Text(256), 'Project legal country. Max length: 256 chars.', true) + ->param('legalState', '', new Text(256), 'Project legal state. Max length: 256 chars.', true) + ->param('legalCity', '', new Text(256), 'Project legal city. Max length: 256 chars.', true) + ->param('legalAddress', '', new Text(256), 'Project legal address. Max length: 256 chars.', true) + ->param('legalTaxId', '', new Text(256), 'Project legal tax ID. Max length: 256 chars.', true) + ->inject('response') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action(string $organizationId, string $projectId, string $name, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, Response $response, Database $dbForPlatform) + { + $team = $dbForPlatform->getDocument('teams', $organizationId); + + if ($team->isEmpty()) { + throw new Exception(Exception::TEAM_NOT_FOUND); + } + + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + if ($project->getAttribute('teamInternalId') !== $team->getSequence()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $project = $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'name' => $name, + 'description' => $description, + 'logo' => $logo, + 'url' => $url, + 'legalName' => $legalName, + 'legalCountry' => $legalCountry, + 'legalState' => $legalState, + 'legalCity' => $legalCity, + 'legalAddress' => $legalAddress, + 'legalTaxId' => $legalTaxId, + 'search' => implode(' ', [$projectId, $name]), + ])); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php new file mode 100644 index 0000000000..cd2c194289 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php @@ -0,0 +1,201 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/organizations/:organizationId/projects') + ->desc('List projects') + ->groups(['api', 'projects']) + ->label('scope', 'projects.read') + ->label('sdk', new Method( + namespace: 'projects', + group: 'projects', + name: 'list', + description: <<param('organizationId', '', new UID(), 'Organization unique ID.') + ->param('queries', [], $this->getQueriesValidator(), '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(', ', Projects::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', 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') + ->callback($this->action(...)); + } + + public function action(string $organizationId, array $queries, string $search, bool $includeTotal, Response $response, Database $dbForPlatform) + { + $team = $dbForPlatform->getDocument('teams', $organizationId); + + if ($team->isEmpty()) { + throw new Exception(Exception::TEAM_NOT_FOUND); + } + + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + $queries[] = Query::equal('teamInternalId', [$team->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()); + } + + $projectId = $cursor->getValue(); + $cursorDocument = $dbForPlatform->getDocument('projects', $projectId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Project '{$projectId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + try { + $selectQueries = Query::groupByType($queries)['selections']; + $filterQueries = Query::groupByType($queries)['filters']; + + $projects = $this->find($dbForPlatform, $queries, $selectQueries); + $total = $includeTotal ? $dbForPlatform->count('projects', $filterQueries, APP_LIMIT_COUNT) : 0; + } catch (Order $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."); + } + + $response->addFilter(new ListSelection($selectQueries, 'projects')); + + $response->dynamic(new Document([ + 'projects' => $projects, + 'total' => $total, + ]), Response::MODEL_PROJECT_LIST); + } + + // Build mapping of columns to their subQuery filters + private static function getAttributeToSubQueryFilters(): array + { + if (self::$attributeToSubQueryFilters !== null) { + return self::$attributeToSubQueryFilters; + } + + self::$attributeToSubQueryFilters = []; + + $collections = Config::getParam('collections', []); + $projectAttributes = $collections['platform']['projects']['attributes'] ?? []; + + foreach ($projectAttributes as $attribute) { + $attributeId = $attribute['$id'] ?? null; + $filters = $attribute['filters'] ?? []; + + if ($attributeId === null || empty($filters)) { + continue; + } + + // extract only subQuery filters + $subQueryFilters = \array_filter($filters, function ($filter) { + return \str_starts_with($filter, 'subQuery'); + }); + + if (!empty($subQueryFilters)) { + self::$attributeToSubQueryFilters[$attributeId] = \array_values($subQueryFilters); + } + } + + return self::$attributeToSubQueryFilters; + } + + private function find(Database $dbForPlatform, array $queries, array $selectQueries): array + { + if (empty($selectQueries)) { + return $dbForPlatform->find('projects', $queries); + } + + $selectedAttributes = []; + foreach ($selectQueries as $query) { + foreach ($query->getValues() as $value) { + $selectedAttributes[] = $value; + } + } + + if (\in_array('*', $selectedAttributes)) { + return $dbForPlatform->find('projects', $queries); + } + + $filtersToSkipMap = []; + $selectedAttributesMap = \array_flip($selectedAttributes); + $attributeToSubQueryFilters = self::getAttributeToSubQueryFilters(); + + foreach ($attributeToSubQueryFilters as $attributeName => $subQueryFilters) { + if (!isset($selectedAttributesMap[$attributeName])) { + foreach ($subQueryFilters as $filter) { + $filtersToSkipMap[$filter] = true; + } + } + } + + $filtersToSkip = \array_keys($filtersToSkipMap); + + return empty($filtersToSkip) + ? $dbForPlatform->find('projects', $queries) + : $dbForPlatform->skipFilters(fn () => $dbForPlatform->find('projects', $queries), $filtersToSkip); + } +} diff --git a/src/Appwrite/Platform/Modules/Organization/Module.php b/src/Appwrite/Platform/Modules/Organization/Module.php new file mode 100644 index 0000000000..eb7a2dc433 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Organization/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/Organization/Services/Http.php b/src/Appwrite/Platform/Modules/Organization/Services/Http.php new file mode 100644 index 0000000000..030bff9fac --- /dev/null +++ b/src/Appwrite/Platform/Modules/Organization/Services/Http.php @@ -0,0 +1,22 @@ +type = Service::TYPE_HTTP; + + $this->addAction(CreateProject::getName(), new CreateProject()); + $this->addAction(ListProjects::getName(), new ListProjects()); + $this->addAction(GetProject::getName(), new GetProject()); + $this->addAction(UpdateProject::getName(), new UpdateProject()); + } +} From 275a6fe078b8fafec4c5655f4cbdecb5c1ba6199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 14 May 2026 12:01:51 +0200 Subject: [PATCH 02/34] merge endpoints as aliases --- .../Organization/Http/Projects/Create.php | 11 +- .../Organization/Http/Projects/Update.php | 11 +- .../Organization/Http/Projects/XList.php | 11 +- .../Modules/Projects/Http/Projects/Create.php | 245 ------------------ .../Modules/Projects/Http/Projects/Update.php | 94 ------- .../Modules/Projects/Http/Projects/XList.php | 197 -------------- .../Modules/Projects/Services/Http.php | 6 - 7 files changed, 30 insertions(+), 545 deletions(-) delete mode 100644 src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php delete mode 100644 src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php delete mode 100644 src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php index 6ad8a25e9f..aebf228380 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php @@ -42,6 +42,7 @@ class Create extends Action $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) ->setHttpPath('/v1/organizations/:organizationId/projects') + ->httpAlias('/v1/projects') ->desc('Create project') ->groups(['api', 'projects']) ->label('audits.event', 'projects.create') @@ -70,11 +71,19 @@ class Create extends Action ->inject('cache') ->inject('pools') ->inject('hooks') + ->inject('team') ->callback($this->action(...)); } - public function action(string $organizationId, string $projectId, string $name, string $region, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks) + public function action(string $organizationId, string $projectId, string $name, string $region, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks, Document $team) { + if (empty($organizationId)) { + if ($team->isEmpty()) { + throw new Exception(Exception::TEAM_NOT_FOUND); + } + $organizationId = $team->getId(); + } + $team = $dbForPlatform->getDocument('teams', $organizationId); if ($team->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php index 883e8e4626..36009a9512 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php @@ -28,6 +28,7 @@ class Update extends Action $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) ->setHttpPath('/v1/organizations/:organizationId/projects/:projectId') + ->httpAlias('/v1/projects/:projectId') ->desc('Update project') ->groups(['api', 'projects']) ->label('scope', 'projects.write') @@ -60,11 +61,19 @@ class Update extends Action ->param('legalTaxId', '', new Text(256), 'Project legal tax ID. Max length: 256 chars.', true) ->inject('response') ->inject('dbForPlatform') + ->inject('team') ->callback($this->action(...)); } - public function action(string $organizationId, string $projectId, string $name, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, Response $response, Database $dbForPlatform) + public function action(string $organizationId, string $projectId, string $name, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, Response $response, Database $dbForPlatform, Document $team) { + if (empty($organizationId)) { + if ($team->isEmpty()) { + throw new Exception(Exception::TEAM_NOT_FOUND); + } + $organizationId = $team->getId(); + } + $team = $dbForPlatform->getDocument('teams', $organizationId); if ($team->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php index cd2c194289..837cbff9e5 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php @@ -45,6 +45,7 @@ class XList extends Action $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/organizations/:organizationId/projects') + ->httpAlias('/v1/projects') ->desc('List projects') ->groups(['api', 'projects']) ->label('scope', 'projects.read') @@ -70,11 +71,19 @@ class XList extends Action ->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') + ->inject('team') ->callback($this->action(...)); } - public function action(string $organizationId, array $queries, string $search, bool $includeTotal, Response $response, Database $dbForPlatform) + public function action(string $organizationId, array $queries, string $search, bool $includeTotal, Response $response, Database $dbForPlatform, Document $team) { + if (empty($organizationId)) { + if ($team->isEmpty()) { + throw new Exception(Exception::TEAM_NOT_FOUND); + } + $organizationId = $team->getId(); + } + $team = $dbForPlatform->getDocument('teams', $organizationId); if ($team->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php deleted file mode 100644 index d2c92fc65c..0000000000 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php +++ /dev/null @@ -1,245 +0,0 @@ -setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) - ->setHttpPath('/v1/projects') - ->desc('Create project') - ->groups(['api', 'projects']) - ->label('audits.event', 'projects.create') - ->label('audits.resource', 'project/{response.$id}') - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'create', - description: '/docs/references/projects/create.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', new ProjectId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, and hyphen. Can\'t start with a special char. Max length is 36 chars.') - ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') - ->param('teamId', '', new UID(), 'Team unique ID.') - ->param('region', System::getEnv('_APP_REGION', 'default'), new WhiteList(array_keys(array_filter(Config::getParam('regions'), fn ($config) => !$config['disabled']))), 'Project Region.', true) - ->inject('request') - ->inject('response') - ->inject('dbForPlatform') - ->inject('cache') - ->inject('pools') - ->inject('hooks') - ->callback($this->action(...)); - } - - public function action(string $projectId, string $name, string $teamId, string $region, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks) - { - $team = $dbForPlatform->getDocument('teams', $teamId); - - if ($team->isEmpty()) { - throw new Exception(Exception::TEAM_NOT_FOUND); - } - - $allowList = \array_filter(\explode(',', System::getEnv('_APP_PROJECT_REGIONS', ''))); - - if (!empty($allowList) && !\in_array($region, $allowList)) { - throw new Exception(Exception::PROJECT_REGION_UNSUPPORTED, 'Region "' . $region . '" is not supported'); - } - - $auth = Config::getParam('auth', []); - $auths = [ - 'limit' => 0, - 'maxSessions' => 0, - 'passwordHistory' => 0, - 'passwordDictionary' => false, - 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, - 'personalDataCheck' => false, - 'disposableEmails' => false, - 'canonicalEmails' => false, - 'freeEmails' => false, - 'mockNumbers' => [], - 'sessionAlerts' => false, - 'membershipsUserName' => false, - 'membershipsUserEmail' => false, - 'membershipsMfa' => false, - 'membershipsUserId' => false, - 'membershipsUserPhone' => false, - 'invalidateSessions' => true - ]; - - foreach ($auth as $method) { - $auths[$method['key'] ?? ''] = true; - } - - $projectId = ($projectId == 'unique()') ? ID::unique() : $projectId; - - if ($projectId === 'console') { - throw new Exception(Exception::PROJECT_RESERVED_PROJECT, "'console' is a reserved project."); - } - - $databases = Config::getParam('pools-database', []); - - if ($region !== 'default') { - $databaseKeys = System::getEnv('_APP_DATABASE_KEYS', ''); - $keys = explode(',', $databaseKeys); - $databases = array_filter($keys, function ($value) use ($region) { - return str_contains($value, $region); - }); - } - - $databaseOverride = System::getEnv('_APP_DATABASE_OVERRIDE'); - $index = \array_search($databaseOverride, $databases); - if ($index !== false) { - $dsn = $databases[$index]; - } else { - $dsn = $databases[array_rand($databases)]; - } - - // TODO: Temporary until all projects are using shared tables. - $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - - if (\in_array($dsn, $sharedTables)) { - $schema = 'appwrite'; - $database = 'appwrite'; - $namespace = System::getEnv('_APP_DATABASE_SHARED_NAMESPACE', ''); - $dsn = $schema . '://' . $dsn . '?database=' . $database; - - if (!empty($namespace)) { - $dsn .= '&namespace=' . $namespace; - } - } - - try { - $project = $dbForPlatform->createDocument('projects', new Document([ - '$id' => $projectId, - '$permissions' => $this->getPermissions($teamId, $projectId), - 'name' => $name, - 'teamInternalId' => $team->getSequence(), - 'teamId' => $team->getId(), - 'region' => $region, - 'version' => APP_VERSION_STABLE, - 'services' => new \stdClass(), - 'platforms' => null, - 'oAuthProviders' => [], - 'webhooks' => null, - 'keys' => null, - 'auths' => $auths, - 'accessedAt' => DateTime::now(), - 'search' => implode(' ', [$projectId, $name]), - 'database' => $dsn, - 'labels' => [], - 'status' => PROJECT_STATUS_ACTIVE, - ])); - } catch (Duplicate) { - throw new Exception(Exception::PROJECT_ALREADY_EXISTS); - } - - try { - $dsn = new DSN($dsn); - } catch (\InvalidArgumentException) { - // TODO: Temporary until all projects are using shared tables - $dsn = new DSN('mysql://' . $dsn); - } - - $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - $projectTables = !\in_array($dsn->getHost(), $sharedTables); - - if ($projectTables) { - $adapter = new DatabasePool($pools->get($dsn->getHost())); - $dbForProject = new Database($adapter, $cache); - $dbForProject - ->setDatabase(APP_DATABASE) - ->setSharedTables(false) - ->setTenant(null) - ->setNamespace('_' . $project->getSequence()); - - $create = true; - - try { - $dbForProject->create(); - } catch (Duplicate) { - $create = false; - } - - $adapter = new AdapterDatabase($dbForProject); - $audit = new Audit($adapter); - $audit->setup(); - - if ($create) { - /** @var array $collections */ - $collections = Config::getParam('collections', [])['projects'] ?? []; - - foreach ($collections as $key => $collection) { - if (($collection['$collection'] ?? '') !== Database::METADATA) { - continue; - } - - $attributes = \array_map(fn ($attribute) => new Document($attribute), $collection['attributes']); - $indexes = \array_map(fn (array $index) => new Document($index), $collection['indexes']); - - try { - $dbForProject->createCollection($key, $attributes, $indexes); - } catch (Duplicate) { - // Collection already exists - } - } - } - } - - // Hook allowing instant project mirroring during migration - // Outside of migration, hook is not registered and has no effect - $hooks->trigger('afterProjectCreation', [$project, $pools, $cache]); - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($project, Response::MODEL_PROJECT); - } -} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php deleted file mode 100644 index 29c26b33ea..0000000000 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php +++ /dev/null @@ -1,94 +0,0 @@ -setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/projects/:projectId') - ->desc('Update project') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('audits.event', 'projects.update') - ->label('audits.resource', 'project/{request.projectId}') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'update', - description: '/docs/references/projects/update.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') - ->param('description', '', new Text(256), 'Project description. Max length: 256 chars.', true) - ->param('logo', '', new Text(1024), 'Project logo.', true) - ->param('url', '', new URL(), 'Project URL.', true) - ->param('legalName', '', new Text(256), 'Project legal name. Max length: 256 chars.', true) - ->param('legalCountry', '', new Text(256), 'Project legal country. Max length: 256 chars.', true) - ->param('legalState', '', new Text(256), 'Project legal state. Max length: 256 chars.', true) - ->param('legalCity', '', new Text(256), 'Project legal city. Max length: 256 chars.', true) - ->param('legalAddress', '', new Text(256), 'Project legal address. Max length: 256 chars.', true) - ->param('legalTaxId', '', new Text(256), 'Project legal tax ID. Max length: 256 chars.', true) - ->inject('response') - ->inject('dbForPlatform') - ->callback($this->action(...)); - } - - public function action(string $projectId, string $name, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, Response $response, Database $dbForPlatform) - { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('name', $name) - ->setAttribute('description', $description) - ->setAttribute('logo', $logo) - ->setAttribute('url', $url) - ->setAttribute('legalName', $legalName) - ->setAttribute('legalCountry', $legalCountry) - ->setAttribute('legalState', $legalState) - ->setAttribute('legalCity', $legalCity) - ->setAttribute('legalAddress', $legalAddress) - ->setAttribute('legalTaxId', $legalTaxId) - ->setAttribute('search', implode(' ', [$projectId, $name]))); - - $response->dynamic($project, Response::MODEL_PROJECT); - } -} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php deleted file mode 100644 index 0d2a951388..0000000000 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php +++ /dev/null @@ -1,197 +0,0 @@ -setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/projects') - ->desc('List projects') - ->groups(['api', 'projects']) - ->label('scope', 'projects.read') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'list', - description: <<param('queries', [], $this->getQueriesValidator(), '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(', ', Projects::ALLOWED_ATTRIBUTES), true) - ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', 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') - ->inject('team') - ->callback($this->action(...)); - } - - public function action(array $queries, string $search, bool $includeTotal, Response $response, Database $dbForPlatform, Document $team) - { - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - if (!empty($search)) { - $queries[] = Query::search('search', $search); - } - - if (!$team->isEmpty()) { - $queries[] = Query::equal('teamInternalId', [$team->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()); - } - - $projectId = $cursor->getValue(); - $cursorDocument = $dbForPlatform->getDocument('projects', $projectId); - - if ($cursorDocument->isEmpty()) { - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Project '{$projectId}' for the 'cursor' value not found."); - } - - $cursor->setValue($cursorDocument); - } - - try { - $selectQueries = Query::groupByType($queries)['selections']; - $filterQueries = Query::groupByType($queries)['filters']; - - $projects = $this->find($dbForPlatform, $queries, $selectQueries); - $total = $includeTotal ? $dbForPlatform->count('projects', $filterQueries, APP_LIMIT_COUNT) : 0; - } catch (Order $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."); - } - - $response->addFilter(new ListSelection($selectQueries, 'projects')); - - $response->dynamic(new Document([ - 'projects' => $projects, - 'total' => $total, - ]), Response::MODEL_PROJECT_LIST); - } - - // Build mapping of columns to their subQuery filters - private static function getAttributeToSubQueryFilters(): array - { - if (self::$attributeToSubQueryFilters !== null) { - return self::$attributeToSubQueryFilters; - } - - self::$attributeToSubQueryFilters = []; - - $collections = Config::getParam('collections', []); - $projectAttributes = $collections['platform']['projects']['attributes'] ?? []; - - foreach ($projectAttributes as $attribute) { - $attributeId = $attribute['$id'] ?? null; - $filters = $attribute['filters'] ?? []; - - if ($attributeId === null || empty($filters)) { - continue; - } - - // extract only subQuery filters - $subQueryFilters = \array_filter($filters, function ($filter) { - return \str_starts_with($filter, 'subQuery'); - }); - - if (!empty($subQueryFilters)) { - self::$attributeToSubQueryFilters[$attributeId] = \array_values($subQueryFilters); - } - } - - return self::$attributeToSubQueryFilters; - } - - private function find(Database $dbForPlatform, array $queries, array $selectQueries): array - { - if (empty($selectQueries)) { - return $dbForPlatform->find('projects', $queries); - } - - $selectedAttributes = []; - foreach ($selectQueries as $query) { - foreach ($query->getValues() as $value) { - $selectedAttributes[] = $value; - } - } - - if (\in_array('*', $selectedAttributes)) { - return $dbForPlatform->find('projects', $queries); - } - - $filtersToSkipMap = []; - $selectedAttributesMap = \array_flip($selectedAttributes); - $attributeToSubQueryFilters = self::getAttributeToSubQueryFilters(); - - foreach ($attributeToSubQueryFilters as $attributeName => $subQueryFilters) { - if (!isset($selectedAttributesMap[$attributeName])) { - foreach ($subQueryFilters as $filter) { - $filtersToSkipMap[$filter] = true; - } - } - } - - $filtersToSkip = \array_keys($filtersToSkipMap); - - return empty($filtersToSkip) - ? $dbForPlatform->find('projects', $queries) - : $dbForPlatform->skipFilters(fn () => $dbForPlatform->find('projects', $queries), $filtersToSkip); - } -} diff --git a/src/Appwrite/Platform/Modules/Projects/Services/Http.php b/src/Appwrite/Platform/Modules/Projects/Services/Http.php index 8275e664d5..b1662cef31 100644 --- a/src/Appwrite/Platform/Modules/Projects/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Projects/Services/Http.php @@ -7,10 +7,7 @@ use Appwrite\Platform\Modules\Projects\Http\DevKeys\Delete as DeleteDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\Get as GetDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\Update as UpdateDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\XList as ListDevKeys; -use Appwrite\Platform\Modules\Projects\Http\Projects\Create as CreateProject; use Appwrite\Platform\Modules\Projects\Http\Projects\Team\Update as UpdateProjectTeam; -use Appwrite\Platform\Modules\Projects\Http\Projects\Update as UpdateProject; -use Appwrite\Platform\Modules\Projects\Http\Projects\XList as ListProjects; use Appwrite\Platform\Modules\Projects\Http\Schedules\Create as CreateSchedule; use Appwrite\Platform\Modules\Projects\Http\Schedules\Get as GetSchedule; use Appwrite\Platform\Modules\Projects\Http\Schedules\XList as ListSchedules; @@ -27,9 +24,6 @@ class Http extends Service $this->addAction(ListDevKeys::getName(), new ListDevKeys()); $this->addAction(DeleteDevKey::getName(), new DeleteDevKey()); - $this->addAction(CreateProject::getName(), new CreateProject()); - $this->addAction(UpdateProject::getName(), new UpdateProject()); - $this->addAction(ListProjects::getName(), new ListProjects()); $this->addAction(UpdateProjectTeam::getName(), new UpdateProjectTeam()); $this->addAction(CreateSchedule::getName(), new CreateSchedule()); From b524b9f9340caa35f2bc337a9ba573df0d0a134b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 14 May 2026 12:18:09 +0200 Subject: [PATCH 03/34] Fix scopes --- app/config/roles.php | 2 ++ app/config/scopes/organization.php | 4 ++-- .../Platform/Modules/Organization/Http/Projects/Create.php | 2 +- .../Platform/Modules/Organization/Http/Projects/Get.php | 2 +- .../Platform/Modules/Organization/Http/Projects/Update.php | 2 +- .../Platform/Modules/Organization/Http/Projects/XList.php | 2 +- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/config/roles.php b/app/config/roles.php index cb4b178a29..fd3b9d887d 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -19,6 +19,7 @@ $member = [ 'files.read', 'files.write', 'projects.read', + 'organization.projects.read', 'locale.read', 'avatars.read', 'executions.read', @@ -64,6 +65,7 @@ $admins = [ 'templates.read', 'templates.write', 'projects.write', + 'organization.projects.write', 'keys.read', 'keys.write', 'devKeys.read', diff --git a/app/config/scopes/organization.php b/app/config/scopes/organization.php index 228a1437f2..f531975353 100644 --- a/app/config/scopes/organization.php +++ b/app/config/scopes/organization.php @@ -3,10 +3,10 @@ // List of scopes for organization (teams) API keys return [ - "projects.read" => [ + "organization.projects.read" => [ "description" => 'Access to read organization\'s projects', ], - "projects.write" => [ + "organization.projects.write" => [ "description" => "Access to create, update, and delete projects in organization", ], diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php index aebf228380..f7cc3c4203 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php @@ -47,7 +47,7 @@ class Create extends Action ->groups(['api', 'projects']) ->label('audits.event', 'projects.create') ->label('audits.resource', 'project/{response.$id}') - ->label('scope', 'projects.write') + ->label('scope', 'organization.projects.write') ->label('sdk', new Method( namespace: 'projects', group: 'projects', diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php index 012f223ae5..333d8711d6 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php @@ -28,7 +28,7 @@ class Get extends Action ->setHttpPath('/v1/organizations/:organizationId/projects/:projectId') ->desc('Get project') ->groups(['api', 'project']) - ->label('scope', 'projects.read') + ->label('scope', 'organization.projects.read') ->label('sdk', new Method( namespace: 'project', group: null, diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php index 36009a9512..a21921413e 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php @@ -31,7 +31,7 @@ class Update extends Action ->httpAlias('/v1/projects/:projectId') ->desc('Update project') ->groups(['api', 'projects']) - ->label('scope', 'projects.write') + ->label('scope', 'organization.projects.write') ->label('audits.event', 'projects.update') ->label('audits.resource', 'project/{request.projectId}') ->label('sdk', new Method( diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php index 837cbff9e5..646b6ec968 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php @@ -48,7 +48,7 @@ class XList extends Action ->httpAlias('/v1/projects') ->desc('List projects') ->groups(['api', 'projects']) - ->label('scope', 'projects.read') + ->label('scope', 'organization.projects.read') ->label('sdk', new Method( namespace: 'projects', group: 'projects', From 8a4a6d83d402fa8d33a9eb1e0927e2b7008e3ee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 14 May 2026 13:06:39 +0200 Subject: [PATCH 04/34] Manual migration fixes --- .../Modules/Organization/Http/Init.php | 28 ++++++++++ .../Organization/Http/Projects/Create.php | 36 ++++--------- .../Organization/Http/Projects/Get.php | 23 ++++----- .../Organization/Http/Projects/Update.php | 51 ++++--------------- .../Organization/Http/Projects/XList.php | 29 +++-------- .../Modules/Organization/Services/Http.php | 5 ++ 6 files changed, 70 insertions(+), 102 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Organization/Http/Init.php diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Init.php b/src/Appwrite/Platform/Modules/Organization/Http/Init.php new file mode 100644 index 0000000000..56eb6db3a0 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Organization/Http/Init.php @@ -0,0 +1,28 @@ +setType(Action::TYPE_INIT) + ->groups(['organization']) + ->inject('team') + ->callback(function (Document $team) { + if ($team->isEmpty()) { + throw new Exception(Exception::TEAM_NOT_FOUND); + } + }); + } +} diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php index f7cc3c4203..5f4575a300 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php @@ -20,7 +20,6 @@ use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate; use Utopia\Database\Helpers\ID; -use Utopia\Database\Validator\UID; use Utopia\DSN\DSN; use Utopia\Platform\Scope\HTTP; use Utopia\Pools\Group; @@ -41,19 +40,21 @@ class Create extends Action { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) - ->setHttpPath('/v1/organizations/:organizationId/projects') + ->setHttpPath('/v1/organization/projects') ->httpAlias('/v1/projects') - ->desc('Create project') - ->groups(['api', 'projects']) + ->desc('Create organization project') + ->groups(['api', 'organization']) ->label('audits.event', 'projects.create') ->label('audits.resource', 'project/{response.$id}') ->label('scope', 'organization.projects.write') ->label('sdk', new Method( - namespace: 'projects', + namespace: 'organization', group: 'projects', - name: 'create', - description: '/docs/references/projects/create.md', - auth: [AuthType::ADMIN], + name: 'createProject', + description: <<param('organizationId', '', new UID(), 'Organization unique ID.') ->param('projectId', '', new ProjectId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, and hyphen. Can\'t start with a special char. Max length is 36 chars.') ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') ->param('region', System::getEnv('_APP_REGION', 'default'), new WhiteList(array_keys(array_filter(Config::getParam('regions'), fn ($config) => !$config['disabled']))), 'Project Region.', true) - ->inject('request') ->inject('response') ->inject('dbForPlatform') ->inject('cache') @@ -75,21 +74,8 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $organizationId, string $projectId, string $name, string $region, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks, Document $team) + public function action(string $projectId, string $name, string $region, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks, Document $team) { - if (empty($organizationId)) { - if ($team->isEmpty()) { - throw new Exception(Exception::TEAM_NOT_FOUND); - } - $organizationId = $team->getId(); - } - - $team = $dbForPlatform->getDocument('teams', $organizationId); - - if ($team->isEmpty()) { - throw new Exception(Exception::TEAM_NOT_FOUND); - } - $allowList = \array_filter(\explode(',', System::getEnv('_APP_PROJECT_REGIONS', ''))); if (!empty($allowList) && !\in_array($region, $allowList)) { @@ -162,7 +148,7 @@ class Create extends Action try { $project = $dbForPlatform->createDocument('projects', new Document([ '$id' => $projectId, - '$permissions' => $this->getPermissions($organizationId, $projectId), + '$permissions' => $this->getPermissions($team->getId(), $projectId), 'name' => $name, 'teamInternalId' => $team->getSequence(), 'teamId' => $team->getId(), diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php index 333d8711d6..d75356704d 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php @@ -9,6 +9,7 @@ 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\UID; use Utopia\Platform\Scope\HTTP; @@ -25,14 +26,14 @@ class Get extends Action { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/organizations/:organizationId/projects/:projectId') - ->desc('Get project') - ->groups(['api', 'project']) + ->setHttpPath('/v1/organization/projects/:projectId') + ->desc('Get organization project') + ->groups(['api', 'organization']) ->label('scope', 'organization.projects.read') ->label('sdk', new Method( - namespace: 'project', - group: null, - name: 'get', + namespace: 'organization', + group: 'projects', + name: 'getProject', description: <<param('organizationId', '', new UID(), 'Organization unique ID.') ->param('projectId', '', new UID(), 'Project unique ID.') ->inject('response') ->inject('dbForPlatform') + ->inject('team') ->callback($this->action(...)); } public function action( - string $organizationId, string $projectId, Response $response, Database $dbForPlatform, + Document $team, ) { - $team = $dbForPlatform->getDocument('teams', $organizationId); - - if ($team->isEmpty()) { - throw new Exception(Exception::TEAM_NOT_FOUND); - } - $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php index a21921413e..1cb70a6aaf 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php @@ -12,7 +12,6 @@ use Utopia\Database\Document; use Utopia\Database\Validator\UID; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Text; -use Utopia\Validator\URL; class Update extends Action { @@ -27,19 +26,21 @@ class Update extends Action { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/organizations/:organizationId/projects/:projectId') + ->setHttpPath('/v1/organization/projects/:projectId') ->httpAlias('/v1/projects/:projectId') - ->desc('Update project') - ->groups(['api', 'projects']) + ->desc('Update organization project') + ->groups(['api', 'organization']) ->label('scope', 'organization.projects.write') ->label('audits.event', 'projects.update') ->label('audits.resource', 'project/{request.projectId}') ->label('sdk', new Method( - namespace: 'projects', + namespace: 'organization', group: 'projects', - name: 'update', - description: '/docs/references/projects/update.md', - auth: [AuthType::ADMIN], + name: 'updateProject', + description: <<param('organizationId', '', new UID(), 'Organization unique ID.') ->param('projectId', '', new UID(), 'Project unique ID.') ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') - ->param('description', '', new Text(256), 'Project description. Max length: 256 chars.', true) - ->param('logo', '', new Text(1024), 'Project logo.', true) - ->param('url', '', new URL(), 'Project URL.', true) - ->param('legalName', '', new Text(256), 'Project legal name. Max length: 256 chars.', true) - ->param('legalCountry', '', new Text(256), 'Project legal country. Max length: 256 chars.', true) - ->param('legalState', '', new Text(256), 'Project legal state. Max length: 256 chars.', true) - ->param('legalCity', '', new Text(256), 'Project legal city. Max length: 256 chars.', true) - ->param('legalAddress', '', new Text(256), 'Project legal address. Max length: 256 chars.', true) - ->param('legalTaxId', '', new Text(256), 'Project legal tax ID. Max length: 256 chars.', true) ->inject('response') ->inject('dbForPlatform') ->inject('team') ->callback($this->action(...)); } - public function action(string $organizationId, string $projectId, string $name, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, Response $response, Database $dbForPlatform, Document $team) + public function action(string $projectId, string $name, Response $response, Database $dbForPlatform, Document $team) { - if (empty($organizationId)) { - if ($team->isEmpty()) { - throw new Exception(Exception::TEAM_NOT_FOUND); - } - $organizationId = $team->getId(); - } - - $team = $dbForPlatform->getDocument('teams', $organizationId); - - if ($team->isEmpty()) { - throw new Exception(Exception::TEAM_NOT_FOUND); - } - $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -92,15 +70,6 @@ class Update extends Action $project = $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ 'name' => $name, - 'description' => $description, - 'logo' => $logo, - 'url' => $url, - 'legalName' => $legalName, - 'legalCountry' => $legalCountry, - 'legalState' => $legalState, - 'legalCity' => $legalCity, - 'legalAddress' => $legalAddress, - 'legalTaxId' => $legalTaxId, 'search' => implode(' ', [$projectId, $name]), ])); diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php index 646b6ec968..2c2b480ef5 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php @@ -17,7 +17,6 @@ use Utopia\Database\Exception\Order; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; use Utopia\Database\Validator\Query\Cursor; -use Utopia\Database\Validator\UID; use Utopia\Platform\Scope\HTTP; use Utopia\Validator; use Utopia\Validator\Boolean; @@ -44,19 +43,19 @@ class XList extends Action { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/organizations/:organizationId/projects') + ->setHttpPath('/v1/organization/projects') ->httpAlias('/v1/projects') - ->desc('List projects') - ->groups(['api', 'projects']) + ->desc('List organization projects') + ->groups(['api', 'organization']) ->label('scope', 'organization.projects.read') ->label('sdk', new Method( - namespace: 'projects', + namespace: 'organization', group: 'projects', - name: 'list', + name: 'listProjects', description: <<param('organizationId', '', new UID(), 'Organization unique ID.') ->param('queries', [], $this->getQueriesValidator(), '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(', ', Projects::ALLOWED_ATTRIBUTES), true) ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) @@ -75,21 +73,8 @@ class XList extends Action ->callback($this->action(...)); } - public function action(string $organizationId, array $queries, string $search, bool $includeTotal, Response $response, Database $dbForPlatform, Document $team) + public function action(array $queries, string $search, bool $includeTotal, Response $response, Database $dbForPlatform, Document $team) { - if (empty($organizationId)) { - if ($team->isEmpty()) { - throw new Exception(Exception::TEAM_NOT_FOUND); - } - $organizationId = $team->getId(); - } - - $team = $dbForPlatform->getDocument('teams', $organizationId); - - if ($team->isEmpty()) { - throw new Exception(Exception::TEAM_NOT_FOUND); - } - try { $queries = Query::parseQueries($queries); } catch (QueryException $e) { diff --git a/src/Appwrite/Platform/Modules/Organization/Services/Http.php b/src/Appwrite/Platform/Modules/Organization/Services/Http.php index 030bff9fac..4a7cd69220 100644 --- a/src/Appwrite/Platform/Modules/Organization/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Organization/Services/Http.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Organization\Services; +use Appwrite\Platform\Modules\Organization\Http\Init as Init; use Appwrite\Platform\Modules\Organization\Http\Projects\Create as CreateProject; use Appwrite\Platform\Modules\Organization\Http\Projects\Get as GetProject; use Appwrite\Platform\Modules\Organization\Http\Projects\Update as UpdateProject; @@ -14,6 +15,10 @@ class Http extends Service { $this->type = Service::TYPE_HTTP; + // Init hook + $this->addAction(Init::getName(), new Init()); + + // Projects $this->addAction(CreateProject::getName(), new CreateProject()); $this->addAction(ListProjects::getName(), new ListProjects()); $this->addAction(GetProject::getName(), new GetProject()); From 94274381ff9b36db54474fccf77977e919b7a949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 14 May 2026 13:52:25 +0200 Subject: [PATCH 05/34] agentic migration fixes --- .../Modules/Organization/Http/Projects/Create.php | 4 +++- .../Modules/Organization/Http/Projects/Get.php | 4 +++- .../Modules/Organization/Http/Projects/Update.php | 10 +++++++--- .../Modules/Organization/Http/Projects/XList.php | 10 ++++++---- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php index 5f4575a300..9962289343 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php @@ -5,6 +5,7 @@ namespace Appwrite\Platform\Modules\Organization\Http\Projects; use Appwrite\Extend\Exception; use Appwrite\Hooks\Hooks; use Appwrite\SDK\AuthType; +use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Database\Validator\ProjectId; @@ -60,7 +61,8 @@ class Create extends Action code: Response::STATUS_CODE_CREATED, model: Response::MODEL_PROJECT, ) - ] + ], + contentType: ContentType::JSON )) ->param('projectId', '', new ProjectId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, and hyphen. Can\'t start with a special char. Max length is 36 chars.') ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php index d75356704d..a85eb8fb15 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php @@ -69,6 +69,8 @@ class Get extends Action throw new Exception(Exception::PROJECT_NOT_FOUND); } - $response->dynamic($project, Response::MODEL_PROJECT); + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php index 1cb70a6aaf..9415374044 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php @@ -4,6 +4,7 @@ namespace Appwrite\Platform\Modules\Organization\Http\Projects; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; +use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; @@ -32,7 +33,7 @@ class Update extends Action ->groups(['api', 'organization']) ->label('scope', 'organization.projects.write') ->label('audits.event', 'projects.update') - ->label('audits.resource', 'project/{request.projectId}') + ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( namespace: 'organization', group: 'projects', @@ -46,7 +47,8 @@ class Update extends Action code: Response::STATUS_CODE_OK, model: Response::MODEL_PROJECT, ) - ] + ], + contentType: ContentType::JSON )) ->param('projectId', '', new UID(), 'Project unique ID.') ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') @@ -73,6 +75,8 @@ class Update extends Action 'search' => implode(' ', [$projectId, $name]), ])); - $response->dynamic($project, Response::MODEL_PROJECT); + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php index 2c2b480ef5..6f04222a69 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php @@ -118,10 +118,12 @@ class XList extends Action $response->addFilter(new ListSelection($selectQueries, 'projects')); - $response->dynamic(new Document([ - 'projects' => $projects, - 'total' => $total, - ]), Response::MODEL_PROJECT_LIST); + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document([ + 'projects' => $projects, + 'total' => $total, + ]), Response::MODEL_PROJECT_LIST); } // Build mapping of columns to their subQuery filters From aaa870e65637f816a46d5b44f23741e1d9468c3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 14 May 2026 13:52:34 +0200 Subject: [PATCH 06/34] Formatting fixes --- .../Platform/Modules/Organization/Http/Projects/Create.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php index 9962289343..d6f12c4f41 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php @@ -9,7 +9,6 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Database\Validator\ProjectId; -use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use Utopia\Audit\Adapter\Database as AdapterDatabase; use Utopia\Audit\Audit; From b618bf13538fb975c5da10aaa17c5601504c2acf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 14 May 2026 17:26:08 +0200 Subject: [PATCH 07/34] Fix inability to CRUD projects --- app/init/resources/request.php | 57 ++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 68f5968519..68a8af3605 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -1,5 +1,6 @@ set('team', function (Document $project, Database $dbForPlatform, Http $utopia, Request $request, Authorization $authorization) { + $context->set('team', function (Document $project, Database $dbForPlatform, Http $utopia, Request $request, Authorization $authorization, Document $user) { $teamInternalId = ''; if ($project->getId() !== 'console') { $teamInternalId = $project->getAttribute('teamInternalId', ''); @@ -1100,7 +1103,55 @@ return function (Container $context): void { $route = $utopia->match($request); $path = ! empty($route) ? $route->getPath() : $request->getURI(); $orgHeader = $request->getHeader('x-appwrite-organization', ''); - if (str_starts_with($path, '/v1/projects/:projectId')) { + + // Backwards compatibility: /v1/organitation/projects acting as /v1/projects + if (\str_starts_with($path, '/v1/organization/projects')) { + // Backwards compatibility: Take from payload param + $teamId = $request->getParam('organizationId', $request->getParam('teamId', '')); + + // Backawrds compatibility: Get from URL param project ID + if (empty($teamId)) { + $projectId = \explode('/', $request->getURI())[4] ?? ''; + if (!empty($projectId)) { + $urlProject = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId)); + $teamId = $urlProject->getAttribute('teamId', ''); + } + } + + // Backwards compatibility: Get from queries param + if (empty($teamId)) { + $queries = $request->getParam('queries', []); + $queries = \is_array($queries) ? $queries : [$queries]; + + try { + $queries = Query::parseQueries($queries); + $queries = Query::getByType($queries, [Query::TYPE_EQUAL]); + + foreach ($queries as $query) { + if ($query->getAttribute() === 'teamId') { + $teamId = $query->getValues()[0] ?? ''; + break; + } + } + } catch (QueryException $e) { + // Ignore, do not parse from queries + } + } + + // Backwards compatibility: We cannot have organization project API call without organitation, take first one + if (empty($teamId)) { + if (!$user->isEmpty()) { + $teamId = ($user->getAttribute('memberships', [])[0] ?? new Document())->getAttribute('teamId', ''); + } + } + + if (!empty($teamId)) { + $team = $authorization->skip(function () use ($dbForPlatform, $teamId) { + return $dbForPlatform->getDocument('teams', $teamId); + }); + return $team; + } + } elseif (str_starts_with($path, '/v1/projects/:projectId')) { $uri = $request->getURI(); $pid = explode('/', $uri)[3]; $p = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $pid)); @@ -1133,7 +1184,7 @@ return function (Container $context): void { }); return $team; - }, ['project', 'dbForPlatform', 'utopia', 'request', 'authorization']); + }, ['project', 'dbForPlatform', 'utopia', 'request', 'authorization', 'user']); $context->set('previewHostname', function (Request $request, ?Key $apiKey) { $allowed = false; From 2a82604a2b8a20a6f6143ebc40acbeb641acdb57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 14 May 2026 17:36:03 +0200 Subject: [PATCH 08/34] Better backwards compatibility for project creation --- .../Platform/Modules/Organization/Http/Projects/Create.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php index d6f12c4f41..28f74302e2 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php @@ -20,6 +20,7 @@ use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate; use Utopia\Database\Helpers\ID; +use Utopia\Database\Validator\UID; use Utopia\DSN\DSN; use Utopia\Platform\Scope\HTTP; use Utopia\Pools\Group; @@ -66,6 +67,7 @@ class Create extends Action ->param('projectId', '', new ProjectId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, and hyphen. Can\'t start with a special char. Max length is 36 chars.') ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') ->param('region', System::getEnv('_APP_REGION', 'default'), new WhiteList(array_keys(array_filter(Config::getParam('regions'), fn ($config) => !$config['disabled']))), 'Project Region.', true) + ->param('teamId', '', new UID(), 'Team unique ID.', deprecated: true) // Backwards compatibility ->inject('response') ->inject('dbForPlatform') ->inject('cache') @@ -75,7 +77,8 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $projectId, string $name, string $region, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks, Document $team) + // teamId intentionally unused; used by resource for backwards compatibility + public function action(string $projectId, string $name, string $region, string $teamId, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks, Document $team) { $allowList = \array_filter(\explode(',', System::getEnv('_APP_PROJECT_REGIONS', ''))); From 18380a679dc72405b62974bee3c7b35bda08dc04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 14 May 2026 17:46:13 +0200 Subject: [PATCH 09/34] Fix backwards compatibility --- app/config/roles.php | 2 -- app/config/scopes/organization.php | 14 ++++++++++---- .../Modules/Organization/Http/Projects/Create.php | 2 +- .../Modules/Organization/Http/Projects/Get.php | 2 +- .../Modules/Organization/Http/Projects/Update.php | 2 +- .../Modules/Organization/Http/Projects/XList.php | 2 +- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/app/config/roles.php b/app/config/roles.php index fd3b9d887d..cb4b178a29 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -19,7 +19,6 @@ $member = [ 'files.read', 'files.write', 'projects.read', - 'organization.projects.read', 'locale.read', 'avatars.read', 'executions.read', @@ -65,7 +64,6 @@ $admins = [ 'templates.read', 'templates.write', 'projects.write', - 'organization.projects.write', 'keys.read', 'keys.write', 'devKeys.read', diff --git a/app/config/scopes/organization.php b/app/config/scopes/organization.php index f531975353..0486d02c8d 100644 --- a/app/config/scopes/organization.php +++ b/app/config/scopes/organization.php @@ -3,18 +3,24 @@ // List of scopes for organization (teams) API keys return [ - "organization.projects.read" => [ - "description" => 'Access to read organization\'s projects', + "projects.read" => [ + "description" => 'Access to read organization projects.', + 'category' => 'Projects' ], - "organization.projects.write" => [ + "projects.write" => [ "description" => - "Access to create, update, and delete projects in organization", + "Access to create, update, and delete organization projects.", + 'category' => 'Projects' ], "devKeys.read" => [ "description" => 'Access to read project\'s development keys', + 'deprecated' => true, + 'category' => 'Other' ], "devKeys.write" => [ "description" => "Access to create, update, and delete project\'s development keys", + 'deprecated' => true, + 'category' => 'Other' ], ]; diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php index 28f74302e2..c84fa80381 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php @@ -47,7 +47,7 @@ class Create extends Action ->groups(['api', 'organization']) ->label('audits.event', 'projects.create') ->label('audits.resource', 'project/{response.$id}') - ->label('scope', 'organization.projects.write') + ->label('scope', 'projects.write') ->label('sdk', new Method( namespace: 'organization', group: 'projects', diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php index a85eb8fb15..37f2dd417a 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php @@ -29,7 +29,7 @@ class Get extends Action ->setHttpPath('/v1/organization/projects/:projectId') ->desc('Get organization project') ->groups(['api', 'organization']) - ->label('scope', 'organization.projects.read') + ->label('scope', 'projects.read') ->label('sdk', new Method( namespace: 'organization', group: 'projects', diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php index 9415374044..9188060639 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php @@ -31,7 +31,7 @@ class Update extends Action ->httpAlias('/v1/projects/:projectId') ->desc('Update organization project') ->groups(['api', 'organization']) - ->label('scope', 'organization.projects.write') + ->label('scope', 'projects.write') ->label('audits.event', 'projects.update') ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php index 6f04222a69..5b223904d6 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php @@ -47,7 +47,7 @@ class XList extends Action ->httpAlias('/v1/projects') ->desc('List organization projects') ->groups(['api', 'organization']) - ->label('scope', 'organization.projects.read') + ->label('scope', 'projects.read') ->label('sdk', new Method( namespace: 'organization', group: 'projects', From f37e091b770be141e1728dafa60aee45f3f276e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 14 May 2026 17:50:36 +0200 Subject: [PATCH 10/34] Fix specs generation --- app/init/models.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/init/models.php b/app/init/models.php index 521a3b77cd..b63051514c 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -263,7 +263,7 @@ Response::setModel(new BaseList('Frameworks List', Response::MODEL_FRAMEWORK_LIS Response::setModel(new BaseList('Runtimes List', Response::MODEL_RUNTIME_LIST, 'runtimes', Response::MODEL_RUNTIME)); Response::setModel(new BaseList('Deployments List', Response::MODEL_DEPLOYMENT_LIST, 'deployments', Response::MODEL_DEPLOYMENT)); Response::setModel(new BaseList('Executions List', Response::MODEL_EXECUTION_LIST, 'executions', Response::MODEL_EXECUTION)); -Response::setModel(new BaseList('Projects List', Response::MODEL_PROJECT_LIST, 'projects', Response::MODEL_PROJECT, true, false)); +Response::setModel(new BaseList('Projects List', Response::MODEL_PROJECT_LIST, 'projects', Response::MODEL_PROJECT, true, true)); Response::setModel(new BaseList('Webhooks List', Response::MODEL_WEBHOOK_LIST, 'webhooks', Response::MODEL_WEBHOOK, true, true)); Response::setModel(new BaseList('API Keys List', Response::MODEL_KEY_LIST, 'keys', Response::MODEL_KEY, true, true)); Response::setModel(new BaseList('Dev Keys List', Response::MODEL_DEV_KEY_LIST, 'devKeys', Response::MODEL_DEV_KEY, true, false)); From bb25a36cf555a90da5caac4f56567e5fac8cd169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 14 May 2026 17:53:03 +0200 Subject: [PATCH 11/34] Fix project creation 401 expectation tests --- app/init/resources/request.php | 7 ------- .../Platform/Modules/Organization/Http/Projects/Create.php | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 68a8af3605..6db371b8c3 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -1138,13 +1138,6 @@ return function (Container $context): void { } } - // Backwards compatibility: We cannot have organization project API call without organitation, take first one - if (empty($teamId)) { - if (!$user->isEmpty()) { - $teamId = ($user->getAttribute('memberships', [])[0] ?? new Document())->getAttribute('teamId', ''); - } - } - if (!empty($teamId)) { $team = $authorization->skip(function () use ($dbForPlatform, $teamId) { return $dbForPlatform->getDocument('teams', $teamId); diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php index c84fa80381..88a17cdd7a 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php @@ -67,7 +67,7 @@ class Create extends Action ->param('projectId', '', new ProjectId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, and hyphen. Can\'t start with a special char. Max length is 36 chars.') ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') ->param('region', System::getEnv('_APP_REGION', 'default'), new WhiteList(array_keys(array_filter(Config::getParam('regions'), fn ($config) => !$config['disabled']))), 'Project Region.', true) - ->param('teamId', '', new UID(), 'Team unique ID.', deprecated: true) // Backwards compatibility + ->param('teamId', '', new UID(), 'Team unique ID.', deprecated: true, optional: true) // Backwards compatibility ->inject('response') ->inject('dbForPlatform') ->inject('cache') From 176d59e7c9ec7bc1c01e95d4936613efd1e27cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 14 May 2026 18:02:20 +0200 Subject: [PATCH 12/34] Fix broken test after breaking change List ALL projects no longer exists - we now have list project per organization --- .../Services/Projects/ProjectsConsoleClientTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index aa5e6911f1..632636370d 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -354,6 +354,16 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-response-format' => '1.9.4', ], $this->getHeaders())); + $this->assertEquals(404, $response['headers']['status-code']); + + + $response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $data['teamId'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertGreaterThan(0, count($response['body']['projects'])); @@ -564,6 +574,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::select(['$id', 'name'])->toString(), @@ -7041,6 +7052,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::contains('labels', ['nonvip'])->toString(), @@ -7273,6 +7285,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, + 'x-appwrite-organization' => $teamId, ]); $this->assertEquals(200, $response['headers']['status-code']); From f40c7d415cb63d172ab23bf34d181a5a07b8b943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 14 May 2026 18:26:47 +0200 Subject: [PATCH 13/34] Prioritize org id header --- app/init/resources/request.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 6db371b8c3..e1409d6412 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -1104,6 +1104,11 @@ return function (Container $context): void { $path = ! empty($route) ? $route->getPath() : $request->getURI(); $orgHeader = $request->getHeader('x-appwrite-organization', ''); + // Prioritx #1: If org ID header present, respect it + if (!empty($orgHeader)) { + return $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $orgHeader)); + } + // Backwards compatibility: /v1/organitation/projects acting as /v1/projects if (\str_starts_with($path, '/v1/organization/projects')) { // Backwards compatibility: Take from payload param @@ -1159,8 +1164,6 @@ return function (Container $context): void { $team = $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $teamId)); return $team; - } elseif (! empty($orgHeader)) { - return $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $orgHeader)); } } From 3596662fec7121401d4043f2fa09b72567a14a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 14 May 2026 18:42:30 +0200 Subject: [PATCH 14/34] Update more tests --- .../Services/Projects/ProjectsConsoleClientTest.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 632636370d..74d7dfcae2 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -375,6 +375,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $data['teamId'], ], $this->getHeaders(), [ 'search' => $id ])); @@ -387,6 +388,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $data['teamId'], ], $this->getHeaders(), [ 'search' => 'Project Test' ])); @@ -604,7 +606,8 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.4' + 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $data['teamId'], ], $this->getHeaders()), [ 'queries' => [ Query::select(['$id', 'name', 'teamId', 'description', '$createdAt', '$updatedAt'])->toString(), @@ -7066,6 +7069,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::contains('labels', ['vip'])->toString(), @@ -7078,6 +7082,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::contains('labels', ['imagine'])->toString(), @@ -7091,6 +7096,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::contains('labels', ['nonvip', 'imagine'])->toString(), @@ -7105,6 +7111,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'projectId' => ID::unique(), 'name' => 'Test project - Labels 2', @@ -7134,6 +7141,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::contains('labels', ['imagine'])->toString(), @@ -7149,6 +7157,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::contains('labels', ['vip'])->toString(), @@ -7163,6 +7172,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::contains('labels', ['vip'])->toString(), @@ -7178,6 +7188,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::contains('labels', ['vip', 'imagine'])->toString(), From d7e29a4daeab79c2761df2953e3665e1e8bbbb52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 14 May 2026 19:03:14 +0200 Subject: [PATCH 15/34] fix more tests --- .../Projects/ProjectsConsoleClientTest.php | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 74d7dfcae2..90546e1569 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -435,6 +435,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $team['body']['$id'], ], $this->getHeaders()), [ 'queries' => [ Query::equal('teamId', [$team['body']['$id']])->toString(), @@ -450,6 +451,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $team['body']['$id'], ], $this->getHeaders()), [ 'queries' => [ Query::limit(1)->toString(), @@ -464,6 +466,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $team['body']['$id'], ], $this->getHeaders()), [ 'queries' => [ Query::offset(1)->toString(), @@ -477,6 +480,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $team['body']['$id'], ], $this->getHeaders()), [ 'queries' => [ Query::equal('name', ['Project Test 2'])->toString(), @@ -492,6 +496,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $team['body']['$id'], ], $this->getHeaders()), [ 'queries' => [ Query::orderDesc()->toString(), @@ -506,6 +511,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $team['body']['$id'], ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); @@ -516,6 +522,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $team['body']['$id'], ], $this->getHeaders()), [ 'queries' => [ Query::cursorAfter(new Document(['$id' => $response['body']['projects'][0]['$id']]))->toString(), @@ -532,6 +539,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $team['body']['$id'], ], $this->getHeaders()), [ 'queries' => [ Query::cursorAfter(new Document(['$id' => 'unknown']))->toString(), @@ -607,7 +615,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $data['teamId'], + 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::select(['$id', 'name', 'teamId', 'description', '$createdAt', '$updatedAt'])->toString(), @@ -640,6 +648,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::select(['$id', 'name', 'teamId'])->toString(), @@ -672,6 +681,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::select(['$id', 'name'])->toString(), @@ -703,6 +713,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::select(['$id', 'name', 'platforms'])->toString(), @@ -733,6 +744,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::select(['$id', 'name', 'webhooks', 'keys'])->toString(), @@ -763,6 +775,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::select(['*'])->toString(), @@ -794,6 +807,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', + 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::select(['$id', 'invalidAttribute'])->toString(), @@ -1816,7 +1830,13 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('en-us', $response['body']['locale']); /** Update Email template, fail due to SMTP disabled */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ + $projectWithoutSmtp = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Without SMTP', + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectWithoutSmtp . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.1', From bcd2bfe5b272e442159b69499421edce992dd223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 14 May 2026 19:34:49 +0200 Subject: [PATCH 16/34] Fix security --- app/controllers/shared/api.php | 4 +-- app/init/resources/request.php | 58 +++++++++++++++------------------- 2 files changed, 27 insertions(+), 35 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 6f808296b0..573f6d5f06 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -315,7 +315,7 @@ Http::init() } $projectId = $project->getId(); - if ($projectId === 'console' && str_starts_with($route->getPath(), '/v1/projects/:projectId')) { + if ($projectId === 'console' && (str_starts_with($route->getPath(), '/v1/projects/:projectId') || str_starts_with($route->getPath(), '/v1/organization/projects'))) { $uri = $request->getURI(); $projectId = explode('/', $uri)[3]; } @@ -342,7 +342,7 @@ Http::init() * For console projects resource, we use platform DB. * Enabling authorization restricts admin user to the projects they have access to. */ - if ($project->getId() === 'console' && ($route->getPath() === '/v1/projects' || $route->getPath() === '/v1/projects/:projectId')) { + if ($project->getId() === 'console' && ($route->getPath() === '/v1/projects' || $route->getPath() === '/v1/projects/:projectId' || $route->getPath() === '/v1/organization/projects')) { $authorization->setDefaultStatus(true); } else { // Otherwise, disable authorization checks. diff --git a/app/init/resources/request.php b/app/init/resources/request.php index e1409d6412..2854cb2401 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -1096,9 +1096,9 @@ return function (Container $context): void { }, ['request', 'project', 'servers', 'dbForPlatform', 'authorization']); $context->set('team', function (Document $project, Database $dbForPlatform, Http $utopia, Request $request, Authorization $authorization, Document $user) { - $teamInternalId = ''; + $teamId = ''; if ($project->getId() !== 'console') { - $teamInternalId = $project->getAttribute('teamInternalId', ''); + $teamId = $project->getAttribute('teamId', ''); } else { $route = $utopia->match($request); $path = ! empty($route) ? $route->getPath() : $request->getURI(); @@ -1106,12 +1106,12 @@ return function (Container $context): void { // Prioritx #1: If org ID header present, respect it if (!empty($orgHeader)) { - return $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $orgHeader)); - } + $teamId = $orgHeader; + } elseif (\str_starts_with($path, '/v1/organization/projects')) { + // Backwards compatibility: /v1/organitation/projects acting as /v1/projects - // Backwards compatibility: /v1/organitation/projects acting as /v1/projects - if (\str_starts_with($path, '/v1/organization/projects')) { // Backwards compatibility: Take from payload param + // organizationId not required, but easy to use by mistake in future unknowingly $teamId = $request->getParam('organizationId', $request->getParam('teamId', '')); // Backawrds compatibility: Get from URL param project ID @@ -1142,44 +1142,36 @@ return function (Container $context): void { // Ignore, do not parse from queries } } - - if (!empty($teamId)) { - $team = $authorization->skip(function () use ($dbForPlatform, $teamId) { - return $dbForPlatform->getDocument('teams', $teamId); - }); - return $team; - } } elseif (str_starts_with($path, '/v1/projects/:projectId')) { $uri = $request->getURI(); - $pid = explode('/', $uri)[3]; - $p = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $pid)); - $teamInternalId = $p->getAttribute('teamInternalId', ''); + $projectId = explode('/', $uri)[3]; + $project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId)); + $teamId = $project->getAttribute('teamId', ''); } elseif ($path === '/v1/projects') { $teamId = $request->getParam('teamId', ''); + } + } - if (empty($teamId)) { - return new Document([]); - } + // No team scenario + if (empty($teamId)) { + return new Document([]); + } - $team = $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $teamId)); + $team = $authorization->skip(function () use ($dbForPlatform, $teamId) { + return $dbForPlatform->getDocument('teams', $teamId); + }); + // Unauthorized team scenario + // Ensure $user has membership in team + $memberships = $user->getAttribute('memberships', []); + foreach ($memberships as $membership) { + if ($membership->getAttribute('teamId', '') === $teamId) { return $team; } } - // if teamInternalId is empty, return an empty document - - if (empty($teamInternalId)) { - return new Document([]); - } - - $team = $authorization->skip(function () use ($dbForPlatform, $teamInternalId) { - return $dbForPlatform->findOne('teams', [ - Query::equal('$sequence', [$teamInternalId]), - ]); - }); - - return $team; + // Unauthorized, do not allow the team + return new Document([]); }, ['project', 'dbForPlatform', 'utopia', 'request', 'authorization', 'user']); $context->set('previewHostname', function (Request $request, ?Key $apiKey) { From 3e35b888a7b6b01169765b1c7687895014d8750a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 14 May 2026 19:47:59 +0200 Subject: [PATCH 17/34] Fix bug --- app/controllers/shared/api.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 573f6d5f06..d900492f10 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -317,7 +317,8 @@ Http::init() $projectId = $project->getId(); if ($projectId === 'console' && (str_starts_with($route->getPath(), '/v1/projects/:projectId') || str_starts_with($route->getPath(), '/v1/organization/projects'))) { $uri = $request->getURI(); - $projectId = explode('/', $uri)[3]; + $parts = explode('/', $uri); + $projectId = str_starts_with($route->getPath(), '/v1/organization/projects') ? $parts[4] : $parts[3]; } // Base scopes for admin users to allow listing teams and projects. From 5b109f2f8ddff2fbcda1aea0d05c5aafca2cd51b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 14 May 2026 19:52:14 +0200 Subject: [PATCH 18/34] Fix org key usecase --- app/init/resources/request.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 2854cb2401..17af87affb 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -1170,6 +1170,11 @@ return function (Container $context): void { } } + // API key auth bypasses membership check; key validity is verified later + if (!empty($request->getHeader('x-appwrite-key', ''))) { + return $team; + } + // Unauthorized, do not allow the team return new Document([]); }, ['project', 'dbForPlatform', 'utopia', 'request', 'authorization', 'user']); From 11839c6dcc3fc2675145325f009a7d0eca710dab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 15 May 2026 11:26:22 +0200 Subject: [PATCH 19/34] Remove old endpoint aliases --- .../Platform/Modules/Organization/Http/Projects/Create.php | 1 - .../Platform/Modules/Organization/Http/Projects/Update.php | 1 - .../Platform/Modules/Organization/Http/Projects/XList.php | 1 - 3 files changed, 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php index 88a17cdd7a..6ad1139ba7 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php @@ -42,7 +42,6 @@ class Create extends Action $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) ->setHttpPath('/v1/organization/projects') - ->httpAlias('/v1/projects') ->desc('Create organization project') ->groups(['api', 'organization']) ->label('audits.event', 'projects.create') diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php index 9188060639..c364a5d6df 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php @@ -28,7 +28,6 @@ class Update extends Action $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) ->setHttpPath('/v1/organization/projects/:projectId') - ->httpAlias('/v1/projects/:projectId') ->desc('Update organization project') ->groups(['api', 'organization']) ->label('scope', 'projects.write') diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php index 5b223904d6..6b45d92175 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php @@ -44,7 +44,6 @@ class XList extends Action $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/organization/projects') - ->httpAlias('/v1/projects') ->desc('List organization projects') ->groups(['api', 'organization']) ->label('scope', 'projects.read') From 79f536445e287110ec28d950e4b01c0b976ce0b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 15 May 2026 11:26:35 +0200 Subject: [PATCH 20/34] Revive old endpoints --- .../Modules/Projects/Http/Projects/Create.php | 245 ++++++++++++++++++ .../Modules/Projects/Http/Projects/Update.php | 94 +++++++ .../Modules/Projects/Http/Projects/XList.php | 197 ++++++++++++++ .../Modules/Projects/Services/Http.php | 6 + 4 files changed, 542 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php create mode 100644 src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php create mode 100644 src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php new file mode 100644 index 0000000000..d2c92fc65c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php @@ -0,0 +1,245 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/projects') + ->desc('Create project') + ->groups(['api', 'projects']) + ->label('audits.event', 'projects.create') + ->label('audits.resource', 'project/{response.$id}') + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'projects', + name: 'create', + description: '/docs/references/projects/create.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_CREATED, + model: Response::MODEL_PROJECT, + ) + ] + )) + ->param('projectId', '', new ProjectId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, and hyphen. Can\'t start with a special char. Max length is 36 chars.') + ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') + ->param('teamId', '', new UID(), 'Team unique ID.') + ->param('region', System::getEnv('_APP_REGION', 'default'), new WhiteList(array_keys(array_filter(Config::getParam('regions'), fn ($config) => !$config['disabled']))), 'Project Region.', true) + ->inject('request') + ->inject('response') + ->inject('dbForPlatform') + ->inject('cache') + ->inject('pools') + ->inject('hooks') + ->callback($this->action(...)); + } + + public function action(string $projectId, string $name, string $teamId, string $region, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks) + { + $team = $dbForPlatform->getDocument('teams', $teamId); + + if ($team->isEmpty()) { + throw new Exception(Exception::TEAM_NOT_FOUND); + } + + $allowList = \array_filter(\explode(',', System::getEnv('_APP_PROJECT_REGIONS', ''))); + + if (!empty($allowList) && !\in_array($region, $allowList)) { + throw new Exception(Exception::PROJECT_REGION_UNSUPPORTED, 'Region "' . $region . '" is not supported'); + } + + $auth = Config::getParam('auth', []); + $auths = [ + 'limit' => 0, + 'maxSessions' => 0, + 'passwordHistory' => 0, + 'passwordDictionary' => false, + 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, + 'personalDataCheck' => false, + 'disposableEmails' => false, + 'canonicalEmails' => false, + 'freeEmails' => false, + 'mockNumbers' => [], + 'sessionAlerts' => false, + 'membershipsUserName' => false, + 'membershipsUserEmail' => false, + 'membershipsMfa' => false, + 'membershipsUserId' => false, + 'membershipsUserPhone' => false, + 'invalidateSessions' => true + ]; + + foreach ($auth as $method) { + $auths[$method['key'] ?? ''] = true; + } + + $projectId = ($projectId == 'unique()') ? ID::unique() : $projectId; + + if ($projectId === 'console') { + throw new Exception(Exception::PROJECT_RESERVED_PROJECT, "'console' is a reserved project."); + } + + $databases = Config::getParam('pools-database', []); + + if ($region !== 'default') { + $databaseKeys = System::getEnv('_APP_DATABASE_KEYS', ''); + $keys = explode(',', $databaseKeys); + $databases = array_filter($keys, function ($value) use ($region) { + return str_contains($value, $region); + }); + } + + $databaseOverride = System::getEnv('_APP_DATABASE_OVERRIDE'); + $index = \array_search($databaseOverride, $databases); + if ($index !== false) { + $dsn = $databases[$index]; + } else { + $dsn = $databases[array_rand($databases)]; + } + + // TODO: Temporary until all projects are using shared tables. + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + + if (\in_array($dsn, $sharedTables)) { + $schema = 'appwrite'; + $database = 'appwrite'; + $namespace = System::getEnv('_APP_DATABASE_SHARED_NAMESPACE', ''); + $dsn = $schema . '://' . $dsn . '?database=' . $database; + + if (!empty($namespace)) { + $dsn .= '&namespace=' . $namespace; + } + } + + try { + $project = $dbForPlatform->createDocument('projects', new Document([ + '$id' => $projectId, + '$permissions' => $this->getPermissions($teamId, $projectId), + 'name' => $name, + 'teamInternalId' => $team->getSequence(), + 'teamId' => $team->getId(), + 'region' => $region, + 'version' => APP_VERSION_STABLE, + 'services' => new \stdClass(), + 'platforms' => null, + 'oAuthProviders' => [], + 'webhooks' => null, + 'keys' => null, + 'auths' => $auths, + 'accessedAt' => DateTime::now(), + 'search' => implode(' ', [$projectId, $name]), + 'database' => $dsn, + 'labels' => [], + 'status' => PROJECT_STATUS_ACTIVE, + ])); + } catch (Duplicate) { + throw new Exception(Exception::PROJECT_ALREADY_EXISTS); + } + + try { + $dsn = new DSN($dsn); + } catch (\InvalidArgumentException) { + // TODO: Temporary until all projects are using shared tables + $dsn = new DSN('mysql://' . $dsn); + } + + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + $projectTables = !\in_array($dsn->getHost(), $sharedTables); + + if ($projectTables) { + $adapter = new DatabasePool($pools->get($dsn->getHost())); + $dbForProject = new Database($adapter, $cache); + $dbForProject + ->setDatabase(APP_DATABASE) + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $project->getSequence()); + + $create = true; + + try { + $dbForProject->create(); + } catch (Duplicate) { + $create = false; + } + + $adapter = new AdapterDatabase($dbForProject); + $audit = new Audit($adapter); + $audit->setup(); + + if ($create) { + /** @var array $collections */ + $collections = Config::getParam('collections', [])['projects'] ?? []; + + foreach ($collections as $key => $collection) { + if (($collection['$collection'] ?? '') !== Database::METADATA) { + continue; + } + + $attributes = \array_map(fn ($attribute) => new Document($attribute), $collection['attributes']); + $indexes = \array_map(fn (array $index) => new Document($index), $collection['indexes']); + + try { + $dbForProject->createCollection($key, $attributes, $indexes); + } catch (Duplicate) { + // Collection already exists + } + } + } + } + + // Hook allowing instant project mirroring during migration + // Outside of migration, hook is not registered and has no effect + $hooks->trigger('afterProjectCreation', [$project, $pools, $cache]); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php new file mode 100644 index 0000000000..29c26b33ea --- /dev/null +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php @@ -0,0 +1,94 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/projects/:projectId') + ->desc('Update project') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->label('audits.event', 'projects.update') + ->label('audits.resource', 'project/{request.projectId}') + ->label('sdk', new Method( + namespace: 'projects', + group: 'projects', + name: 'update', + description: '/docs/references/projects/update.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PROJECT, + ) + ] + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') + ->param('description', '', new Text(256), 'Project description. Max length: 256 chars.', true) + ->param('logo', '', new Text(1024), 'Project logo.', true) + ->param('url', '', new URL(), 'Project URL.', true) + ->param('legalName', '', new Text(256), 'Project legal name. Max length: 256 chars.', true) + ->param('legalCountry', '', new Text(256), 'Project legal country. Max length: 256 chars.', true) + ->param('legalState', '', new Text(256), 'Project legal state. Max length: 256 chars.', true) + ->param('legalCity', '', new Text(256), 'Project legal city. Max length: 256 chars.', true) + ->param('legalAddress', '', new Text(256), 'Project legal address. Max length: 256 chars.', true) + ->param('legalTaxId', '', new Text(256), 'Project legal tax ID. Max length: 256 chars.', true) + ->inject('response') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action(string $projectId, string $name, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, Response $response, Database $dbForPlatform) + { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project + ->setAttribute('name', $name) + ->setAttribute('description', $description) + ->setAttribute('logo', $logo) + ->setAttribute('url', $url) + ->setAttribute('legalName', $legalName) + ->setAttribute('legalCountry', $legalCountry) + ->setAttribute('legalState', $legalState) + ->setAttribute('legalCity', $legalCity) + ->setAttribute('legalAddress', $legalAddress) + ->setAttribute('legalTaxId', $legalTaxId) + ->setAttribute('search', implode(' ', [$projectId, $name]))); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php new file mode 100644 index 0000000000..0d2a951388 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php @@ -0,0 +1,197 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/projects') + ->desc('List projects') + ->groups(['api', 'projects']) + ->label('scope', 'projects.read') + ->label('sdk', new Method( + namespace: 'projects', + group: 'projects', + name: 'list', + description: <<param('queries', [], $this->getQueriesValidator(), '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(', ', Projects::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', 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') + ->inject('team') + ->callback($this->action(...)); + } + + public function action(array $queries, string $search, bool $includeTotal, Response $response, Database $dbForPlatform, Document $team) + { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + if (!$team->isEmpty()) { + $queries[] = Query::equal('teamInternalId', [$team->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()); + } + + $projectId = $cursor->getValue(); + $cursorDocument = $dbForPlatform->getDocument('projects', $projectId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Project '{$projectId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + try { + $selectQueries = Query::groupByType($queries)['selections']; + $filterQueries = Query::groupByType($queries)['filters']; + + $projects = $this->find($dbForPlatform, $queries, $selectQueries); + $total = $includeTotal ? $dbForPlatform->count('projects', $filterQueries, APP_LIMIT_COUNT) : 0; + } catch (Order $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."); + } + + $response->addFilter(new ListSelection($selectQueries, 'projects')); + + $response->dynamic(new Document([ + 'projects' => $projects, + 'total' => $total, + ]), Response::MODEL_PROJECT_LIST); + } + + // Build mapping of columns to their subQuery filters + private static function getAttributeToSubQueryFilters(): array + { + if (self::$attributeToSubQueryFilters !== null) { + return self::$attributeToSubQueryFilters; + } + + self::$attributeToSubQueryFilters = []; + + $collections = Config::getParam('collections', []); + $projectAttributes = $collections['platform']['projects']['attributes'] ?? []; + + foreach ($projectAttributes as $attribute) { + $attributeId = $attribute['$id'] ?? null; + $filters = $attribute['filters'] ?? []; + + if ($attributeId === null || empty($filters)) { + continue; + } + + // extract only subQuery filters + $subQueryFilters = \array_filter($filters, function ($filter) { + return \str_starts_with($filter, 'subQuery'); + }); + + if (!empty($subQueryFilters)) { + self::$attributeToSubQueryFilters[$attributeId] = \array_values($subQueryFilters); + } + } + + return self::$attributeToSubQueryFilters; + } + + private function find(Database $dbForPlatform, array $queries, array $selectQueries): array + { + if (empty($selectQueries)) { + return $dbForPlatform->find('projects', $queries); + } + + $selectedAttributes = []; + foreach ($selectQueries as $query) { + foreach ($query->getValues() as $value) { + $selectedAttributes[] = $value; + } + } + + if (\in_array('*', $selectedAttributes)) { + return $dbForPlatform->find('projects', $queries); + } + + $filtersToSkipMap = []; + $selectedAttributesMap = \array_flip($selectedAttributes); + $attributeToSubQueryFilters = self::getAttributeToSubQueryFilters(); + + foreach ($attributeToSubQueryFilters as $attributeName => $subQueryFilters) { + if (!isset($selectedAttributesMap[$attributeName])) { + foreach ($subQueryFilters as $filter) { + $filtersToSkipMap[$filter] = true; + } + } + } + + $filtersToSkip = \array_keys($filtersToSkipMap); + + return empty($filtersToSkip) + ? $dbForPlatform->find('projects', $queries) + : $dbForPlatform->skipFilters(fn () => $dbForPlatform->find('projects', $queries), $filtersToSkip); + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Services/Http.php b/src/Appwrite/Platform/Modules/Projects/Services/Http.php index b1662cef31..8275e664d5 100644 --- a/src/Appwrite/Platform/Modules/Projects/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Projects/Services/Http.php @@ -7,7 +7,10 @@ use Appwrite\Platform\Modules\Projects\Http\DevKeys\Delete as DeleteDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\Get as GetDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\Update as UpdateDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\XList as ListDevKeys; +use Appwrite\Platform\Modules\Projects\Http\Projects\Create as CreateProject; use Appwrite\Platform\Modules\Projects\Http\Projects\Team\Update as UpdateProjectTeam; +use Appwrite\Platform\Modules\Projects\Http\Projects\Update as UpdateProject; +use Appwrite\Platform\Modules\Projects\Http\Projects\XList as ListProjects; use Appwrite\Platform\Modules\Projects\Http\Schedules\Create as CreateSchedule; use Appwrite\Platform\Modules\Projects\Http\Schedules\Get as GetSchedule; use Appwrite\Platform\Modules\Projects\Http\Schedules\XList as ListSchedules; @@ -24,6 +27,9 @@ class Http extends Service $this->addAction(ListDevKeys::getName(), new ListDevKeys()); $this->addAction(DeleteDevKey::getName(), new DeleteDevKey()); + $this->addAction(CreateProject::getName(), new CreateProject()); + $this->addAction(UpdateProject::getName(), new UpdateProject()); + $this->addAction(ListProjects::getName(), new ListProjects()); $this->addAction(UpdateProjectTeam::getName(), new UpdateProjectTeam()); $this->addAction(CreateSchedule::getName(), new CreateSchedule()); From cd54e9178468edeb9a94ae21fcee8f272c2c0180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 15 May 2026 11:27:19 +0200 Subject: [PATCH 21/34] Deprecate old projects endpoints --- .../Modules/Projects/Http/Projects/Create.php | 13 ------------- .../Modules/Projects/Http/Projects/Update.php | 13 ------------- .../Modules/Projects/Http/Projects/XList.php | 16 ---------------- 3 files changed, 42 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php index d2c92fc65c..b5873228cc 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php @@ -54,19 +54,6 @@ class Create extends Action ->label('audits.event', 'projects.create') ->label('audits.resource', 'project/{response.$id}') ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'create', - description: '/docs/references/projects/create.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_PROJECT, - ) - ] - )) ->param('projectId', '', new ProjectId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, and hyphen. Can\'t start with a special char. Max length is 36 chars.') ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') ->param('teamId', '', new UID(), 'Team unique ID.') diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php index 29c26b33ea..c1694a029c 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php @@ -39,19 +39,6 @@ class Update extends Action ->label('scope', 'projects.write') ->label('audits.event', 'projects.update') ->label('audits.resource', 'project/{request.projectId}') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'update', - description: '/docs/references/projects/update.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) ->param('projectId', '', new UID(), 'Project unique ID.') ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') ->param('description', '', new Text(256), 'Project description. Max length: 256 chars.', true) diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php index 0d2a951388..98318fe2a8 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php @@ -48,22 +48,6 @@ class XList extends Action ->desc('List projects') ->groups(['api', 'projects']) ->label('scope', 'projects.read') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'list', - description: <<param('queries', [], $this->getQueriesValidator(), '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(', ', Projects::ALLOWED_ATTRIBUTES), true) ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) From 2d9470494800e10f21e71a8346ef840cba7258f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 15 May 2026 11:30:06 +0200 Subject: [PATCH 22/34] Formatting fix --- .../Platform/Modules/Projects/Http/Projects/Create.php | 3 --- .../Platform/Modules/Projects/Http/Projects/Update.php | 3 --- .../Platform/Modules/Projects/Http/Projects/XList.php | 4 ---- 3 files changed, 10 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php index b5873228cc..18250cb140 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php @@ -4,9 +4,6 @@ namespace Appwrite\Platform\Modules\Projects\Http\Projects; use Appwrite\Extend\Exception; use Appwrite\Hooks\Hooks; -use Appwrite\SDK\AuthType; -use Appwrite\SDK\Method; -use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Database\Validator\ProjectId; use Appwrite\Utopia\Database\Validator\Queries\Projects; use Appwrite\Utopia\Request; diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php index c1694a029c..f6df843d07 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php @@ -3,9 +3,6 @@ namespace Appwrite\Platform\Modules\Projects\Http\Projects; use Appwrite\Extend\Exception; -use Appwrite\SDK\AuthType; -use Appwrite\SDK\Method; -use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Database\Validator\Queries\Projects; use Appwrite\Utopia\Response; use Utopia\Database\Database; diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php index 98318fe2a8..b967c29451 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php @@ -4,10 +4,6 @@ namespace Appwrite\Platform\Modules\Projects\Http\Projects; use Appwrite\Extend\Exception; use Appwrite\Platform\Action; -use Appwrite\SDK\AuthType; -use Appwrite\SDK\ContentType; -use Appwrite\SDK\Method; -use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Database\Validator\Queries\Projects; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Filters\ListSelection; From 34078ad9afe011f801f48f82aee2c13ddf117577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 15 May 2026 11:37:04 +0200 Subject: [PATCH 23/34] Revert unwanted changes --- app/config/scopes/organization.php | 4 ++-- .../Platform/Modules/Organization/Http/Projects/Create.php | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/app/config/scopes/organization.php b/app/config/scopes/organization.php index 4e10331b7f..d74452f259 100644 --- a/app/config/scopes/organization.php +++ b/app/config/scopes/organization.php @@ -13,13 +13,13 @@ return [ "category" => "Projects", ], "devKeys.read" => [ - "description" => 'Access to read organization project\'s development keys', + "description" => 'Access to read project\'s development keys', "category" => "Other", "deprecated" => true, ], "devKeys.write" => [ "description" => - "Access to create, update, and delete organization project\'s development keys", + "Access to create, update, and delete project\'s development keys", "category" => "Other", "deprecated" => true, ], diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php index 6ad1139ba7..c4c5278d1f 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php @@ -66,7 +66,6 @@ class Create extends Action ->param('projectId', '', new ProjectId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, and hyphen. Can\'t start with a special char. Max length is 36 chars.') ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') ->param('region', System::getEnv('_APP_REGION', 'default'), new WhiteList(array_keys(array_filter(Config::getParam('regions'), fn ($config) => !$config['disabled']))), 'Project Region.', true) - ->param('teamId', '', new UID(), 'Team unique ID.', deprecated: true, optional: true) // Backwards compatibility ->inject('response') ->inject('dbForPlatform') ->inject('cache') @@ -76,8 +75,7 @@ class Create extends Action ->callback($this->action(...)); } - // teamId intentionally unused; used by resource for backwards compatibility - public function action(string $projectId, string $name, string $region, string $teamId, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks, Document $team) + public function action(string $projectId, string $name, string $region, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks, Document $team) { $allowList = \array_filter(\explode(',', System::getEnv('_APP_PROJECT_REGIONS', ''))); From d422b7abdcb887976b2dd7640d1fdce5c638b718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 15 May 2026 11:44:47 +0200 Subject: [PATCH 24/34] Final reverts --- app/controllers/shared/api.php | 7 ++- app/init/resources/request.php | 96 +++++++++------------------------- 2 files changed, 29 insertions(+), 74 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 5bcb51355b..6e5167660a 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -313,10 +313,9 @@ Http::init() } $projectId = $project->getId(); - if ($projectId === 'console' && (str_starts_with($route->getPath(), '/v1/projects/:projectId') || str_starts_with($route->getPath(), '/v1/organization/projects'))) { + if ($projectId === 'console' && str_starts_with($route->getPath(), '/v1/projects/:projectId')) { $uri = $request->getURI(); - $parts = explode('/', $uri); - $projectId = str_starts_with($route->getPath(), '/v1/organization/projects') ? $parts[4] : $parts[3]; + $projectId = explode('/', $uri)[3]; } // Base scopes for admin users to allow listing teams and projects. @@ -341,7 +340,7 @@ Http::init() * For console projects resource, we use platform DB. * Enabling authorization restricts admin user to the projects they have access to. */ - if ($project->getId() === 'console' && ($route->getPath() === '/v1/projects' || $route->getPath() === '/v1/projects/:projectId' || $route->getPath() === '/v1/organization/projects')) { + if ($project->getId() === 'console' && ($route->getPath() === '/v1/projects' || $route->getPath() === '/v1/projects/:projectId')) { $authorization->setDefaultStatus(true); } else { // Otherwise, disable authorization checks. diff --git a/app/init/resources/request.php b/app/init/resources/request.php index e6382be032..85d8db3698 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -1,6 +1,5 @@ set('team', function (Document $project, Database $dbForPlatform, Http $utopia, Request $request, Authorization $authorization, Document $user) { - $teamId = ''; + $context->set('team', function (Document $project, Database $dbForPlatform, Http $utopia, Request $request, Authorization $authorization) { + $teamInternalId = ''; if ($project->getId() !== 'console') { - $teamId = $project->getAttribute('teamId', ''); + $teamInternalId = $project->getAttribute('teamInternalId', ''); } else { $route = $utopia->match($request); $path = ! empty($route) ? $route->getPath() : $request->getURI(); $orgHeader = $request->getHeader('x-appwrite-organization', ''); - - // Prioritx #1: If org ID header present, respect it - if (!empty($orgHeader)) { - $teamId = $orgHeader; - } elseif (\str_starts_with($path, '/v1/organization/projects')) { - // Backwards compatibility: /v1/organitation/projects acting as /v1/projects - - // Backwards compatibility: Take from payload param - // organizationId not required, but easy to use by mistake in future unknowingly - $teamId = $request->getParam('organizationId', $request->getParam('teamId', '')); - - // Backawrds compatibility: Get from URL param project ID - if (empty($teamId)) { - $projectId = \explode('/', $request->getURI())[4] ?? ''; - if (!empty($projectId)) { - $urlProject = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId)); - $teamId = $urlProject->getAttribute('teamId', ''); - } - } - - // Backwards compatibility: Get from queries param - if (empty($teamId)) { - $queries = $request->getParam('queries', []); - $queries = \is_array($queries) ? $queries : [$queries]; - - try { - $queries = Query::parseQueries($queries); - $queries = Query::getByType($queries, [Query::TYPE_EQUAL]); - - foreach ($queries as $query) { - if ($query->getAttribute() === 'teamId') { - $teamId = $query->getValues()[0] ?? ''; - break; - } - } - } catch (QueryException $e) { - // Ignore, do not parse from queries - } - } - } elseif (str_starts_with($path, '/v1/projects/:projectId')) { + if (str_starts_with($path, '/v1/projects/:projectId')) { $uri = $request->getURI(); - $projectId = explode('/', $uri)[3]; - $project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId)); - $teamId = $project->getAttribute('teamId', ''); + $pid = explode('/', $uri)[3]; + $p = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $pid)); + $teamInternalId = $p->getAttribute('teamInternalId', ''); } elseif ($path === '/v1/projects') { $teamId = $request->getParam('teamId', ''); + + if (empty($teamId)) { + return new Document([]); + } + + $team = $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $teamId)); + + return $team; + } elseif (! empty($orgHeader)) { + return $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $orgHeader)); } } - // No team scenario - if (empty($teamId)) { + // if teamInternalId is empty, return an empty document + + if (empty($teamInternalId)) { return new Document([]); } - $team = $authorization->skip(function () use ($dbForPlatform, $teamId) { - return $dbForPlatform->getDocument('teams', $teamId); + $team = $authorization->skip(function () use ($dbForPlatform, $teamInternalId) { + return $dbForPlatform->findOne('teams', [ + Query::equal('$sequence', [$teamInternalId]), + ]); }); - // Unauthorized team scenario - // Ensure $user has membership in team - $memberships = $user->getAttribute('memberships', []); - foreach ($memberships as $membership) { - if ($membership->getAttribute('teamId', '') === $teamId) { - return $team; - } - } - - // API key auth bypasses membership check; key validity is verified later - if (!empty($request->getHeader('x-appwrite-key', ''))) { - return $team; - } - - // Unauthorized, do not allow the team - return new Document([]); - }, ['project', 'dbForPlatform', 'utopia', 'request', 'authorization', 'user']); + return $team; + }, ['project', 'dbForPlatform', 'utopia', 'request', 'authorization']); $context->set('previewHostname', function (Request $request, ?Key $apiKey) { $allowed = false; From 5a0bb57db25c7aff8cb8f209022ab025815584f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 15 May 2026 11:45:39 +0200 Subject: [PATCH 25/34] linter fix --- .../Platform/Modules/Organization/Http/Projects/Create.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php index c4c5278d1f..227c64ce8c 100644 --- a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Create.php @@ -20,7 +20,6 @@ use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate; use Utopia\Database\Helpers\ID; -use Utopia\Database\Validator\UID; use Utopia\DSN\DSN; use Utopia\Platform\Scope\HTTP; use Utopia\Pools\Group; From a0c27afec8a08711a7be8b393c64d57fc5ab7fe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 15 May 2026 11:47:52 +0200 Subject: [PATCH 26/34] Revert test changes --- .../Projects/ProjectsConsoleClientTest.php | 40 +------------------ 1 file changed, 1 insertion(+), 39 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 90546e1569..7b44b0485d 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -354,16 +354,6 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-response-format' => '1.9.4', ], $this->getHeaders())); - $this->assertEquals(404, $response['headers']['status-code']); - - - $response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $data['teamId'], - ], $this->getHeaders())); - $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertGreaterThan(0, count($response['body']['projects'])); @@ -375,7 +365,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $data['teamId'], ], $this->getHeaders(), [ 'search' => $id ])); @@ -388,7 +377,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $data['teamId'], ], $this->getHeaders(), [ 'search' => 'Project Test' ])); @@ -435,7 +423,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $team['body']['$id'], ], $this->getHeaders()), [ 'queries' => [ Query::equal('teamId', [$team['body']['$id']])->toString(), @@ -451,7 +438,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $team['body']['$id'], ], $this->getHeaders()), [ 'queries' => [ Query::limit(1)->toString(), @@ -466,7 +452,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $team['body']['$id'], ], $this->getHeaders()), [ 'queries' => [ Query::offset(1)->toString(), @@ -480,7 +465,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $team['body']['$id'], ], $this->getHeaders()), [ 'queries' => [ Query::equal('name', ['Project Test 2'])->toString(), @@ -496,7 +480,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $team['body']['$id'], ], $this->getHeaders()), [ 'queries' => [ Query::orderDesc()->toString(), @@ -511,7 +494,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $team['body']['$id'], ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); @@ -522,7 +504,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $team['body']['$id'], ], $this->getHeaders()), [ 'queries' => [ Query::cursorAfter(new Document(['$id' => $response['body']['projects'][0]['$id']]))->toString(), @@ -539,7 +520,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $team['body']['$id'], ], $this->getHeaders()), [ 'queries' => [ Query::cursorAfter(new Document(['$id' => 'unknown']))->toString(), @@ -584,7 +564,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::select(['$id', 'name'])->toString(), @@ -614,8 +593,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $teamId, + 'x-appwrite-response-format' => '1.9.4' ], $this->getHeaders()), [ 'queries' => [ Query::select(['$id', 'name', 'teamId', 'description', '$createdAt', '$updatedAt'])->toString(), @@ -648,7 +626,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::select(['$id', 'name', 'teamId'])->toString(), @@ -681,7 +658,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::select(['$id', 'name'])->toString(), @@ -713,7 +689,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::select(['$id', 'name', 'platforms'])->toString(), @@ -744,7 +719,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::select(['$id', 'name', 'webhooks', 'keys'])->toString(), @@ -775,7 +749,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::select(['*'])->toString(), @@ -807,7 +780,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::select(['$id', 'invalidAttribute'])->toString(), @@ -7075,7 +7047,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::contains('labels', ['nonvip'])->toString(), @@ -7089,7 +7060,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::contains('labels', ['vip'])->toString(), @@ -7102,7 +7072,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::contains('labels', ['imagine'])->toString(), @@ -7116,7 +7085,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::contains('labels', ['nonvip', 'imagine'])->toString(), @@ -7131,7 +7099,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'projectId' => ID::unique(), 'name' => 'Test project - Labels 2', @@ -7161,7 +7128,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::contains('labels', ['imagine'])->toString(), @@ -7177,7 +7143,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::contains('labels', ['vip'])->toString(), @@ -7192,7 +7157,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::contains('labels', ['vip'])->toString(), @@ -7208,7 +7172,6 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', - 'x-appwrite-organization' => $teamId, ], $this->getHeaders()), [ 'queries' => [ Query::contains('labels', ['vip', 'imagine'])->toString(), @@ -7316,7 +7279,6 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-response-format' => '1.9.4', 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, - 'x-appwrite-organization' => $teamId, ]); $this->assertEquals(200, $response['headers']['status-code']); From b627a7d6ff4e7f524fb2d450b5f11f5aa7986561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 15 May 2026 12:46:27 +0200 Subject: [PATCH 27/34] Org key tests --- tests/e2e/Scopes/SideServerOrganization.php | 81 +++++++++++++++++++ .../Services/Organization/ProjectsBase.php | 26 ++++++ .../ProjectsConsoleClientTest.php | 14 ++++ .../Organization/ProjectsCustomServerTest.php | 14 ++++ 4 files changed, 135 insertions(+) create mode 100644 tests/e2e/Scopes/SideServerOrganization.php create mode 100644 tests/e2e/Services/Organization/ProjectsBase.php create mode 100644 tests/e2e/Services/Organization/ProjectsConsoleClientTest.php create mode 100644 tests/e2e/Services/Organization/ProjectsCustomServerTest.php diff --git a/tests/e2e/Scopes/SideServerOrganization.php b/tests/e2e/Scopes/SideServerOrganization.php new file mode 100644 index 0000000000..45fa48451f --- /dev/null +++ b/tests/e2e/Scopes/SideServerOrganization.php @@ -0,0 +1,81 @@ +client->call(Client::METHOD_POST, '/teams', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + ], [ + 'teamId' => $teamId, + 'name' => 'Organization Test', + ]); + if (\in_array($team['headers']['status-code'], [201, 409])) { + break; + } + \usleep(500000); + } + $this->assertContains($team['headers']['status-code'], [201, 409]); + $teamId = $team['body']['$id'] ?? $teamId; + + $key = $this->client->call(Client::METHOD_POST, '/v1/organization/keys', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + 'x-appwrite-organization' => $teamId, + ], [ + 'keyId' => ID::unique(), + 'name' => 'Organization Key', + 'scopes' => ['projects.read', 'projects.write'], + ]); + + $this->assertEquals(201, $key['headers']['status-code']); + $this->assertNotEmpty($key['body']['secret']); + + self::$organization = [ + '$id' => $teamId, + 'apiKey' => $key['body']['secret'], + ]; + + return self::$organization; + } + + public function getHeaders(bool $devKey = false): array + { + $organization = $this->getOrganization(); + + return [ + 'x-appwrite-key' => $organization['apiKey'], + 'x-appwrite-organization' => $organization['$id'], + ]; + } + + /** + * @return string + */ + public function getSide() + { + return 'server'; + } +} diff --git a/tests/e2e/Services/Organization/ProjectsBase.php b/tests/e2e/Services/Organization/ProjectsBase.php new file mode 100644 index 0000000000..eff7c827a3 --- /dev/null +++ b/tests/e2e/Services/Organization/ProjectsBase.php @@ -0,0 +1,26 @@ +client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Organization Project Test', + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals('Organization Project Test', $response['body']['name']); + } +} diff --git a/tests/e2e/Services/Organization/ProjectsConsoleClientTest.php b/tests/e2e/Services/Organization/ProjectsConsoleClientTest.php new file mode 100644 index 0000000000..5d016eff01 --- /dev/null +++ b/tests/e2e/Services/Organization/ProjectsConsoleClientTest.php @@ -0,0 +1,14 @@ + Date: Tue, 19 May 2026 17:20:15 +0200 Subject: [PATCH 28/34] Revert unwanted tests --- tests/e2e/Scopes/SideServerOrganization.php | 81 ------------------- .../Organization/ProjectsCustomServerTest.php | 14 ---- 2 files changed, 95 deletions(-) delete mode 100644 tests/e2e/Scopes/SideServerOrganization.php delete mode 100644 tests/e2e/Services/Organization/ProjectsCustomServerTest.php diff --git a/tests/e2e/Scopes/SideServerOrganization.php b/tests/e2e/Scopes/SideServerOrganization.php deleted file mode 100644 index 45fa48451f..0000000000 --- a/tests/e2e/Scopes/SideServerOrganization.php +++ /dev/null @@ -1,81 +0,0 @@ -client->call(Client::METHOD_POST, '/teams', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - 'x-appwrite-project' => 'console', - ], [ - 'teamId' => $teamId, - 'name' => 'Organization Test', - ]); - if (\in_array($team['headers']['status-code'], [201, 409])) { - break; - } - \usleep(500000); - } - $this->assertContains($team['headers']['status-code'], [201, 409]); - $teamId = $team['body']['$id'] ?? $teamId; - - $key = $this->client->call(Client::METHOD_POST, '/v1/organization/keys', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - 'x-appwrite-project' => 'console', - 'x-appwrite-organization' => $teamId, - ], [ - 'keyId' => ID::unique(), - 'name' => 'Organization Key', - 'scopes' => ['projects.read', 'projects.write'], - ]); - - $this->assertEquals(201, $key['headers']['status-code']); - $this->assertNotEmpty($key['body']['secret']); - - self::$organization = [ - '$id' => $teamId, - 'apiKey' => $key['body']['secret'], - ]; - - return self::$organization; - } - - public function getHeaders(bool $devKey = false): array - { - $organization = $this->getOrganization(); - - return [ - 'x-appwrite-key' => $organization['apiKey'], - 'x-appwrite-organization' => $organization['$id'], - ]; - } - - /** - * @return string - */ - public function getSide() - { - return 'server'; - } -} diff --git a/tests/e2e/Services/Organization/ProjectsCustomServerTest.php b/tests/e2e/Services/Organization/ProjectsCustomServerTest.php deleted file mode 100644 index 68caa2f299..0000000000 --- a/tests/e2e/Services/Organization/ProjectsCustomServerTest.php +++ /dev/null @@ -1,14 +0,0 @@ - Date: Wed, 20 May 2026 13:49:29 +0200 Subject: [PATCH 29/34] PR cleanup --- docs/references/projects/create-jwt.md | 1 - docs/references/projects/create-smtp-test.md | 1 - docs/references/projects/create.md | 1 - docs/references/projects/delete-email-template.md | 1 - docs/references/projects/delete.md | 1 - docs/references/projects/get-email-template.md | 1 - docs/references/projects/get.md | 1 - docs/references/projects/update-auth-status.md | 1 - docs/references/projects/update-email-template.md | 1 - docs/references/projects/update-mock-numbers.md | 1 - docs/references/projects/update-oauth2.md | 1 - docs/references/projects/update-smtp.md | 1 - docs/references/projects/update.md | 1 - 13 files changed, 13 deletions(-) delete mode 100644 docs/references/projects/create-jwt.md delete mode 100644 docs/references/projects/create-smtp-test.md delete mode 100644 docs/references/projects/create.md delete mode 100644 docs/references/projects/delete-email-template.md delete mode 100644 docs/references/projects/delete.md delete mode 100644 docs/references/projects/get-email-template.md delete mode 100644 docs/references/projects/get.md delete mode 100644 docs/references/projects/update-auth-status.md delete mode 100644 docs/references/projects/update-email-template.md delete mode 100644 docs/references/projects/update-mock-numbers.md delete mode 100644 docs/references/projects/update-oauth2.md delete mode 100644 docs/references/projects/update-smtp.md delete mode 100644 docs/references/projects/update.md diff --git a/docs/references/projects/create-jwt.md b/docs/references/projects/create-jwt.md deleted file mode 100644 index 9a6f8ebf6b..0000000000 --- a/docs/references/projects/create-jwt.md +++ /dev/null @@ -1 +0,0 @@ -Create a new JWT token. This token can be used to authenticate users with custom scopes and expiration time. \ No newline at end of file diff --git a/docs/references/projects/create-smtp-test.md b/docs/references/projects/create-smtp-test.md deleted file mode 100644 index 63cea9d21f..0000000000 --- a/docs/references/projects/create-smtp-test.md +++ /dev/null @@ -1 +0,0 @@ -Send a test email to verify SMTP configuration. \ No newline at end of file diff --git a/docs/references/projects/create.md b/docs/references/projects/create.md deleted file mode 100644 index d502c269ef..0000000000 --- a/docs/references/projects/create.md +++ /dev/null @@ -1 +0,0 @@ -Create a new project. You can create a maximum of 100 projects per account. \ No newline at end of file diff --git a/docs/references/projects/delete-email-template.md b/docs/references/projects/delete-email-template.md deleted file mode 100644 index 332b1d6117..0000000000 --- a/docs/references/projects/delete-email-template.md +++ /dev/null @@ -1 +0,0 @@ -Reset a custom email template to its default value. This endpoint removes any custom content and restores the template to its original state. \ No newline at end of file diff --git a/docs/references/projects/delete.md b/docs/references/projects/delete.md deleted file mode 100644 index 4a8070c082..0000000000 --- a/docs/references/projects/delete.md +++ /dev/null @@ -1 +0,0 @@ -Delete a project by its unique ID. \ No newline at end of file diff --git a/docs/references/projects/get-email-template.md b/docs/references/projects/get-email-template.md deleted file mode 100644 index 6119a0a183..0000000000 --- a/docs/references/projects/get-email-template.md +++ /dev/null @@ -1 +0,0 @@ -Get a custom email template for the specified locale and type. This endpoint returns the template content, subject, and other configuration details. \ No newline at end of file diff --git a/docs/references/projects/get.md b/docs/references/projects/get.md deleted file mode 100644 index b7a1165adc..0000000000 --- a/docs/references/projects/get.md +++ /dev/null @@ -1 +0,0 @@ -Get a project by its unique ID. This endpoint allows you to retrieve the project's details, including its name, description, team, region, and other metadata. \ No newline at end of file diff --git a/docs/references/projects/update-auth-status.md b/docs/references/projects/update-auth-status.md deleted file mode 100644 index 5d39ec29c4..0000000000 --- a/docs/references/projects/update-auth-status.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of a specific authentication method. Use this endpoint to enable or disable different authentication methods such as email, magic urls or sms in your project. \ No newline at end of file diff --git a/docs/references/projects/update-email-template.md b/docs/references/projects/update-email-template.md deleted file mode 100644 index d2bf124541..0000000000 --- a/docs/references/projects/update-email-template.md +++ /dev/null @@ -1 +0,0 @@ -Update a custom email template for the specified locale and type. Use this endpoint to modify the content of your email templates. \ No newline at end of file diff --git a/docs/references/projects/update-mock-numbers.md b/docs/references/projects/update-mock-numbers.md deleted file mode 100644 index 7fa92455c1..0000000000 --- a/docs/references/projects/update-mock-numbers.md +++ /dev/null @@ -1 +0,0 @@ -Update the list of mock phone numbers for testing. Use these numbers to bypass SMS verification in development. \ No newline at end of file diff --git a/docs/references/projects/update-oauth2.md b/docs/references/projects/update-oauth2.md deleted file mode 100644 index 2285135991..0000000000 --- a/docs/references/projects/update-oauth2.md +++ /dev/null @@ -1 +0,0 @@ -Update the OAuth2 provider configurations. Use this endpoint to set up or update the OAuth2 provider credentials or enable/disable providers. \ No newline at end of file diff --git a/docs/references/projects/update-smtp.md b/docs/references/projects/update-smtp.md deleted file mode 100644 index 7d898e1ed1..0000000000 --- a/docs/references/projects/update-smtp.md +++ /dev/null @@ -1 +0,0 @@ -Update the SMTP configuration for your project. Use this endpoint to configure your project's SMTP provider with your custom settings for sending transactional emails. \ No newline at end of file diff --git a/docs/references/projects/update.md b/docs/references/projects/update.md deleted file mode 100644 index 60c072c477..0000000000 --- a/docs/references/projects/update.md +++ /dev/null @@ -1 +0,0 @@ -Update a project by its unique ID. \ No newline at end of file From cbcf86e362ef271979ee1f7ece03b1bb82b4cf84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 20 May 2026 13:49:40 +0200 Subject: [PATCH 30/34] Add missing delete endpoint --- .../Organization/Http/Projects/Delete.php | 93 +++++++++++++++++++ .../Modules/Organization/Services/Http.php | 2 + 2 files changed, 95 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Organization/Http/Projects/Delete.php diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Delete.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Delete.php new file mode 100644 index 0000000000..fc8d5cccfc --- /dev/null +++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Delete.php @@ -0,0 +1,93 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/organization/projects/:projectId') + ->desc('Delete organization project') + ->groups(['api', 'organization']) + ->label('scope', 'projects.write') + ->label('audits.event', 'projects.delete') + ->label('audits.resource', 'project/{request.projectId}') + ->label('sdk', new Method( + namespace: 'organization', + group: 'projects', + name: 'deleteProject', + description: <<param('projectId', '', new UID(), 'Project unique ID.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('publisherForDeletes') + ->inject('authorization') + ->inject('team') + ->callback($this->action(...)); + } + + public function action( + string $projectId, + Response $response, + Database $dbForPlatform, + DeletePublisher $publisherForDeletes, + Authorization $authorization, + Document $team, + ) { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + if ($project->getAttribute('teamInternalId') !== $team->getSequence()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('projects', $project->getId()))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove project from DB'); + } + + $publisherForDeletes->enqueue(new DeleteMessage( + project: $project, + type: DELETE_TYPE_DOCUMENT, + document: $project, + )); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Organization/Services/Http.php b/src/Appwrite/Platform/Modules/Organization/Services/Http.php index 4a7cd69220..49a8f7d832 100644 --- a/src/Appwrite/Platform/Modules/Organization/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Organization/Services/Http.php @@ -4,6 +4,7 @@ namespace Appwrite\Platform\Modules\Organization\Services; use Appwrite\Platform\Modules\Organization\Http\Init as Init; use Appwrite\Platform\Modules\Organization\Http\Projects\Create as CreateProject; +use Appwrite\Platform\Modules\Organization\Http\Projects\Delete as DeleteProject; use Appwrite\Platform\Modules\Organization\Http\Projects\Get as GetProject; use Appwrite\Platform\Modules\Organization\Http\Projects\Update as UpdateProject; use Appwrite\Platform\Modules\Organization\Http\Projects\XList as ListProjects; @@ -23,5 +24,6 @@ class Http extends Service $this->addAction(ListProjects::getName(), new ListProjects()); $this->addAction(GetProject::getName(), new GetProject()); $this->addAction(UpdateProject::getName(), new UpdateProject()); + $this->addAction(DeleteProject::getName(), new DeleteProject()); } } From 4400eedbae2894c7e8d974f0a6f42290b8058dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 20 May 2026 13:56:47 +0200 Subject: [PATCH 31/34] Add proper test coverage --- .../Services/Organization/ProjectsBase.php | 458 ++++++++++++++++++ 1 file changed, 458 insertions(+) diff --git a/tests/e2e/Services/Organization/ProjectsBase.php b/tests/e2e/Services/Organization/ProjectsBase.php index eff7c827a3..4e18050670 100644 --- a/tests/e2e/Services/Organization/ProjectsBase.php +++ b/tests/e2e/Services/Organization/ProjectsBase.php @@ -2,14 +2,123 @@ namespace Tests\E2E\Services\Organization; +use Appwrite\Extend\Exception; use Tests\E2E\Client; +use Utopia\Database\Document; use Utopia\Database\Helpers\ID; +use Utopia\Database\Query; use Utopia\System\System; trait ProjectsBase { + private static array $cachedOrganization = []; + private static array $cachedProjectData = []; + + /** + * Setup and cache an organization (team) for organization endpoint tests. + */ + protected function setupOrganization(): array + { + if (!empty(self::$cachedOrganization)) { + return self::$cachedOrganization; + } + + $teamId = ID::unique(); + $team = null; + for ($i = 0; $i < 3; $i++) { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => $teamId, + 'name' => 'Organization Test', + ]); + if (\in_array($team['headers']['status-code'], [201, 409])) { + break; + } + \usleep(500000); + } + $this->assertContains($team['headers']['status-code'], [201, 409], 'Setup organization (team) failed'); + + self::$cachedOrganization = [ + 'teamId' => $team['body']['$id'] ?? $teamId, + ]; + + return self::$cachedOrganization; + } + + protected function getOrganizationHeaders(): array + { + $organization = $this->setupOrganization(); + + return array_merge($this->getHeaders(), [ + 'x-appwrite-organization' => $organization['teamId'], + ]); + } + + /** + * Setup and cache a project created via organization endpoints. + */ + protected function setupOrganizationProject(): array + { + if (!empty(self::$cachedProjectData)) { + return self::$cachedProjectData; + } + + $project = null; + for ($i = 0; $i < 3; $i++) { + $project = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Organization Project Test', + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + if ($project['headers']['status-code'] === 201) { + break; + } + \usleep(500000); + } + $this->assertEquals(201, $project['headers']['status-code'], 'Setup organization project failed'); + + self::$cachedProjectData = [ + 'projectId' => $project['body']['$id'], + 'teamId' => $this->setupOrganization()['teamId'], + ]; + + return self::$cachedProjectData; + } + public function testCreateProject(): void { + $organization = $this->setupOrganization(); + $teamId = $organization['teamId']; + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Organization Project Test', + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals('Organization Project Test', $response['body']['name']); + $this->assertEquals($teamId, $response['body']['teamId']); + $this->assertEquals(PROJECT_STATUS_ACTIVE, $response['body']['status']); + $this->assertArrayHasKey('platforms', $response['body']); + $this->assertArrayHasKey('webhooks', $response['body']); + $this->assertArrayHasKey('keys', $response['body']); + + /** + * Test for FAILURE - missing organization header + */ $response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -19,8 +128,357 @@ trait ProjectsBase 'region' => System::getEnv('_APP_REGION', 'default'), ]); + $this->assertEquals(404, $response['headers']['status-code']); + + /** + * Test for FAILURE - empty name + */ + $response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'projectId' => ID::unique(), + 'name' => '', + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + public function testCreateDuplicateProject(): void + { + $organization = $this->setupOrganization(); + $projectId = ID::unique(); + + $response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'projectId' => $projectId, + 'name' => 'Original Organization Project', + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** + * Test for FAILURE - duplicate project ID + */ + $response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'projectId' => $projectId, + 'name' => 'Duplicate Organization Project', + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertEquals(409, $response['headers']['status-code']); + $this->assertEquals(409, $response['body']['code']); + $this->assertEquals(Exception::PROJECT_ALREADY_EXISTS, $response['body']['type']); + } + + public function testGetProject(): void + { + $data = $this->setupOrganizationProject(); + $projectId = $data['projectId']; + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals($projectId, $response['body']['$id']); $this->assertEquals('Organization Project Test', $response['body']['name']); + $this->assertEquals(PROJECT_STATUS_ACTIVE, $response['body']['status']); + $this->assertArrayHasKey('platforms', $response['body']); + $this->assertArrayHasKey('webhooks', $response['body']); + $this->assertArrayHasKey('keys', $response['body']); + + /** + * Test for FAILURE - project not found + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects/' . ID::unique(), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders())); + + $this->assertEquals(404, $response['headers']['status-code']); + + /** + * Test for FAILURE - project from different organization + */ + $otherTeam = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'Other Organization', + ]); + $this->assertContains($otherTeam['headers']['status-code'], [201, 409]); + $otherTeamId = $otherTeam['body']['$id'] ?? $otherTeam['body']['teamId']; + + $otherProject = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], array_merge($this->getHeaders(), [ + 'x-appwrite-organization' => $otherTeamId, + ])), [ + 'projectId' => ID::unique(), + 'name' => 'Other Organization Project', + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + $this->assertEquals(201, $otherProject['headers']['status-code']); + $otherProjectId = $otherProject['body']['$id']; + + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects/' . $otherProjectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders())); + + $this->assertEquals(404, $response['headers']['status-code']); + } + + public function testUpdateProject(): void + { + $data = $this->setupOrganizationProject(); + $projectId = $data['projectId']; + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_PATCH, '/v1/organization/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'name' => 'Updated Organization Project', + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($projectId, $response['body']['$id']); + $this->assertEquals('Updated Organization Project', $response['body']['name']); + + /** + * Test for FAILURE - project not found + */ + $response = $this->client->call(Client::METHOD_PATCH, '/v1/organization/projects/' . ID::unique(), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'name' => 'Should Fail', + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + + /** + * Test for FAILURE - empty name + */ + $response = $this->client->call(Client::METHOD_PATCH, '/v1/organization/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'name' => '', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + public function testDeleteProject(): void + { + $organization = $this->setupOrganization(); + + $project = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Project To Delete', + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + $projectId = $project['body']['$id']; + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_DELETE, '/v1/organization/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders())); + + $this->assertEquals(204, $response['headers']['status-code']); + + // Verify project is actually deleted + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders())); + + $this->assertEquals(404, $response['headers']['status-code']); + + /** + * Test for FAILURE - project not found (already deleted) + */ + $response = $this->client->call(Client::METHOD_DELETE, '/v1/organization/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders())); + + $this->assertEquals(404, $response['headers']['status-code']); + } + + public function testListProjects(): void + { + $organization = $this->setupOrganization(); + $teamId = $organization['teamId']; + + // Create a second project in the same organization + $project2 = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Second Organization Project', + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertEquals(201, $project2['headers']['status-code']); + $project2Id = $project2['body']['$id']; + + /** + * Test for SUCCESS - basic list + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertGreaterThan(0, count($response['body']['projects'])); + $this->assertGreaterThan(0, $response['body']['total']); + + /** + * Test search queries + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders(), [ + 'search' => 'Second Organization Project', + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertGreaterThan(0, $response['body']['total']); + $this->assertIsArray($response['body']['projects']); + $this->assertEquals('Second Organization Project', $response['body']['projects'][0]['name']); + + /** + * Test pagination with limit + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'queries' => [ + Query::limit(1)->toString(), + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['projects']); + + /** + * Test pagination with offset + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'queries' => [ + Query::offset(1)->toString(), + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + + /** + * Test query by name + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'queries' => [ + Query::equal('name', ['Second Organization Project'])->toString(), + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, count($response['body']['projects'])); + $this->assertEquals('Second Organization Project', $response['body']['projects'][0]['name']); + + /** + * Test cursor pagination + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['projects']); + + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'queries' => [ + Query::cursorAfter(new Document(['$id' => $response['body']['projects'][0]['$id']]))->toString(), + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + + /** + * Test for FAILURE - invalid cursor + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'queries' => [ + Query::cursorAfter(new Document(['$id' => 'unknown']))->toString(), + ], + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + public function testListProjectsQuerySelect(): void + { + $data = $this->setupOrganizationProject(); + $projectId = $data['projectId']; + + $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getOrganizationHeaders()), [ + 'queries' => [ + Query::select(['name'])->toString(), + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['projects']); + $this->assertEquals('Organization Project Test', $response['body']['projects'][0]['name']); } } From 238b4e447d46ffdcfb049ddd4ec3d5467874ccd2 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 21 May 2026 18:38:59 +1200 Subject: [PATCH 32/34] test: stabilize function logging executions --- .../Functions/FunctionsCustomServerTest.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index b1f07c3f9d..44d5d274da 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -2778,17 +2778,17 @@ class FunctionsCustomServerTest extends Scope $this->assertEmpty($executions['body']['executions'][0]['logs']); $this->assertEmpty($executions['body']['executions'][0]['errors']); - // Ensure executions count - $executions = $this->listExecutions($functionId); + $this->assertEventually(function () use ($functionId) { + $executions = $this->listExecutions($functionId); - $this->assertEquals(200, $executions['headers']['status-code']); - $this->assertCount(3, $executions['body']['executions']); + $this->assertEquals(200, $executions['headers']['status-code']); + $this->assertCount(3, $executions['body']['executions']); - // Double check logs and errors are empty - foreach ($executions['body']['executions'] as $execution) { - $this->assertEmpty($execution['logs']); - $this->assertEmpty($execution['errors']); - } + foreach ($executions['body']['executions'] as $execution) { + $this->assertEmpty($execution['logs']); + $this->assertEmpty($execution['errors']); + } + }, 10000, 500); $this->cleanupFunction($functionId); } From 4105f2fb38188caf4778d3c7e0957d9164581ad7 Mon Sep 17 00:00:00 2001 From: premtsd-code Date: Thu, 21 May 2026 08:39:44 +0100 Subject: [PATCH 33/34] Fix testCreateAppwriteMigration: statusCounters is populated after sync performMigrationSync waits for the migration to reach completed status before returning, so statusCounters is populated with success/error counts by the time the test asserts on it. Flip assertEmpty -> assertNotEmpty. --- tests/e2e/Services/Migrations/MigrationsBase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 115d5e5b72..f51a938c5e 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -224,7 +224,7 @@ trait MigrationsBase $this->assertEquals(Appwrite::getSupportedResources(), $response['resources']); $this->assertEquals('Appwrite', $response['source']); $this->assertEquals('Appwrite', $response['destination']); - $this->assertEmpty($response['statusCounters']); + $this->assertNotEmpty($response['statusCounters']); } /** From 041bbee5f45472052b47ce874e79554eb00831ae Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 21 May 2026 13:35:27 +0100 Subject: [PATCH 34/34] Use unique API key name in migration test + bump migration pin --- composer.lock | 8 ++++---- tests/e2e/Services/Migrations/MigrationsBase.php | 10 +++++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/composer.lock b/composer.lock index 8634289c82..8c803fa22c 100644 --- a/composer.lock +++ b/composer.lock @@ -4679,12 +4679,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "16ff6ce143a7aff20bc8c86007264bce4c767ca2" + "reference": "91140887b9ff8efcefe326857115bd37c90db1cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/16ff6ce143a7aff20bc8c86007264bce4c767ca2", - "reference": "16ff6ce143a7aff20bc8c86007264bce4c767ca2", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/91140887b9ff8efcefe326857115bd37c90db1cf", + "reference": "91140887b9ff8efcefe326857115bd37c90db1cf", "shasum": "" }, "require": { @@ -4744,7 +4744,7 @@ "source": "https://github.com/utopia-php/migration/tree/add-policies-migration", "issues": "https://github.com/utopia-php/migration/issues" }, - "time": "2026-05-20T12:08:45+00:00" + "time": "2026-05-21T12:22:42+00:00" }, { "name": "utopia-php/mongo", diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index f51a938c5e..f0c8f2963e 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -2554,10 +2554,14 @@ trait MigrationsBase 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]; + // Unique name so re-runs and parallel suites can't match a stale key + // left behind by a previous crashed run. + $keyName = 'Test API Key ' . ID::unique(); + // Create API key on source project $response = $this->client->call(Client::METHOD_POST, '/project/keys', $sourceHeaders, [ 'keyId' => ID::unique(), - 'name' => 'Test API Key', + 'name' => $keyName, 'scopes' => ['databases.read', 'databases.write'], 'expire' => null, ]); @@ -2596,7 +2600,7 @@ trait MigrationsBase $foundKey = null; foreach ($response['body']['keys'] as $k) { - if ($k['name'] === 'Test API Key') { + if ($k['name'] === $keyName) { $foundKey = $k; break; @@ -2604,7 +2608,7 @@ trait MigrationsBase } $this->assertNotNull($foundKey); - $this->assertEquals('Test API Key', $foundKey['name']); + $this->assertEquals($keyName, $foundKey['name']); $this->assertEqualsCanonicalizing(['databases.read', 'databases.write'], $foundKey['scopes']); $this->assertEmpty($foundKey['expire']); $this->assertNotEquals($apiKey['secret'], $foundKey['secret']);