From 0c70a49ce71f235d4160bbb89d53b84294b4ccf7 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 18 May 2026 12:34:08 +0100 Subject: [PATCH 01/17] Add AuthMethods migration Singleton resource per project carrying the 7 auth-method flags (email/password, magic URL, email OTP, anonymous, invites, JWT, phone). Source reads via raw GET /v1/project (no SDK get() method exposed); destination flips each flag via Project::updateAuthMethod(). Renames destination $project (string) -> $projectId so $project can hold the Project SDK service, matching the source-side convention. --- src/Migration/Destinations/Appwrite.php | 52 +++++++-- src/Migration/Resource.php | 3 + src/Migration/Resources/Auth/AuthMethods.php | 113 +++++++++++++++++++ src/Migration/Sources/Appwrite.php | 48 ++++++++ src/Migration/Transfer.php | 4 +- 5 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 src/Migration/Resources/Auth/AuthMethods.php diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 90f596ee..a58e4037 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -5,6 +5,7 @@ use Appwrite\AppwriteException; use Appwrite\Client; use Appwrite\Enums\Adapter; +use Appwrite\Enums\AuthMethod; use Appwrite\Enums\BuildRuntime; use Appwrite\Enums\Compression; use Appwrite\Enums\Framework; @@ -14,6 +15,7 @@ use Appwrite\InputFile; use Appwrite\Services\Functions; use Appwrite\Services\Messaging; +use Appwrite\Services\Project; use Appwrite\Services\Sites; use Appwrite\Services\Storage; use Appwrite\Services\Teams; @@ -37,6 +39,7 @@ use Utopia\Migration\Destination; use Utopia\Migration\Exception; use Utopia\Migration\Resource; +use Utopia\Migration\Resources\Auth\AuthMethods; use Utopia\Migration\Resources\Auth\Hash; use Utopia\Migration\Resources\Auth\Membership; use Utopia\Migration\Resources\Auth\Team; @@ -91,12 +94,13 @@ class Appwrite extends Destination ]; protected Client $client; - protected string $project; + protected string $projectId; protected string $key; private Functions $functions; private Messaging $messaging; + private Project $project; private Sites $sites; private Storage $storage; private Teams $teams; @@ -170,7 +174,7 @@ public function __construct( protected OnDuplicate $onDuplicate = OnDuplicate::Fail, ?callable $getDatabaseDSN = null, ) { - $this->project = $project; + $this->projectId = $project; $this->endpoint = $endpoint; $this->key = $key; @@ -181,6 +185,7 @@ public function __construct( $this->functions = new Functions($this->client); $this->messaging = new Messaging($this->client); + $this->project = new Project($this->client); $this->sites = new Sites($this->client); $this->storage = new Storage($this->client); $this->teams = new Teams($this->client); @@ -2160,6 +2165,10 @@ public function importAuthResource(Resource $resource): Resource userId: $user->getId(), ); break; + case Resource::TYPE_AUTH_METHODS: + /** @var AuthMethods $resource */ + $this->createAuthMethods($resource); + break; } $resource->setStatus(Resource::STATUS_SUCCESS); @@ -3175,7 +3184,7 @@ protected function createWebhook(Webhook $resource): bool '$id' => ID::unique(), '$permissions' => $resource->getPermissions(), 'projectInternalId' => $this->projectInternalId, - 'projectId' => $this->project, + 'projectId' => $this->projectId, 'name' => $resource->getWebhookName(), 'events' => $resource->getEvents(), 'url' => $resource->getUrl(), @@ -3194,7 +3203,7 @@ protected function createWebhook(Webhook $resource): bool return false; } - $this->dbForPlatform->purgeCachedDocument('projects', $this->project); + $this->dbForPlatform->purgeCachedDocument('projects', $this->projectId); return true; } @@ -3205,7 +3214,7 @@ protected function createWebhook(Webhook $resource): bool protected function createPlatform(Platform $resource): bool { $existing = $this->dbForPlatform->findOne('platforms', [ - Query::equal('projectId', [$this->project]), + Query::equal('projectId', [$this->projectId]), Query::equal('type', [$resource->getType()]), Query::equal('name', [$resource->getPlatformName()]), ]); @@ -3223,7 +3232,7 @@ protected function createPlatform(Platform $resource): bool '$id' => ID::unique(), '$permissions' => $resource->getPermissions(), 'projectInternalId' => $this->projectInternalId, - 'projectId' => $this->project, + 'projectId' => $this->projectId, 'type' => $resource->getType(), 'name' => $resource->getPlatformName(), 'key' => $resource->getKey(), @@ -3237,7 +3246,32 @@ protected function createPlatform(Platform $resource): bool return false; } - $this->dbForPlatform->purgeCachedDocument('projects', $this->project); + $this->dbForPlatform->purgeCachedDocument('projects', $this->projectId); + + return true; + } + + /** + * Flip each auth-method flag on the destination project via the SDK. Seven + * single-field updates rather than one bulk write — the SDK only exposes + * per-flag setters, and the destination needs to honor any server-side + * validation per flag (e.g. provider-specific guards). + */ + protected function createAuthMethods(AuthMethods $resource): bool + { + $flags = [ + [AuthMethod::EMAILPASSWORD(), $resource->getEmailPassword()], + [AuthMethod::MAGICURL(), $resource->getMagicURL()], + [AuthMethod::EMAILOTP(), $resource->getEmailOtp()], + [AuthMethod::ANONYMOUS(), $resource->getAnonymous()], + [AuthMethod::INVITES(), $resource->getInvites()], + [AuthMethod::JWT(), $resource->getJwt()], + [AuthMethod::PHONE(), $resource->getPhone()], + ]; + + foreach ($flags as [$method, $enabled]) { + $this->project->updateAuthMethod($method, $enabled); + } return true; } @@ -3264,7 +3298,7 @@ protected function createApiKey(ApiKey $resource): bool '$id' => ID::unique(), '$permissions' => $resource->getPermissions(), 'resourceInternalId' => $this->projectInternalId, - 'resourceId' => $this->project, + 'resourceId' => $this->projectId, 'resourceType' => 'projects', 'name' => $resource->getApiKeyName(), 'scopes' => $resource->getScopes(), @@ -3280,7 +3314,7 @@ protected function createApiKey(ApiKey $resource): bool return false; } - $this->dbForPlatform->purgeCachedDocument('projects', $this->project); + $this->dbForPlatform->purgeCachedDocument('projects', $this->projectId); return true; } diff --git a/src/Migration/Resource.php b/src/Migration/Resource.php index 9dc936ac..dbd84744 100644 --- a/src/Migration/Resource.php +++ b/src/Migration/Resource.php @@ -69,6 +69,8 @@ abstract class Resource implements \JsonSerializable public const TYPE_HASH = 'hash'; + public const TYPE_AUTH_METHODS = 'auth-methods'; + public const TYPE_ENVIRONMENT_VARIABLE = 'environment-variable'; // Integrations @@ -117,6 +119,7 @@ abstract class Resource implements \JsonSerializable self::TYPE_ENVIRONMENT_VARIABLE, self::TYPE_TEAM, self::TYPE_MEMBERSHIP, + self::TYPE_AUTH_METHODS, self::TYPE_PLATFORM, self::TYPE_API_KEY, self::TYPE_PROJECT_VARIABLE, diff --git a/src/Migration/Resources/Auth/AuthMethods.php b/src/Migration/Resources/Auth/AuthMethods.php new file mode 100644 index 00000000..bc1adbe4 --- /dev/null +++ b/src/Migration/Resources/Auth/AuthMethods.php @@ -0,0 +1,113 @@ +id = $id; + $this->createdAt = $createdAt; + $this->updatedAt = $updatedAt; + } + + /** + * @param array $array + */ + public static function fromArray(array $array): self + { + return new self( + $array['id'], + (bool) ($array['emailPassword'] ?? true), + (bool) ($array['magicURL'] ?? true), + (bool) ($array['emailOtp'] ?? true), + (bool) ($array['anonymous'] ?? true), + (bool) ($array['invites'] ?? true), + (bool) ($array['jwt'] ?? true), + (bool) ($array['phone'] ?? true), + createdAt: $array['createdAt'] ?? '', + updatedAt: $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'emailPassword' => $this->emailPassword, + 'magicURL' => $this->magicURL, + 'emailOtp' => $this->emailOtp, + 'anonymous' => $this->anonymous, + 'invites' => $this->invites, + 'jwt' => $this->jwt, + 'phone' => $this->phone, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public static function getName(): string + { + return Resource::TYPE_AUTH_METHODS; + } + + public function getGroup(): string + { + return Transfer::GROUP_AUTH; + } + + public function getEmailPassword(): bool + { + return $this->emailPassword; + } + + public function getMagicURL(): bool + { + return $this->magicURL; + } + + public function getEmailOtp(): bool + { + return $this->emailOtp; + } + + public function getAnonymous(): bool + { + return $this->anonymous; + } + + public function getInvites(): bool + { + return $this->invites; + } + + public function getJwt(): bool + { + return $this->jwt; + } + + public function getPhone(): bool + { + return $this->phone; + } +} diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index fd03b7d0..ef74e241 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -19,6 +19,7 @@ use Utopia\Database\Document as UtopiaDocument; use Utopia\Migration\Exception; use Utopia\Migration\Resource; +use Utopia\Migration\Resources\Auth\AuthMethods; use Utopia\Migration\Resources\Auth\Hash; use Utopia\Migration\Resources\Auth\Membership; use Utopia\Migration\Resources\Auth\Team; @@ -359,6 +360,11 @@ private function reportAuth(array $resources, array &$report, array $resourceIds )->total; } } + + if (\in_array(Resource::TYPE_AUTH_METHODS, $resources)) { + // Singleton — there is exactly one auth-methods config per project. + $report[Resource::TYPE_AUTH_METHODS] = 1; + } } /** @@ -599,6 +605,48 @@ protected function exportGroupAuth(int $batchSize, array $resources): void previous: $e )); } + + try { + if (\in_array(Resource::TYPE_AUTH_METHODS, $resources)) { + $this->exportAuthMethods(); + } + } catch (\Throwable $e) { + $this->addError(new Exception( + Resource::TYPE_AUTH_METHODS, + Transfer::GROUP_AUTH, + message: $e->getMessage(), + code: (int) $e->getCode() ?: Exception::CODE_INTERNAL, + previous: $e + )); + } + } + + /** + * Read auth-method flags from the source project document. + * No SDK Project.get() exists; using raw HTTP against /v1/project. + */ + private function exportAuthMethods(): void + { + $response = $this->call('GET', '/v1/project'); + + if (!\is_array($response)) { + return; + } + + $authMethods = new AuthMethods( + $this->projectId, + (bool) ($response['authEmailPassword'] ?? true), + (bool) ($response['authUsersAuthMagicURL'] ?? true), + (bool) ($response['authEmailOtp'] ?? true), + (bool) ($response['authAnonymous'] ?? true), + (bool) ($response['authInvites'] ?? true), + (bool) ($response['authJWT'] ?? true), + (bool) ($response['authPhone'] ?? true), + createdAt: $response['$createdAt'] ?? '', + updatedAt: $response['$updatedAt'] ?? '', + ); + + $this->callback([$authMethods]); } /** diff --git a/src/Migration/Transfer.php b/src/Migration/Transfer.php index 05c61f40..bdc3c2a9 100644 --- a/src/Migration/Transfer.php +++ b/src/Migration/Transfer.php @@ -34,7 +34,8 @@ class Transfer Resource::TYPE_USER, Resource::TYPE_TEAM, Resource::TYPE_MEMBERSHIP, - Resource::TYPE_HASH + Resource::TYPE_HASH, + Resource::TYPE_AUTH_METHODS, ]; public const GROUP_STORAGE_RESOURCES = [ @@ -114,6 +115,7 @@ class Transfer Resource::TYPE_USER, Resource::TYPE_TEAM, Resource::TYPE_MEMBERSHIP, + Resource::TYPE_AUTH_METHODS, Resource::TYPE_FILE, Resource::TYPE_BUCKET, Resource::TYPE_FUNCTION, From 5224e3ce9a96373dfd83648b5e8e4e4d20b85020 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 18 May 2026 13:08:47 +0100 Subject: [PATCH 02/17] Register TYPE_AUTH_METHODS in source/destination getSupportedResources --- src/Migration/Destinations/Appwrite.php | 1 + src/Migration/Sources/Appwrite.php | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index a58e4037..f9d64f96 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -246,6 +246,7 @@ public static function getSupportedResources(): array Resource::TYPE_USER, Resource::TYPE_TEAM, Resource::TYPE_MEMBERSHIP, + Resource::TYPE_AUTH_METHODS, // Database Resource::TYPE_DATABASE, diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index ef74e241..8a72145c 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -175,6 +175,7 @@ public static function getSupportedResources(): array Resource::TYPE_USER, Resource::TYPE_TEAM, Resource::TYPE_MEMBERSHIP, + Resource::TYPE_AUTH_METHODS, // Database Resource::TYPE_DATABASE, From d74e8cbd195f341810ec75adaf620447214fc110 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 18 May 2026 14:09:23 +0100 Subject: [PATCH 03/17] Add Protocols migration Singleton settings resource carrying REST/GraphQL/WebSocket flags. Source reads via raw GET /v1/project; destination flips each via Project::updateProtocol(). Lives in GROUP_SETTINGS. --- src/Migration/Destinations/Appwrite.php | 27 ++++++ src/Migration/Resource.php | 2 + .../Resources/Settings/Protocols.php | 82 +++++++++++++++++++ src/Migration/Sources/Appwrite.php | 45 ++++++++++ src/Migration/Transfer.php | 2 + 5 files changed, 158 insertions(+) create mode 100644 src/Migration/Resources/Settings/Protocols.php diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index f9d64f96..37c16142 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -10,6 +10,7 @@ use Appwrite\Enums\Compression; use Appwrite\Enums\Framework; use Appwrite\Enums\PasswordHash; +use Appwrite\Enums\ProtocolId; use Appwrite\Enums\Runtime; use Appwrite\Enums\SmtpEncryption; use Appwrite\InputFile; @@ -60,6 +61,7 @@ use Utopia\Migration\Resources\Messaging\Subscriber; use Utopia\Migration\Resources\Messaging\Topic; use Utopia\Migration\Resources\Settings\ProjectVariable; +use Utopia\Migration\Resources\Settings\Protocols; use Utopia\Migration\Resources\Settings\Webhook; use Utopia\Migration\Resources\Sites\Deployment as SiteDeployment; use Utopia\Migration\Resources\Sites\EnvVar as SiteEnvVar; @@ -289,6 +291,7 @@ public static function getSupportedResources(): array // Settings Resource::TYPE_PROJECT_VARIABLE, Resource::TYPE_WEBHOOK, + Resource::TYPE_PROTOCOLS, // Backups Resource::TYPE_BACKUP_POLICY, @@ -3117,6 +3120,10 @@ public function importSettingsResource(Resource $resource): Resource /** @var Webhook $resource */ $this->createWebhook($resource); break; + case Resource::TYPE_PROTOCOLS: + /** @var Protocols $resource */ + $this->createProtocols($resource); + break; } if ($resource->getStatus() !== Resource::STATUS_SKIPPED) { @@ -3165,6 +3172,26 @@ protected function createProjectVariable(ProjectVariable $resource): bool return true; } + /** + * Flip each protocol on the destination via the SDK. Three single-field + * updates rather than one bulk write — the SDK only exposes per-protocol + * setters, mirroring upstream's per-flag controllers. + */ + protected function createProtocols(Protocols $resource): bool + { + $flags = [ + [ProtocolId::REST(), $resource->getRest()], + [ProtocolId::GRAPHQL(), $resource->getGraphql()], + [ProtocolId::WEBSOCKET(), $resource->getWebsocket()], + ]; + + foreach ($flags as [$protocol, $enabled]) { + $this->project->updateProtocol($protocol, $enabled); + } + + return true; + } + protected function createWebhook(Webhook $resource): bool { $existing = $this->dbForPlatform->findOne('webhooks', [ diff --git a/src/Migration/Resource.php b/src/Migration/Resource.php index dbd84744..17150628 100644 --- a/src/Migration/Resource.php +++ b/src/Migration/Resource.php @@ -80,6 +80,7 @@ abstract class Resource implements \JsonSerializable // Settings public const TYPE_PROJECT_VARIABLE = 'project-variable'; public const TYPE_WEBHOOK = 'webhook'; + public const TYPE_PROTOCOLS = 'protocols'; // Messaging public const TYPE_SUBSCRIBER = 'subscriber'; @@ -124,6 +125,7 @@ abstract class Resource implements \JsonSerializable self::TYPE_API_KEY, self::TYPE_PROJECT_VARIABLE, self::TYPE_WEBHOOK, + self::TYPE_PROTOCOLS, self::TYPE_PROVIDER, self::TYPE_TOPIC, self::TYPE_SUBSCRIBER, diff --git a/src/Migration/Resources/Settings/Protocols.php b/src/Migration/Resources/Settings/Protocols.php new file mode 100644 index 00000000..de0609a4 --- /dev/null +++ b/src/Migration/Resources/Settings/Protocols.php @@ -0,0 +1,82 @@ +id = $id; + $this->createdAt = $createdAt; + $this->updatedAt = $updatedAt; + } + + /** + * @param array $array + */ + public static function fromArray(array $array): self + { + return new self( + $array['id'], + (bool) ($array['rest'] ?? true), + (bool) ($array['graphql'] ?? true), + (bool) ($array['websocket'] ?? true), + createdAt: $array['createdAt'] ?? '', + updatedAt: $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'rest' => $this->rest, + 'graphql' => $this->graphql, + 'websocket' => $this->websocket, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public static function getName(): string + { + return Resource::TYPE_PROTOCOLS; + } + + public function getGroup(): string + { + return Transfer::GROUP_SETTINGS; + } + + public function getRest(): bool + { + return $this->rest; + } + + public function getGraphql(): bool + { + return $this->graphql; + } + + public function getWebsocket(): bool + { + return $this->websocket; + } +} diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 8a72145c..0155099b 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -64,6 +64,7 @@ use Utopia\Migration\Resources\Messaging\Subscriber; use Utopia\Migration\Resources\Messaging\Topic; use Utopia\Migration\Resources\Settings\ProjectVariable; +use Utopia\Migration\Resources\Settings\Protocols; use Utopia\Migration\Resources\Settings\Webhook; use Utopia\Migration\Resources\Sites\Deployment as SiteDeployment; use Utopia\Migration\Resources\Sites\EnvVar as SiteEnvVar; @@ -224,6 +225,7 @@ public static function getSupportedResources(): array // Settings Resource::TYPE_PROJECT_VARIABLE, Resource::TYPE_WEBHOOK, + Resource::TYPE_PROTOCOLS, ]; } @@ -1534,6 +1536,11 @@ private function reportSettings(array $resources, array &$report, array $resourc $report[Resource::TYPE_WEBHOOK] = 0; } } + + if (\in_array(Resource::TYPE_PROTOCOLS, $resources)) { + // Singleton — there is exactly one protocols config per project. + $report[Resource::TYPE_PROTOCOLS] = 1; + } } /** @@ -1569,6 +1576,44 @@ protected function exportGroupSettings(int $batchSize, array $resources): void )); } } + + try { + if (\in_array(Resource::TYPE_PROTOCOLS, $resources)) { + $this->exportProtocols(); + } + } catch (\Throwable $e) { + $this->addError(new Exception( + Resource::TYPE_PROTOCOLS, + Transfer::GROUP_SETTINGS, + message: $e->getMessage(), + code: $e->getCode(), + previous: $e + )); + } + } + + /** + * Read protocol flags from the source project document. + * No SDK Project.get() exposed; using raw HTTP against /v1/project. + */ + private function exportProtocols(): void + { + $response = $this->call('GET', '/v1/project'); + + if (!\is_array($response)) { + return; + } + + $protocols = new Protocols( + $this->projectId, + (bool) ($response['protocolStatusForRest'] ?? true), + (bool) ($response['protocolStatusForGraphql'] ?? true), + (bool) ($response['protocolStatusForWebsocket'] ?? true), + createdAt: $response['$createdAt'] ?? '', + updatedAt: $response['$updatedAt'] ?? '', + ); + + $this->callback([$protocols]); } /** diff --git a/src/Migration/Transfer.php b/src/Migration/Transfer.php index bdc3c2a9..ccd3a8da 100644 --- a/src/Migration/Transfer.php +++ b/src/Migration/Transfer.php @@ -98,6 +98,7 @@ class Transfer public const GROUP_SETTINGS_RESOURCES = [ Resource::TYPE_PROJECT_VARIABLE, Resource::TYPE_WEBHOOK, + Resource::TYPE_PROTOCOLS, ]; public const GROUP_BACKUPS_RESOURCES = [ @@ -142,6 +143,7 @@ class Transfer // Settings Resource::TYPE_PROJECT_VARIABLE, Resource::TYPE_WEBHOOK, + Resource::TYPE_PROTOCOLS, // legacy Resource::TYPE_DOCUMENT, From b7b64ac3ed6359d9c9f7ccaa6a53b2f6dca89edc Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 18 May 2026 15:18:57 +0100 Subject: [PATCH 04/17] Add Labels migration Singleton settings resource carrying the project's RBAC label array. Source reads via raw GET /v1/project; destination overwrites via Project::updateLabels() (wholesale replace). --- src/Migration/Destinations/Appwrite.php | 17 +++++ src/Migration/Resource.php | 2 + src/Migration/Resources/Settings/Labels.php | 72 +++++++++++++++++++++ src/Migration/Sources/Appwrite.php | 42 ++++++++++++ src/Migration/Transfer.php | 2 + 5 files changed, 135 insertions(+) create mode 100644 src/Migration/Resources/Settings/Labels.php diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 37c16142..df1817c8 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -60,6 +60,7 @@ use Utopia\Migration\Resources\Messaging\Provider; use Utopia\Migration\Resources\Messaging\Subscriber; use Utopia\Migration\Resources\Messaging\Topic; +use Utopia\Migration\Resources\Settings\Labels; use Utopia\Migration\Resources\Settings\ProjectVariable; use Utopia\Migration\Resources\Settings\Protocols; use Utopia\Migration\Resources\Settings\Webhook; @@ -292,6 +293,7 @@ public static function getSupportedResources(): array Resource::TYPE_PROJECT_VARIABLE, Resource::TYPE_WEBHOOK, Resource::TYPE_PROTOCOLS, + Resource::TYPE_LABELS, // Backups Resource::TYPE_BACKUP_POLICY, @@ -3124,6 +3126,10 @@ public function importSettingsResource(Resource $resource): Resource /** @var Protocols $resource */ $this->createProtocols($resource); break; + case Resource::TYPE_LABELS: + /** @var Labels $resource */ + $this->createLabels($resource); + break; } if ($resource->getStatus() !== Resource::STATUS_SKIPPED) { @@ -3192,6 +3198,17 @@ protected function createProtocols(Protocols $resource): bool return true; } + /** + * Overwrite destination labels with the source array. Project::updateLabels + * is a wholesale replace, so the source's array is authoritative. + */ + protected function createLabels(Labels $resource): bool + { + $this->project->updateLabels($resource->getLabels()); + + return true; + } + protected function createWebhook(Webhook $resource): bool { $existing = $this->dbForPlatform->findOne('webhooks', [ diff --git a/src/Migration/Resource.php b/src/Migration/Resource.php index 17150628..ddbed667 100644 --- a/src/Migration/Resource.php +++ b/src/Migration/Resource.php @@ -81,6 +81,7 @@ abstract class Resource implements \JsonSerializable public const TYPE_PROJECT_VARIABLE = 'project-variable'; public const TYPE_WEBHOOK = 'webhook'; public const TYPE_PROTOCOLS = 'protocols'; + public const TYPE_LABELS = 'labels'; // Messaging public const TYPE_SUBSCRIBER = 'subscriber'; @@ -126,6 +127,7 @@ abstract class Resource implements \JsonSerializable self::TYPE_PROJECT_VARIABLE, self::TYPE_WEBHOOK, self::TYPE_PROTOCOLS, + self::TYPE_LABELS, self::TYPE_PROVIDER, self::TYPE_TOPIC, self::TYPE_SUBSCRIBER, diff --git a/src/Migration/Resources/Settings/Labels.php b/src/Migration/Resources/Settings/Labels.php new file mode 100644 index 00000000..a2da658d --- /dev/null +++ b/src/Migration/Resources/Settings/Labels.php @@ -0,0 +1,72 @@ + $labels + */ + public function __construct( + string $id, + private readonly array $labels = [], + string $createdAt = '', + string $updatedAt = '', + ) { + $this->id = $id; + $this->createdAt = $createdAt; + $this->updatedAt = $updatedAt; + } + + /** + * @param array $array + */ + public static function fromArray(array $array): self + { + return new self( + $array['id'], + (array) ($array['labels'] ?? []), + createdAt: $array['createdAt'] ?? '', + updatedAt: $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'labels' => $this->labels, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public static function getName(): string + { + return Resource::TYPE_LABELS; + } + + public function getGroup(): string + { + return Transfer::GROUP_SETTINGS; + } + + /** + * @return array + */ + public function getLabels(): array + { + return $this->labels; + } +} diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 0155099b..e166a6e5 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -63,6 +63,7 @@ use Utopia\Migration\Resources\Messaging\Provider; use Utopia\Migration\Resources\Messaging\Subscriber; use Utopia\Migration\Resources\Messaging\Topic; +use Utopia\Migration\Resources\Settings\Labels; use Utopia\Migration\Resources\Settings\ProjectVariable; use Utopia\Migration\Resources\Settings\Protocols; use Utopia\Migration\Resources\Settings\Webhook; @@ -226,6 +227,7 @@ public static function getSupportedResources(): array Resource::TYPE_PROJECT_VARIABLE, Resource::TYPE_WEBHOOK, Resource::TYPE_PROTOCOLS, + Resource::TYPE_LABELS, ]; } @@ -1541,6 +1543,11 @@ private function reportSettings(array $resources, array &$report, array $resourc // Singleton — there is exactly one protocols config per project. $report[Resource::TYPE_PROTOCOLS] = 1; } + + if (\in_array(Resource::TYPE_LABELS, $resources)) { + // Singleton — one labels array per project. + $report[Resource::TYPE_LABELS] = 1; + } } /** @@ -1590,6 +1597,41 @@ protected function exportGroupSettings(int $batchSize, array $resources): void previous: $e )); } + + try { + if (\in_array(Resource::TYPE_LABELS, $resources)) { + $this->exportLabels(); + } + } catch (\Throwable $e) { + $this->addError(new Exception( + Resource::TYPE_LABELS, + Transfer::GROUP_SETTINGS, + message: $e->getMessage(), + code: $e->getCode(), + previous: $e + )); + } + } + + /** + * Read project-level labels from /v1/project. No SDK Project.get() exposed. + */ + private function exportLabels(): void + { + $response = $this->call('GET', '/v1/project'); + + if (!\is_array($response)) { + return; + } + + $labels = new Labels( + $this->projectId, + (array) ($response['labels'] ?? []), + createdAt: $response['$createdAt'] ?? '', + updatedAt: $response['$updatedAt'] ?? '', + ); + + $this->callback([$labels]); } /** diff --git a/src/Migration/Transfer.php b/src/Migration/Transfer.php index ccd3a8da..1e096efc 100644 --- a/src/Migration/Transfer.php +++ b/src/Migration/Transfer.php @@ -99,6 +99,7 @@ class Transfer Resource::TYPE_PROJECT_VARIABLE, Resource::TYPE_WEBHOOK, Resource::TYPE_PROTOCOLS, + Resource::TYPE_LABELS, ]; public const GROUP_BACKUPS_RESOURCES = [ @@ -144,6 +145,7 @@ class Transfer Resource::TYPE_PROJECT_VARIABLE, Resource::TYPE_WEBHOOK, Resource::TYPE_PROTOCOLS, + Resource::TYPE_LABELS, // legacy Resource::TYPE_DOCUMENT, From 2f7270734300d4f5ac450a9bb3d0e11b32986a53 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 18 May 2026 16:10:10 +0100 Subject: [PATCH 05/17] Add Services migration Singleton settings resource carrying 17 per-service enable/disable flags (Account, Avatars, Databases, TablesDB, Locale, Health, Project, Storage, Teams, Users, VCS, Sites, Functions, Proxy, GraphQL, Migrations, Messaging). Source reads via raw GET /v1/project; destination flips each via Project::updateService(). --- src/Migration/Destinations/Appwrite.php | 41 ++++ src/Migration/Resource.php | 2 + src/Migration/Resources/Settings/Services.php | 194 ++++++++++++++++++ src/Migration/Sources/Appwrite.php | 60 ++++++ src/Migration/Transfer.php | 2 + 5 files changed, 299 insertions(+) create mode 100644 src/Migration/Resources/Settings/Services.php diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index df1817c8..a40ae2c4 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -12,6 +12,7 @@ use Appwrite\Enums\PasswordHash; use Appwrite\Enums\ProtocolId; use Appwrite\Enums\Runtime; +use Appwrite\Enums\ServiceId; use Appwrite\Enums\SmtpEncryption; use Appwrite\InputFile; use Appwrite\Services\Functions; @@ -63,6 +64,7 @@ use Utopia\Migration\Resources\Settings\Labels; use Utopia\Migration\Resources\Settings\ProjectVariable; use Utopia\Migration\Resources\Settings\Protocols; +use Utopia\Migration\Resources\Settings\Services as ServicesResource; use Utopia\Migration\Resources\Settings\Webhook; use Utopia\Migration\Resources\Sites\Deployment as SiteDeployment; use Utopia\Migration\Resources\Sites\EnvVar as SiteEnvVar; @@ -294,6 +296,7 @@ public static function getSupportedResources(): array Resource::TYPE_WEBHOOK, Resource::TYPE_PROTOCOLS, Resource::TYPE_LABELS, + Resource::TYPE_SERVICES, // Backups Resource::TYPE_BACKUP_POLICY, @@ -3130,6 +3133,10 @@ public function importSettingsResource(Resource $resource): Resource /** @var Labels $resource */ $this->createLabels($resource); break; + case Resource::TYPE_SERVICES: + /** @var ServicesResource $resource */ + $this->createServices($resource); + break; } if ($resource->getStatus() !== Resource::STATUS_SKIPPED) { @@ -3209,6 +3216,40 @@ protected function createLabels(Labels $resource): bool return true; } + /** + * Flip each service flag on the destination via the SDK. 17 single-field + * updates rather than one bulk write — the SDK only exposes per-service + * setters, matching upstream's per-flag controller. + */ + protected function createServices(ServicesResource $resource): bool + { + $flags = [ + [ServiceId::ACCOUNT(), $resource->getAccount()], + [ServiceId::AVATARS(), $resource->getAvatars()], + [ServiceId::DATABASES(), $resource->getDatabases()], + [ServiceId::TABLESDB(), $resource->getTablesdb()], + [ServiceId::LOCALE(), $resource->getLocale()], + [ServiceId::HEALTH(), $resource->getHealth()], + [ServiceId::PROJECT(), $resource->getProject()], + [ServiceId::STORAGE(), $resource->getStorage()], + [ServiceId::TEAMS(), $resource->getTeams()], + [ServiceId::USERS(), $resource->getUsers()], + [ServiceId::VCS(), $resource->getVcs()], + [ServiceId::SITES(), $resource->getSites()], + [ServiceId::FUNCTIONS(), $resource->getFunctions()], + [ServiceId::PROXY(), $resource->getProxy()], + [ServiceId::GRAPHQL(), $resource->getGraphql()], + [ServiceId::MIGRATIONS(), $resource->getMigrations()], + [ServiceId::MESSAGING(), $resource->getMessaging()], + ]; + + foreach ($flags as [$service, $enabled]) { + $this->project->updateService($service, $enabled); + } + + return true; + } + protected function createWebhook(Webhook $resource): bool { $existing = $this->dbForPlatform->findOne('webhooks', [ diff --git a/src/Migration/Resource.php b/src/Migration/Resource.php index ddbed667..a77ceaa9 100644 --- a/src/Migration/Resource.php +++ b/src/Migration/Resource.php @@ -82,6 +82,7 @@ abstract class Resource implements \JsonSerializable public const TYPE_WEBHOOK = 'webhook'; public const TYPE_PROTOCOLS = 'protocols'; public const TYPE_LABELS = 'labels'; + public const TYPE_SERVICES = 'services'; // Messaging public const TYPE_SUBSCRIBER = 'subscriber'; @@ -128,6 +129,7 @@ abstract class Resource implements \JsonSerializable self::TYPE_WEBHOOK, self::TYPE_PROTOCOLS, self::TYPE_LABELS, + self::TYPE_SERVICES, self::TYPE_PROVIDER, self::TYPE_TOPIC, self::TYPE_SUBSCRIBER, diff --git a/src/Migration/Resources/Settings/Services.php b/src/Migration/Resources/Settings/Services.php new file mode 100644 index 00000000..3b7474c4 --- /dev/null +++ b/src/Migration/Resources/Settings/Services.php @@ -0,0 +1,194 @@ +id = $id; + $this->createdAt = $createdAt; + $this->updatedAt = $updatedAt; + } + + /** + * @param array $array + */ + public static function fromArray(array $array): self + { + return new self( + $array['id'], + (bool) ($array['account'] ?? true), + (bool) ($array['avatars'] ?? true), + (bool) ($array['databases'] ?? true), + (bool) ($array['tablesdb'] ?? true), + (bool) ($array['locale'] ?? true), + (bool) ($array['health'] ?? true), + (bool) ($array['project'] ?? true), + (bool) ($array['storage'] ?? true), + (bool) ($array['teams'] ?? true), + (bool) ($array['users'] ?? true), + (bool) ($array['vcs'] ?? true), + (bool) ($array['sites'] ?? true), + (bool) ($array['functions'] ?? true), + (bool) ($array['proxy'] ?? true), + (bool) ($array['graphql'] ?? true), + (bool) ($array['migrations'] ?? true), + (bool) ($array['messaging'] ?? true), + createdAt: $array['createdAt'] ?? '', + updatedAt: $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'account' => $this->account, + 'avatars' => $this->avatars, + 'databases' => $this->databases, + 'tablesdb' => $this->tablesdb, + 'locale' => $this->locale, + 'health' => $this->health, + 'project' => $this->project, + 'storage' => $this->storage, + 'teams' => $this->teams, + 'users' => $this->users, + 'vcs' => $this->vcs, + 'sites' => $this->sites, + 'functions' => $this->functions, + 'proxy' => $this->proxy, + 'graphql' => $this->graphql, + 'migrations' => $this->migrations, + 'messaging' => $this->messaging, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public static function getName(): string + { + return Resource::TYPE_SERVICES; + } + + public function getGroup(): string + { + return Transfer::GROUP_SETTINGS; + } + + public function getAccount(): bool + { + return $this->account; + } + + public function getAvatars(): bool + { + return $this->avatars; + } + + public function getDatabases(): bool + { + return $this->databases; + } + + public function getTablesdb(): bool + { + return $this->tablesdb; + } + + public function getLocale(): bool + { + return $this->locale; + } + + public function getHealth(): bool + { + return $this->health; + } + + public function getProject(): bool + { + return $this->project; + } + + public function getStorage(): bool + { + return $this->storage; + } + + public function getTeams(): bool + { + return $this->teams; + } + + public function getUsers(): bool + { + return $this->users; + } + + public function getVcs(): bool + { + return $this->vcs; + } + + public function getSites(): bool + { + return $this->sites; + } + + public function getFunctions(): bool + { + return $this->functions; + } + + public function getProxy(): bool + { + return $this->proxy; + } + + public function getGraphql(): bool + { + return $this->graphql; + } + + public function getMigrations(): bool + { + return $this->migrations; + } + + public function getMessaging(): bool + { + return $this->messaging; + } +} diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index e166a6e5..9d0c265c 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -66,6 +66,7 @@ use Utopia\Migration\Resources\Settings\Labels; use Utopia\Migration\Resources\Settings\ProjectVariable; use Utopia\Migration\Resources\Settings\Protocols; +use Utopia\Migration\Resources\Settings\Services as ServicesResource; use Utopia\Migration\Resources\Settings\Webhook; use Utopia\Migration\Resources\Sites\Deployment as SiteDeployment; use Utopia\Migration\Resources\Sites\EnvVar as SiteEnvVar; @@ -228,6 +229,7 @@ public static function getSupportedResources(): array Resource::TYPE_WEBHOOK, Resource::TYPE_PROTOCOLS, Resource::TYPE_LABELS, + Resource::TYPE_SERVICES, ]; } @@ -1548,6 +1550,11 @@ private function reportSettings(array $resources, array &$report, array $resourc // Singleton — one labels array per project. $report[Resource::TYPE_LABELS] = 1; } + + if (\in_array(Resource::TYPE_SERVICES, $resources)) { + // Singleton — one services config per project. + $report[Resource::TYPE_SERVICES] = 1; + } } /** @@ -1611,6 +1618,59 @@ protected function exportGroupSettings(int $batchSize, array $resources): void previous: $e )); } + + try { + if (\in_array(Resource::TYPE_SERVICES, $resources)) { + $this->exportServices(); + } + } catch (\Throwable $e) { + $this->addError(new Exception( + Resource::TYPE_SERVICES, + Transfer::GROUP_SETTINGS, + message: $e->getMessage(), + code: $e->getCode(), + previous: $e + )); + } + } + + /** + * Read service enable/disable flags from /v1/project. The response exposes + * them as `serviceStatusFor` keys; we collapse them into one Services + * resource carrying all 17 toggles. + */ + private function exportServices(): void + { + $response = $this->call('GET', '/v1/project'); + + if (!\is_array($response)) { + return; + } + + $services = new ServicesResource( + $this->projectId, + (bool) ($response['serviceStatusForAccount'] ?? true), + (bool) ($response['serviceStatusForAvatars'] ?? true), + (bool) ($response['serviceStatusForDatabases'] ?? true), + (bool) ($response['serviceStatusForTablesdb'] ?? true), + (bool) ($response['serviceStatusForLocale'] ?? true), + (bool) ($response['serviceStatusForHealth'] ?? true), + (bool) ($response['serviceStatusForProject'] ?? true), + (bool) ($response['serviceStatusForStorage'] ?? true), + (bool) ($response['serviceStatusForTeams'] ?? true), + (bool) ($response['serviceStatusForUsers'] ?? true), + (bool) ($response['serviceStatusForVcs'] ?? true), + (bool) ($response['serviceStatusForSites'] ?? true), + (bool) ($response['serviceStatusForFunctions'] ?? true), + (bool) ($response['serviceStatusForProxy'] ?? true), + (bool) ($response['serviceStatusForGraphql'] ?? true), + (bool) ($response['serviceStatusForMigrations'] ?? true), + (bool) ($response['serviceStatusForMessaging'] ?? true), + createdAt: $response['$createdAt'] ?? '', + updatedAt: $response['$updatedAt'] ?? '', + ); + + $this->callback([$services]); } /** diff --git a/src/Migration/Transfer.php b/src/Migration/Transfer.php index 1e096efc..7bc80502 100644 --- a/src/Migration/Transfer.php +++ b/src/Migration/Transfer.php @@ -100,6 +100,7 @@ class Transfer Resource::TYPE_WEBHOOK, Resource::TYPE_PROTOCOLS, Resource::TYPE_LABELS, + Resource::TYPE_SERVICES, ]; public const GROUP_BACKUPS_RESOURCES = [ @@ -146,6 +147,7 @@ class Transfer Resource::TYPE_WEBHOOK, Resource::TYPE_PROTOCOLS, Resource::TYPE_LABELS, + Resource::TYPE_SERVICES, // legacy Resource::TYPE_DOCUMENT, From eab858e31f0f96c122333b17a441e9a46ff801fc Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 18 May 2026 16:37:59 +0100 Subject: [PATCH 06/17] Add Policies migration --- src/Migration/Destinations/Appwrite.php | 33 +++++ src/Migration/Resource.php | 3 + src/Migration/Resources/Auth/Policies.php | 163 ++++++++++++++++++++++ src/Migration/Sources/Appwrite.php | 56 ++++++++ src/Migration/Transfer.php | 2 + 5 files changed, 257 insertions(+) create mode 100644 src/Migration/Resources/Auth/Policies.php diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index a40ae2c4..43832ae9 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -44,6 +44,7 @@ use Utopia\Migration\Resources\Auth\AuthMethods; use Utopia\Migration\Resources\Auth\Hash; use Utopia\Migration\Resources\Auth\Membership; +use Utopia\Migration\Resources\Auth\Policies; use Utopia\Migration\Resources\Auth\Team; use Utopia\Migration\Resources\Auth\User; use Utopia\Migration\Resources\Database\Attribute; @@ -252,6 +253,7 @@ public static function getSupportedResources(): array Resource::TYPE_TEAM, Resource::TYPE_MEMBERSHIP, Resource::TYPE_AUTH_METHODS, + Resource::TYPE_POLICIES, // Database Resource::TYPE_DATABASE, @@ -2178,6 +2180,10 @@ public function importAuthResource(Resource $resource): Resource /** @var AuthMethods $resource */ $this->createAuthMethods($resource); break; + case Resource::TYPE_POLICIES: + /** @var Policies $resource */ + $this->createPolicies($resource); + break; } $resource->setStatus(Resource::STATUS_SUCCESS); @@ -3362,6 +3368,33 @@ protected function createAuthMethods(AuthMethods $resource): bool return true; } + /** + * Replay each project security policy on the destination via the SDK. Nine + * single-policy updates rather than one bulk write — each policy endpoint + * runs its own validation (history bounds, session-duration window, etc.), + * and membership-privacy bundles its five sub-flags into one call. + */ + protected function createPolicies(Policies $resource): bool + { + $this->project->updatePasswordHistoryPolicy($resource->getPasswordHistory()); + $this->project->updateSessionDurationPolicy($resource->getSessionDuration()); + $this->project->updateSessionLimitPolicy($resource->getSessionsLimit()); + $this->project->updateUserLimitPolicy($resource->getUserLimit()); + $this->project->updatePasswordDictionaryPolicy($resource->getPasswordDictionary()); + $this->project->updatePasswordPersonalDataPolicy($resource->getPersonalDataCheck()); + $this->project->updateSessionAlertPolicy($resource->getSessionAlerts()); + $this->project->updateSessionInvalidationPolicy($resource->getSessionInvalidation()); + $this->project->updateMembershipPrivacyPolicy( + userId: $resource->getMembershipsUserId(), + userEmail: $resource->getMembershipsUserEmail(), + userPhone: $resource->getMembershipsUserPhone(), + userName: $resource->getMembershipsUserName(), + userMFA: $resource->getMembershipsUserMfa(), + ); + + return true; + } + protected function createApiKey(ApiKey $resource): bool { $existing = $this->dbForPlatform->findOne('keys', [ diff --git a/src/Migration/Resource.php b/src/Migration/Resource.php index a77ceaa9..f77b3d8d 100644 --- a/src/Migration/Resource.php +++ b/src/Migration/Resource.php @@ -71,6 +71,8 @@ abstract class Resource implements \JsonSerializable public const TYPE_AUTH_METHODS = 'auth-methods'; + public const TYPE_POLICIES = 'policies'; + public const TYPE_ENVIRONMENT_VARIABLE = 'environment-variable'; // Integrations @@ -123,6 +125,7 @@ abstract class Resource implements \JsonSerializable self::TYPE_TEAM, self::TYPE_MEMBERSHIP, self::TYPE_AUTH_METHODS, + self::TYPE_POLICIES, self::TYPE_PLATFORM, self::TYPE_API_KEY, self::TYPE_PROJECT_VARIABLE, diff --git a/src/Migration/Resources/Auth/Policies.php b/src/Migration/Resources/Auth/Policies.php new file mode 100644 index 00000000..1e264751 --- /dev/null +++ b/src/Migration/Resources/Auth/Policies.php @@ -0,0 +1,163 @@ +id = $id; + $this->createdAt = $createdAt; + $this->updatedAt = $updatedAt; + } + + /** + * @param array $array + */ + public static function fromArray(array $array): self + { + return new self( + $array['id'], + (int) ($array['passwordHistory'] ?? 0), + (int) ($array['sessionDuration'] ?? 31536000), + (int) ($array['sessionsLimit'] ?? 100), + (int) ($array['userLimit'] ?? 0), + (bool) ($array['passwordDictionary'] ?? false), + (bool) ($array['personalDataCheck'] ?? false), + (bool) ($array['sessionAlerts'] ?? false), + (bool) ($array['sessionInvalidation'] ?? false), + (bool) ($array['membershipsUserId'] ?? true), + (bool) ($array['membershipsUserEmail'] ?? true), + (bool) ($array['membershipsUserName'] ?? true), + (bool) ($array['membershipsUserMfa'] ?? true), + (bool) ($array['membershipsUserPhone'] ?? true), + createdAt: $array['createdAt'] ?? '', + updatedAt: $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'passwordHistory' => $this->passwordHistory, + 'sessionDuration' => $this->sessionDuration, + 'sessionsLimit' => $this->sessionsLimit, + 'userLimit' => $this->userLimit, + 'passwordDictionary' => $this->passwordDictionary, + 'personalDataCheck' => $this->personalDataCheck, + 'sessionAlerts' => $this->sessionAlerts, + 'sessionInvalidation' => $this->sessionInvalidation, + 'membershipsUserId' => $this->membershipsUserId, + 'membershipsUserEmail' => $this->membershipsUserEmail, + 'membershipsUserName' => $this->membershipsUserName, + 'membershipsUserMfa' => $this->membershipsUserMfa, + 'membershipsUserPhone' => $this->membershipsUserPhone, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public static function getName(): string + { + return Resource::TYPE_POLICIES; + } + + public function getGroup(): string + { + return Transfer::GROUP_AUTH; + } + + public function getPasswordHistory(): int + { + return $this->passwordHistory; + } + + public function getSessionDuration(): int + { + return $this->sessionDuration; + } + + public function getSessionsLimit(): int + { + return $this->sessionsLimit; + } + + public function getUserLimit(): int + { + return $this->userLimit; + } + + public function getPasswordDictionary(): bool + { + return $this->passwordDictionary; + } + + public function getPersonalDataCheck(): bool + { + return $this->personalDataCheck; + } + + public function getSessionAlerts(): bool + { + return $this->sessionAlerts; + } + + public function getSessionInvalidation(): bool + { + return $this->sessionInvalidation; + } + + public function getMembershipsUserId(): bool + { + return $this->membershipsUserId; + } + + public function getMembershipsUserEmail(): bool + { + return $this->membershipsUserEmail; + } + + public function getMembershipsUserName(): bool + { + return $this->membershipsUserName; + } + + public function getMembershipsUserMfa(): bool + { + return $this->membershipsUserMfa; + } + + public function getMembershipsUserPhone(): bool + { + return $this->membershipsUserPhone; + } +} diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 9d0c265c..a92833a2 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -22,6 +22,7 @@ use Utopia\Migration\Resources\Auth\AuthMethods; use Utopia\Migration\Resources\Auth\Hash; use Utopia\Migration\Resources\Auth\Membership; +use Utopia\Migration\Resources\Auth\Policies; use Utopia\Migration\Resources\Auth\Team; use Utopia\Migration\Resources\Auth\User; use Utopia\Migration\Resources\Database\Attribute; @@ -179,6 +180,7 @@ public static function getSupportedResources(): array Resource::TYPE_TEAM, Resource::TYPE_MEMBERSHIP, Resource::TYPE_AUTH_METHODS, + Resource::TYPE_POLICIES, // Database Resource::TYPE_DATABASE, @@ -372,6 +374,11 @@ private function reportAuth(array $resources, array &$report, array $resourceIds // Singleton — there is exactly one auth-methods config per project. $report[Resource::TYPE_AUTH_METHODS] = 1; } + + if (\in_array(Resource::TYPE_POLICIES, $resources)) { + // Singleton — one policies config per project. + $report[Resource::TYPE_POLICIES] = 1; + } } /** @@ -626,6 +633,55 @@ protected function exportGroupAuth(int $batchSize, array $resources): void previous: $e )); } + + try { + if (\in_array(Resource::TYPE_POLICIES, $resources)) { + $this->exportPolicies(); + } + } catch (\Throwable $e) { + $this->addError(new Exception( + Resource::TYPE_POLICIES, + Transfer::GROUP_AUTH, + message: $e->getMessage(), + code: (int) $e->getCode() ?: Exception::CODE_INTERNAL, + previous: $e + )); + } + } + + /** + * Read project security policies from /v1/project. 9 sub-policies collapse + * into a single Policies resource carrying 13 fields (MembershipPrivacy + * alone has 5 sub-flags). + */ + private function exportPolicies(): void + { + $response = $this->call('GET', '/v1/project'); + + if (!\is_array($response)) { + return; + } + + $policies = new Policies( + $this->projectId, + (int) ($response['authPasswordHistory'] ?? 0), + (int) ($response['authDuration'] ?? 31536000), + (int) ($response['authSessionsLimit'] ?? 100), + (int) ($response['authLimit'] ?? 0), + (bool) ($response['authPasswordDictionary'] ?? false), + (bool) ($response['authPersonalDataCheck'] ?? false), + (bool) ($response['authSessionAlerts'] ?? false), + (bool) ($response['authInvalidateSessions'] ?? false), + (bool) ($response['authMembershipsUserId'] ?? true), + (bool) ($response['authMembershipsUserEmail'] ?? true), + (bool) ($response['authMembershipsUserName'] ?? true), + (bool) ($response['authMembershipsMfa'] ?? true), + (bool) ($response['authMembershipsUserPhone'] ?? true), + createdAt: $response['$createdAt'] ?? '', + updatedAt: $response['$updatedAt'] ?? '', + ); + + $this->callback([$policies]); } /** diff --git a/src/Migration/Transfer.php b/src/Migration/Transfer.php index 7bc80502..c22d1c26 100644 --- a/src/Migration/Transfer.php +++ b/src/Migration/Transfer.php @@ -36,6 +36,7 @@ class Transfer Resource::TYPE_MEMBERSHIP, Resource::TYPE_HASH, Resource::TYPE_AUTH_METHODS, + Resource::TYPE_POLICIES, ]; public const GROUP_STORAGE_RESOURCES = [ @@ -119,6 +120,7 @@ class Transfer Resource::TYPE_TEAM, Resource::TYPE_MEMBERSHIP, Resource::TYPE_AUTH_METHODS, + Resource::TYPE_POLICIES, Resource::TYPE_FILE, Resource::TYPE_BUCKET, Resource::TYPE_FUNCTION, From ddb4a6d666a79fd286c631cd9500cdb4062e6ad9 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 18 May 2026 22:04:46 +0100 Subject: [PATCH 07/17] Fix doubled /v1 prefix in source-side project calls The SDK Client's endpoint already includes /v1; calling $this->call('GET', '/v1/project') produced http://host/v1/v1/project and 404'd. Existing '/health/version' caller already follows the prefix-less convention. Affects all five project-singleton sources (AuthMethods, Policies, Protocols, Labels, Services). --- src/Migration/Sources/Appwrite.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index a92833a2..413bfc6d 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -656,7 +656,7 @@ protected function exportGroupAuth(int $batchSize, array $resources): void */ private function exportPolicies(): void { - $response = $this->call('GET', '/v1/project'); + $response = $this->call('GET', '/project'); if (!\is_array($response)) { return; @@ -690,7 +690,7 @@ private function exportPolicies(): void */ private function exportAuthMethods(): void { - $response = $this->call('GET', '/v1/project'); + $response = $this->call('GET', '/project'); if (!\is_array($response)) { return; @@ -1697,7 +1697,7 @@ protected function exportGroupSettings(int $batchSize, array $resources): void */ private function exportServices(): void { - $response = $this->call('GET', '/v1/project'); + $response = $this->call('GET', '/project'); if (!\is_array($response)) { return; @@ -1734,7 +1734,7 @@ private function exportServices(): void */ private function exportLabels(): void { - $response = $this->call('GET', '/v1/project'); + $response = $this->call('GET', '/project'); if (!\is_array($response)) { return; @@ -1756,7 +1756,7 @@ private function exportLabels(): void */ private function exportProtocols(): void { - $response = $this->call('GET', '/v1/project'); + $response = $this->call('GET', '/project'); if (!\is_array($response)) { return; From 0a9e5bfe1e6be75f2a80b2be39d26a742a57a7c7 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 19 May 2026 15:27:06 +0100 Subject: [PATCH 08/17] Coerce 0 to null for nullable-int policy fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit passwordHistory / sessionsLimit / userLimit treat 0 as "disabled" in the source's project document, but the server policy validators reject 0 — they accept a positive int or null. Disabled policies were round-tripping as 0 and the destination rejected the update with: Invalid `total` param: Value must be a valid range between 1 and 5,000 or null Coerce 0 -> null at the SDK call site so the disabled state preserves correctly across the migration. --- src/Migration/Destinations/Appwrite.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 43832ae9..edd576e0 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -3373,13 +3373,20 @@ protected function createAuthMethods(AuthMethods $resource): bool * single-policy updates rather than one bulk write — each policy endpoint * runs its own validation (history bounds, session-duration window, etc.), * and membership-privacy bundles its five sub-flags into one call. + * + * passwordHistory / sessionsLimit / userLimit treat 0 as "disabled" in the + * source's project document, but the server validators reject 0 — they + * accept a positive int or null. Coerce 0 → null at the call site so the + * disabled state round-trips correctly. */ protected function createPolicies(Policies $resource): bool { - $this->project->updatePasswordHistoryPolicy($resource->getPasswordHistory()); + $nullIfZero = fn (int $v): ?int => $v > 0 ? $v : null; + + $this->project->updatePasswordHistoryPolicy($nullIfZero($resource->getPasswordHistory())); $this->project->updateSessionDurationPolicy($resource->getSessionDuration()); - $this->project->updateSessionLimitPolicy($resource->getSessionsLimit()); - $this->project->updateUserLimitPolicy($resource->getUserLimit()); + $this->project->updateSessionLimitPolicy($nullIfZero($resource->getSessionsLimit())); + $this->project->updateUserLimitPolicy($nullIfZero($resource->getUserLimit())); $this->project->updatePasswordDictionaryPolicy($resource->getPasswordDictionary()); $this->project->updatePasswordPersonalDataPolicy($resource->getPersonalDataCheck()); $this->project->updateSessionAlertPolicy($resource->getSessionAlerts()); From e998c64a7e88896b01ddadac4aa6a53b05a237b0 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 19 May 2026 16:07:17 +0100 Subject: [PATCH 09/17] Write policies via dbForPlatform on destination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 9 SDK setter calls (updatePasswordHistoryPolicy, etc.) with a single dbForPlatform->updateDocument('projects', ...) write. Matches the convention used by Webhook / Platform / ProjectVariable / ApiKey destinations — every other project-singleton resource already writes directly to the project document instead of going through the API. Drops two server-side workarounds along the way: - SDK setters return MODEL_PROJECT, which carries cloud's empty billingLimits as {} that strict-typed SDKs reject ("Missing required field bandwidth"). Direct DB write never deserializes a Project, so the bug is irrelevant. - PasswordHistory / SessionLimit / UserLimit endpoints reject total: 0 even though 0 is the storage convention for "disabled" (see response model description). Direct DB write writes 0 straight to storage, bypassing the validator mismatch. Note: cloud spec fix (appwrite-labs/cloud#4068) and validator fix (separate appwrite/appwrite PR for Range(0, ...)) are still worth landing — they help any other SDK consumer hitting the same paths — but the migration no longer depends on either. --- src/Migration/Destinations/Appwrite.php | 68 ++++++++++++++++--------- 1 file changed, 44 insertions(+), 24 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index edd576e0..813d3102 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -3369,36 +3369,56 @@ protected function createAuthMethods(AuthMethods $resource): bool } /** - * Replay each project security policy on the destination via the SDK. Nine - * single-policy updates rather than one bulk write — each policy endpoint - * runs its own validation (history bounds, session-duration window, etc.), - * and membership-privacy bundles its five sub-flags into one call. + * Write all 9 policies into the project document's `auths` attribute in a + * single `updateDocument` call. Matches the convention used by Webhook / + * Platform / ProjectVariable / ApiKey destinations. * - * passwordHistory / sessionsLimit / userLimit treat 0 as "disabled" in the - * source's project document, but the server validators reject 0 — they - * accept a positive int or null. Coerce 0 → null at the call site so the - * disabled state round-trips correctly. + * Direct DB write — chosen over the SDK setter methods because: + * - SDK methods (updatePasswordHistoryPolicy, etc.) return the full + * Project response model. Cloud's response emits `billingLimits: {}` + * which crashes typed-SDK deserialization on the required `bandwidth` + * field, so every per-policy call would crash even though its write + * succeeded server-side. + * - PasswordHistory/UserLimit/SessionLimit endpoints reject `total: 0` + * even though `0` is the storage value for "disabled" (see response + * model docs). A round-trip of disabled state goes through 0 in + * storage but the validator only accepts 1-N or null. + * + * Going direct to the underlying document sidesteps both. The data shape + * matches what the per-policy controllers themselves write into `auths`. */ protected function createPolicies(Policies $resource): bool { - $nullIfZero = fn (int $v): ?int => $v > 0 ? $v : null; - - $this->project->updatePasswordHistoryPolicy($nullIfZero($resource->getPasswordHistory())); - $this->project->updateSessionDurationPolicy($resource->getSessionDuration()); - $this->project->updateSessionLimitPolicy($nullIfZero($resource->getSessionsLimit())); - $this->project->updateUserLimitPolicy($nullIfZero($resource->getUserLimit())); - $this->project->updatePasswordDictionaryPolicy($resource->getPasswordDictionary()); - $this->project->updatePasswordPersonalDataPolicy($resource->getPersonalDataCheck()); - $this->project->updateSessionAlertPolicy($resource->getSessionAlerts()); - $this->project->updateSessionInvalidationPolicy($resource->getSessionInvalidation()); - $this->project->updateMembershipPrivacyPolicy( - userId: $resource->getMembershipsUserId(), - userEmail: $resource->getMembershipsUserEmail(), - userPhone: $resource->getMembershipsUserPhone(), - userName: $resource->getMembershipsUserName(), - userMFA: $resource->getMembershipsUserMfa(), + $project = $this->dbForPlatform->getDocument('projects', $this->projectId); + $auths = $project->getAttribute('auths', []); + + // Ints. Source's 0 = disabled; the same convention applies in storage. + $auths['passwordHistory'] = $resource->getPasswordHistory(); + $auths['duration'] = $resource->getSessionDuration(); + $auths['maxSessions'] = $resource->getSessionsLimit(); + $auths['limit'] = $resource->getUserLimit(); + + // Booleans. + $auths['passwordDictionary'] = $resource->getPasswordDictionary(); + $auths['personalDataCheck'] = $resource->getPersonalDataCheck(); + $auths['sessionAlerts'] = $resource->getSessionAlerts(); + $auths['invalidateSessions'] = $resource->getSessionInvalidation(); + + // Membership-privacy bundle. + $auths['membershipsUserId'] = $resource->getMembershipsUserId(); + $auths['membershipsUserEmail'] = $resource->getMembershipsUserEmail(); + $auths['membershipsUserName'] = $resource->getMembershipsUserName(); + $auths['membershipsMfa'] = $resource->getMembershipsUserMfa(); + $auths['membershipsUserPhone'] = $resource->getMembershipsUserPhone(); + + $this->dbForPlatform->updateDocument( + 'projects', + $this->projectId, + new UtopiaDocument(['auths' => $auths]), ); + $this->dbForPlatform->purgeCachedDocument('projects', $this->projectId); + return true; } From 83341566bf29536fd43ba2093cf9a65ea215c259 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 19 May 2026 17:40:16 +0100 Subject: [PATCH 10/17] Skip auth on platform-DB project update for policies write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform 'projects' collection has document-level permissions restricted to team-owner roles. The migration worker has no team context, so the updateDocument call was being silently rejected by the authorization validator — the migration reported success because no exception bubbled up, but the destination project's auths attribute was left unchanged. Match the upstream Policies/Labels controllers' pattern by wrapping the write in $dbForPlatform->getAuthorization()->skip(...). --- src/Migration/Destinations/Appwrite.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 813d3102..0b8e5f09 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -3411,11 +3411,14 @@ protected function createPolicies(Policies $resource): bool $auths['membershipsMfa'] = $resource->getMembershipsUserMfa(); $auths['membershipsUserPhone'] = $resource->getMembershipsUserPhone(); - $this->dbForPlatform->updateDocument( + // The projects document is restricted to team-owner role on update; + // we're running in the migration worker which has no team context, so + // skip authorization the same way the upstream policy controllers do. + $this->dbForPlatform->getAuthorization()->skip(fn () => $this->dbForPlatform->updateDocument( 'projects', $this->projectId, new UtopiaDocument(['auths' => $auths]), - ); + )); $this->dbForPlatform->purgeCachedDocument('projects', $this->projectId); From a18e76b1de14feae0966e2c1a99fcc1f9d7a245b Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Wed, 20 May 2026 11:14:22 +0100 Subject: [PATCH 11/17] chore: bump appwrite/appwrite to 24.* and rename Project* enums - composer.json: appwrite/appwrite ^23 -> ^24 - composer.lock: appwrite/appwrite 23.1.0 -> 24.1.0 - AuthMethod -> ProjectAuthMethodId - ProtocolId -> ProjectProtocolId - ServiceId -> ProjectServiceId 24.1.0 brings the BillingLimits nullable + Project.consoleAccessedAt fix that was blocking the policies migration, and adds Project::get and Project::getPolicy methods used by the source-side refactor. --- composer.json | 2 +- composer.lock | 14 +++--- src/Migration/Destinations/Appwrite.php | 60 ++++++++++++------------- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/composer.json b/composer.json index 6bd64041..5dc3f557 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "ext-curl": "*", "ext-openssl": "*", - "appwrite/appwrite": "23.*", + "appwrite/appwrite": "24.*", "utopia-php/database": "5.*", "utopia-php/storage": "2.*", "utopia-php/dsn": "0.2.*", diff --git a/composer.lock b/composer.lock index ab0a68c7..d4a75bb0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "44746ecb1183e23d963fc90b1481541a", + "content-hash": "d500f176f703c97758a1fc200f0ace55", "packages": [ { "name": "appwrite/appwrite", - "version": "23.1.0", + "version": "24.1.0", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-for-php.git", - "reference": "2f275921f10ceb7cff99f2d463f7328b296234fa" + "reference": "dcb3550a3332de1c1665a015a09e9c73ff515e4f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/2f275921f10ceb7cff99f2d463f7328b296234fa", - "reference": "2f275921f10ceb7cff99f2d463f7328b296234fa", + "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/dcb3550a3332de1c1665a015a09e9c73ff515e4f", + "reference": "dcb3550a3332de1c1665a015a09e9c73ff515e4f", "shasum": "" }, "require": { @@ -43,10 +43,10 @@ "support": { "email": "team@appwrite.io", "issues": "https://github.com/appwrite/sdk-for-php/issues", - "source": "https://github.com/appwrite/sdk-for-php/tree/23.1.0", + "source": "https://github.com/appwrite/sdk-for-php/tree/24.1.0", "url": "https://appwrite.io/support" }, - "time": "2026-05-08T13:44:58+00:00" + "time": "2026-05-20T09:37:03+00:00" }, { "name": "brick/math", diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 0b8e5f09..b475f975 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -5,14 +5,14 @@ use Appwrite\AppwriteException; use Appwrite\Client; use Appwrite\Enums\Adapter; -use Appwrite\Enums\AuthMethod; +use Appwrite\Enums\ProjectAuthMethodId; use Appwrite\Enums\BuildRuntime; use Appwrite\Enums\Compression; use Appwrite\Enums\Framework; use Appwrite\Enums\PasswordHash; -use Appwrite\Enums\ProtocolId; +use Appwrite\Enums\ProjectProtocolId; use Appwrite\Enums\Runtime; -use Appwrite\Enums\ServiceId; +use Appwrite\Enums\ProjectServiceId; use Appwrite\Enums\SmtpEncryption; use Appwrite\InputFile; use Appwrite\Services\Functions; @@ -3199,9 +3199,9 @@ protected function createProjectVariable(ProjectVariable $resource): bool protected function createProtocols(Protocols $resource): bool { $flags = [ - [ProtocolId::REST(), $resource->getRest()], - [ProtocolId::GRAPHQL(), $resource->getGraphql()], - [ProtocolId::WEBSOCKET(), $resource->getWebsocket()], + [ProjectProtocolId::REST(), $resource->getRest()], + [ProjectProtocolId::GRAPHQL(), $resource->getGraphql()], + [ProjectProtocolId::WEBSOCKET(), $resource->getWebsocket()], ]; foreach ($flags as [$protocol, $enabled]) { @@ -3230,23 +3230,23 @@ protected function createLabels(Labels $resource): bool protected function createServices(ServicesResource $resource): bool { $flags = [ - [ServiceId::ACCOUNT(), $resource->getAccount()], - [ServiceId::AVATARS(), $resource->getAvatars()], - [ServiceId::DATABASES(), $resource->getDatabases()], - [ServiceId::TABLESDB(), $resource->getTablesdb()], - [ServiceId::LOCALE(), $resource->getLocale()], - [ServiceId::HEALTH(), $resource->getHealth()], - [ServiceId::PROJECT(), $resource->getProject()], - [ServiceId::STORAGE(), $resource->getStorage()], - [ServiceId::TEAMS(), $resource->getTeams()], - [ServiceId::USERS(), $resource->getUsers()], - [ServiceId::VCS(), $resource->getVcs()], - [ServiceId::SITES(), $resource->getSites()], - [ServiceId::FUNCTIONS(), $resource->getFunctions()], - [ServiceId::PROXY(), $resource->getProxy()], - [ServiceId::GRAPHQL(), $resource->getGraphql()], - [ServiceId::MIGRATIONS(), $resource->getMigrations()], - [ServiceId::MESSAGING(), $resource->getMessaging()], + [ProjectServiceId::ACCOUNT(), $resource->getAccount()], + [ProjectServiceId::AVATARS(), $resource->getAvatars()], + [ProjectServiceId::DATABASES(), $resource->getDatabases()], + [ProjectServiceId::TABLESDB(), $resource->getTablesdb()], + [ProjectServiceId::LOCALE(), $resource->getLocale()], + [ProjectServiceId::HEALTH(), $resource->getHealth()], + [ProjectServiceId::PROJECT(), $resource->getProject()], + [ProjectServiceId::STORAGE(), $resource->getStorage()], + [ProjectServiceId::TEAMS(), $resource->getTeams()], + [ProjectServiceId::USERS(), $resource->getUsers()], + [ProjectServiceId::VCS(), $resource->getVcs()], + [ProjectServiceId::SITES(), $resource->getSites()], + [ProjectServiceId::FUNCTIONS(), $resource->getFunctions()], + [ProjectServiceId::PROXY(), $resource->getProxy()], + [ProjectServiceId::GRAPHQL(), $resource->getGraphql()], + [ProjectServiceId::MIGRATIONS(), $resource->getMigrations()], + [ProjectServiceId::MESSAGING(), $resource->getMessaging()], ]; foreach ($flags as [$service, $enabled]) { @@ -3352,13 +3352,13 @@ protected function createPlatform(Platform $resource): bool protected function createAuthMethods(AuthMethods $resource): bool { $flags = [ - [AuthMethod::EMAILPASSWORD(), $resource->getEmailPassword()], - [AuthMethod::MAGICURL(), $resource->getMagicURL()], - [AuthMethod::EMAILOTP(), $resource->getEmailOtp()], - [AuthMethod::ANONYMOUS(), $resource->getAnonymous()], - [AuthMethod::INVITES(), $resource->getInvites()], - [AuthMethod::JWT(), $resource->getJwt()], - [AuthMethod::PHONE(), $resource->getPhone()], + [ProjectAuthMethodId::EMAILPASSWORD(), $resource->getEmailPassword()], + [ProjectAuthMethodId::MAGICURL(), $resource->getMagicURL()], + [ProjectAuthMethodId::EMAILOTP(), $resource->getEmailOtp()], + [ProjectAuthMethodId::ANONYMOUS(), $resource->getAnonymous()], + [ProjectAuthMethodId::INVITES(), $resource->getInvites()], + [ProjectAuthMethodId::JWT(), $resource->getJwt()], + [ProjectAuthMethodId::PHONE(), $resource->getPhone()], ]; foreach ($flags as [$method, $enabled]) { From bac84c4c1e2fd571f51179597bc15e7023dc0ac2 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Wed, 20 May 2026 11:22:35 +0100 Subject: [PATCH 12/17] refactor(policies): export via per-policy SDK methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /v1/project response doesn't expose per-policy fields at the top level — they live inside the project document's `auths` attribute which the public Project response model omits. Switch from a raw `GET /v1/project` call to per-policy SDK methods (Project::getPolicy(ProjectPolicyId::*)) which return typed policy models (PolicyPasswordHistory, PolicySessionAlert, etc.). Each of the 9 sub-policies maps to one method call. --- src/Migration/Sources/Appwrite.php | 66 +++++++++++++++++++----------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 413bfc6d..fcea1bb0 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -4,6 +4,7 @@ use Appwrite\AppwriteException; use Appwrite\Client; +use Appwrite\Enums\ProjectPolicyId; use Appwrite\Query; use Appwrite\Services\Functions; use Appwrite\Services\Messaging; @@ -650,35 +651,54 @@ protected function exportGroupAuth(int $batchSize, array $resources): void } /** - * Read project security policies from /v1/project. 9 sub-policies collapse - * into a single Policies resource carrying 13 fields (MembershipPrivacy - * alone has 5 sub-flags). + * Read project security policies via the per-policy SDK endpoints. + * + * The /v1/project response doesn't expose the per-policy fields at the + * top level — they live inside the project document's `auths` attribute + * which the public Project response model omits. The dedicated per-policy + * SDK methods read each policy individually and return per-policy typed + * models (PolicyPasswordHistory, PolicySessionAlert, etc.), avoiding the + * Project response model entirely. + * + * 9 single-policy reads collapse into one Policies resource carrying 13 + * fields (MembershipPrivacy alone has 5 sub-flags). */ private function exportPolicies(): void { - $response = $this->call('GET', '/project'); - - if (!\is_array($response)) { - return; - } + /** @var \Appwrite\Models\PolicyPasswordHistory $passwordHistory */ + $passwordHistory = $this->project->getPolicy(ProjectPolicyId::PASSWORDHISTORY()); + /** @var \Appwrite\Models\PolicyPasswordDictionary $passwordDictionary */ + $passwordDictionary = $this->project->getPolicy(ProjectPolicyId::PASSWORDDICTIONARY()); + /** @var \Appwrite\Models\PolicyPasswordPersonalData $passwordPersonalData */ + $passwordPersonalData = $this->project->getPolicy(ProjectPolicyId::PASSWORDPERSONALDATA()); + /** @var \Appwrite\Models\PolicySessionAlert $sessionAlert */ + $sessionAlert = $this->project->getPolicy(ProjectPolicyId::SESSIONALERT()); + /** @var \Appwrite\Models\PolicySessionDuration $sessionDuration */ + $sessionDuration = $this->project->getPolicy(ProjectPolicyId::SESSIONDURATION()); + /** @var \Appwrite\Models\PolicySessionInvalidation $sessionInvalidation */ + $sessionInvalidation = $this->project->getPolicy(ProjectPolicyId::SESSIONINVALIDATION()); + /** @var \Appwrite\Models\PolicySessionLimit $sessionLimit */ + $sessionLimit = $this->project->getPolicy(ProjectPolicyId::SESSIONLIMIT()); + /** @var \Appwrite\Models\PolicyUserLimit $userLimit */ + $userLimit = $this->project->getPolicy(ProjectPolicyId::USERLIMIT()); + /** @var \Appwrite\Models\PolicyMembershipPrivacy $membershipPrivacy */ + $membershipPrivacy = $this->project->getPolicy(ProjectPolicyId::MEMBERSHIPPRIVACY()); $policies = new Policies( $this->projectId, - (int) ($response['authPasswordHistory'] ?? 0), - (int) ($response['authDuration'] ?? 31536000), - (int) ($response['authSessionsLimit'] ?? 100), - (int) ($response['authLimit'] ?? 0), - (bool) ($response['authPasswordDictionary'] ?? false), - (bool) ($response['authPersonalDataCheck'] ?? false), - (bool) ($response['authSessionAlerts'] ?? false), - (bool) ($response['authInvalidateSessions'] ?? false), - (bool) ($response['authMembershipsUserId'] ?? true), - (bool) ($response['authMembershipsUserEmail'] ?? true), - (bool) ($response['authMembershipsUserName'] ?? true), - (bool) ($response['authMembershipsMfa'] ?? true), - (bool) ($response['authMembershipsUserPhone'] ?? true), - createdAt: $response['$createdAt'] ?? '', - updatedAt: $response['$updatedAt'] ?? '', + $passwordHistory->total, + $sessionDuration->duration, + $sessionLimit->total, + $userLimit->total, + $passwordDictionary->enabled, + $passwordPersonalData->enabled, + $sessionAlert->enabled, + $sessionInvalidation->enabled, + $membershipPrivacy->userId, + $membershipPrivacy->userEmail, + $membershipPrivacy->userName, + $membershipPrivacy->userMFA, + $membershipPrivacy->userPhone, ); $this->callback([$policies]); From de887c1443362299d232322196d940d816c79f0d Mon Sep 17 00:00:00 2001 From: premtsd-code Date: Wed, 20 May 2026 12:12:59 +0100 Subject: [PATCH 13/17] refactor(source): use Project::get() SDK for AuthMethods/Protocols/Services/Labels Replaces 4 raw 'GET /v1/project' HTTP calls with the typed Project model from SDK 24.1.0. Each resource iterates the typed authMethods/protocols/ services arrays (each containing typed ProjectAuthMethod/ProjectProtocol/ ProjectService models with id+enabled fields) and builds an id->enabled lookup keyed by the corresponding Project*Id enum. Brings the source side fully in line with policies: all 5 settings resources now read via the SDK only, no raw HTTP calls remain. --- src/Migration/Sources/Appwrite.php | 107 +++++++++++++++-------------- 1 file changed, 54 insertions(+), 53 deletions(-) diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index fcea1bb0..26f59473 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -4,7 +4,10 @@ use Appwrite\AppwriteException; use Appwrite\Client; +use Appwrite\Enums\ProjectAuthMethodId; use Appwrite\Enums\ProjectPolicyId; +use Appwrite\Enums\ProjectProtocolId; +use Appwrite\Enums\ProjectServiceId; use Appwrite\Query; use Appwrite\Services\Functions; use Appwrite\Services\Messaging; @@ -710,23 +713,24 @@ private function exportPolicies(): void */ private function exportAuthMethods(): void { - $response = $this->call('GET', '/project'); + $project = $this->project->get(); - if (!\is_array($response)) { - return; + $byId = []; + foreach ($project->authMethods as $method) { + $byId[(string) $method->id] = $method->enabled; } $authMethods = new AuthMethods( $this->projectId, - (bool) ($response['authEmailPassword'] ?? true), - (bool) ($response['authUsersAuthMagicURL'] ?? true), - (bool) ($response['authEmailOtp'] ?? true), - (bool) ($response['authAnonymous'] ?? true), - (bool) ($response['authInvites'] ?? true), - (bool) ($response['authJWT'] ?? true), - (bool) ($response['authPhone'] ?? true), - createdAt: $response['$createdAt'] ?? '', - updatedAt: $response['$updatedAt'] ?? '', + $byId[(string) ProjectAuthMethodId::EMAILPASSWORD()] ?? true, + $byId[(string) ProjectAuthMethodId::MAGICURL()] ?? true, + $byId[(string) ProjectAuthMethodId::EMAILOTP()] ?? true, + $byId[(string) ProjectAuthMethodId::ANONYMOUS()] ?? true, + $byId[(string) ProjectAuthMethodId::INVITES()] ?? true, + $byId[(string) ProjectAuthMethodId::JWT()] ?? true, + $byId[(string) ProjectAuthMethodId::PHONE()] ?? true, + createdAt: $project->createdAt, + updatedAt: $project->updatedAt, ); $this->callback([$authMethods]); @@ -1717,78 +1721,75 @@ protected function exportGroupSettings(int $batchSize, array $resources): void */ private function exportServices(): void { - $response = $this->call('GET', '/project'); + $project = $this->project->get(); - if (!\is_array($response)) { - return; + $byId = []; + foreach ($project->services as $service) { + $byId[(string) $service->id] = $service->enabled; } $services = new ServicesResource( $this->projectId, - (bool) ($response['serviceStatusForAccount'] ?? true), - (bool) ($response['serviceStatusForAvatars'] ?? true), - (bool) ($response['serviceStatusForDatabases'] ?? true), - (bool) ($response['serviceStatusForTablesdb'] ?? true), - (bool) ($response['serviceStatusForLocale'] ?? true), - (bool) ($response['serviceStatusForHealth'] ?? true), - (bool) ($response['serviceStatusForProject'] ?? true), - (bool) ($response['serviceStatusForStorage'] ?? true), - (bool) ($response['serviceStatusForTeams'] ?? true), - (bool) ($response['serviceStatusForUsers'] ?? true), - (bool) ($response['serviceStatusForVcs'] ?? true), - (bool) ($response['serviceStatusForSites'] ?? true), - (bool) ($response['serviceStatusForFunctions'] ?? true), - (bool) ($response['serviceStatusForProxy'] ?? true), - (bool) ($response['serviceStatusForGraphql'] ?? true), - (bool) ($response['serviceStatusForMigrations'] ?? true), - (bool) ($response['serviceStatusForMessaging'] ?? true), - createdAt: $response['$createdAt'] ?? '', - updatedAt: $response['$updatedAt'] ?? '', + $byId[(string) ProjectServiceId::ACCOUNT()] ?? true, + $byId[(string) ProjectServiceId::AVATARS()] ?? true, + $byId[(string) ProjectServiceId::DATABASES()] ?? true, + $byId[(string) ProjectServiceId::TABLESDB()] ?? true, + $byId[(string) ProjectServiceId::LOCALE()] ?? true, + $byId[(string) ProjectServiceId::HEALTH()] ?? true, + $byId[(string) ProjectServiceId::PROJECT()] ?? true, + $byId[(string) ProjectServiceId::STORAGE()] ?? true, + $byId[(string) ProjectServiceId::TEAMS()] ?? true, + $byId[(string) ProjectServiceId::USERS()] ?? true, + $byId[(string) ProjectServiceId::VCS()] ?? true, + $byId[(string) ProjectServiceId::SITES()] ?? true, + $byId[(string) ProjectServiceId::FUNCTIONS()] ?? true, + $byId[(string) ProjectServiceId::PROXY()] ?? true, + $byId[(string) ProjectServiceId::GRAPHQL()] ?? true, + $byId[(string) ProjectServiceId::MIGRATIONS()] ?? true, + $byId[(string) ProjectServiceId::MESSAGING()] ?? true, + createdAt: $project->createdAt, + updatedAt: $project->updatedAt, ); $this->callback([$services]); } /** - * Read project-level labels from /v1/project. No SDK Project.get() exposed. + * Read project-level labels via the typed Project model. */ private function exportLabels(): void { - $response = $this->call('GET', '/project'); - - if (!\is_array($response)) { - return; - } + $project = $this->project->get(); $labels = new Labels( $this->projectId, - (array) ($response['labels'] ?? []), - createdAt: $response['$createdAt'] ?? '', - updatedAt: $response['$updatedAt'] ?? '', + $project->labels, + createdAt: $project->createdAt, + updatedAt: $project->updatedAt, ); $this->callback([$labels]); } /** - * Read protocol flags from the source project document. - * No SDK Project.get() exposed; using raw HTTP against /v1/project. + * Read protocol flags from the typed Project model. */ private function exportProtocols(): void { - $response = $this->call('GET', '/project'); + $project = $this->project->get(); - if (!\is_array($response)) { - return; + $byId = []; + foreach ($project->protocols as $protocol) { + $byId[(string) $protocol->id] = $protocol->enabled; } $protocols = new Protocols( $this->projectId, - (bool) ($response['protocolStatusForRest'] ?? true), - (bool) ($response['protocolStatusForGraphql'] ?? true), - (bool) ($response['protocolStatusForWebsocket'] ?? true), - createdAt: $response['$createdAt'] ?? '', - updatedAt: $response['$updatedAt'] ?? '', + $byId[(string) ProjectProtocolId::REST()] ?? true, + $byId[(string) ProjectProtocolId::GRAPHQL()] ?? true, + $byId[(string) ProjectProtocolId::WEBSOCKET()] ?? true, + createdAt: $project->createdAt, + updatedAt: $project->updatedAt, ); $this->callback([$protocols]); From fb634d6696c3041e75b552d1fe71625c7a84359f Mon Sep 17 00:00:00 2001 From: premtsd-code Date: Wed, 20 May 2026 12:21:35 +0100 Subject: [PATCH 14/17] refactor(destination): direct dbForPlatform writes for all 5 settings Unifies the destination side: Protocols, Labels, Services, AuthMethods, and Policies now all write to the project document directly via dbForPlatform->updateDocument(...) wrapped in getAuthorization()->skip(). Each resource bundles its fields into ONE document write instead of N SDK round-trips: - Protocols 3 SDK calls -> 1 document update - Services 17 SDK calls -> 1 document update - AuthMethods 7 SDK calls -> 1 document update - Labels 1 SDK call -> 1 document update (with array_unique dedupe) - Policies (unchanged: was already direct DB) Field mapping mirrors the upstream server handlers: - Protocols -> project.apis (map) - Services -> project.services (map) - Labels -> project.labels (array, deduped) - AuthMethods -> project.auths (map; keys from app/config/auth.php) - Policies -> project.auths (map; shares same attribute as AuthMethods) AuthMethods and Policies both read-then-merge the auths map so they coexist when both ship in the same migration. --- src/Migration/Destinations/Appwrite.php | 143 +++++++++++++++--------- 1 file changed, 90 insertions(+), 53 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index b475f975..8949a6ad 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -5,7 +5,6 @@ use Appwrite\AppwriteException; use Appwrite\Client; use Appwrite\Enums\Adapter; -use Appwrite\Enums\ProjectAuthMethodId; use Appwrite\Enums\BuildRuntime; use Appwrite\Enums\Compression; use Appwrite\Enums\Framework; @@ -3196,62 +3195,86 @@ protected function createProjectVariable(ProjectVariable $resource): bool * updates rather than one bulk write — the SDK only exposes per-protocol * setters, mirroring upstream's per-flag controllers. */ + /** + * Write protocol flags directly to the project document's `apis` attribute. + * Mirrors upstream's per-protocol Update handler which does the same map merge + * + auth-skipped write, but bundles the 3 flags into a single document write. + */ protected function createProtocols(Protocols $resource): bool { - $flags = [ - [ProjectProtocolId::REST(), $resource->getRest()], - [ProjectProtocolId::GRAPHQL(), $resource->getGraphql()], - [ProjectProtocolId::WEBSOCKET(), $resource->getWebsocket()], - ]; + $project = $this->dbForPlatform->getDocument('projects', $this->projectId); + $apis = $project->getAttribute('apis', []); - foreach ($flags as [$protocol, $enabled]) { - $this->project->updateProtocol($protocol, $enabled); - } + $apis[(string) ProjectProtocolId::REST()] = $resource->getRest(); + $apis[(string) ProjectProtocolId::GRAPHQL()] = $resource->getGraphql(); + $apis[(string) ProjectProtocolId::WEBSOCKET()] = $resource->getWebsocket(); + + $this->dbForPlatform->getAuthorization()->skip(fn () => $this->dbForPlatform->updateDocument( + 'projects', + $this->projectId, + new UtopiaDocument(['apis' => $apis]), + )); + + $this->dbForPlatform->purgeCachedDocument('projects', $this->projectId); return true; } /** - * Overwrite destination labels with the source array. Project::updateLabels - * is a wholesale replace, so the source's array is authoritative. + * Overwrite destination labels with the source array. Mirrors upstream's + * Labels Update handler which dedupes via array_unique + array_values. */ protected function createLabels(Labels $resource): bool { - $this->project->updateLabels($resource->getLabels()); + $labels = \array_values(\array_unique($resource->getLabels())); + + $this->dbForPlatform->getAuthorization()->skip(fn () => $this->dbForPlatform->updateDocument( + 'projects', + $this->projectId, + new UtopiaDocument(['labels' => $labels]), + )); + + $this->dbForPlatform->purgeCachedDocument('projects', $this->projectId); return true; } /** - * Flip each service flag on the destination via the SDK. 17 single-field - * updates rather than one bulk write — the SDK only exposes per-service - * setters, matching upstream's per-flag controller. + * Write all 17 service flags directly to the project document's `services` + * map in a single document write. Mirrors upstream's per-service Update + * handler which also reads/merges the `services` map; this bundles the 17 + * flags into one update instead of 17 round-trips. */ protected function createServices(ServicesResource $resource): bool { - $flags = [ - [ProjectServiceId::ACCOUNT(), $resource->getAccount()], - [ProjectServiceId::AVATARS(), $resource->getAvatars()], - [ProjectServiceId::DATABASES(), $resource->getDatabases()], - [ProjectServiceId::TABLESDB(), $resource->getTablesdb()], - [ProjectServiceId::LOCALE(), $resource->getLocale()], - [ProjectServiceId::HEALTH(), $resource->getHealth()], - [ProjectServiceId::PROJECT(), $resource->getProject()], - [ProjectServiceId::STORAGE(), $resource->getStorage()], - [ProjectServiceId::TEAMS(), $resource->getTeams()], - [ProjectServiceId::USERS(), $resource->getUsers()], - [ProjectServiceId::VCS(), $resource->getVcs()], - [ProjectServiceId::SITES(), $resource->getSites()], - [ProjectServiceId::FUNCTIONS(), $resource->getFunctions()], - [ProjectServiceId::PROXY(), $resource->getProxy()], - [ProjectServiceId::GRAPHQL(), $resource->getGraphql()], - [ProjectServiceId::MIGRATIONS(), $resource->getMigrations()], - [ProjectServiceId::MESSAGING(), $resource->getMessaging()], - ]; + $project = $this->dbForPlatform->getDocument('projects', $this->projectId); + $services = $project->getAttribute('services', []); + + $services[(string) ProjectServiceId::ACCOUNT()] = $resource->getAccount(); + $services[(string) ProjectServiceId::AVATARS()] = $resource->getAvatars(); + $services[(string) ProjectServiceId::DATABASES()] = $resource->getDatabases(); + $services[(string) ProjectServiceId::TABLESDB()] = $resource->getTablesdb(); + $services[(string) ProjectServiceId::LOCALE()] = $resource->getLocale(); + $services[(string) ProjectServiceId::HEALTH()] = $resource->getHealth(); + $services[(string) ProjectServiceId::PROJECT()] = $resource->getProject(); + $services[(string) ProjectServiceId::STORAGE()] = $resource->getStorage(); + $services[(string) ProjectServiceId::TEAMS()] = $resource->getTeams(); + $services[(string) ProjectServiceId::USERS()] = $resource->getUsers(); + $services[(string) ProjectServiceId::VCS()] = $resource->getVcs(); + $services[(string) ProjectServiceId::SITES()] = $resource->getSites(); + $services[(string) ProjectServiceId::FUNCTIONS()] = $resource->getFunctions(); + $services[(string) ProjectServiceId::PROXY()] = $resource->getProxy(); + $services[(string) ProjectServiceId::GRAPHQL()] = $resource->getGraphql(); + $services[(string) ProjectServiceId::MIGRATIONS()] = $resource->getMigrations(); + $services[(string) ProjectServiceId::MESSAGING()] = $resource->getMessaging(); - foreach ($flags as [$service, $enabled]) { - $this->project->updateService($service, $enabled); - } + $this->dbForPlatform->getAuthorization()->skip(fn () => $this->dbForPlatform->updateDocument( + 'projects', + $this->projectId, + new UtopiaDocument(['services' => $services]), + )); + + $this->dbForPlatform->purgeCachedDocument('projects', $this->projectId); return true; } @@ -3344,26 +3367,40 @@ protected function createPlatform(Platform $resource): bool } /** - * Flip each auth-method flag on the destination project via the SDK. Seven - * single-field updates rather than one bulk write — the SDK only exposes - * per-flag setters, and the destination needs to honor any server-side - * validation per flag (e.g. provider-specific guards). + * Write all 7 auth-method flags directly to the project document's `auths` + * map in a single document write. Mirrors upstream's per-method Update + * handler which also reads/merges the `auths` map; this bundles the 7 + * flags into one update instead of 7 round-trips. + * + * The auths-map storage keys are NOT the same as the SDK enum values: + * `email-password` → `emailPassword`, `magic-url` → `usersAuthMagicURL`, + * `email-otp` → `emailOtp`, `jwt` → `JWT`. The mapping mirrors + * `app/config/auth.php` on the server. + * + * The same `auths` map also stores policy values (see createPolicies). The + * read-then-merge pattern preserves whatever Policies wrote earlier in the + * migration (and vice versa). */ protected function createAuthMethods(AuthMethods $resource): bool { - $flags = [ - [ProjectAuthMethodId::EMAILPASSWORD(), $resource->getEmailPassword()], - [ProjectAuthMethodId::MAGICURL(), $resource->getMagicURL()], - [ProjectAuthMethodId::EMAILOTP(), $resource->getEmailOtp()], - [ProjectAuthMethodId::ANONYMOUS(), $resource->getAnonymous()], - [ProjectAuthMethodId::INVITES(), $resource->getInvites()], - [ProjectAuthMethodId::JWT(), $resource->getJwt()], - [ProjectAuthMethodId::PHONE(), $resource->getPhone()], - ]; + $project = $this->dbForPlatform->getDocument('projects', $this->projectId); + $auths = $project->getAttribute('auths', []); - foreach ($flags as [$method, $enabled]) { - $this->project->updateAuthMethod($method, $enabled); - } + $auths['emailPassword'] = $resource->getEmailPassword(); + $auths['usersAuthMagicURL'] = $resource->getMagicURL(); + $auths['emailOtp'] = $resource->getEmailOtp(); + $auths['anonymous'] = $resource->getAnonymous(); + $auths['invites'] = $resource->getInvites(); + $auths['JWT'] = $resource->getJwt(); + $auths['phone'] = $resource->getPhone(); + + $this->dbForPlatform->getAuthorization()->skip(fn () => $this->dbForPlatform->updateDocument( + 'projects', + $this->projectId, + new UtopiaDocument(['auths' => $auths]), + )); + + $this->dbForPlatform->purgeCachedDocument('projects', $this->projectId); return true; } From 16ff6ce143a7aff20bc8c86007264bce4c767ca2 Mon Sep 17 00:00:00 2001 From: premtsd-code Date: Wed, 20 May 2026 13:08:45 +0100 Subject: [PATCH 15/17] chore: trim verbose comments to match existing style Existing private/protected migration functions carry at most a one-line description (most have just @throws). The recent docblocks I added were over-explaining what the code already says. Kept only the two non-obvious WHYs: - createAuthMethods: storage keys differ from SDK enum values; shares the auths map with createPolicies. - createPolicies: SDK setters reject 0 even though 0 = disabled in storage. --- src/Migration/Destinations/Appwrite.php | 61 ++----------------------- src/Migration/Sources/Appwrite.php | 37 --------------- 2 files changed, 5 insertions(+), 93 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 8949a6ad..07f26dcb 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -3190,16 +3190,6 @@ protected function createProjectVariable(ProjectVariable $resource): bool return true; } - /** - * Flip each protocol on the destination via the SDK. Three single-field - * updates rather than one bulk write — the SDK only exposes per-protocol - * setters, mirroring upstream's per-flag controllers. - */ - /** - * Write protocol flags directly to the project document's `apis` attribute. - * Mirrors upstream's per-protocol Update handler which does the same map merge - * + auth-skipped write, but bundles the 3 flags into a single document write. - */ protected function createProtocols(Protocols $resource): bool { $project = $this->dbForPlatform->getDocument('projects', $this->projectId); @@ -3220,10 +3210,6 @@ protected function createProtocols(Protocols $resource): bool return true; } - /** - * Overwrite destination labels with the source array. Mirrors upstream's - * Labels Update handler which dedupes via array_unique + array_values. - */ protected function createLabels(Labels $resource): bool { $labels = \array_values(\array_unique($resource->getLabels())); @@ -3239,12 +3225,6 @@ protected function createLabels(Labels $resource): bool return true; } - /** - * Write all 17 service flags directly to the project document's `services` - * map in a single document write. Mirrors upstream's per-service Update - * handler which also reads/merges the `services` map; this bundles the 17 - * flags into one update instead of 17 round-trips. - */ protected function createServices(ServicesResource $resource): bool { $project = $this->dbForPlatform->getDocument('projects', $this->projectId); @@ -3367,19 +3347,8 @@ protected function createPlatform(Platform $resource): bool } /** - * Write all 7 auth-method flags directly to the project document's `auths` - * map in a single document write. Mirrors upstream's per-method Update - * handler which also reads/merges the `auths` map; this bundles the 7 - * flags into one update instead of 7 round-trips. - * - * The auths-map storage keys are NOT the same as the SDK enum values: - * `email-password` → `emailPassword`, `magic-url` → `usersAuthMagicURL`, - * `email-otp` → `emailOtp`, `jwt` → `JWT`. The mapping mirrors - * `app/config/auth.php` on the server. - * - * The same `auths` map also stores policy values (see createPolicies). The - * read-then-merge pattern preserves whatever Policies wrote earlier in the - * migration (and vice versa). + * Storage keys mirror app/config/auth.php, not the SDK enum values. + * Shares the `auths` map with createPolicies — read-then-merge. */ protected function createAuthMethods(AuthMethods $resource): bool { @@ -3406,51 +3375,31 @@ protected function createAuthMethods(AuthMethods $resource): bool } /** - * Write all 9 policies into the project document's `auths` attribute in a - * single `updateDocument` call. Matches the convention used by Webhook / - * Platform / ProjectVariable / ApiKey destinations. - * - * Direct DB write — chosen over the SDK setter methods because: - * - SDK methods (updatePasswordHistoryPolicy, etc.) return the full - * Project response model. Cloud's response emits `billingLimits: {}` - * which crashes typed-SDK deserialization on the required `bandwidth` - * field, so every per-policy call would crash even though its write - * succeeded server-side. - * - PasswordHistory/UserLimit/SessionLimit endpoints reject `total: 0` - * even though `0` is the storage value for "disabled" (see response - * model docs). A round-trip of disabled state goes through 0 in - * storage but the validator only accepts 1-N or null. - * - * Going direct to the underlying document sidesteps both. The data shape - * matches what the per-policy controllers themselves write into `auths`. + * Direct DB write — SDK policy setters reject `total: 0` but `0` is the + * storage value for "disabled". Shares the `auths` map with + * createAuthMethods — read-then-merge. */ protected function createPolicies(Policies $resource): bool { $project = $this->dbForPlatform->getDocument('projects', $this->projectId); $auths = $project->getAttribute('auths', []); - // Ints. Source's 0 = disabled; the same convention applies in storage. $auths['passwordHistory'] = $resource->getPasswordHistory(); $auths['duration'] = $resource->getSessionDuration(); $auths['maxSessions'] = $resource->getSessionsLimit(); $auths['limit'] = $resource->getUserLimit(); - // Booleans. $auths['passwordDictionary'] = $resource->getPasswordDictionary(); $auths['personalDataCheck'] = $resource->getPersonalDataCheck(); $auths['sessionAlerts'] = $resource->getSessionAlerts(); $auths['invalidateSessions'] = $resource->getSessionInvalidation(); - // Membership-privacy bundle. $auths['membershipsUserId'] = $resource->getMembershipsUserId(); $auths['membershipsUserEmail'] = $resource->getMembershipsUserEmail(); $auths['membershipsUserName'] = $resource->getMembershipsUserName(); $auths['membershipsMfa'] = $resource->getMembershipsUserMfa(); $auths['membershipsUserPhone'] = $resource->getMembershipsUserPhone(); - // The projects document is restricted to team-owner role on update; - // we're running in the migration worker which has no team context, so - // skip authorization the same way the upstream policy controllers do. $this->dbForPlatform->getAuthorization()->skip(fn () => $this->dbForPlatform->updateDocument( 'projects', $this->projectId, diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 26f59473..d54f935e 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -653,38 +653,16 @@ protected function exportGroupAuth(int $batchSize, array $resources): void } } - /** - * Read project security policies via the per-policy SDK endpoints. - * - * The /v1/project response doesn't expose the per-policy fields at the - * top level — they live inside the project document's `auths` attribute - * which the public Project response model omits. The dedicated per-policy - * SDK methods read each policy individually and return per-policy typed - * models (PolicyPasswordHistory, PolicySessionAlert, etc.), avoiding the - * Project response model entirely. - * - * 9 single-policy reads collapse into one Policies resource carrying 13 - * fields (MembershipPrivacy alone has 5 sub-flags). - */ private function exportPolicies(): void { - /** @var \Appwrite\Models\PolicyPasswordHistory $passwordHistory */ $passwordHistory = $this->project->getPolicy(ProjectPolicyId::PASSWORDHISTORY()); - /** @var \Appwrite\Models\PolicyPasswordDictionary $passwordDictionary */ $passwordDictionary = $this->project->getPolicy(ProjectPolicyId::PASSWORDDICTIONARY()); - /** @var \Appwrite\Models\PolicyPasswordPersonalData $passwordPersonalData */ $passwordPersonalData = $this->project->getPolicy(ProjectPolicyId::PASSWORDPERSONALDATA()); - /** @var \Appwrite\Models\PolicySessionAlert $sessionAlert */ $sessionAlert = $this->project->getPolicy(ProjectPolicyId::SESSIONALERT()); - /** @var \Appwrite\Models\PolicySessionDuration $sessionDuration */ $sessionDuration = $this->project->getPolicy(ProjectPolicyId::SESSIONDURATION()); - /** @var \Appwrite\Models\PolicySessionInvalidation $sessionInvalidation */ $sessionInvalidation = $this->project->getPolicy(ProjectPolicyId::SESSIONINVALIDATION()); - /** @var \Appwrite\Models\PolicySessionLimit $sessionLimit */ $sessionLimit = $this->project->getPolicy(ProjectPolicyId::SESSIONLIMIT()); - /** @var \Appwrite\Models\PolicyUserLimit $userLimit */ $userLimit = $this->project->getPolicy(ProjectPolicyId::USERLIMIT()); - /** @var \Appwrite\Models\PolicyMembershipPrivacy $membershipPrivacy */ $membershipPrivacy = $this->project->getPolicy(ProjectPolicyId::MEMBERSHIPPRIVACY()); $policies = new Policies( @@ -707,10 +685,6 @@ private function exportPolicies(): void $this->callback([$policies]); } - /** - * Read auth-method flags from the source project document. - * No SDK Project.get() exists; using raw HTTP against /v1/project. - */ private function exportAuthMethods(): void { $project = $this->project->get(); @@ -1714,11 +1688,6 @@ protected function exportGroupSettings(int $batchSize, array $resources): void } } - /** - * Read service enable/disable flags from /v1/project. The response exposes - * them as `serviceStatusFor` keys; we collapse them into one Services - * resource carrying all 17 toggles. - */ private function exportServices(): void { $project = $this->project->get(); @@ -1754,9 +1723,6 @@ private function exportServices(): void $this->callback([$services]); } - /** - * Read project-level labels via the typed Project model. - */ private function exportLabels(): void { $project = $this->project->get(); @@ -1771,9 +1737,6 @@ private function exportLabels(): void $this->callback([$labels]); } - /** - * Read protocol flags from the typed Project model. - */ private function exportProtocols(): void { $project = $this->project->get(); From ec6c2cc5fb6073a0580206f4805b76de4908737d Mon Sep 17 00:00:00 2001 From: premtsd-code Date: Thu, 21 May 2026 09:59:53 +0100 Subject: [PATCH 16/17] chore: fix import ordering (Pint) --- src/Migration/Destinations/Appwrite.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 07f26dcb..7af35c2d 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -10,8 +10,8 @@ use Appwrite\Enums\Framework; use Appwrite\Enums\PasswordHash; use Appwrite\Enums\ProjectProtocolId; -use Appwrite\Enums\Runtime; use Appwrite\Enums\ProjectServiceId; +use Appwrite\Enums\Runtime; use Appwrite\Enums\SmtpEncryption; use Appwrite\InputFile; use Appwrite\Services\Functions; From 91140887b9ff8efcefe326857115bd37c90db1cf Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 21 May 2026 13:22:42 +0100 Subject: [PATCH 17/17] Normalize error codes in exportGroupSettings handlers --- src/Migration/Sources/Appwrite.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index d54f935e..1b9c601e 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -1625,7 +1625,7 @@ protected function exportGroupSettings(int $batchSize, array $resources): void Resource::TYPE_PROJECT_VARIABLE, Transfer::GROUP_SETTINGS, message: $e->getMessage(), - code: $e->getCode(), + code: (int) $e->getCode() ?: Exception::CODE_INTERNAL, previous: $e )); } @@ -1639,7 +1639,7 @@ protected function exportGroupSettings(int $batchSize, array $resources): void Resource::TYPE_WEBHOOK, Transfer::GROUP_SETTINGS, message: $e->getMessage(), - code: $e->getCode(), + code: (int) $e->getCode() ?: Exception::CODE_INTERNAL, previous: $e )); } @@ -1654,7 +1654,7 @@ protected function exportGroupSettings(int $batchSize, array $resources): void Resource::TYPE_PROTOCOLS, Transfer::GROUP_SETTINGS, message: $e->getMessage(), - code: $e->getCode(), + code: (int) $e->getCode() ?: Exception::CODE_INTERNAL, previous: $e )); } @@ -1668,7 +1668,7 @@ protected function exportGroupSettings(int $batchSize, array $resources): void Resource::TYPE_LABELS, Transfer::GROUP_SETTINGS, message: $e->getMessage(), - code: $e->getCode(), + code: (int) $e->getCode() ?: Exception::CODE_INTERNAL, previous: $e )); } @@ -1682,7 +1682,7 @@ protected function exportGroupSettings(int $batchSize, array $resources): void Resource::TYPE_SERVICES, Transfer::GROUP_SETTINGS, message: $e->getMessage(), - code: $e->getCode(), + code: (int) $e->getCode() ?: Exception::CODE_INTERNAL, previous: $e )); }