Merge branch '1.9.x' into feat-out-of-order-chunk-uploads

This commit is contained in:
Torsten Dittmann
2026-04-29 15:03:33 +04:00
committed by GitHub
83 changed files with 4404 additions and 1546 deletions
+93 -57
View File
@@ -7,6 +7,7 @@ concurrency:
env:
COMPOSE_FILE: docker-compose.yml
IMAGE: appwrite-dev
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}/appwrite-dev
K6_VERSION: '0.53.0'
on:
@@ -19,6 +20,10 @@ on:
type: string
default: ''
permissions:
contents: read
packages: write
jobs:
dependencies:
name: Checks / Dependencies
@@ -258,32 +263,30 @@ jobs:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build Appwrite
- name: Build and push Appwrite
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: ${{ env.IMAGE }}
load: true
push: true
tags: ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=docker,dest=/tmp/${{ env.IMAGE }}.tar
target: development
build-args: |
DEBUG=false
TESTING=true
VERSION=dev
- name: Upload Docker Image
uses: actions/upload-artifact@v7
with:
name: ${{ env.IMAGE }}
path: /tmp/${{ env.IMAGE }}.tar
retention-days: 1
unit:
name: Tests / Unit
runs-on: ubuntu-latest
@@ -291,26 +294,32 @@ jobs:
permissions:
contents: read
pull-requests: write
packages: read
steps:
- name: checkout
uses: actions/checkout@v6
- name: Download Docker Image
uses: actions/download-artifact@v7
with:
name: ${{ env.IMAGE }}
path: /tmp
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull Docker Image
run: |
docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
- name: Load and Start Appwrite
timeout-minutes: 5
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
@@ -338,26 +347,32 @@ jobs:
permissions:
contents: read
pull-requests: write
packages: read
steps:
- name: checkout
uses: actions/checkout@v6
- name: Download Docker Image
uses: actions/download-artifact@v7
with:
name: ${{ env.IMAGE }}
path: /tmp
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull Docker Image
run: |
docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
- name: Load and Start Appwrite
timeout-minutes: 5
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
@@ -396,6 +411,7 @@ jobs:
permissions:
contents: read
pull-requests: write
packages: read
strategy:
fail-fast: false
matrix:
@@ -450,16 +466,10 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Download Docker Image
uses: actions/download-artifact@v7
with:
name: ${{ env.IMAGE }}
path: /tmp
- name: Set environment
run: |
echo "_APP_OPTIONS_ROUTER_PROTECTION=enabled" >> $GITHUB_ENV
if [ "${{ matrix.database }}" = "MariaDB" ]; then
echo "COMPOSE_PROFILES=mariadb" >> $GITHUB_ENV
echo "_APP_DB_ADAPTER=mariadb" >> $GITHUB_ENV
@@ -483,6 +493,18 @@ jobs:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull Docker Image
run: |
docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
- name: Load and Start Appwrite
timeout-minutes: 5
env:
@@ -491,7 +513,6 @@ jobs:
_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
_APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
@@ -545,6 +566,7 @@ jobs:
permissions:
contents: read
pull-requests: write
packages: read
strategy:
fail-fast: false
matrix:
@@ -555,18 +577,24 @@ jobs:
with:
fetch-depth: 1
- name: Download Docker Image
uses: actions/download-artifact@v7
with:
name: ${{ env.IMAGE }}
path: /tmp
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull Docker Image
run: |
docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
- name: Load and Start Appwrite
timeout-minutes: 5
env:
@@ -575,7 +603,6 @@ jobs:
_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
_APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
@@ -606,6 +633,7 @@ jobs:
permissions:
contents: read
pull-requests: write
packages: read
strategy:
fail-fast: false
matrix:
@@ -614,18 +642,24 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Download Docker Image
uses: actions/download-artifact@v7
with:
name: ${{ env.IMAGE }}
path: /tmp
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull Docker Image
run: |
docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
- name: Load and Start Appwrite
timeout-minutes: 5
env:
@@ -633,7 +667,6 @@ jobs:
_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
_APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
@@ -675,28 +708,31 @@ jobs:
contents: read
issues: write
pull-requests: write
packages: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Download Docker Image
uses: actions/download-artifact@v7
with:
name: ${{ env.IMAGE }}
path: /tmp
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Load Appwrite image
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull Appwrite image
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
docker tag ${{ env.IMAGE }} ${{ env.IMAGE }}:after
docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}:after
- name: Setup k6
uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2
+31 -1
View File
@@ -5,6 +5,11 @@ on:
types:
- closed
permissions:
actions: write
contents: read
packages: write
jobs:
cleanup:
runs-on: ubuntu-latest
@@ -36,4 +41,29 @@ jobs:
done
done
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Cleanup GHCR image
continue-on-error: true
run: |
package_path="${GITHUB_REPOSITORY#*/}/appwrite-dev"
encoded_path="$(printf '%s' "$package_path" | jq -Rr @uri)"
gh api --paginate "/repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.pull_request.number }}/commits" --jq '.[].sha' | while read -r sha; do
version_ids=$(gh api --paginate -H "Accept: application/vnd.github+json" \
"/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions" \
--jq ".[] | select(.metadata.container.tags | index(\"${sha}\")) | .id")
if [ -z "$version_ids" ]; then
echo "No GHCR version found for SHA ${sha}"
continue
fi
echo "$version_ids" | while read -r version_id; do
gh api --method DELETE -H "Accept: application/vnd.github+json" \
"/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions/${version_id}"
echo "Deleted ${package_path}:${sha} (version ${version_id})"
done
done
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+6 -2
View File
@@ -24,9 +24,11 @@ jobs:
ignore-unfixed: 'false'
severity: 'CRITICAL,HIGH'
- name: Upload Docker Image Scan Results
uses: github/codeql-action/upload-sarif@v2
uses: github/codeql-action/upload-sarif@v4
if: always() && hashFiles('trivy-image-results.sarif') != ''
with:
sarif_file: 'trivy-image-results.sarif'
category: 'trivy-image'
scan-code:
name: Scan Code
@@ -42,6 +44,8 @@ jobs:
output: 'trivy-fs-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Code Scan Results
uses: github/codeql-action/upload-sarif@v2
uses: github/codeql-action/upload-sarif@v4
if: always() && hashFiles('trivy-fs-results.sarif') != ''
with:
sarif_file: 'trivy-fs-results.sarif'
category: 'trivy-source'
+7 -2
View File
@@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \
--no-plugins --no-scripts --prefer-dist \
`if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi`
FROM appwrite/base:1.0.1 AS base
FROM appwrite/base:1.2.1 AS base
LABEL maintainer="team@appwrite.io"
@@ -24,6 +24,10 @@ ENV _APP_VERSION=$VERSION \
_APP_HOME=https://appwrite.io
RUN \
if [ "$DEBUG" != "true" ]; then \
rm -f /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
rm -f /usr/local/lib/php/extensions/no-debug-non-zts-*/xdebug.so; \
fi && \
if [ "$DEBUG" == "true" ]; then \
apk add boost boost-dev; \
fi
@@ -100,7 +104,8 @@ RUN mkdir -p /etc/letsencrypt/live/ && chmod -Rf 755 /etc/letsencrypt/live/
FROM base AS production
RUN rm -rf /usr/src/code/app/config/specs && \
rm -f /usr/local/lib/php/extensions/no-debug-non-zts-20240924/xdebug.so && \
rm -f /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini && \
rm -f /usr/local/lib/php/extensions/no-debug-non-zts-*/xdebug.so && \
find /usr -name '*.a' -delete 2>/dev/null || true && \
find /usr -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true && \
find /usr -name '*.pyc' -delete 2>/dev/null || true
+1 -1
View File
@@ -384,7 +384,7 @@ return [
],
Exception::API_KEY_EXPIRED => [
'name' => Exception::API_KEY_EXPIRED,
'description' => 'The dynamic API key has expired. Please don\'t use dynamic API keys for more than duration of the execution.',
'description' => 'The ephemeral API key has expired. Please don\'t use ephemeral API keys for more than duration of the execution.',
'code' => 401,
],
+1 -1
View File
@@ -286,7 +286,7 @@ return [
'name' => 'Migrations',
'subtitle' => 'The Migrations service allows you to migrate third-party data to your Appwrite project.',
'description' => '/docs/services/migrations.md',
'controller' => 'api/migrations.php',
'controller' => '', // Uses modules
'sdk' => true,
'docs' => true,
'docsUrl' => 'https://appwrite.io/docs/migrations',
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -27,6 +27,7 @@ use Appwrite\Utopia\Request\Filters\V20 as RequestV20;
use Appwrite\Utopia\Request\Filters\V21 as RequestV21;
use Appwrite\Utopia\Request\Filters\V22 as RequestV22;
use Appwrite\Utopia\Request\Filters\V23 as RequestV23;
use Appwrite\Utopia\Request\Filters\V24 as RequestV24;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Filters\V16 as ResponseV16;
use Appwrite\Utopia\Response\Filters\V17 as ResponseV17;
@@ -36,6 +37,7 @@ use Appwrite\Utopia\Response\Filters\V20 as ResponseV20;
use Appwrite\Utopia\Response\Filters\V21 as ResponseV21;
use Appwrite\Utopia\Response\Filters\V22 as ResponseV22;
use Appwrite\Utopia\Response\Filters\V23 as ResponseV23;
use Appwrite\Utopia\Response\Filters\V24 as ResponseV24;
use Appwrite\Utopia\View;
use Executor\Executor;
use MaxMind\Db\Reader;
@@ -397,7 +399,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
'projectId' => $project->getId(),
'scopes' => $resource->getAttribute('scopes', [])
]);
$headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $jwtKey;
$headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $jwtKey;
$headers['x-appwrite-trigger'] = 'http';
$headers['x-appwrite-user-jwt'] = '';
@@ -899,6 +901,9 @@ Http::init()
if (version_compare($requestFormat, '1.9.2', '<')) {
$request->addFilter(new RequestV23());
}
if (version_compare($requestFormat, '1.9.3', '<')) {
$request->addFilter(new RequestV24());
}
}
$localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', ''));
@@ -923,6 +928,9 @@ Http::init()
*/
$responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', ''));
if ($responseFormat) {
if (version_compare($responseFormat, '1.9.3', '<')) {
$response->addFilter(new ResponseV24());
}
if (version_compare($responseFormat, '1.9.2', '<')) {
$response->addFilter(new ResponseV23());
}
+2 -1
View File
@@ -183,7 +183,8 @@ Http::init()
// Handle special app role case
if ($apiKey->getRole() === User::ROLE_APPS) {
// Disable authorization checks for project API keys
if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_DYNAMIC) && $apiKey->getProjectId() === $project->getId()) {
// Dynamic supported for backwards compatibility
if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_EPHEMERAL || $apiKey->getType() === 'dynamic') && $apiKey->getProjectId() === $project->getId()) {
$authorization->setDefaultStatus(false);
}
+3 -3
View File
@@ -44,8 +44,8 @@ const APP_PROJECT_ACCESS = 24 * 60 * 60; // 24 hours
const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours
const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours
const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours
const APP_CACHE_BUSTER = 4323;
const APP_VERSION_STABLE = '1.9.2';
const APP_CACHE_BUSTER = 4324;
const APP_VERSION_STABLE = '1.9.3';
const APP_DATABASE_ATTRIBUTE_EMAIL = 'email';
const APP_DATABASE_ATTRIBUTE_ENUM = 'enum';
const APP_DATABASE_ATTRIBUTE_IP = 'ip';
@@ -256,7 +256,7 @@ const MESSAGE_TYPE_SMS = 'sms';
const MESSAGE_TYPE_PUSH = 'push';
// API key types
const API_KEY_STANDARD = 'standard';
const API_KEY_DYNAMIC = 'dynamic';
const API_KEY_EPHEMERAL = 'ephemeral';
const API_KEY_ORGANIZATION = 'organization';
const API_KEY_ACCOUNT = 'account';
// Usage metrics
+6
View File
@@ -56,6 +56,8 @@ use Appwrite\Utopia\Response\Model\ColumnString;
use Appwrite\Utopia\Response\Model\ColumnText;
use Appwrite\Utopia\Response\Model\ColumnURL;
use Appwrite\Utopia\Response\Model\ColumnVarchar;
use Appwrite\Utopia\Response\Model\ConsoleKeyScope;
use Appwrite\Utopia\Response\Model\ConsoleKeyScopeList;
use Appwrite\Utopia\Response\Model\ConsoleOAuth2Provider;
use Appwrite\Utopia\Response\Model\ConsoleOAuth2ProviderList;
use Appwrite\Utopia\Response\Model\ConsoleOAuth2ProviderParameter;
@@ -71,6 +73,7 @@ use Appwrite\Utopia\Response\Model\DetectionVariable;
use Appwrite\Utopia\Response\Model\DevKey;
use Appwrite\Utopia\Response\Model\Document as ModelDocument;
use Appwrite\Utopia\Response\Model\Embedding;
use Appwrite\Utopia\Response\Model\EphemeralKey;
use Appwrite\Utopia\Response\Model\Error;
use Appwrite\Utopia\Response\Model\ErrorDev;
use Appwrite\Utopia\Response\Model\Execution;
@@ -392,6 +395,7 @@ Response::setModel(new Execution());
Response::setModel(new Project());
Response::setModel(new Webhook());
Response::setModel(new Key());
Response::setModel(new EphemeralKey());
Response::setModel(new DevKey());
Response::setModel(new MockNumber());
Response::setModel(new OAuth2GitHub());
@@ -486,6 +490,8 @@ Response::setModel(new ConsoleVariables());
Response::setModel(new ConsoleOAuth2ProviderParameter());
Response::setModel(new ConsoleOAuth2Provider());
Response::setModel(new ConsoleOAuth2ProviderList());
Response::setModel(new ConsoleKeyScope());
Response::setModel(new ConsoleKeyScopeList());
Response::setModel(new MFAChallenge());
Response::setModel(new MFARecoveryCodes());
Response::setModel(new MFAType());
+5 -3
View File
@@ -327,9 +327,11 @@ return function (Container $container): void {
}
}
$impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', '');
$impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', '');
$impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', '');
// Query params mirror the header fallback pattern used by ?project= and ?devKey=,
// allowing Console to embed impersonation in direct file/image URLs where headers cannot be set.
$impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', ''));
$impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', (string)$request->getParam('impersonateEmail', ''));
$impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', (string)$request->getParam('impersonatePhone', ''));
if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) {
$userDb = ($mode === APP_MODE_ADMIN || $project->getId() === 'console') ? $dbForPlatform : $dbForProject;
+6 -4
View File
@@ -571,10 +571,12 @@ return function (Container $container): void {
}
}
// Impersonation: if current user has impersonator capability and headers are set, act as another user
$impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', '');
$impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', '');
$impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', '');
// Impersonation: if current user has impersonator capability and headers/params are set, act as another user
// Query params mirror the header fallback pattern used by ?project= and ?devKey=,
// allowing Console to embed impersonation in direct file/image URLs where headers cannot be set.
$impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', ''));
$impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', (string)$request->getParam('impersonateEmail', ''));
$impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', (string)$request->getParam('impersonatePhone', ''));
if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) {
$userDb = (APP_MODE_ADMIN === $mode || $project->getId() === 'console') ? $dbForPlatform : $dbForProject;
$targetUser = null;
+24 -4
View File
@@ -45,7 +45,10 @@ use Utopia\WebSocket\Adapter;
use Utopia\WebSocket\Server;
require_once __DIR__ . '/init.php';
require_once __DIR__ . '/init/span.php';
if (System::getEnv('_APP_EDITION', 'self-hosted') === 'self-hosted') {
require_once __DIR__ . '/init/span.php';
}
/** @var Registry $register */
$register = $GLOBALS['register'] ?? throw new \RuntimeException('Registry not initialized');
@@ -566,6 +569,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
$roles = $user->getRoles($database->getAuthorization());
$authorization = $realtime->connections[$connection]['authorization'] ?? null;
$previousUserId = $realtime->connections[$connection]['userId'] ?? '';
$meta = $realtime->getSubscriptionMetadata($connection);
@@ -573,13 +577,19 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
foreach ($meta as $subscriptionId => $subscription) {
$queries = Query::parseQueries($subscription['queries'] ?? []);
$channels = Realtime::rebindAccountChannels(
$subscription['channels'] ?? [],
$previousUserId,
$userId
);
$realtime->subscribe(
$projectId,
$connection,
$subscriptionId,
$roles,
$subscription['channels'] ?? [],
$queries
$channels,
$queries,
$userId
);
}
@@ -1068,6 +1078,11 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
$authorization = $realtime->connections[$connection]['authorization'] ?? null;
$projectId = $realtime->connections[$connection]['projectId'] ?? null;
// Capture the pre-auth userId so we can rebind any account channels
// that were stored under it (e.g. guest who subscribed to `account`
// and now authenticates). unsubscribe() below clears the connection
// entry, so we must read it first.
$previousUserId = $realtime->connections[$connection]['userId'] ?? '';
$subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection));
$meta = $realtime->getSubscriptionMetadata($connection);
@@ -1077,13 +1092,18 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
if (!empty($projectId)) {
foreach ($meta as $subscriptionId => $subscription) {
$queries = Query::parseQueries($subscription['queries'] ?? []);
$channels = Realtime::rebindAccountChannels(
$subscription['channels'] ?? [],
$previousUserId,
$user->getId()
);
$realtime->subscribe(
$projectId,
$connection,
$subscriptionId,
$roles,
$subscription['channels'] ?? [],
$channels,
$queries,
$user->getId()
);
@@ -1 +0,0 @@
List all OAuth2 providers supported by the Appwrite server, along with the parameters required to configure each provider. The response excludes mock providers but includes sandbox providers.
-1
View File
@@ -1 +0,0 @@
Get all Environment Variables that are relevant for the console.
+5 -3
View File
@@ -105,7 +105,7 @@ class Key
/**
* Decode the given secret key into a Key object, containing the project ID, type, role, scopes, and name.
* Can be a stored API key or a dynamic key (JWT).
* Can be a stored API key or an ephemeral key (JWT).
*
* @throws Exception
*/
@@ -138,7 +138,9 @@ class Key
);
switch ($type) {
case API_KEY_DYNAMIC:
// Dynamic supported for backwards compatibility
case API_KEY_EPHEMERAL:
case 'dynamic':
$jwtObj = new JWT(
key: System::getEnv('_APP_OPENSSL_KEY_V1'),
algo: 'HS256',
@@ -153,7 +155,7 @@ class Key
$expired = true;
}
$name = $payload['name'] ?? 'Dynamic Key';
$name = $payload['name'] ?? 'Ephemeral Key';
$projectId = $payload['projectId'] ?? '';
$disabledMetrics = $payload['disabledMetrics'] ?? [];
$hostnameOverride = $payload['hostnameOverride'] ?? false;
-2
View File
@@ -206,8 +206,6 @@ abstract class OAuth2
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
\curl_close($ch);
if ($code >= 400) {
throw new Exception($response, $code);
}
+155 -17
View File
@@ -14,6 +14,28 @@ use Utopia\Database\Query;
class Realtime extends MessagingAdapter
{
public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert', 'delete'];
// Resources whose channels receive an action-suffixed sibling at publish time.
// The suffix loop in fromPayload() treats any channel whose last OR second-to-last
// segment matches an entry here as a candidate for `.{action}` suffixing.
//
// `functions` is intentionally a parent-only entry: fromPayload publishes
// `functions.{functionId}` (suffixed to `functions.{functionId}.{action}`) but
// never emits a bare `functions` channel — so subscribing to bare
// `functions.{action}` is a silent no-op. Per-function filters
// (`functions.{functionId}.{action}`) are the supported form.
private const RESOURCE_LEAF_NAMES = [
'documents',
'rows',
'files',
'executions',
'functions',
'account',
'teams',
'memberships',
];
/**
* Connection Tree
*
@@ -45,8 +67,6 @@ class Realtime extends MessagingAdapter
/**
* Get the PubSubPool instance, initializing it lazily if needed.
* This allows unit tests to work without requiring the global $register.
*
* @return PubSubPool
*/
private function getPubSubPool(): PubSubPool
{
@@ -54,6 +74,7 @@ class Realtime extends MessagingAdapter
global $register;
$this->pubSubPool = new PubSubPool($register->get('pools')->get('pubsub'));
}
return $this->pubSubPool;
}
@@ -147,7 +168,7 @@ class Realtime extends MessagingAdapter
$roles = $this->connections[$connection]['roles'] ?? [];
$channels = $this->connections[$connection]['channels'] ?? [];
if (!$projectId || empty($roles) || empty($channels)) {
if (! $projectId || empty($roles) || empty($channels)) {
return [];
}
@@ -168,7 +189,7 @@ class Realtime extends MessagingAdapter
if (!isset($subscriptions[$subscriptionId])) {
$subscriptions[$subscriptionId] = [
'channels' => [],
'queries' => $data['strings'] ?? []
'queries' => $data['strings'] ?? [],
];
}
if (!\in_array($channel, $subscriptions[$subscriptionId]['channels'])) {
@@ -306,7 +327,7 @@ class Realtime extends MessagingAdapter
*/
public function hasSubscriber(string $projectId, string $role, string $channel = ''): bool
{
//TODO: look into moving it to an abstract class in the parent class
// TODO: look into moving it to an abstract class in the parent class
if (empty($channel)) {
return array_key_exists($projectId, $this->subscriptions)
&& array_key_exists($role, $this->subscriptions[$projectId]);
@@ -327,6 +348,7 @@ class Realtime extends MessagingAdapter
* @param array $roles
* @param array $options
* @return void
*
* @throws \Exception
*/
public function send(string $projectId, array $payload, array $events, array $channels, array $roles, array $options = []): void
@@ -347,8 +369,8 @@ class Realtime extends MessagingAdapter
'events' => $events,
'channels' => $channels,
'timestamp' => DateTime::formatTz(DateTime::now()),
'payload' => $payload
]
'payload' => $payload,
],
]));
}
@@ -361,7 +383,6 @@ class Realtime extends MessagingAdapter
* - 1.5 ms | 1,000 Connections / 10,000 Subscriptions
* - 15 ms | 10,000 Connections / 100,000 Subscriptions
*
* @param array $event
* @return array<int|string, array> Map of connection IDs to matched query groups
*/
public function getSubscribers(array $event): array
@@ -377,7 +398,7 @@ class Realtime extends MessagingAdapter
foreach ($this->subscriptions[$event['project']] as $role => $subscriptionsByChannel) {
foreach ($event['data']['channels'] as $channel) {
if (
!\array_key_exists($channel, $subscriptionsByChannel)
! \array_key_exists($channel, $subscriptionsByChannel)
|| (!\in_array($role, $event['roles']) && !\in_array(Role::any()->toString(), $event['roles']))
) {
continue;
@@ -410,6 +431,12 @@ class Realtime extends MessagingAdapter
/**
* Converts the channels from the Query Params into an array.
* Also renames the account channel to account.USER_ID, rewrites action-suffixed
* account variants (`account.create`, `account.update`, `account.upsert`,
* `account.delete`) to `account.USER_ID.{action}` so they match the channels
* fromPayload() publishes for top-level user events, and removes all other
* illegal account channel variations (e.g. another user's `account.{otherId}`).
*
* Also renames the account channel to account.USER_ID and removes all illegal account channel variations.
* @param array $channels
* @param string $userId
@@ -421,27 +448,94 @@ class Realtime extends MessagingAdapter
foreach ($channels as $key => $value) {
switch (true) {
case str_starts_with($key, 'account.'):
unset($channels[$key]);
break;
case $key === 'account':
if (!empty($userId)) {
$channels['account.' . $userId] = $value;
$channels['account.'.$userId] = $value;
}
break;
case \in_array(\substr($key, \strlen('account.')), self::SUPPORTED_ACTIONS, true) && str_starts_with($key, 'account.'):
// Authenticated: rewrite `account.{action}` → `account.{userId}.{action}`
// so the subscriber only receives their own account events.
// Guest: keep the literal `account.{action}` so the action filter
// applies to the broadcast `account.{action}` channel that fromPayload
// emits for top-level user events. On in-band auth, rebindAccountChannels
// rewrites the literal to the user-scoped form.
if (!empty($userId)) {
unset($channels[$key]);
$action = \substr($key, \strlen('account.'));
$channels['account.'.$userId.'.'.$action] = $value;
}
break;
case str_starts_with($key, 'account.'):
unset($channels[$key]);
break;
}
}
return $channels;
}
/**
* Rewrites stored account channels to match a new userId. Used when in-band
* authentication changes the connection's user identity:
*
* - guest authenticated: rewrites the literal `account.{action}` form
* that convertChannels preserves for guests into `account.{userId}.{action}`.
* - reauth as a different user: rewrites `account.{oldUserId}` and
* `account.{oldUserId}.{action}` to the new userId.
*
* Returns channels unchanged when there's nothing to do same user, or an
* empty target (defensive: avoids producing malformed `account.` strings if
* a caller ever passes `$newUserId = ''`, e.g. an in-band logout flow).
*/
public static function rebindAccountChannels(array $channels, string $oldUserId, string $newUserId): array
{
if ($newUserId === '' || $oldUserId === $newUserId) {
return $channels;
}
return \array_map(function (string $channel) use ($oldUserId, $newUserId) {
if (!\str_starts_with($channel, 'account.')) {
return $channel;
}
// Guest origin: literal `account.{action}` (preserved by convertChannels
// for unauthenticated connections) becomes `account.{newUserId}.{action}`.
if ($oldUserId === '') {
$suffix = \substr($channel, \strlen('account.'));
if (\in_array($suffix, self::SUPPORTED_ACTIONS, true)) {
return 'account.'.$newUserId.'.'.$suffix;
}
return $channel;
}
// Authenticated → different user.
if ($channel === 'account.'.$oldUserId) {
return 'account.'.$newUserId;
}
$oldPrefix = 'account.'.$oldUserId.'.';
if (\str_starts_with($channel, $oldPrefix)) {
$action = \substr($channel, \strlen($oldPrefix));
if (\in_array($action, self::SUPPORTED_ACTIONS, true)) {
return 'account.'.$newUserId.'.'.$action;
}
}
return $channel;
}, $channels);
}
/**
* Constructs subscriptions from query parameters.
*
* @param array $channelNames
* @param callable $getQueryParam
* @return array [index => ['channels' => string[], 'queries' => Query[]]]
*
* @throws QueryException
*/
public static function constructSubscriptions(array $channelNames, callable $getQueryParam): array
@@ -482,10 +576,11 @@ class Realtime extends MessagingAdapter
if (empty($subscriptions[0]['queries'])) {
$subscriptions[0]['queries'] = [Query::select(['*'])];
}
continue;
}
if (!\is_array($params)) {
if (! \is_array($params)) {
$params = [$params];
}
@@ -512,6 +607,7 @@ class Realtime extends MessagingAdapter
* Converts the queries from the Query Params into an array.
* @param array|string $queries
* @return array
*
* @throws QueryException
*/
public static function convertQueries(mixed $queries): array
@@ -524,7 +620,7 @@ class Realtime extends MessagingAdapter
$query = array_pop($stack);
$method = $query->getMethod();
if (!in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) {
if (! in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) {
throw new QueryException(
"Query method '{$method}' is not supported in Realtime queries. Allowed: {$allowed}"
);
@@ -680,11 +776,53 @@ class Realtime extends MessagingAdapter
break;
}
// Action is the last segment for plain CRUD events (e.g. `documents.X.create`),
// and the second-to-last segment for attribute-trailing events
// (e.g. `users.U.update.email`, `teams.T.update.prefs`,
// `teams.T.memberships.M.update.status`). Without the second-to-last fallback
$count = \count($parts);
$action = null;
if (\in_array($parts[$count - 1], self::SUPPORTED_ACTIONS, true)) {
$action = $parts[$count - 1];
} elseif ($count >= 2 && \in_array($parts[$count - 2], self::SUPPORTED_ACTIONS, true)) {
$action = $parts[$count - 2];
}
// The `users` branch emits only user-level account channels
// (`account`, `account.{userId}`) regardless of event depth, so nested events
// like `users.U.sessions.S.create` or `users.U.challenges.C.create` would
// otherwise be suffixed as `account.create` — making a subscription to
// `account.create` receive unrelated session/challenge/recovery/verification
// events. Restrict suffixing to top-level user events where the action sits
// at parts[2] (`users.U.create`, `users.U.update.email`, etc.).
if (
$action !== null
&& $parts[0] === 'users'
&& ($parts[2] ?? null) !== $action
) {
$action = null;
}
if ($action !== null && !empty($channels)) {
$augmented = $channels;
foreach ($channels as $channel) {
$segments = \explode('.', $channel);
$segCount = \count($segments);
$leafIsResource = \in_array($segments[$segCount - 1], self::RESOURCE_LEAF_NAMES, true);
$parentIsResource = $segCount >= 2 && \in_array($segments[$segCount - 2], self::RESOURCE_LEAF_NAMES, true);
if ($leafIsResource || $parentIsResource) {
$augmented[] = $channel. '.' .$action;
}
}
$channels = \array_values(\array_unique($augmented));
}
return [
'channels' => $channels,
'roles' => $roles,
'permissionsChanged' => $permissionsChanged,
'projectId' => $projectId
'projectId' => $projectId,
];
}
+1
View File
@@ -95,6 +95,7 @@ abstract class Migration
'1.9.0' => 'V24',
'1.9.1' => 'V24',
'1.9.2' => 'V24',
'1.9.3' => 'V24',
];
/**
+2
View File
@@ -9,6 +9,7 @@ use Appwrite\Platform\Modules\Core;
use Appwrite\Platform\Modules\Databases;
use Appwrite\Platform\Modules\Functions;
use Appwrite\Platform\Modules\Health;
use Appwrite\Platform\Modules\Migrations;
use Appwrite\Platform\Modules\Project;
use Appwrite\Platform\Modules\Projects;
use Appwrite\Platform\Modules\Proxy;
@@ -39,6 +40,7 @@ class Appwrite extends Platform
$this->addModule(new Storage\Module());
$this->addModule(new VCS\Module());
$this->addModule(new Webhooks\Module());
$this->addModule(new Migrations\Module());
$this->addModule(new Project\Module());
}
}
@@ -62,7 +62,6 @@ class Get extends Action
curl_setopt_array($ch, $options);
curl_exec($ch);
$errno = curl_errno($ch);
curl_close($ch);
return $errno === 0;
}
@@ -85,8 +85,6 @@ class Create extends Action
curl_exec($ch);
curl_close($ch);
$response->chunk('', true);
}
}
@@ -34,7 +34,7 @@ class XList extends Action
namespace: 'console',
group: 'console',
name: 'listOAuth2Providers',
description: '/docs/references/console/list-oauth2-providers.md',
description: 'List all OAuth2 providers supported by the Appwrite server, along with the parameters required to configure each provider. The response excludes mock providers but includes sandbox providers.',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
@@ -0,0 +1,67 @@
<?php
namespace Appwrite\Platform\Modules\Console\Http\Scopes\Key;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class XList extends Action
{
use HTTP;
public static function getName(): string
{
return 'listKeyScopes';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/console/scopes/key')
->desc('List key scopes')
->groups(['api'])
->label('scope', 'public')
->label('sdk', new Method(
namespace: 'console',
group: 'console',
name: 'listKeyScopes',
description: 'List all scopes available for project API keys, along with a description for each scope.',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_CONSOLE_KEY_SCOPE_LIST,
)
],
contentType: ContentType::JSON
))
->inject('response')
->callback($this->action(...));
}
public function action(Response $response): void
{
$scopesConfig = Config::getParam('projectScopes', []);
$scopes = [];
foreach ($scopesConfig as $scopeId => $scope) {
$scopes[] = new Document([
'$id' => $scopeId,
'description' => $scope['description'] ?? '',
]);
}
$response->dynamic(new Document([
'total' => \count($scopes),
'scopes' => $scopes,
]), Response::MODEL_CONSOLE_KEY_SCOPE_LIST);
}
}
@@ -36,7 +36,7 @@ class Get extends Action
namespace: 'console',
group: 'console',
name: 'variables',
description: '/docs/references/console/variables.md',
description: 'Get all Environment Variables that are relevant for the console.',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
@@ -15,6 +15,7 @@ use Appwrite\Platform\Modules\Console\Http\Redirects\Recover\Get as RedirectReco
use Appwrite\Platform\Modules\Console\Http\Redirects\Register\Get as RedirectRegister;
use Appwrite\Platform\Modules\Console\Http\Redirects\Root\Get as RedirectRoot;
use Appwrite\Platform\Modules\Console\Http\Resources\Get as GetResourceAvailability;
use Appwrite\Platform\Modules\Console\Http\Scopes\Key\XList as ListKeyScopes;
use Appwrite\Platform\Modules\Console\Http\Variables\Get as GetVariables;
use Utopia\Platform\Service;
@@ -30,6 +31,7 @@ class Http extends Service
$this->addAction(GetVariables::getName(), new GetVariables());
$this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers());
$this->addAction(ListKeyScopes::getName(), new ListKeyScopes());
$this->addAction(CreateAssistantQuery::getName(), new CreateAssistantQuery());
$this->addAction(GetResourceAvailability::getName(), new GetResourceAvailability());
@@ -228,7 +228,7 @@ class Create extends Base
$executionId = ID::unique();
$headers['x-appwrite-execution-id'] = $executionId;
$headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey;
$headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $apiKey;
$headers['x-appwrite-trigger'] = 'http';
$headers['x-appwrite-user-id'] = $user->getId();
$headers['x-appwrite-user-jwt'] = $jwt;
@@ -624,7 +624,7 @@ class Builds extends Action
$vars = [
...$vars,
'APPWRITE_FUNCTION_API_ENDPOINT' => $endpoint,
'APPWRITE_FUNCTION_API_KEY' => API_KEY_DYNAMIC . '_' . $apiKey,
'APPWRITE_FUNCTION_API_KEY' => API_KEY_EPHEMERAL . '_' . $apiKey,
'APPWRITE_FUNCTION_ID' => $resource->getId(),
'APPWRITE_FUNCTION_NAME' => $resource->getAttribute('name'),
'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(),
@@ -639,7 +639,7 @@ class Builds extends Action
$vars = [
...$vars,
'APPWRITE_SITE_API_ENDPOINT' => $endpoint,
'APPWRITE_SITE_API_KEY' => API_KEY_DYNAMIC . '_' . $apiKey,
'APPWRITE_SITE_API_KEY' => API_KEY_EPHEMERAL . '_' . $apiKey,
'APPWRITE_SITE_ID' => $resource->getId(),
'APPWRITE_SITE_NAME' => $resource->getAttribute('name'),
'APPWRITE_SITE_DEPLOYMENT' => $deployment->getId(),
@@ -168,7 +168,7 @@ class Screenshots extends Action
$config = $configs[$key];
$config['headers'] = \array_merge($config['headers'], [
'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey
'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey
]);
$config['sleep'] = 3000;
@@ -0,0 +1,110 @@
<?php
namespace Appwrite\Platform\Modules\Migrations\Http\Migrations\Appwrite;
use Appwrite\Event\Event;
use Appwrite\Event\Message\Migration as MigrationMessage;
use Appwrite\Event\Publisher\Migration as MigrationPublisher;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Validator\UID;
use Utopia\Migration\Sources\Appwrite as AppwriteSource;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Text;
use Utopia\Validator\URL;
use Utopia\Validator\WhiteList;
class Create extends Action
{
use HTTP;
public static function getName(): string
{
return 'createAppwriteMigration';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/migrations/appwrite')
->desc('Create Appwrite migration')
->groups(['api', 'migrations'])
->label('scope', 'migrations.write')
->label('event', 'migrations.[migrationId].create')
->label('audits.event', 'migration.create')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'createAppwriteMigration',
description: '/docs/references/migrations/migration-appwrite.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_ACCEPTED,
model: Response::MODEL_MIGRATION,
)
]
))
->param('resources', [], new ArrayList(new WhiteList(AppwriteSource::getSupportedResources())), 'List of resources to migrate')
->param('endpoint', '', new URL(), 'Source Appwrite endpoint')
->param('projectId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Source Project ID', false, ['dbForProject'])
->param('apiKey', '', new Text(512), 'Source API Key')
->inject('response')
->inject('dbForProject')
->inject('project')
->inject('platform')
->inject('queueForEvents')
->inject('publisherForMigrations')
->callback($this->action(...));
}
public function action(
array $resources,
string $endpoint,
string $projectId,
string $apiKey,
Response $response,
Database $dbForProject,
Document $project,
array $platform,
Event $queueForEvents,
MigrationPublisher $publisherForMigrations
): void {
$migration = $dbForProject->createDocument('migrations', new Document([
'$id' => ID::unique(),
'status' => 'pending',
'stage' => 'init',
'source' => AppwriteSource::getName(),
'destination' => AppwriteSource::getName(),
'credentials' => [
'endpoint' => $endpoint,
'projectId' => $projectId,
'apiKey' => $apiKey,
],
'resources' => $resources,
'statusCounters' => '{}',
'resourceData' => '{}',
'errors' => [],
]));
$queueForEvents->setParam('migrationId', $migration->getId());
$publisherForMigrations->enqueue(new MigrationMessage(
project: $project,
migration: $migration,
platform: $platform,
));
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
->dynamic($migration, Response::MODEL_MIGRATION);
}
}
@@ -0,0 +1,80 @@
<?php
namespace Appwrite\Platform\Modules\Migrations\Http\Migrations\Appwrite\Report;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Document;
use Utopia\Migration\Sources\Appwrite as AppwriteSource;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Text;
use Utopia\Validator\URL;
use Utopia\Validator\WhiteList;
class Get extends Action
{
use HTTP;
public static function getName(): string
{
return 'getAppwriteReport';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/migrations/appwrite/report')
->desc('Get Appwrite migration report')
->groups(['api', 'migrations'])
->label('scope', 'migrations.write')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'getAppwriteReport',
description: '/docs/references/migrations/migration-appwrite-report.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_MIGRATION_REPORT,
)
]
))
->param('resources', [], new ArrayList(new WhiteList(AppwriteSource::getSupportedResources())), 'List of resources to migrate')
->param('endpoint', '', new URL(), "Source's Appwrite Endpoint")
->param('projectID', '', new Text(512), "Source's Project ID")
->param('key', '', new Text(512), "Source's API Key")
->inject('response')
->inject('getDatabasesDB')
->callback($this->action(...));
}
public function action(
array $resources,
string $endpoint,
string $projectID,
string $key,
Response $response,
callable $getDatabasesDB
): void {
try {
$appwrite = new AppwriteSource($projectID, $endpoint, $key, $getDatabasesDB);
$report = $appwrite->report($resources);
} catch (\Throwable $e) {
throw new Exception(
Exception::MIGRATION_PROVIDER_ERROR,
'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
);
}
$response
->setStatusCode(Response::STATUS_CODE_OK)
->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT);
}
}
@@ -0,0 +1,213 @@
<?php
namespace Appwrite\Platform\Modules\Migrations\Http\Migrations\CSV\Exports;
use Appwrite\Event\Event;
use Appwrite\Event\Message\Migration as MigrationMessage;
use Appwrite\Event\Publisher\Migration as MigrationPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\CompoundUID;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Queries\Documents;
use Utopia\Migration\Resource;
use Utopia\Migration\Sources\Appwrite as AppwriteSource;
use Utopia\Migration\Sources\CSV;
use Utopia\Migration\Transfer;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
class Create extends Action
{
use HTTP;
public static function getName(): string
{
return 'createCSVExport';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/migrations/csv/exports')
->desc('Export documents to CSV')
->groups(['api', 'migrations'])
->label('scope', 'migrations.write')
->label('event', 'migrations.[migrationId].create')
->label('audits.event', 'migration.create')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'createCSVExport',
description: '/docs/references/migrations/migration-csv-export.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_ACCEPTED,
model: Response::MODEL_MIGRATION,
)
]
))
->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.')
->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .csv extension.')
->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true)
->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true)
->param('delimiter', ',', new Text(1), 'The character that separates each column value. Default is comma.', true)
->param('enclosure', '"', new Text(1), 'The character that encloses each column value. Default is double quotes.', true)
->param('escape', '"', new Text(1), 'The escape character for the enclosure character. Default is double quotes.', true)
->param('header', true, new Boolean(), 'Whether to include the header row with column names. Default is true.', true)
->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true)
->inject('user')
->inject('response')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('authorization')
->inject('project')
->inject('platform')
->inject('queueForEvents')
->inject('publisherForMigrations')
->callback($this->action(...));
}
public function action(
string $resourceId,
string $filename,
array $columns,
array $queries,
string $delimiter,
string $enclosure,
string $escape,
bool $header,
bool $notify,
Document $user,
Response $response,
Database $dbForProject,
Database $dbForPlatform,
Authorization $authorization,
Document $project,
array $platform,
Event $queueForEvents,
MigrationPublisher $publisherForMigrations
): void {
try {
$parsedQueries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
$bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default'));
if ($bucket->isEmpty()) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
}
[$databaseId, $collectionId] = \explode(':', $resourceId, 2);
if (empty($databaseId)) {
throw new Exception(Exception::DATABASE_NOT_FOUND);
}
if (empty($collectionId)) {
throw new Exception(Exception::COLLECTION_NOT_FOUND);
}
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty()) {
throw new Exception(Exception::DATABASE_NOT_FOUND);
}
$collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId));
if ($collection->isEmpty()) {
throw new Exception(Exception::COLLECTION_NOT_FOUND);
}
$databaseType = $database->getAttribute('type');
if (!\in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) {
throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv');
}
// Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields
$isSchemaless = \in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]);
$validator = new Documents(
attributes: $collection->getAttribute('attributes', []),
indexes: $collection->getAttribute('indexes', []),
idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(),
supportForAttributes: !$isSchemaless,
);
if (!$validator->isValid($parsedQueries)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]);
$resourceType = self::resourceTypeForDatabaseType($databaseType);
$migration = $dbForProject->createDocument('migrations', new Document([
'$id' => ID::unique(),
'status' => 'pending',
'stage' => 'init',
'source' => AppwriteSource::getName(),
'destination' => CSV::getName(),
'resources' => $resources,
'resourceId' => $resourceId,
'resourceType' => $resourceType,
'statusCounters' => '{}',
'resourceData' => '{}',
'errors' => [],
'options' => [
'bucketId' => 'default', // Always use internal bucket
'filename' => $filename,
'columns' => $columns,
'queries' => $queries,
'delimiter' => $delimiter,
'enclosure' => $enclosure,
'escape' => $escape,
'header' => $header,
'notify' => $notify,
'userInternalId' => $user->getSequence(),
],
]));
$queueForEvents->setParam('migrationId', $migration->getId());
$publisherForMigrations->enqueue(new MigrationMessage(
project: $project,
migration: $migration,
platform: $platform,
));
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
->dynamic($migration, Response::MODEL_MIGRATION);
}
private static function transferGroupForDatabaseType(string $databaseType): string
{
return match ($databaseType) {
DATABASE_TYPE_LEGACY,
DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB,
DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB,
DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB,
default => throw new \LogicException('Unknown database type: ' . $databaseType),
};
}
private static function resourceTypeForDatabaseType(string $databaseType): string
{
return match ($databaseType) {
DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB,
DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB,
default => Resource::TYPE_DATABASE,
};
}
}
@@ -0,0 +1,220 @@
<?php
namespace Appwrite\Platform\Modules\Migrations\Http\Migrations\CSV\Imports;
use Appwrite\Event\Event;
use Appwrite\Event\Message\Migration as MigrationMessage;
use Appwrite\Event\Publisher\Migration as MigrationPublisher;
use Appwrite\Extend\Exception;
use Appwrite\OpenSSL\OpenSSL;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\CompoundUID;
use Appwrite\Utopia\Response;
use Utopia\Compression\Algorithms\GZIP;
use Utopia\Compression\Algorithms\Zstd;
use Utopia\Compression\Compression;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Migration\Resource;
use Utopia\Migration\Sources\Appwrite as AppwriteSource;
use Utopia\Migration\Sources\CSV;
use Utopia\Migration\Transfer;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Storage\Device;
use Utopia\System\System;
use Utopia\Validator\Boolean;
class Create extends Action
{
use HTTP;
public static function getName(): string
{
return 'createCSVImport';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/migrations/csv/imports')
->httpAlias('/v1/migrations/csv')
->desc('Import documents from a CSV')
->groups(['api', 'migrations'])
->label('scope', 'migrations.write')
->label('event', 'migrations.[migrationId].create')
->label('audits.event', 'migration.create')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'createCSVImport',
description: '/docs/references/migrations/migration-csv-import.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_ACCEPTED,
model: Response::MODEL_MIGRATION,
)
]
))
->param('bucketId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).', false, ['dbForProject'])
->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File ID.', false, ['dbForProject'])
->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.')
->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true)
->inject('response')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('authorization')
->inject('project')
->inject('platform')
->inject('deviceForFiles')
->inject('deviceForMigrations')
->inject('queueForEvents')
->inject('publisherForMigrations')
->callback($this->action(...));
}
public function action(
string $bucketId,
string $fileId,
string $resourceId,
bool $internalFile,
Response $response,
Database $dbForProject,
Database $dbForPlatform,
Authorization $authorization,
Document $project,
array $platform,
Device $deviceForFiles,
Device $deviceForMigrations,
Event $queueForEvents,
MigrationPublisher $publisherForMigrations
): void {
$bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) {
if ($internalFile) {
return $dbForPlatform->getDocument('buckets', 'default');
}
return $dbForProject->getDocument('buckets', $bucketId);
});
if ($bucket->isEmpty()) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
}
$file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId));
if ($file->isEmpty()) {
throw new Exception(Exception::STORAGE_FILE_NOT_FOUND);
}
$path = $file->getAttribute('path', '');
if (!$deviceForFiles->exists($path)) {
throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path);
}
// No encryption or compression on files above 20MB.
$hasEncryption = !empty($file->getAttribute('openSSLCipher'));
$compression = $file->getAttribute('algorithm', Compression::NONE);
$hasCompression = $compression !== Compression::NONE;
$migrationId = ID::unique();
$newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.csv');
if ($hasEncryption || $hasCompression) {
$source = $deviceForFiles->read($path);
if ($hasEncryption) {
$source = OpenSSL::decrypt(
$source,
$file->getAttribute('openSSLCipher'),
System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')),
0,
hex2bin($file->getAttribute('openSSLIV')),
hex2bin($file->getAttribute('openSSLTag'))
);
}
if ($hasCompression) {
switch ($compression) {
case Compression::ZSTD:
$source = (new Zstd())->decompress($source);
break;
case Compression::GZIP:
$source = (new GZIP())->decompress($source);
break;
}
}
// Manual write after decryption and/or decompression
if (!$deviceForMigrations->write($newPath, $source, 'text/csv')) {
throw new \Exception('Unable to copy file');
}
} elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) {
throw new \Exception('Unable to copy file');
}
[$databaseId] = \explode(':', $resourceId, 2);
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
$databaseType = $database->getAttribute('type');
if (!\in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) {
throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv');
}
$fileSize = $deviceForMigrations->getFileSize($newPath);
$resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]);
$resourceType = self::resourceTypeForDatabaseType($databaseType);
$migration = $dbForProject->createDocument('migrations', new Document([
'$id' => $migrationId,
'status' => 'pending',
'stage' => 'init',
'source' => CSV::getName(),
'destination' => AppwriteSource::getName(),
'resources' => $resources,
'resourceId' => $resourceId,
'resourceType' => $resourceType,
'statusCounters' => '{}',
'resourceData' => '{}',
'errors' => [],
'options' => [
'path' => $newPath,
'size' => $fileSize,
],
]));
$queueForEvents->setParam('migrationId', $migration->getId());
$publisherForMigrations->enqueue(new MigrationMessage(
project: $project,
migration: $migration,
));
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
->dynamic($migration, Response::MODEL_MIGRATION);
}
private static function transferGroupForDatabaseType(string $databaseType): string
{
return match ($databaseType) {
DATABASE_TYPE_LEGACY,
DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB,
DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB,
DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB,
default => throw new \LogicException('Unknown database type: ' . $databaseType),
};
}
private static function resourceTypeForDatabaseType(string $databaseType): string
{
return match ($databaseType) {
DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB,
DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB,
default => Resource::TYPE_DATABASE,
};
}
}
@@ -0,0 +1,74 @@
<?php
namespace Appwrite\Platform\Modules\Migrations\Http\Migrations;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class Delete extends Action
{
use HTTP;
public static function getName(): string
{
return 'deleteMigration';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
->setHttpPath('/v1/migrations/:migrationId')
->desc('Delete migration')
->groups(['api', 'migrations'])
->label('scope', 'migrations.write')
->label('event', 'migrations.[migrationId].delete')
->label('audits.event', 'migrationId.delete')
->label('audits.resource', 'migrations/{request.migrationId}')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'delete',
description: '/docs/references/migrations/delete-migration.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(string $migrationId, Response $response, Database $dbForProject, Event $queueForEvents): void
{
$migration = $dbForProject->getDocument('migrations', $migrationId);
if ($migration->isEmpty()) {
throw new Exception(Exception::MIGRATION_NOT_FOUND);
}
if (!$dbForProject->deleteDocument('migrations', $migration->getId())) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove migration from DB');
}
$queueForEvents->setParam('migrationId', $migration->getId());
$response->noContent();
}
}
@@ -0,0 +1,114 @@
<?php
namespace Appwrite\Platform\Modules\Migrations\Http\Migrations\Firebase;
use Appwrite\Event\Event;
use Appwrite\Event\Message\Migration as MigrationMessage;
use Appwrite\Event\Publisher\Migration as MigrationPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Migration\Sources\Appwrite as AppwriteSource;
use Utopia\Migration\Sources\Firebase;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
class Create extends Action
{
use HTTP;
public static function getName(): string
{
return 'createFirebaseMigration';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/migrations/firebase')
->desc('Create Firebase migration')
->groups(['api', 'migrations'])
->label('scope', 'migrations.write')
->label('event', 'migrations.[migrationId].create')
->label('audits.event', 'migration.create')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'createFirebaseMigration',
description: '/docs/references/migrations/migration-firebase.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_ACCEPTED,
model: Response::MODEL_MIGRATION,
)
]
))
->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate')
->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials')
->inject('response')
->inject('dbForProject')
->inject('project')
->inject('platform')
->inject('queueForEvents')
->inject('publisherForMigrations')
->callback($this->action(...));
}
public function action(
array $resources,
string $serviceAccount,
Response $response,
Database $dbForProject,
Document $project,
array $platform,
Event $queueForEvents,
MigrationPublisher $publisherForMigrations
): void {
$serviceAccountData = json_decode($serviceAccount, true);
if (empty($serviceAccountData)) {
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON');
}
if (!isset($serviceAccountData['project_id']) || !isset($serviceAccountData['client_email']) || !isset($serviceAccountData['private_key'])) {
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON');
}
$migration = $dbForProject->createDocument('migrations', new Document([
'$id' => ID::unique(),
'status' => 'pending',
'stage' => 'init',
'source' => Firebase::getName(),
'destination' => AppwriteSource::getName(),
'credentials' => [
'serviceAccount' => $serviceAccount,
],
'resources' => $resources,
'statusCounters' => '{}',
'resourceData' => '{}',
'errors' => [],
]));
$queueForEvents->setParam('migrationId', $migration->getId());
$publisherForMigrations->enqueue(new MigrationMessage(
project: $project,
migration: $migration,
platform: $platform,
));
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
->dynamic($migration, Response::MODEL_MIGRATION);
}
}
@@ -0,0 +1,80 @@
<?php
namespace Appwrite\Platform\Modules\Migrations\Http\Migrations\Firebase\Report;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Document;
use Utopia\Migration\Sources\Firebase;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
class Get extends Action
{
use HTTP;
public static function getName(): string
{
return 'getFirebaseReport';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/migrations/firebase/report')
->desc('Get Firebase migration report')
->groups(['api', 'migrations'])
->label('scope', 'migrations.write')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'getFirebaseReport',
description: '/docs/references/migrations/migration-firebase-report.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_MIGRATION_REPORT,
)
]
))
->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate')
->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials')
->inject('response')
->callback($this->action(...));
}
public function action(array $resources, string $serviceAccount, Response $response): void
{
$serviceAccount = json_decode($serviceAccount, true);
if (empty($serviceAccount)) {
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON');
}
if (!isset($serviceAccount['project_id']) || !isset($serviceAccount['client_email']) || !isset($serviceAccount['private_key'])) {
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON');
}
try {
$firebase = new Firebase($serviceAccount);
$report = $firebase->report($resources);
} catch (\Throwable $e) {
throw new Exception(
Exception::MIGRATION_PROVIDER_ERROR,
'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
);
}
$response
->setStatusCode(Response::STATUS_CODE_OK)
->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT);
}
}
@@ -0,0 +1,61 @@
<?php
namespace Appwrite\Platform\Modules\Migrations\Http\Migrations;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class Get extends Action
{
use HTTP;
public static function getName(): string
{
return 'getMigration';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/migrations/:migrationId')
->desc('Get migration')
->groups(['api', 'migrations'])
->label('scope', 'migrations.read')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'get',
description: '/docs/references/migrations/get-migration.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_MIGRATION,
)
]
))
->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration unique ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
public function action(string $migrationId, Response $response, Database $dbForProject): void
{
$migration = $dbForProject->getDocument('migrations', $migrationId);
if ($migration->isEmpty()) {
throw new Exception(Exception::MIGRATION_NOT_FOUND);
}
$response->dynamic($migration, Response::MODEL_MIGRATION);
}
}
@@ -0,0 +1,198 @@
<?php
namespace Appwrite\Platform\Modules\Migrations\Http\Migrations\JSON\Exports;
use Appwrite\Event\Event;
use Appwrite\Event\Message\Migration as MigrationMessage;
use Appwrite\Event\Publisher\Migration as MigrationPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\CompoundUID;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Queries\Documents;
use Utopia\Migration\Resource;
use Utopia\Migration\Sources\Appwrite as AppwriteSource;
use Utopia\Migration\Sources\JSON as JSONSource;
use Utopia\Migration\Transfer;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
class Create extends Action
{
use HTTP;
public static function getName(): string
{
return 'createJSONExport';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/migrations/json/exports')
->desc('Export documents to JSON')
->groups(['api', 'migrations'])
->label('scope', 'migrations.write')
->label('event', 'migrations.[migrationId].create')
->label('audits.event', 'migration.create')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'createJSONExport',
description: '/docs/references/migrations/migration-json-export.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_ACCEPTED,
model: Response::MODEL_MIGRATION,
)
]
))
->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.')
->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .json extension.')
->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true)
->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true)
->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true)
->inject('user')
->inject('response')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('authorization')
->inject('project')
->inject('platform')
->inject('queueForEvents')
->inject('publisherForMigrations')
->callback($this->action(...));
}
public function action(
string $resourceId,
string $filename,
array $columns,
array $queries,
bool $notify,
Document $user,
Response $response,
Database $dbForProject,
Database $dbForPlatform,
Authorization $authorization,
Document $project,
array $platform,
Event $queueForEvents,
MigrationPublisher $publisherForMigrations
): void {
try {
$parsedQueries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
$bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default'));
if ($bucket->isEmpty()) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
}
[$databaseId, $collectionId] = \explode(':', $resourceId, 2);
if (empty($databaseId)) {
throw new Exception(Exception::DATABASE_NOT_FOUND);
}
if (empty($collectionId)) {
throw new Exception(Exception::COLLECTION_NOT_FOUND);
}
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty()) {
throw new Exception(Exception::DATABASE_NOT_FOUND);
}
$collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId));
if ($collection->isEmpty()) {
throw new Exception(Exception::COLLECTION_NOT_FOUND);
}
$databaseType = $database->getAttribute('type');
// Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields
$isSchemaless = \in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]);
$validator = new Documents(
attributes: $collection->getAttribute('attributes', []),
indexes: $collection->getAttribute('indexes', []),
idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(),
supportForAttributes: !$isSchemaless,
);
if (!$validator->isValid($parsedQueries)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]);
$resourceType = self::resourceTypeForDatabaseType($databaseType);
$migration = $dbForProject->createDocument('migrations', new Document([
'$id' => ID::unique(),
'status' => 'pending',
'stage' => 'init',
'source' => AppwriteSource::getName(),
'destination' => JSONSource::getName(),
'resources' => $resources,
'resourceId' => $resourceId,
'resourceType' => $resourceType,
'statusCounters' => '{}',
'resourceData' => '{}',
'errors' => [],
'options' => [
'bucketId' => 'default', // Always use internal bucket
'filename' => $filename,
'columns' => $columns,
'queries' => $queries,
'notify' => $notify,
'userInternalId' => $user->getSequence(),
],
]));
$queueForEvents->setParam('migrationId', $migration->getId());
$publisherForMigrations->enqueue(new MigrationMessage(
project: $project,
migration: $migration,
platform: $platform,
));
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
->dynamic($migration, Response::MODEL_MIGRATION);
}
private static function transferGroupForDatabaseType(string $databaseType): string
{
return match ($databaseType) {
DATABASE_TYPE_LEGACY,
DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB,
DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB,
DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB,
default => throw new \LogicException('Unknown database type: ' . $databaseType),
};
}
private static function resourceTypeForDatabaseType(string $databaseType): string
{
return match ($databaseType) {
DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB,
DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB,
default => Resource::TYPE_DATABASE,
};
}
}
@@ -0,0 +1,221 @@
<?php
namespace Appwrite\Platform\Modules\Migrations\Http\Migrations\JSON\Imports;
use Appwrite\Event\Event;
use Appwrite\Event\Message\Migration as MigrationMessage;
use Appwrite\Event\Publisher\Migration as MigrationPublisher;
use Appwrite\Extend\Exception;
use Appwrite\OpenSSL\OpenSSL;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\CompoundUID;
use Appwrite\Utopia\Response;
use Utopia\Compression\Algorithms\GZIP;
use Utopia\Compression\Algorithms\Zstd;
use Utopia\Compression\Compression;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Migration\Resource;
use Utopia\Migration\Sources\Appwrite as AppwriteSource;
use Utopia\Migration\Sources\JSON as JSONSource;
use Utopia\Migration\Transfer;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Storage\Device;
use Utopia\System\System;
use Utopia\Validator\Boolean;
class Create extends Action
{
use HTTP;
public static function getName(): string
{
return 'createJSONImport';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/migrations/json/imports')
->desc('Import documents from a JSON')
->groups(['api', 'migrations'])
->label('scope', 'migrations.write')
->label('event', 'migrations.[migrationId].create')
->label('audits.event', 'migration.create')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'createJSONImport',
description: '/docs/references/migrations/migration-json-import.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_ACCEPTED,
model: Response::MODEL_MIGRATION,
)
]
))
->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).')
->param('fileId', '', new UID(), 'File ID.')
->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.')
->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true)
->inject('response')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('authorization')
->inject('project')
->inject('platform')
->inject('deviceForFiles')
->inject('deviceForMigrations')
->inject('queueForEvents')
->inject('publisherForMigrations')
->callback($this->action(...));
}
public function action(
string $bucketId,
string $fileId,
string $resourceId,
bool $internalFile,
Response $response,
Database $dbForProject,
Database $dbForPlatform,
Authorization $authorization,
Document $project,
array $platform,
Device $deviceForFiles,
Device $deviceForMigrations,
Event $queueForEvents,
MigrationPublisher $publisherForMigrations
): void {
$bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) {
if ($internalFile) {
return $dbForPlatform->getDocument('buckets', 'default');
}
return $dbForProject->getDocument('buckets', $bucketId);
});
if ($bucket->isEmpty()) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
}
$file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId));
if ($file->isEmpty()) {
throw new Exception(Exception::STORAGE_FILE_NOT_FOUND);
}
$path = $file->getAttribute('path', '');
if (!$deviceForFiles->exists($path)) {
throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path);
}
// No encryption or compression on files above 20MB.
$hasEncryption = !empty($file->getAttribute('openSSLCipher'));
$compression = $file->getAttribute('algorithm', Compression::NONE);
$hasCompression = $compression !== Compression::NONE;
$migrationId = ID::unique();
$newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.json');
if ($hasEncryption || $hasCompression) {
$source = $deviceForFiles->read($path);
if ($hasEncryption) {
$source = OpenSSL::decrypt(
$source,
$file->getAttribute('openSSLCipher'),
System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')),
0,
hex2bin($file->getAttribute('openSSLIV')),
hex2bin($file->getAttribute('openSSLTag'))
);
}
if ($hasCompression) {
switch ($compression) {
case Compression::ZSTD:
$source = (new Zstd())->decompress($source);
break;
case Compression::GZIP:
$source = (new GZIP())->decompress($source);
break;
}
}
// Manual write after decryption and/or decompression
if (!$deviceForMigrations->write($newPath, $source, 'application/json')) {
throw new \Exception('Unable to copy file');
}
} elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) {
throw new \Exception('Unable to copy file');
}
$fileSize = $deviceForMigrations->getFileSize($newPath);
[$databaseId] = \explode(':', $resourceId, 2);
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty()) {
throw new Exception(Exception::DATABASE_NOT_FOUND);
}
$databaseType = $database->getAttribute('type');
$resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]);
$resourceType = self::resourceTypeForDatabaseType($databaseType);
$migration = $dbForProject->createDocument('migrations', new Document([
'$id' => $migrationId,
'status' => 'pending',
'stage' => 'init',
'source' => JSONSource::getName(),
'destination' => AppwriteSource::getName(),
'resources' => $resources,
'resourceId' => $resourceId,
'resourceType' => $resourceType,
'statusCounters' => '{}',
'resourceData' => '{}',
'errors' => [],
'options' => [
'path' => $newPath,
'size' => $fileSize,
],
]));
$queueForEvents->setParam('migrationId', $migration->getId());
$publisherForMigrations->enqueue(new MigrationMessage(
project: $project,
migration: $migration,
platform: $platform,
));
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
->dynamic($migration, Response::MODEL_MIGRATION);
}
private static function transferGroupForDatabaseType(string $databaseType): string
{
return match ($databaseType) {
DATABASE_TYPE_LEGACY,
DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB,
DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB,
DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB,
default => throw new \LogicException('Unknown database type: ' . $databaseType),
};
}
private static function resourceTypeForDatabaseType(string $databaseType): string
{
return match ($databaseType) {
DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB,
DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB,
default => Resource::TYPE_DATABASE,
};
}
}
@@ -0,0 +1,122 @@
<?php
namespace Appwrite\Platform\Modules\Migrations\Http\Migrations\NHost;
use Appwrite\Event\Event;
use Appwrite\Event\Message\Migration as MigrationMessage;
use Appwrite\Event\Publisher\Migration as MigrationPublisher;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Migration\Sources\Appwrite as AppwriteSource;
use Utopia\Migration\Sources\NHost;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Integer;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
class Create extends Action
{
use HTTP;
public static function getName(): string
{
return 'createNHostMigration';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/migrations/nhost')
->desc('Create NHost migration')
->groups(['api', 'migrations'])
->label('scope', 'migrations.write')
->label('event', 'migrations.[migrationId].create')
->label('audits.event', 'migration.create')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'createNHostMigration',
description: '/docs/references/migrations/migration-nhost.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_ACCEPTED,
model: Response::MODEL_MIGRATION,
)
]
))
->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate')
->param('subdomain', '', new Text(512), 'Source\'s Subdomain')
->param('region', '', new Text(512), 'Source\'s Region')
->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret')
->param('database', '', new Text(512), 'Source\'s Database Name')
->param('username', '', new Text(512), 'Source\'s Database Username')
->param('password', '', new Text(512), 'Source\'s Database Password')
->param('port', 5432, new Integer(true), 'Source\'s Database Port', true)
->inject('response')
->inject('dbForProject')
->inject('project')
->inject('platform')
->inject('queueForEvents')
->inject('publisherForMigrations')
->callback($this->action(...));
}
public function action(
array $resources,
string $subdomain,
string $region,
string $adminSecret,
string $database,
string $username,
string $password,
int $port,
Response $response,
Database $dbForProject,
Document $project,
array $platform,
Event $queueForEvents,
MigrationPublisher $publisherForMigrations
): void {
$migration = $dbForProject->createDocument('migrations', new Document([
'$id' => ID::unique(),
'status' => 'pending',
'stage' => 'init',
'source' => NHost::getName(),
'destination' => AppwriteSource::getName(),
'credentials' => [
'subdomain' => $subdomain,
'region' => $region,
'adminSecret' => $adminSecret,
'database' => $database,
'username' => $username,
'password' => $password,
'port' => $port,
],
'resources' => $resources,
'statusCounters' => '{}',
'resourceData' => '{}',
'errors' => [],
]));
$queueForEvents->setParam('migrationId', $migration->getId());
$publisherForMigrations->enqueue(new MigrationMessage(
project: $project,
migration: $migration,
platform: $platform,
));
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
->dynamic($migration, Response::MODEL_MIGRATION);
}
}
@@ -0,0 +1,86 @@
<?php
namespace Appwrite\Platform\Modules\Migrations\Http\Migrations\NHost\Report;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Document;
use Utopia\Migration\Sources\NHost;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Integer;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
class Get extends Action
{
use HTTP;
public static function getName(): string
{
return 'getNHostReport';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/migrations/nhost/report')
->desc('Get NHost migration report')
->groups(['api', 'migrations'])
->label('scope', 'migrations.write')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'getNHostReport',
description: '/docs/references/migrations/migration-nhost-report.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_MIGRATION_REPORT,
)
]
))
->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate.')
->param('subdomain', '', new Text(512), 'Source\'s Subdomain.')
->param('region', '', new Text(512), 'Source\'s Region.')
->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret.')
->param('database', '', new Text(512), 'Source\'s Database Name.')
->param('username', '', new Text(512), 'Source\'s Database Username.')
->param('password', '', new Text(512), 'Source\'s Database Password.')
->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true)
->inject('response')
->callback($this->action(...));
}
public function action(
array $resources,
string $subdomain,
string $region,
string $adminSecret,
string $database,
string $username,
string $password,
int $port,
Response $response
): void {
try {
$nhost = new NHost($subdomain, $region, $adminSecret, $database, $username, $password, $port);
$report = $nhost->report($resources);
} catch (\Throwable $e) {
throw new Exception(
Exception::MIGRATION_PROVIDER_ERROR,
'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
);
}
$response
->setStatusCode(Response::STATUS_CODE_OK)
->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT);
}
}
@@ -0,0 +1,120 @@
<?php
namespace Appwrite\Platform\Modules\Migrations\Http\Migrations\Supabase;
use Appwrite\Event\Event;
use Appwrite\Event\Message\Migration as MigrationMessage;
use Appwrite\Event\Publisher\Migration as MigrationPublisher;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Migration\Sources\Appwrite as AppwriteSource;
use Utopia\Migration\Sources\Supabase;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Integer;
use Utopia\Validator\Text;
use Utopia\Validator\URL;
use Utopia\Validator\WhiteList;
class Create extends Action
{
use HTTP;
public static function getName(): string
{
return 'createSupabaseMigration';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/migrations/supabase')
->desc('Create Supabase migration')
->groups(['api', 'migrations'])
->label('scope', 'migrations.write')
->label('event', 'migrations.[migrationId].create')
->label('audits.event', 'migration.create')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'createSupabaseMigration',
description: '/docs/references/migrations/migration-supabase.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_ACCEPTED,
model: Response::MODEL_MIGRATION,
)
]
))
->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate')
->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint')
->param('apiKey', '', new Text(512), 'Source\'s API Key')
->param('databaseHost', '', new Text(512), 'Source\'s Database Host')
->param('username', '', new Text(512), 'Source\'s Database Username')
->param('password', '', new Text(512), 'Source\'s Database Password')
->param('port', 5432, new Integer(true), 'Source\'s Database Port', true)
->inject('response')
->inject('dbForProject')
->inject('project')
->inject('platform')
->inject('queueForEvents')
->inject('publisherForMigrations')
->callback($this->action(...));
}
public function action(
array $resources,
string $endpoint,
string $apiKey,
string $databaseHost,
string $username,
string $password,
int $port,
Response $response,
Database $dbForProject,
Document $project,
array $platform,
Event $queueForEvents,
MigrationPublisher $publisherForMigrations
): void {
$migration = $dbForProject->createDocument('migrations', new Document([
'$id' => ID::unique(),
'status' => 'pending',
'stage' => 'init',
'source' => Supabase::getName(),
'destination' => AppwriteSource::getName(),
'credentials' => [
'endpoint' => $endpoint,
'apiKey' => $apiKey,
'databaseHost' => $databaseHost,
'username' => $username,
'password' => $password,
'port' => $port,
],
'resources' => $resources,
'statusCounters' => '{}',
'resourceData' => '{}',
'errors' => [],
]));
$queueForEvents->setParam('migrationId', $migration->getId());
$publisherForMigrations->enqueue(new MigrationMessage(
project: $project,
migration: $migration,
platform: $platform,
));
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
->dynamic($migration, Response::MODEL_MIGRATION);
}
}
@@ -0,0 +1,85 @@
<?php
namespace Appwrite\Platform\Modules\Migrations\Http\Migrations\Supabase\Report;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Document;
use Utopia\Migration\Sources\Supabase;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Integer;
use Utopia\Validator\Text;
use Utopia\Validator\URL;
use Utopia\Validator\WhiteList;
class Get extends Action
{
use HTTP;
public static function getName(): string
{
return 'getSupabaseReport';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/migrations/supabase/report')
->desc('Get Supabase migration report')
->groups(['api', 'migrations'])
->label('scope', 'migrations.write')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'getSupabaseReport',
description: '/docs/references/migrations/migration-supabase-report.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_MIGRATION_REPORT,
)
]
))
->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate')
->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint.')
->param('apiKey', '', new Text(512), 'Source\'s API Key.')
->param('databaseHost', '', new Text(512), 'Source\'s Database Host.')
->param('username', '', new Text(512), 'Source\'s Database Username.')
->param('password', '', new Text(512), 'Source\'s Database Password.')
->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true)
->inject('response')
->callback($this->action(...));
}
public function action(
array $resources,
string $endpoint,
string $apiKey,
string $databaseHost,
string $username,
string $password,
int $port,
Response $response
): void {
try {
$supabase = new Supabase($endpoint, $apiKey, $databaseHost, 'postgres', $username, $password, $port);
$report = $supabase->report($resources);
} catch (\Throwable $e) {
throw new Exception(
Exception::MIGRATION_PROVIDER_ERROR,
'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
);
}
$response
->setStatusCode(Response::STATUS_CODE_OK)
->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT);
}
}
@@ -0,0 +1,90 @@
<?php
namespace Appwrite\Platform\Modules\Migrations\Http\Migrations;
use Appwrite\Event\Message\Migration as MigrationMessage;
use Appwrite\Event\Publisher\Migration as MigrationPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class Update extends Action
{
use HTTP;
public static function getName(): string
{
return 'retryMigration';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
->setHttpPath('/v1/migrations/:migrationId')
->desc('Update retry migration')
->groups(['api', 'migrations'])
->label('scope', 'migrations.write')
->label('event', 'migrations.[migrationId].retry')
->label('audits.event', 'migration.retry')
->label('audits.resource', 'migrations/{request.migrationId}')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'retry',
description: '/docs/references/migrations/retry-migration.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_ACCEPTED,
model: Response::MODEL_MIGRATION,
)
]
))
->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration unique ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
->inject('project')
->inject('platform')
->inject('publisherForMigrations')
->callback($this->action(...));
}
public function action(
string $migrationId,
Response $response,
Database $dbForProject,
Document $project,
array $platform,
MigrationPublisher $publisherForMigrations
): void {
$migration = $dbForProject->getDocument('migrations', $migrationId);
if ($migration->isEmpty()) {
throw new Exception(Exception::MIGRATION_NOT_FOUND);
}
if ($migration->getAttribute('status') !== 'failed') {
throw new Exception(Exception::MIGRATION_IN_PROGRESS, 'Migration not failed yet');
}
$migration
->setAttribute('status', 'pending')
->setAttribute('dateUpdated', \time());
$publisherForMigrations->enqueue(new MigrationMessage(
project: $project,
migration: $migration,
platform: $platform,
));
$response->noContent();
}
}
@@ -0,0 +1,104 @@
<?php
namespace Appwrite\Platform\Modules\Migrations\Http\Migrations;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\Migrations;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
class XList extends Action
{
use HTTP;
public static function getName(): string
{
return 'listMigrations';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/migrations')
->desc('List migrations')
->groups(['api', 'migrations'])
->label('scope', 'migrations.read')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'list',
description: '/docs/references/migrations/list-migrations.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_MIGRATION_LIST,
)
]
))
->param('queries', [], new Migrations(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Migrations::ALLOWED_ATTRIBUTES), true)
->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
public function action(array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject): void
{
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
if (!empty($search)) {
$queries[] = Query::search('search', $search);
}
$cursor = Query::getCursorQueries($queries, false);
$cursor = \reset($cursor);
if ($cursor !== false) {
$validator = new Cursor();
if (!$validator->isValid($cursor)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$migrationId = $cursor->getValue();
$cursorDocument = $dbForProject->getDocument('migrations', $migrationId);
if ($cursorDocument->isEmpty()) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Migration '{$migrationId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
$migrations = $dbForProject->find('migrations', $queries);
$total = $includeTotal ? $dbForProject->count('migrations', $filterQueries, APP_LIMIT_COUNT) : 0;
} catch (OrderException $e) {
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
}
$response->dynamic(new Document([
'migrations' => $migrations,
'total' => $total,
]), Response::MODEL_MIGRATION_LIST);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Appwrite\Platform\Modules\Migrations;
use Appwrite\Platform\Modules\Migrations\Services\Http;
use Utopia\Platform;
class Module extends Platform\Module
{
public function __construct()
{
$this->addService('http', new Http());
}
}
@@ -0,0 +1,59 @@
<?php
namespace Appwrite\Platform\Modules\Migrations\Services;
use Appwrite\Platform\Modules\Migrations\Http\Migrations\Appwrite\Create as CreateAppwriteMigration;
use Appwrite\Platform\Modules\Migrations\Http\Migrations\Appwrite\Report\Get as GetAppwriteReport;
use Appwrite\Platform\Modules\Migrations\Http\Migrations\CSV\Exports\Create as CreateCSVExport;
use Appwrite\Platform\Modules\Migrations\Http\Migrations\CSV\Imports\Create as CreateCSVImport;
use Appwrite\Platform\Modules\Migrations\Http\Migrations\Delete as DeleteMigration;
use Appwrite\Platform\Modules\Migrations\Http\Migrations\Firebase\Create as CreateFirebaseMigration;
use Appwrite\Platform\Modules\Migrations\Http\Migrations\Firebase\Report\Get as GetFirebaseReport;
use Appwrite\Platform\Modules\Migrations\Http\Migrations\Get as GetMigration;
use Appwrite\Platform\Modules\Migrations\Http\Migrations\JSON\Exports\Create as CreateJSONExport;
use Appwrite\Platform\Modules\Migrations\Http\Migrations\JSON\Imports\Create as CreateJSONImport;
use Appwrite\Platform\Modules\Migrations\Http\Migrations\NHost\Create as CreateNHostMigration;
use Appwrite\Platform\Modules\Migrations\Http\Migrations\NHost\Report\Get as GetNHostReport;
use Appwrite\Platform\Modules\Migrations\Http\Migrations\Supabase\Create as CreateSupabaseMigration;
use Appwrite\Platform\Modules\Migrations\Http\Migrations\Supabase\Report\Get as GetSupabaseReport;
use Appwrite\Platform\Modules\Migrations\Http\Migrations\Update as UpdateMigration;
use Appwrite\Platform\Modules\Migrations\Http\Migrations\XList as ListMigrations;
use Utopia\Platform\Service;
class Http extends Service
{
public function __construct()
{
$this->type = Service::TYPE_HTTP;
// Migrations
$this->addAction(ListMigrations::getName(), new ListMigrations());
$this->addAction(GetMigration::getName(), new GetMigration());
$this->addAction(UpdateMigration::getName(), new UpdateMigration());
$this->addAction(DeleteMigration::getName(), new DeleteMigration());
// Appwrite source
$this->addAction(CreateAppwriteMigration::getName(), new CreateAppwriteMigration());
$this->addAction(GetAppwriteReport::getName(), new GetAppwriteReport());
// Firebase source
$this->addAction(CreateFirebaseMigration::getName(), new CreateFirebaseMigration());
$this->addAction(GetFirebaseReport::getName(), new GetFirebaseReport());
// Supabase source
$this->addAction(CreateSupabaseMigration::getName(), new CreateSupabaseMigration());
$this->addAction(GetSupabaseReport::getName(), new GetSupabaseReport());
// NHost source
$this->addAction(CreateNHostMigration::getName(), new CreateNHostMigration());
$this->addAction(GetNHostReport::getName(), new GetNHostReport());
// CSV import / export
$this->addAction(CreateCSVImport::getName(), new CreateCSVImport());
$this->addAction(CreateCSVExport::getName(), new CreateCSVExport());
// JSON import / export
$this->addAction(CreateJSONImport::getName(), new CreateJSONImport());
$this->addAction(CreateJSONExport::getName(), new CreateJSONExport());
}
}
@@ -0,0 +1,106 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\Keys\Ephemeral;
use Ahc\Jwt\JWT;
use Appwrite\Event\Event as QueueEvent;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\DateTime as DatabaseDateTime;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Range;
use Utopia\Validator\WhiteList;
class Create extends Base
{
use HTTP;
public static function getName()
{
return 'createEphemeralProjectKey';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/project/keys/ephemeral')
->httpAlias('/v1/projects/:projectId/jwts')
->desc('Create ephemeral project key')
->groups(['api', 'project'])
->label('scope', 'keys.write')
->label('event', 'keys.[keyId].create')
->label('audits.event', 'project.key.create')
->label('audits.resource', 'project.key/{response.$id}')
->label('sdk', new Method(
namespace: 'project',
group: 'keys',
name: 'createEphemeralKey',
description: <<<EOT
Create a new ephemeral API key. It's recommended to have multiple API keys with strict scopes for separate functions within your project.
You can also create a standard API key if you need a longer-lived key instead.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_EPHEMERAL_KEY,
)
],
))
->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', optional: false)
->param('duration', 900, new Range(1, 3600), 'Time in seconds before ephemeral key expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true)
->inject('response')
->inject('queueForEvents')
->inject('project')
->callback($this->action(...));
}
public function action(
array $scopes,
int $duration,
Response $response,
QueueEvent $queueForEvents,
Document $project,
) {
$keyId = ID::unique();
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0);
$secret = $jwt->encode([
'projectId' => $project->getId(),
'scopes' => $scopes
]);
$now = new \DateTime();
$expire = $now->add(new \DateInterval('PT' . $duration . 'S'))->format('Y-m-d\TH:i:s.u\Z');
$key = new Document([
'$id' => $keyId,
'$createdAt' => DatabaseDateTime::now(),
'$updatedAt' => DatabaseDateTime::now(),
'name' => '',
'scopes' => $scopes,
'expire' => $expire,
'sdks' => [],
'accessedAt' => null,
'secret' => API_KEY_EPHEMERAL . '_' . $secret,
]);
$queueForEvents->setParam('keyId', $key->getId());
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($key, Response::MODEL_EPHEMERAL_KEY);
}
}
@@ -1,6 +1,6 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\Keys;
namespace Appwrite\Platform\Modules\Project\Http\Project\Keys\Standard;
use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
@@ -30,16 +30,17 @@ class Create extends Base
public static function getName()
{
return 'createProjectKey';
return 'createStandardProjectKey';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/project/keys')
->setHttpPath('/v1/project/keys/standard')
->httpAlias('/v1/project/keys')
->httpAlias('/v1/projects/:projectId/keys')
->desc('Create project key')
->desc('Create standard project key')
->groups(['api', 'project'])
->label('scope', 'keys.write')
->label('event', 'keys.[keyId].create')
@@ -48,9 +49,11 @@ class Create extends Base
->label('sdk', new Method(
namespace: 'project',
group: 'keys',
name: 'createKey',
name: 'createStandardKey',
description: <<<EOT
Create a new API key. It's recommended to have multiple API keys with strict scopes for separate functions within your project.
Create a new standard API key. It's recommended to have multiple API keys with strict scopes for separate functions within your project.
You can also create an ephemeral API key if you need a short-lived key instead.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
@@ -5,9 +5,10 @@ namespace Appwrite\Platform\Modules\Project\Services;
use Appwrite\Platform\Modules\Project\Http\Init;
use Appwrite\Platform\Modules\Project\Http\Project\AuthMethods\Update as UpdateAuthMethod;
use Appwrite\Platform\Modules\Project\Http\Project\Delete as DeleteProject;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Ephemeral\Create as CreateEphemeralKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Standard\Create as CreateStandardKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Update as UpdateKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\XList as ListKeys;
use Appwrite\Platform\Modules\Project\Http\Project\Labels\Update as UpdateProjectLabels;
@@ -130,7 +131,8 @@ class Http extends Service
$this->addAction(UpdateVariable::getName(), new UpdateVariable());
// Keys
$this->addAction(CreateKey::getName(), new CreateKey());
$this->addAction(CreateStandardKey::getName(), new CreateStandardKey());
$this->addAction(CreateEphemeralKey::getName(), new CreateEphemeralKey());
$this->addAction(ListKeys::getName(), new ListKeys());
$this->addAction(GetKey::getName(), new GetKey());
$this->addAction(DeleteKey::getName(), new DeleteKey());
+1 -1
View File
@@ -434,7 +434,7 @@ class Functions extends Action
]);
$headers['x-appwrite-execution-id'] = $executionId ?? '';
$headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey;
$headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $apiKey;
$headers['x-appwrite-trigger'] = $trigger;
$headers['x-appwrite-event'] = $event ?? '';
$headers['x-appwrite-user-id'] = $user->getId();
+1 -1
View File
@@ -402,7 +402,7 @@ class Migrations extends Action
]
]);
return API_KEY_DYNAMIC . '_' . $apiKey;
return API_KEY_EPHEMERAL . '_' . $apiKey;
}
/**
+38 -42
View File
@@ -106,51 +106,47 @@ class Webhooks extends Action
$httpPass = $webhook->getAttribute('httpPass');
$ch = \curl_init($webhook->getAttribute('url'));
try {
\curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
\curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
\curl_setopt($ch, CURLOPT_HEADER, 0);
\curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
\curl_setopt($ch, CURLOPT_TIMEOUT, 15);
\curl_setopt($ch, CURLOPT_MAXFILESIZE, self::MAX_FILE_SIZE);
\curl_setopt($ch, CURLOPT_USERAGENT, \sprintf(
APP_USERAGENT,
System::getEnv('_APP_VERSION', 'UNKNOWN'),
System::getEnv('_APP_EMAIL_SECURITY', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS', APP_EMAIL_SECURITY))
));
\curl_setopt(
$ch,
CURLOPT_HTTPHEADER,
[
'Content-Type: application/json',
'Content-Length: ' . \strlen($payload),
'X-' . APP_NAME . '-Webhook-Id: ' . $webhook->getId(),
'X-' . APP_NAME . '-Webhook-Events: ' . implode(',', $events),
'X-' . APP_NAME . '-Webhook-Name: ' . $webhook->getAttribute('name', ''),
'X-' . APP_NAME . '-Webhook-User-Id: ' . $user->getId(),
'X-' . APP_NAME . '-Webhook-Project-Id: ' . $project->getId(),
'X-' . APP_NAME . '-Webhook-Signature: ' . $signature,
]
);
\curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
\curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
\curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
\curl_setopt($ch, CURLOPT_HEADER, 0);
\curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
\curl_setopt($ch, CURLOPT_TIMEOUT, 15);
\curl_setopt($ch, CURLOPT_MAXFILESIZE, self::MAX_FILE_SIZE);
\curl_setopt($ch, CURLOPT_USERAGENT, \sprintf(
APP_USERAGENT,
System::getEnv('_APP_VERSION', 'UNKNOWN'),
System::getEnv('_APP_EMAIL_SECURITY', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS', APP_EMAIL_SECURITY))
));
\curl_setopt(
$ch,
CURLOPT_HTTPHEADER,
[
'Content-Type: application/json',
'Content-Length: ' . \strlen($payload),
'X-' . APP_NAME . '-Webhook-Id: ' . $webhook->getId(),
'X-' . APP_NAME . '-Webhook-Events: ' . implode(',', $events),
'X-' . APP_NAME . '-Webhook-Name: ' . $webhook->getAttribute('name', ''),
'X-' . APP_NAME . '-Webhook-User-Id: ' . $user->getId(),
'X-' . APP_NAME . '-Webhook-Project-Id: ' . $project->getId(),
'X-' . APP_NAME . '-Webhook-Signature: ' . $signature,
]
);
\curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
if (!$webhook->getAttribute('security', true)) {
\curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
\curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
}
if (!empty($httpUser) && !empty($httpPass)) {
\curl_setopt($ch, CURLOPT_USERPWD, "$httpUser:$httpPass");
\curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
}
$responseBody = \curl_exec($ch);
$curlError = \curl_error($ch);
$statusCode = \curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
} finally {
\curl_close($ch);
if (!$webhook->getAttribute('security', true)) {
\curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
\curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
}
if (!empty($httpUser) && !empty($httpPass)) {
\curl_setopt($ch, CURLOPT_USERPWD, "$httpUser:$httpPass");
\curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
}
$responseBody = \curl_exec($ch);
$curlError = \curl_error($ch);
$statusCode = \curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if (!empty($curlError) || $statusCode >= 400) {
$dbForPlatform->increaseDocumentAttribute('webhooks', $webhook->getId(), 'attempts', 1);
$webhook = $dbForPlatform->getDocument('webhooks', $webhook->getId());
@@ -0,0 +1,36 @@
<?php
namespace Appwrite\Utopia\Request\Filters;
use Appwrite\Utopia\Request\Filter;
class V24 extends Filter
{
// Convert 1.9.2 params to 1.9.3
public function parse(array $content, string $model): array
{
switch ($model) {
case 'project.createStandardKey':
$content = $this->fillKeyId($content);
$content = $this->parseKeyScopes($content);
break;
}
return $content;
}
protected function fillKeyId(array $content): array
{
$content['keyId'] = $content['keyId'] ?? 'unique()';
return $content;
}
protected function parseKeyScopes(array $content): array
{
if (!\is_array($content['scopes'] ?? null)) {
$content['scopes'] = [];
}
return $content;
}
}
+3
View File
@@ -251,6 +251,7 @@ class Response extends SwooleResponse
public const MODEL_WEBHOOK_LIST = 'webhookList';
public const MODEL_KEY = 'key';
public const MODEL_KEY_LIST = 'keyList';
public const MODEL_EPHEMERAL_KEY = 'ephemeralKey';
public const MODEL_DEV_KEY = 'devKey';
public const MODEL_DEV_KEY_LIST = 'devKeyList';
public const MODEL_MOCK_NUMBER = 'mockNumber';
@@ -334,6 +335,8 @@ class Response extends SwooleResponse
public const MODEL_CONSOLE_OAUTH2_PROVIDER_PARAMETER = 'consoleOAuth2ProviderParameter';
public const MODEL_CONSOLE_OAUTH2_PROVIDER = 'consoleOAuth2Provider';
public const MODEL_CONSOLE_OAUTH2_PROVIDER_LIST = 'consoleOAuth2ProviderList';
public const MODEL_CONSOLE_KEY_SCOPE = 'consoleKeyScope';
public const MODEL_CONSOLE_KEY_SCOPE_LIST = 'consoleKeyScopeList';
// Deprecated
public const MODEL_PERMISSIONS = 'permissions';
@@ -0,0 +1,56 @@
<?php
namespace Appwrite\Utopia\Response\Filters;
use Ahc\Jwt\JWT;
use Ahc\Jwt\JWTException;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Filter;
use Utopia\System\System;
// Convert 1.9.3 Data format to 1.9.2 format
class V24 extends Filter
{
public function parse(array $content, string $model): array
{
return match ($model) {
Response::MODEL_EPHEMERAL_KEY => $this->parseEphemeralKey($content),
default => $content,
};
}
private function parseEphemeralKey(array $content): array
{
unset($content['$id']);
unset($content['$createdAt']);
unset($content['$updatedAt']);
unset($content['name']);
unset($content['expire']);
unset($content['sdks']);
unset($content['accessedAt']);
$secret = $content['secret'] ?? '';
unset($content['secret']);
$content['projectId'] = $this->extractProjectId($secret);
$content['jwt'] = $secret;
return $content;
}
private function extractProjectId(string $secret): string
{
$token = explode('_', $secret, 2)[1] ?? '';
if ($token === '') {
return '';
}
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256');
try {
return $jwt->decode($token, false)['projectId'] ?? '';
} catch (JWTException) {
return '';
}
}
}
@@ -0,0 +1,37 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class ConsoleKeyScope extends Model
{
public function __construct()
{
$this
->addRule('$id', [
'type' => self::TYPE_STRING,
'description' => 'Scope ID.',
'default' => '',
'example' => 'users.read',
])
->addRule('description', [
'type' => self::TYPE_STRING,
'description' => 'Scope description.',
'default' => '',
'example' => 'Access to read your project\'s users',
])
;
}
public function getName(): string
{
return 'Console Key Scope';
}
public function getType(): string
{
return Response::MODEL_CONSOLE_KEY_SCOPE;
}
}
@@ -0,0 +1,37 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class ConsoleKeyScopeList extends Model
{
public function __construct()
{
$this
->addRule('total', [
'type' => self::TYPE_INTEGER,
'description' => 'Total number of key scopes exposed by the server.',
'default' => 0,
'example' => 5,
])
->addRule('scopes', [
'type' => Response::MODEL_CONSOLE_KEY_SCOPE,
'description' => 'List of key scopes, each with its ID and description.',
'default' => [],
'array' => true,
])
;
}
public function getName(): string
{
return 'Console Key Scopes List';
}
public function getType(): string
{
return Response::MODEL_CONSOLE_KEY_SCOPE_LIST;
}
}
@@ -0,0 +1,33 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
class EphemeralKey extends Key
{
public function __construct()
{
parent::__construct();
}
/**
* Get Name
*
* @return string
*/
public function getName(): string
{
return 'Ephemeral Key';
}
/**
* Get Type
*
* @return string
*/
public function getType(): string
{
return Response::MODEL_EPHEMERAL_KEY;
}
}
@@ -7,11 +7,6 @@ use Appwrite\Utopia\Response\Model;
class Key extends Model
{
/**
* @var bool
*/
protected bool $public = true; // Public because reused for more key types
public function __construct()
{
$this
+1 -1
View File
@@ -31,7 +31,7 @@ class Comment
'Trigger functions via HTTP, SDKs, events, webhooks, or scheduled cron jobs',
'Each function runs in its own isolated container with custom environment variables',
'Build commands execute in runtime containers during deployment',
'Dynamic API keys are generated automatically for each function execution',
'Ephemeral API keys are generated automatically for each function execution',
'JWT tokens let functions act on behalf of users while preserving their permissions',
'Storage files get ClamAV malware scanning and encryption by default',
'Roll back Sites deployments instantly by switching between versions',
-3
View File
@@ -378,7 +378,6 @@ class Executor
$responseBody = curl_exec($ch);
if (isset($callback)) {
curl_close($ch);
return [];
}
@@ -418,8 +417,6 @@ class Executor
throw new Exception($curlErrorMessage . ' with status code ' . $responseStatus, $responseStatus);
}
curl_close($ch);
$responseHeaders['status-code'] = $responseStatus;
return [
-2
View File
@@ -294,8 +294,6 @@ class Client
throw new Exception(curl_error($ch) . ' with status code ' . $responseStatus, $responseStatus);
}
curl_close($ch);
$responseHeaders['status-code'] = $responseStatus;
if ($responseStatus === 500) {
@@ -128,4 +128,47 @@ class ConsoleConsoleClientTest extends Scope
// Sandbox providers (e.g. paypalSandbox) are included
$this->assertContains('paypalSandbox', $providerIds);
}
public function testListKeyScopes(): void
{
$response = $this->client->call(Client::METHOD_GET, '/console/scopes/key', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertIsInt($response['body']['total']);
$this->assertIsArray($response['body']['scopes']);
$this->assertGreaterThan(0, $response['body']['total']);
$this->assertEquals($response['body']['total'], \count($response['body']['scopes']));
$scopeIds = \array_column($response['body']['scopes'], '$id');
// Well-known scopes must be present
$this->assertContains('users.read', $scopeIds);
$this->assertContains('users.write', $scopeIds);
$this->assertContains('functions.read', $scopeIds);
$this->assertContains('functions.write', $scopeIds);
// Every scope has the expected shape
foreach ($response['body']['scopes'] as $scope) {
$this->assertArrayHasKey('$id', $scope);
$this->assertIsString($scope['$id']);
$this->assertNotEmpty($scope['$id']);
$this->assertArrayHasKey('description', $scope);
$this->assertIsString($scope['description']);
$this->assertNotEmpty($scope['description']);
}
// A specific scope has the expected description
$usersRead = null;
foreach ($response['body']['scopes'] as $scope) {
if ($scope['$id'] === 'users.read') {
$usersRead = $scope;
break;
}
}
$this->assertNotNull($usersRead);
$this->assertEquals('Access to read your project\'s users', $usersRead['description']);
}
}
@@ -43,4 +43,22 @@ class ConsoleCustomServerTest extends Scope
$this->assertContains('github', $providerIds);
$this->assertNotContains('mock', $providerIds);
}
public function testListKeyScopes(): void
{
// Public endpoint: must succeed without admin authentication. Drop the
// headers from getHeaders() and only pass project + content-type.
$response = $this->client->call(Client::METHOD_GET, '/console/scopes/key', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertIsInt($response['body']['total']);
$this->assertIsArray($response['body']['scopes']);
$this->assertGreaterThan(0, $response['body']['total']);
$scopeIds = \array_column($response['body']['scopes'], '$id');
$this->assertContains('users.read', $scopeIds);
}
}
@@ -352,7 +352,6 @@ trait FunctionsBase
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$commitData = json_decode($response, true);
@@ -700,7 +700,7 @@ class FunctionsCustomServerTest extends Scope
$this->assertEquals(200, $function['headers']['status-code']);
$this->assertEquals($deploymentId, $function['body']['deploymentId']);
// Test starter code is used and that dynamic keys work
// Test starter code is used and that ephemeral keys work
$execution = $this->createExecution($functionId, [
'path' => '/ping',
]);
@@ -2241,7 +2241,7 @@ class FunctionsCustomServerTest extends Scope
]);
$deploymentId = $this->setupDeployment($functionId, [
'code' => $this->packageFunction('dynamic-api-key'),
'code' => $this->packageFunction('ephemeral-api-key'),
'activate' => true,
]);
@@ -1303,6 +1303,7 @@ trait MigrationsBase
$mimeType = match ($csvFileName) {
default => 'text/csv',
'missing-column.csv',
'missing-row.csv' => 'text/plain', // invalid csv structure, falls back to plain text!
};
+131
View File
@@ -239,6 +239,112 @@ trait KeysBase
$this->deleteKey($customId);
}
// =========================================================================
// Create ephemeral key tests
// =========================================================================
public function testCreateEphemeralKey(): void
{
$key = $this->createEphemeralKey(
['users.read', 'users.write'],
);
$this->assertSame(201, $key['headers']['status-code']);
$this->assertNotEmpty($key['body']['$id']);
$this->assertSame('', $key['body']['name']);
$this->assertSame(['users.read', 'users.write'], $key['body']['scopes']);
$this->assertNotEmpty($key['body']['secret']);
$this->assertStringStartsWith(API_KEY_EPHEMERAL . '_', $key['body']['secret']);
$this->assertSame([], $key['body']['sdks']);
$this->assertSame('', $key['body']['accessedAt']);
$dateValidator = new DatetimeValidator();
$this->assertSame(true, $dateValidator->isValid($key['body']['$createdAt']));
$this->assertSame(true, $dateValidator->isValid($key['body']['$updatedAt']));
$this->assertSame(true, $dateValidator->isValid($key['body']['expire']));
// Verify JWT payload
$jwt = substr($key['body']['secret'], strlen(API_KEY_EPHEMERAL . '_'));
$parts = explode('.', $jwt);
$this->assertCount(3, $parts);
$payload = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $parts[1])), true);
$this->assertNotEmpty($payload['projectId']);
$this->assertSame(['users.read', 'users.write'], $payload['scopes']);
// Verify default duration (900 seconds)
$expireDt = new \DateTime($key['body']['expire']);
$now = new \DateTime();
$diff = $expireDt->getTimestamp() - $now->getTimestamp();
$this->assertGreaterThanOrEqual(890, $diff);
$this->assertLessThanOrEqual(910, $diff);
}
public function testCreateEphemeralKeyWithDuration(): void
{
$duration = 1800;
$key = $this->createEphemeralKey(
['databases.read'],
$duration,
);
$this->assertSame(201, $key['headers']['status-code']);
$this->assertSame(['databases.read'], $key['body']['scopes']);
$expireDt = new \DateTime($key['body']['expire']);
$now = new \DateTime();
$diff = $expireDt->getTimestamp() - $now->getTimestamp();
$this->assertGreaterThanOrEqual($duration - 10, $diff);
$this->assertLessThanOrEqual($duration + 10, $diff);
}
public function testCreateEphemeralKeyWithEmptyScopes(): void
{
$key = $this->createEphemeralKey(
[],
);
$this->assertSame(201, $key['headers']['status-code']);
$this->assertSame([], $key['body']['scopes']);
}
public function testCreateEphemeralKeyWithoutAuthentication(): void
{
$response = $this->createEphemeralKey(
['users.read'],
null,
false
);
$this->assertSame(401, $response['headers']['status-code']);
}
public function testCreateEphemeralKeyInvalidScope(): void
{
$response = $this->createEphemeralKey(
['invalid.scope'],
);
$this->assertSame(400, $response['headers']['status-code']);
}
public function testCreateEphemeralKeyInvalidDuration(): void
{
$response = $this->createEphemeralKey(
['users.read'],
0,
);
$this->assertSame(400, $response['headers']['status-code']);
$response = $this->createEphemeralKey(
['users.read'],
3601,
);
$this->assertSame(400, $response['headers']['status-code']);
}
// =========================================================================
// Update key tests
// =========================================================================
@@ -855,4 +961,29 @@ trait KeysBase
return $this->client->call(Client::METHOD_DELETE, '/project/keys/' . $keyId, $headers);
}
/**
* @param array<string> $scopes
*/
protected function createEphemeralKey(array $scopes, ?int $duration = null, bool $authenticated = true): mixed
{
$params = [
'scopes' => $scopes,
];
if ($duration !== null) {
$params['duration'] = $duration;
}
$headers = [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
];
if ($authenticated) {
$headers = array_merge($headers, $this->getHeaders());
}
return $this->client->call(Client::METHOD_POST, '/project/keys/ephemeral', $headers, $params);
}
}
@@ -0,0 +1,103 @@
<?php
namespace Tests\E2E\Services\Project;
use Tests\E2E\Client;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideServer;
use Utopia\Database\Helpers\ID;
class KeysIntegrationTest extends Scope
{
use ProjectCustom;
use SideServer;
public function testEphemeralKeyScopeEnforcement(): void
{
$projectId = $this->getProject()['$id'];
$apiKey = $this->getProject()['apiKey'];
$serverHeaders = [
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $apiKey,
];
$consoleHeaders = [
'origin' => 'http://localhost',
'content-type' => 'application/json',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
'x-appwrite-mode' => 'admin',
'x-appwrite-project' => $projectId,
];
// Step 1: Create an ephemeral key scoped to users.read only.
$ephemeralKey = $this->client->call(
Client::METHOD_POST,
'/project/keys/ephemeral',
$serverHeaders,
[
'scopes' => ['users.read'],
'duration' => 900,
]
);
$this->assertSame(201, $ephemeralKey['headers']['status-code']);
$this->assertNotEmpty($ephemeralKey['body']['secret']);
$ephemeralKeySecret = $ephemeralKey['body']['secret'];
$ephemeralHeaders = [
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $ephemeralKeySecret,
];
// Step 2: Create a project user using console headers.
$user = $this->client->call(
Client::METHOD_POST,
'/users',
$consoleHeaders,
[
'userId' => ID::unique(),
'email' => 'ephemeral_key_' . \uniqid() . '@localhost.test',
'password' => 'password1234',
'name' => 'Ephemeral Key Test User',
]
);
$this->assertSame(201, $user['headers']['status-code']);
$userId = $user['body']['$id'];
// Step 3: Ephemeral key can list users.
$list = $this->client->call(
Client::METHOD_GET,
'/users',
$ephemeralHeaders
);
$this->assertSame(200, $list['headers']['status-code']);
$this->assertGreaterThanOrEqual(1, $list['body']['total']);
// Step 4: Ephemeral key can get the specific user.
$get = $this->client->call(
Client::METHOD_GET,
'/users/' . $userId,
$ephemeralHeaders
);
$this->assertSame(200, $get['headers']['status-code']);
$this->assertSame($userId, $get['body']['$id']);
// Step 5: Ephemeral key cannot create users (missing users.write scope).
$createAttempt = $this->client->call(
Client::METHOD_POST,
'/users',
$ephemeralHeaders,
[
'userId' => ID::unique(),
'email' => 'should_fail_' . \uniqid() . '@localhost.test',
'password' => 'password1234',
'name' => 'Should Fail',
]
);
$this->assertSame(401, $createAttempt['headers']['status-code']);
}
}
@@ -3941,6 +3941,61 @@ class ProjectsConsoleClientTest extends Scope
$this->assertEmpty($response['body']);
}
// JWT Keys
public function testJWTKey(): void
{
$data = $this->setupProjectData();
$id = $data['projectId'];
// Create JWT key
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/jwts', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-response-format' => '1.9.2',
], $this->getHeaders()), [
'duration' => 5,
'scopes' => ['users.read'],
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']['jwt']);
$this->assertNotEmpty($response['body']['projectId']);
$this->assertSame($id, $response['body']['projectId']);
$jwt = $response['body']['jwt'];
// Ensure JWT key works
$response = $this->client->call(Client::METHOD_GET, '/users', [
'content-type' => 'application/json',
'x-appwrite-project' => $id,
'x-appwrite-key' => $jwt,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertArrayHasKey('users', $response['body']);
// Ensure JWT key respect scopes
$response = $this->client->call(Client::METHOD_GET, '/functions', [
'content-type' => 'application/json',
'x-appwrite-project' => $id,
'x-appwrite-key' => $jwt,
]);
$this->assertEquals(401, $response['headers']['status-code']);
// Ensure JWT key expires
\sleep(10);
$response = $this->client->call(Client::METHOD_GET, '/users', [
'content-type' => 'application/json',
'x-appwrite-project' => $id,
'x-appwrite-key' => $jwt,
]);
$this->assertEquals(401, $response['headers']['status-code']);
}
// Platforms
public function testCreateProjectPlatform(): void
@@ -335,10 +335,12 @@ class RealtimeCustomClientTest extends Scope
$this->assertArrayHasKey('data', $response);
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertCount(2, $response['data']['channels']);
$this->assertCount(4, $response['data']['channels']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertContains('account', $response['data']['channels']);
$this->assertContains('account.' . $userId, $response['data']['channels']);
$this->assertContains('account.update', $response['data']['channels']);
$this->assertContains('account.' . $userId . '.update', $response['data']['channels']);
$this->assertContains("users.{$userId}.update.name", $response['data']['events']);
$this->assertContains("users.{$userId}.update", $response['data']['events']);
$this->assertContains("users.{$userId}", $response['data']['events']);
@@ -368,10 +370,12 @@ class RealtimeCustomClientTest extends Scope
$this->assertArrayHasKey('data', $response);
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertCount(2, $response['data']['channels']);
$this->assertCount(4, $response['data']['channels']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertContains('account', $response['data']['channels']);
$this->assertContains('account.' . $userId, $response['data']['channels']);
$this->assertContains('account.update', $response['data']['channels']);
$this->assertContains('account.' . $userId . '.update', $response['data']['channels']);
$this->assertContains("users.{$userId}.update.password", $response['data']['events']);
$this->assertContains("users.{$userId}.update", $response['data']['events']);
$this->assertContains("users.{$userId}", $response['data']['events']);
@@ -401,10 +405,12 @@ class RealtimeCustomClientTest extends Scope
$this->assertArrayHasKey('data', $response);
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertCount(2, $response['data']['channels']);
$this->assertCount(4, $response['data']['channels']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertContains('account', $response['data']['channels']);
$this->assertContains('account.' . $userId, $response['data']['channels']);
$this->assertContains('account.update', $response['data']['channels']);
$this->assertContains('account.' . $userId . '.update', $response['data']['channels']);
$this->assertContains("users.{$userId}.update.email", $response['data']['events']);
$this->assertContains("users.{$userId}.update", $response['data']['events']);
$this->assertContains("users.{$userId}", $response['data']['events']);
@@ -432,11 +438,14 @@ class RealtimeCustomClientTest extends Scope
$this->assertArrayHasKey('data', $response);
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
// Nested user event (verification) — must NOT suffix the account channels.
$this->assertCount(2, $response['data']['channels']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertArrayNotHasKey('secret', $response['data']);
$this->assertContains('account', $response['data']['channels']);
$this->assertContains('account.' . $userId, $response['data']['channels']);
$this->assertNotContains('account.create', $response['data']['channels']);
$this->assertNotContains('account.' . $userId . '.create', $response['data']['channels']);
$this->assertContains("users.{$userId}.verification.{$verificationId}.create", $response['data']['events']);
$this->assertContains("users.{$userId}.verification.{$verificationId}", $response['data']['events']);
$this->assertContains("users.{$userId}.verification.*.create", $response['data']['events']);
@@ -475,10 +484,13 @@ class RealtimeCustomClientTest extends Scope
$this->assertArrayHasKey('data', $response);
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
// Nested user event (verification) — must NOT suffix the account channels.
$this->assertCount(2, $response['data']['channels']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertContains('account', $response['data']['channels']);
$this->assertContains('account.' . $userId, $response['data']['channels']);
$this->assertNotContains('account.update', $response['data']['channels']);
$this->assertNotContains('account.' . $userId . '.update', $response['data']['channels']);
$this->assertContains("users.{$userId}.verification.{$verificationId}.update", $response['data']['events']);
$this->assertContains("users.{$userId}.verification.{$verificationId}", $response['data']['events']);
$this->assertContains("users.{$userId}.verification.*.update", $response['data']['events']);
@@ -510,10 +522,12 @@ class RealtimeCustomClientTest extends Scope
$this->assertArrayHasKey('data', $response);
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertCount(2, $response['data']['channels']);
$this->assertCount(4, $response['data']['channels']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertContains('account', $response['data']['channels']);
$this->assertContains('account.' . $userId, $response['data']['channels']);
$this->assertContains('account.update', $response['data']['channels']);
$this->assertContains('account.' . $userId . '.update', $response['data']['channels']);
$this->assertContains("users.{$userId}.update.prefs", $response['data']['events']);
$this->assertContains("users.{$userId}.update", $response['data']['events']);
$this->assertContains("users.{$userId}", $response['data']['events']);
@@ -551,10 +565,13 @@ class RealtimeCustomClientTest extends Scope
$this->assertArrayHasKey('data', $response);
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
// Nested user event (sessions) — must NOT suffix the account channels.
$this->assertCount(2, $response['data']['channels']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertContains('account', $response['data']['channels']);
$this->assertContains('account.' . $userId, $response['data']['channels']);
$this->assertNotContains('account.create', $response['data']['channels']);
$this->assertNotContains('account.' . $userId . '.create', $response['data']['channels']);
$this->assertContains("users.{$userId}.sessions.{$sessionNewId}.create", $response['data']['events']);
$this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']);
$this->assertContains("users.{$userId}.sessions.*.create", $response['data']['events']);
@@ -583,10 +600,13 @@ class RealtimeCustomClientTest extends Scope
$this->assertArrayHasKey('data', $response);
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
// Nested user event (sessions) — must NOT suffix the account channels.
$this->assertCount(2, $response['data']['channels']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertContains('account', $response['data']['channels']);
$this->assertContains('account.' . $userId, $response['data']['channels']);
$this->assertNotContains('account.delete', $response['data']['channels']);
$this->assertNotContains('account.' . $userId . '.delete', $response['data']['channels']);
$this->assertContains("users.{$userId}.sessions.{$sessionNewId}.delete", $response['data']['events']);
$this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']);
$this->assertContains("users.{$userId}.sessions.*.delete", $response['data']['events']);
@@ -620,10 +640,13 @@ class RealtimeCustomClientTest extends Scope
$this->assertArrayHasKey('data', $response);
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
// Nested user event (sessions) — must NOT suffix the account channels.
$this->assertCount(2, $response['data']['channels']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertContains('account', $response['data']['channels']);
$this->assertContains('account.' . $userId, $response['data']['channels']);
$this->assertNotContains('account.delete', $response['data']['channels']);
$this->assertNotContains('account.' . $userId . '.delete', $response['data']['channels']);
$this->assertContains("users.{$userId}.sessions.{$sessionNewId}.delete", $response['data']['events']);
$this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']);
$this->assertContains("users.{$userId}.sessions.*.delete", $response['data']['events']);
@@ -661,10 +684,13 @@ class RealtimeCustomClientTest extends Scope
$this->assertArrayHasKey('data', $response);
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
// Nested user event (recovery) — must NOT suffix the account channels.
$this->assertCount(2, $response['data']['channels']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertContains('account', $response['data']['channels']);
$this->assertContains('account.' . $userId, $response['data']['channels']);
$this->assertNotContains('account.create', $response['data']['channels']);
$this->assertNotContains('account.' . $userId . '.create', $response['data']['channels']);
$this->assertContains("users.{$userId}.recovery.{$recoveryId}.create", $response['data']['events']);
$this->assertContains("users.{$userId}.recovery.{$recoveryId}", $response['data']['events']);
$this->assertContains("users.{$userId}.recovery.*.create", $response['data']['events']);
@@ -695,10 +721,13 @@ class RealtimeCustomClientTest extends Scope
$this->assertArrayHasKey('data', $response);
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
// Nested user event (recovery) — must NOT suffix the account channels.
$this->assertCount(2, $response['data']['channels']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertContains('account', $response['data']['channels']);
$this->assertContains('account.' . $userId, $response['data']['channels']);
$this->assertNotContains('account.update', $response['data']['channels']);
$this->assertNotContains('account.' . $userId . '.update', $response['data']['channels']);
$this->assertContains("users.{$userId}.recovery.{$recoveryId}.update", $response['data']['events']);
$this->assertContains("users.{$userId}.recovery.{$recoveryId}", $response['data']['events']);
$this->assertContains("users.{$userId}.recovery.*.update", $response['data']['events']);
@@ -820,7 +849,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']);
$this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']);
@@ -865,7 +894,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -921,7 +950,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -977,7 +1006,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']);
@@ -1009,7 +1038,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']);
@@ -1058,7 +1087,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
@@ -1086,7 +1115,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
@@ -1114,7 +1143,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
@@ -1151,7 +1180,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
@@ -1180,7 +1209,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
@@ -1209,7 +1238,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
@@ -1256,7 +1285,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']);
@@ -1435,7 +1464,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response1['type']);
$this->assertNotEmpty($response1['data']);
$this->assertArrayHasKey('timestamp', $response1['data']);
$this->assertCount(8, $response1['data']['channels']);
$this->assertCount(16, $response1['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.create", $response1['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.create", $response1['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response1['data']['events']);
@@ -1466,7 +1495,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response2['type']);
$this->assertNotEmpty($response2['data']);
$this->assertArrayHasKey('timestamp', $response2['data']);
$this->assertCount(8, $response2['data']['channels']);
$this->assertCount(16, $response2['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.create", $response2['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.create", $response2['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response2['data']['events']);
@@ -1516,7 +1545,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response1['type']);
$this->assertNotEmpty($response1['data']);
$this->assertArrayHasKey('timestamp', $response1['data']);
$this->assertCount(8, $response1['data']['channels']);
$this->assertCount(16, $response1['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.update", $response1['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response1['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response1['data']['events']);
@@ -1570,7 +1599,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response2['type']);
$this->assertNotEmpty($response2['data']);
$this->assertArrayHasKey('timestamp', $response2['data']);
$this->assertCount(8, $response2['data']['channels']);
$this->assertCount(16, $response2['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.update", $response2['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response2['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response2['data']['events']);
@@ -1623,7 +1652,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response1['type']);
$this->assertNotEmpty($response1['data']);
$this->assertArrayHasKey('timestamp', $response1['data']);
$this->assertCount(8, $response1['data']['channels']);
$this->assertCount(16, $response1['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.update", $response1['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response1['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response1['data']['events']);
@@ -1650,7 +1679,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response2['type']);
$this->assertNotEmpty($response2['data']);
$this->assertArrayHasKey('timestamp', $response2['data']);
$this->assertCount(8, $response2['data']['channels']);
$this->assertCount(16, $response2['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.update", $response2['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response2['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response2['data']['events']);
@@ -1689,7 +1718,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response1['type']);
$this->assertNotEmpty($response1['data']);
$this->assertArrayHasKey('timestamp', $response1['data']);
$this->assertCount(8, $response1['data']['channels']);
$this->assertCount(16, $response1['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.delete", $response1['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.delete", $response1['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response1['data']['events']);
@@ -1720,7 +1749,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response2['type']);
$this->assertNotEmpty($response2['data']);
$this->assertArrayHasKey('timestamp', $response2['data']);
$this->assertCount(8, $response2['data']['channels']);
$this->assertCount(16, $response2['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.delete", $response2['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.delete", $response2['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response2['data']['events']);
@@ -1773,7 +1802,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']);
@@ -1811,7 +1840,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']);
@@ -1953,7 +1982,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -1992,7 +2021,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -2042,7 +2071,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -2130,10 +2159,13 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains('files', $response['data']['channels']);
$this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['channels']);
$this->assertContains("buckets.{$bucketId}.files", $response['data']['channels']);
$this->assertContains('files.create', $response['data']['channels']);
$this->assertContains("buckets.{$bucketId}.files.create", $response['data']['channels']);
$this->assertContains("buckets.{$bucketId}.files.{$fileId}.create", $response['data']['channels']);
$this->assertContains("buckets.{$bucketId}.files.{$fileId}.create", $response['data']['events']);
$this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['events']);
$this->assertContains("buckets.{$bucketId}.files.*.create", $response['data']['events']);
@@ -2169,10 +2201,13 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains('files', $response['data']['channels']);
$this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['channels']);
$this->assertContains("buckets.{$bucketId}.files", $response['data']['channels']);
$this->assertContains('files.update', $response['data']['channels']);
$this->assertContains("buckets.{$bucketId}.files.update", $response['data']['channels']);
$this->assertContains("buckets.{$bucketId}.files.{$fileId}.update", $response['data']['channels']);
$this->assertContains("buckets.{$bucketId}.files.{$fileId}.update", $response['data']['events']);
$this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['events']);
$this->assertContains("buckets.{$bucketId}.files.*.update", $response['data']['events']);
@@ -2200,10 +2235,13 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains('files', $response['data']['channels']);
$this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['channels']);
$this->assertContains("buckets.{$bucketId}.files", $response['data']['channels']);
$this->assertContains('files.delete', $response['data']['channels']);
$this->assertContains("buckets.{$bucketId}.files.delete", $response['data']['channels']);
$this->assertContains("buckets.{$bucketId}.files.{$fileId}.delete", $response['data']['channels']);
$this->assertContains("buckets.{$bucketId}.files.{$fileId}.delete", $response['data']['events']);
$this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['events']);
$this->assertContains("buckets.{$bucketId}.files.*.delete", $response['data']['events']);
@@ -2320,7 +2358,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(5, $response['data']['channels']);
$this->assertCount(8, $response['data']['channels']);
$this->assertContains('console', $response['data']['channels']);
$this->assertContains("projects.{$this->getProject()['$id']}", $response['data']['channels']);
$this->assertContains('executions', $response['data']['channels']);
@@ -2343,7 +2381,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $responseUpdate['type']);
$this->assertNotEmpty($responseUpdate['data']);
$this->assertArrayHasKey('timestamp', $responseUpdate['data']);
$this->assertCount(5, $responseUpdate['data']['channels']);
$this->assertCount(8, $responseUpdate['data']['channels']);
$this->assertContains('console', $responseUpdate['data']['channels']);
$this->assertContains("projects.{$this->getProject()['$id']}", $response['data']['channels']);
$this->assertContains('executions', $responseUpdate['data']['channels']);
@@ -2418,9 +2456,11 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(2, $response['data']['channels']);
$this->assertCount(4, $response['data']['channels']);
$this->assertContains('teams', $response['data']['channels']);
$this->assertContains("teams.{$teamId}", $response['data']['channels']);
$this->assertContains('teams.create', $response['data']['channels']);
$this->assertContains("teams.{$teamId}.create", $response['data']['channels']);
$this->assertContains("teams.{$teamId}.create", $response['data']['events']);
$this->assertContains("teams.{$teamId}", $response['data']['events']);
$this->assertContains("teams.*.create", $response['data']['events']);
@@ -2447,9 +2487,11 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(2, $response['data']['channels']);
$this->assertCount(4, $response['data']['channels']);
$this->assertContains('teams', $response['data']['channels']);
$this->assertContains("teams.{$teamId}", $response['data']['channels']);
$this->assertContains('teams.update', $response['data']['channels']);
$this->assertContains("teams.{$teamId}.update", $response['data']['channels']);
$this->assertContains("teams.{$teamId}.update", $response['data']['events']);
$this->assertContains("teams.{$teamId}", $response['data']['events']);
$this->assertContains("teams.*.update", $response['data']['events']);
@@ -2480,9 +2522,11 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(2, $response['data']['channels']);
$this->assertCount(4, $response['data']['channels']);
$this->assertContains('teams', $response['data']['channels']);
$this->assertContains("teams.{$teamId}", $response['data']['channels']);
$this->assertContains('teams.update', $response['data']['channels']);
$this->assertContains("teams.{$teamId}.update", $response['data']['channels']);
$this->assertContains("teams.{$teamId}.update", $response['data']['events']);
$this->assertContains("teams.{$teamId}.update.prefs", $response['data']['events']);
$this->assertContains("teams.{$teamId}", $response['data']['events']);
@@ -2547,9 +2591,11 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(2, $response['data']['channels']);
$this->assertCount(4, $response['data']['channels']);
$this->assertContains('memberships', $response['data']['channels']);
$this->assertContains("memberships.{$membershipId}", $response['data']['channels']);
$this->assertContains('memberships.update', $response['data']['channels']);
$this->assertContains("memberships.{$membershipId}.update", $response['data']['channels']);
$this->assertContains("teams.{$teamId}.memberships.{$membershipId}.update", $response['data']['events']);
$this->assertContains("teams.{$teamId}.memberships.{$membershipId}", $response['data']['events']);
$this->assertContains("teams.{$teamId}.memberships.*.update", $response['data']['events']);
@@ -4276,7 +4322,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $rowId, $response['data']['channels']);
$this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']);
@@ -4333,7 +4379,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -4401,7 +4447,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains('rows', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['channels']);
@@ -4472,7 +4518,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']);
@@ -4518,7 +4564,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']);
@@ -4582,7 +4628,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
@@ -4624,7 +4670,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
@@ -4666,7 +4712,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
@@ -4717,7 +4763,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
@@ -4760,7 +4806,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
@@ -4789,7 +4835,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
@@ -4836,7 +4882,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']);
@@ -4957,7 +5003,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']);
$this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']);
@@ -4992,7 +5038,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -5036,7 +5082,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -5080,7 +5126,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertNotEmpty($response['data']['payload']);
$this->assertIsArray($response['data']['payload']);
$this->assertArrayHasKey('$id', $response['data']['payload']);
@@ -5098,7 +5144,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertNotEmpty($response['data']['payload']);
$this->assertIsArray($response['data']['payload']);
$this->assertArrayHasKey('$id', $response['data']['payload']);
@@ -5133,7 +5179,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
$this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']);
$this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
@@ -5161,7 +5207,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
$this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']);
$this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
@@ -5189,7 +5235,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
$this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']);
$this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
@@ -5226,7 +5272,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
$this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']);
$this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
@@ -5255,7 +5301,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
$this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']);
$this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
@@ -5284,7 +5330,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
$this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']);
$this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
@@ -5331,7 +5377,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']);
$this->assertContains("documentsdb.*.collections.*.documents.*.upsert", $response['data']['events']);
@@ -5436,7 +5482,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
// vectorsdb channels should include 3 items like documentsdb
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']);
$this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']);
@@ -5467,7 +5513,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']);
$this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']);
$this->assertNotEmpty($response['data']['payload']);
@@ -5486,7 +5532,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']);
$this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']);
@@ -5525,7 +5571,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $response['data']['payload']['$id'] . '.create', $response['data']['events']);
$this->assertContains('vectorsdb.*.collections.*.documents.*.create', $response['data']['events']);
$this->assertContains('vectorsdb.' . $databaseId . '.collections.*.documents.*.create', $response['data']['events']);
@@ -5540,7 +5586,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(3, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $response['data']['payload']['$id'] . '.create', $response['data']['events']);
$client->close();
@@ -5643,7 +5689,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']);
$this->assertNotEmpty($response['data']['payload']);
@@ -5674,7 +5720,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']);
$this->assertNotEmpty($response['data']['payload']);
@@ -2446,4 +2446,471 @@ trait RealtimeQueryBase
$clientWithMatchingQuery->close();
$clientWithNonMatchingQuery->close();
}
/**
* Sets up a database + collection + 'name' string attribute, returning their IDs.
* Used by action-channel tests to avoid duplicating fixture code.
*
* @return array{databaseId: string, collectionId: string}
*/
private function createActorsCollection(): array
{
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]), [
'databaseId' => ID::unique(),
'name' => 'Action Channel DB',
]);
$databaseId = $database['body']['$id'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]), [
'collectionId' => ID::unique(),
'name' => 'Actors',
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'documentSecurity' => true,
]);
$collectionId = $collection['body']['$id'];
$this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]), [
'key' => 'name',
'size' => 256,
'required' => true,
]);
$this->assertEventually(function () use ($databaseId, $collectionId) {
$response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/name', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]));
$this->assertEquals('available', $response['body']['status']);
}, 30000, 250);
return ['databaseId' => $databaseId, 'collectionId' => $collectionId];
}
/**
* Creates a document with the given ID and name. Returns the parsed body.
* Permissions allow Role::any() for all CRUD so any session can observe the events.
*
* @return array<string, mixed>
*/
private function createActor(string $databaseId, string $collectionId, string $documentId, string $name): array
{
$document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'documentId' => $documentId,
'data' => ['name' => $name],
'permissions' => [
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
return $document['body'];
}
public function testChannelActionFilterReflectedInConnectedResponse(): void
{
$user = $this->getUser();
$session = $user['session'] ?? '';
$projectId = $this->getProject()['$id'];
$headers = [
'origin' => 'http://localhost',
'cookie' => 'a_session_' . $projectId . '=' . $session,
];
// Subscribing with an action suffix should round-trip the original channel
// name on the connected response. Only meaningful in URL-subscribe mode —
// the message-based path consumes the connected response inside its
// getWebsocket helper before returning, so we can't observe it here.
$client = $this->getWebsocket([
'documents.create',
'documents.update',
'documents.upsert',
'documents',
], $headers);
$connected = $this->assertConnectionStatusIfSupported($client);
if ($connected === null) {
$client->close();
$this->markTestSkipped('Connected-response channels are not surfaced through the message-based subscribe path.');
}
$this->assertContains('documents.create', $connected['data']['channels']);
$this->assertContains('documents.update', $connected['data']['channels']);
$this->assertContains('documents.upsert', $connected['data']['channels']);
$this->assertContains('documents', $connected['data']['channels']);
$client->close();
}
public function testChannelActionFilterDeliversOnlyMatchingActions(): void
{
$user = $this->getUser();
$session = $user['session'] ?? '';
$projectId = $this->getProject()['$id'];
$headers = [
'origin' => 'http://localhost',
'cookie' => 'a_session_' . $projectId . '=' . $session,
];
['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection();
$createChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.create";
$updateChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.update";
$upsertChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.upsert";
$clientCreate = $this->getWebsocket([$createChannel], $headers);
$clientUpdate = $this->getWebsocket([$updateChannel], $headers);
$clientUpsert = $this->getWebsocket([$upsertChannel], $headers);
$this->assertConnectionStatusIfSupported($clientCreate);
$this->assertConnectionStatusIfSupported($clientUpdate);
$this->assertConnectionStatusIfSupported($clientUpsert);
$documentId = ID::unique();
$this->createActor($databaseId, $collectionId, $documentId, 'Chris Evans');
// Create event delivers only to the .create subscriber.
$createEvent = json_decode($clientCreate->receive(), true);
$this->assertEquals('event', $createEvent['type']);
$this->assertContains(
"databases.{$databaseId}.collections.{$collectionId}.documents.{$documentId}.create",
$createEvent['data']['events']
);
$this->assertEquals('Chris Evans', $createEvent['data']['payload']['name']);
try {
$clientUpdate->receive();
$this->fail('Update subscriber should not receive a create event.');
} catch (TimeoutException $e) {
$this->addToAssertionCount(1);
}
try {
$clientUpsert->receive();
$this->fail('Upsert subscriber should not receive a create event.');
} catch (TimeoutException $e) {
$this->addToAssertionCount(1);
}
// Update fires update events; only the .update subscriber should hear them.
$this->client->call(Client::METHOD_PATCH, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'data' => ['name' => 'Chris Evans 2'],
]);
$updateEvent = json_decode($clientUpdate->receive(), true);
$this->assertEquals('event', $updateEvent['type']);
$this->assertContains(
"databases.{$databaseId}.collections.{$collectionId}.documents.{$documentId}.update",
$updateEvent['data']['events']
);
$this->assertEquals('Chris Evans 2', $updateEvent['data']['payload']['name']);
try {
$clientCreate->receive();
$this->fail('Create subscriber should not receive an update event.');
} catch (TimeoutException $e) {
$this->addToAssertionCount(1);
}
try {
$clientUpsert->receive();
$this->fail('Upsert subscriber should not receive an update event.');
} catch (TimeoutException $e) {
$this->addToAssertionCount(1);
}
// PUT bulk upsert fires upsert events; only the .upsert subscriber should hear them.
$this->client->call(Client::METHOD_PUT, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
]), [
'documents' => [
[
'$id' => ID::unique(),
'name' => 'Robert Downey Jr.',
'$permissions' => [
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
],
],
]);
$upsertEvent = json_decode($clientUpsert->receive(), true);
$this->assertEquals('event', $upsertEvent['type']);
$this->assertContains(
"databases.{$databaseId}.collections.*.documents.*.upsert",
$upsertEvent['data']['events']
);
try {
$clientCreate->receive();
$this->fail('Create subscriber should not receive an upsert event.');
} catch (TimeoutException $e) {
$this->addToAssertionCount(1);
}
try {
$clientUpdate->receive();
$this->fail('Update subscriber should not receive an upsert event.');
} catch (TimeoutException $e) {
$this->addToAssertionCount(1);
}
$clientCreate->close();
$clientUpdate->close();
$clientUpsert->close();
}
public function testChannelActionFilterByDocumentId(): void
{
$user = $this->getUser();
$session = $user['session'] ?? '';
$projectId = $this->getProject()['$id'];
$headers = [
'origin' => 'http://localhost',
'cookie' => 'a_session_' . $projectId . '=' . $session,
];
['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection();
// Use a known custom ID so the .id.action channel can be subscribed before the
// document exists. Without this the channel name can't be predicted.
$watchedId = 'actor-watched';
$idCreateChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create";
$clientWatched = $this->getWebsocket([$idCreateChannel], $headers);
$connected = $this->assertConnectionStatusIfSupported($clientWatched);
if ($connected !== null) {
$this->assertContains($idCreateChannel, $connected['data']['channels']);
}
// Creating a *different* document should not trigger the watched-id subscription.
$this->createActor($databaseId, $collectionId, ID::unique(), 'Other Actor');
try {
$clientWatched->receive();
$this->fail('Subscriber to .{id}.create should not receive events for a different document.');
} catch (TimeoutException $e) {
$this->addToAssertionCount(1);
}
// Creating the watched document delivers exactly one create event.
$this->createActor($databaseId, $collectionId, $watchedId, 'Watched Actor');
$event = json_decode($clientWatched->receive(), true);
$this->assertEquals('event', $event['type']);
$this->assertContains(
"databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create",
$event['data']['events']
);
$this->assertEquals($watchedId, $event['data']['payload']['$id']);
$this->assertEquals('Watched Actor', $event['data']['payload']['name']);
// Updating the watched document does NOT match — action filter is `create` only.
$this->client->call(Client::METHOD_PATCH, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$watchedId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'data' => ['name' => 'Watched Actor v2'],
]);
try {
$clientWatched->receive();
$this->fail('Subscriber to .{id}.create should not receive update events on the same document.');
} catch (TimeoutException $e) {
$this->addToAssertionCount(1);
}
$clientWatched->close();
}
public function testChannelActionFilterMultiChannelSubscription(): void
{
$user = $this->getUser();
$session = $user['session'] ?? '';
$projectId = $this->getProject()['$id'];
$headers = [
'origin' => 'http://localhost',
'cookie' => 'a_session_' . $projectId . '=' . $session,
];
['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection();
$watchedId = 'actor-multi';
$idCreateChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create";
$rowsChannel = "databases.{$databaseId}.tables.{$collectionId}.rows";
// One subscription that listens on both:
// 1. `databases...documents.{watchedId}.create` — narrow, action-filtered
// 2. `databases...tables.{collectionId}.rows` — broad, non-action (tablesdb mirror)
// A create on the watched document must reach this subscriber via *both* channels.
$clientMulti = $this->getWebsocket([$idCreateChannel, $rowsChannel], $headers);
$connected = $this->assertConnectionStatusIfSupported($clientMulti);
if ($connected !== null) {
$this->assertContains($idCreateChannel, $connected['data']['channels']);
$this->assertContains($rowsChannel, $connected['data']['channels']);
}
$this->createActor($databaseId, $collectionId, $watchedId, 'Multi Actor');
$event = json_decode($clientMulti->receive(), true);
$this->assertEquals('event', $event['type']);
// The event payload's channels list reports the underlying base channels that
// the published event carries. Both the broad rows channel and the document
// channel that the action filter is anchored on should be present.
$this->assertContains($rowsChannel, $event['data']['channels']);
$this->assertContains(
"databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}",
$event['data']['channels']
);
$this->assertContains(
"databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create",
$event['data']['events']
);
$this->assertEquals('Multi Actor', $event['data']['payload']['name']);
// Update on the same doc: the .{id}.create branch is filtered out, but the
// broad rows channel has no action filter — the subscription still receives
// the event via that branch (a single delivery, not two).
$this->client->call(Client::METHOD_PATCH, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$watchedId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'data' => ['name' => 'Multi Actor v2'],
]);
$update = json_decode($clientMulti->receive(), true);
$this->assertEquals('event', $update['type']);
$this->assertContains($rowsChannel, $update['data']['channels']);
$this->assertContains(
"databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.update",
$update['data']['events']
);
// No second copy of the same update should arrive — getSubscribers folds
// multi-channel matches into a single connection delivery.
try {
$clientMulti->receive();
$this->fail('Multi-channel subscriber should receive a single delivery per event.');
} catch (TimeoutException $e) {
$this->addToAssertionCount(1);
}
$clientMulti->close();
}
public function testChannelActionFilterDeliversDeleteEvents(): void
{
$user = $this->getUser();
$session = $user['session'] ?? '';
$projectId = $this->getProject()['$id'];
$headers = [
'origin' => 'http://localhost',
'cookie' => 'a_session_' . $projectId . '=' . $session,
];
['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection();
$deleteChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.delete";
$clientDelete = $this->getWebsocket([$deleteChannel], $headers);
$connected = $this->assertConnectionStatusIfSupported($clientDelete);
if ($connected !== null) {
$this->assertContains($deleteChannel, $connected['data']['channels']);
}
$documentId = ID::unique();
$this->createActor($databaseId, $collectionId, $documentId, 'About To Be Deleted');
// Create event must not arrive — the action filter is `delete`.
try {
$clientDelete->receive();
$this->fail('Delete subscriber should not receive a create event.');
} catch (TimeoutException $e) {
$this->addToAssertionCount(1);
}
$this->client->call(Client::METHOD_DELETE, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()));
$deleteEvent = json_decode($clientDelete->receive(), true);
$this->assertEquals('event', $deleteEvent['type']);
$this->assertContains(
"databases.{$databaseId}.collections.{$collectionId}.documents.{$documentId}.delete",
$deleteEvent['data']['events']
);
$this->assertEquals($documentId, $deleteEvent['data']['payload']['$id']);
$clientDelete->close();
}
public function testChannelActionFilterUnknownSuffixTreatedAsLiteral(): void
{
$user = $this->getUser();
$session = $user['session'] ?? '';
$projectId = $this->getProject()['$id'];
$headers = [
'origin' => 'http://localhost',
'cookie' => 'a_session_' . $projectId . '=' . $session,
];
['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection();
// An unrecognised suffix is NOT in SUPPORTED_ACTIONS, so parseActionChannel
// leaves the channel name intact and treats it as a literal channel that no
// published event ever carries — the subscriber should receive nothing.
$client = $this->getWebsocket(['documents.bogus'], $headers);
$connected = $this->assertConnectionStatusIfSupported($client);
if ($connected !== null) {
$this->assertContains('documents.bogus', $connected['data']['channels']);
}
$documentId = ID::unique();
$this->createActor($databaseId, $collectionId, $documentId, 'No Bogus Listener');
$this->client->call(Client::METHOD_DELETE, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()));
try {
$client->receive();
$this->fail('Unrecognised action suffix should not deliver any events.');
} catch (TimeoutException $e) {
$this->addToAssertionCount(1);
}
$client->close();
}
}
-1
View File
@@ -350,7 +350,6 @@ trait SitesBase
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$commitData = json_decode($response, true);
@@ -2166,7 +2166,7 @@ class SitesCustomServerTest extends Scope
'previewAuthDisabled' => true,
]);
$response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [
'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey,
'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertStringContainsString("Hello Appwrite", $response['body']);
@@ -2174,7 +2174,7 @@ class SitesCustomServerTest extends Scope
$this->assertGreaterThan($contentLength, $response['headers']['content-length']);
$response = $proxyClient->call(Client::METHOD_GET, '/non-existing-path', followRedirects: false, headers: [
'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey,
'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey,
]);
$this->assertEquals(404, $response['headers']['status-code']);
$this->assertStringContainsString("Page not found", $response['body']);
@@ -3010,7 +3010,7 @@ class SitesCustomServerTest extends Scope
]);
$response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [
'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey,
'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey,
]);
$this->assertEquals(400, $response['headers']['status-code']);
$deployment = $this->getDeployment($siteId, $deploymentId);
@@ -3052,7 +3052,7 @@ class SitesCustomServerTest extends Scope
// deployment is still building error page
$response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [
'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey,
'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey,
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertStringContainsString("Deployment is still building", $response['body']);
@@ -3067,7 +3067,7 @@ class SitesCustomServerTest extends Scope
// deployment failed error page
$response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [
'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey,
'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey,
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertStringContainsString("Deployment build failed", $response['body']);
+96
View File
@@ -2708,6 +2708,102 @@ trait UsersBase
$this->assertIsArray($response['body']['users']);
}
/**
* Test impersonation via URL query params mirrors the ?project= and ?devKey= pattern.
* Allows Console to embed impersonation in direct file/image URLs where headers cannot be set.
*/
public function testImpersonateByQueryParams(): void
{
$projectId = $this->getProject()['$id'];
$headers = array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders());
$emailA = 'queryparam-impersonator-' . \uniqid() . '@appwrite.io';
$emailB = 'queryparam-target-' . \uniqid() . '@appwrite.io';
$emailC = 'queryparam-target-c-' . \uniqid() . '@appwrite.io';
$phone = '+1' . \rand(1000000000, 9999999999);
$userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [
'userId' => ID::unique(),
'email' => $emailA,
'password' => 'password',
'name' => 'Query Param Impersonator',
]);
$this->assertEquals(201, $userA['headers']['status-code']);
$idA = $userA['body']['$id'];
$userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [
'userId' => ID::unique(),
'email' => $emailB,
'password' => 'password',
'name' => 'Query Param Target',
]);
$this->assertEquals(201, $userB['headers']['status-code']);
$idB = $userB['body']['$id'];
$patch = $this->client->call(Client::METHOD_PATCH, '/users/' . $idA . '/impersonator', $headers, ['impersonator' => true]);
$this->assertEquals(200, $patch['headers']['status-code']);
$session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers);
$this->assertEquals(201, $session['headers']['status-code']);
$sessionSecret = $session['body']['secret'];
$sessionHeaders = [
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-session' => $sessionSecret,
];
// Impersonate by user ID via query param
$account = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, [
'impersonateUserId' => $idB,
]);
$this->assertEquals(200, $account['headers']['status-code']);
$this->assertEquals($idB, $account['body']['$id']);
$this->assertEquals('Query Param Target', $account['body']['name']);
$this->assertEquals($idA, $account['body']['impersonatorUserId']);
// Impersonate by email via query param
$accountByEmail = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, [
'impersonateEmail' => $emailB,
]);
$this->assertEquals(200, $accountByEmail['headers']['status-code']);
$this->assertEquals($idB, $accountByEmail['body']['$id']);
$this->assertEquals($idA, $accountByEmail['body']['impersonatorUserId']);
// Impersonate by phone via query param (update target user with a phone first)
$this->client->call(Client::METHOD_PATCH, '/users/' . $idB . '/phone', $headers, [
'number' => $phone,
]);
$accountByPhone = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, [
'impersonatePhone' => $phone,
]);
$this->assertEquals(200, $accountByPhone['headers']['status-code']);
$this->assertEquals($idB, $accountByPhone['body']['$id']);
$this->assertEquals($idA, $accountByPhone['body']['impersonatorUserId']);
// Header takes priority over query param when both are present
$userC = $this->client->call(Client::METHOD_POST, '/users', $headers, [
'userId' => ID::unique(),
'email' => $emailC,
'password' => 'password',
'name' => 'Query Param Target C',
]);
$this->assertEquals(201, $userC['headers']['status-code']);
$idC = $userC['body']['$id'];
$accountHeaderPriority = $this->client->call(
Client::METHOD_GET,
'/account',
array_merge($sessionHeaders, ['x-appwrite-impersonate-user-id' => $idC]),
['impersonateUserId' => $idB]
);
$this->assertEquals(200, $accountHeaderPriority['headers']['status-code']);
$this->assertEquals($idC, $accountHeaderPriority['body']['$id'], 'header must take priority over query param');
}
/**
* Test PATCH /users/:userId/impersonator for non-existent user returns 404
*/
@@ -1,11 +1,11 @@
{
"name": "dynamic-api-key",
"name": "ephemeral-api-key",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dynamic-api-key",
"name": "ephemeral-api-key",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
@@ -1,5 +1,5 @@
{
"name": "dynamic-api-key",
"name": "ephemeral-api-key",
"version": "1.0.0",
"main": "index.js",
"scripts": {
+12 -12
View File
@@ -14,7 +14,7 @@ class KeyTest extends TestCase
{
public function testDecode(): void
{
// Decode dynamic key
// Decode ephemeral key
$projectId = 'test';
$usage = false;
$scopes = [
@@ -36,12 +36,12 @@ class KeyTest extends TestCase
$this->assertEquals($projectId, $decoded->getProjectId());
$this->assertEquals('', $decoded->getTeamId());
$this->assertEquals('', $decoded->getUserId());
$this->assertEquals(API_KEY_DYNAMIC, $decoded->getType());
$this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType());
$this->assertEquals(User::ROLE_APPS, $decoded->getRole());
$this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes());
$this->assertEquals('Dynamic Key', $decoded->getName());
$this->assertEquals('Ephemeral Key', $decoded->getName());
// Decode dynamic key with extras
// Decode ephemeral key with extras
$extra = [
'disabledMetrics' => ['metric123'],
'hostnameOverride' => true,
@@ -60,10 +60,10 @@ class KeyTest extends TestCase
$this->assertEquals($projectId, $decoded->getProjectId());
$this->assertEquals('', $decoded->getTeamId());
$this->assertEquals('', $decoded->getUserId());
$this->assertEquals(API_KEY_DYNAMIC, $decoded->getType());
$this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType());
$this->assertEquals(User::ROLE_APPS, $decoded->getRole());
$this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes());
$this->assertEquals('Dynamic Key', $decoded->getName());
$this->assertEquals('Ephemeral Key', $decoded->getName());
$this->assertEquals(['metric123'], $decoded->getDisabledMetrics());
$this->assertEquals(true, $decoded->getHostnameOverride());
$this->assertEquals(true, $decoded->isBannerDisabled());
@@ -71,8 +71,8 @@ class KeyTest extends TestCase
$this->assertEquals(true, $decoded->isPreviewAuthDisabled());
$this->assertEquals(true, $decoded->isDeploymentStatusIgnored());
// Decode invalid dynamic key
$invalidKey = API_KEY_DYNAMIC . '_invalid_jwt_token';
// Decode invalid ephemeral key
$invalidKey = API_KEY_EPHEMERAL . '_invalid_jwt_token';
$decoded = Key::decode(
project: new Document(['$id' => $projectId]),
team: new Document(),
@@ -82,12 +82,12 @@ class KeyTest extends TestCase
$this->assertEquals($projectId, $decoded->getProjectId());
$this->assertEquals('', $decoded->getTeamId());
$this->assertEquals('', $decoded->getUserId());
$this->assertEquals(API_KEY_DYNAMIC, $decoded->getType());
$this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType());
$this->assertEquals(User::ROLE_GUESTS, $decoded->getRole());
$this->assertEquals($guestRoleScopes, $decoded->getScopes());
$this->assertEquals('UNKNOWN', $decoded->getName());
// Decode expired dynamic key
// Decode expired ephemeral key
$expiredKey = self::generateKey($projectId, $usage, $scopes, maxAge: 1, timestamp: time() - 60);
\sleep(2);
$decoded = Key::decode(
@@ -99,7 +99,7 @@ class KeyTest extends TestCase
$this->assertEquals($projectId, $decoded->getProjectId());
$this->assertEquals('', $decoded->getTeamId());
$this->assertEquals('', $decoded->getUserId());
$this->assertEquals(API_KEY_DYNAMIC, $decoded->getType());
$this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType());
$this->assertEquals(User::ROLE_GUESTS, $decoded->getRole());
$this->assertEquals($guestRoleScopes, $decoded->getScopes());
$this->assertEquals('UNKNOWN', $decoded->getName());
@@ -363,6 +363,6 @@ class KeyTest extends TestCase
'scopes' => $scopes,
], $extra));
return API_KEY_DYNAMIC . '_' . $apiKey;
return API_KEY_EPHEMERAL . '_' . $apiKey;
}
}
+508
View File
@@ -396,6 +396,248 @@ class MessagingTest extends TestCase
$this->assertArrayNotHasKey('account.456', $channels);
}
public function testConvertChannelsRewritesAccountActionSuffixes(): void
{
// Authenticated subscriber to `account.{action}` is translated to the
// user-scoped `account.{userId}.{action}` form so events from other
// users' accounts don't leak through the literal channel.
$channels = Realtime::convertChannels(
['account.create', 'account.update', 'account.upsert', 'account.delete'],
'123',
);
$this->assertArrayHasKey('account.123.create', $channels);
$this->assertArrayHasKey('account.123.update', $channels);
$this->assertArrayHasKey('account.123.upsert', $channels);
$this->assertArrayHasKey('account.123.delete', $channels);
$this->assertArrayNotHasKey('account.create', $channels);
$this->assertArrayNotHasKey('account.update', $channels);
$this->assertArrayNotHasKey('account.upsert', $channels);
$this->assertArrayNotHasKey('account.delete', $channels);
// Other-user channels and unknown action-like suffixes still get stripped.
$channels = Realtime::convertChannels(
['account.other_id', 'account.bogus', 'account.123', 'account.create'],
'123',
);
$this->assertArrayNotHasKey('account.other_id', $channels);
$this->assertArrayNotHasKey('account.bogus', $channels);
$this->assertArrayNotHasKey('account.123', $channels);
$this->assertArrayHasKey('account.123.create', $channels);
}
public function testConvertChannelsPreservesAccountActionsForGuest(): void
{
// Guests can't scope an action filter to a userId yet, so `account.{action}`
// is preserved verbatim. fromPayload publishes the unscoped `account.{action}`
// channel for top-level user events, so the guest's stored form matches and
// delivers correctly. After the connection authenticates,
// rebindAccountChannels rewrites the literal to `account.{userId}.{action}`
// so the action filter survives the auth transition.
$channels = Realtime::convertChannels(
['account.create', 'account.update', 'account.upsert', 'account.delete', 'account'],
'',
);
$this->assertArrayHasKey('account.create', $channels);
$this->assertArrayHasKey('account.update', $channels);
$this->assertArrayHasKey('account.upsert', $channels);
$this->assertArrayHasKey('account.delete', $channels);
$this->assertArrayHasKey('account', $channels);
}
public function testRebindAccountChannelsRemapsAfterReauth(): void
{
// Reauth as a different user must remap the user-scoped channels so the
// connection no longer receives the previous user's account events.
$rebound = Realtime::rebindAccountChannels(
['account.A', 'account.A.create', 'account.A.update', 'documents', 'documents.A.something'],
'A',
'B',
);
$this->assertContains('account.B', $rebound);
$this->assertContains('account.B.create', $rebound);
$this->assertContains('account.B.update', $rebound);
$this->assertNotContains('account.A', $rebound);
$this->assertNotContains('account.A.create', $rebound);
$this->assertNotContains('account.A.update', $rebound);
// Non-account channels left alone — the rewrite is precise.
$this->assertContains('documents', $rebound);
$this->assertContains('documents.A.something', $rebound);
}
public function testRebindAccountChannelsIsNoopForUnchangedUser(): void
{
// Same user → nothing to rewrite. Avoids unnecessary churn when the
// permissionsChanged path fires (roles change, userId is constant).
$channels = ['account.A', 'account.A.create', 'documents'];
$this->assertSame($channels, Realtime::rebindAccountChannels($channels, 'A', 'A'));
}
public function testRebindAccountChannelsIsNoopForEmptyTarget(): void
{
// Defensive: if a caller ever passes an empty $newUserId (e.g. a
// hypothetical in-band logout), we leave channels untouched rather than
// producing malformed `account.` strings.
$channels = ['account.A', 'account.A.create', 'account.create', 'documents'];
$this->assertSame($channels, Realtime::rebindAccountChannels($channels, 'A', ''));
$this->assertSame($channels, Realtime::rebindAccountChannels($channels, '', ''));
}
public function testRebindAccountChannelsPromotesGuestActionFilters(): void
{
// Guest connections store `account.{action}` literally (convertChannels
// preserves the form when userId is empty). On in-band authentication,
// rebindAccountChannels promotes those literals to user-scoped form so
// the action filter survives.
$rebound = Realtime::rebindAccountChannels(
['account', 'account.create', 'account.update', 'documents'],
'',
'B',
);
$this->assertContains('account.B.create', $rebound);
$this->assertContains('account.B.update', $rebound);
$this->assertNotContains('account.create', $rebound);
$this->assertNotContains('account.update', $rebound);
// Plain `account` and unrelated channels are left alone.
$this->assertContains('account', $rebound);
$this->assertContains('documents', $rebound);
}
public function testRebindAccountChannelsOnlyRemapsKnownActions(): void
{
// Defensive: only suffixes in SUPPORTED_ACTIONS are rewritten, so a
// channel like `account.A.bogus` stays intact rather than being
// silently rebound.
$rebound = Realtime::rebindAccountChannels(
['account.A.bogus', 'account.A.create'],
'A',
'B',
);
$this->assertContains('account.A.bogus', $rebound);
$this->assertContains('account.B.create', $rebound);
$this->assertNotContains('account.B.bogus', $rebound);
$this->assertNotContains('account.A.create', $rebound);
}
public function testReauthThenPermissionsChangeThenReauthPreservesAccountAction(): void
{
// Full lifecycle, mirrors the auth + permissionsChanged handler logic in
// app/realtime.php:
// 1. user A subscribes to account.create (stored as account.A.create)
// 2. in-band reauth as B → rebound to account.B.create, userId=B
// 3. permissions-change for B → userId on connection MUST stay 'B'
// so a subsequent reauth as C still has previousUserId='B'.
// 4. reauth as C → rebound to account.C.create, userId=C
$realtime = new Realtime();
// Step 1.
$aChannels = \array_keys(Realtime::convertChannels(['account.create'], 'A'));
$this->assertSame(['account.A.create'], $aChannels);
$realtime->subscribe('1', 1, 'sub-1', [Role::user(ID::custom('A'))->toString()], $aChannels, [], 'A');
$this->assertSame('A', $realtime->connections[1]['userId']);
// Step 2: A → B.
$previousUserId = $realtime->connections[1]['userId'];
$meta = $realtime->getSubscriptionMetadata(1);
$realtime->unsubscribe(1);
foreach ($meta as $subId => $sub) {
$rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'B');
$realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('B'))->toString()], $rebound, [], 'B');
}
$this->assertSame('B', $realtime->connections[1]['userId']);
$this->assertContains('account.B.create', $realtime->connections[1]['channels']);
// Step 3: permissions-change for B (userId stays 'B').
$previousUserId = $realtime->connections[1]['userId'];
$meta = $realtime->getSubscriptionMetadata(1);
$realtime->unsubscribe(1);
foreach ($meta as $subId => $sub) {
$rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'B');
$realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('B'))->toString()], $rebound, [], 'B');
}
$this->assertSame('B', $realtime->connections[1]['userId']);
$this->assertContains('account.B.create', $realtime->connections[1]['channels']);
// Step 4: B → C.
$previousUserId = $realtime->connections[1]['userId'];
$meta = $realtime->getSubscriptionMetadata(1);
$realtime->unsubscribe(1);
foreach ($meta as $subId => $sub) {
$rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'C');
$realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('C'))->toString()], $rebound, [], 'C');
}
$this->assertSame('C', $realtime->connections[1]['userId']);
$this->assertContains('account.C.create', $realtime->connections[1]['channels']);
$this->assertNotContains('account.B.create', $realtime->connections[1]['channels']);
$this->assertNotContains('account.A.create', $realtime->connections[1]['channels']);
}
public function testGuestAccountActionFilterSurvivesAuthenticationEndToEnd(): void
{
// Full lifecycle:
// 1. Guest connects, subscribes to `account.create`.
// 2. fromPayload publishes a top-level `users.B.create` event — guest
// receives it via the unscoped `account.create` broadcast channel.
// 3. Guest authenticates as B. Resubscribe goes through
// rebindAccountChannels so the same subscription is now scoped to
// `account.B.create` and only matches B's events.
$realtime = new Realtime();
// Step 1: guest subscribes. convertChannels preserves the literal form.
$guestChannels = \array_keys(Realtime::convertChannels(['account.create'], ''));
$this->assertSame(['account.create'], $guestChannels);
$realtime->subscribe('1', 1, 'sub-1', [Role::guests()->toString()], $guestChannels, [], '');
// Step 2: fromPayload publishes account.create alongside the user-scoped form.
$publish = Realtime::fromPayload(
event: 'users.B.create',
payload: new Document(['$id' => ID::custom('B')]),
);
$this->assertContains('account.create', $publish['channels']);
$this->assertContains('account.B.create', $publish['channels']);
// Guest receives the unscoped channel.
$event = [
'project' => '1',
'roles' => [Role::guests()->toString()],
'data' => [
'channels' => $publish['channels'],
'payload' => ['$id' => 'B'],
],
];
$this->assertArrayHasKey(1, $realtime->getSubscribers($event));
// Step 3: in-band auth promotes the guest to user 'B'.
$previousUserId = $realtime->connections[1]['userId'] ?? '';
$meta = $realtime->getSubscriptionMetadata(1);
$realtime->unsubscribe(1);
foreach ($meta as $subId => $sub) {
$rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'B');
$realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('B'))->toString()], $rebound, [], 'B');
}
// Literal channel is gone; user-scoped form is in place.
$this->assertNotContains('account.create', $realtime->connections[1]['channels']);
$this->assertContains('account.B.create', $realtime->connections[1]['channels']);
// B-scoped event delivers via the user-scoped channel.
$bEvent = [
'project' => '1',
'roles' => [Role::user(ID::custom('B'))->toString()],
'data' => [
'channels' => $publish['channels'],
'payload' => ['$id' => 'B'],
],
];
$this->assertArrayHasKey(1, $realtime->getSubscribers($bEvent));
}
public function testFromPayloadPermissions(): void
{
/**
@@ -517,4 +759,270 @@ class MessagingTest extends TestCase
$this->assertContains(Role::any()->toString(), $result['roles']);
$this->assertContains(Role::team('123abc')->toString(), $result['roles']);
}
public function testFromPayloadEmitsActionSuffixedChannels(): void
{
$result = Realtime::fromPayload(
event: 'databases.database_id.collections.collection_id.documents.document_id.create',
payload: new Document([
'$id' => ID::custom('document_id'),
'$collection' => ID::custom('collection_id'),
'$collectionId' => 'collection_id',
'$permissions' => [Permission::read(Role::any())],
]),
database: new Document(['$id' => ID::custom('database_id')]),
collection: new Document([
'$id' => ID::custom('collection_id'),
'$permissions' => [Permission::read(Role::any())],
])
);
// Base channels remain.
$this->assertContains('documents', $result['channels']);
$this->assertContains('databases.database_id.collections.collection_id.documents', $result['channels']);
$this->assertContains('databases.database_id.collections.collection_id.documents.document_id', $result['channels']);
// Action-suffixed variants are appended for every base channel.
$this->assertContains('documents.create', $result['channels']);
$this->assertContains('databases.database_id.collections.collection_id.documents.create', $result['channels']);
$this->assertContains('databases.database_id.collections.collection_id.documents.document_id.create', $result['channels']);
// No mismatched action suffixes leak in.
$this->assertNotContains('documents.update', $result['channels']);
$this->assertNotContains('documents.delete', $result['channels']);
}
public function testFromPayloadEmitsActionSuffixForEveryAction(): void
{
foreach (['create', 'update', 'upsert', 'delete'] as $action) {
$result = Realtime::fromPayload(
event: "databases.database_id.collections.collection_id.documents.document_id.{$action}",
payload: new Document([
'$id' => ID::custom('document_id'),
'$collection' => ID::custom('collection_id'),
'$collectionId' => 'collection_id',
'$permissions' => [Permission::read(Role::any())],
]),
database: new Document(['$id' => ID::custom('database_id')]),
collection: new Document([
'$id' => ID::custom('collection_id'),
'$permissions' => [Permission::read(Role::any())],
])
);
$this->assertContains("documents.{$action}", $result['channels'], "documents.{$action} missing");
$this->assertContains(
"databases.database_id.collections.collection_id.documents.document_id.{$action}",
$result['channels'],
"specific-doc {$action} channel missing"
);
}
}
public function testFromPayloadDoesNotSuffixWhenNoAction(): void
{
// Synthetic event without an action segment: e.g. an attribute event whose
// last segment is not a known action and whose second-to-last segment is
// also not a known action.
$result = Realtime::fromPayload(
event: 'buckets.bucket_id.files.file_id.update',
payload: new Document([
'$id' => ID::custom('file_id'),
'bucketId' => 'bucket_id',
'$permissions' => [Permission::read(Role::any())],
]),
bucket: new Document([
'$id' => ID::custom('bucket_id'),
'$permissions' => [Permission::read(Role::any())],
])
);
// Action-suffixed variants for the file event.
$this->assertContains('files.update', $result['channels']);
$this->assertContains('buckets.bucket_id.files.update', $result['channels']);
$this->assertContains('buckets.bucket_id.files.file_id.update', $result['channels']);
// Base channels remain.
$this->assertContains('files', $result['channels']);
$this->assertContains('buckets.bucket_id.files', $result['channels']);
$this->assertContains('buckets.bucket_id.files.file_id', $result['channels']);
}
public function testFromPayloadDoesNotSuffixAdminChannels(): void
{
// Function execution event emits resource-leaf channels (executions / functions)
// alongside admin channels (console / projects.X). Admin channels must NOT
// get an action suffix — only the resource-leaf channels do.
$result = Realtime::fromPayload(
event: 'functions.function_id.executions.execution_id.create',
payload: new Document([
'$id' => ID::custom('execution_id'),
'functionId' => 'function_id',
'$read' => [Role::any()->toString()],
'$permissions' => [Permission::read(Role::any())],
]),
project: new Document([
'$id' => ID::custom('project_id'),
'teamId' => '123abc',
])
);
// Resource-leaf channels are suffixed.
$this->assertContains('executions', $result['channels']);
$this->assertContains('executions.create', $result['channels']);
$this->assertContains('executions.execution_id', $result['channels']);
$this->assertContains('executions.execution_id.create', $result['channels']);
$this->assertContains('functions.function_id', $result['channels']);
$this->assertContains('functions.function_id.create', $result['channels']);
// Admin channels are NOT suffixed.
$this->assertContains('console', $result['channels']);
$this->assertNotContains('console.create', $result['channels']);
$this->assertContains('projects.project_id', $result['channels']);
$this->assertNotContains('projects.project_id.create', $result['channels']);
// The bare `functions` channel is never emitted by fromPayload (only
// `functions.{functionId}` is). The per-function action variant
// (`functions.{functionId}.create`) is the supported subscription
// form — bare `functions.create` would be a silent no-op and must
// therefore NOT appear in the published channel set either.
$this->assertNotContains('functions', $result['channels']);
$this->assertNotContains('functions.create', $result['channels']);
}
public function testFromPayloadHandlesAttributeTrailingActionEvents(): void
{
// `users.[userId].update.{attr}` (e.g. .email, .prefs, .name) — action is the
// second-to-last segment, not the last one. The suffix must still be `.update`.
$userResult = Realtime::fromPayload(
event: 'users.user_id.update.email',
payload: new Document(['$id' => ID::custom('user_id')])
);
$this->assertContains('account', $userResult['channels']);
$this->assertContains('account.user_id', $userResult['channels']);
$this->assertContains('account.update', $userResult['channels']);
$this->assertContains('account.user_id.update', $userResult['channels']);
// The attribute name must NOT leak into the channel namespace.
$this->assertNotContains('account.email', $userResult['channels']);
$this->assertNotContains('account.user_id.email', $userResult['channels']);
// `teams.[teamId].update.prefs` — same shape at the team level.
$teamResult = Realtime::fromPayload(
event: 'teams.team_id.update.prefs',
payload: new Document(['$id' => ID::custom('team_id')])
);
$this->assertContains('teams', $teamResult['channels']);
$this->assertContains('teams.team_id', $teamResult['channels']);
$this->assertContains('teams.update', $teamResult['channels']);
$this->assertContains('teams.team_id.update', $teamResult['channels']);
$this->assertNotContains('teams.prefs', $teamResult['channels']);
$this->assertNotContains('teams.team_id.prefs', $teamResult['channels']);
// `teams.[teamId].memberships.[membershipId].update.{attr}` — same again, deeper.
$membershipResult = Realtime::fromPayload(
event: 'teams.team_id.memberships.membership_id.update.status',
payload: new Document(['$id' => ID::custom('membership_id')])
);
$this->assertContains('memberships', $membershipResult['channels']);
$this->assertContains('memberships.membership_id', $membershipResult['channels']);
$this->assertContains('memberships.update', $membershipResult['channels']);
$this->assertContains('memberships.membership_id.update', $membershipResult['channels']);
$this->assertNotContains('memberships.status', $membershipResult['channels']);
$this->assertNotContains('memberships.membership_id.status', $membershipResult['channels']);
}
public function testFromPayloadDoesNotSuffixAccountForNestedUserEvents(): void
{
// Nested user events (challenges/sessions/recovery/verification) emit only
// user-level account channels in fromPayload. The trailing action belongs to
// the nested resource, NOT to the user account. A subscriber to
// `account.create` must not receive `users.U.challenges.C.create` or
// `users.U.sessions.S.delete` events — that would silently leak unrelated
// MFA / session traffic into account-level filters.
foreach (['challenges', 'sessions', 'recovery', 'verification'] as $sub) {
foreach (['create', 'update', 'delete'] as $action) {
$result = Realtime::fromPayload(
event: "users.user_id.{$sub}.sub_id.{$action}",
payload: new Document(['$id' => ID::custom('sub_id')])
);
$this->assertContains('account', $result['channels'], "{$sub}.{$action} should still emit base account channel");
$this->assertContains('account.user_id', $result['channels'], "{$sub}.{$action} should still emit user-scoped account channel");
$this->assertNotContains("account.{$action}", $result['channels'], "{$sub}.{$action} must NOT leak action suffix onto account channel");
$this->assertNotContains("account.user_id.{$action}", $result['channels'], "{$sub}.{$action} must NOT leak action suffix onto user-scoped account channel");
}
}
// Top-level user events SHOULD still suffix — guard against an over-eager fix
// that suppresses the suffix for legitimate account-level CRUD.
$createResult = Realtime::fromPayload(
event: 'users.user_id.create',
payload: new Document(['$id' => ID::custom('user_id')])
);
$this->assertContains('account.create', $createResult['channels']);
$this->assertContains('account.user_id.create', $createResult['channels']);
$updateResult = Realtime::fromPayload(
event: 'users.user_id.update.email',
payload: new Document(['$id' => ID::custom('user_id')])
);
$this->assertContains('account.update', $updateResult['channels']);
$this->assertContains('account.user_id.update', $updateResult['channels']);
}
public function testActionSuffixDeliversOnlyMatchingActionEndToEnd(): void
{
$realtime = new Realtime();
// Subscriber A scopes to creates; Subscriber B scopes to deletes.
$realtime->subscribe('1', 1, 'sub-create', [Role::any()->toString()], ['documents.create']);
$realtime->subscribe('1', 2, 'sub-delete', [Role::any()->toString()], ['documents.delete']);
// Simulate what fromPayload would publish for a create event.
$createEvent = [
'project' => '1',
'roles' => [Role::any()->toString()],
'data' => [
'channels' => ['documents', 'documents.create'],
'payload' => ['$id' => 'doc'],
],
];
$createReceivers = $realtime->getSubscribers($createEvent);
$this->assertArrayHasKey(1, $createReceivers);
$this->assertArrayNotHasKey(2, $createReceivers);
// Delete event.
$deleteEvent = [
'project' => '1',
'roles' => [Role::any()->toString()],
'data' => [
'channels' => ['documents', 'documents.delete'],
'payload' => ['$id' => 'doc'],
],
];
$deleteReceivers = $realtime->getSubscribers($deleteEvent);
$this->assertArrayHasKey(2, $deleteReceivers);
$this->assertArrayNotHasKey(1, $deleteReceivers);
}
public function testPlainChannelStillReceivesAllActionsEndToEnd(): void
{
$realtime = new Realtime();
$realtime->subscribe('1', 1, 'sub-all', [Role::any()->toString()], ['documents']);
foreach (['create', 'update', 'upsert', 'delete'] as $action) {
$event = [
'project' => '1',
'roles' => [Role::any()->toString()],
'data' => [
'channels' => ['documents', "documents.{$action}"],
'payload' => ['$id' => 'doc'],
],
];
$this->assertArrayHasKey(1, $realtime->getSubscribers($event), "plain `documents` should match {$action} event");
}
}
}