From 130c2221ecfa3b183c4a51acd38fbff0dc52d23f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 3 Apr 2026 22:12:21 +0530 Subject: [PATCH 01/21] Fix VectorsDB metadata bootstrap race --- .../Http/VectorsDB/Collections/Create.php | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php index a7e2d68eac..787c7ae0d9 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php @@ -62,7 +62,7 @@ class Create extends CollectionAction new SDKResponse( code: SwooleResponse::STATUS_CODE_CREATED, model: $this->getResponseModel(), - ) + ), ], contentType: ContentType::JSON )) @@ -72,7 +72,7 @@ class Create extends CollectionAction ->param('dimension', null, new Range(MIN_VECTOR_DIMENSION, MAX_VECTOR_DIMENSION), 'Embedding dimension.') ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of permissions strings. By default, no user is granted with any permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) - ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->param('enabled', true, new Boolean, 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') @@ -95,7 +95,7 @@ class Create extends CollectionAction $permissions = Permission::aggregate($permissions) ?? []; try { - $collection = $dbForProject->createDocument('database_' . $database->getSequence(), new Document([ + $collection = $dbForProject->createDocument('database_'.$database->getSequence(), new Document([ '$id' => $collectionId, 'databaseInternalId' => $database->getSequence(), 'databaseId' => $databaseId, @@ -130,25 +130,27 @@ class Create extends CollectionAction $indexes[] = new Document($index); } try { - if (!$dbForDatabases->exists(null, Database::METADATA)) { - try { - $dbForDatabases->create(); - } catch (DuplicateException) { - } + // Bootstrap the database metadata without a separate existence + // check to avoid races when multiple first collections are created + // concurrently for the same VectorsDB database. + try { + $dbForDatabases->create(); + } catch (DuplicateException) { } $dbForDatabases->createCollection( - id: 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), + id: 'database_'.$database->getSequence().'_collection_'.$collection->getSequence(), permissions: $permissions, documentSecurity: $documentSecurity, - attributes:$attributes, - indexes:$indexes + attributes: $attributes, + indexes: $indexes ); // Create attribute and indexes metadata documents in the attributes and indexes collections // needed for the get and list calls $attributeDocs = array_map(function ($attributeConfig) use ($database, $collection, $databaseId, $collectionId, $dimension) { $key = \is_string($attributeConfig['$id']) ? $attributeConfig['$id'] : (string) $attributeConfig['$id']; + return new Document([ - '$id' => ID::custom($database->getSequence() . '_' . $collection->getSequence() . '_' . $key), + '$id' => ID::custom($database->getSequence().'_'.$collection->getSequence().'_'.$key), 'key' => $key, 'databaseInternalId' => $database->getSequence(), 'databaseId' => $databaseId, @@ -173,7 +175,7 @@ class Create extends CollectionAction $key = \is_string($indexConfig['$id']) ? $indexConfig['$id'] : (string) $indexConfig['$id']; return new Document([ - '$id' => ID::custom($database->getSequence() . '_' . $collection->getSequence() . '_' . $key), + '$id' => ID::custom($database->getSequence().'_'.$collection->getSequence().'_'.$key), 'key' => $key, 'status' => 'available', 'databaseInternalId' => $database->getSequence(), @@ -187,7 +189,7 @@ class Create extends CollectionAction ]); }, $collections['defaultIndexes']); - if (!empty($indexDocs)) { + if (! empty($indexDocs)) { $dbForProject->createDocuments('indexes', $indexDocs); } } catch (DuplicateException) { From a5f45b46e9dfbff8ec640cc9dd3ba7e4b708ec88 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 3 Apr 2026 23:41:44 +0530 Subject: [PATCH 02/21] Handle raced VectorsDB metadata bootstrap errors --- .../Http/VectorsDB/Collections/Create.php | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php index 787c7ae0d9..d03213d4b8 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php @@ -72,7 +72,7 @@ class Create extends CollectionAction ->param('dimension', null, new Range(MIN_VECTOR_DIMENSION, MAX_VECTOR_DIMENSION), 'Embedding dimension.') ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of permissions strings. By default, no user is granted with any permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) - ->param('enabled', true, new Boolean, 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') @@ -133,9 +133,23 @@ class Create extends CollectionAction // Bootstrap the database metadata without a separate existence // check to avoid races when multiple first collections are created // concurrently for the same VectorsDB database. - try { - $dbForDatabases->create(); - } catch (DuplicateException) { + for ($attempt = 0; $attempt < 5; $attempt++) { + try { + $dbForDatabases->create(); + break; + } catch (DuplicateException) { + break; + } catch (\Throwable $e) { + if ($dbForDatabases->exists(null, Database::METADATA)) { + break; + } + + if ($attempt === 4) { + throw $e; + } + + \usleep(100_000); + } } $dbForDatabases->createCollection( id: 'database_'.$database->getSequence().'_collection_'.$collection->getSequence(), From 3cb53f06047e3411893b45b49ef4671e3fe2ea85 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 3 Apr 2026 23:43:51 +0530 Subject: [PATCH 03/21] Drop unrelated formatting churn from VectorsDB fix --- .../Http/VectorsDB/Collections/Create.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php index d03213d4b8..0294790a9e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php @@ -62,7 +62,7 @@ class Create extends CollectionAction new SDKResponse( code: SwooleResponse::STATUS_CODE_CREATED, model: $this->getResponseModel(), - ), + ) ], contentType: ContentType::JSON )) @@ -95,7 +95,7 @@ class Create extends CollectionAction $permissions = Permission::aggregate($permissions) ?? []; try { - $collection = $dbForProject->createDocument('database_'.$database->getSequence(), new Document([ + $collection = $dbForProject->createDocument('database_' . $database->getSequence(), new Document([ '$id' => $collectionId, 'databaseInternalId' => $database->getSequence(), 'databaseId' => $databaseId, @@ -152,11 +152,11 @@ class Create extends CollectionAction } } $dbForDatabases->createCollection( - id: 'database_'.$database->getSequence().'_collection_'.$collection->getSequence(), + id: 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), permissions: $permissions, documentSecurity: $documentSecurity, - attributes: $attributes, - indexes: $indexes + attributes:$attributes, + indexes:$indexes ); // Create attribute and indexes metadata documents in the attributes and indexes collections // needed for the get and list calls @@ -164,7 +164,7 @@ class Create extends CollectionAction $key = \is_string($attributeConfig['$id']) ? $attributeConfig['$id'] : (string) $attributeConfig['$id']; return new Document([ - '$id' => ID::custom($database->getSequence().'_'.$collection->getSequence().'_'.$key), + '$id' => ID::custom($database->getSequence() . '_' . $collection->getSequence() . '_' . $key), 'key' => $key, 'databaseInternalId' => $database->getSequence(), 'databaseId' => $databaseId, @@ -189,7 +189,7 @@ class Create extends CollectionAction $key = \is_string($indexConfig['$id']) ? $indexConfig['$id'] : (string) $indexConfig['$id']; return new Document([ - '$id' => ID::custom($database->getSequence().'_'.$collection->getSequence().'_'.$key), + '$id' => ID::custom($database->getSequence() . '_' . $collection->getSequence() . '_' . $key), 'key' => $key, 'status' => 'available', 'databaseInternalId' => $database->getSequence(), @@ -203,7 +203,7 @@ class Create extends CollectionAction ]); }, $collections['defaultIndexes']); - if (! empty($indexDocs)) { + if (!empty($indexDocs)) { $dbForProject->createDocuments('indexes', $indexDocs); } } catch (DuplicateException) { From f3f2855fe5e74917cba658799ae3250fcecf4516 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 3 Apr 2026 23:44:19 +0530 Subject: [PATCH 04/21] Remove final formatting-only diff --- .../Modules/Databases/Http/VectorsDB/Collections/Create.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php index 0294790a9e..58433c7deb 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php @@ -162,7 +162,6 @@ class Create extends CollectionAction // needed for the get and list calls $attributeDocs = array_map(function ($attributeConfig) use ($database, $collection, $databaseId, $collectionId, $dimension) { $key = \is_string($attributeConfig['$id']) ? $attributeConfig['$id'] : (string) $attributeConfig['$id']; - return new Document([ '$id' => ID::custom($database->getSequence() . '_' . $collection->getSequence() . '_' . $key), 'key' => $key, From c978b6f34f7a18a740cdec915f2de0b895f1ab81 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 3 Apr 2026 23:58:25 +0530 Subject: [PATCH 05/21] Stabilize function deployment activation in tests --- tests/e2e/Services/Functions/FunctionsBase.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/e2e/Services/Functions/FunctionsBase.php b/tests/e2e/Services/Functions/FunctionsBase.php index af426d5221..42976cda84 100644 --- a/tests/e2e/Services/Functions/FunctionsBase.php +++ b/tests/e2e/Services/Functions/FunctionsBase.php @@ -100,6 +100,24 @@ trait FunctionsBase 'x-appwrite-key' => $this->getProject()['apiKey'], ])); $this->assertNotEquals(401, $function['headers']['status-code'], 'Auth failed while polling function activation'); + + if ( + ($function['body']['deploymentId'] ?? '') !== $deploymentId + && ($function['body']['latestDeploymentId'] ?? '') === $deploymentId + && ($function['body']['latestDeploymentStatus'] ?? '') === 'ready' + ) { + $activation = $this->updateFunctionDeployment($functionId, $deploymentId); + $this->assertContains( + $activation['headers']['status-code'], + [200, 409], + 'Deployment activation request failed: ' . json_encode($activation['body'], JSON_PRETTY_PRINT) + ); + + if ($activation['headers']['status-code'] === 200) { + $function = $activation; + } + } + $this->assertEquals($deploymentId, $function['body']['deploymentId'] ?? '', 'Deployment is not activated, deployment: ' . json_encode($function['body'], JSON_PRETTY_PRINT)); }, 120000, 500); } From 860cf8cd5741f791ce3314b5ae56601fc374a8b6 Mon Sep 17 00:00:00 2001 From: "Eldad A. Fux" Date: Sat, 4 Apr 2026 08:32:46 +0200 Subject: [PATCH 06/21] Revise README content and structure Updated the README to enhance clarity and remove outdated information. --- README.md | 61 ++++++++++++++++++------------------------------------- 1 file changed, 20 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 9815229e43..fe34cba676 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,16 @@ -> We just announced DB operators for Appwrite Databases - [Learn more](https://appwrite.io/blog/post/announcing-db-operators) - -> Appwrite Cloud is now Generally Available - [Learn more](https://appwrite.io/cloud-ga) - -> [Get started with Appwrite](https://apwr.dev/appcloud) +image

- Appwrite banner, with logo and text saying -
-
- Appwrite is a best-in-class, developer-first platform that gives builders everything they need to create scalable, stable, and production-ready software, fast. +

Appwrite

+ Appwrite is an open-source, all-in-one development platform. Use built-in backend infrastructure and web hosting, all from a single place.

- - -[![We're Hiring label](https://img.shields.io/static/v1?label=We're&message=Hiring&color=blue&style=flat-square)](https://appwrite.io/company/careers) -[![Hacktoberfest label](https://img.shields.io/static/v1?label=hacktoberfest&message=ready&color=191120&style=flat-square)](https://hacktoberfest.appwrite.io) -[![Discord label](https://img.shields.io/discord/564160730845151244?label=discord&style=flat-square)](https://appwrite.io/discord?r=Github) -[![Build Status label](https://img.shields.io/github/actions/workflow/status/appwrite/appwrite/tests.yml?branch=master&label=tests&style=flat-square)](https://github.com/appwrite/appwrite/actions) -[![X Account label](https://img.shields.io/twitter/follow/appwrite?color=00acee&label=twitter&style=flat-square)](https://twitter.com/appwrite) - - - - +[![Discord](https://img.shields.io/badge/chat-5865F2?style=flat-square&logo=discord&logoColor=white)](https://appwrite.io/discord) +[![X](https://img.shields.io/badge/follow-000000?style=flat-square&logo=x&logoColor=white)](https://x.com/appwrite) +[![Appwrite Cloud](https://img.shields.io/badge/Cloud-F02E65?style=flat-square&logo=icloud&logoColor=white)](https://cloud.appwrite.io) English | [简体中文](README-CN.md) @@ -32,8 +18,6 @@ Appwrite is an end-to-end platform for building Web, Mobile, Native, or Backend Using Appwrite, you can easily integrate your app with user authentication and multiple sign-in methods, a database for storing and querying users and team data, storage and file management, image manipulation, Cloud Functions, messaging, and [more services](https://appwrite.io/docs). -![Appwrite project dashboard showing various Appwrite features](public/images/github.png) - Find out more at: [https://appwrite.io](https://appwrite.io). Table of Contents: @@ -191,28 +175,23 @@ Below is a list of currently supported platforms and languages. If you would lik #### Client -- :white_check_mark:   [Web](https://github.com/appwrite/sdk-for-web) (Maintained by the Appwrite Team) -- :white_check_mark:   [Flutter](https://github.com/appwrite/sdk-for-flutter) (Maintained by the Appwrite Team) -- :white_check_mark:   [Apple](https://github.com/appwrite/sdk-for-apple) (Maintained by the Appwrite Team) -- :white_check_mark:   [Android](https://github.com/appwrite/sdk-for-android) (Maintained by the Appwrite Team) -- :white_check_mark:   [React Native](https://github.com/appwrite/sdk-for-react-native) - **Beta** (Maintained by the Appwrite Team) +- :white_check_mark:   [Web](https://github.com/appwrite/sdk-for-web) +- :white_check_mark:   [Flutter](https://github.com/appwrite/sdk-for-flutter) +- :white_check_mark:   [Apple](https://github.com/appwrite/sdk-for-apple) +- :white_check_mark:   [Android](https://github.com/appwrite/sdk-for-android) +- :white_check_mark:   [React Native](https://github.com/appwrite/sdk-for-react-native) #### Server -- :white_check_mark:   [NodeJS](https://github.com/appwrite/sdk-for-node) (Maintained by the Appwrite Team) -- :white_check_mark:   [PHP](https://github.com/appwrite/sdk-for-php) (Maintained by the Appwrite Team) -- :white_check_mark:   [Dart](https://github.com/appwrite/sdk-for-dart) (Maintained by the Appwrite Team) -- :white_check_mark:   [Deno](https://github.com/appwrite/sdk-for-deno) (Maintained by the Appwrite Team) -- :white_check_mark:   [Ruby](https://github.com/appwrite/sdk-for-ruby) (Maintained by the Appwrite Team) -- :white_check_mark:   [Python](https://github.com/appwrite/sdk-for-python) (Maintained by the Appwrite Team) -- :white_check_mark:   [Kotlin](https://github.com/appwrite/sdk-for-kotlin) (Maintained by the Appwrite Team) -- :white_check_mark:   [Swift](https://github.com/appwrite/sdk-for-swift) (Maintained by the Appwrite Team) -- :white_check_mark:   [.NET](https://github.com/appwrite/sdk-for-dotnet) - **Beta** (Maintained by the Appwrite Team) - -#### Community - -- :white_check_mark:   [Appcelerator Titanium](https://github.com/m1ga/ti.appwrite) (Maintained by [Michael Gangolf](https://github.com/m1ga/)) -- :white_check_mark:   [Godot Engine](https://github.com/GodotNuts/appwrite-sdk) (Maintained by [fenix-hub @GodotNuts](https://github.com/fenix-hub)) +- :white_check_mark:   [NodeJS](https://github.com/appwrite/sdk-for-node) +- :white_check_mark:   [PHP](https://github.com/appwrite/sdk-for-php) +- :white_check_mark:   [Dart](https://github.com/appwrite/sdk-for-dart) +- :white_check_mark:   [Deno](https://github.com/appwrite/sdk-for-deno) +- :white_check_mark:   [Ruby](https://github.com/appwrite/sdk-for-ruby) +- :white_check_mark:   [Python](https://github.com/appwrite/sdk-for-python) +- :white_check_mark:   [Kotlin](https://github.com/appwrite/sdk-for-kotlin) +- :white_check_mark:   [Swift](https://github.com/appwrite/sdk-for-swift) +- :white_check_mark:   [.NET](https://github.com/appwrite/sdk-for-dotnet) Looking for more SDKs? - Help us by contributing a pull request to our [SDK Generator](https://github.com/appwrite/sdk-generator)! From cf8519bfa51dcc9a6925dcb4d4b6dda50948ea4d Mon Sep 17 00:00:00 2001 From: "Eldad A. Fux" Date: Sat, 4 Apr 2026 08:41:23 +0200 Subject: [PATCH 07/21] Revise Appwrite description and product details Updated the description of Appwrite to emphasize its open-source nature and capabilities. Added detailed product descriptions for Appwrite services. --- README.md | 42 ++++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index fe34cba676..ed83252e2f 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,15 @@ English | [简体中文](README-CN.md) -Appwrite is an end-to-end platform for building Web, Mobile, Native, or Backend apps, packaged as a set of Docker microservices. It includes both a backend server and a fully integrated hosting solution for deploying static and server-side rendered frontends. Appwrite abstracts the complexity and repetitiveness required to build modern apps from scratch and allows you to build secure, full-stack applications faster. +Appwrite is an open-source development platform for building web, mobile, and AI applications. It brings together backend infrastructure and web hosting in one place, so teams can build, ship, and scale without stitching together a fragmented stack. Appwrite is available as a managed cloud platform and can also be self-hosted on infrastructure you control. -Using Appwrite, you can easily integrate your app with user authentication and multiple sign-in methods, a database for storing and querying users and team data, storage and file management, image manipulation, Cloud Functions, messaging, and [more services](https://appwrite.io/docs). +With Appwrite, you can add authentication, databases, storage, functions, messaging, realtime capabilities, and integrated web app hosting through Sites. It is designed to reduce the repetitive backend work required to launch modern products while giving developers secure primitives and flexible APIs to build production-ready applications faster. -Find out more at: [https://appwrite.io](https://appwrite.io). +Find out more at [https://appwrite.io](https://appwrite.io). Table of Contents: +- [Products](#products) - [Installation \& Setup](#installation--setup) - [Self-Hosting](#self-hosting) - [Unix](#unix) @@ -31,17 +32,31 @@ Table of Contents: - [Upgrade from an Older Version](#upgrade-from-an-older-version) - [One-Click Setups](#one-click-setups) - [Getting Started](#getting-started) - - [Products](#products) - [SDKs](#sdks) - [Client](#client) - [Server](#server) - - [Community](#community) - [Architecture](#architecture) - [Contributing](#contributing) - [Security](#security) - [Follow Us](#follow-us) - [License](#license) + +## Products + +- **[Appwrite Auth](https://appwrite.io/docs/products/authentication)** - Secure user authentication with multiple login methods including email/password, SMS, OAuth, anonymous sessions, and magic links. Includes session management, multi-factor authentication, and user verification flows. + +- **[Appwrite Databases](https://appwrite.io/docs/products/databases)** - Scalable structured data storage with support for databases, tables, and rows. Includes querying, pagination, indexing, and relationships to model complex application data. + +- **[Appwrite Storage](https://appwrite.io/docs/products/storage)** - Secure file storage with support for uploads, downloads, encryption, compression, and file transformations for media and assets. + +- **[Appwrite Functions](https://appwrite.io/docs/products/functions)** - Serverless compute platform to run custom backend logic in isolated runtimes, triggered by events or scheduled jobs.15 runtimes supported. + +- **[Appwrite Messaging](https://appwrite.io/docs/products/messaging)** - Multi-channel messaging system for sending emails, SMS, and push notifications to users for engagement, alerts, and transactional workflows. + +- **[Appwrite Sites](https://appwrite.io/docs/products/sites)** - Integrated hosting platform to deploy and scale web applications with support for custom domains, SSR, and seamless backend integration. Git integration and previews are supported. + + ## Installation & Setup The easiest way to get started with Appwrite is by [signing up for Appwrite Cloud](https://cloud.appwrite.io/). While Appwrite Cloud is in public beta, you can build with Appwrite completely free, and we won't collect your credit card information. @@ -152,23 +167,6 @@ Getting started with Appwrite is as easy as creating a new project, choosing you | | [Quick start for Kotlin](https://appwrite.io/docs/quick-starts/kotlin) | | | [Quick start for Swift](https://appwrite.io/docs/quick-starts/swift) | -### Products - -- [**Account**](https://appwrite.io/docs/references/cloud/client-web/account) - Manage current user authentication and account. Track and manage the user sessions, devices, sign-in methods, and security logs. -- [**Users**](https://appwrite.io/docs/server/users) - Manage and list all project users when building backend integrations with Server SDKs. -- [**Teams**](https://appwrite.io/docs/references/cloud/client-web/teams) - Manage and group users in teams. Manage memberships, invites, and user roles within a team. -- [**Databases**](https://appwrite.io/docs/references/cloud/client-web/databases) - Manage databases, collections, and documents. Read, create, update, and delete documents and filter lists of document collections using advanced filters. -- [**Storage**](https://appwrite.io/docs/references/cloud/client-web/storage) - Manage storage files. Read, create, delete, and preview files. Manipulate the preview of your files to perfectly fit your app. All files are scanned by ClamAV and stored in a secure and encrypted way. -- [**Functions**](https://appwrite.io/docs/references/cloud/server-nodejs/functions) - Customize your Appwrite project by executing your custom code in a secure, isolated environment. You can trigger your code on any Appwrite system event either manually or using a CRON schedule. -- [**Messaging**](https://appwrite.io/docs/references/cloud/client-web/messaging) - Communicate with your users through push notifications, emails, and SMS text messages using Appwrite Messaging. -- [**Realtime**](https://appwrite.io/docs/realtime) - Listen to real-time events for any of your Appwrite services including users, storage, functions, databases, and more. -- [**Locale**](https://appwrite.io/docs/references/cloud/client-web/locale) - Track your user's location and manage your app locale-based data. -- [**Avatars**](https://appwrite.io/docs/references/cloud/client-web/avatars) - Manage your users' avatars, countries' flags, browser icons, and credit card symbols. Generate QR codes from links or plaintext strings. -- [**MCP**](https://appwrite.io/docs/tooling/mcp) - Use Appwrite's Model Context Protocol (MCP) server to allow LLMs and AI tools like Claude Desktop, Cursor, and Windsurf Editor to directly interact with your Appwrite project through natural language. -- [**Sites**](https://appwrite.io/docs/products/sites) - Develop, deploy, and scale your web applications directly from Appwrite, alongside your backend. - -For the complete API documentation, visit [https://appwrite.io/docs](https://appwrite.io/docs). For more tutorials, news and announcements check out our [blog](https://medium.com/appwrite-io) and [Discord Server](https://discord.gg/GSeTUeA). - ### SDKs Below is a list of currently supported platforms and languages. If you would like to help us add support to your platform of choice, you can go over to our [SDK Generator](https://github.com/appwrite/sdk-generator) project and view our [contribution guide](https://github.com/appwrite/sdk-generator/blob/master/CONTRIBUTING.md). From 66e68aea143eda00130c3b793960940b788eb4cd Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 5 Apr 2026 19:37:29 +0530 Subject: [PATCH 08/21] fix: fail specs when docs are missing --- app/cli.php | 6 +++++- src/Appwrite/SDK/Specification/Format.php | 19 +++++++++++++++++++ .../SDK/Specification/Format/OpenAPI3.php | 4 ++-- .../SDK/Specification/Format/Swagger2.php | 4 ++-- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/app/cli.php b/app/cli.php index b8721320be..b636707f1c 100644 --- a/app/cli.php +++ b/app/cli.php @@ -329,17 +329,20 @@ $setResource('bus', function (Registry $register) use ($cli) { $setResource('telemetry', fn () => new NoTelemetry(), []); +$exitCode = 0; + $cli ->error() ->inject('error') ->inject('logError') - ->action(function (Throwable $error, callable $logError) use ($taskName) { + ->action(function (Throwable $error, callable $logError) use ($taskName, &$exitCode) { call_user_func_array($logError, [ $error, 'Task', $taskName, ]); + $exitCode = 1; Timer::clearAll(); }); @@ -348,3 +351,4 @@ $cli->shutdown()->action(fn () => Timer::clearAll()); Runtime::enableCoroutine(SWOOLE_HOOK_ALL); require_once __DIR__ . '/init/span.php'; run($cli->run(...)); +Console::exit($exitCode); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 7a867c5b91..dd4d378345 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -210,6 +210,25 @@ abstract class Format return $this->services; } + protected function getDescriptionContents(?string $description): string + { + if ($description === null || $description === '') { + return ''; + } + + if (!\str_ends_with($description, '.md')) { + return $description; + } + + $contents = @\file_get_contents($description); + + if ($contents === false) { + throw new \RuntimeException('Documentation file not found or unreadable: ' . $description); + } + + return $contents; + } + protected function getRequestEnumName(string $service, string $method, string $param): ?string { /* `$service` is `$namespace` */ diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 753a0dc52f..41ed386e30 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -128,7 +128,7 @@ class OpenAPI3 extends Format if ($desc === null) { $desc = ''; } - $descContents = \str_ends_with($desc, '.md') ? \file_get_contents($desc) : $desc; + $descContents = $this->getDescriptionContents($desc); $temp = [ 'summary' => $route->getDesc(), @@ -193,7 +193,7 @@ class OpenAPI3 extends Format 'parameters' => [], 'required' => [], 'responses' => [], - 'description' => ($desc) ? \file_get_contents($desc) : '', + 'description' => $this->getDescriptionContents($desc), 'demo' => \strtolower($namespace) . '/' . Template::fromCamelCaseToDash($methodObj->getMethodName()) . '.md', 'public' => $methodObj->isPublic(), ]; diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 3e9ac891fa..dc65bea215 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -129,7 +129,7 @@ class Swagger2 extends Format if ($desc === null) { $desc = ''; } - $descContents = \str_ends_with($desc, '.md') ? \file_get_contents($desc) : $desc; + $descContents = $this->getDescriptionContents($desc); $temp = [ 'summary' => $route->getDesc(), @@ -201,7 +201,7 @@ class Swagger2 extends Format 'parameters' => [], 'required' => [], 'responses' => [], - 'description' => ($desc) ? \file_get_contents($desc) : '', + 'description' => $this->getDescriptionContents($desc), 'demo' => \strtolower($namespace) . '/' . Template::fromCamelCaseToDash($methodObj->getMethodName()) . '.md', 'public' => $methodObj->isPublic(), ]; From 9be447aacf8b66289a09430410d30e7eeeaf961d Mon Sep 17 00:00:00 2001 From: shimon Date: Sun, 5 Apr 2026 17:20:31 +0300 Subject: [PATCH 09/21] Update enqueue timer and improve schedule function logic Reduced the ENQUEUE_TIMER constant from 60 seconds to 30 seconds. Modified the condition for currentTick to use less than or equal to (<=) instead of less than (<) for better accuracy in scheduling. Changed return statement to continue in case of missing schedule key to enhance flow control. --- src/Appwrite/Platform/Tasks/ScheduleFunctions.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php index 88725a190a..69f105652c 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php +++ b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php @@ -61,7 +61,7 @@ class ScheduleFunctions extends ScheduleBase $nextDate = $cron->getNextRunDate(); $next = DateTime::format($nextDate); - $currentTick = $next < $timeFrame; + $currentTick = $next <= $timeFrame; if (!$currentTick) { continue; @@ -88,7 +88,7 @@ class ScheduleFunctions extends ScheduleBase $scheduleKey = $delayConfig['key']; // Ensure schedule was not deleted if (!\array_key_exists($scheduleKey, $this->schedules)) { - return; + continue; } $schedule = $this->schedules[$scheduleKey]; From 5ab28ad99acaf946db36438cdbe126ba6ebf18f7 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 5 Apr 2026 19:52:48 +0530 Subject: [PATCH 10/21] docs: add missing json migration references --- docs/references/migrations/migration-json-export.md | 1 + docs/references/migrations/migration-json-import.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 docs/references/migrations/migration-json-export.md create mode 100644 docs/references/migrations/migration-json-import.md diff --git a/docs/references/migrations/migration-json-export.md b/docs/references/migrations/migration-json-export.md new file mode 100644 index 0000000000..8a955c5990 --- /dev/null +++ b/docs/references/migrations/migration-json-export.md @@ -0,0 +1 @@ +Export documents to a JSON file from your Appwrite database. This endpoint allows you to export documents to a JSON file stored in a secure internal bucket. You'll receive an email with a download link when the export is complete. diff --git a/docs/references/migrations/migration-json-import.md b/docs/references/migrations/migration-json-import.md new file mode 100644 index 0000000000..2eeeaf5619 --- /dev/null +++ b/docs/references/migrations/migration-json-import.md @@ -0,0 +1 @@ +Import documents from a JSON file into your Appwrite database. This endpoint allows you to import documents from a JSON file uploaded to Appwrite Storage bucket. From 5d1da00138c87a7b9e6c082a5feb6413ce57ee92 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 5 Apr 2026 20:12:25 +0530 Subject: [PATCH 11/21] refactor: remove redundant desc guards --- src/Appwrite/SDK/Specification/Format/OpenAPI3.php | 3 --- src/Appwrite/SDK/Specification/Format/Swagger2.php | 3 --- 2 files changed, 6 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 41ed386e30..88f577eac6 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -125,9 +125,6 @@ class OpenAPI3 extends Format $namespace = $sdk->getNamespace() ?? 'default'; - if ($desc === null) { - $desc = ''; - } $descContents = $this->getDescriptionContents($desc); $temp = [ diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index dc65bea215..f9c79431f0 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -126,9 +126,6 @@ class Swagger2 extends Format $sdkPlatforms = array_values(array_unique($sdkPlatforms)); $namespace = $sdk->getNamespace() ?? 'default'; - if ($desc === null) { - $desc = ''; - } $descContents = $this->getDescriptionContents($desc); $temp = [ From 44f3bbae03115024ab3132d88c4ed86757aa64ac Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 6 Apr 2026 01:40:07 +0000 Subject: [PATCH 12/21] fix: add CORS headers to error responses The Http::error() handler was missing CORS headers, causing browsers to block error responses (e.g. 403 PROJECT_PAUSED) with a generic CORS error instead of showing the actual error message. This injects the cors resource into the error handler and adds CORS headers before sending the error response, matching the pattern already used in Http::init(). Co-Authored-By: Claude Opus 4.6 (1M context) --- app/controllers/general.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/controllers/general.php b/app/controllers/general.php index 3bf5f027f2..3f8adeb368 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -1196,7 +1196,8 @@ Http::error() ->inject('bus') ->inject('devKey') ->inject('authorization') - ->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, Bus $bus, Document $devKey, Authorization $authorization) { + ->inject('cors') + ->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, Bus $bus, Document $devKey, Authorization $authorization, Cors $cors) { $trace = $error->getTrace(); foreach (array_slice($trace, 0, 100) as $index => $traceEntry) { @@ -1493,6 +1494,10 @@ Http::error() 'type' => $type, ]; + foreach ($cors->headers($request->getOrigin()) as $name => $value) { + $response->addHeader($name, $value); + } + $response ->addHeader('Cache-Control', 'no-cache, no-store, must-revalidate') ->addHeader('Expires', '0') From ba2584987136a5e4e430891e16563e313ff50d13 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 6 Apr 2026 02:59:08 +0000 Subject: [PATCH 13/21] fix: resolve cors safely in error handler to avoid cascading failures - Remove cors from inject chain; resolve via getResource() inside try-catch so DB failures don't cascade when resolving the cors resource dependency chain (cors -> allowedHostnames -> rule -> DB) - Use override:true on addHeader to prevent duplicate CORS headers when init() already set them before the exception was thrown - Degrades gracefully: if cors resolution fails, error response is sent without CORS headers (same behavior as before this PR) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/controllers/general.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/app/controllers/general.php b/app/controllers/general.php index 3f8adeb368..3eeeef3fae 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -1196,8 +1196,7 @@ Http::error() ->inject('bus') ->inject('devKey') ->inject('authorization') - ->inject('cors') - ->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, Bus $bus, Document $devKey, Authorization $authorization, Cors $cors) { + ->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, Bus $bus, Document $devKey, Authorization $authorization) { $trace = $error->getTrace(); foreach (array_slice($trace, 0, 100) as $index => $traceEntry) { @@ -1494,8 +1493,17 @@ Http::error() 'type' => $type, ]; - foreach ($cors->headers($request->getOrigin()) as $name => $value) { - $response->addHeader($name, $value); + // Add CORS headers to error responses so browsers can read the error. + // Wrapped in try-catch: if the error itself is a DB failure, resolving + // the cors resource (which depends on rule -> DB) would cascade. + // Uses override:true to avoid duplicate headers if init() already set them. + try { + $cors = $utopia->getResource('cors'); + foreach ($cors->headers($request->getOrigin()) as $name => $value) { + $response->addHeader($name, $value, override: true); + } + } catch (Throwable) { + // Degrade gracefully - error response without CORS is no worse than before. } $response From cb74a5756a81d0b961756583ab348fbd2eccc894 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 6 Apr 2026 10:20:18 +0530 Subject: [PATCH 14/21] Remove request and response static state --- app/controllers/general.php | 4 ++-- src/Appwrite/Utopia/Request.php | 18 ++++++++--------- src/Appwrite/Utopia/Response.php | 31 +++++++++++++++++++++++++++--- tests/unit/Utopia/RequestTest.php | 15 +++++++++++++++ tests/unit/Utopia/ResponseTest.php | 23 ++++++++++++++++++++++ 5 files changed, 77 insertions(+), 14 deletions(-) diff --git a/app/controllers/general.php b/app/controllers/general.php index 3bf5f027f2..dcc5764bdd 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -868,7 +868,7 @@ Http::init() * Request format */ $route = $utopia->getRoute(); - Request::setRoute($route); + $request->setRoute($route); if ($route === null) { $response->setStatusCode(404); @@ -1019,7 +1019,7 @@ Http::init() return; } $route = $request->getRoute(); - if ($route->getLabel('origin', false) === '*') { + if ($route?->getLabel('origin', false) === '*') { return; } if (!$originValidator->isValid($origin)) { diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index 9428ff9d88..ed602ecdd5 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -17,7 +17,7 @@ class Request extends UtopiaRequest * @var array */ private array $filters = []; - private static ?Route $route = null; + private ?Route $route = null; public function __construct(SwooleRequest $request) { @@ -34,11 +34,11 @@ class Request extends UtopiaRequest { $parameters = parent::getParams(); - if (!$this->hasFilters() || !self::hasRoute()) { + if (!$this->hasFilters() || !$this->hasRoute()) { return $parameters; } - $methods = self::getRoute()->getLabel('sdk', null); + $methods = $this->getRoute()?->getLabel('sdk', null); if (empty($methods)) { return $parameters; @@ -131,9 +131,9 @@ class Request extends UtopiaRequest * * @return void */ - public static function setRoute(?Route $route): void + public function setRoute(?Route $route): void { - self::$route = $route; + $this->route = $route; } /** @@ -141,9 +141,9 @@ class Request extends UtopiaRequest * * @return Route|null */ - public static function getRoute(): ?Route + public function getRoute(): ?Route { - return self::$route; + return $this->route; } /** @@ -151,9 +151,9 @@ class Request extends UtopiaRequest * * @return bool */ - public static function hasRoute(): bool + public function hasRoute(): bool { - return self::$route !== null; + return $this->route !== null; } /** diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index e01dc58bf6..649b0562a5 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -8,6 +8,7 @@ use Appwrite\Utopia\Response\Filter; use Appwrite\Utopia\Response\Model; use Exception; use JsonException; +use Swoole\Coroutine; use Swoole\Http\Response as SwooleHTTPResponse; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; @@ -19,6 +20,8 @@ use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; */ class Response extends SwooleResponse { + private const SHOW_SENSITIVE_CONTEXT_KEY = '__appwrite_response_show_sensitive'; + // General public const MODEL_NONE = 'none'; public const MODEL_ANY = 'any'; @@ -509,7 +512,7 @@ class Response extends SwooleResponse $isPrivilegedUser = $user->isPrivileged($roles); $isAppUser = $user->isApp($roles); - if ((!$isPrivilegedUser && !$isAppUser) && !self::$showSensitive) { + if ((!$isPrivilegedUser && !$isAppUser) && !self::isShowingSensitive()) { $data->setAttribute($key, ''); } } @@ -666,14 +669,36 @@ class Response extends SwooleResponse */ public static function showSensitive(callable $callback): array { + $previous = self::isShowingSensitive(); + try { - self::$showSensitive = true; + self::setShowSensitive(true); return $callback(); } finally { - self::$showSensitive = false; + self::setShowSensitive($previous); } } + private static function isShowingSensitive(): bool + { + if (Coroutine::getCid() !== -1) { + return (bool) (Coroutine::getContext()[self::SHOW_SENSITIVE_CONTEXT_KEY] ?? false); + } + + return self::$showSensitive; + } + + private static function setShowSensitive(bool $value): void + { + if (Coroutine::getCid() !== -1) { + Coroutine::getContext()[self::SHOW_SENSITIVE_CONTEXT_KEY] = $value; + + return; + } + + self::$showSensitive = $value; + } + private ?Authorization $authorization = null; private ?DBUser $user = null; diff --git a/tests/unit/Utopia/RequestTest.php b/tests/unit/Utopia/RequestTest.php index 78a3717c38..d5cd5d800a 100644 --- a/tests/unit/Utopia/RequestTest.php +++ b/tests/unit/Utopia/RequestTest.php @@ -147,6 +147,21 @@ class RequestTest extends TestCase $this->assertSame('unexpected', $params['extra']); } + public function testRouteIsScopedToRequestInstance(): void + { + $firstRequest = new Request(new SwooleRequest()); + $secondRequest = new Request(new SwooleRequest()); + + $firstRoute = new Route(Request::METHOD_GET, '/first'); + $secondRoute = new Route(Request::METHOD_GET, '/second'); + + $firstRequest->setRoute($firstRoute); + $secondRequest->setRoute($secondRoute); + + $this->assertSame($firstRoute, $firstRequest->getRoute()); + $this->assertSame($secondRoute, $secondRequest->getRoute()); + } + /** * Helper to attach a route with multiple SDK methods to the request. */ diff --git a/tests/unit/Utopia/ResponseTest.php b/tests/unit/Utopia/ResponseTest.php index 452119fafb..d5c3a079cd 100644 --- a/tests/unit/Utopia/ResponseTest.php +++ b/tests/unit/Utopia/ResponseTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Utopia; use Appwrite\Utopia\Response; use Exception; use PHPUnit\Framework\TestCase; +use ReflectionMethod; use Swoole\Http\Response as SwooleResponse; use Tests\Unit\Utopia\Response\Filters\First; use Tests\Unit\Utopia\Response\Filters\Second; @@ -176,4 +177,26 @@ class ResponseTest extends TestCase $this->assertArrayHasKey('required', $single); $this->assertArrayNotHasKey('hidden', $singleFromArray); } + + public function testShowSensitiveRestoresPreviousState(): void + { + $isShowingSensitive = new ReflectionMethod(Response::class, 'isShowingSensitive'); + + $this->assertFalse($isShowingSensitive->invoke(null)); + + $payload = Response::showSensitive(function () use ($isShowingSensitive) { + return [ + 'outer' => $isShowingSensitive->invoke(null), + 'inner' => Response::showSensitive(fn () => [ + 'state' => $isShowingSensitive->invoke(null), + ]), + 'afterInner' => $isShowingSensitive->invoke(null), + ]; + }); + + $this->assertTrue($payload['outer']); + $this->assertTrue($payload['inner']['state']); + $this->assertTrue($payload['afterInner']); + $this->assertFalse($isShowingSensitive->invoke(null)); + } } From b8eb0810c2d94ac4061311fbbefead5f8729f66e Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 6 Apr 2026 10:24:32 +0530 Subject: [PATCH 15/21] Make response sensitive mode instance-scoped --- app/controllers/api/account.php | 8 +++---- src/Appwrite/Utopia/Response.php | 37 ++++++------------------------ tests/unit/Utopia/ResponseTest.php | 18 +++++++-------- 3 files changed, 20 insertions(+), 43 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index d576bbce44..fb968d3972 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -3937,7 +3937,7 @@ Http::post('/v1/account/recovery') ->setParam('userId', $profile->getId()) ->setParam('tokenId', $recovery->getId()) ->setUser($profile) - ->setPayload(Response::showSensitive(fn () => $response->output($recovery, Response::MODEL_TOKEN)), sensitive: ['secret']); + ->setPayload($response->showSensitive(fn () => $response->output($recovery, Response::MODEL_TOKEN)), sensitive: ['secret']); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -4038,7 +4038,7 @@ Http::put('/v1/account/recovery') $queueForEvents ->setParam('userId', $profile->getId()) ->setParam('tokenId', $recoveryDocument->getId()) - ->setPayload(Response::showSensitive(fn () => $response->output($recoveryDocument, Response::MODEL_TOKEN)), sensitive: ['secret']); + ->setPayload($response->showSensitive(fn () => $response->output($recoveryDocument, Response::MODEL_TOKEN)), sensitive: ['secret']); $response->dynamic($recoveryDocument, Response::MODEL_TOKEN); }); @@ -4268,7 +4268,7 @@ Http::post('/v1/account/verifications/email') $queueForEvents ->setParam('userId', $user->getId()) ->setParam('tokenId', $verification->getId()) - ->setPayload(Response::showSensitive(fn () => $response->output($verification, Response::MODEL_TOKEN)), sensitive: ['secret']); + ->setPayload($response->showSensitive(fn () => $response->output($verification, Response::MODEL_TOKEN)), sensitive: ['secret']); $response ->setStatusCode(Response::STATUS_CODE_CREATED) @@ -4360,7 +4360,7 @@ Http::put('/v1/account/verifications/email') $queueForEvents ->setParam('userId', $userId) ->setParam('tokenId', $verification->getId()) - ->setPayload(Response::showSensitive(fn () => $response->output($verification, Response::MODEL_TOKEN)), sensitive: ['secret']); + ->setPayload($response->showSensitive(fn () => $response->output($verification, Response::MODEL_TOKEN)), sensitive: ['secret']); $response->dynamic($verification, Response::MODEL_TOKEN); }); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 649b0562a5..9d0e8abefa 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -8,7 +8,6 @@ use Appwrite\Utopia\Response\Filter; use Appwrite\Utopia\Response\Model; use Exception; use JsonException; -use Swoole\Coroutine; use Swoole\Http\Response as SwooleHTTPResponse; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; @@ -20,8 +19,6 @@ use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; */ class Response extends SwooleResponse { - private const SHOW_SENSITIVE_CONTEXT_KEY = '__appwrite_response_show_sensitive'; - // General public const MODEL_NONE = 'none'; public const MODEL_ANY = 'any'; @@ -302,7 +299,7 @@ class Response extends SwooleResponse /** * @var bool */ - protected static bool $showSensitive = false; + protected bool $showSensitive = false; /** * @var array @@ -512,7 +509,7 @@ class Response extends SwooleResponse $isPrivilegedUser = $user->isPrivileged($roles); $isAppUser = $user->isApp($roles); - if ((!$isPrivilegedUser && !$isAppUser) && !self::isShowingSensitive()) { + if ((!$isPrivilegedUser && !$isAppUser) && !$this->showSensitive) { $data->setAttribute($key, ''); } } @@ -662,43 +659,23 @@ class Response extends SwooleResponse } /** - * Static wrapper to show sensitive data in response + * Wrapper to show sensitive data in response * * @param callable(): array $callback The callback to show sensitive information for * @return array */ - public static function showSensitive(callable $callback): array + public function showSensitive(callable $callback): array { - $previous = self::isShowingSensitive(); + $previous = $this->showSensitive; try { - self::setShowSensitive(true); + $this->showSensitive = true; return $callback(); } finally { - self::setShowSensitive($previous); + $this->showSensitive = $previous; } } - private static function isShowingSensitive(): bool - { - if (Coroutine::getCid() !== -1) { - return (bool) (Coroutine::getContext()[self::SHOW_SENSITIVE_CONTEXT_KEY] ?? false); - } - - return self::$showSensitive; - } - - private static function setShowSensitive(bool $value): void - { - if (Coroutine::getCid() !== -1) { - Coroutine::getContext()[self::SHOW_SENSITIVE_CONTEXT_KEY] = $value; - - return; - } - - self::$showSensitive = $value; - } - private ?Authorization $authorization = null; private ?DBUser $user = null; diff --git a/tests/unit/Utopia/ResponseTest.php b/tests/unit/Utopia/ResponseTest.php index d5c3a079cd..be8cfdc216 100644 --- a/tests/unit/Utopia/ResponseTest.php +++ b/tests/unit/Utopia/ResponseTest.php @@ -5,7 +5,7 @@ namespace Tests\Unit\Utopia; use Appwrite\Utopia\Response; use Exception; use PHPUnit\Framework\TestCase; -use ReflectionMethod; +use ReflectionProperty; use Swoole\Http\Response as SwooleResponse; use Tests\Unit\Utopia\Response\Filters\First; use Tests\Unit\Utopia\Response\Filters\Second; @@ -180,23 +180,23 @@ class ResponseTest extends TestCase public function testShowSensitiveRestoresPreviousState(): void { - $isShowingSensitive = new ReflectionMethod(Response::class, 'isShowingSensitive'); + $isShowingSensitive = new ReflectionProperty(Response::class, 'showSensitive'); - $this->assertFalse($isShowingSensitive->invoke(null)); + $this->assertFalse($isShowingSensitive->getValue($this->response)); - $payload = Response::showSensitive(function () use ($isShowingSensitive) { + $payload = $this->response->showSensitive(function () use ($isShowingSensitive) { return [ - 'outer' => $isShowingSensitive->invoke(null), - 'inner' => Response::showSensitive(fn () => [ - 'state' => $isShowingSensitive->invoke(null), + 'outer' => $isShowingSensitive->getValue($this->response), + 'inner' => $this->response->showSensitive(fn () => [ + 'state' => $isShowingSensitive->getValue($this->response), ]), - 'afterInner' => $isShowingSensitive->invoke(null), + 'afterInner' => $isShowingSensitive->getValue($this->response), ]; }); $this->assertTrue($payload['outer']); $this->assertTrue($payload['inner']['state']); $this->assertTrue($payload['afterInner']); - $this->assertFalse($isShowingSensitive->invoke(null)); + $this->assertFalse($isShowingSensitive->getValue($this->response)); } } From 221b52bac0a2dfbd571d01e97a6213875c5a3f17 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 6 Apr 2026 12:30:25 +0530 Subject: [PATCH 16/21] Add request-scoped cookie domain resource --- app/controllers/api/account.php | 59 +++++++++++-------- app/controllers/general.php | 14 ----- app/init/resources.php | 33 +++++++++++ .../Teams/Http/Memberships/Status/Update.php | 7 ++- 4 files changed, 71 insertions(+), 42 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index fb968d3972..fa0e30ee53 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -207,7 +207,7 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, arr } -$createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode, Authorization $authorization) { +$createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode, ?string $cookieDomain, Authorization $authorization) { // Attempt to decode secret as a JWT (used by OAuth2 token flow to carry provider info) $oauthProvider = null; @@ -353,8 +353,8 @@ $createSession = function (string $userId, string $secret, Request $request, Res $protocol = $request->getProtocol(); $response - ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) + ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, null) + ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite')) ->setStatusCode(Response::STATUS_CODE_CREATED); $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); @@ -719,7 +719,8 @@ Http::delete('/v1/account/sessions') ->inject('queueForDeletes') ->inject('store') ->inject('proofForToken') - ->action(function (Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken) { + ->inject('cookieDomain') + ->action(function (Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken, ?string $cookieDomain) { $protocol = $request->getProtocol(); $sessions = $user->getAttribute('sessions', []); @@ -741,8 +742,8 @@ Http::delete('/v1/account/sessions') // If current session delete the cookies too $response - ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie($store->getKey(), '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')); + ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, null) + ->addCookie($store->getKey(), '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite')); // Use current session for events. $currentSession = $session; @@ -849,7 +850,8 @@ Http::delete('/v1/account/sessions/:sessionId') ->inject('queueForDeletes') ->inject('store') ->inject('proofForToken') - ->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken) { + ->inject('cookieDomain') + ->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken, ?string $cookieDomain) { $protocol = $request->getProtocol(); $sessionId = ($sessionId === 'current') @@ -880,8 +882,8 @@ Http::delete('/v1/account/sessions/:sessionId') } $response - ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie($store->getKey(), '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')); + ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, null) + ->addCookie($store->getKey(), '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite')); } $dbForProject->purgeCachedDocument('users', $user->getId()); @@ -1035,8 +1037,9 @@ Http::post('/v1/account/sessions/email') ->inject('store') ->inject('proofForPassword') ->inject('proofForToken') + ->inject('cookieDomain') ->inject('authorization') - ->action(function (string $email, string $password, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) { + ->action(function (string $email, string $password, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, ?string $cookieDomain, Authorization $authorization) { $email = \strtolower($email); $protocol = $request->getProtocol(); @@ -1117,8 +1120,8 @@ Http::post('/v1/account/sessions/email') $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); $response - ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) + ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, null) + ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite')) ->setStatusCode(Response::STATUS_CODE_CREATED) ; @@ -1184,8 +1187,9 @@ Http::post('/v1/account/sessions/anonymous') ->inject('store') ->inject('proofForPassword') ->inject('proofForToken') + ->inject('cookieDomain') ->inject('authorization') - ->action(function (Request $request, Response $response, Locale $locale, User $user, Document $project, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) { + ->action(function (Request $request, Response $response, Locale $locale, User $user, Document $project, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, ?string $cookieDomain, Authorization $authorization) { $protocol = $request->getProtocol(); if ('console' === $project->getId()) { @@ -1283,8 +1287,8 @@ Http::post('/v1/account/sessions/anonymous') $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); $response - ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) + ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, null) + ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite')) ->setStatusCode(Response::STATUS_CODE_CREATED) ; @@ -1339,7 +1343,8 @@ Http::post('/v1/account/sessions/token') ->inject('store') ->inject('proofForToken') ->inject('proofForCode') -->inject('authorization') + ->inject('cookieDomain') + ->inject('authorization') ->action($createSession); Http::get('/v1/account/sessions/oauth2/:provider') @@ -1538,8 +1543,9 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->inject('proofForPassword') ->inject('proofForToken') ->inject('plan') + ->inject('cookieDomain') ->inject('authorization') - ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, array $plan, Authorization $authorization) use ($oauthDefaultSuccess) { + ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, array $plan, ?string $cookieDomain, Authorization $authorization) use ($oauthDefaultSuccess) { $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); @@ -2068,14 +2074,14 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') // TODO: Remove this deprecated workaround - support only token if ($state['success']['path'] == $oauthDefaultSuccess) { $query['project'] = $project->getId(); - $query['domain'] = Config::getParam('cookieDomain'); + $query['domain'] = $cookieDomain; $query['key'] = $store->getKey(); $query['secret'] = $encoded; } $response - ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')); + ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, null) + ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite')); } if (isset($sessionUpgrade) && $sessionUpgrade && isset($session)) { @@ -2886,11 +2892,12 @@ Http::put('/v1/account/sessions/magic-url') ->inject('queueForMails') ->inject('store') ->inject('proofForCode') + ->inject('cookieDomain') ->inject('authorization') - ->action(function ($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForCode, $authorization) use ($createSession) { + ->action(function ($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForCode, $cookieDomain, $authorization) use ($createSession) { $proofForToken = new ProofsToken(TOKEN_LENGTH_MAGIC_URL); $proofForToken->setHash(new Sha()); - $createSession($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForToken, $proofForCode, $authorization); + $createSession($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForToken, $proofForCode, $cookieDomain, $authorization); }); Http::put('/v1/account/sessions/phone') @@ -2936,6 +2943,7 @@ Http::put('/v1/account/sessions/phone') ->inject('store') ->inject('proofForToken') ->inject('proofForCode') + ->inject('cookieDomain') ->inject('authorization') ->action($createSession); @@ -3727,7 +3735,8 @@ Http::patch('/v1/account/status') ->inject('dbForProject') ->inject('queueForEvents') ->inject('store') - ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Store $store) { + ->inject('cookieDomain') + ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Store $store, ?string $cookieDomain) { $user->setAttribute('status', false); @@ -3743,8 +3752,8 @@ Http::patch('/v1/account/status') $protocol = $request->getProtocol(); $response - ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie($store->getKey(), '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) + ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, null) + ->addCookie($store->getKey(), '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite')) ; $response->dynamic($user, Response::MODEL_ACCOUNT); diff --git a/app/controllers/general.php b/app/controllers/general.php index d10ad9a060..af85c4e459 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -924,20 +924,6 @@ Http::init() $isLocalHost = in_array($request->getHostname(), $localHosts); $isIpAddress = filter_var($request->getHostname(), FILTER_VALIDATE_IP) !== false; - $isConsoleProject = $project->getAttribute('$id', '') === 'console'; - $isConsoleRootSession = System::getEnv('_APP_CONSOLE_ROOT_SESSION', 'disabled') === 'enabled'; - - Config::setParam( - 'cookieDomain', - $isLocalHost || $isIpAddress - ? null - : ( - $isConsoleProject && $isConsoleRootSession - ? '.' . $selfDomain->getRegisterable() - : '.' . $request->getHostname() - ) - ); - $warnings = []; /* diff --git a/app/init/resources.php b/app/init/resources.php index 8acecb8e3e..472c52fa4e 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -53,6 +53,7 @@ use Utopia\Database\DateTime as DatabaseDateTime; use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; +use Utopia\Domains\Domain; use Utopia\DSN\DSN; use Utopia\Http\Http; use Utopia\Locale\Locale; @@ -249,6 +250,38 @@ Http::setResource('allowedSchemes', function (array $platform, Document $project return array_unique($allowed); }, ['platform', 'project']); +/** + * Cookie domain for the current request. + */ +Http::setResource('cookieDomain', function (Request $request, Document $project) { + $localHosts = ['localhost', 'localhost:' . $request->getPort()]; + + $migrationHost = System::getEnv('_APP_MIGRATION_HOST'); + if (!empty($migrationHost)) { + $localHosts[] = $migrationHost; + $localHosts[] = $migrationHost . ':' . $request->getPort(); + } + + $hostname = $request->getHostname(); + $isLocalHost = \in_array($hostname, $localHosts, true); + $isIpAddress = \filter_var($hostname, FILTER_VALIDATE_IP) !== false; + + if ($isLocalHost || $isIpAddress) { + return; + } + + $isConsoleProject = $project->getAttribute('$id', '') === 'console'; + $isConsoleRootSession = System::getEnv('_APP_CONSOLE_ROOT_SESSION', 'disabled') === 'enabled'; + + if ($isConsoleProject && $isConsoleRootSession) { + $domain = new Domain($hostname); + + return '.' . $domain->getRegisterable(); + } + + return '.' . $hostname; +}, ['request', 'project']); + /** * Rule associated with a request origin. */ diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php index 46b6c3cacf..f1c0a5cad5 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php @@ -74,10 +74,11 @@ class Update extends Action ->inject('queueForEvents') ->inject('store') ->inject('proofForToken') + ->inject('cookieDomain') ->callback($this->action(...)); } - public function action(string $teamId, string $membershipId, string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Authorization $authorization, $project, Reader $geodb, Event $queueForEvents, Store $store, Token $proofForToken) + public function action(string $teamId, string $membershipId, string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Authorization $authorization, $project, Reader $geodb, Event $queueForEvents, Store $store, Token $proofForToken, ?string $cookieDomain) { $protocol = $request->getProtocol(); @@ -172,7 +173,7 @@ class Update extends Action value: $encoded, expire: (new \DateTime($expire))->getTimestamp(), path: '/', - domain: Config::getParam('cookieDomain'), + domain: $cookieDomain, secure: ('https' === $protocol), httponly: true ) @@ -181,7 +182,7 @@ class Update extends Action value: $encoded, expire: (new \DateTime($expire))->getTimestamp(), path: '/', - domain: Config::getParam('cookieDomain'), + domain: $cookieDomain, secure: ('https' === $protocol), httponly: true, sameSite: Config::getParam('cookieSamesite') From d1b59ff3f3b9d5d0f60f41d3ef5eb84b45bda802 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 6 Apr 2026 12:30:48 +0530 Subject: [PATCH 17/21] Remove unused cookie domain locals --- app/controllers/general.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/controllers/general.php b/app/controllers/general.php index af85c4e459..917588bee3 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -921,9 +921,6 @@ Http::init() $localHosts[] = $migrationHost.':'.$request->getPort(); } - $isLocalHost = in_array($request->getHostname(), $localHosts); - $isIpAddress = filter_var($request->getHostname(), FILTER_VALIDATE_IP) !== false; - $warnings = []; /* From 1f7fc4bd40a69e703f563d47b3884f0b7bd3a0ff Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 6 Apr 2026 12:43:05 +0530 Subject: [PATCH 18/21] Use request-scoped domain verification --- app/controllers/api/account.php | 41 +++++++++++-------- app/controllers/general.php | 9 ---- app/init/resources.php | 12 ++++++ .../Teams/Http/Memberships/Status/Update.php | 5 ++- 4 files changed, 40 insertions(+), 27 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index fa0e30ee53..cbdf11225a 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -207,7 +207,7 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, arr } -$createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode, ?string $cookieDomain, Authorization $authorization) { +$createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) { // Attempt to decode secret as a JWT (used by OAuth2 token flow to carry provider info) $oauthProvider = null; @@ -345,7 +345,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res ->setProperty('secret', $sessionSecret) ->encode(); - if (!Config::getParam('domainVerification')) { + if (!$domainVerification) { $response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded])); } @@ -719,8 +719,9 @@ Http::delete('/v1/account/sessions') ->inject('queueForDeletes') ->inject('store') ->inject('proofForToken') + ->inject('domainVerification') ->inject('cookieDomain') - ->action(function (Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken, ?string $cookieDomain) { + ->action(function (Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain) { $protocol = $request->getProtocol(); $sessions = $user->getAttribute('sessions', []); @@ -729,7 +730,7 @@ Http::delete('/v1/account/sessions') foreach ($sessions as $session) {/** @var Document $session */ $dbForProject->deleteDocument('sessions', $session->getId()); - if (!Config::getParam('domainVerification')) { + if (!$domainVerification) { $response->addHeader('X-Fallback-Cookies', \json_encode([])); } @@ -850,8 +851,9 @@ Http::delete('/v1/account/sessions/:sessionId') ->inject('queueForDeletes') ->inject('store') ->inject('proofForToken') + ->inject('domainVerification') ->inject('cookieDomain') - ->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken, ?string $cookieDomain) { + ->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain) { $protocol = $request->getProtocol(); $sessionId = ($sessionId === 'current') @@ -877,7 +879,7 @@ Http::delete('/v1/account/sessions/:sessionId') ->setAttribute('current', true) ->setAttribute('countryName', $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown'))); - if (!Config::getParam('domainVerification')) { + if (!$domainVerification) { $response->addHeader('X-Fallback-Cookies', \json_encode([])); } @@ -1037,9 +1039,10 @@ Http::post('/v1/account/sessions/email') ->inject('store') ->inject('proofForPassword') ->inject('proofForToken') + ->inject('domainVerification') ->inject('cookieDomain') ->inject('authorization') - ->action(function (string $email, string $password, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, ?string $cookieDomain, Authorization $authorization) { + ->action(function (string $email, string $password, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) { $email = \strtolower($email); $protocol = $request->getProtocol(); @@ -1113,7 +1116,7 @@ Http::post('/v1/account/sessions/email') ->setProperty('secret', $secret) ->encode(); - if (!Config::getParam('domainVerification')) { + if (!$domainVerification) { $response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded])); } @@ -1187,9 +1190,10 @@ Http::post('/v1/account/sessions/anonymous') ->inject('store') ->inject('proofForPassword') ->inject('proofForToken') + ->inject('domainVerification') ->inject('cookieDomain') ->inject('authorization') - ->action(function (Request $request, Response $response, Locale $locale, User $user, Document $project, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, ?string $cookieDomain, Authorization $authorization) { + ->action(function (Request $request, Response $response, Locale $locale, User $user, Document $project, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) { $protocol = $request->getProtocol(); if ('console' === $project->getId()) { @@ -1280,7 +1284,7 @@ Http::post('/v1/account/sessions/anonymous') ->setProperty('secret', $secret) ->encode(); - if (!Config::getParam('domainVerification')) { + if (!$domainVerification) { $response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded])); } @@ -1343,6 +1347,7 @@ Http::post('/v1/account/sessions/token') ->inject('store') ->inject('proofForToken') ->inject('proofForCode') + ->inject('domainVerification') ->inject('cookieDomain') ->inject('authorization') ->action($createSession); @@ -1543,9 +1548,10 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->inject('proofForPassword') ->inject('proofForToken') ->inject('plan') + ->inject('domainVerification') ->inject('cookieDomain') ->inject('authorization') - ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, array $plan, ?string $cookieDomain, Authorization $authorization) use ($oauthDefaultSuccess) { + ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, array $plan, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) use ($oauthDefaultSuccess) { $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); @@ -2061,7 +2067,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->setProperty('secret', $secret) ->encode(); - if (!Config::getParam('domainVerification')) { + if (!$domainVerification) { $response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded])); } @@ -2892,12 +2898,13 @@ Http::put('/v1/account/sessions/magic-url') ->inject('queueForMails') ->inject('store') ->inject('proofForCode') + ->inject('domainVerification') ->inject('cookieDomain') ->inject('authorization') - ->action(function ($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForCode, $cookieDomain, $authorization) use ($createSession) { + ->action(function ($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForCode, $domainVerification, $cookieDomain, $authorization) use ($createSession) { $proofForToken = new ProofsToken(TOKEN_LENGTH_MAGIC_URL); $proofForToken->setHash(new Sha()); - $createSession($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForToken, $proofForCode, $cookieDomain, $authorization); + $createSession($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForToken, $proofForCode, $domainVerification, $cookieDomain, $authorization); }); Http::put('/v1/account/sessions/phone') @@ -2943,6 +2950,7 @@ Http::put('/v1/account/sessions/phone') ->inject('store') ->inject('proofForToken') ->inject('proofForCode') + ->inject('domainVerification') ->inject('cookieDomain') ->inject('authorization') ->action($createSession); @@ -3735,8 +3743,9 @@ Http::patch('/v1/account/status') ->inject('dbForProject') ->inject('queueForEvents') ->inject('store') + ->inject('domainVerification') ->inject('cookieDomain') - ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Store $store, ?string $cookieDomain) { + ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Store $store, bool $domainVerification, ?string $cookieDomain) { $user->setAttribute('status', false); @@ -3746,7 +3755,7 @@ Http::patch('/v1/account/status') ->setParam('userId', $user->getId()) ->setPayload($response->output($user, Response::MODEL_ACCOUNT)); - if (!Config::getParam('domainVerification')) { + if (!$domainVerification) { $response->addHeader('X-Fallback-Cookies', \json_encode([])); } diff --git a/app/controllers/general.php b/app/controllers/general.php index 917588bee3..120f8a17d6 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -904,15 +904,6 @@ Http::init() $locale->setDefault($localeParam); } - $origin = \parse_url($request->getOrigin($request->getReferer('')), PHP_URL_HOST); - $selfDomain = new Domain($request->getHostname()); - $endDomain = new Domain((string)$origin); - Config::setParam( - 'domainVerification', - ($selfDomain->getRegisterable() === $endDomain->getRegisterable()) && - $endDomain->getRegisterable() !== '' - ); - $localHosts = ['localhost','localhost:'.$request->getPort()]; $migrationHost = System::getEnv('_APP_MIGRATION_HOST'); diff --git a/app/init/resources.php b/app/init/resources.php index 472c52fa4e..67ac115b61 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -250,6 +250,18 @@ Http::setResource('allowedSchemes', function (array $platform, Document $project return array_unique($allowed); }, ['platform', 'project']); +/** + * Whether the request origin is verified against the request hostname. + */ +Http::setResource('domainVerification', function (Request $request) { + $origin = \parse_url($request->getOrigin($request->getReferer('')), PHP_URL_HOST); + $selfDomain = new Domain($request->getHostname()); + $endDomain = new Domain((string) $origin); + + return ($selfDomain->getRegisterable() === $endDomain->getRegisterable()) + && $endDomain->getRegisterable() !== ''; +}, ['request']); + /** * Cookie domain for the current request. */ diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php index f1c0a5cad5..28bfa769ee 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php @@ -74,11 +74,12 @@ class Update extends Action ->inject('queueForEvents') ->inject('store') ->inject('proofForToken') + ->inject('domainVerification') ->inject('cookieDomain') ->callback($this->action(...)); } - public function action(string $teamId, string $membershipId, string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Authorization $authorization, $project, Reader $geodb, Event $queueForEvents, Store $store, Token $proofForToken, ?string $cookieDomain) + public function action(string $teamId, string $membershipId, string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Authorization $authorization, $project, Reader $geodb, Event $queueForEvents, Store $store, Token $proofForToken, bool $domainVerification, ?string $cookieDomain) { $protocol = $request->getProtocol(); @@ -163,7 +164,7 @@ class Update extends Action ->setProperty('secret', $secret) ->encode(); - if (!Config::getParam('domainVerification')) { + if (!$domainVerification) { $response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded])); } From e3053bb83d6bf4895944106475b72d1ace0f76e5 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 6 Apr 2026 12:44:48 +0530 Subject: [PATCH 19/21] Remove dead cookie config defaults --- app/controllers/general.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/controllers/general.php b/app/controllers/general.php index 120f8a17d6..00bfc6bd67 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -61,8 +61,6 @@ use Utopia\System\System; use Utopia\Validator; use Utopia\Validator\Text; -Config::setParam('domainVerification', false); -Config::setParam('cookieDomain', 'localhost'); Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE); function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount) From 59a773e9a01c5b7c50deb735550e7f97a7b7dcaa Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 6 Apr 2026 12:47:06 +0530 Subject: [PATCH 20/21] Document migration host local-domain handling --- app/controllers/general.php | 2 ++ app/init/resources.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/controllers/general.php b/app/controllers/general.php index 00bfc6bd67..5a5c2dd507 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -906,6 +906,8 @@ Http::init() $migrationHost = System::getEnv('_APP_MIGRATION_HOST'); if (!empty($migrationHost)) { + // Treat the migration host like localhost because internal migration and + // CI traffic may use it before a public domain is configured. $localHosts[] = $migrationHost; $localHosts[] = $migrationHost.':'.$request->getPort(); } diff --git a/app/init/resources.php b/app/init/resources.php index 67ac115b61..92164c3c95 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -270,6 +270,8 @@ Http::setResource('cookieDomain', function (Request $request, Document $project) $migrationHost = System::getEnv('_APP_MIGRATION_HOST'); if (!empty($migrationHost)) { + // Treat the migration host like localhost because internal migration and CI + // traffic may use it before a public domain is configured. $localHosts[] = $migrationHost; $localHosts[] = $migrationHost . ':' . $request->getPort(); } From 04173600ae318d2082c87c5d6c68b70135dae38a Mon Sep 17 00:00:00 2001 From: shimon Date: Mon, 6 Apr 2026 22:33:18 +0300 Subject: [PATCH 21/21] revert ScheduleFunctions.php updates --- src/Appwrite/Platform/Tasks/ScheduleFunctions.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php index 69f105652c..88725a190a 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php +++ b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php @@ -61,7 +61,7 @@ class ScheduleFunctions extends ScheduleBase $nextDate = $cron->getNextRunDate(); $next = DateTime::format($nextDate); - $currentTick = $next <= $timeFrame; + $currentTick = $next < $timeFrame; if (!$currentTick) { continue; @@ -88,7 +88,7 @@ class ScheduleFunctions extends ScheduleBase $scheduleKey = $delayConfig['key']; // Ensure schedule was not deleted if (!\array_key_exists($scheduleKey, $this->schedules)) { - continue; + return; } $schedule = $this->schedules[$scheduleKey];