diff --git a/.claude/parallel-chunk-upload-storage-plan.md b/.claude/parallel-chunk-upload-storage-plan.md
new file mode 100644
index 0000000000..289fa97f5a
--- /dev/null
+++ b/.claude/parallel-chunk-upload-storage-plan.md
@@ -0,0 +1,174 @@
+# Parallel Chunk Upload Support for utopia-php/storage
+
+## Context
+
+The Appwrite API now supports out-of-order chunked uploads (chunks can arrive in any sequence). The next step is **parallel uploads** — multiple chunks uploaded simultaneously via separate HTTP requests. The SDK guarantees the first chunk is sent before any parallel chunks, so the document creation race is handled at the API layer. However, the storage device layer has a race condition that must be fixed.
+
+## Problem: `Local::joinChunks()` Race
+
+When two requests upload the final missing chunks in parallel, both can observe `countChunks() == $chunks` and call `joinChunks()` simultaneously.
+
+### Current behavior (loser throws)
+
+```php
+// Local::joinChunks()
+$dest = \fopen($tmpAssemble, 'wb');
+// ... stream all parts into $tmpAssemble ...
+
+if (! \rename($tmpAssemble, $path)) {
+ \unlink($tmpAssemble);
+ throw new Exception('Failed to finalize assembled file '.$path);
+}
+```
+
+The winner succeeds with `rename()`. The loser gets `false` from `rename()` (file already exists at `$path`) and throws a 500-error exception. The client that lost the race receives an error even though the file is fully assembled.
+
+### Required behavior
+
+If `$path` already exists, another request already assembled the file. The loser should **silently succeed** — the file is complete, nothing more to do.
+
+## Proposed Changes
+
+### 1. `Local::joinChunks()` — Handle assembly race
+
+Before opening `$tmpAssemble`, check if the final file already exists. If it does, skip assembly entirely.
+
+```php
+private function joinChunks(string $path, int $chunks): void
+{
+ // Race winner already assembled the file
+ if (\file_exists($path)) {
+ return;
+ }
+
+ $tmp = \dirname($path).DIRECTORY_SEPARATOR.'tmp_'.asename($path);
+ $tmpAssemble = \dirname($path).DIRECTORY_SEPARATOR.'tmp_assemble_'.asename($path);
+
+ // ... rest of assembly logic ...
+
+ if (! \rename($tmpAssemble, $path)) {
+ // Another request may have won the race between fclose and rename
+ if (\file_exists($path)) {
+ \unlink($tmpAssemble);
+ return;
+ }
+ \unlink($tmpAssemble);
+ throw new Exception('Failed to finalize assembled file '.$path);
+ }
+
+ // ... cleanup ...
+}
+```
+
+### 2. `Local::countChunks()` — Reliability under concurrent writes
+
+`countChunks()` uses `glob()` on the temp directory. Under heavy parallel load, `glob()` might miss files or return inconsistent counts. The current implementation is already fairly robust (it validates `.part.\d+` suffix), but we should document that the return value is a best-effort snapshot.
+
+No code change needed here unless tests reveal issues.
+
+### 3. Tests — Concurrent chunk uploads
+
+Add a test that simulates two parallel requests completing a multi-chunk upload:
+
+```php
+public function testParallelChunkUpload(): void
+{
+ $storage = $this->makeJoinTestStorage();
+ $dest = $storage->getRoot().DIRECTORY_SEPARATOR.'parallel.dat';
+
+ // Upload chunk 1 (creates temp directory)
+ $storage->uploadData('AAAA', $dest, 'application/octet-stream', 1, 2);
+
+ // Simulate two parallel requests uploading the last chunk
+ // In a real test, use pcntl_fork() or pthreads for true concurrency
+ // For the test suite, sequential calls are sufficient if we verify
+ // the second call doesn't throw after the first completed assembly
+ $storage->uploadData('BBBB', $dest, 'application/octet-stream', 2, 2);
+
+ // Verify file exists and is correct
+ $this->assertTrue(\file_exists($dest));
+ $this->assertSame('AAAABBBB', \file_get_contents($dest));
+
+ // Verify second assembly attempt doesn't throw
+ // (This simulates the race where another request already assembled)
+ try {
+ $storage->uploadData('BBBB', $dest, 'application/octet-stream', 2, 2);
+ } catch (\Exception $e) {
+ $this->fail('Duplicate assembly should not throw: '.$e->getMessage());
+ }
+
+ $storage->delete($storage->getRoot(), true);
+}
+```
+
+A more realistic concurrent test using `pcntl_fork()`:
+
+```php
+public function testParallelChunkUploadWithFork(): void
+{
+ if (!\function_exists('pcntl_fork')) {
+ $this->markTestSkipped('pcntl extension required for fork-based concurrency test');
+ }
+
+ $storage = $this->makeJoinTestStorage();
+ $dest = $storage->getRoot().DIRECTORY_SEPARATOR.'parallel-fork.dat';
+
+ // Pre-upload chunk 1
+ $storage->uploadData('AAAA', $dest, 'application/octet-stream', 1, 2);
+
+ $pid = pcntl_fork();
+ if ($pid === -1) {
+ $this->fail('Failed to fork');
+ } elseif ($pid === 0) {
+ // Child process: upload chunk 2
+ try {
+ $storage->uploadData('BBBB', $dest, 'application/octet-stream', 2, 2);
+ exit(0);
+ } catch (\Exception $e) {
+ exit(1);
+ }
+ }
+
+ // Parent process: also upload chunk 2 (race condition)
+ $parentSuccess = true;
+ try {
+ $storage->uploadData('BBBB', $dest, 'application/octet-stream', 2, 2);
+ } catch (\Exception $e) {
+ $parentSuccess = false;
+ }
+
+ pcntl_waitpid($pid, $status);
+ $childSuccess = pcntl_wexitstatus($status) === 0;
+
+ // At least one should succeed
+ $this->assertTrue($parentSuccess || $childSuccess, 'At least one parallel upload should succeed');
+
+ // File should be correctly assembled
+ $this->assertTrue(\file_exists($dest));
+ $this->assertSame('AAAABBBB', \file_get_contents($dest));
+
+ $storage->delete($storage->getRoot(), true);
+}
+```
+
+## S3 Device
+
+S3 already handles out-of-order multipart uploads natively. The `completeMultipartUpload` call with `ksort()` sorts parts by number regardless of upload order. However, parallel `completeMultipartUpload` calls for the same `uploadId` would still be problematic.
+
+This is an **API-layer concern** — the Appwrite API should ensure only one request calls `completeMultipartUpload` per upload. The S3 device itself does not need changes.
+
+## Files to Change
+
+| File | Change |
+|------|--------|
+| `src/Storage/Device/Local.php` | Add `file_exists($path)` guard at start of `joinChunks()` and in `rename()` failure handler |
+| `tests/Storage/Device/LocalTest.php` | Add `testParallelChunkUpload` and `testParallelChunkUploadWithFork` |
+
+## Backwards Compatibility
+
+Fully backwards compatible. The change only affects the error path when `rename()` fails due to an existing file. Previously it threw; now it returns silently. No public API signatures change.
+
+## Related PRs
+
+- Appwrite server PR: https://github.com/appwrite/appwrite/pull/12138 (out-of-order upload support)
+- This storage PR is a prerequisite for the follow-up Appwrite PR that enables parallel chunk uploads at the API level.
diff --git a/.env b/.env
index 9abfa756e1..4a6a3ac344 100644
--- a/.env
+++ b/.env
@@ -47,6 +47,8 @@ _APP_DB_SCHEMA=appwrite
_APP_DB_USER=user
_APP_DB_PASS=password
_APP_DB_ROOT_PASS=rootsecretpassword
+_APP_DATABASE_SHARED_TABLES=
+_APP_DATABASE_SHARED_NAMESPACE=
_APP_DB_ADAPTER_DOCUMENTSDB=mongodb
_APP_DB_HOST_DOCUMENTSDB=mongodb
_APP_DB_PORT_DOCUMENTSDB=27017
@@ -146,3 +148,5 @@ _APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main
_APP_TRUSTED_HEADERS=x-forwarded-for
_APP_POOL_ADAPTER=stack
_APP_WORKER_SCREENSHOTS_ROUTER=http://appwrite
+_TESTS_OAUTH2_GITHUB_CLIENT_ID=
+_TESTS_OAUTH2_GITHUB_CLIENT_SECRET=
diff --git a/.github/workflows/ai-moderator.yml b/.github/workflows/ai-moderator.yml
index 483f3dbeee..948fa6c0c1 100644
--- a/.github/workflows/ai-moderator.yml
+++ b/.github/workflows/ai-moderator.yml
@@ -25,6 +25,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: AI Moderator
- uses: github/ai-moderator@v1
+ uses: github/ai-moderator@81159c370785e295c97461ade67d7c33576e9319 # v1.1.4
with:
token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/auto-label-issue.yml b/.github/workflows/auto-label-issue.yml
index e0eb0de98d..0151c2f9c1 100644
--- a/.github/workflows/auto-label-issue.yml
+++ b/.github/workflows/auto-label-issue.yml
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Issue Labeler
- uses: github/issue-labeler@v3.4
+ uses: github/issue-labeler@c1b0f9f52a63158c4adc09425e858e87b32e9685 # v3.4
with:
configuration-path: .github/labeler.yml
enable-versioned-regex: false
diff --git a/.github/workflows/benchmark-comment.js b/.github/workflows/benchmark-comment.js
new file mode 100644
index 0000000000..f25116c4f2
--- /dev/null
+++ b/.github/workflows/benchmark-comment.js
@@ -0,0 +1,349 @@
+const fs = require('fs');
+
+const marker = '';
+const serviceLabels = ['Account', 'TablesDB', 'Storage', 'Functions'];
+
+module.exports = async ({ github, context, core }) => {
+ const body = buildComment(core);
+ fs.writeFileSync('benchmark-comment.txt', body);
+
+ const pullRequest = context.payload.pull_request;
+ if (!pullRequest || pullRequest.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) {
+ return;
+ }
+
+ const comments = await github.paginate(github.rest.issues.listComments, {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: pullRequest.number,
+ per_page: 100,
+ });
+
+ const existing = comments.find((comment) => {
+ return comment.user?.type === 'Bot' && comment.body?.includes(marker);
+ }) || comments.find((comment) => {
+ return comment.user?.type === 'Bot' && comment.body?.includes('Benchmark results');
+ });
+
+ if (existing) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: existing.id,
+ body,
+ });
+ return;
+ }
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: pullRequest.number,
+ body,
+ });
+};
+
+function buildComment(core) {
+ const before = readSummary('benchmark-before-summary.json', core);
+ const after = readSummary('benchmark-after-summary.json', core);
+ const beforeSamples = readSamples('benchmark-before-samples.json', core);
+ const afterSamples = readSamples('benchmark-after-samples.json', core);
+ const baseRef = markdownText(process.env.BENCHMARK_BASE_REF || 'base');
+ const headRef = markdownText(process.env.BENCHMARK_HEAD_REF || 'head');
+ const rows = benchmarkRows(before, after, beforeSamples, afterSamples);
+ const topWaits = topSamples(afterSamples, 'appwrite_api_waiting', 3);
+ const lines = [
+ marker,
+ '## :sparkles: Benchmark results',
+ '',
+ `Comparing ${baseRef} (before) to ${headRef} (after).`,
+ '',
+ ];
+
+ if (before === null) {
+ lines.push('> Before benchmark did not complete; showing current branch metrics only.', '');
+ }
+ if (after === null) {
+ lines.push('> Current branch benchmark did not complete; showing available metrics only.', '');
+ }
+
+ lines.push(
+ '**Before**',
+ '',
+ metricTable(rows, 'before'),
+ '',
+ '**After**',
+ '',
+ metricTable(rows, 'after'),
+ '',
+ '**Delta**',
+ '',
+ '| Scenario | P95 delta (ms) |',
+ '| --- | ---: |',
+ ...rows.map(deltaRow),
+ '',
+ '',
+ 'Top API waits
',
+ '',
+ '
',
+ '',
+ '| API request | Max wait (ms) |',
+ '| --- | ---: |',
+ ...topWaitRows(topWaits),
+ '',
+ ' ',
+ );
+
+ return `${lines.join('\n')}\n`;
+}
+
+function readSummary(path, core) {
+ if (!fs.existsSync(path)) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(fs.readFileSync(path, 'utf8'));
+ } catch (error) {
+ core?.warning(`Invalid benchmark summary ${path}: ${error.message}`);
+ return null;
+ }
+}
+
+function readSamples(path, core) {
+ if (!fs.existsSync(path)) {
+ return [];
+ }
+
+ const contents = fs.readFileSync(path, 'utf8').trim();
+ if (contents === '') {
+ return [];
+ }
+
+ return contents
+ .split('\n')
+ .filter(Boolean)
+ .flatMap((line) => {
+ try {
+ return [JSON.parse(line)];
+ } catch (error) {
+ core?.warning(`Invalid benchmark sample in ${path}: ${error.message}`);
+ return [];
+ }
+ });
+}
+
+function benchmarkRows(before, after, beforeSamples, afterSamples) {
+ const beforeServices = serviceStats(beforeSamples);
+ const afterServices = serviceStats(afterSamples);
+ return [
+ {
+ label: 'API total',
+ before: apiSampleStats(beforeSamples) || summaryStats(before, 'appwrite_api_duration'),
+ after: apiSampleStats(afterSamples) || summaryStats(after, 'appwrite_api_duration'),
+ },
+ ...serviceLabels.map((label) => ({
+ label,
+ before: beforeServices.get(label) || null,
+ after: afterServices.get(label) || null,
+ })),
+ ];
+}
+
+function summaryStats(summary, durationMetric, iterationsMetric = null, rpsMetric = null) {
+ const values = metricValues(summary, durationMetric);
+ if (!values) {
+ return null;
+ }
+
+ return {
+ p50: values.med ?? null,
+ p95: values['p(95)'] ?? null,
+ iterations: iterationsMetric ? metricValue(summary, iterationsMetric, 'count') : values.count ?? null,
+ rps: rpsMetric ? metricValue(summary, rpsMetric, 'rate') : null,
+ };
+}
+
+function serviceStats(samples) {
+ const apiSamples = samples.filter((sample) => {
+ return sample.metric === 'appwrite_api_duration' && typeof sample.data?.value === 'number';
+ });
+ const groups = new Map();
+
+ for (const sample of apiSamples) {
+ const service = serviceFromName(sample.data.tags?.name || '');
+ if (!service) {
+ continue;
+ }
+
+ const serviceSamples = groups.get(service) || [];
+ serviceSamples.push(sample);
+ groups.set(service, serviceSamples);
+ }
+
+ return new Map([...groups.entries()].map(([service, serviceSamples]) => {
+ const values = serviceSamples.map((sample) => sample.data.value);
+ const durationSeconds = sampleWindowSeconds(serviceSamples);
+ return [service, {
+ p50: percentile(values, 50),
+ p95: percentile(values, 95),
+ iterations: values.length,
+ rps: durationSeconds ? values.length / durationSeconds : null,
+ }];
+ }));
+}
+
+function apiSampleStats(samples) {
+ const apiSamples = samples.filter((sample) => {
+ return sample.metric === 'appwrite_api_duration' && typeof sample.data?.value === 'number';
+ });
+ const values = apiSamples.map((sample) => sample.data.value);
+ if (values.length === 0) {
+ return null;
+ }
+
+ const durationSeconds = sampleWindowSeconds(apiSamples);
+ return {
+ p50: percentile(values, 50),
+ p95: percentile(values, 95),
+ iterations: values.length,
+ rps: durationSeconds ? values.length / durationSeconds : null,
+ };
+}
+
+function serviceFromName(name) {
+ if (name.startsWith('account.')) {
+ return 'Account';
+ }
+ if (name.startsWith('tablesdb.')) {
+ return 'TablesDB';
+ }
+ if (name.startsWith('storage.') || name.startsWith('tokens.')) {
+ return 'Storage';
+ }
+ if (name.startsWith('functions.')) {
+ return 'Functions';
+ }
+ return null;
+}
+
+function sampleWindowSeconds(samples) {
+ const times = samples
+ .map((sample) => Date.parse(sample.data?.time))
+ .filter((value) => !Number.isNaN(value));
+ if (times.length < 2) {
+ return null;
+ }
+
+ return Math.max((Math.max(...times) - Math.min(...times)) / 1000, 1);
+}
+
+function percentile(values, percentileValue) {
+ if (values.length === 0) {
+ return null;
+ }
+
+ const sorted = [...values].sort((left, right) => left - right);
+ const index = Math.ceil((percentileValue / 100) * sorted.length) - 1;
+ return sorted[Math.max(0, Math.min(index, sorted.length - 1))];
+}
+
+function metricValues(data, metric) {
+ return data?.metrics?.[metric]?.values ?? null;
+}
+
+function metricValue(data, metric, stat) {
+ return metricValues(data, metric)?.[stat] ?? null;
+}
+
+function metricTable(rows, side) {
+ return [
+ '| Scenario | P50 (ms) | P95 (ms) | Requests | RPS |',
+ '| --- | ---: | ---: | ---: | ---: |',
+ ...rows.map((row) => metricRow(row, side)),
+ ].join('\n');
+}
+
+function metricRow(row, side) {
+ const values = row[side];
+ return `| ${row.label} | ${formatMs(values?.p50)} | ${formatMs(values?.p95)} | ${formatCount(values?.iterations)} | ${formatRate(values?.rps)} |`;
+}
+
+function deltaRow(row) {
+ return `| ${row.label} | ${formatDelta(row.before?.p95, row.after?.p95)} |`;
+}
+
+function topSamples(samples, metric, limit) {
+ const byName = samples.reduce((result, sample) => {
+ if (sample.metric !== metric || typeof sample.data?.value !== 'number') {
+ return result;
+ }
+
+ const name = sample.data.tags?.name || 'unknown';
+ const current = result.get(name);
+ if (!current || sample.data.value > current.value) {
+ result.set(name, { name, value: sample.data.value });
+ }
+
+ return result;
+ }, new Map());
+
+ return [...byName.values()]
+ .sort((left, right) => right.value - left.value)
+ .slice(0, limit);
+}
+
+function topWaitRows(samples) {
+ if (samples.length === 0) {
+ return ['| n/a | n/a |'];
+ }
+
+ return samples.map((sample) => {
+ return `| ${markdownText(sample.name).replace(/\|/g, '\\|')} | ${formatMs(sample.value)} |`;
+ });
+}
+
+function markdownText(value) {
+ return String(value || '').replace(/[\r\n]/g, ' ').replace(/[&<>"']/g, (char) => {
+ return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char];
+ });
+}
+
+function formatMs(value) {
+ return formatNumber(value, 2);
+}
+
+function formatRate(value) {
+ return formatNumber(value, 2);
+}
+
+function formatCount(value) {
+ if (value === null || value === undefined || Number.isNaN(value)) {
+ return 'n/a';
+ }
+
+ return `${Math.round(value)}`;
+}
+
+function formatDelta(before, after) {
+ if (before === null || before === undefined || after === null || after === undefined || Number.isNaN(before) || Number.isNaN(after)) {
+ return 'n/a';
+ }
+
+ const difference = Number((after - before).toFixed(2));
+ return `${difference > 0 ? '+' : ''}${trimNumber(difference)}`;
+}
+
+function formatNumber(value, decimals) {
+ if (value === null || value === undefined || Number.isNaN(value)) {
+ return 'n/a';
+ }
+
+ return trimNumber(Number(value).toFixed(decimals));
+}
+
+function trimNumber(value) {
+ const text = String(value);
+ const trimmed = text.includes('.') ? text.replace(/\.?0+$/, '') : text;
+ return trimmed === '' ? '0' : trimmed;
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 494a9c1424..08804bd723 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,6 +7,8 @@ concurrency:
env:
COMPOSE_FILE: docker-compose.yml
IMAGE: appwrite-dev
+ REGISTRY_IMAGE: ghcr.io/${{ github.repository }}/appwrite-dev
+ K6_VERSION: '0.53.0'
on:
pull_request:
@@ -18,6 +20,10 @@ on:
type: string
default: ''
+permissions:
+ contents: read
+ packages: write
+
jobs:
dependencies:
name: Checks / Dependencies
@@ -26,7 +32,7 @@ jobs:
actions: read
security-events: write
contents: read
- uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@v2.3.3"
+ uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
security:
name: Checks / Image
@@ -37,13 +43,13 @@ jobs:
security-events: write
steps:
- name: Check out code
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
submodules: 'recursive'
- name: Build the Docker image
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
push: false
@@ -52,7 +58,7 @@ jobs:
target: production
- name: Run Trivy vulnerability scanner on image
- uses: aquasecurity/trivy-action@0.35.0
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: 'pr_image:${{ github.sha }}'
format: 'sarif'
@@ -60,7 +66,7 @@ jobs:
severity: 'CRITICAL,HIGH'
- name: Run Trivy vulnerability scanner on source code
- uses: aquasecurity/trivy-action@0.35.0
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: 'fs'
scan-ref: '.'
@@ -70,14 +76,14 @@ jobs:
skip-setup-trivy: true
- name: Upload image scan results
- uses: github/codeql-action/upload-sarif@v4
+ uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
if: always() && hashFiles('trivy-image-results.sarif') != ''
with:
sarif_file: 'trivy-image-results.sarif'
category: 'trivy-image'
- name: Upload source code scan results
- uses: github/codeql-action/upload-sarif@v4
+ uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
if: always() && hashFiles('trivy-fs-results.sarif') != ''
with:
sarif_file: 'trivy-fs-results.sarif'
@@ -88,10 +94,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup PHP
- uses: shivammathur/setup-php@v2
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version: '8.3'
tools: composer:v2
@@ -113,7 +119,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 2
@@ -121,7 +127,7 @@ jobs:
if: github.event_name == 'pull_request'
- name: Setup PHP
- uses: shivammathur/setup-php@v2
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version: '8.3'
tools: composer:v2
@@ -138,10 +144,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup PHP
- uses: shivammathur/setup-php@v2
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version: '8.3'
tools: composer:v2
@@ -151,7 +157,7 @@ jobs:
run: composer install --prefer-dist --no-progress --ignore-platform-reqs
- name: Cache PHPStan result cache
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .phpstan-cache
key: phpstan-${{ github.sha }}
@@ -166,10 +172,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup PHP
- uses: shivammathur/setup-php@v2
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version: '8.3'
extensions: swoole
@@ -187,10 +193,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
@@ -206,11 +212,11 @@ jobs:
steps:
- name: Generate matrix
id: generate
- uses: actions/github-script@v8
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const allDatabases = ['MariaDB', 'PostgreSQL', 'MongoDB'];
- const allModes = ['dedicated', 'shared_v1', 'shared_v2'];
+ const allModes = ['dedicated', 'shared'];
const defaultDatabases = ['MongoDB'];
const defaultModes = ['dedicated'];
@@ -247,42 +253,40 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v4
+ - name: Login to GHCR
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
- - name: Build Appwrite
- uses: docker/build-push-action@v6
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
+
+ - name: Build and push Appwrite
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
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
@@ -290,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
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Login to GHCR
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ 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
@@ -317,7 +327,7 @@ jobs:
run: docker compose exec -T appwrite vars
- name: Run Unit Tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
@@ -337,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
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Login to GHCR
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ 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
@@ -369,7 +385,7 @@ jobs:
done
- name: Run General Tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
@@ -390,11 +406,12 @@ jobs:
e2e_service:
name: Tests / E2E / ${{ matrix.database }} (${{ matrix.mode }}) / ${{ matrix.service }}
- runs-on: ${{ matrix.runner || 'ubuntu-latest' }}
+ runs-on: ${{ matrix.runner || format('runs-on={0}/runner=4cpu-linux-x64/volume=120g/spot=false', github.run_id) }}
needs: [build, matrix]
permissions:
contents: read
pull-requests: write
+ packages: read
strategy:
fail-fast: false
matrix:
@@ -410,6 +427,7 @@ jobs:
FunctionsSchedule,
GraphQL,
Health,
+ Advisor,
Locale,
Projects,
Realtime,
@@ -424,33 +442,28 @@ jobs:
VCS,
Messaging,
Migrations,
- Project
+ Project,
+ Presences
]
include:
- service: Databases
- runner: blacksmith-4vcpu-ubuntu-2404
- - service: Sites
- runner: blacksmith-4vcpu-ubuntu-2404
- - service: Functions
- runner: blacksmith-4vcpu-ubuntu-2404
- - service: Avatars
- runner: blacksmith-4vcpu-ubuntu-2404
- - service: Realtime
- runner: blacksmith-4vcpu-ubuntu-2404
+ runner: runs-on=${{ github.run_id }}/runner=8cpu-linux-x64/volume=120g/spot=false
+ paratest_processes: 3
+ timeout_minutes: 30
- service: TablesDB
- runner: blacksmith-4vcpu-ubuntu-2404
+ runner: runs-on=${{ github.run_id }}/runner=8cpu-linux-x64/volume=120g/spot=false
+ paratest_processes: 3
+ timeout_minutes: 30
+ - service: Migrations
+ paratest_processes: 1
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - name: Download Docker Image
- uses: actions/download-artifact@v7
- with:
- name: ${{ env.IMAGE }}
- path: /tmp
-
- - name: Set database environment
+ - 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
@@ -469,23 +482,31 @@ jobs:
fi
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Login to GHCR
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ 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:
_APP_BROWSER_HOST: http://invalid-browser/v1
_APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }}
- _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }}
_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
- _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }}
_APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
- _APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }}
run: |
- docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
@@ -498,11 +519,11 @@ jobs:
done
- name: Run tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
- timeout_minutes: 20
+ timeout_minutes: ${{ matrix.timeout_minutes || 20 }}
job_id: ${{ job.check_run_id }}
github_token: ${{ secrets.GITHUB_TOKEN }}
test_dir: tests/e2e/Services/${{ matrix.service }}
@@ -512,12 +533,19 @@ jobs:
# Services that rely on sequential test method execution (shared static state)
FUNCTIONAL_FLAG="--functional"
case "${{ matrix.service }}" in
- Databases|TablesDB|Functions|Realtime|GraphQL) FUNCTIONAL_FLAG="" ;;
+ Databases|TablesDB|Functions|Realtime|GraphQL|ProjectWebhooks) FUNCTIONAL_FLAG="" ;;
esac
+ PARATEST_PROCESSES="${{ matrix.paratest_processes }}"
+ if [ -z "$PARATEST_PROCESSES" ]; then
+ PARATEST_PROCESSES="$(nproc)"
+ fi
+
docker compose exec -T \
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
- appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml
+ -e _TESTS_OAUTH2_GITHUB_CLIENT_ID="${{ secrets.TESTS_OAUTH2_GITHUB_CLIENT_ID }}" \
+ -e _TESTS_OAUTH2_GITHUB_CLIENT_SECRET="${{ secrets.TESTS_OAUTH2_GITHUB_CLIENT_SECRET }}" \
+ appwrite vendor/bin/paratest --processes "$PARATEST_PROCESSES" $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml
- name: Failure Logs
if: failure()
@@ -532,43 +560,48 @@ jobs:
permissions:
contents: read
pull-requests: write
+ packages: read
strategy:
fail-fast: false
matrix:
mode: ${{ fromJSON(needs.matrix.outputs.modes) }}
steps:
- name: Checkout repository
- uses: actions/checkout@v6
-
- - name: Download Docker Image
- uses: actions/download-artifact@v7
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
- name: ${{ env.IMAGE }}
- path: /tmp
+ fetch-depth: 1
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Login to GHCR
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ 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:
_APP_OPTIONS_ABUSE: enabled
_APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }}
- _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }}
_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
- _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }}
_APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
- _APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }}
run: |
- docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
- name: Run tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
@@ -594,37 +627,40 @@ jobs:
permissions:
contents: read
pull-requests: write
+ packages: read
strategy:
fail-fast: false
matrix:
mode: ${{ fromJSON(needs.matrix.outputs.modes) }}
steps:
- name: Checkout repository
- uses: actions/checkout@v6
-
- - name: Download Docker Image
- uses: actions/download-artifact@v7
- with:
- name: ${{ env.IMAGE }}
- path: /tmp
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Login to GHCR
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ 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:
_APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }}
- _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }}
_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
- _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }}
_APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
- _APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }}
run: |
- docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
@@ -637,7 +673,7 @@ jobs:
done
- name: Run tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
@@ -658,99 +694,174 @@ jobs:
benchmark:
name: Benchmark
+ if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
needs: build
permissions:
+ actions: read
+ contents: read
+ issues: write
pull-requests: write
+ packages: read
steps:
- name: Checkout repository
- uses: actions/checkout@v6
-
- - name: Download Docker Image
- uses: actions/download-artifact@v7
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
- name: ${{ env.IMAGE }}
- path: /tmp
+ fetch-depth: 1
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- - name: Load and Start Appwrite
- run: |
- sed -i 's/traefik/localhost/g' .env
- docker load --input /tmp/${{ env.IMAGE }}.tar
- docker compose up -d
- sleep 10
-
- - name: Install Oha
- run: |
- echo "deb [signed-by=/usr/share/keyrings/azlux-archive-keyring.gpg] http://packages.azlux.fr/debian/ stable main" | sudo tee /etc/apt/sources.list.d/azlux.list
- sudo wget -O /usr/share/keyrings/azlux-archive-keyring.gpg https://azlux.fr/repo.gpg
- sudo apt update
- sudo apt install oha
- oha --version
-
- - name: Benchmark PR
- run: 'oha -z 180s http://localhost/v1/health/version --output-format json > benchmark.json'
-
- - name: Cleaning
- run: docker compose down -v
-
- - name: Installing latest version
- run: |
- rm docker-compose.yml
- rm .env
- curl https://appwrite.io/install/compose -o docker-compose.yml
- curl https://appwrite.io/install/env -o .env
- sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env
- docker compose up -d
- sleep 10
-
- - name: Benchmark Latest
- run: oha -z 180s http://localhost/v1/health/version --output-format json > benchmark-latest.json
-
- - name: Prepare comment
- run: |
- echo '## :sparkles: Benchmark results' > benchmark.txt
- echo ' ' >> benchmark.txt
- echo "- Requests per second: $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt
- echo "- Requests with 200 status code: $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt
- echo "- P99 latency: $(jq -r '.latencyPercentiles.p99' benchmark.json )" >> benchmark.txt
- echo " " >> benchmark.txt
- echo " " >> benchmark.txt
- echo "## :zap: Benchmark Comparison" >> benchmark.txt
- echo " " >> benchmark.txt
- echo "| Metric | This PR | Latest version | " >> benchmark.txt
- echo "| --- | --- | --- | " >> benchmark.txt
- echo "| RPS | $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.summary.requestsPerSec|tonumber|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt
- echo "| 200 | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt
- echo "| P99 | $(jq -r '.latencyPercentiles.p99' benchmark.json ) | $(jq -r '.latencyPercentiles.p99' benchmark-latest.json ) | " >> benchmark.txt
-
- - name: Save results
- uses: actions/upload-artifact@v7
- if: ${{ !cancelled() }}
+ - name: Login to GHCR
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
- name: benchmark.json
- path: benchmark.json
- retention-days: 7
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
- - name: Find Comment
- if: github.event.pull_request.head.repo.full_name == github.repository
- uses: peter-evans/find-comment@v3
- id: fc
+ - name: Pull Appwrite image
+ run: |
+ 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@db07bd9765aac508ef18982e52ab937fe633a065 # v1.2.1
with:
- issue-number: ${{ github.event.pull_request.number }}
- comment-author: 'github-actions[bot]'
- body-includes: Benchmark results
+ k6-version: ${{ env.K6_VERSION }}
+
+ - name: Prepare benchmark before
+ id: benchmark_before_prepare
+ continue-on-error: true
+ run: |
+ git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
+ git worktree add --detach /tmp/appwrite-benchmark-before ${{ github.event.pull_request.base.sha }}
+ docker build \
+ --cache-from ${{ env.IMAGE }}:after \
+ --target development \
+ --build-arg DEBUG=false \
+ --build-arg TESTING=true \
+ --build-arg VERSION=dev \
+ --tag ${{ env.IMAGE }}:before \
+ /tmp/appwrite-benchmark-before
+
+ - name: Start before Appwrite
+ id: benchmark_before_start
+ if: steps.benchmark_before_prepare.outcome == 'success'
+ continue-on-error: true
+ working-directory: /tmp/appwrite-benchmark-before
+ env:
+ _APP_DOMAIN: localhost
+ _APP_CONSOLE_DOMAIN: localhost
+ _APP_DOMAIN_FUNCTIONS: functions.localhost
+ _APP_OPTIONS_ABUSE: disabled
+ run: |
+ docker tag ${{ env.IMAGE }}:before ${{ env.IMAGE }}
+ docker compose up -d --wait --no-build
+
+ - name: Prepare benchmark files
+ run: rm -f benchmark-before-summary.json benchmark-after-summary.json benchmark-before-samples.json benchmark-after-samples.json
+
+ - name: Benchmark before
+ if: steps.benchmark_before_start.outcome == 'success'
+ continue-on-error: true
+ uses: grafana/run-k6-action@de51a7390bdf0ac85a3bef493691bd71d4c7c158 # v1.4.0
+ env:
+ APPWRITE_ENDPOINT: 'http://localhost/v1'
+ APPWRITE_BENCHMARK_ITERATIONS: '5'
+ APPWRITE_BENCHMARK_VUS: '1'
+ APPWRITE_WORKER_TIMEOUT_MS: '120000'
+ APPWRITE_BENCHMARK_SUMMARY_PATH: 'benchmark-before-summary.json'
+ with:
+ path: tests/benchmarks/http.js
+ flags: --quiet --out json=benchmark-before-samples.json
+ cloud-comment-on-pr: false
+ debug: true
+
+ - name: Stop before Appwrite
+ if: always()
+ run: |
+ if [ -d /tmp/appwrite-benchmark-before ]; then
+ cd /tmp/appwrite-benchmark-before
+ docker compose down -v || true
+ fi
+
+ - name: Wait for benchmark ports
+ if: always()
+ run: |
+ for port in 80 443 8080 9503; do
+ for attempt in $(seq 1 30); do
+ if ! ss -ltn | awk '{print $4}' | grep -Eq "[:.]${port}$"; then
+ break
+ fi
+ sleep 1
+ done
+
+ if ss -ltn | awk '{print $4}' | grep -Eq "[:.]${port}$"; then
+ echo "Port ${port} is still in use after stopping the before stack"
+ ss -ltn
+ exit 1
+ fi
+ done
+
+ - name: Start after Appwrite
+ env:
+ _APP_DOMAIN: localhost
+ _APP_CONSOLE_DOMAIN: localhost
+ _APP_DOMAIN_FUNCTIONS: functions.localhost
+ _APP_OPTIONS_ABUSE: disabled
+ run: |
+ docker tag ${{ env.IMAGE }}:after ${{ env.IMAGE }}
+ docker compose up -d --wait --no-build
+
+ - name: Benchmark after
+ id: benchmark_after
+ continue-on-error: true
+ uses: grafana/run-k6-action@de51a7390bdf0ac85a3bef493691bd71d4c7c158 # v1.4.0
+ env:
+ APPWRITE_ENDPOINT: 'http://localhost/v1'
+ APPWRITE_BENCHMARK_ITERATIONS: '5'
+ APPWRITE_BENCHMARK_VUS: '1'
+ APPWRITE_WORKER_TIMEOUT_MS: '120000'
+ APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH: '../../benchmark-before-summary.json'
+ APPWRITE_BENCHMARK_SUMMARY_PATH: 'benchmark-after-summary.json'
+ with:
+ path: tests/benchmarks/http.js
+ flags: --quiet --out json=benchmark-after-samples.json
+ cloud-comment-on-pr: false
+ debug: true
+
+ - name: Stop after Appwrite
+ if: always()
+ run: docker compose down -v || true
- name: Comment on PR
- if: github.event.pull_request.head.repo.full_name == github.repository
- uses: peter-evans/create-or-update-comment@v4
+ if: always()
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ BENCHMARK_BASE_REF: ${{ github.event.pull_request.base.ref }}
+ BENCHMARK_HEAD_REF: ${{ github.event.pull_request.head.ref }}
with:
- comment-id: ${{ steps.fc.outputs.comment-id }}
- issue-number: ${{ github.event.pull_request.number }}
- body-path: benchmark.txt
- edit-mode: replace
+ script: |
+ const comment = require('./.github/workflows/benchmark-comment.js');
+ await comment({ github, context, core });
+
+ - name: Save results
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ if: ${{ !cancelled() }}
+ with:
+ name: benchmark-results
+ path: |
+ benchmark-comment.txt
+ benchmark-before-summary.json
+ benchmark-after-summary.json
+ benchmark-before-samples.json
+ benchmark-after-samples.json
+ retention-days: 7
+
+ - name: Fail benchmark
+ if: always() && steps.benchmark_after.outcome != 'success'
+ run: exit 1
diff --git a/.github/workflows/cleanup-cache.yml b/.github/workflows/cleanup-cache.yml
index 8f9f05a38c..e4f28816be 100644
--- a/.github/workflows/cleanup-cache.yml
+++ b/.github/workflows/cleanup-cache.yml
@@ -5,12 +5,17 @@ on:
types:
- closed
+permissions:
+ actions: write
+ contents: read
+ packages: write
+
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Check out code
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cleanup
run: |
@@ -36,4 +41,29 @@ jobs:
done
done
env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
+ 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 }}
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 7edfde0aae..cb9b09b496 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -34,7 +34,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -47,14 +47,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@v2
+ uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@v2
+ uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -68,4 +68,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2
+ uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 5cbec8f867..0a49f658ac 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -10,13 +10,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
- name: Build the Docker image
run: DOCKER_BUILDKIT=1 docker build . --target production -t appwrite_image:latest
- name: Run Trivy vulnerability scanner on image
- uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: 'appwrite_image:latest'
format: 'sarif'
@@ -24,24 +24,28 @@ 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@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
+ if: always() && hashFiles('trivy-image-results.sarif') != ''
with:
sarif_file: 'trivy-image-results.sarif'
+ category: 'trivy-image'
scan-code:
name: Scan Code
runs-on: ubuntu-latest
steps:
- name: Check out code
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run Trivy vulnerability scanner on filesystem
- uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: 'fs'
format: 'sarif'
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@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
+ if: always() && hashFiles('trivy-fs-results.sarif') != ''
with:
sarif_file: 'trivy-fs-results.sarif'
+ category: 'trivy-source'
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 692861d44d..68ab657213 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -12,33 +12,33 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 2
submodules: recursive
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to Docker Hub
- uses: docker/login-action@v3
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
- uses: docker/metadata-action@v4
+ uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: appwrite/cloud
tags: |
type=ref,event=tag
- name: Build & Publish to DockerHub
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64,linux/arm64
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 84fc4c9fba..ed4e46d811 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,7 +11,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -20,20 +20,20 @@ jobs:
submodules: recursive
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to Docker Hub
- uses: docker/login-action@v3
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
- uses: docker/metadata-action@v4
+ uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: appwrite/appwrite
tags: |
@@ -42,7 +42,7 @@ jobs:
type=semver,pattern={{major}}
- name: Build and push
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64,linux/arm64
diff --git a/.github/workflows/sdk-preview.yml b/.github/workflows/sdk-preview.yml
index f81346a7d1..dacc37a64a 100644
--- a/.github/workflows/sdk-preview.yml
+++ b/.github/workflows/sdk-preview.yml
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set SDK type
id: set-sdk
@@ -49,7 +49,7 @@ jobs:
docker compose exec appwrite sdks --platform=${{ steps.set-sdk.outputs.platform }} --sdk=${{ steps.set-sdk.outputs.sdk_type }} --version=latest --git=no
sudo chown -R $USER:$USER ./app/sdks/${{ steps.set-sdk.outputs.platform }}-${{ steps.set-sdk.outputs.sdk_type }}
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml
index 6f377354d5..85c76bacd3 100644
--- a/.github/workflows/specs.yml
+++ b/.github/workflows/specs.yml
@@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 6e4a8ba73b..73b767aafe 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/stale@v10
+ - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue has been labeled as a 'question', indicating that it requires additional information from the requestor. It has been inactive for 7 days. If no further activity occurs, this issue will be closed in 14 days."
diff --git a/AGENTS.md b/AGENTS.md
index 4c5db871d7..b84bc89c3b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -115,6 +115,14 @@ Common injections: `$response`, `$request`, `$dbForProject`, `$dbForPlatform`, `
- Never hardcode credentials -- use environment variables.
- Code changes may require container restart. No central log location -- check relevant containers.
+## Tracing with Utopia Span
+
+In handlers, only call `Span::add($key, $value)`. **Never** call `Span::init`, `Span::error`, or `Span::finish` -- lifecycle is owned by the entry-point harness (`app/http.php`, `app/worker.php`, `app/realtime.php`, `Bus::dispatch`). For selective export, filter in the sampler in `app/init/span.php`.
+
+Keys are `snake_case` with dots only for child relationships: `project.id` (id of project), `storage.bucket.id`. No dot otherwise: `inbound_bytes`, not `inbound.bytes`. No camelCase, no bare top-level keys (`function.id`, not `functionId`).
+
+Cross-cutting identifiers (`project.id`, `function.id`, `user.id`) live at the top level, not under a subsystem (no `realtime.project.id`). The trace sampler and downstream filters look them up by the canonical key.
+
## Patch release process
For bumping patch versions (e.g., `1.9.0` -> `1.9.1`), follow the checklist in `.claude/skills/patch-release-checklist/SKILL.md`. It covers the 4 files that must be updated, console image bumps, CHANGES.md updates, and common pitfalls to avoid.
diff --git a/CHANGES.md b/CHANGES.md
index 548c0d72b0..6894322043 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -892,7 +892,7 @@
* Unset index length by @fogelito in https://github.com/appwrite/appwrite/pull/8978
* Update base to 0.9.5 by @basert in https://github.com/appwrite/appwrite/pull/9005
* Sync main into 1.6.x by @TorstenDittmann in https://github.com/appwrite/appwrite/pull/9011
-* Improved shared tables V2 by @abnegate in https://github.com/appwrite/appwrite/pull/9013
+* Improved shared tables by @abnegate in https://github.com/appwrite/appwrite/pull/9013
* Ensure backwards compatibility for 1.6.x by @christyjacob4 in https://github.com/appwrite/appwrite/pull/9018
# Version 1.6.0
diff --git a/Dockerfile b/Dockerfile
index 7cb007c188..9a61635415 100755
--- a/Dockerfile
+++ b/Dockerfile
@@ -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.4.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
diff --git a/README-CN.md b/README-CN.md
index 2c7402f1ef..212b5bb08d 100644
--- a/README-CN.md
+++ b/README-CN.md
@@ -72,7 +72,7 @@ docker run -it --rm \
--volume /var/run/docker.sock:/var/run/docker.sock \
--volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \
--entrypoint="install" \
- appwrite/appwrite:1.9.1
+ appwrite/appwrite:1.9.0
```
### Windows
@@ -84,7 +84,7 @@ docker run -it --rm ^
--volume //var/run/docker.sock:/var/run/docker.sock ^
--volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^
--entrypoint="install" ^
- appwrite/appwrite:1.9.1
+ appwrite/appwrite:1.9.0
```
#### PowerShell
@@ -94,7 +94,7 @@ docker run -it --rm `
--volume /var/run/docker.sock:/var/run/docker.sock `
--volume ${pwd}/appwrite:/usr/src/code/appwrite:rw `
--entrypoint="install" `
- appwrite/appwrite:1.9.1
+ appwrite/appwrite:1.9.0
```
运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。
diff --git a/README.md b/README.md
index 31076ffa31..88d527f060 100644
--- a/README.md
+++ b/README.md
@@ -75,7 +75,7 @@ docker run -it --rm \
--volume /var/run/docker.sock:/var/run/docker.sock \
--volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \
--entrypoint="install" \
- appwrite/appwrite:1.9.1
+ appwrite/appwrite:1.9.0
```
### Windows
@@ -88,7 +88,7 @@ docker run -it --rm ^
--volume //var/run/docker.sock:/var/run/docker.sock ^
--volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^
--entrypoint="install" ^
- appwrite/appwrite:1.9.1
+ appwrite/appwrite:1.9.0
```
#### PowerShell
@@ -99,7 +99,7 @@ docker run -it --rm `
--volume /var/run/docker.sock:/var/run/docker.sock `
--volume ${pwd}/appwrite:/usr/src/code/appwrite:rw `
--entrypoint="install" `
- appwrite/appwrite:1.9.1
+ appwrite/appwrite:1.9.0
```
Once the Docker installation is complete, go to http://localhost to access the Appwrite console from your browser. Please note that on non-Linux native hosts, the server might take a few minutes to start after completing the installation.
diff --git a/app/cli.php b/app/cli.php
index 73908510d9..496a79eab9 100644
--- a/app/cli.php
+++ b/app/cli.php
@@ -2,17 +2,10 @@
require_once __DIR__ . '/init.php';
-use Appwrite\Event\Certificate;
-use Appwrite\Event\Delete;
-use Appwrite\Event\Event;
-use Appwrite\Event\Func;
-use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher;
-use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Platform\Appwrite;
use Appwrite\Runtimes\Runtimes;
use Appwrite\Usage\Context as UsageContext;
use Appwrite\Utopia\Database\Documents\User;
-use Executor\Executor;
use Swoole\Runtime;
use Swoole\Timer;
use Utopia\Cache\Adapter\Pool as CachePool;
@@ -26,17 +19,12 @@ use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
-use Utopia\DI\Container;
use Utopia\DSN\DSN;
use Utopia\Logger\Log;
use Utopia\Platform\Service;
use Utopia\Pools\Group;
-use Utopia\Queue\Broker\Pool as BrokerPool;
-use Utopia\Queue\Publisher;
-use Utopia\Queue\Queue;
use Utopia\Registry\Registry;
use Utopia\System\System;
-use Utopia\Telemetry\Adapter\None as NoTelemetry;
use function Swoole\Coroutine\run;
@@ -47,6 +35,7 @@ Config::setParam('runtimes', (new Runtimes('v5'))->getAll(supported: false));
require_once __DIR__ . '/controllers/general.php';
global $register;
+global $container;
$platform = new Appwrite();
$args = $_SERVER['argv'] ?? [];
@@ -58,7 +47,6 @@ if (! isset($args[0])) {
}
$taskName = $args[0];
-$container = new Container();
$cli = new CLI(new Generic(), $_SERVER['argv'] ?? [], $container);
$platform->setCli($cli);
@@ -131,10 +119,6 @@ $container->set('dbForPlatform', function ($pools, $cache, $authorization) {
return $dbForPlatform;
}, ['pools', 'cache', 'authorization']);
-$container->set('console', function () {
- return new Document(Config::getParam('console'));
-}, []);
-
$container->set(
'isResourceBlocked',
fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false,
@@ -157,12 +141,19 @@ $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform,
}
if (isset($databases[$dsn->getHost()])) {
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
$database = $databases[$dsn->getHost()];
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -182,9 +173,16 @@ $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform,
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
$database
->setSharedTables(true)
->setTenant($project->getSequence())
+ ->setGlobalCollections($projectsGlobalCollections)
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -212,6 +210,11 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
return $database;
}
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $logsCollections = $collections['logs'] ?? [];
+ $logsCollections = array_keys($logsCollections);
+
$adapter = new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
@@ -220,6 +223,7 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
->setAuthorization($authorization)
->setSharedTables(true)
->setNamespace('logsV1')
+ ->setGlobalCollections($logsCollections)
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_TASK)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
@@ -231,41 +235,10 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
return $database;
};
}, ['pools', 'cache', 'authorization']);
-$container->set('publisher', function (Group $pools) {
- return new BrokerPool(publisher: $pools->get('publisher'));
-}, ['pools']);
-$container->set('publisherDatabases', function (BrokerPool $publisher) {
- return $publisher;
-}, ['publisher']);
-$container->set('publisherFunctions', function (BrokerPool $publisher) {
- return $publisher;
-}, ['publisher']);
-$container->set('publisherMigrations', function (BrokerPool $publisher) {
- return $publisher;
-}, ['publisher']);
-$container->set('publisherMessaging', function (BrokerPool $publisher) {
- return $publisher;
-}, ['publisher']);
+
$container->set('usage', function () {
return new UsageContext();
}, []);
-$container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
- $publisher,
- new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
-), ['publisher']);
-$container->set('publisherForStatsResources', fn (Publisher $publisher) => new StatsResourcesPublisher(
- $publisher,
- new Queue(System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME))
-), ['publisher']);
-$container->set('queueForFunctions', function (Publisher $publisher) {
- return new Func($publisher);
-}, ['publisher']);
-$container->set('queueForDeletes', function (Publisher $publisher) {
- return new Delete($publisher);
-}, ['publisher']);
-$container->set('queueForCertificates', function (Publisher $publisher) {
- return new Certificate($publisher);
-}, ['publisher']);
$container->set('logError', function (Registry $register) {
return function (Throwable $error, string $namespace, string $action) use ($register) {
Console::error('[Error] Timestamp: ' . date('c', time()));
@@ -318,14 +291,10 @@ $container->set('logError', function (Registry $register) {
};
}, ['register']);
-$container->set('executor', fn () => new Executor(), []);
-
$container->set('bus', function (Registry $register) use ($container) {
return $register->get('bus')->setResolver(fn (string $name) => $container->get($name));
}, ['register']);
-$container->set('telemetry', fn () => new NoTelemetry(), []);
-
$exitCode = 0;
$cli
diff --git a/app/config/collections/common.php b/app/config/collections/common.php
index 80bb717423..37fbcc8ca3 100644
--- a/app/config/collections/common.php
+++ b/app/config/collections/common.php
@@ -1523,6 +1523,13 @@ return [
'lengths' => [],
'orders' => [Database::ORDER_ASC],
],
+ [
+ '$id' => ID::custom('_key_team_confirm'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['teamInternalId', 'confirm'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
],
],
diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php
index 6195c11724..7496b7a9a7 100644
--- a/app/config/collections/platform.php
+++ b/app/config/collections/platform.php
@@ -404,6 +404,13 @@ $platformCollections = [
'lengths' => [],
'orders' => [],
],
+ [
+ '$id' => ID::custom('_key_teamInternalId'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['teamInternalId'],
+ 'lengths' => [Database::LENGTH_KEY],
+ 'orders' => [Database::ORDER_ASC],
+ ],
],
],
@@ -635,6 +642,13 @@ $platformCollections = [
'lengths' => [Database::LENGTH_KEY],
'orders' => [Database::ORDER_ASC],
],
+ [
+ '$id' => ID::custom('_key_project_id'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectId'],
+ 'lengths' => [Database::LENGTH_KEY],
+ 'orders' => [Database::ORDER_ASC],
+ ],
],
],
@@ -1007,7 +1021,14 @@ $platformCollections = [
'attributes' => ['projectInternalId'],
'lengths' => [Database::LENGTH_KEY],
'orders' => [Database::ORDER_ASC],
- ]
+ ],
+ [
+ '$id' => ID::custom('_key_project_id'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectId'],
+ 'lengths' => [Database::LENGTH_KEY],
+ 'orders' => [Database::ORDER_ASC],
+ ],
],
],
@@ -1935,6 +1956,440 @@ $platformCollections = [
'attributes' => [],
'indexes' => []
],
+
+ 'reports' => [
+ '$collection' => ID::custom(Database::METADATA),
+ '$id' => ID::custom('reports'),
+ 'name' => 'Reports',
+ 'attributes' => [
+ [
+ '$id' => ID::custom('projectInternalId'),
+ 'type' => Database::VAR_ID,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('projectId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('appInternalId'),
+ 'type' => Database::VAR_ID,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('appId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('type'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 64,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('title'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 256,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('summary'),
+ 'type' => Database::VAR_TEXT,
+ 'format' => '',
+ 'size' => 65535,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => '',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ // Resource type the report is about. Plural noun, e.g. databases, sites, urls.
+ '$id' => ID::custom('targetType'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 64,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ // Free-form target identifier (URL for lighthouse, resource ID for db).
+ // Indexed by `_key_project_target` with an explicit prefix length.
+ '$id' => ID::custom('target'),
+ 'type' => Database::VAR_TEXT,
+ 'format' => '',
+ 'size' => 65535,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ // Category strings, e.g. 'performance', 'accessibility'. Native array
+ // column — we never query on individual entries (MySQL JSON-array
+ // indexes are weak), this is read+rewrite only.
+ '$id' => ID::custom('categories'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 64,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => true,
+ 'filters' => [],
+ ],
+ [
+ // Virtual attribute — insights live in the `insights` collection
+ // back-referenced by `reportInternalId`. The subQuery filter joins
+ // them at read time.
+ '$id' => ID::custom('insights'),
+ 'type' => Database::VAR_TEXT,
+ 'format' => '',
+ 'size' => 65535,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => ['subQueryReportInsights'],
+ ],
+ [
+ '$id' => ID::custom('analyzedAt'),
+ 'type' => Database::VAR_DATETIME,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => false,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => ['datetime'],
+ ],
+ ],
+ 'indexes' => [
+ [
+ '$id' => ID::custom('_key_project_app_type'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'appInternalId', 'type'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_target'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'appInternalId', 'targetType', 'target'],
+ 'lengths' => [null, null, null, 700],
+ 'orders' => [],
+ ],
+ ],
+ ],
+
+ 'insights' => [
+ '$collection' => ID::custom(Database::METADATA),
+ '$id' => ID::custom('insights'),
+ 'name' => 'Insights',
+ 'attributes' => [
+ [
+ '$id' => ID::custom('projectInternalId'),
+ 'type' => Database::VAR_ID,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('projectId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('reportInternalId'),
+ 'type' => Database::VAR_ID,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('reportId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => '',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('type'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 64,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('severity'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 16,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('status'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 16,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => 'active',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('resourceType'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 64,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('resourceId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('resourceInternalId'),
+ 'type' => Database::VAR_ID,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('parentResourceType'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 64,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => '',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('parentResourceId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => '',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('parentResourceInternalId'),
+ 'type' => Database::VAR_ID,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('title'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 256,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('summary'),
+ 'type' => Database::VAR_TEXT,
+ 'format' => '',
+ 'size' => 65535,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => '',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('ctas'),
+ 'type' => Database::VAR_TEXT,
+ 'format' => '',
+ 'size' => 65535,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => ['json'],
+ ],
+ [
+ '$id' => ID::custom('analyzedAt'),
+ 'type' => Database::VAR_DATETIME,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => false,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => ['datetime'],
+ ],
+ [
+ '$id' => ID::custom('dismissedAt'),
+ 'type' => Database::VAR_DATETIME,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => false,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => ['datetime'],
+ ],
+ [
+ '$id' => ID::custom('dismissedBy'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => '',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ ],
+ 'indexes' => [
+ [
+ '$id' => ID::custom('_key_project_report'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'reportInternalId'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_resource'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'resourceType', 'resourceId'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_parent_resource'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'parentResourceType', 'parentResourceId'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_type'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'type'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_severity'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'severity'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_status'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'status'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_dismissedAt'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'dismissedAt'],
+ 'lengths' => [],
+ 'orders' => [Database::ORDER_ASC, Database::ORDER_DESC],
+ ],
+ ],
+ ],
+
];
// Organization API keys subquery
diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php
index 9568c59369..120c9704ce 100644
--- a/app/config/collections/projects.php
+++ b/app/config/collections/projects.php
@@ -841,6 +841,28 @@ return [
'array' => true,
'filters' => [],
],
+ [
+ '$id' => ID::custom('providerBranches'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 128,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => [],
+ 'array' => true,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('providerPaths'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 128,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => [],
+ 'array' => true,
+ 'filters' => [],
+ ],
],
'indexes' => [
[
@@ -1320,6 +1342,28 @@ return [
'array' => false,
'filters' => [],
],
+ [
+ '$id' => ID::custom('providerBranches'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 128,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => [],
+ 'array' => true,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('providerPaths'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 128,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => [],
+ 'array' => true,
+ 'filters' => [],
+ ],
],
'indexes' => [
[
@@ -2754,4 +2798,146 @@ return [
],
],
],
+
+ // Naming it presenceLogs as later it might be only be used as a presence events table only and not for the actual presence
+ 'presenceLogs' => [
+ '$collection' => ID::custom(Database::METADATA),
+ '$id' => ID::custom('presenceLogs'),
+ 'name' => 'Presence Logs',
+ 'attributes' => [
+ [
+ '$id' => ID::custom('userInternalId'),
+ 'type' => Database::VAR_ID,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('userId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('expiresAt'),
+ 'type' => Database::VAR_DATETIME,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => false,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => ['datetime'],
+ ],
+ [
+ '$id' => ID::custom('status'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('source'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => true,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('hostname'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('metadata'),
+ 'type' => Database::VAR_TEXT,
+ 'format' => '',
+ 'size' => 65535,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => new \stdClass(),
+ 'array' => false,
+ 'filters' => ['json'],
+ ],
+ [
+ '$id' => ID::custom('permissionsHash'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 32,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ ],
+ 'indexes' => [
+ [
+ '$id' => ID::custom('_unique_userId'),
+ 'type' => Database::INDEX_UNIQUE,
+ 'attributes' => ['userId'],
+ 'lengths' => [Database::LENGTH_KEY],
+ 'orders' => [Database::ORDER_ASC]
+ ],
+ [
+ '$id' => ID::custom('_key_userInternal'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['userInternalId'],
+ 'orders' => [Database::ORDER_ASC]
+ ],
+ [
+ '$id' => ID::custom('_key_expiresAt'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['expiresAt'],
+ 'lengths' => [],
+ 'orders' => [Database::ORDER_ASC]
+ ],
+ [
+ '$id' => ID::custom('_key_status'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['status'],
+ 'lengths' => [Database::LENGTH_KEY],
+ 'orders' => [Database::ORDER_ASC]
+ ],
+ [
+ '$id' => ID::custom('_key_source'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['source'],
+ 'lengths' => [Database::LENGTH_KEY],
+ 'orders' => [Database::ORDER_ASC]
+ ],
+ [
+ '$id' => ID::custom('_key_source_status'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['source', 'status']
+ ],
+ [
+ '$id' => ID::custom('_key_permissionsHash'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['permissionsHash']
+ ]
+ ]
+ ]
];
diff --git a/app/config/console.php b/app/config/console.php
index 0b0d6c5881..b7a3f2195a 100644
--- a/app/config/console.php
+++ b/app/config/console.php
@@ -34,6 +34,11 @@ $console = [
'legalAddress' => '',
'legalTaxId' => '',
'auths' => [
+ 'membershipsUserName' => true,
+ 'membershipsUserEmail' => true,
+ 'membershipsMfa' => true,
+ 'membershipsUserId' => true,
+ 'membershipsUserPhone' => true,
'mockNumbers' => [],
'invites' => System::getEnv('_APP_CONSOLE_INVITES', 'enabled') === 'enabled',
'limit' => (System::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled') === 'enabled') ? 1 : 0, // limit signup to 1 user
diff --git a/app/config/errors.php b/app/config/errors.php
index 4190c6e277..4a6f08d432 100644
--- a/app/config/errors.php
+++ b/app/config/errors.php
@@ -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,
],
@@ -623,6 +623,11 @@ return [
'description' => 'Synchronous function execution timed out. Use asynchronous execution instead, or ensure the execution duration doesn\'t exceed 30 seconds.',
'code' => 408,
],
+ Exception::FUNCTION_ASYNCHRONOUS_TIMEOUT => [
+ 'name' => Exception::FUNCTION_ASYNCHRONOUS_TIMEOUT,
+ 'description' => 'Asynchronous function execution timed out. Ensure the execution duration doesn\'t exceed the configured function timeout.',
+ 'code' => 408,
+ ],
Exception::FUNCTION_TEMPLATE_NOT_FOUND => [
'name' => Exception::FUNCTION_TEMPLATE_NOT_FOUND,
'description' => 'Function Template with the requested ID could not be found.',
@@ -687,6 +692,11 @@ return [
'description' => 'Build with the requested ID failed. Please check the logs for more information.',
'code' => 400,
],
+ Exception::BUILD_TIMEOUT => [
+ 'name' => Exception::BUILD_TIMEOUT,
+ 'description' => 'Build timed out. Increase the build timeout via the `_APP_COMPUTE_BUILD_TIMEOUT` environment variable, or simplify the build to complete within the limit.',
+ 'code' => 408,
+ ],
/** Deployments */
Exception::DEPLOYMENT_NOT_FOUND => [
@@ -715,6 +725,18 @@ return [
'code' => 404,
],
+ /** Presence */
+ Exception::PRESENCE_NOT_FOUND => [
+ 'name' => Exception::PRESENCE_NOT_FOUND,
+ 'description' => 'Presence with the requested ID could not be found.',
+ 'code' => 404,
+ ],
+ Exception::PRESENCE_ALREADY_EXISTS => [
+ 'name' => Exception::PRESENCE_ALREADY_EXISTS,
+ 'description' => 'Presence with the requested ID \'%s\' already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
+ 'code' => 409,
+ ],
+
/** Databases */
Exception::DATABASE_NOT_FOUND => [
'name' => Exception::DATABASE_NOT_FOUND,
@@ -1236,6 +1258,26 @@ return [
'description' => 'The specified database type is not supported for CSV import or export operations.',
'code' => 400,
],
+ Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED => [
+ 'name' => Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED,
+ 'description' => 'A source projectId is required for Appwrite migrations. Provide it in the migration credentials.',
+ 'code' => 400,
+ ],
+ Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND => [
+ 'name' => Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND,
+ 'description' => 'The source project for the provided projectId was not found. Verify the projectId and the API key has access to it.',
+ 'code' => 404,
+ ],
+ Exception::MIGRATION_SOURCE_TYPE_INVALID => [
+ 'name' => Exception::MIGRATION_SOURCE_TYPE_INVALID,
+ 'description' => 'The migration source type is invalid. Use one of the supported source types.',
+ 'code' => 400,
+ ],
+ Exception::MIGRATION_DESTINATION_TYPE_INVALID => [
+ 'name' => Exception::MIGRATION_DESTINATION_TYPE_INVALID,
+ 'description' => 'The migration destination type is invalid. Use one of the supported destination types.',
+ 'code' => 400,
+ ],
/** Realtime */
Exception::REALTIME_MESSAGE_FORMAT_INVALID => [
@@ -1408,4 +1450,43 @@ return [
'description' => 'When using project API key, make sure to pass x-appwrite-project header with your project ID.',
'code' => 403,
],
+ Exception::MOCK_NUMBER_ALREADY_EXISTS => [
+ 'name' => Exception::MOCK_NUMBER_ALREADY_EXISTS,
+ 'description' => 'Mock number with the requested number already exists. Try again with a different number. or update OTP of existing mock number.',
+ 'code' => 409,
+ ],
+ Exception::MOCK_NUMBER_NOT_FOUND => [
+ 'name' => Exception::MOCK_NUMBER_NOT_FOUND,
+ 'description' => 'Mock number with the requested number could not be found.',
+ 'code' => 404,
+ ],
+ Exception::MOCK_NUMBER_LIMIT_EXCEEDED => [
+ 'name' => Exception::MOCK_NUMBER_LIMIT_EXCEEDED,
+ 'description' => 'The maximum number of mock phones for this project has been reached.',
+ 'code' => 400,
+ ],
+
+ /** Advisor */
+ Exception::INSIGHT_NOT_FOUND => [
+ 'name' => Exception::INSIGHT_NOT_FOUND,
+ 'description' => 'Insight with the requested ID could not be found.',
+ 'code' => 404,
+ ],
+ Exception::INSIGHT_ALREADY_EXISTS => [
+ 'name' => Exception::INSIGHT_ALREADY_EXISTS,
+ 'description' => 'Insight with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
+ 'code' => 409,
+ ],
+
+ /** Reports */
+ Exception::REPORT_NOT_FOUND => [
+ 'name' => Exception::REPORT_NOT_FOUND,
+ 'description' => 'Report with the requested ID could not be found.',
+ 'code' => 404,
+ ],
+ Exception::REPORT_ALREADY_EXISTS => [
+ 'name' => Exception::REPORT_ALREADY_EXISTS,
+ 'description' => 'Report with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
+ 'code' => 409,
+ ],
];
diff --git a/app/config/events.php b/app/config/events.php
index 11dc2e0e4a..2825562ab7 100644
--- a/app/config/events.php
+++ b/app/config/events.php
@@ -426,5 +426,33 @@ return [
'update' => [
'$description' => 'This event triggers when a proxy rule is updated.',
]
- ]
+ ],
+ 'reports' => [
+ '$model' => Response::MODEL_REPORT,
+ '$resource' => true,
+ '$description' => 'This event triggers on any report event.',
+ 'create' => [
+ '$description' => 'This event triggers when a report is created.',
+ ],
+ 'update' => [
+ '$description' => 'This event triggers when a report is updated.',
+ ],
+ 'delete' => [
+ '$description' => 'This event triggers when a report is deleted.',
+ ],
+ 'insights' => [
+ '$model' => Response::MODEL_INSIGHT,
+ '$resource' => true,
+ '$description' => 'This event triggers on any insight event.',
+ 'create' => [
+ '$description' => 'This event triggers when an insight is created.',
+ ],
+ 'update' => [
+ '$description' => 'This event triggers when an insight is updated.',
+ ],
+ 'delete' => [
+ '$description' => 'This event triggers when an insight is deleted.',
+ ],
+ ],
+ ],
];
diff --git a/app/config/frameworks.php b/app/config/frameworks.php
index 6078c53c63..342657017f 100644
--- a/app/config/frameworks.php
+++ b/app/config/frameworks.php
@@ -14,7 +14,11 @@ return [
'name' => 'Analog',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
- 'runtimes' => $templateRuntimes['NODE'],
+ 'runtimes' => array_merge(
+ $templateRuntimes['NODE'],
+ $templateRuntimes['BUN'],
+ $templateRuntimes['DENO']
+ ),
'bundleCommand' => 'bash /usr/local/server/helpers/analog/bundle.sh',
'envCommand' => 'source /usr/local/server/helpers/analog/env.sh',
'adapters' => [
@@ -40,7 +44,11 @@ return [
'name' => 'Angular',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
- 'runtimes' => $templateRuntimes['NODE'],
+ 'runtimes' => array_merge(
+ $templateRuntimes['NODE'],
+ $templateRuntimes['BUN'],
+ $templateRuntimes['DENO']
+ ),
'bundleCommand' => 'bash /usr/local/server/helpers/angular/bundle.sh',
'envCommand' => 'source /usr/local/server/helpers/angular/env.sh',
'adapters' => [
@@ -66,7 +74,11 @@ return [
'name' => 'Next.js',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
- 'runtimes' => $templateRuntimes['NODE'],
+ 'runtimes' => array_merge(
+ $templateRuntimes['NODE'],
+ $templateRuntimes['BUN'],
+ $templateRuntimes['DENO']
+ ),
'bundleCommand' => 'bash /usr/local/server/helpers/next-js/bundle.sh',
'envCommand' => 'source /usr/local/server/helpers/next-js/env.sh',
'adapters' => [
@@ -91,7 +103,11 @@ return [
'name' => 'React',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
- 'runtimes' => $templateRuntimes['NODE'],
+ 'runtimes' => array_merge(
+ $templateRuntimes['NODE'],
+ $templateRuntimes['BUN'],
+ $templateRuntimes['DENO']
+ ),
'adapters' => [
'static' => [
'key' => 'static',
@@ -108,7 +124,11 @@ return [
'name' => 'Nuxt',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
- 'runtimes' => $templateRuntimes['NODE'],
+ 'runtimes' => array_merge(
+ $templateRuntimes['NODE'],
+ $templateRuntimes['BUN'],
+ $templateRuntimes['DENO']
+ ),
'bundleCommand' => 'bash /usr/local/server/helpers/nuxt/bundle.sh',
'envCommand' => 'source /usr/local/server/helpers/nuxt/env.sh',
'adapters' => [
@@ -133,7 +153,11 @@ return [
'name' => 'Vue.js',
'screenshotSleep' => 5000,
'buildRuntime' => 'node-22',
- 'runtimes' => $templateRuntimes['NODE'],
+ 'runtimes' => array_merge(
+ $templateRuntimes['NODE'],
+ $templateRuntimes['BUN'],
+ $templateRuntimes['DENO']
+ ),
'adapters' => [
'static' => [
'key' => 'static',
@@ -150,7 +174,11 @@ return [
'name' => 'SvelteKit',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
- 'runtimes' => $templateRuntimes['NODE'],
+ 'runtimes' => array_merge(
+ $templateRuntimes['NODE'],
+ $templateRuntimes['BUN'],
+ $templateRuntimes['DENO']
+ ),
'bundleCommand' => 'bash /usr/local/server/helpers/sveltekit/bundle.sh',
'envCommand' => 'source /usr/local/server/helpers/sveltekit/env.sh',
'adapters' => [
@@ -175,7 +203,11 @@ return [
'name' => 'Astro',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
- 'runtimes' => $templateRuntimes['NODE'],
+ 'runtimes' => array_merge(
+ $templateRuntimes['NODE'],
+ $templateRuntimes['BUN'],
+ $templateRuntimes['DENO']
+ ),
'bundleCommand' => 'bash /usr/local/server/helpers/astro/bundle.sh',
'envCommand' => 'source /usr/local/server/helpers/astro/env.sh',
'adapters' => [
@@ -200,7 +232,11 @@ return [
'name' => 'TanStack Start',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
- 'runtimes' => $templateRuntimes['NODE'],
+ 'runtimes' => array_merge(
+ $templateRuntimes['NODE'],
+ $templateRuntimes['BUN'],
+ $templateRuntimes['DENO']
+ ),
'bundleCommand' => 'bash /usr/local/server/helpers/tanstack-start/bundle.sh',
'envCommand' => 'source /usr/local/server/helpers/tanstack-start/env.sh',
'adapters' => [
@@ -225,7 +261,11 @@ return [
'name' => 'Remix',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
- 'runtimes' => $templateRuntimes['NODE'],
+ 'runtimes' => array_merge(
+ $templateRuntimes['NODE'],
+ $templateRuntimes['BUN'],
+ $templateRuntimes['DENO']
+ ),
'bundleCommand' => 'bash /usr/local/server/helpers/remix/bundle.sh',
'envCommand' => 'source /usr/local/server/helpers/remix/env.sh',
'adapters' => [
@@ -250,7 +290,11 @@ return [
'name' => 'Lynx',
'screenshotSleep' => 5000,
'buildRuntime' => 'node-22',
- 'runtimes' => $templateRuntimes['NODE'],
+ 'runtimes' => array_merge(
+ $templateRuntimes['NODE'],
+ $templateRuntimes['BUN'],
+ $templateRuntimes['DENO']
+ ),
'adapters' => [
'static' => [
'key' => 'static',
@@ -284,7 +328,11 @@ return [
'name' => 'React Native',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
- 'runtimes' => $templateRuntimes['NODE'],
+ 'runtimes' => array_merge(
+ $templateRuntimes['NODE'],
+ $templateRuntimes['BUN'],
+ $templateRuntimes['DENO']
+ ),
'adapters' => [
'static' => [
'key' => 'static',
@@ -301,7 +349,11 @@ return [
'name' => 'Vite',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
- 'runtimes' => $templateRuntimes['NODE'],
+ 'runtimes' => array_merge(
+ $templateRuntimes['NODE'],
+ $templateRuntimes['BUN'],
+ $templateRuntimes['DENO']
+ ),
'adapters' => [
'static' => [
'key' => 'static',
@@ -317,7 +369,11 @@ return [
'name' => 'Other',
'screenshotSleep' => 3000,
'buildRuntime' => 'node-22',
- 'runtimes' => $templateRuntimes['NODE'],
+ 'runtimes' => array_merge(
+ $templateRuntimes['NODE'],
+ $templateRuntimes['BUN'],
+ $templateRuntimes['DENO']
+ ),
'adapters' => [
'static' => [
'key' => 'static',
diff --git a/app/config/locale/templates.php b/app/config/locale/templates.php
index 6aa376678a..680034554b 100644
--- a/app/config/locale/templates.php
+++ b/app/config/locale/templates.php
@@ -9,11 +9,5 @@ return [
'mfaChallenge',
'sessionAlert',
'otpSession'
- ],
- 'sms' => [
- 'verification',
- 'login',
- 'invitation',
- 'mfaChallenge'
]
];
diff --git a/app/config/locale/translations/es.json b/app/config/locale/translations/es.json
index 21a406b418..1bbc8062be 100644
--- a/app/config/locale/translations/es.json
+++ b/app/config/locale/translations/es.json
@@ -28,6 +28,16 @@
"emails.invitation.thanks": "Gracias.,",
"emails.invitation.buttonText": "Aceptar invitación a {{team}}",
"emails.invitation.signature": "El equipo de {{project}}",
+ "emails.sessionAlert.subject": "Alerta de seguridad: nueva sesión en tu cuenta de {{project}}",
+ "emails.sessionAlert.preview": "Nuevo inicio de sesión detectado en {{project}} a las {{time}} UTC.",
+ "emails.sessionAlert.hello": "Hola {{user}},",
+ "emails.sessionAlert.body": "Se ha creado una nueva sesión en tu cuenta de {{b}}{{project}}{{/b}}, {{b}}el {{date}} de {{year}} a las {{time}} UTC{{/b}}.\nEstos son los detalles de la nueva sesión:",
+ "emails.sessionAlert.listDevice": "Dispositivo: {{b}}{{device}}{{/b}}",
+ "emails.sessionAlert.listIpAddress": "Dirección IP: {{b}}{{ipAddress}}{{/b}}",
+ "emails.sessionAlert.listCountry": "País: {{b}}{{country}}{{/b}}",
+ "emails.sessionAlert.footer": "Si has sido tú, no tienes que hacer nada más.\nSi no has iniciado esta sesión o sospechas actividad no autorizada, protege tu cuenta.",
+ "emails.sessionAlert.thanks": "Gracias,",
+ "emails.sessionAlert.signature": "El equipo de {{project}}",
"locale.country.unknown": "Desconocido",
"countries.af": "Afganistán",
"countries.ao": "Angola",
diff --git a/app/config/oAuthProviders.php b/app/config/oAuthProviders.php
index cda6459519..3b492fd8bf 100644
--- a/app/config/oAuthProviders.php
+++ b/app/config/oAuthProviders.php
@@ -167,6 +167,17 @@ return [
'mock' => false,
'class' => 'Appwrite\\Auth\\OAuth2\\Figma',
],
+ 'fusionauth' => [
+ 'name' => 'FusionAuth',
+ 'developers' => 'https://fusionauth.io/docs/',
+ 'icon' => 'icon-fusionauth',
+ 'enabled' => true,
+ 'sandbox' => false,
+ 'form' => 'fusionauth.phtml',
+ 'beta' => false,
+ 'mock' => false,
+ 'class' => 'Appwrite\\Auth\\OAuth2\\FusionAuth',
+ ],
'github' => [
'name' => 'GitHub',
'developers' => 'https://developer.github.com/',
@@ -200,6 +211,28 @@ return [
'mock' => false,
'class' => 'Appwrite\\Auth\\OAuth2\\Google',
],
+ 'keycloak' => [
+ 'name' => 'Keycloak',
+ 'developers' => 'https://www.keycloak.org/documentation',
+ 'icon' => 'icon-keycloak',
+ 'enabled' => true,
+ 'sandbox' => false,
+ 'form' => 'keycloak.phtml',
+ 'beta' => false,
+ 'mock' => false,
+ 'class' => 'Appwrite\\Auth\\OAuth2\\Keycloak',
+ ],
+ 'kick' => [
+ 'name' => 'Kick',
+ 'developers' => 'https://docs.kick.com/',
+ 'icon' => 'icon-kick',
+ 'enabled' => true,
+ 'sandbox' => false,
+ 'form' => false,
+ 'beta' => false,
+ 'mock' => false,
+ 'class' => 'Appwrite\\Auth\\OAuth2\\Kick',
+ ],
'linkedin' => [
'name' => 'LinkedIn',
'developers' => 'https://developer.linkedin.com/',
diff --git a/app/config/roles.php b/app/config/roles.php
index 116e8ac932..abb8d4481f 100644
--- a/app/config/roles.php
+++ b/app/config/roles.php
@@ -12,6 +12,8 @@ $member = [
'account',
'teams.read',
'teams.write',
+ 'presences.read',
+ 'presences.write',
'documents.read',
'documents.write',
'rows.read',
@@ -21,8 +23,8 @@ $member = [
'projects.read',
'locale.read',
'avatars.read',
- 'execution.read',
- 'execution.write',
+ 'executions.read',
+ 'executions.write',
'targets.read',
'targets.write',
'subscribers.write',
@@ -47,6 +49,8 @@ $admins = [
'buckets.write',
'users.read',
'users.write',
+ 'presences.read',
+ 'presences.write',
'databases.read',
'databases.write',
'collections.read',
@@ -55,6 +59,14 @@ $admins = [
'tables.write',
'platforms.read',
'platforms.write',
+ 'oauth2.read',
+ 'oauth2.write',
+ 'mocks.read',
+ 'mocks.write',
+ 'project.policies.read',
+ 'project.policies.write',
+ 'templates.read',
+ 'templates.write',
'projects.write',
'keys.read',
'keys.write',
@@ -73,8 +85,8 @@ $admins = [
'sites.write',
'log.read',
'log.write',
- 'execution.read',
- 'execution.write',
+ 'executions.read',
+ 'executions.write',
'rules.read',
'rules.write',
'migrations.read',
@@ -95,6 +107,10 @@ $admins = [
'tokens.write',
'schedules.read',
'schedules.write',
+ 'insights.read',
+ 'insights.write',
+ 'reports.read',
+ 'reports.write',
];
return [
@@ -115,7 +131,7 @@ return [
'files.write',
'locale.read',
'avatars.read',
- 'execution.write',
+ 'executions.write',
],
],
User::ROLE_USERS => [
diff --git a/app/config/scopes/organization.php b/app/config/scopes/organization.php
index 228a1437f2..d74452f259 100644
--- a/app/config/scopes/organization.php
+++ b/app/config/scopes/organization.php
@@ -4,17 +4,23 @@
return [
"projects.read" => [
- "description" => 'Access to read organization\'s projects',
+ "description" => 'Access to read organization projects',
+ "category" => "Projects",
],
"projects.write" => [
"description" =>
- "Access to create, update, and delete projects in organization",
+ "Access to create, update, and delete organization projects",
+ "category" => "Projects",
],
"devKeys.read" => [
"description" => 'Access to read project\'s development keys',
+ "category" => "Other",
+ "deprecated" => true,
],
"devKeys.write" => [
"description" =>
"Access to create, update, and delete project\'s development keys",
+ "category" => "Other",
+ "deprecated" => true,
],
];
diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php
index 6c7f75c08e..cd50abd57b 100644
--- a/app/config/scopes/project.php
+++ b/app/config/scopes/project.php
@@ -1,207 +1,390 @@
[
- 'description' => 'Access to create, update, and delete user sessions',
- ],
- 'users.read' => [
- 'description' => 'Access to read your project\'s users',
- ],
- 'users.write' => [
- 'description' => 'Access to create, update, and delete your project\'s users',
- ],
- 'teams.read' => [
- 'description' => 'Access to read your project\'s teams',
- ],
- 'teams.write' => [
- 'description' => 'Access to create, update, and delete your project\'s teams',
- ],
- 'databases.read' => [
- 'description' => 'Access to read your project\'s databases',
- ],
- 'databases.write' => [
- 'description' => 'Access to create, update, and delete your project\'s databases',
- ],
- 'collections.read' => [
- 'description' => 'Access to read your project\'s database collections',
- ],
- 'collections.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database collections',
- ],
- 'tables.read' => [
- 'description' => 'Access to read your project\'s database tables',
- ],
- 'tables.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database tables',
- ],
- 'attributes.read' => [
- 'description' => 'Access to read your project\'s database collection\'s attributes',
- ],
- 'attributes.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database collection\'s attributes',
- ],
- 'columns.read' => [
- 'description' => 'Access to read your project\'s database table\'s columns',
- ],
- 'columns.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database table\'s columns',
- ],
- 'indexes.read' => [
- 'description' => 'Access to read your project\'s database table\'s indexes',
- ],
- 'indexes.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database table\'s indexes',
- ],
- 'documents.read' => [
- 'description' => 'Access to read your project\'s database documents',
- ],
- 'documents.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database documents',
- ],
- 'rows.read' => [
- 'description' => 'Access to read your project\'s database rows',
- ],
- 'rows.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database rows',
- ],
- 'files.read' => [
- 'description' => 'Access to read your project\'s storage files and preview images',
- ],
- 'files.write' => [
- 'description' => 'Access to create, update, and delete your project\'s storage files',
- ],
- 'buckets.read' => [
- 'description' => 'Access to read your project\'s storage buckets',
- ],
- 'buckets.write' => [
- 'description' => 'Access to create, update, and delete your project\'s storage buckets',
- ],
- 'functions.read' => [
- 'description' => 'Access to read your project\'s functions and code deployments',
- ],
- 'functions.write' => [
- 'description' => 'Access to create, update, and delete your project\'s functions and code deployments',
- ],
- 'sites.read' => [
- 'description' => 'Access to read your project\'s sites and deployments',
- ],
- 'sites.write' => [
- 'description' => 'Access to create, update, and delete your project\'s sites and deployments',
- ],
- 'log.read' => [
- 'description' => 'Access to read your site\'s logs',
- ],
- 'log.write' => [
- 'description' => 'Access to update, and delete your site\'s logs',
- ],
- 'execution.read' => [
- 'description' => 'Access to read your project\'s execution logs',
- ],
- 'execution.write' => [
- 'description' => 'Access to execute your project\'s functions',
- ],
- 'locale.read' => [
- 'description' => 'Access to access your project\'s Locale service',
- ],
- 'avatars.read' => [
- 'description' => 'Access to access your project\'s Avatars service',
- ],
- 'health.read' => [
- 'description' => 'Access to read your project\'s health status',
- ],
- 'providers.read' => [
- 'description' => 'Access to read your project\'s providers',
- ],
- 'providers.write' => [
- 'description' => 'Access to create, update, and delete your project\'s providers',
- ],
- 'messages.read' => [
- 'description' => 'Access to read your project\'s messages',
- ],
- 'messages.write' => [
- 'description' => 'Access to create, update, and delete your project\'s messages',
- ],
- 'topics.read' => [
- 'description' => 'Access to read your project\'s topics',
- ],
- 'topics.write' => [
- 'description' => 'Access to create, update, and delete your project\'s topics',
- ],
- 'subscribers.read' => [
- 'description' => 'Access to read your project\'s subscribers',
- ],
- 'subscribers.write' => [
- 'description' => 'Access to create, update, and delete your project\'s subscribers',
- ],
- 'targets.read' => [
- 'description' => 'Access to read your project\'s targets',
- ],
- 'targets.write' => [
- 'description' => 'Access to create, update, and delete your project\'s targets',
- ],
- 'rules.read' => [
- 'description' => 'Access to read your project\'s proxy rules',
- ],
- 'rules.write' => [
- 'description' => 'Access to create, update, and delete your project\'s proxy rules',
- ],
- 'schedules.read' => [
- 'description' => 'Access to read your project\'s schedules',
- ],
- 'schedules.write' => [
- 'description' => 'Access to create, update, and delete your project\'s schedules',
- ],
- 'migrations.read' => [
- 'description' => 'Access to read your project\'s migrations',
- ],
- 'migrations.write' => [
- 'description' => 'Access to create, update, and delete your project\'s migrations.',
- ],
- 'vcs.read' => [
- 'description' => 'Access to read your project\'s VCS repositories',
- ],
- 'vcs.write' => [
- 'description' => 'Access to create, update, and delete your project\'s VCS repositories',
- ],
- 'assistant.read' => [
- 'description' => 'Access to read the Assistant service',
- ],
- 'tokens.read' => [
- 'description' => 'Access to read your project\'s tokens',
- ],
- 'tokens.write' => [
- 'description' => 'Access to create, update, and delete your project\'s tokens',
- ],
- "webhooks.read" => [
- "description" =>
- "Access to read project\'s webhooks",
- ],
- "webhooks.write" => [
- "description" =>
- "Access to create, update, and delete project\'s webhooks",
- ],
+// List of publicly visible scopes
+return [
+ // Project
"project.read" => [
"description" =>
"Access to read project\'s information",
+ "category" => "Project",
],
"project.write" => [
"description" =>
"Access to update project\'s information",
+ "category" => "Project",
],
"keys.read" => [
"description" =>
"Access to read project\'s keys",
+ "category" => "Project",
],
"keys.write" => [
"description" =>
"Access to create, update, and delete project\'s keys",
+ "category" => "Project",
],
"platforms.read" => [
"description" =>
"Access to read project\'s platforms",
+ "category" => "Project",
],
"platforms.write" => [
"description" =>
"Access to create, update, and delete project\'s platforms",
+ "category" => "Project",
+ ],
+ "mocks.read" => [
+ "description" =>
+ "Access to read project\'s mocks",
+ "category" => "Project",
+ ],
+ "mocks.write" => [
+ "description" =>
+ "Access to create, update, and delete project\'s mocks",
+ "category" => "Project",
+ ],
+ "policies.read" => [
+ "description" =>
+ "Access to read project\'s policies. Replaced by \'project.policies.read\' for more granular control",
+ "category" => "Project",
+ 'deprecated' => true,
+ ],
+ "policies.write" => [
+ "description" =>
+ "Access to update project\'s policies. Replaces by \'project.policies.write\' for more granular control",
+ "category" => "Project",
+ 'deprecated' => true,
+ ],
+ "project.policies.read" => [
+ "description" =>
+ "Access to read project\'s policies",
+ "category" => "Project",
+ ],
+ "project.policies.write" => [
+ "description" =>
+ "Access to update project\'s policies",
+ "category" => "Project",
+ ],
+ "templates.read" => [
+ "description" =>
+ "Access to read project\'s templates",
+ "category" => "Project",
+ ],
+ "templates.write" => [
+ "description" =>
+ "Access to create, update, and delete project\'s templates",
+ "category" => "Project",
+ ],
+ "oauth2.read" => [
+ "description" =>
+ "Access to read project\'s OAuth2 configuration",
+ "category" => "Project",
+ ],
+ "oauth2.write" => [
+ "description" =>
+ "Access to update project\'s OAuth2 configuration",
+ "category" => "Project",
+ ],
+
+ // Auth
+ 'users.read' => [
+ 'description' => 'Access to read users',
+ 'category' => 'Auth',
+ ],
+ 'users.write' => [
+ 'description' => 'Access to create, update, and delete users',
+ 'category' => 'Auth',
+ ],
+ 'sessions.read' => [
+ 'description' => 'Access to read user sessions',
+ 'category' => 'Auth',
+ ],
+ 'sessions.write' => [
+ 'description' => 'Access to create, update, and delete user sessions',
+ 'category' => 'Auth',
+ ],
+ 'teams.read' => [
+ 'description' => 'Access to read teams',
+ 'category' => 'Auth',
+ ],
+ 'teams.write' => [
+ 'description' => 'Access to create, update, and delete teams',
+ 'category' => 'Auth',
+ ],
+
+ // Databases
+ 'databases.read' => [
+ 'description' => 'Access to read databases',
+ 'category' => 'Databases',
+ ],
+ 'databases.write' => [
+ 'description' => 'Access to create, update, and delete databases',
+ 'category' => 'Databases',
+ ],
+ 'tables.read' => [
+ 'description' => 'Access to read database tables',
+ 'category' => 'Databases',
+ ],
+ 'tables.write' => [
+ 'description' => 'Access to create, update, and delete database tables',
+ 'category' => 'Databases',
+ ],
+ 'columns.read' => [
+ 'description' => 'Access to read database table columns',
+ 'category' => 'Databases',
+ ],
+ 'columns.write' => [
+ 'description' => 'Access to create, update, and delete database table columns',
+ 'category' => 'Databases',
+ ],
+ 'indexes.read' => [
+ 'description' => 'Access to read database table indexes',
+ 'category' => 'Databases',
+ ],
+ 'indexes.write' => [
+ 'description' => 'Access to create, update, and delete database table indexes',
+ 'category' => 'Databases',
+ ],
+ 'rows.read' => [
+ 'description' => 'Access to read database table rows',
+ 'category' => 'Databases',
+ ],
+ 'rows.write' => [
+ 'description' => 'Access to create, update, and delete database table rows',
+ 'category' => 'Databases',
+ ],
+ 'collections.read' => [
+ 'description' => 'Access to read database collections',
+ 'category' => 'Databases',
+ 'deprecated' => true,
+ ],
+ 'collections.write' => [
+ 'description' => 'Access to create, update, and delete database collections',
+ 'category' => 'Databases',
+ 'deprecated' => true,
+ ],
+ 'attributes.read' => [
+ 'description' => 'Access to read database collection attributes',
+ 'category' => 'Databases',
+ 'deprecated' => true,
+ ],
+ 'attributes.write' => [
+ 'description' => 'Access to create, update, and delete database collection attributes',
+ 'category' => 'Databases',
+ 'deprecated' => true,
+ ],
+ 'documents.read' => [
+ 'description' => 'Access to read database collection documents',
+ 'category' => 'Databases',
+ 'deprecated' => true,
+ ],
+ 'documents.write' => [
+ 'description' => 'Access to create, update, and delete database collection documents',
+ 'category' => 'Databases',
+ 'deprecated' => true,
+ ],
+
+ // Storage
+ 'buckets.read' => [
+ 'description' => 'Access to read storage buckets',
+ 'category' => 'Storage',
+ ],
+ 'buckets.write' => [
+ 'description' => 'Access to create, update, and delete storage buckets',
+ 'category' => 'Storage',
+ ],
+ 'files.read' => [
+ 'description' => 'Access to read storage files and preview images',
+ 'category' => 'Storage',
+ ],
+ 'files.write' => [
+ 'description' => 'Access to create, update, and delete storage files',
+ 'category' => 'Storage',
+ ],
+ 'tokens.read' => [
+ 'description' => 'Access to read storage file tokens',
+ 'category' => 'Storage',
+ ],
+ 'tokens.write' => [
+ 'description' => 'Access to create, update, and delete storage file tokens',
+ 'category' => 'Storage',
+ ],
+
+ // Functions
+ 'functions.read' => [
+ 'description' => 'Access to read functions and deployments',
+ 'category' => 'Functions',
+ ],
+ 'functions.write' => [
+ 'description' => 'Access to create, update, and delete functions and deployments',
+ 'category' => 'Functions',
+ ],
+ 'executions.read' => [
+ 'description' => 'Access to read function executions',
+ 'category' => 'Functions',
+ ],
+ 'executions.write' => [
+ 'description' => 'Access to create function executions',
+ 'category' => 'Functions',
+ ],
+ 'execution.read' => [
+ 'description' => 'Access to read function executions. This scope is deprecated for consistency purposes, and replaced by `executions.read`.',
+ 'category' => 'Functions',
+ 'deprecated' => true,
+ ],
+ 'execution.write' => [
+ 'description' => 'Access to create function executions. This scope is deprecated for consistency purposes, and replaced by `executions.write`.',
+ 'category' => 'Functions',
+ 'deprecated' => true,
+ ],
+
+ // Sites
+ 'sites.read' => [
+ 'description' => 'Access to read sites and deployments',
+ 'category' => 'Sites',
+ ],
+ 'sites.write' => [
+ 'description' => 'Access to create, update, and delete sites and deployments',
+ 'category' => 'Sites',
+ ],
+ 'log.read' => [
+ 'description' => 'Access to read site logs',
+ 'category' => 'Sites',
+ ],
+ 'log.write' => [
+ 'description' => 'Access to update, and delete site logs',
+ 'category' => 'Sites',
+ ],
+
+ // Messaging
+ 'providers.read' => [
+ 'description' => 'Access to read messaging providers',
+ 'category' => 'Messaging',
+ ],
+ 'providers.write' => [
+ 'description' => 'Access to create, update, and delete messaging providers',
+ 'category' => 'Messaging',
+ ],
+ 'topics.read' => [
+ 'description' => 'Access to read messaging topics',
+ 'category' => 'Messaging',
+ ],
+ 'topics.write' => [
+ 'description' => 'Access to create, update, and delete messaging topics',
+ 'category' => 'Messaging',
+ ],
+ 'subscribers.read' => [
+ 'description' => 'Access to read messaging subscribers',
+ 'category' => 'Messaging',
+ ],
+ 'subscribers.write' => [
+ 'description' => 'Access to create, update, and delete messaging subscribers',
+ 'category' => 'Messaging',
+ ],
+ 'targets.read' => [
+ 'description' => 'Access to read messaging targets',
+ 'category' => 'Messaging',
+ ],
+ 'targets.write' => [
+ 'description' => 'Access to create, update, and delete messaging targets',
+ 'category' => 'Messaging',
+ ],
+ 'messages.read' => [
+ 'description' => 'Access to read messaging messages',
+ 'category' => 'Messaging',
+ ],
+ 'messages.write' => [
+ 'description' => 'Access to create, update, and delete messaging messages',
+ 'category' => 'Messaging',
+ ],
+
+ // Proxy
+ 'rules.read' => [
+ 'description' => 'Access to read proxy rules.',
+ 'category' => 'Proxy',
+ ],
+ 'rules.write' => [
+ 'description' => 'Access to create, update, and delete proxy rules.',
+ 'category' => 'Proxy',
+ ],
+
+ // Other
+ "webhooks.read" => [
+ "description" =>
+ "Access to read webhooks",
+ 'category' => 'Other',
+ ],
+ "webhooks.write" => [
+ "description" =>
+ "Access to create, update, and delete webhooks",
+ 'category' => 'Other',
+ ],
+ 'locale.read' => [
+ 'description' => 'Access to use Locale service',
+ 'category' => 'Other',
+ ],
+ 'avatars.read' => [
+ 'description' => 'Access to use Avatars service',
+ 'category' => 'Other',
+ ],
+ 'health.read' => [
+ 'description' => 'Access to use Health service',
+ 'category' => 'Other',
+ ],
+ 'assistant.read' => [
+ 'description' => 'Access to use Assistant service',
+ 'category' => 'Other',
+ ],
+ 'migrations.read' => [
+ 'description' => 'Access to read migrations',
+ 'category' => 'Other',
+ ],
+ 'migrations.write' => [
+ 'description' => 'Access to create, update, and delete migrations.',
+ 'category' => 'Other',
+ ],
+
+ // TODO: Figure out where to move those
+ 'schedules.read' => [
+ 'description' => 'Access to read schedules.',
+ 'category' => 'Other',
+ ],
+ 'schedules.write' => [
+ 'description' => 'Access to create, update, and delete schedules.',
+ 'category' => 'Other',
+ ],
+ 'vcs.read' => [
+ 'description' => 'Access to read resources under VCS service.',
+ 'category' => 'Other',
+ ],
+ 'vcs.write' => [
+ 'description' => 'Access to create, update, and delete resources under VCS service.',
+ 'category' => 'Other',
+ ],
+
+ // Advisor
+ 'insights.read' => [
+ 'description' => 'Access to read insights under Advisor service.',
+ 'category' => 'Advisor',
+ ],
+ 'insights.write' => [
+ 'description' => 'Reserved for Advisor insight ingestion outside CE.',
+ 'category' => 'Advisor',
+ ],
+ 'reports.read' => [
+ 'description' => 'Access to read reports under Advisor service.',
+ 'category' => 'Advisor',
+ ],
+ 'reports.write' => [
+ 'description' => 'Access to delete reports under Advisor service.',
+ 'category' => 'Advisor',
+ ],
+ 'presences.read' => [
+ 'description' => 'Access to read your project\'s presences',
+ 'category' => 'Presences',
+ ],
+ 'presences.write' => [
+ 'description' => 'Access to create, update, and delete your project\'s presences',
+ 'category' => 'Presences',
],
];
diff --git a/app/config/sdks.php b/app/config/sdks.php
index 47dc8845b6..36a973167d 100644
--- a/app/config/sdks.php
+++ b/app/config/sdks.php
@@ -8,55 +8,6 @@ return [
'enabled' => true,
'beta' => false,
'sdks' => [
- [
- 'key' => 'web',
- 'name' => 'Web',
- 'version' => '22.4.0',
- 'url' => 'https://github.com/appwrite/sdk-for-web',
- 'package' => 'https://www.npmjs.com/package/appwrite',
- 'enabled' => true,
- 'beta' => false,
- 'dev' => false,
- 'hidden' => false,
- 'family' => APP_SDK_PLATFORM_CLIENT,
- 'prism' => 'javascript',
- 'source' => \realpath(__DIR__ . '/../sdks/client-web'),
- 'gitUrl' => 'git@github.com:appwrite/sdk-for-web.git',
- 'gitRepoName' => 'sdk-for-web',
- 'gitUserName' => 'appwrite',
- 'gitBranch' => 'dev',
- 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/web/CHANGELOG.md'),
- 'demos' => [
- [
- 'icon' => 'react.svg',
- 'name' => 'Todo App with React JS',
- 'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.',
- 'source' => 'https://github.com/appwrite/todo-with-react',
- 'url' => 'https://appwrite-todo-with-react.vercel.app/',
- ],
- [
- 'icon' => 'vue.svg',
- 'name' => 'Todo App with Vue JS',
- 'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.',
- 'source' => 'https://github.com/appwrite/todo-with-vue',
- 'url' => 'https://appwrite-todo-with-vue.vercel.app/',
- ],
- [
- 'icon' => 'angular.svg',
- 'name' => 'Todo App with Angular',
- 'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.',
- 'source' => 'https://github.com/appwrite/todo-with-angular',
- 'url' => 'https://appwrite-todo-with-angular.vercel.app/',
- ],
- [
- 'icon' => 'svelte.svg',
- 'name' => 'Todo App with Svelte',
- 'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.',
- 'source' => 'https://github.com/appwrite/todo-with-svelte',
- 'url' => 'https://appwrite-todo-with-svelte.vercel.app/',
- ],
- ]
- ],
[
'key' => 'flutter',
'name' => 'Flutter',
@@ -300,6 +251,46 @@ return [
'repoBranch' => 'main',
'changelog' => \realpath(__DIR__ . '/../../docs/sdks/cursor-plugin/CHANGELOG.md'),
],
+ [
+ 'key' => 'claude-plugin',
+ 'name' => 'ClaudePlugin',
+ 'version' => '0.1.0',
+ 'url' => 'https://github.com/appwrite/claude-plugin.git',
+ 'enabled' => true,
+ 'beta' => false,
+ 'dev' => false,
+ 'hidden' => false,
+ 'spec' => 'static',
+ 'family' => APP_SDK_PLATFORM_STATIC,
+ 'prism' => 'claude-plugin',
+ 'source' => \realpath(__DIR__ . '/../sdks/static-claude-plugin'),
+ 'gitUrl' => 'git@github.com:appwrite/claude-plugin.git',
+ 'gitRepoName' => 'claude-plugin',
+ 'gitUserName' => 'appwrite',
+ 'gitBranch' => 'dev',
+ 'repoBranch' => 'main',
+ 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/claude-plugin/CHANGELOG.md'),
+ ],
+ [
+ 'key' => 'codex-plugin',
+ 'name' => 'CodexPlugin',
+ 'version' => '0.1.1',
+ 'url' => 'https://github.com/appwrite/codex-plugin.git',
+ 'enabled' => true,
+ 'beta' => false,
+ 'dev' => false,
+ 'hidden' => false,
+ 'spec' => 'static',
+ 'family' => APP_SDK_PLATFORM_STATIC,
+ 'prism' => 'codex-plugin',
+ 'source' => \realpath(__DIR__ . '/../sdks/static-codex-plugin'),
+ 'gitUrl' => 'git@github.com:appwrite/codex-plugin.git',
+ 'gitRepoName' => 'codex-plugin',
+ 'gitUserName' => 'appwrite',
+ 'gitBranch' => 'dev',
+ 'repoBranch' => 'main',
+ 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/codex-plugin/CHANGELOG.md'),
+ ],
],
],
@@ -310,6 +301,55 @@ return [
'enabled' => true,
'beta' => false,
'sdks' => [
+ [
+ 'key' => 'web',
+ 'name' => 'Web',
+ 'version' => '22.4.0',
+ 'url' => 'https://github.com/appwrite/sdk-for-web',
+ 'package' => 'https://www.npmjs.com/package/appwrite',
+ 'enabled' => true,
+ 'beta' => false,
+ 'dev' => false,
+ 'hidden' => false,
+ 'family' => APP_SDK_PLATFORM_SERVER,
+ 'prism' => 'javascript',
+ 'source' => \realpath(__DIR__ . '/../sdks/client-web'),
+ 'gitUrl' => 'git@github.com:appwrite/sdk-for-web.git',
+ 'gitRepoName' => 'sdk-for-web',
+ 'gitUserName' => 'appwrite',
+ 'gitBranch' => 'dev',
+ 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/web/CHANGELOG.md'),
+ 'demos' => [
+ [
+ 'icon' => 'react.svg',
+ 'name' => 'Todo App with React JS',
+ 'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.',
+ 'source' => 'https://github.com/appwrite/todo-with-react',
+ 'url' => 'https://appwrite-todo-with-react.vercel.app/',
+ ],
+ [
+ 'icon' => 'vue.svg',
+ 'name' => 'Todo App with Vue JS',
+ 'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.',
+ 'source' => 'https://github.com/appwrite/todo-with-vue',
+ 'url' => 'https://appwrite-todo-with-vue.vercel.app/',
+ ],
+ [
+ 'icon' => 'angular.svg',
+ 'name' => 'Todo App with Angular',
+ 'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.',
+ 'source' => 'https://github.com/appwrite/todo-with-angular',
+ 'url' => 'https://appwrite-todo-with-angular.vercel.app/',
+ ],
+ [
+ 'icon' => 'svelte.svg',
+ 'name' => 'Todo App with Svelte',
+ 'description' => 'A simple Todo app that uses both the Appwrite account and database APIs.',
+ 'source' => 'https://github.com/appwrite/todo-with-svelte',
+ 'url' => 'https://appwrite-todo-with-svelte.vercel.app/',
+ ],
+ ]
+ ],
[
'key' => 'nodejs',
'name' => 'Node.js',
diff --git a/app/config/services.php b/app/config/services.php
index 548f659a81..f829937623 100644
--- a/app/config/services.php
+++ b/app/config/services.php
@@ -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',
@@ -308,5 +308,19 @@ return [
'optional' => true,
'icon' => '/images/services/messaging.png',
'platforms' => ['client', 'server', 'console'],
- ]
+ ],
+ 'advisor' => [
+ 'key' => 'advisor',
+ 'name' => 'Advisor',
+ 'subtitle' => 'The Advisor service surfaces actionable reports about your project resources, with CTA descriptors for one-click remediation in the console.',
+ 'description' => '/docs/services/advisor.md',
+ 'controller' => '', // Uses modules
+ 'sdk' => true,
+ 'docs' => true,
+ 'docsUrl' => 'https://appwrite.io/docs/server/advisor',
+ 'tests' => true,
+ 'optional' => true,
+ 'icon' => '/images/services/insights.png',
+ 'platforms' => ['server', 'console'],
+ ],
];
diff --git a/app/config/templates/function.php b/app/config/templates/function.php
index df3a569705..c6ac446509 100644
--- a/app/config/templates/function.php
+++ b/app/config/templates/function.php
@@ -79,12 +79,13 @@ return [
...getRuntimes($templateRuntimes['DENO'], 'deno cache src/main.ts', 'src/main.ts', 'deno/starter', $allowList),
...getRuntimes($templateRuntimes['BUN'], 'bun install', 'src/main.ts', 'bun/starter', $allowList),
...getRuntimes($templateRuntimes['RUBY'], 'bundle install', 'lib/main.rb', 'ruby/starter', $allowList),
+ ...getRuntimes($templateRuntimes['RUST'], '', 'main.rs', 'rust/starter', $allowList),
],
- 'instructions' => 'For documentation and instructions check out file.',
+ 'instructions' => 'For documentation and instructions check out the templates repository.',
'vcsProvider' => 'github',
'providerRepositoryId' => 'templates',
'providerOwner' => 'appwrite',
- 'providerVersion' => '0.2.*',
+ 'providerVersion' => '0.3.*',
'variables' => [],
'scopes' => ['users.read']
],
diff --git a/app/config/templates/site.php b/app/config/templates/site.php
index 26f8e39817..b26d31f475 100644
--- a/app/config/templates/site.php
+++ b/app/config/templates/site.php
@@ -1487,13 +1487,13 @@ return [
]
],
[
- 'key' => 'crm-dashboard-react-admin',
- 'name' => 'CRM dashboard with React Admin',
- 'tagline' => 'A React-based admin dashboard template with CRM features.',
+ 'key' => 'dashboard-react-admin',
+ 'name' => 'E-commerce dashboard with React Admin',
+ 'tagline' => 'A React-based admin dashboard template with e-commerce features.',
'score' => 4, // 0 to 10 based on looks of screenshot (avoid 1,2,3,8,9,10 if possible)
- 'useCases' => [SiteUseCases::DASHBOARD],
- 'screenshotDark' => $url . '/images/sites/templates/crm-dashboard-react-admin-dark.png',
- 'screenshotLight' => $url . '/images/sites/templates/crm-dashboard-react-admin-light.png',
+ 'useCases' => [SiteUseCases::DASHBOARD, SiteUseCases::ECOMMERCE],
+ 'screenshotDark' => $url . '/images/sites/templates/dashboard-react-admin-dark.png',
+ 'screenshotLight' => $url . '/images/sites/templates/dashboard-react-admin-light.png',
'frameworks' => [
getFramework('REACT', [
'providerRootDirectory' => './react/react-admin',
diff --git a/app/config/variables.php b/app/config/variables.php
index c834656ff4..90df9b4518 100644
--- a/app/config/variables.php
+++ b/app/config/variables.php
@@ -1336,6 +1336,15 @@ return [
'category' => 'Migrations',
'description' => '',
'variables' => [
+ [
+ 'name' => '_APP_MIGRATION_HOST',
+ 'description' => 'Internal hostname the migrations worker uses to reach this instance\'s API (for migrations and CSV/JSON imports & exports). Defaults to \'appwrite\', the API service name in the standard Docker Compose setup. Only change this for non-standard deployments.',
+ 'introduction' => '1.9.0',
+ 'default' => 'appwrite',
+ 'required' => false,
+ 'question' => '',
+ 'filter' => ''
+ ],
[
'name' => '_APP_MIGRATIONS_FIREBASE_CLIENT_ID',
'description' => 'Google OAuth client ID. You can find it in your GCP application settings.',
diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php
index 37aee8cc6a..f764dd9fec 100644
--- a/app/controllers/api/account.php
+++ b/app/controllers/api/account.php
@@ -11,10 +11,13 @@ use Appwrite\Auth\Validator\PersonalData;
use Appwrite\Auth\Validator\Phone;
use Appwrite\Bus\Events\SessionCreated;
use Appwrite\Detector\Detector;
-use Appwrite\Event\Delete;
use Appwrite\Event\Event;
-use Appwrite\Event\Mail;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Message\Delete as DeleteMessage;
+use Appwrite\Event\Message\Mail as MailMessage;
+use Appwrite\Event\Message\Messaging as MessagingMessage;
+use Appwrite\Event\Publisher\Delete as DeletePublisher;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Hooks\Hooks;
use Appwrite\Network\Validator\Redirect;
@@ -133,9 +136,6 @@ $createSession = function (string $userId, string $secret, Request $request, Res
});
$provider = match ($verifiedToken->getAttribute('type')) {
- TOKEN_TYPE_VERIFICATION,
- TOKEN_TYPE_RECOVERY,
- TOKEN_TYPE_INVITE => SESSION_PROVIDER_EMAIL,
TOKEN_TYPE_MAGIC_URL => SESSION_PROVIDER_MAGIC_URL,
TOKEN_TYPE_PHONE => SESSION_PROVIDER_PHONE,
TOKEN_TYPE_OAUTH2 => $oauthProvider,
@@ -335,15 +335,15 @@ Http::post('/v1/account')
throw new Exception(Exception::GENERAL_INVALID_EMAIL);
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -453,7 +453,7 @@ Http::delete('/v1/account')
->groups(['api', 'account'])
->label('scope', 'account')
->label('audits.event', 'user.delete')
- ->label('audits.resource', 'user/{response.$id}')
+ ->label('audits.resource', 'user/{user.$id}')
->label('sdk', new Method(
namespace: 'account',
group: 'account',
@@ -473,28 +473,37 @@ Http::delete('/v1/account')
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
- ->inject('queueForDeletes')
- ->action(function (Document $user, Document $project, Response $response, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes) {
+ ->inject('publisherForDeletes')
+ ->inject('authorization')
+ ->action(function (Document $user, Document $project, Response $response, Database $dbForProject, Event $queueForEvents, DeletePublisher $publisherForDeletes, Authorization $authorization) {
if ($user->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
}
if ($project->getId() === 'console') {
- // get all memberships
$memberships = $user->getAttribute('memberships', []);
foreach ($memberships as $membership) {
- // prevent deletion if at least one active membership
- if ($membership->getAttribute('confirm', false)) {
- throw new Exception(Exception::USER_DELETION_PROHIBITED);
+ if (!$membership->getAttribute('confirm', false)) {
+ continue;
}
+
+ $team = $dbForProject->getDocument('teams', $membership->getAttribute('teamId'));
+ if ($team->isEmpty()) {
+ continue;
+ }
+
+ // Team is left as-is — we don't promote non-owner members to owner.
+ // Orphan teams are cleaned up later by Cloud's inactive project cleanup.
}
}
$dbForProject->deleteDocument('users', $user->getId());
- $queueForDeletes
- ->setType(DELETE_TYPE_DOCUMENT)
- ->setDocument($user);
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $project,
+ type: DELETE_TYPE_DOCUMENT,
+ document: $user,
+ ));
$queueForEvents
->setParam('userId', $user->getId())
@@ -576,12 +585,12 @@ Http::delete('/v1/account/sessions')
->inject('dbForProject')
->inject('locale')
->inject('queueForEvents')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('store')
->inject('proofForToken')
->inject('domainVerification')
->inject('cookieDomain')
- ->action(function (Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain) {
+ ->action(function (Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, DeletePublisher $publisherForDeletes, Store $store, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain) {
$protocol = $request->getProtocol();
$sessions = $user->getAttribute('sessions', []);
@@ -611,10 +620,11 @@ Http::delete('/v1/account/sessions')
$queueForEvents
->setPayload($response->output($session, Response::MODEL_SESSION));
- $queueForDeletes
- ->setType(DELETE_TYPE_SESSION_TARGETS)
- ->setDocument($session)
- ->trigger();
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $queueForEvents->getProject(),
+ type: DELETE_TYPE_SESSION_TARGETS,
+ document: $session,
+ ));
}
}
@@ -708,12 +718,12 @@ Http::delete('/v1/account/sessions/:sessionId')
->inject('dbForProject')
->inject('locale')
->inject('queueForEvents')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('store')
->inject('proofForToken')
->inject('domainVerification')
->inject('cookieDomain')
- ->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain) {
+ ->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, DeletePublisher $publisherForDeletes, Store $store, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain) {
$protocol = $request->getProtocol();
$sessionId = ($sessionId === 'current')
@@ -755,10 +765,11 @@ Http::delete('/v1/account/sessions/:sessionId')
->setParam('sessionId', $session->getId())
->setPayload($response->output($session, Response::MODEL_SESSION));
- $queueForDeletes
- ->setType(DELETE_TYPE_SESSION_TARGETS)
- ->setDocument($session)
- ->trigger();
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $queueForEvents->getProject(),
+ type: DELETE_TYPE_SESSION_TARGETS,
+ document: $session,
+ ));
$response->noContent();
return;
@@ -826,11 +837,11 @@ Http::patch('/v1/account/sessions/:sessionId')
$refreshToken = $session->getAttribute('providerRefreshToken', '');
$oAuthProviders = Config::getParam('oAuthProviders') ?? [];
$className = $oAuthProviders[$provider]['class'] ?? null;
- if (!empty($provider) && ($className === null || !\class_exists($className))) {
+ if (!empty($refreshToken) && ($className === null || !\class_exists($className))) {
throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED);
}
- if (!empty($provider) && $className !== null && \class_exists($className)) {
+ if ($className !== null && \class_exists($className)) {
$appId = $project->getAttribute('oAuthProviders', [])[$provider . 'Appid'] ?? '';
$appSecret = $project->getAttribute('oAuthProviders', [])[$provider . 'Secret'] ?? '{}';
@@ -1228,7 +1239,6 @@ Http::get('/v1/account/sessions/oauth2/:provider')
)
],
contentType: ContentType::HTML,
- hide: [APP_SDK_PLATFORM_SERVER],
))
->label('abuse-limit', 50)
->label('abuse-key', 'ip:{ip}')
@@ -1597,7 +1607,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
}
}
- if ($user === false || $user->isEmpty()) { // No user logged in or with OAuth2 provider ID, create new one or connect with account with same email
+ if ($user->isEmpty()) { // No user logged in or with OAuth2 provider ID, create new one or connect with account with same email
if (empty($email)) {
$failureRedirect(Exception::USER_UNAUTHORIZED, 'OAuth provider failed to return email.');
}
@@ -1614,7 +1624,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
}
// If user is not found, check if there is a user with the same email
- if ($user === false || $user->isEmpty()) {
+ if ($user->isEmpty()) {
$userWithEmail = $dbForProject->findOne('users', [
Query::equal('email', [$email]),
]);
@@ -1627,7 +1637,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
}
// If user is not found, check if there is an identity with the same email
- if ($user === false || $user->isEmpty()) {
+ if ($user->isEmpty()) {
$identityWithMatchingEmail = $dbForProject->findOne('identities', [
Query::equal('providerEmail', [$email]),
]);
@@ -1639,7 +1649,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
}
}
- if ($user === false || $user->isEmpty()) { // Last option -> create the user
+ if ($user->isEmpty()) { // Last option -> create the user
$limit = $project->getAttribute('auths', [])['limit'] ?? 0;
if ($limit !== 0) {
@@ -1672,15 +1682,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
$failureRedirect(Exception::GENERAL_INVALID_EMAIL);
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
$failureRedirect(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
$failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
$failureRedirect(Exception::USER_EMAIL_FREE);
}
@@ -1813,15 +1823,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
$failureRedirect(Exception::GENERAL_INVALID_EMAIL);
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
$failureRedirect(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
$failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
$failureRedirect(Exception::USER_EMAIL_FREE);
}
@@ -1947,7 +1957,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'));
}
- if (isset($sessionUpgrade) && $sessionUpgrade && isset($session)) {
+ if (isset($sessionUpgrade) && isset($session)) {
foreach ($user->getAttribute('targets', []) as $target) {
if ($target->getAttribute('providerType') !== MESSAGE_TYPE_PUSH) {
continue;
@@ -2109,12 +2119,12 @@ Http::post('/v1/account/tokens/magic-url')
->inject('dbForProject')
->inject('locale')
->inject('queueForEvents')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('plan')
->inject('proofForPassword')
->inject('platform')
->inject('authorization')
- ->action(function (string $userId, string $email, string $url, bool $phrase, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, array $plan, ProofsPassword $proofForPassword, array $platform, Authorization $authorization) {
+ ->action(function (string $userId, string $email, string $url, bool $phrase, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, MailPublisher $publisherForMails, array $plan, ProofsPassword $proofForPassword, array $platform, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled');
}
@@ -2171,15 +2181,15 @@ Http::post('/v1/account/tokens/magic-url')
throw new Exception(Exception::GENERAL_INVALID_EMAIL);
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -2265,7 +2275,10 @@ Http::post('/v1/account/tokens/magic-url')
$subject = $locale->getText("emails.magicSession.subject");
$preview = $locale->getText("emails.magicSession.preview");
- $customTemplate = $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? [];
+
+ $customTemplate =
+ $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ??
+ $project->getAttribute('templates', [])['email.magicSession-' . $locale->fallback] ?? [];
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$agentOs = $detector->getOS();
@@ -2295,8 +2308,9 @@ Http::post('/v1/account/tokens/magic-url')
$senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
-
- $replyTo = "";
+ $replyToEmail = '';
+ $replyToName = '';
+ $smtpConfig = [];
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -2305,16 +2319,14 @@ Http::post('/v1/account/tokens/magic-url')
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
- if (!empty($smtp['replyTo'])) {
- $replyTo = $smtp['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '';
+ if (!empty($smtpReplyToEmail)) {
+ $replyToEmail = $smtpReplyToEmail;
+ }
+ if (!empty($smtp['replyToName'])) {
+ $replyToName = $smtp['replyToName'];
}
-
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '');
if (!empty($customTemplate)) {
if (!empty($customTemplate['senderEmail'])) {
@@ -2323,18 +2335,30 @@ Http::post('/v1/account/tokens/magic-url')
if (!empty($customTemplate['senderName'])) {
$senderName = $customTemplate['senderName'];
}
- if (!empty($customTemplate['replyTo'])) {
- $replyTo = $customTemplate['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? '';
+ if (!empty($customReplyToEmail)) {
+ $replyToEmail = $customReplyToEmail;
+ }
+ if (!empty($customTemplate['replyToName'])) {
+ $replyToName = $customTemplate['replyToName'];
}
$body = $customTemplate['message'] ?? '';
$subject = $customTemplate['subject'] ?? $subject;
}
- $queueForMails
- ->setSmtpReplyTo($replyTo)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName);
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ];
}
$projectName = $project->getAttribute('name');
@@ -2356,18 +2380,17 @@ Http::post('/v1/account/tokens/magic-url')
'team' => '',
];
- $queueForMails
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body)
- ->appendVariables($emailVariables)
- ->setRecipient($email);
-
- if ($project->getId() === 'console') {
- $queueForMails->setSenderName($platform['emailSenderName']);
- }
-
- $queueForMails->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $email,
+ subject: $subject,
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ customMailOptions: $project->getId() === 'console' ? ['senderName' => $platform['emailSenderName']] : [],
+ platform: $platform,
+ ));
$token->setAttribute('secret', $tokenSecret);
@@ -2418,12 +2441,12 @@ Http::post('/v1/account/tokens/email')
->inject('dbForProject')
->inject('locale')
->inject('queueForEvents')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('plan')
->inject('proofForPassword')
->inject('proofForCode')
->inject('authorization')
- ->action(function (string $userId, string $email, bool $phrase, Request $request, Response $response, User $user, Document $project, array $platform, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, array $plan, ProofsPassword $proofForPassword, ProofsCode $proofForCode, Authorization $authorization) {
+ ->action(function (string $userId, string $email, bool $phrase, Request $request, Response $response, User $user, Document $project, array $platform, Database $dbForProject, Locale $locale, Event $queueForEvents, MailPublisher $publisherForMails, array $plan, ProofsPassword $proofForPassword, ProofsCode $proofForCode, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled');
}
@@ -2478,15 +2501,15 @@ Http::post('/v1/account/tokens/email')
throw new Exception(Exception::GENERAL_INVALID_EMAIL);
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -2575,7 +2598,9 @@ Http::post('/v1/account/tokens/email')
$preview = $locale->getText("emails.otpSession.preview");
$heading = $locale->getText("emails.otpSession.heading");
- $customTemplate = $project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ?? [];
+ $customTemplate =
+ $project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ??
+ $project->getAttribute('templates', [])['email.otpSession-' . $locale->fallback] ?? [];
$smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base');
$validator = new FileName();
@@ -2611,7 +2636,9 @@ Http::post('/v1/account/tokens/email')
$senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
- $replyTo = "";
+ $replyToEmail = '';
+ $replyToName = '';
+ $smtpConfig = [];
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -2620,16 +2647,14 @@ Http::post('/v1/account/tokens/email')
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
- if (!empty($smtp['replyTo'])) {
- $replyTo = $smtp['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '';
+ if (!empty($smtpReplyToEmail)) {
+ $replyToEmail = $smtpReplyToEmail;
+ }
+ if (!empty($smtp['replyToName'])) {
+ $replyToName = $smtp['replyToName'];
}
-
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '');
if (!empty($customTemplate)) {
if (!empty($customTemplate['senderEmail'])) {
@@ -2638,18 +2663,30 @@ Http::post('/v1/account/tokens/email')
if (!empty($customTemplate['senderName'])) {
$senderName = $customTemplate['senderName'];
}
- if (!empty($customTemplate['replyTo'])) {
- $replyTo = $customTemplate['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? '';
+ if (!empty($customReplyToEmail)) {
+ $replyToEmail = $customReplyToEmail;
+ }
+ if (!empty($customTemplate['replyToName'])) {
+ $replyToName = $customTemplate['replyToName'];
}
$body = $customTemplate['message'] ?? '';
$subject = $customTemplate['subject'] ?? $subject;
}
- $queueForMails
- ->setSmtpReplyTo($replyTo)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName);
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ];
}
$projectName = $project->getAttribute('name');
@@ -2685,20 +2722,18 @@ Http::post('/v1/account/tokens/email')
]);
}
- $queueForMails
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body)
- ->setBodyTemplate($bodyTemplate)
- ->appendVariables($emailVariables)
- ->setRecipient($email);
-
- // since this is console project, set email sender name!
- if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) {
- $queueForMails->setSenderName($platform['emailSenderName']);
- }
-
- $queueForMails->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $email,
+ subject: $subject,
+ bodyTemplate: $bodyTemplate,
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ customMailOptions: $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE ? ['senderName' => $platform['emailSenderName']] : [],
+ platform: $platform,
+ ));
$token->setAttribute('secret', $tokenSecret);
@@ -2848,7 +2883,7 @@ Http::post('/v1/account/tokens/phone')
->inject('platform')
->inject('dbForProject')
->inject('queueForEvents')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('locale')
->inject('timelimit')
->inject('usage')
@@ -2856,7 +2891,7 @@ Http::post('/v1/account/tokens/phone')
->inject('store')
->inject('proofForCode')
->inject('authorization')
- ->action(function (string $userId, string $phone, Request $request, Response $response, User $user, Document $project, array $platform, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Locale $locale, callable $timelimit, Context $usage, array $plan, Store $store, ProofsCode $proofForCode, Authorization $authorization) {
+ ->action(function (string $userId, string $phone, Request $request, Response $response, User $user, Document $project, array $platform, Database $dbForProject, Event $queueForEvents, MessagingPublisher $publisherForMessaging, Locale $locale, callable $timelimit, Context $usage, array $plan, Store $store, ProofsCode $proofForCode, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMS_PROVIDER'))) {
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
}
@@ -2968,11 +3003,6 @@ Http::post('/v1/account/tokens/phone')
if ($sendSMS) {
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl');
- $customTemplate = $project->getAttribute('templates', [])['sms.login-' . $locale->default] ?? [];
- if (!empty($customTemplate)) {
- $message = $customTemplate['message'] ?? $message;
- }
-
$projectName = $project->getAttribute('name');
if ($project->getId() === 'console') {
$projectName = $platform['platformName'];
@@ -2994,11 +3024,13 @@ Http::post('/v1/account/tokens/phone')
],
]);
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_INTERNAL)
- ->setMessage($messageDoc)
- ->setRecipients([$phone])
- ->setProviderType(MESSAGE_TYPE_SMS);
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_INTERNAL,
+ project: $project,
+ message: $messageDoc,
+ recipients: [$phone],
+ providerType: MESSAGE_TYPE_SMS,
+ ));
$helper = PhoneNumberUtil::getInstance();
try {
@@ -3267,7 +3299,7 @@ Http::patch('/v1/account/password')
}
$history[] = $newPassword;
- $history = array_slice($history, (count($history) - $historyLimit), $historyLimit);
+ $history = array_slice($history, -$historyLimit);
}
if ($project->getAttribute('auths', [])['personalDataCheck'] ?? false) {
@@ -3390,15 +3422,15 @@ Http::patch('/v1/account/email')
throw new Exception(Exception::GENERAL_INVALID_EMAIL);
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -3430,9 +3462,6 @@ Http::patch('/v1/account/email')
try {
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
- /**
- * @var Document $oldTarget
- */
$oldTarget = $user->find('identifier', $oldEmail, 'targets');
if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) {
@@ -3519,9 +3548,6 @@ Http::patch('/v1/account/phone')
try {
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
- /**
- * @var Document $oldTarget
- */
$oldTarget = $user->find('identifier', $oldPhone, 'targets');
if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) {
@@ -3660,11 +3686,11 @@ Http::post('/v1/account/recovery')
->inject('project')
->inject('platform')
->inject('locale')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('queueForEvents')
->inject('proofForToken')
->inject('authorization')
- ->action(function (string $email, string $url, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Mail $queueForMails, Event $queueForEvents, ProofsToken $proofForToken, Authorization $authorization) {
+ ->action(function (string $email, string $url, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, MailPublisher $publisherForMails, Event $queueForEvents, ProofsToken $proofForToken, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled');
@@ -3726,7 +3752,9 @@ Http::post('/v1/account/recovery')
$body = $locale->getText("emails.recovery.body");
$subject = $locale->getText("emails.recovery.subject");
$preview = $locale->getText("emails.recovery.preview");
- $customTemplate = $project->getAttribute('templates', [])['email.recovery-' . $locale->default] ?? [];
+ $customTemplate =
+ $project->getAttribute('templates', [])['email.recovery-' . $locale->default] ??
+ $project->getAttribute('templates', [])['email.recovery-' . $locale->fallback] ?? [];
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-inner-base.tpl');
$message
@@ -3743,7 +3771,9 @@ Http::post('/v1/account/recovery')
$senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
- $replyTo = "";
+ $replyToEmail = '';
+ $replyToName = '';
+ $smtpConfig = [];
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -3752,16 +3782,14 @@ Http::post('/v1/account/recovery')
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
- if (!empty($smtp['replyTo'])) {
- $replyTo = $smtp['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '';
+ if (!empty($smtpReplyToEmail)) {
+ $replyToEmail = $smtpReplyToEmail;
+ }
+ if (!empty($smtp['replyToName'])) {
+ $replyToName = $smtp['replyToName'];
}
-
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '');
if (!empty($customTemplate)) {
if (!empty($customTemplate['senderEmail'])) {
@@ -3770,18 +3798,30 @@ Http::post('/v1/account/recovery')
if (!empty($customTemplate['senderName'])) {
$senderName = $customTemplate['senderName'];
}
- if (!empty($customTemplate['replyTo'])) {
- $replyTo = $customTemplate['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? '';
+ if (!empty($customReplyToEmail)) {
+ $replyToEmail = $customReplyToEmail;
+ }
+ if (!empty($customTemplate['replyToName'])) {
+ $replyToName = $customTemplate['replyToName'];
}
$body = $customTemplate['message'] ?? '';
$subject = $customTemplate['subject'] ?? $subject;
}
- $queueForMails
- ->setSmtpReplyTo($replyTo)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName);
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ];
}
$emailVariables = [
@@ -3794,19 +3834,18 @@ Http::post('/v1/account/recovery')
'team' => ''
];
- $queueForMails
- ->setRecipient($profile->getAttribute('email', ''))
- ->setName($profile->getAttribute('name', ''))
- ->setBody($body)
- ->appendVariables($emailVariables)
- ->setSubject($subject)
- ->setPreview($preview);
-
- if ($project->getId() === 'console') {
- $queueForMails->setSenderName($platform['emailSenderName']);
- }
-
- $queueForMails->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $profile->getAttribute('email', ''),
+ name: $profile->getAttribute('name', ''),
+ subject: $subject,
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ customMailOptions: $project->getId() === 'console' ? ['senderName' => $platform['emailSenderName']] : [],
+ platform: $platform,
+ ));
$recovery->setAttribute('secret', $secret);
@@ -3974,10 +4013,10 @@ Http::post('/v1/account/verifications/email')
->inject('dbForProject')
->inject('locale')
->inject('queueForEvents')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('proofForToken')
->inject('authorization')
- ->action(function (string $url, Request $request, Response $response, Document $project, array $platform, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsToken $proofForToken, Authorization $authorization) {
+ ->action(function (string $url, Request $request, Response $response, Document $project, array $platform, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, MailPublisher $publisherForMails, ProofsToken $proofForToken, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled');
@@ -4034,7 +4073,9 @@ Http::post('/v1/account/verifications/email')
$subject = $locale->getText("emails.verification.subject");
$heading = $locale->getText("emails.verification.heading");
- $customTemplate = $project->getAttribute('templates', [])['email.verification-' . $locale->default] ?? [];
+ $customTemplate =
+ $project->getAttribute('templates', [])['email.verification-' . $locale->default] ??
+ $project->getAttribute('templates', [])['email.verification-' . $locale->fallback] ?? [];
$smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base');
$validator = new FileName();
@@ -4060,7 +4101,9 @@ Http::post('/v1/account/verifications/email')
$senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
- $replyTo = "";
+ $replyToEmail = '';
+ $replyToName = '';
+ $smtpConfig = [];
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -4069,16 +4112,14 @@ Http::post('/v1/account/verifications/email')
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
- if (!empty($smtp['replyTo'])) {
- $replyTo = $smtp['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '';
+ if (!empty($smtpReplyToEmail)) {
+ $replyToEmail = $smtpReplyToEmail;
+ }
+ if (!empty($smtp['replyToName'])) {
+ $replyToName = $smtp['replyToName'];
}
-
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '');
if (!empty($customTemplate)) {
if (!empty($customTemplate['senderEmail'])) {
@@ -4087,18 +4128,30 @@ Http::post('/v1/account/verifications/email')
if (!empty($customTemplate['senderName'])) {
$senderName = $customTemplate['senderName'];
}
- if (!empty($customTemplate['replyTo'])) {
- $replyTo = $customTemplate['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? '';
+ if (!empty($customReplyToEmail)) {
+ $replyToEmail = $customReplyToEmail;
+ }
+ if (!empty($customTemplate['replyToName'])) {
+ $replyToName = $customTemplate['replyToName'];
}
$body = $customTemplate['message'] ?? '';
$subject = $customTemplate['subject'] ?? $subject;
}
- $queueForMails
- ->setSmtpReplyTo($replyTo)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName);
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ];
}
$emailVariables = [
@@ -4125,20 +4178,19 @@ Http::post('/v1/account/verifications/email')
]);
}
- $queueForMails
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body)
- ->setBodyTemplate($bodyTemplate)
- ->appendVariables($emailVariables)
- ->setRecipient($user->getAttribute('email'))
- ->setName($user->getAttribute('name') ?? '');
-
- if ($project->getId() === 'console') {
- $queueForMails->setSenderName($platform['emailSenderName']);
- }
-
- $queueForMails->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $user->getAttribute('email'),
+ name: $user->getAttribute('name') ?? '',
+ subject: $subject,
+ bodyTemplate: $bodyTemplate,
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ customMailOptions: $project->getId() === 'console' ? ['senderName' => $platform['emailSenderName']] : [],
+ platform: $platform,
+ ));
$verification->setAttribute('secret', $verificationSecret);
@@ -4272,7 +4324,7 @@ Http::post('/v1/account/verifications/phone')
->inject('user')
->inject('dbForProject')
->inject('queueForEvents')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('project')
->inject('locale')
->inject('timelimit')
@@ -4280,7 +4332,7 @@ Http::post('/v1/account/verifications/phone')
->inject('plan')
->inject('proofForCode')
->inject('authorization')
- ->action(function (Request $request, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Document $project, Locale $locale, callable $timelimit, Context $usage, array $plan, ProofsCode $proofForCode, Authorization $authorization) {
+ ->action(function (Request $request, Response $response, User $user, Database $dbForProject, Event $queueForEvents, MessagingPublisher $publisherForMessaging, Document $project, Locale $locale, callable $timelimit, Context $usage, array $plan, ProofsCode $proofForCode, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMS_PROVIDER'))) {
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
}
@@ -4333,11 +4385,6 @@ Http::post('/v1/account/verifications/phone')
if ($sendSMS) {
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl');
- $customTemplate = $project->getAttribute('templates', [])['sms.verification-' . $locale->default] ?? [];
- if (!empty($customTemplate)) {
- $message = $customTemplate['message'] ?? $message;
- }
-
$messageContent = Template::fromString($locale->getText("sms.verification.body"));
$messageContent
->setParam('{{project}}', $project->getAttribute('name'))
@@ -4354,11 +4401,13 @@ Http::post('/v1/account/verifications/phone')
],
]);
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_INTERNAL)
- ->setMessage($messageDoc)
- ->setRecipients([$user->getAttribute('phone')])
- ->setProviderType(MESSAGE_TYPE_SMS);
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_INTERNAL,
+ project: $project,
+ message: $messageDoc,
+ recipients: [$user->getAttribute('phone')],
+ providerType: MESSAGE_TYPE_SMS,
+ ));
$helper = PhoneNumberUtil::getInstance();
try {
@@ -4465,7 +4514,7 @@ Http::post('/v1/account/targets/push')
group: 'pushTargets',
name: 'createPushTarget',
description: '/docs/references/account/create-push-target.md',
- auth: [AuthType::ADMIN, AuthType::SESSION],
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
@@ -4549,7 +4598,7 @@ Http::put('/v1/account/targets/:targetId/push')
group: 'pushTargets',
name: 'updatePushTarget',
description: '/docs/references/account/update-push-target.md',
- auth: [AuthType::ADMIN, AuthType::SESSION],
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
@@ -4612,14 +4661,14 @@ Http::delete('/v1/account/targets/:targetId/push')
->groups(['api', 'account'])
->label('scope', 'targets.write')
->label('audits.event', 'target.delete')
- ->label('audits.resource', 'target/response.$id')
+ ->label('audits.resource', 'target/{request.targetId}')
->label('event', 'users.[userId].targets.[targetId].delete')
->label('sdk', new Method(
namespace: 'account',
group: 'pushTargets',
name: 'deletePushTarget',
description: '/docs/references/account/delete-push-target.md',
- auth: [AuthType::ADMIN, AuthType::SESSION],
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
@@ -4630,13 +4679,13 @@ Http::delete('/v1/account/targets/:targetId/push')
))
->param('targetId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Target ID.', false, ['dbForProject'])
->inject('queueForEvents')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('user')
->inject('request')
->inject('response')
->inject('dbForProject')
->inject('authorization')
- ->action(function (string $targetId, Event $queueForEvents, Delete $queueForDeletes, Document $user, Request $request, Response $response, Database $dbForProject, Authorization $authorization) {
+ ->action(function (string $targetId, Event $queueForEvents, DeletePublisher $publisherForDeletes, Document $user, Request $request, Response $response, Database $dbForProject, Authorization $authorization) {
$target = $authorization->skip(fn () => $dbForProject->getDocument('targets', $targetId));
if ($target->isEmpty()) {
@@ -4651,9 +4700,11 @@ Http::delete('/v1/account/targets/:targetId/push')
$dbForProject->purgeCachedDocument('users', $user->getId());
- $queueForDeletes
- ->setType(DELETE_TYPE_TARGET)
- ->setDocument($target);
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $queueForEvents->getProject(),
+ type: DELETE_TYPE_TARGET,
+ document: $target,
+ ));
$queueForEvents
->setParam('userId', $user->getId())
diff --git a/app/controllers/api/messaging.php b/app/controllers/api/messaging.php
index 2a0012bd30..d1ffa2e478 100644
--- a/app/controllers/api/messaging.php
+++ b/app/controllers/api/messaging.php
@@ -3,9 +3,11 @@
use Ahc\Jwt\JWT;
use Appwrite\Auth\Validator\Phone;
use Appwrite\Detector\Detector;
-use Appwrite\Event\Delete;
use Appwrite\Event\Event;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Message\Delete as DeleteMessage;
+use Appwrite\Event\Message\Messaging as MessagingMessage;
+use Appwrite\Event\Publisher\Delete as DeletePublisher;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Messaging\Status as MessageStatus;
use Appwrite\Permission;
@@ -482,7 +484,6 @@ Http::post('/v1/messaging/providers/msg91')
$enabled === true
&& \array_key_exists('senderId', $credentials)
&& \array_key_exists('authKey', $credentials)
- && \array_key_exists('from', $options)
) {
$enabled = true;
} else {
@@ -2728,9 +2729,9 @@ Http::delete('/v1/messaging/topics/:topicId')
->param('topicId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Topic ID.', false, ['dbForProject'])
->inject('queueForEvents')
->inject('dbForProject')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('response')
- ->action(function (string $topicId, Event $queueForEvents, Database $dbForProject, Delete $queueForDeletes, Response $response) {
+ ->action(function (string $topicId, Event $queueForEvents, Database $dbForProject, DeletePublisher $publisherForDeletes, Response $response) {
$topic = $dbForProject->getDocument('topics', $topicId);
if ($topic->isEmpty()) {
@@ -2739,9 +2740,11 @@ Http::delete('/v1/messaging/topics/:topicId')
$dbForProject->deleteDocument('topics', $topicId);
- $queueForDeletes
- ->setType(DELETE_TYPE_TOPIC)
- ->setDocument($topic);
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $queueForEvents->getProject(),
+ type: DELETE_TYPE_TOPIC,
+ document: $topic,
+ ));
$queueForEvents
->setParam('topicId', $topic->getId());
@@ -3188,9 +3191,9 @@ Http::post('/v1/messaging/messages/email')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
- ->action(function (string $messageId, string $subject, string $content, ?array $topics, ?array $users, ?array $targets, ?array $cc, ?array $bcc, ?array $attachments, bool $draft, bool $html, ?string $scheduledAt, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response) {
+ ->action(function (string $messageId, string $subject, string $content, ?array $topics, ?array $users, ?array $targets, ?array $cc, ?array $bcc, ?array $attachments, bool $draft, bool $html, ?string $scheduledAt, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, MessagingPublisher $publisherForMessaging, Response $response) {
$messageId = $messageId == 'unique()'
? ID::unique()
: $messageId;
@@ -3207,10 +3210,6 @@ Http::post('/v1/messaging/messages/email')
throw new Exception(Exception::MESSAGE_MISSING_TARGET);
}
- if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) {
- throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE);
- }
-
$mergedTargets = \array_merge($targets, $cc, $bcc);
if (!empty($mergedTargets)) {
@@ -3279,9 +3278,11 @@ Http::post('/v1/messaging/messages/email')
switch ($status) {
case MessageStatus::PROCESSING:
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($message->getId());
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $project,
+ messageId: $message->getId(),
+ ));
break;
case MessageStatus::SCHEDULED:
$schedule = $dbForPlatform->createDocument('schedules', new Document([
@@ -3367,9 +3368,9 @@ Http::post('/v1/messaging/messages/sms')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
- ->action(function (string $messageId, string $content, ?array $topics, ?array $users, ?array $targets, bool $draft, ?string $scheduledAt, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response) {
+ ->action(function (string $messageId, string $content, ?array $topics, ?array $users, ?array $targets, bool $draft, ?string $scheduledAt, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, MessagingPublisher $publisherForMessaging, Response $response) {
$messageId = $messageId == 'unique()'
? ID::unique()
: $messageId;
@@ -3386,10 +3387,6 @@ Http::post('/v1/messaging/messages/sms')
throw new Exception(Exception::MESSAGE_MISSING_TARGET);
}
- if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) {
- throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE);
- }
-
if (!empty($targets)) {
$foundTargets = $dbForProject->find('targets', [
Query::equal('$id', $targets),
@@ -3427,9 +3424,11 @@ Http::post('/v1/messaging/messages/sms')
switch ($status) {
case MessageStatus::PROCESSING:
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($message->getId());
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $project,
+ messageId: $message->getId(),
+ ));
break;
case MessageStatus::SCHEDULED:
$schedule = $dbForPlatform->createDocument('schedules', new Document([
@@ -3507,10 +3506,10 @@ Http::post('/v1/messaging/messages/push')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
->inject('platform')
- ->action(function (string $messageId, string $title, string $body, ?array $topics, ?array $users, ?array $targets, ?array $data, string $action, string $image, string $icon, string $sound, string $color, string $tag, int $badge, bool $draft, ?string $scheduledAt, bool $contentAvailable, bool $critical, string $priority, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response, array $platform) {
+ ->action(function (string $messageId, string $title, string $body, ?array $topics, ?array $users, ?array $targets, ?array $data, string $action, string $image, string $icon, string $sound, string $color, string $tag, int $badge, bool $draft, ?string $scheduledAt, bool $contentAvailable, bool $critical, string $priority, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, MessagingPublisher $publisherForMessaging, Response $response, array $platform) {
$messageId = $messageId == 'unique()'
? ID::unique()
: $messageId;
@@ -3527,10 +3526,6 @@ Http::post('/v1/messaging/messages/push')
throw new Exception(Exception::MESSAGE_MISSING_TARGET);
}
- if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) {
- throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE);
- }
-
if (!empty($targets)) {
$foundTargets = $dbForProject->find('targets', [
Query::equal('$id', $targets),
@@ -3651,9 +3646,11 @@ Http::post('/v1/messaging/messages/push')
switch ($status) {
case MessageStatus::PROCESSING:
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($message->getId());
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $project,
+ messageId: $message->getId(),
+ ));
break;
case MessageStatus::SCHEDULED:
$schedule = $dbForPlatform->createDocument('schedules', new Document([
@@ -3996,9 +3993,9 @@ Http::patch('/v1/messaging/messages/email/:messageId')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
- ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $subject, ?string $content, ?bool $draft, ?bool $html, ?array $cc, ?array $bcc, ?string $scheduledAt, ?array $attachments, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response) {
+ ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $subject, ?string $content, ?bool $draft, ?bool $html, ?array $cc, ?array $bcc, ?string $scheduledAt, ?array $attachments, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, MessagingPublisher $publisherForMessaging, Response $response) {
$message = $dbForProject->getDocument('messages', $messageId);
if ($message->isEmpty()) {
@@ -4154,9 +4151,11 @@ Http::patch('/v1/messaging/messages/email/:messageId')
$message = $dbForProject->updateDocument('messages', $message->getId(), $message);
if ($status === MessageStatus::PROCESSING) {
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($message->getId());
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $project,
+ messageId: $message->getId(),
+ ));
}
$queueForEvents
@@ -4218,9 +4217,9 @@ Http::patch('/v1/messaging/messages/sms/:messageId')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
- ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $content, ?bool $draft, ?string $scheduledAt, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response) {
+ ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $content, ?bool $draft, ?string $scheduledAt, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, MessagingPublisher $publisherForMessaging, Response $response) {
$message = $dbForProject->getDocument('messages', $messageId);
if ($message->isEmpty()) {
@@ -4336,9 +4335,11 @@ Http::patch('/v1/messaging/messages/sms/:messageId')
$message = $dbForProject->updateDocument('messages', $message->getId(), $message);
if ($status === MessageStatus::PROCESSING) {
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($message->getId());
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $project,
+ messageId: $message->getId(),
+ ));
}
$queueForEvents
@@ -4392,10 +4393,10 @@ Http::patch('/v1/messaging/messages/push/:messageId')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
->inject('platform')
- ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $title, ?string $body, ?array $data, ?string $action, ?string $image, ?string $icon, ?string $sound, ?string $color, ?string $tag, ?int $badge, ?bool $draft, ?string $scheduledAt, ?bool $contentAvailable, ?bool $critical, ?string $priority, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response, array $platform) {
+ ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $title, ?string $body, ?array $data, ?string $action, ?string $image, ?string $icon, ?string $sound, ?string $color, ?string $tag, ?int $badge, ?bool $draft, ?string $scheduledAt, ?bool $contentAvailable, ?bool $critical, ?string $priority, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, MessagingPublisher $publisherForMessaging, Response $response, array $platform) {
$message = $dbForProject->getDocument('messages', $messageId);
if ($message->isEmpty()) {
@@ -4597,9 +4598,11 @@ Http::patch('/v1/messaging/messages/push/:messageId')
$message = $dbForProject->updateDocument('messages', $message->getId(), $message);
if ($status === MessageStatus::PROCESSING) {
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($message->getId());
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $project,
+ messageId: $message->getId(),
+ ));
}
$queueForEvents
@@ -4660,7 +4663,7 @@ Http::delete('/v1/messaging/messages/:messageId')
if (!empty($scheduleId)) {
try {
$dbForPlatform->deleteDocument('schedules', $scheduleId);
- } catch (Exception) {
+ } catch (\Throwable) {
// Ignore
}
}
diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php
deleted file mode 100644
index 4c541d2817..0000000000
--- a/app/controllers/api/migrations.php
+++ /dev/null
@@ -1,1276 +0,0 @@
- Transfer::GROUP_DATABASES_TABLES_DB,
- DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB,
- DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB
- };
-}
-
-function getDatabaseResourceType(string $databaseType): string
-{
- return match($databaseType) {
- DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB,
- DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB,
- default => Resource::TYPE_DATABASE,
- };
-}
-
-Http::post('/v1/migrations/appwrite')
- ->groups(['api', 'migrations'])
- ->desc('Create Appwrite migration')
- ->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(Appwrite::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')
- ->action(function (array $resources, string $endpoint, string $projectId, string $apiKey, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) {
- $migration = $dbForProject->createDocument('migrations', new Document([
- '$id' => ID::unique(),
- 'status' => 'pending',
- 'stage' => 'init',
- 'source' => Appwrite::getName(),
- 'destination' => Appwrite::getName(),
- 'credentials' => [
- 'endpoint' => $endpoint,
- 'projectId' => $projectId,
- 'apiKey' => $apiKey,
- ],
- 'resources' => $resources,
- 'statusCounters' => '{}',
- 'resourceData' => '{}',
- 'errors' => [],
- ]));
-
- $queueForEvents->setParam('migrationId', $migration->getId());
-
- // Trigger Transfer
- $publisherForMigrations->enqueue(new MigrationMessage(
- project: $project,
- migration: $migration,
- platform: $platform,
- ));
-
- $response
- ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
- ->dynamic($migration, Response::MODEL_MIGRATION);
- });
-
-Http::post('/v1/migrations/firebase')
- ->groups(['api', 'migrations'])
- ->desc('Create Firebase migration')
- ->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')
- ->action(function (array $resources, string $serviceAccount, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) {
- $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' => Appwrite::getName(),
- 'credentials' => [
- 'serviceAccount' => $serviceAccount,
- ],
- 'resources' => $resources,
- 'statusCounters' => '{}',
- 'resourceData' => '{}',
- 'errors' => [],
- ]));
-
- $queueForEvents->setParam('migrationId', $migration->getId());
-
- // Trigger Transfer
- $publisherForMigrations->enqueue(new MigrationMessage(
- project: $project,
- migration: $migration,
- platform: $platform,
- ));
-
- $response
- ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
- ->dynamic($migration, Response::MODEL_MIGRATION);
- });
-
-Http::post('/v1/migrations/supabase')
- ->groups(['api', 'migrations'])
- ->desc('Create Supabase migration')
- ->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')
- ->action(function (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) {
- $migration = $dbForProject->createDocument('migrations', new Document([
- '$id' => ID::unique(),
- 'status' => 'pending',
- 'stage' => 'init',
- 'source' => Supabase::getName(),
- 'destination' => Appwrite::getName(),
- 'credentials' => [
- 'endpoint' => $endpoint,
- 'apiKey' => $apiKey,
- 'databaseHost' => $databaseHost,
- 'username' => $username,
- 'password' => $password,
- 'port' => $port,
- ],
- 'resources' => $resources,
- 'statusCounters' => '{}',
- 'resourceData' => '{}',
- 'errors' => [],
- ]));
-
- $queueForEvents->setParam('migrationId', $migration->getId());
-
- // Trigger Transfer
- $publisherForMigrations->enqueue(new MigrationMessage(
- project: $project,
- migration: $migration,
- platform: $platform,
- ));
-
- $response
- ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
- ->dynamic($migration, Response::MODEL_MIGRATION);
- });
-
-Http::post('/v1/migrations/nhost')
- ->groups(['api', 'migrations'])
- ->desc('Create NHost migration')
- ->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')
- ->action(function (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) {
- $migration = $dbForProject->createDocument('migrations', new Document([
- '$id' => ID::unique(),
- 'status' => 'pending',
- 'stage' => 'init',
- 'source' => NHost::getName(),
- 'destination' => Appwrite::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());
-
- // Trigger Transfer
- $publisherForMigrations->enqueue(new MigrationMessage(
- project: $project,
- migration: $migration,
- platform: $platform,
- ));
-
- $response
- ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
- ->dynamic($migration, Response::MODEL_MIGRATION);
- });
-
-Http::post('/v1/migrations/csv/imports')
- ->alias('/v1/migrations/csv')
- ->groups(['api', 'migrations'])
- ->desc('Import documents from a CSV')
- ->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')
- ->action(function (
- 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
- ) {
- $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');
- }
-
- // getting databasetype
- $resources = explode(':', $resourceId);
- $databaseId = $resources[0];
- $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([getDatabaseTransferResourceServices($databaseType)]);
- $resourceType = getDatabaseResourceType($databaseType);
-
- $migration = $dbForProject->createDocument('migrations', new Document([
- '$id' => $migrationId,
- 'status' => 'pending',
- 'stage' => 'init',
- 'source' => CSV::getName(),
- 'destination' => Appwrite::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);
- });
-
-Http::post('/v1/migrations/csv/exports')
- ->groups(['api', 'migrations'])
- ->desc('Export documents to CSV')
- ->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')
- ->action(function (
- 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
- ) {
- 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);
- }
-
- // getting databasetype
- $resources = explode(':', $resourceId);
- $databaseId = $resources[0];
- $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');
- }
-
- // 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([getDatabaseTransferResourceServices($databaseType)]);
- $resourceType = getDatabaseResourceType($databaseType);
-
- $migration = $dbForProject->createDocument('migrations', new Document([
- '$id' => ID::unique(),
- 'status' => 'pending',
- 'stage' => 'init',
- 'source' => Appwrite::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);
- });
-
-Http::post('/v1/migrations/json/imports')
- ->groups(['api', 'migrations'])
- ->desc('Import documents from a JSON')
- ->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')
- ->action(function (
- 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
- ) {
- $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([getDatabaseTransferResourceServices($databaseType)]);
- $resourceType = getDatabaseResourceType($databaseType);
-
- $migration = $dbForProject->createDocument('migrations', new Document([
- '$id' => $migrationId,
- 'status' => 'pending',
- 'stage' => 'init',
- 'source' => JSON::getName(),
- 'destination' => Appwrite::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);
- });
-
-Http::post('/v1/migrations/json/exports')
- ->groups(['api', 'migrations'])
- ->desc('Export documents to JSON')
- ->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')
- ->action(function (
- 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
- ) {
- 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([getDatabaseTransferResourceServices($databaseType)]);
- $resourceType = getDatabaseResourceType($databaseType);
-
- $migration = $dbForProject->createDocument('migrations', new Document([
- '$id' => ID::unique(),
- 'status' => 'pending',
- 'stage' => 'init',
- 'source' => Appwrite::getName(),
- 'destination' => JSON::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);
- });
-
-Http::get('/v1/migrations')
- ->groups(['api', 'migrations'])
- ->desc('List 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')
- ->action(function (array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject) {
- 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);
- });
-
-Http::get('/v1/migrations/:migrationId')
- ->groups(['api', 'migrations'])
- ->desc('Get migration')
- ->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')
- ->action(function (string $migrationId, Response $response, Database $dbForProject) {
- $migration = $dbForProject->getDocument('migrations', $migrationId);
-
- if ($migration->isEmpty()) {
- throw new Exception(Exception::MIGRATION_NOT_FOUND);
- }
-
- $response->dynamic($migration, Response::MODEL_MIGRATION);
- });
-
-Http::get('/v1/migrations/appwrite/report')
- ->groups(['api', 'migrations'])
- ->desc('Get Appwrite migration report')
- ->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(Appwrite::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')
- ->action(function (array $resources, string $endpoint, string $projectID, string $key, Response $response, callable $getDatabasesDB) {
-
- try {
- $appwrite = new Appwrite($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);
- });
-
-Http::get('/v1/migrations/firebase/report')
- ->groups(['api', 'migrations'])
- ->desc('Get Firebase migration report')
- ->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')
- ->action(function (array $resources, string $serviceAccount, Response $response) {
- $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);
- });
-
-Http::get('/v1/migrations/supabase/report')
- ->groups(['api', 'migrations'])
- ->desc('Get Supabase migration report')
- ->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')
- ->inject('dbForProject')
- ->action(function (array $resources, string $endpoint, string $apiKey, string $databaseHost, string $username, string $password, int $port, Response $response) {
- 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);
- });
-
-Http::get('/v1/migrations/nhost/report')
- ->groups(['api', 'migrations'])
- ->desc('Get NHost migration report')
- ->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')
- ->action(function (array $resources, string $subdomain, string $region, string $adminSecret, string $database, string $username, string $password, int $port, Response $response) {
- 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);
- });
-
-Http::patch('/v1/migrations/:migrationId')
- ->groups(['api', 'migrations'])
- ->desc('Update retry migration')
- ->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')
- ->action(function (string $migrationId, Response $response, Database $dbForProject, Document $project, array $platform, MigrationPublisher $publisherForMigrations) {
- $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());
-
- // Trigger Migration
- $publisherForMigrations->enqueue(new MigrationMessage(
- project: $project,
- migration: $migration,
- platform: $platform,
- ));
-
- $response->noContent();
- });
-
-Http::delete('/v1/migrations/:migrationId')
- ->groups(['api', 'migrations'])
- ->desc('Delete migration')
- ->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')
- ->action(function (string $migrationId, Response $response, Database $dbForProject, Event $queueForEvents) {
- $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();
- });
diff --git a/app/controllers/api/project.php b/app/controllers/api/project.php
index 054a7c8f0d..544beade77 100644
--- a/app/controllers/api/project.php
+++ b/app/controllers/api/project.php
@@ -113,11 +113,12 @@ Http::get('/v1/project/usage')
$factor = match ($period) {
'1h' => 3600,
'1d' => 86400,
+ default => throw new \LogicException('Unsupported period: ' . $period),
};
$limit = match ($period) {
'1h' => (new DateTime($startDate))->diff(new DateTime($endDate))->days * 24,
- '1d' => (new DateTime($startDate))->diff(new DateTime($endDate))->days
+ '1d' => (new DateTime($startDate))->diff(new DateTime($endDate))->days,
};
$format = match ($period) {
diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php
index 1cb53cffed..2fa5ce97f0 100644
--- a/app/controllers/api/projects.php
+++ b/app/controllers/api/projects.php
@@ -1,31 +1,19 @@
desc('Get project')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.read')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'projects',
- name: 'get',
- description: '/docs/references/projects/get.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/service/all')
- ->desc('Update all service status')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->action(function () {
- throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.');
- });
-
-Http::patch('/v1/projects/:projectId/api/all')
- ->desc('Update all API status')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->action(function () {
- throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.');
- });
-
+// Backwards compatibility
Http::patch('/v1/projects/:projectId/oauth2')
->desc('Update project OAuth2')
->groups(['api', 'projects'])
->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateOAuth2',
- description: '/docs/references/projects/update-oauth2.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
->param('provider', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders')), true), 'Provider Name')
->param('appId', null, new Nullable(new Text(256)), 'Provider app ID. Max length: 256 chars.', true)
@@ -687,23 +616,11 @@ Http::patch('/v1/projects/:projectId/auth/max-sessions')
$response->dynamic($project, Response::MODEL_PROJECT);
});
+// Backwards compatibility
Http::patch('/v1/projects/:projectId/auth/mock-numbers')
->desc('Update the mock numbers for the project')
->groups(['api', 'projects'])
->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateMockNumbers',
- description: '/docs/references/projects/update-mock-numbers.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
->param('numbers', '', new ArrayList(new MockNumber(), 10), 'An array of mock numbers and their corresponding verification codes (OTPs). Each number should be a valid E.164 formatted phone number. Maximum of 10 numbers are allowed.')
->inject('response')
@@ -733,755 +650,29 @@ Http::patch('/v1/projects/:projectId/auth/mock-numbers')
$response->dynamic($project, Response::MODEL_PROJECT);
});
-Http::delete('/v1/projects/:projectId')
- ->desc('Delete project')
- ->groups(['api', 'projects'])
- ->label('audits.event', 'projects.delete')
- ->label('audits.resource', 'project/{request.projectId}')
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'projects',
- name: 'delete',
- description: '/docs/references/projects/delete.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_NOCONTENT,
- model: Response::MODEL_NONE,
- )
- ],
- contentType: ContentType::NONE
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->inject('response')
- ->inject('user')
- ->inject('dbForPlatform')
- ->inject('queueForDeletes')
- ->action(function (string $projectId, Response $response, Document $user, Database $dbForPlatform, Delete $queueForDeletes) {
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $queueForDeletes
- ->setProject($project)
- ->setType(DELETE_TYPE_DOCUMENT)
- ->setDocument($project);
-
- if (!$dbForPlatform->deleteDocument('projects', $projectId)) {
- throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove project from DB');
- }
-
- $response->noContent();
- });
-
-// JWT Keys
-
-Http::post('/v1/projects/:projectId/jwts')
- ->groups(['api', 'projects'])
- ->desc('Create JWT')
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'createJWT',
- description: '/docs/references/projects/create-jwt.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_CREATED,
- model: Response::MODEL_JWT,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for JWT key. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.')
- ->param('duration', 900, new Range(0, 3600), 'Time in seconds before JWT expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true)
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, array $scopes, int $duration, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0);
-
- $response
- ->setStatusCode(Response::STATUS_CODE_CREATED)
- ->dynamic(new Document(['jwt' => API_KEY_DYNAMIC . '_' . $jwt->encode([
- 'projectId' => $project->getId(),
- 'scopes' => $scopes
- ])]), Response::MODEL_JWT);
- });
-
-// CUSTOM SMTP and Templates
-Http::patch('/v1/projects/:projectId/smtp')
- ->desc('Update SMTP')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', [
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'updateSmtp',
- description: '/docs/references/projects/update-smtp.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ],
- deprecated: new Deprecated(
- since: '1.8.0',
- replaceWith: 'projects.updateSMTP',
- ),
- public: false,
- ),
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'updateSMTP',
- description: '/docs/references/projects/update-smtp.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- )
- ])
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('enabled', false, new Boolean(), 'Enable custom SMTP service')
- ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true)
- ->param('senderEmail', '', new Email(), 'Email of the sender', true)
- ->param('replyTo', '', new Email(), 'Reply to email', true)
- ->param('host', '', new HostName(), 'SMTP server host name', true)
- ->param('port', 587, new Integer(), 'SMTP server port', true)
- ->param('username', '', new Text(0, 0), 'SMTP server username', true)
- ->param('password', '', new Text(0, 0), 'SMTP server password', true)
- ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true)
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, bool $enabled, string $senderName, string $senderEmail, string $replyTo, string $host, int $port, string $username, string $password, string $secure, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- // Ensure required params for when enabling SMTP
- if ($enabled) {
- if (empty($senderName)) {
- throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Sender name is required when enabling SMTP.');
- } elseif (empty($senderEmail)) {
- throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Sender email is required when enabling SMTP.');
- } elseif (empty($host)) {
- throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Host is required when enabling SMTP.');
- } elseif (empty($port)) {
- throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Port is required when enabling SMTP.');
- }
- }
-
- // validate SMTP settings
- if ($enabled) {
- $mail = new PHPMailer(true);
- $mail->isSMTP();
- $mail->SMTPAuth = (!empty($username) && !empty($password));
- $mail->Username = $username;
- $mail->Password = $password;
- $mail->Host = $host;
- $mail->Port = $port;
- $mail->SMTPSecure = $secure;
- $mail->SMTPAutoTLS = false;
- $mail->Timeout = 5;
-
- try {
- $valid = $mail->SmtpConnect();
-
- if (!$valid) {
- throw new Exception('Connection is not valid.');
- }
- } catch (Throwable $error) {
- throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage());
- }
- }
-
- // Save SMTP settings
- if ($enabled) {
- $smtp = [
- 'enabled' => $enabled,
- 'senderName' => $senderName,
- 'senderEmail' => $senderEmail,
- 'replyTo' => $replyTo,
- 'host' => $host,
- 'port' => $port,
- 'username' => $username,
- 'password' => $password,
- 'secure' => $secure,
- ];
- } else {
- $smtp = [
- 'enabled' => false
- ];
- }
-
- $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::post('/v1/projects/:projectId/smtp/tests')
- ->desc('Create SMTP test')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', [
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'createSmtpTest',
- description: '/docs/references/projects/create-smtp-test.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_NOCONTENT,
- model: Response::MODEL_NONE,
- )
- ],
- deprecated: new Deprecated(
- since: '1.8.0',
- replaceWith: 'projects.createSMTPTest',
- ),
- public: false,
- ),
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'createSMTPTest',
- description: '/docs/references/projects/create-smtp-test.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_NOCONTENT,
- model: Response::MODEL_NONE,
- )
- ]
- )
- ])
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('emails', [], new ArrayList(new Email(), 10), 'Array of emails to send test email to. Maximum of 10 emails are allowed.')
- ->param('senderName', System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'), new Text(255, 0), 'Name of the email sender')
- ->param('senderEmail', System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM), new Email(), 'Email of the sender')
- ->param('replyTo', '', new Email(), 'Reply to email', true)
- ->param('host', '', new HostName(), 'SMTP server host name')
- ->param('port', 587, new Integer(), 'SMTP server port', true)
- ->param('username', '', new Text(0, 0), 'SMTP server username', true)
- ->param('password', '', new Text(0, 0), 'SMTP server password', true)
- ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true)
- ->inject('response')
- ->inject('dbForPlatform')
- ->inject('queueForMails')
- ->inject('plan')
- ->action(function (string $projectId, array $emails, string $senderName, string $senderEmail, string $replyTo, string $host, int $port, string $username, string $password, string $secure, Response $response, Database $dbForPlatform, Mail $queueForMails, array $plan) {
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $replyToEmail = !empty($replyTo) ? $replyTo : $senderEmail;
-
- $subject = 'Custom SMTP email sample';
- $template = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-smtp-test.tpl');
- $template
- ->setParam('{{from}}', "{$senderName} ({$senderEmail})")
- ->setParam('{{replyTo}}', "{$senderName} ({$replyToEmail})")
- ->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL)
- ->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR)
- ->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER)
- ->setParam('{{discordUrl}}', $plan['discordUrl'] ?? APP_SOCIAL_DISCORD)
- ->setParam('{{githubUrl}}', $plan['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE)
- ->setParam('{{termsUrl}}', $plan['termsUrl'] ?? APP_EMAIL_TERMS_URL)
- ->setParam('{{privacyUrl}}', $plan['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL);
-
- foreach ($emails as $email) {
- $queueForMails
- ->setSmtpHost($host)
- ->setSmtpPort($port)
- ->setSmtpUsername($username)
- ->setSmtpPassword($password)
- ->setSmtpSecure($secure)
- ->setSmtpReplyTo($replyTo)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName)
- ->setRecipient($email)
- ->setName('')
- ->setBodyTemplate(__DIR__ . '/../../config/locale/templates/email-base-styled.tpl')
- ->setBody($template->render())
- ->setVariables([])
- ->setSubject($subject)
- ->trigger();
- }
-
- $response->noContent();
- });
-
-Http::get('/v1/projects/:projectId/templates/sms/:type/:locale')
- ->desc('Get custom SMS template')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', [
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'getSmsTemplate',
- description: '/docs/references/projects/get-sms-template.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_SMS_TEMPLATE,
- )
- ],
- deprecated: new Deprecated(
- since: '1.8.0',
- replaceWith: 'projects.getSMSTemplate',
- ),
- public: false,
- ),
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'getSMSTemplate',
- description: '/docs/references/projects/get-sms-template.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_SMS_TEMPLATE,
- )
- ]
- )
- ])
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('type', '', new WhiteList(Config::getParam('locale-templates')['sms'] ?? [], true), 'Template type')
- ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes'])
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) {
-
- throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED);
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $templates = $project->getAttribute('templates', []);
- $template = $templates['sms.' . $type . '-' . $locale] ?? null;
-
- if (is_null($template)) {
- $template = [
- 'message' => Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl')->render(),
- ];
- }
-
- $template['type'] = $type;
- $template['locale'] = $locale;
-
- $response->dynamic(new Document($template), Response::MODEL_SMS_TEMPLATE);
- });
-
-
-Http::get('/v1/projects/:projectId/templates/email/:type/:locale')
- ->desc('Get custom email template')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'getEmailTemplate',
- description: '/docs/references/projects/get-email-template.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_EMAIL_TEMPLATE,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type')
- ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes'])
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $templates = $project->getAttribute('templates', []);
- $template = $templates['email.' . $type . '-' . $locale] ?? null;
-
- $localeObj = new Locale($locale);
- $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en'));
-
- if (is_null($template)) {
- /**
- * different templates, different placeholders.
- */
- $templateConfigs = [
- 'magicSession' => [
- 'file' => 'email-magic-url.tpl',
- 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase']
- ],
- 'mfaChallenge' => [
- 'file' => 'email-mfa-challenge.tpl',
- 'placeholders' => ['description', 'clientInfo']
- ],
- 'otpSession' => [
- 'file' => 'email-otp.tpl',
- 'placeholders' => ['description', 'clientInfo', 'securityPhrase']
- ],
- 'sessionAlert' => [
- 'file' => 'email-session-alert.tpl',
- 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer']
- ],
- ];
-
- // fallback to the base template.
- $config = $templateConfigs[$type] ?? [
- 'file' => 'email-inner-base.tpl',
- 'placeholders' => ['buttonText', 'body', 'footer']
- ];
-
- $templateString = file_get_contents(__DIR__ . '/../../config/locale/templates/' . $config['file']);
-
- // We use `fromString` due to the replace above
- $message = Template::fromString($templateString);
-
- // Set type-specific parameters
- foreach ($config['placeholders'] as $param) {
- $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']);
- $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$type}.{$param}"), escapeHtml: $escapeHtml);
- }
-
- $message
- // common placeholders on all the templates
- ->setParam('{{hello}}', $localeObj->getText("emails.{$type}.hello"))
- ->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks"))
- ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature"));
-
- // `useContent: false` will strip new lines!
- $message = $message->render(useContent: true);
-
- $template = [
- 'message' => $message,
- 'subject' => $localeObj->getText('emails.' . $type . '.subject'),
- 'senderEmail' => '',
- 'senderName' => ''
- ];
- }
-
- $template['type'] = $type;
- $template['locale'] = $locale;
-
- $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE);
- });
-
-Http::patch('/v1/projects/:projectId/templates/sms/:type/:locale')
- ->desc('Update custom SMS template')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', [
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'updateSmsTemplate',
- description: '/docs/references/projects/update-sms-template.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_SMS_TEMPLATE,
- )
- ],
- deprecated: new Deprecated(
- since: '1.8.0',
- replaceWith: 'projects.updateSMSTemplate',
- ),
- public: false,
- ),
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'updateSMSTemplate',
- description: '/docs/references/projects/update-sms-template.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_SMS_TEMPLATE,
- )
- ]
- )
- ])
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('type', '', new WhiteList(Config::getParam('locale-templates')['sms'] ?? [], true), 'Template type')
- ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes'])
- ->param('message', '', new Text(0), 'Template message')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $type, string $locale, string $message, Response $response, Database $dbForPlatform) {
-
- throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED);
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $templates = $project->getAttribute('templates', []);
- $templates['sms.' . $type . '-' . $locale] = [
- 'message' => $message
- ];
-
- $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates));
-
- $response->dynamic(new Document([
- 'message' => $message,
- 'type' => $type,
- 'locale' => $locale,
- ]), Response::MODEL_SMS_TEMPLATE);
- });
-
-Http::patch('/v1/projects/:projectId/templates/email/:type/:locale')
- ->desc('Update custom email templates')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'updateEmailTemplate',
- description: '/docs/references/projects/update-email-template.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_EMAIL_TEMPLATE,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type')
- ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes'])
- ->param('subject', '', new Text(255), 'Email Subject')
- ->param('message', '', new Text(0), 'Template message')
- ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true)
- ->param('senderEmail', '', new Email(), 'Email of the sender', true)
- ->param('replyTo', '', new Email(), 'Reply to email', true)
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $templates = $project->getAttribute('templates', []);
- $templates['email.' . $type . '-' . $locale] = [
- 'senderName' => $senderName,
- 'senderEmail' => $senderEmail,
- 'subject' => $subject,
- 'replyTo' => $replyTo,
- 'message' => $message
- ];
-
- $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates));
-
- $response->dynamic(new Document([
- 'type' => $type,
- 'locale' => $locale,
- 'senderName' => $senderName,
- 'senderEmail' => $senderEmail,
- 'subject' => $subject,
- 'replyTo' => $replyTo,
- 'message' => $message
- ]), Response::MODEL_EMAIL_TEMPLATE);
- });
-
-Http::delete('/v1/projects/:projectId/templates/sms/:type/:locale')
- ->desc('Reset custom SMS template')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', [
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'deleteSmsTemplate',
- description: '/docs/references/projects/delete-sms-template.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_SMS_TEMPLATE,
- )
- ],
- contentType: ContentType::JSON,
- deprecated: new Deprecated(
- since: '1.8.0',
- replaceWith: 'projects.deleteSMSTemplate',
- ),
- public: false,
- ),
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'deleteSMSTemplate',
- description: '/docs/references/projects/delete-sms-template.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_SMS_TEMPLATE,
- )
- ],
- contentType: ContentType::JSON
- )
- ])
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('type', '', new WhiteList(Config::getParam('locale-templates')['sms'] ?? [], true), 'Template type')
- ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes'])
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) {
-
- throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED);
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $templates = $project->getAttribute('templates', []);
- $template = $templates['sms.' . $type . '-' . $locale] ?? null;
-
- if (is_null($template)) {
- throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION);
- }
-
- unset($template['sms.' . $type . '-' . $locale]);
-
- $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates));
-
- $response->dynamic(new Document([
- 'type' => $type,
- 'locale' => $locale,
- 'message' => $template['message']
- ]), Response::MODEL_SMS_TEMPLATE);
- });
-
-Http::delete('/v1/projects/:projectId/templates/email/:type/:locale')
+// Backwards compatibility
+Http::delete('/v1/projects/:projectId/templates/email')
+ ->alias('/v1/projects/:projectId/templates/email/:type/:locale')
->desc('Delete custom email template')
->groups(['api', 'projects'])
->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'deleteEmailTemplate',
- description: '/docs/references/projects/delete-email-template.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_EMAIL_TEMPLATE,
- )
- ],
- contentType: ContentType::JSON
- ))
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type')
- ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes'])
+ ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes'])
->inject('response')
->inject('dbForPlatform')
->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) {
+ $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en');
$project = $dbForPlatform->getDocument('projects', $projectId);
-
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$templates = $project->getAttribute('templates', []);
- $template = $templates['email.' . $type . '-' . $locale] ?? null;
-
- if (is_null($template)) {
- throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION);
- }
-
unset($templates['email.' . $type . '-' . $locale]);
$project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates));
- $response->dynamic(new Document([
- 'type' => $type,
- 'locale' => $locale,
- 'senderName' => $template['senderName'],
- 'senderEmail' => $template['senderEmail'],
- 'subject' => $template['subject'],
- 'replyTo' => $template['replyTo'],
- 'message' => $template['message']
- ]), Response::MODEL_EMAIL_TEMPLATE);
- });
-
-Http::patch('/v1/projects/:projectId/auth/session-invalidation')
- ->desc('Update invalidate session option of the project')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateSessionInvalidation',
- description: '/docs/references/projects/update-session-invalidation.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('enabled', false, new Boolean(), 'Update authentication session invalidation status. Use this endpoint to enable or disable session invalidation on password change')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $auths = $project->getAttribute('auths', []);
- $auths['invalidateSessions'] = $enabled;
- $dbForPlatform->updateDocument('projects', $project->getId(), $project
- ->setAttribute('auths', $auths));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
+ $response->noContent();
});
diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php
index 867ed94e20..1e4c8391a1 100644
--- a/app/controllers/api/users.php
+++ b/app/controllers/api/users.php
@@ -11,8 +11,9 @@ use Appwrite\Auth\Validator\Phone;
use Appwrite\Deletes\Identities as DeleteIdentities;
use Appwrite\Deletes\Targets as DeleteTargets;
use Appwrite\Detector\Detector;
-use Appwrite\Event\Delete;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Delete as DeleteMessage;
+use Appwrite\Event\Publisher\Delete as DeletePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Hooks\Hooks;
use Appwrite\SDK\AuthType;
@@ -131,15 +132,15 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor
} catch (\Throwable) {
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -856,7 +857,7 @@ Http::get('/v1/users/:userId/targets/:targetId')
Http::get('/v1/users/:userId/sessions')
->desc('List user sessions')
->groups(['api', 'users'])
- ->label('scope', 'users.read')
+ ->label('scope', ['users.read', 'sessions.read'])
->label('sdk', new Method(
namespace: 'users',
group: 'sessions',
@@ -1535,7 +1536,7 @@ Http::patch('/v1/users/:userId/email')
Query::equal('identifier', [$email]),
]);
- if ($target instanceof Document && !$target->isEmpty()) {
+ if (!$target->isEmpty()) {
throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS);
}
}
@@ -1563,15 +1564,15 @@ Http::patch('/v1/users/:userId/email')
} catch (\Throwable) {
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -1595,9 +1596,6 @@ Http::patch('/v1/users/:userId/email')
'emailIsDisposable' => $user->getAttribute('emailIsDisposable'),
'emailIsFree' => $user->getAttribute('emailIsFree'),
]));
- /**
- * @var Document $oldTarget
- */
$oldTarget = $user->find('identifier', $oldEmail, 'targets');
if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) {
@@ -1681,7 +1679,7 @@ Http::patch('/v1/users/:userId/phone')
Query::equal('identifier', [$number]),
]);
- if ($target instanceof Document && !$target->isEmpty()) {
+ if (!$target->isEmpty()) {
throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS);
}
}
@@ -1691,9 +1689,6 @@ Http::patch('/v1/users/:userId/phone')
'phone' => $phoneValue,
'phoneVerification' => $user->getAttribute('phoneVerification'),
]));
- /**
- * @var Document $oldTarget
- */
$oldTarget = $user->find('identifier', $oldPhone, 'targets');
if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) {
@@ -2252,8 +2247,8 @@ Http::delete('/v1/users/:userId/mfa/authenticators/:type')
->label('event', 'users.[userId].delete.mfa')
->label('scope', 'users.write')
->label('audits.event', 'user.update')
- ->label('audits.resource', 'user/{response.$id}')
- ->label('audits.userId', '{response.$id}')
+ ->label('audits.resource', 'user/{request.userId}')
+ ->label('audits.userId', '{request.userId}')
->label('usage.metric', 'users.{scope}.requests.update')
->label('sdk', [
new Method(
@@ -2320,7 +2315,7 @@ Http::post('/v1/users/:userId/sessions')
->desc('Create session')
->groups(['api', 'users'])
->label('event', 'users.[userId].sessions.[sessionId].create')
- ->label('scope', 'users.write')
+ ->label('scope', ['users.write', 'sessions.write'])
->label('audits.event', 'session.create')
->label('audits.resource', 'user/{request.userId}')
->label('usage.metric', 'sessions.{scope}.requests.create')
@@ -2476,7 +2471,7 @@ Http::delete('/v1/users/:userId/sessions/:sessionId')
->desc('Delete user session')
->groups(['api', 'users'])
->label('event', 'users.[userId].sessions.[sessionId].delete')
- ->label('scope', 'users.write')
+ ->label('scope', ['users.write', 'sessions.write'])
->label('audits.event', 'session.delete')
->label('audits.resource', 'user/{request.userId}')
->label('sdk', new Method(
@@ -2527,7 +2522,7 @@ Http::delete('/v1/users/:userId/sessions')
->desc('Delete user sessions')
->groups(['api', 'users'])
->label('event', 'users.[userId].sessions.delete')
- ->label('scope', 'users.write')
+ ->label('scope', ['users.write', 'sessions.write'])
->label('audits.event', 'session.delete')
->label('audits.resource', 'user/{user.$id}')
->label('sdk', new Method(
@@ -2598,8 +2593,8 @@ Http::delete('/v1/users/:userId')
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
- ->inject('queueForDeletes')
- ->action(function (string $userId, Response $response, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes) {
+ ->inject('publisherForDeletes')
+ ->action(function (string $userId, Response $response, Database $dbForProject, Event $queueForEvents, DeletePublisher $publisherForDeletes) {
$user = $dbForProject->getDocument('users', $userId);
@@ -2614,9 +2609,11 @@ Http::delete('/v1/users/:userId')
DeleteIdentities::delete($dbForProject, Query::equal('userInternalId', [$user->getSequence()]));
DeleteTargets::delete($dbForProject, Query::equal('userInternalId', [$user->getSequence()]));
- $queueForDeletes
- ->setType(DELETE_TYPE_DOCUMENT)
- ->setDocument($clone);
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $queueForEvents->getProject(),
+ type: DELETE_TYPE_DOCUMENT,
+ document: $clone,
+ ));
$queueForEvents
->setParam('userId', $user->getId())
@@ -2649,10 +2646,10 @@ Http::delete('/v1/users/:userId/targets/:targetId')
->param('userId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'User ID.', false, ['dbForProject'])
->param('targetId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Target ID.', false, ['dbForProject'])
->inject('queueForEvents')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('response')
->inject('dbForProject')
- ->action(function (string $userId, string $targetId, Event $queueForEvents, Delete $queueForDeletes, Response $response, Database $dbForProject) {
+ ->action(function (string $userId, string $targetId, Event $queueForEvents, DeletePublisher $publisherForDeletes, Response $response, Database $dbForProject) {
$user = $dbForProject->getDocument('users', $userId);
if ($user->isEmpty()) {
@@ -2672,9 +2669,11 @@ Http::delete('/v1/users/:userId/targets/:targetId')
$dbForProject->deleteDocument('targets', $target->getId());
$dbForProject->purgeCachedDocument('users', $user->getId());
- $queueForDeletes
- ->setType(DELETE_TYPE_TARGET)
- ->setDocument($target);
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $queueForEvents->getProject(),
+ type: DELETE_TYPE_TARGET,
+ document: $target,
+ ));
$queueForEvents
->setParam('userId', $user->getId())
@@ -2842,6 +2841,7 @@ Http::get('/v1/users/usage')
$format = match ($days['period']) {
'1h' => 'Y-m-d\TH:00:00.000P',
'1d' => 'Y-m-d\T00:00:00.000P',
+ default => throw new \LogicException('Unsupported period: ' . $days['period']),
};
foreach ($metrics as $metric) {
diff --git a/app/controllers/general.php b/app/controllers/general.php
index 542effc091..b39c2e2623 100644
--- a/app/controllers/general.php
+++ b/app/controllers/general.php
@@ -7,9 +7,10 @@ use Ahc\Jwt\JWTException;
use Appwrite\Auth\Key;
use Appwrite\Bus\Events\ExecutionCompleted;
use Appwrite\Bus\Events\RequestCompleted;
-use Appwrite\Event\Certificate;
-use Appwrite\Event\Delete as DeleteEvent;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Delete as DeleteMessage;
+use Appwrite\Event\Publisher\Certificate;
+use Appwrite\Event\Publisher\Delete as DeletePublisher;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Network\Cors;
use Appwrite\Platform\Appwrite;
@@ -26,6 +27,10 @@ use Appwrite\Utopia\Request\Filters\V19 as RequestV19;
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\Request\Filters\V25 as RequestV25;
+use Appwrite\Utopia\Request\Filters\V26 as RequestV26;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Filters\V16 as ResponseV16;
use Appwrite\Utopia\Response\Filters\V17 as ResponseV17;
@@ -34,7 +39,12 @@ use Appwrite\Utopia\Response\Filters\V19 as ResponseV19;
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\Response\Filters\V25 as ResponseV25;
+use Appwrite\Utopia\Response\Filters\V26 as ResponseV26;
use Appwrite\Utopia\View;
+use Executor\Exception\Timeout as ExecutorTimeout;
use Executor\Executor;
use MaxMind\Db\Reader;
use Swoole\Http\Request as SwooleRequest;
@@ -65,9 +75,9 @@ use Utopia\Validator\Text;
Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE);
-function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount)
+function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeletePublisher $publisherForDeletes, int $executionsRetentionCount)
{
- $host = $request->getHostname() ?? '';
+ $host = $request->getHostname();
if (!empty($previewHostname)) {
$host = $previewHostname;
}
@@ -118,7 +128,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
}
}
- if (!in_array($host, $platformHostnames)) {
+ if (!in_array($host, $platformHostnames) && System::getEnv('_APP_OPTIONS_ROUTER_PROTECTION', 'enabled') === 'enabled') {
throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Router protection does not allow accessing Appwrite over this domain. Please add it as custom domain to your project or disable _APP_OPTIONS_ROUTER_PROTECTION environment variable.', view: $errorView);
}
@@ -174,37 +184,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
/** @var Database $dbForProject */
$dbForProject = $getProjectDB($project);
- if (!empty($rule->getAttribute('deploymentId', ''))) {
- $deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $rule->getAttribute('deploymentId')));
- } else {
- // 1.6.x DB schema compatibility
- // TODO: Make sure deploymentId is never empty, and remove this code
-
- // Check if site or function; should never be site, but better safe than sorry
- // Attempts to use attribute from both schemas (1.6 and 1.7)
- $resourceType = $rule->getAttribute('deploymentResourceType', $rule->getAttribute('resourceType', ''));
-
- // ID of site or function
- $resourceId = $rule->getAttribute('deploymentResourceId', '');
-
- // Document of site or function
- $resource = $resourceType === 'function' ?
- $authorization->skip(fn () => $dbForProject->getDocument('functions', $resourceId)) :
- $authorization->skip(fn () => $dbForProject->getDocument('sites', $resourceId));
-
- // ID of active deployments
- // Attempts to use attribute from both schemas (1.6 and 1.7)
- $activeDeploymentId = $resource->getAttribute('deploymentId', $resource->getAttribute('deployment', ''));
-
- // Get deployment document, as intended originally
- $deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $activeDeploymentId));
- }
-
- if ($deployment->getAttribute('resourceType', '') === 'functions') {
- $type = 'function';
- } elseif ($deployment->getAttribute('resourceType', '') === 'sites') {
- $type = 'site';
- }
+ $deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $rule->getAttribute('deploymentId')));
if ($deployment->isEmpty()) {
$resourceType = $rule->getAttribute('deploymentResourceType', '');
@@ -215,6 +195,14 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
throw $exception;
}
+ if ($deployment->getAttribute('resourceType', '') === 'functions') {
+ $type = 'function';
+ } elseif ($deployment->getAttribute('resourceType', '') === 'sites') {
+ $type = 'site';
+ } else {
+ throw new AppwriteException(AppwriteException::GENERAL_SERVER_ERROR, 'Unknown deployment resource type', view: $errorView);
+ }
+
$resource = $type === 'function' ?
$authorization->skip(fn () => $dbForProject->getDocument('functions', $deployment->getAttribute('resourceId', ''))) :
$authorization->skip(fn () => $dbForProject->getDocument('sites', $deployment->getAttribute('resourceId', '')));
@@ -302,13 +290,13 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
}
}
- $body = $swooleRequest->getContent() ?? '';
+ $body = $swooleRequest->getContent() ?: '';
$method = $swooleRequest->server['request_method'];
$requestHeaders = $request->getHeaders();
if ($resource->isEmpty() || !$resource->getAttribute('enabled')) {
- if ($type === 'functions') {
+ if ($type === 'function') {
throw new AppwriteException(AppwriteException::FUNCTION_NOT_FOUND, view: $errorView);
} else {
throw new AppwriteException(AppwriteException::SITE_NOT_FOUND, view: $errorView);
@@ -330,7 +318,6 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
$runtime = match ($type) {
'function' => $runtimes[$resource->getAttribute('runtime')] ?? null,
'site' => $runtimes[$resource->getAttribute('buildRuntime')] ?? null,
- default => null
};
// Static site enforced runtime
@@ -394,7 +381,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'] = '';
@@ -459,10 +446,10 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
// V2 vars
if ($version === 'v2') {
$vars = \array_merge($vars, [
- 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '',
+ 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'],
'APPWRITE_FUNCTION_DATA' => $body,
- 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '',
- 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? ''
+ 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'],
+ 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt']
]);
}
@@ -574,26 +561,30 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
'site' => '',
};
- $executionResponse = $executor->createExecution(
- projectId: $project->getId(),
- deploymentId: $deployment->getId(),
- body: \strlen($body) > 0 ? $body : null,
- variables: $vars,
- timeout: $resource->getAttribute('timeout', 30),
- image: $runtime['image'],
- source: $source,
- entrypoint: $entrypoint,
- version: $version,
- path: $path,
- method: $method,
- headers: $headers,
- runtimeEntrypoint: $runtimeEntrypoint,
- cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT,
- memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT,
- logging: $resource->getAttribute('logging', true),
- requestTimeout: 30,
- responseFormat: Executor::RESPONSE_FORMAT_ARRAY_HEADERS
- );
+ try {
+ $executionResponse = $executor->createExecution(
+ projectId: $project->getId(),
+ deploymentId: $deployment->getId(),
+ body: \strlen($body) > 0 ? $body : null,
+ variables: $vars,
+ timeout: $resource->getAttribute('timeout', 30),
+ image: $runtime['image'],
+ source: $source,
+ entrypoint: $entrypoint,
+ version: $version,
+ path: $path,
+ method: $method,
+ headers: $headers,
+ runtimeEntrypoint: $runtimeEntrypoint,
+ cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT,
+ memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT,
+ logging: $resource->getAttribute('logging', true),
+ requestTimeout: 30,
+ responseFormat: Executor::RESPONSE_FORMAT_ARRAY_HEADERS
+ );
+ } catch (ExecutorTimeout $th) {
+ throw new AppwriteException(AppwriteException::FUNCTION_SYNCHRONOUS_TIMEOUT, previous: $th);
+ }
$headerOverrides = [];
@@ -678,9 +669,8 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
if (\is_string($logs) && \strlen($logs) > $maxLogLength) {
$warningMessage = "[WARNING] Logs truncated. The output exceeded {$maxLogLength} characters.\n";
- $warningLength = \strlen($warningMessage);
- $maxContentLength = max(0, $maxLogLength - $warningLength);
- $logs = $warningMessage . ($maxContentLength > 0 ? \substr($logs, -$maxContentLength) : '');
+ $maxContentLength = $maxLogLength - \strlen($warningMessage);
+ $logs = $warningMessage . \substr($logs, -$maxContentLength);
}
// Truncate errors if they exceed the limit
@@ -689,9 +679,8 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
if (\is_string($errors) && \strlen($errors) > $maxErrorLength) {
$warningMessage = "[WARNING] Errors truncated. The output exceeded {$maxErrorLength} characters.\n";
- $warningLength = \strlen($warningMessage);
- $maxContentLength = max(0, $maxErrorLength - $warningLength);
- $errors = $warningMessage . ($maxContentLength > 0 ? \substr($errors, -$maxContentLength) : '');
+ $maxContentLength = $maxErrorLength - \strlen($warningMessage);
+ $errors = $warningMessage . \substr($errors, -$maxContentLength);
}
/** Update execution status */
$status = $executionResponse['statusCode'] >= 500 ? 'failed' : 'completed';
@@ -719,14 +708,12 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
throw $th;
}
} finally {
- if ($type === 'function' || $type === 'site') {
- $bus->dispatch(new ExecutionCompleted(
- execution: $execution->getArrayCopy(),
- project: $project->getArrayCopy(),
- spec: $spec,
- resource: $resource->getArrayCopy(),
- ));
- }
+ $bus->dispatch(new ExecutionCompleted(
+ execution: $execution->getArrayCopy(),
+ project: $project->getArrayCopy(),
+ spec: $spec,
+ resource: $resource->getArrayCopy(),
+ ));
}
$execution->setAttribute('logs', '');
@@ -780,12 +767,12 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
? RESOURCE_TYPE_FUNCTIONS
: RESOURCE_TYPE_SITES;
- $queueForDeletes
- ->setProject($project)
- ->setResourceType($resourceType)
- ->setResource($resource->getSequence())
- ->setType(DELETE_TYPE_EXECUTIONS_LIMIT)
- ->trigger();
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $project,
+ type: DELETE_TYPE_EXECUTIONS_LIMIT,
+ resource: (string) $resource->getSequence(),
+ resourceType: $resourceType,
+ ));
}
return true;
@@ -846,17 +833,17 @@ Http::init()
->inject('apiKey')
->inject('cors')
->inject('authorization')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('executionsRetentionCount')
- ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, Event $queueForEvents, Bus $bus, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
+ ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, Event $queueForEvents, Bus $bus, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeletePublisher $publisherForDeletes, int $executionsRetentionCount) {
/*
* Appwrite Router
*/
- $hostname = $request->getHostname() ?? '';
+ $hostname = $request->getHostname();
$platformHostnames = $platform['hostnames'] ?? [];
// Only run Router when external domain
if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) {
- if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
+ if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $publisherForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
@@ -897,6 +884,18 @@ Http::init()
if (version_compare($requestFormat, '1.9.1', '<')) {
$request->addFilter(new RequestV22());
}
+ if (version_compare($requestFormat, '1.9.2', '<')) {
+ $request->addFilter(new RequestV23());
+ }
+ if (version_compare($requestFormat, '1.9.3', '<')) {
+ $request->addFilter(new RequestV24());
+ }
+ if (version_compare($requestFormat, '1.9.4', '<')) {
+ $request->addFilter(new RequestV25());
+ }
+ if (version_compare($requestFormat, '1.9.5', '<')) {
+ $request->addFilter(new RequestV26());
+ }
}
$localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', ''));
@@ -921,6 +920,18 @@ Http::init()
*/
$responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', ''));
if ($responseFormat) {
+ if (version_compare($responseFormat, '1.9.5', '<')) {
+ $response->addFilter(new ResponseV26());
+ }
+ if (version_compare($responseFormat, '1.9.4', '<')) {
+ $response->addFilter(new ResponseV25());
+ }
+ if (version_compare($responseFormat, '1.9.3', '<')) {
+ $response->addFilter(new ResponseV24());
+ }
+ if (version_compare($responseFormat, '1.9.2', '<')) {
+ $response->addFilter(new ResponseV23());
+ }
if (version_compare($responseFormat, '1.9.1', '<')) {
$response->addFilter(new ResponseV22());
}
@@ -1014,11 +1025,11 @@ Http::init()
->inject('request')
->inject('console')
->inject('dbForPlatform')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->inject('platform')
->inject('authorization')
->inject('certifiedDomains')
- ->action(function (Request $request, Document $console, Database $dbForPlatform, Certificate $queueForCertificates, array $platform, Authorization $authorization, Table $certifiedDomains) {
+ ->action(function (Request $request, Document $console, Database $dbForPlatform, Certificate $publisherForCertificates, array $platform, Authorization $authorization, Table $certifiedDomains) {
$hostname = $request->getHostname();
$platformHostnames = $platform['hostnames'] ?? [];
@@ -1044,7 +1055,7 @@ Http::init()
}
// 4. Check/create rule (requires DB access)
- $authorization->skip(function () use ($dbForPlatform, $domain, $console, $queueForCertificates, $certifiedDomains) {
+ $authorization->skip(function () use ($dbForPlatform, $domain, $console, $publisherForCertificates, $certifiedDomains) {
try {
// TODO: (@Meldiron) Remove after 1.7.x migration
$isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5';
@@ -1100,10 +1111,11 @@ Http::init()
$dbForPlatform->createDocument('rules', $document);
Console::info('Issuing a TLS certificate for the main domain (' . $domain->get() . ') in a few seconds...');
- $queueForCertificates
- ->setDomain($document)
- ->setSkipRenewCheck(true)
- ->trigger();
+ $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: $console,
+ domain: $document,
+ skipRenewCheck: true,
+ ));
} catch (Duplicate $e) {
Console::info('Certificate already exists');
} finally {
@@ -1132,16 +1144,16 @@ Http::options()
->inject('apiKey')
->inject('cors')
->inject('authorization')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('executionsRetentionCount')
- ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
+ ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeletePublisher $publisherForDeletes, int $executionsRetentionCount) {
/*
* Appwrite Router
*/
$platformHostnames = $platform['hostnames'] ?? [];
// Only run Router when external domain
if (!in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
- if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
+ if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $publisherForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
@@ -1247,7 +1259,7 @@ Http::error()
if (!$publish && $project->getId() !== 'console') {
$errorUser = new DBUser();
try {
- $resolvedUser = $utopia->getResource('user');
+ $resolvedUser = $utopia->context()->get('user');
if ($resolvedUser instanceof DBUser) {
$errorUser = $resolvedUser;
}
@@ -1266,7 +1278,7 @@ Http::error()
if ($logger && $publish) {
try {
/** @var Utopia\Database\Document $user */
- $user = $utopia->getResource('user');
+ $user = $utopia->context()->get('user');
} catch (\Throwable) {
// All good, user is optional information for logger
}
@@ -1467,7 +1479,7 @@ Http::error()
// the cors resource (which depends on rule -> DB) would cascade.
// Uses override:true to avoid duplicate headers if init() already set them.
try {
- $cors = $utopia->getResource('cors');
+ $cors = $utopia->context()->get('cors');
foreach ($cors->headers($request->getOrigin()) as $name => $value) {
$response
->removeHeader($name)
@@ -1498,9 +1510,9 @@ Http::error()
->setParam('development', Http::isDevelopment())
->setParam('projectName', $project->getAttribute('name'))
->setParam('projectURL', $project->getAttribute('url'))
- ->setParam('message', $output['message'] ?? '')
- ->setParam('type', $output['type'] ?? '')
- ->setParam('code', $output['code'] ?? '')
+ ->setParam('message', $output['message'])
+ ->setParam('type', $output['type'])
+ ->setParam('code', $output['code'])
->setParam('trace', $output['trace'] ?? [])
->setParam('exception', $error);
@@ -1534,15 +1546,15 @@ Http::get('/robots.txt')
->inject('previewHostname')
->inject('apiKey')
->inject('authorization')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('executionsRetentionCount')
- ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
+ ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeletePublisher $publisherForDeletes, int $executionsRetentionCount) {
$platformHostnames = $platform['hostnames'] ?? [];
if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
$template = new View(__DIR__ . '/../views/general/robots.phtml');
$response->text($template->render(false));
} else {
- if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
+ if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $publisherForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
@@ -1568,15 +1580,15 @@ Http::get('/humans.txt')
->inject('previewHostname')
->inject('apiKey')
->inject('authorization')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('executionsRetentionCount')
- ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
+ ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeletePublisher $publisherForDeletes, int $executionsRetentionCount) {
$platformHostnames = $platform['hostnames'] ?? [];
if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
$template = new View(__DIR__ . '/../views/general/humans.phtml');
$response->text($template->render(false));
} else {
- if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
+ if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $publisherForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
@@ -1615,7 +1627,7 @@ Http::get('/.well-known/acme-challenge/*')
throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND, 'Unknown path');
}
- if (!\substr($absolute, 0, \strlen($base)) === $base) {
+ if (\substr($absolute, 0, \strlen($base)) !== $base) {
throw new AppwriteException(AppwriteException::GENERAL_UNAUTHORIZED_SCOPE, 'Invalid path');
}
@@ -1694,7 +1706,7 @@ Http::get('/_appwrite/authorize')
->inject('previewHostname')
->action(function (Request $request, Response $response, string $previewHostname) {
- $host = $request->getHostname() ?? '';
+ $host = $request->getHostname();
if (!empty($previewHostname)) {
$host = $previewHostname;
}
diff --git a/app/controllers/mock.php b/app/controllers/mock.php
index 99713af430..4e92b3482d 100644
--- a/app/controllers/mock.php
+++ b/app/controllers/mock.php
@@ -251,7 +251,7 @@ Http::get('/v1/mock/github/callback')
$privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY');
$githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
- $owner = $github->getOwnerName($providerInstallationId) ?? '';
+ $owner = $github->getOwnerName($providerInstallationId);
$projectInternalId = $project->getSequence();
diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php
index bd54a8300b..6e5167660a 100644
--- a/app/controllers/shared/api.php
+++ b/app/controllers/shared/api.php
@@ -3,21 +3,21 @@
use Appwrite\Auth\Key;
use Appwrite\Auth\MFA\Type\TOTP;
use Appwrite\Bus\Events\RequestCompleted;
-use Appwrite\Event\Audit;
-use Appwrite\Event\Build;
-use Appwrite\Event\Database as EventDatabase;
-use Appwrite\Event\Delete;
+use Appwrite\Event\Context\Audit as AuditContext;
use Appwrite\Event\Event;
-use Appwrite\Event\Func;
-use Appwrite\Event\Mail;
+use Appwrite\Event\Message\Audit as AuditMessage;
+use Appwrite\Event\Message\Func as FunctionMessage;
use Appwrite\Event\Message\Usage as UsageMessage;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Publisher\Audit;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Functions\EventProcessor;
+use Appwrite\Platform\Modules\Storage\Config\CacheControl;
+use Appwrite\Platform\Modules\Storage\Config\StorageCacheControl;
use Appwrite\SDK\Method;
use Appwrite\Usage\Context;
use Appwrite\Utopia\Database\Documents\User;
@@ -42,9 +42,9 @@ use Utopia\System\System;
use Utopia\Telemetry\Adapter as Telemetry;
use Utopia\Validator\WhiteList;
-$parseLabel = function (string $label, array $responsePayload, array $requestParams, User $user) {
+$parseLabel = function (string $label, array $responsePayload, array $requestParams, User $user, Document $project) {
preg_match_all('/{(.*?)}/', $label, $matches);
- foreach ($matches[1] ?? [] as $pos => $match) {
+ foreach ($matches[1] as $pos => $match) {
$find = $matches[0][$pos];
$parts = explode('.', $match);
@@ -52,11 +52,12 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar
throw new Exception(Exception::GENERAL_SERVER_ERROR, "The server encountered an error while parsing the label: $label. Please create an issue on GitHub to allow us to investigate further https://github.com/appwrite/appwrite/issues/new/choose");
}
- $namespace = $parts[0] ?? '';
- $replace = $parts[1] ?? '';
+ $namespace = $parts[0];
+ $replace = $parts[1];
$params = match ($namespace) {
'user' => (array) $user,
+ 'project' => $project->getArrayCopy(),
'request' => $requestParams,
default => $responsePayload,
};
@@ -88,7 +89,7 @@ Http::init()
->inject('request')
->inject('dbForPlatform')
->inject('dbForProject')
- ->inject('queueForAudits')
+ ->inject('auditContext')
->inject('project')
->inject('user')
->inject('session')
@@ -97,7 +98,7 @@ Http::init()
->inject('team')
->inject('apiKey')
->inject('authorization')
- ->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, Audit $queueForAudits, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) {
+ ->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, AuditContext $auditContext, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) {
$route = $utopia->getRoute();
if ($route === null) {
throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND);
@@ -180,7 +181,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);
}
@@ -193,7 +195,7 @@ Http::init()
'name' => $apiKey->getName(),
]);
- $queueForAudits->setUser($user);
+ $auditContext->user = $user;
}
// For standard keys, update last accessed time
@@ -261,10 +263,9 @@ Http::init()
$userClone->setAttribute('type', match ($apiKey->getType()) {
API_KEY_STANDARD => ACTIVITY_TYPE_KEY_PROJECT,
API_KEY_ACCOUNT => ACTIVITY_TYPE_KEY_ACCOUNT,
- API_KEY_ORGANIZATION => ACTIVITY_TYPE_KEY_ORGANIZATION,
- default => ACTIVITY_TYPE_KEY_PROJECT,
+ default => ACTIVITY_TYPE_KEY_ORGANIZATION,
});
- $queueForAudits->setUser($userClone);
+ $auditContext->user = $userClone;
}
// Apply permission
@@ -383,7 +384,7 @@ Http::init()
}
// Step 6: Update project and user last activity
- if (! $project->isEmpty() && $project->getId() !== 'console') {
+ if ($project->getId() !== 'console') {
$accessedAt = $project->getAttribute('accessedAt', 0);
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) {
$authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
@@ -413,9 +414,6 @@ Http::init()
}
// Steps 7-9: Access Control - Method, Namespace and Scope Validation
- /**
- * @var ?Method $method
- */
$method = $route->getLabel('sdk', false);
// Take the first method if there's more than one,
@@ -484,26 +482,99 @@ Http::init()
->inject('response')
->inject('project')
->inject('user')
- ->inject('queueForEvents')
- ->inject('queueForMessaging')
- ->inject('queueForAudits')
- ->inject('queueForDeletes')
- ->inject('queueForDatabase')
- ->inject('queueForBuilds')
- ->inject('usage')
- ->inject('queueForFunctions')
- ->inject('queueForMails')
- ->inject('dbForProject')
->inject('timelimit')
+ ->inject('devKey')
+ ->inject('authorization')
+ ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, callable $timelimit, Document $devKey, Authorization $authorization) {
+ $response->setUser($user);
+ $request->setUser($user);
+
+ $roles = $authorization->getRoles();
+ $shouldCheckAbuse = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled'
+ && ! $user->isApp($roles)
+ && ! $user->isPrivileged($roles)
+ && $devKey->isEmpty();
+
+ $route = $utopia->getRoute();
+ if ($route === null) {
+ throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND);
+ }
+
+ $abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}');
+ $abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel;
+ $closestLimit = null;
+
+ foreach ($abuseKeyLabel as $abuseKey) {
+ $isRateLimited = false;
+
+ try {
+ $start = $request->getContentRangeStart();
+ $end = $request->getContentRangeEnd();
+ $timeLimit = $timelimit($abuseKey, $route->getLabel('abuse-limit', 0), $route->getLabel('abuse-time', 3600));
+ $timeLimit
+ ->setParam('{projectId}', $project->getId())
+ ->setParam('{userId}', $user->getId())
+ ->setParam('{userAgent}', $request->getUserAgent(''))
+ ->setParam('{ip}', $request->getIP())
+ ->setParam('{url}', $request->getHostname() . $route->getPath())
+ ->setParam('{method}', $request->getMethod())
+ ->setParam('{chunkId}', (int) ($start / ($end + 1 - $start)));
+
+ foreach ($request->getParams() as $key => $value) {
+ if (! empty($value)) {
+ $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value);
+ }
+ }
+
+ $abuse = new Abuse($timeLimit);
+ $remaining = $timeLimit->remaining();
+ $limit = $timeLimit->limit();
+ $time = $timeLimit->time() + $route->getLabel('abuse-time', 3600);
+
+ if ($limit && ($remaining < $closestLimit || is_null($closestLimit))) {
+ $closestLimit = $remaining;
+ $response
+ ->addHeader('X-RateLimit-Limit', $limit)
+ ->addHeader('X-RateLimit-Remaining', $remaining)
+ ->addHeader('X-RateLimit-Reset', $time);
+ }
+
+ if ($shouldCheckAbuse) {
+ $isRateLimited = $abuse->check();
+ }
+ } catch (\Throwable $th) {
+ \error_log((string) $th);
+
+ continue;
+ }
+
+ if ($isRateLimited) {
+ throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED);
+ }
+ }
+ });
+
+Http::init()
+ ->groups(['api'])
+ ->inject('utopia')
+ ->inject('request')
+ ->inject('response')
+ ->inject('project')
+ ->inject('user')
+ ->inject('queueForEvents')
+ ->inject('auditContext')
+ ->inject('usage')
+ ->inject('publisherForFunctions')
+ ->inject('dbForProject')
->inject('resourceToken')
->inject('mode')
->inject('apiKey')
->inject('plan')
- ->inject('devKey')
->inject('telemetry')
->inject('platform')
->inject('authorization')
- ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) {
+ ->inject('cacheControlForStorage')
+ ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Context $usage, FunctionPublisher $publisherForFunctions, Database $dbForProject, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Telemetry $telemetry, array $platform, Authorization $authorization, callable $cacheControlForStorage) {
$response->setUser($user);
$request->setUser($user);
@@ -520,70 +591,6 @@ Http::init()
default => '',
};
- /*
- * Abuse Check
- */
-
- $abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}');
- $timeLimitArray = [];
-
- $abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel;
-
- foreach ($abuseKeyLabel as $abuseKey) {
- $start = $request->getContentRangeStart();
- $end = $request->getContentRangeEnd();
- $timeLimit = $timelimit($abuseKey, $route->getLabel('abuse-limit', 0), $route->getLabel('abuse-time', 3600));
- $timeLimit
- ->setParam('{projectId}', $project->getId())
- ->setParam('{userId}', $user->getId())
- ->setParam('{userAgent}', $request->getUserAgent(''))
- ->setParam('{ip}', $request->getIP())
- ->setParam('{url}', $request->getHostname() . $route->getPath())
- ->setParam('{method}', $request->getMethod())
- ->setParam('{chunkId}', (int) ($start / ($end + 1 - $start)));
- $timeLimitArray[] = $timeLimit;
- }
-
- $closestLimit = null;
-
- $roles = $authorization->getRoles();
- $isPrivilegedUser = $user->isPrivileged($roles);
- $isAppUser = $user->isApp($roles);
-
- foreach ($timeLimitArray as $timeLimit) {
- foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys
- if (! empty($value)) {
- $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value);
- }
- }
-
- $abuse = new Abuse($timeLimit);
- $remaining = $timeLimit->remaining();
-
- $limit = $timeLimit->limit();
- $time = $timeLimit->time() + $route->getLabel('abuse-time', 3600);
-
- if ($limit && ($remaining < $closestLimit || is_null($closestLimit))) {
- $closestLimit = $remaining;
- $response
- ->addHeader('X-RateLimit-Limit', $limit)
- ->addHeader('X-RateLimit-Remaining', $remaining)
- ->addHeader('X-RateLimit-Reset', $time);
- }
-
- $enabled = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled';
-
- if (
- $enabled // Abuse is enabled
- && ! $isAppUser // User is not API key
- && ! $isPrivilegedUser // User is not an admin
- && $devKey->isEmpty() // request doesn't not contain development key
- && $abuse->check() // Route is rate-limited
- ) {
- throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED);
- }
- }
-
/**
* TODO: (@loks0n)
* Avoid mutating the message across file boundaries - it's difficult to reason about at scale.
@@ -596,13 +603,12 @@ Http::init()
->setProject($project)
->setUser($user);
- $queueForAudits
- ->setMode($mode)
- ->setUserAgent($request->getUserAgent(''))
- ->setIP($request->getIP())
- ->setHostname($request->getHostname())
- ->setEvent($route->getLabel('audits.event', ''))
- ->setProject($project);
+ $auditContext->mode = $mode;
+ $auditContext->userAgent = $request->getUserAgent('');
+ $auditContext->ip = $request->getIP();
+ $auditContext->hostname = $request->getHostname();
+ $auditContext->event = $route->getLabel('audits.event', '');
+ $auditContext->project = $project;
/* If a session exists, use the user associated with the session */
if (! $user->isEmpty()) {
@@ -611,28 +617,17 @@ Http::init()
if (empty($user->getAttribute('type'))) {
$userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER);
}
- $queueForAudits->setUser($userClone);
+ $auditContext->user = $userClone;
}
- /* Auto-set projects */
- $queueForDeletes->setProject($project);
- $queueForDatabase->setProject($project);
- $queueForMessaging->setProject($project);
- $queueForFunctions->setProject($project);
- $queueForBuilds->setProject($project);
- $queueForMails->setProject($project);
-
- /* Auto-set platforms */
- $queueForFunctions->setPlatform($platform);
- $queueForBuilds->setPlatform($platform);
- $queueForMails->setPlatform($platform);
-
$useCache = $route->getLabel('cache', false);
$storageCacheOperationsCounter = $telemetry->createCounter('storage.cache.operations.load');
if ($useCache) {
$route = $utopia->match($request);
+ $roles = $authorization->getRoles();
+ $isAppUser = $user->isApp($roles);
$isImageTransformation = $route->getPath() === '/v1/storage/buckets/:bucketId/files/:fileId/preview';
- $isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && ! $user->isPrivileged($authorization->getRoles());
+ $isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && ! $user->isPrivileged($roles);
$key = $request->cacheIdentifier();
Span::add('storage.cache.key', $key);
@@ -644,15 +639,16 @@ Http::init()
$data = $cache->load($key, $timestamp);
if (! empty($data) && ! $cacheLog->isEmpty()) {
+ $cacheControl = \sprintf('private, max-age=%d', $timestamp);
$parts = explode('/', $cacheLog->getAttribute('resourceType', ''));
- $type = $parts[0] ?? null;
+ $type = $parts[0];
if ($type === 'bucket' && (! $isImageTransformation || ! $isDisabled)) {
$bucketId = $parts[1] ?? null;
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isToken = ! $resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence();
- $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($roles);
if ($bucket->isEmpty() || (! $bucket->getAttribute('enabled') && ! $isAppUser && ! $isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
@@ -696,6 +692,21 @@ Http::init()
])));
}
}
+
+ if ($isImageTransformation) {
+ $cacheControl = $cacheControlForStorage(new StorageCacheControl(
+ source: CacheControl::SOURCE_CACHE,
+ user: $user,
+ maxAge: $timestamp,
+ project: $project,
+ bucket: $bucket,
+ file: $file,
+ resourceToken: $resourceToken,
+ fileSecurity: $fileSecurity,
+ cacheLog: $cacheLog,
+ route: $route,
+ ));
+ }
}
$accessedAt = $cacheLog->getAttribute('accessedAt', '');
@@ -708,7 +719,7 @@ Http::init()
}
$response
- ->addHeader('Cache-Control', sprintf('private, max-age=%d', $timestamp))
+ ->addHeader('Cache-Control', $cacheControl)
->addHeader('X-Appwrite-Cache', 'hit')
->setContentType($cacheLog->getAttribute('mimeType'));
$storageCacheOperationsCounter->add(1, ['result' => 'hit']);
@@ -756,7 +767,12 @@ Http::shutdown()
->inject('project')
->inject('dbForProject')
->action(function (Http $utopia, Request $request, Response $response, Document $project, Database $dbForProject) {
- $sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT;
+ $sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? 0;
+
+ if ($sessionLimit === 0) {
+ return;
+ }
+
$session = $response->getPayload();
$userId = $session['userId'] ?? '';
if (empty($userId)) {
@@ -790,14 +806,11 @@ Http::shutdown()
->inject('project')
->inject('user')
->inject('queueForEvents')
- ->inject('queueForAudits')
+ ->inject('auditContext')
+ ->inject('publisherForAudits')
->inject('usage')
->inject('publisherForUsage')
- ->inject('queueForDeletes')
- ->inject('queueForDatabase')
- ->inject('queueForBuilds')
- ->inject('queueForMessaging')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('queueForRealtime')
->inject('dbForProject')
@@ -807,7 +820,7 @@ Http::shutdown()
->inject('bus')
->inject('apiKey')
->inject('mode')
- ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) {
+ ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, FunctionPublisher $publisherForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) {
$responsePayload = $response->getPayload();
@@ -836,9 +849,15 @@ Http::shutdown()
if (! empty($functionsEvents)) {
foreach ($generatedEvents as $event) {
if (isset($functionsEvents[$event])) {
- $queueForFunctions
- ->from($queueForEvents)
- ->trigger();
+ $publisherForFunctions->enqueue(FunctionMessage::fromEvent(
+ event: $queueForEvents->getEvent(),
+ params: $queueForEvents->getParams(),
+ project: $queueForEvents->getProject(),
+ user: $queueForEvents->getUser(),
+ userId: $queueForEvents->getUserId(),
+ payload: $queueForEvents->getPayload(),
+ platform: $queueForEvents->getPlatform(),
+ ));
break;
}
}
@@ -900,9 +919,9 @@ Http::shutdown()
*/
$pattern = $route->getLabel('audits.resource', null);
if (! empty($pattern)) {
- $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user);
+ $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project);
if (! empty($resource) && $resource !== $pattern) {
- $queueForAudits->setResource($resource);
+ $auditContext->resource = $resource;
}
}
@@ -912,8 +931,8 @@ Http::shutdown()
if (empty($user->getAttribute('type'))) {
$userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER);
}
- $queueForAudits->setUser($userClone);
- } elseif ($queueForAudits->getUser() === null || $queueForAudits->getUser()->isEmpty()) {
+ $auditContext->user = $userClone;
+ } elseif ($auditContext->user === null || $auditContext->user->isEmpty()) {
/**
* User in the request is empty, and no user was set for auditing previously.
* This indicates:
@@ -931,40 +950,21 @@ Http::shutdown()
'name' => 'Guest',
]);
- $queueForAudits->setUser($user);
+ $auditContext->user = $user;
}
- if (! empty($queueForAudits->getResource()) && ! $queueForAudits->getUser()->isEmpty()) {
+ $auditUser = $auditContext->user;
+ if (! empty($auditContext->resource) && ! $auditUser->isEmpty()) {
/**
* audits.payload is switched to default true
* in order to auto audit payload for all endpoints
*/
$pattern = $route->getLabel('audits.payload', true);
if (! empty($pattern)) {
- $queueForAudits->setPayload($responsePayload);
+ $auditContext->payload = $responsePayload;
}
- foreach ($queueForEvents->getParams() as $key => $value) {
- $queueForAudits->setParam($key, $value);
- }
-
- $queueForAudits->trigger();
- }
-
- if (! empty($queueForDeletes->getType())) {
- $queueForDeletes->trigger();
- }
-
- if (! empty($queueForDatabase->getType())) {
- $queueForDatabase->trigger();
- }
-
- if (! empty($queueForBuilds->getType())) {
- $queueForBuilds->trigger();
- }
-
- if (! empty($queueForMessaging->getType())) {
- $queueForMessaging->trigger();
+ $publisherForAudits->enqueue(AuditMessage::fromContext($auditContext));
}
// Cache label
@@ -972,15 +972,16 @@ Http::shutdown()
if ($useCache) {
$resource = $resourceType = null;
$data = $response->getPayload();
- if (! empty($data['payload'])) {
+ $statusCode = $response->getStatusCode();
+ if (! empty($data['payload']) && $statusCode >= 200 && $statusCode < 300) {
$pattern = $route->getLabel('cache.resource', null);
if (! empty($pattern)) {
- $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user);
+ $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project);
}
$pattern = $route->getLabel('cache.resourceType', null);
if (! empty($pattern)) {
- $resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user);
+ $resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project);
}
$cache = new Cache(
diff --git a/app/http.php b/app/http.php
index 67da67376d..6dc415f000 100644
--- a/app/http.php
+++ b/app/http.php
@@ -3,7 +3,7 @@
require_once __DIR__ . '/init.php';
require_once __DIR__ . '/init/span.php';
-$registerRequestResources = require __DIR__ . '/init/resources/request.php';
+$setRequestContext = require __DIR__ . '/init/resources/request.php';
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
@@ -26,6 +26,7 @@ use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
+use Utopia\DI\Container;
use Utopia\Http\Adapter\Swoole\Server;
use Utopia\Http\Files;
use Utopia\Http\Http;
@@ -57,7 +58,7 @@ $container->set('pools', function ($register) {
$payloadSize = 12 * (1024 * 1024); // 12MB - adding slight buffer for headers and other data that might be sent with the payload - update later with valid testing
$totalWorkers = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6));
-$swooleAdapter = new Server(
+$swoole = new Server(
host: "0.0.0.0",
port: System::getEnv('PORT', 80),
settings: [
@@ -69,12 +70,10 @@ $swooleAdapter = new Server(
Constant::OPTION_OUTPUT_BUFFER_SIZE => $payloadSize,
Constant::OPTION_TASK_WORKER_NUM => 1, // required for the task to fetch domains background
],
- container: $container,
+ resources: $container,
);
-$container->set('container', fn () => fn () => $swooleAdapter->getContainer());
-
-$http = $swooleAdapter->getServer();
+$http = $swoole->getServer();
/**
* Assigns HTTP requests to worker threads by analyzing its payload/content.
@@ -192,13 +191,11 @@ $http->on(Constant::EVENT_AFTER_RELOAD, function ($server) {
Console::success('Reload completed...');
});
-$container->set('bus', function ($register) use ($swooleAdapter) {
- return $register->get('bus')->setResolver(fn (string $name) => $swooleAdapter->getContainer()->get($name));
-}, ['register']);
+$container->set('bus', fn ($register) => $register->get('bus')->setResolver(fn (string $name) => $swoole->context()->get($name)), ['register']);
include __DIR__ . '/controllers/general.php';
-function createDatabase(Http $app, string $resourceKey, string $dbName, array $collections, mixed $pools, ?callable $extraSetup = null): void
+function createDatabase(Container $resources, string $resourceKey, string $dbName, array $collections, mixed $pools, ?callable $extraSetup = null): void
{
$max = 15;
$sleep = 2;
@@ -207,7 +204,7 @@ function createDatabase(Http $app, string $resourceKey, string $dbName, array $c
while (true) {
try {
$attempts++;
- $resource = $app->getResource($resourceKey);
+ $resource = $resources->get($resourceKey);
/* @var $database Database */
$database = is_callable($resource) ? $resource() : $resource;
break; // exit loop on success
@@ -290,23 +287,21 @@ function createDatabase(Http $app, string $resourceKey, string $dbName, array $c
Span::current()?->finish();
}
-$http->on(Constant::EVENT_START, function ($http) use ($payloadSize, $totalWorkers, $swooleAdapter) {
- $app = new Http($swooleAdapter, 'UTC');
-
+$http->on(Constant::EVENT_START, function ($http) use ($payloadSize, $totalWorkers, $container) {
/** @var \Utopia\Pools\Group $pools */
- $pools = $app->getResource('pools');
+ $pools = $container->get('pools');
- go(function () use ($app, $pools) {
+ go(function () use ($container, $pools) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
// create logs database first, `getLogsDB` is a callable.
- createDatabase($app, 'getLogsDB', 'logs', $collections['logs'], $pools);
+ createDatabase($container, 'getLogsDB', 'logs', $collections['logs'], $pools);
// create appwrite database, `dbForPlatform` is a direct access call.
- createDatabase($app, 'dbForPlatform', 'appwrite', $collections['console'], $pools, function (Database $dbForPlatform) use ($collections, $app) {
- $authorization = $app->getResource('authorization');
+ createDatabase($container, 'dbForPlatform', 'appwrite', $collections['console'], $pools, function (Database $dbForPlatform) use ($collections, $container) {
+ $authorization = $container->get('authorization');
if ($dbForPlatform->getCollection(AuditAdapterSQL::COLLECTION)->isEmpty()) {
$adapter = new AdapterDatabase($dbForPlatform);
@@ -415,27 +410,19 @@ $http->on(Constant::EVENT_START, function ($http) use ($payloadSize, $totalWorke
$projectCollections = $collections['projects'];
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
- $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', ''));
- $sharedTablesV2 = \array_diff($sharedTables, $sharedTablesV1);
-
$documentsSharedTables = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', ''));
- $documentsSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', ''));
- $documentsSharedTablesV2 = \array_diff($documentsSharedTables, $documentsSharedTablesV1);
-
$vectorSharedTables = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', ''));
- $vectorSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', ''));
- $vectorSharedTablesV2 = \array_diff($vectorSharedTables, $vectorSharedTablesV1);
- $cache = $app->getResource('cache');
+ $cache = $container->get('cache');
- // All shared tables V2 pools that need project metadata collections
- $sharedTablesV2All = \array_values(\array_unique(\array_filter([
- ...$sharedTablesV2,
- ...$documentsSharedTablesV2,
- ...$vectorSharedTablesV2,
+ // All shared tables pools that need project metadata collections
+ $allSharedTables = \array_values(\array_unique(\array_filter([
+ ...$sharedTables,
+ ...$documentsSharedTables,
+ ...$vectorSharedTables,
])));
- foreach ($sharedTablesV2All as $hostname) {
+ foreach ($allSharedTables as $hostname) {
Span::init('database.setup');
Span::add('database.hostname', $hostname);
@@ -512,7 +499,7 @@ $http->on(Constant::EVENT_START, function ($http) use ($payloadSize, $totalWorke
});
});
-$swooleAdapter->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files, $swooleAdapter, $registerRequestResources) {
+$swoole->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files, $swoole, $setRequestContext) {
Span::init('http.request');
$request = new Request($utopiaRequest->getSwooleRequest());
@@ -532,20 +519,18 @@ $swooleAdapter->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files
return;
}
- $requestContainer = $swooleAdapter->getContainer();
- $requestContainer->set('request', fn () => $request);
- $requestContainer->set('response', fn () => $response);
+ $app = new Http($swoole, 'UTC');
+ $app->context()->set('request', fn () => $request);
+ $app->context()->set('response', fn () => $response);
+ $app->context()->set('utopia', fn () => $app);
- $app = new Http($swooleAdapter, 'UTC');
- $requestContainer->set('utopia', fn () => $app);
-
- $registerRequestResources($requestContainer);
+ $setRequestContext($app->context());
$app->setCompression(System::getEnv('_APP_COMPRESSION_ENABLED', 'enabled') === 'enabled');
$app->setCompressionMinSize(intval(System::getEnv('_APP_COMPRESSION_MIN_SIZE_BYTES', '1024'))); // 1KB
try {
- $authorization = $app->getResource('authorization');
+ $authorization = $app->context()->get('authorization');
$request->setAuthorization($authorization);
$response->setAuthorization($authorization);
@@ -561,18 +546,18 @@ $swooleAdapter->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
- $logger = $app->getResource("logger");
+ $logger = $app->context()->get("logger");
if ($logger) {
try {
/** @var Utopia\Database\Document $user */
- $user = $app->getResource('user');
+ $user = $app->context()->get('user');
} catch (\Throwable $_th) {
// All good, user is optional information for logger
}
$route = $app->getRoute();
- $log = $app->getResource("log");
+ $log = $app->context()->get("log");
if (isset($user) && !$user->isEmpty()) {
$log->setUser(new User($user->getId()));
@@ -651,18 +636,16 @@ $swooleAdapter->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files
});
// Fetch domains every `DOMAIN_SYNC_TIMER` seconds and update in the memory
-$http->on(Constant::EVENT_TASK, function () use ($swooleAdapter) {
+$http->on(Constant::EVENT_TASK, function () use ($container) {
$lastSyncUpdate = null;
- $app = new Http($swooleAdapter, 'UTC');
-
/** @var Utopia\Database\Database $dbForPlatform */
- $dbForPlatform = $app->getResource('dbForPlatform');
+ $dbForPlatform = $container->get('dbForPlatform');
/** @var \Swoole\Table $riskyDomains */
- $riskyDomains = $app->getResource('riskyDomains');
+ $riskyDomains = $container->get('riskyDomains');
- Timer::tick(DOMAIN_SYNC_TIMER * 1000, function () use ($dbForPlatform, $riskyDomains, &$lastSyncUpdate, $app) {
+ Timer::tick(DOMAIN_SYNC_TIMER * 1000, function () use ($dbForPlatform, $riskyDomains, &$lastSyncUpdate, $container) {
try {
$time = DateTime::now();
$limit = 1000;
@@ -679,7 +662,7 @@ $http->on(Constant::EVENT_TASK, function () use ($swooleAdapter) {
}
$results = [];
try {
- $authorization = $app->getResource('authorization');
+ $authorization = $container->get('authorization');
$results = $authorization->skip(fn () => $dbForPlatform->find('rules', $queries));
} catch (Throwable $th) {
Console::error('rules ' . $th->getMessage());
@@ -729,4 +712,4 @@ $http->on(Constant::EVENT_TASK, function () use ($swooleAdapter) {
});
});
-$swooleAdapter->start();
+$swoole->start();
diff --git a/app/init/constants.php b/app/init/constants.php
index f2127cd666..b271b56a14 100644
--- a/app/init/constants.php
+++ b/app/init/constants.php
@@ -1,6 +1,13 @@
value, // legacy databases.createIndex
+ InsightType::TABLES_DB_INDEX->value, // tablesDB.createIndex
+ InsightType::DOCUMENTS_DB_INDEX->value, // documentsDB.createIndex
+ InsightType::VECTORS_DB_INDEX->value, // vectorsDB.createIndex
+ InsightType::DATABASE_PERFORMANCE->value,
+ InsightType::SITE_PERFORMANCE->value,
+ InsightType::SITE_ACCESSIBILITY->value,
+ InsightType::SITE_SEO->value,
+ InsightType::FUNCTION_PERFORMANCE->value,
+];
+
+// Public API services (SDK namespaces) that an insight CTA's `service` can reference.
+// Analyzers must pick the one matching the engine the resource lives in.
+const ADVISOR_CTA_SERVICES = [
+ InsightCTAService::DATABASES->value, // legacy
+ InsightCTAService::TABLES_DB->value,
+ InsightCTAService::DOCUMENTS_DB->value,
+ InsightCTAService::VECTORS_DB->value,
+];
+
+// Public API method names that an insight CTA's `method` can reference for index suggestions.
+const ADVISOR_CTA_METHODS = [
+ InsightCTAMethod::CREATE_INDEX->value,
+];
+
+// Insight severities
+const ADVISOR_SEVERITIES = [
+ InsightSeverity::INFO->value,
+ InsightSeverity::WARNING->value,
+ InsightSeverity::CRITICAL->value,
+];
+
+// Insight statuses
+const ADVISOR_STATUSES = [
+ InsightStatus::ACTIVE->value,
+ InsightStatus::DISMISSED->value,
+];
+
+// Report types
+const ADVISOR_REPORT_TYPES = [
+ ReportType::LIGHTHOUSE->value,
+ ReportType::AUDIT->value,
+ ReportType::DATABASE_ANALYZER->value,
+];
// Resource types for Tokens
const TOKENS_RESOURCE_TYPE_FILES = 'files';
@@ -458,3 +515,7 @@ const CSV_ALLOWED_DATABASE_TYPES = [
DATABASE_TYPE_TABLESDB,
DATABASE_TYPE_VECTORSDB
];
+
+const VCS_DEPLOYMENT_SKIP_PATTERNS = [
+ '[skip ci]',
+];
diff --git a/app/init/database/filters.php b/app/init/database/filters.php
index 5a65479424..e171805c47 100644
--- a/app/init/database/filters.php
+++ b/app/init/database/filters.php
@@ -475,3 +475,17 @@ Database::addFilter(
]));
}
);
+
+Database::addFilter(
+ 'subQueryReportInsights',
+ function (mixed $value) {
+ return;
+ },
+ function (mixed $value, Document $document, Database $database) {
+ return $database->getAuthorization()->skip(fn () => $database->find('insights', [
+ Query::equal('projectInternalId', [$document->getAttribute('projectInternalId')]),
+ Query::equal('reportInternalId', [$document->getSequence()]),
+ Query::limit(APP_LIMIT_SUBQUERY),
+ ]));
+ }
+);
diff --git a/app/init/database/formats.php b/app/init/database/formats.php
index 29a4f0c7d4..9ecf07716a 100644
--- a/app/init/database/formats.php
+++ b/app/init/database/formats.php
@@ -36,6 +36,13 @@ Structure::addFormat(APP_DATABASE_ATTRIBUTE_INT_RANGE, function ($attribute) {
return new Range($min, $max, Range::TYPE_INTEGER);
}, Database::VAR_INTEGER);
+// BigInt uses a dedicated bigintRange format name to avoid clobbering `intRange`.
+Structure::addFormat(APP_DATABASE_ATTRIBUTE_BIGINT_RANGE, function ($attribute) {
+ $min = $attribute['formatOptions']['min'] ?? -INF;
+ $max = $attribute['formatOptions']['max'] ?? INF;
+ return new Range($min, $max, Range::TYPE_INTEGER);
+}, Database::VAR_BIGINT);
+
Structure::addFormat(APP_DATABASE_ATTRIBUTE_FLOAT_RANGE, function ($attribute) {
$min = $attribute['formatOptions']['min'] ?? -INF;
$max = $attribute['formatOptions']['max'] ?? INF;
diff --git a/app/init/models.php b/app/init/models.php
index dd97b03652..08ebc3af23 100644
--- a/app/init/models.php
+++ b/app/init/models.php
@@ -11,6 +11,7 @@ use Appwrite\Utopia\Response\Model\AlgoScryptModified;
use Appwrite\Utopia\Response\Model\AlgoSha;
use Appwrite\Utopia\Response\Model\Any;
use Appwrite\Utopia\Response\Model\Attribute;
+use Appwrite\Utopia\Response\Model\AttributeBigInt;
use Appwrite\Utopia\Response\Model\AttributeBoolean;
use Appwrite\Utopia\Response\Model\AttributeDatetime;
use Appwrite\Utopia\Response\Model\AttributeEmail;
@@ -37,6 +38,7 @@ use Appwrite\Utopia\Response\Model\Branch;
use Appwrite\Utopia\Response\Model\Bucket;
use Appwrite\Utopia\Response\Model\Collection;
use Appwrite\Utopia\Response\Model\Column;
+use Appwrite\Utopia\Response\Model\ColumnBigInt;
use Appwrite\Utopia\Response\Model\ColumnBoolean;
use Appwrite\Utopia\Response\Model\ColumnDatetime;
use Appwrite\Utopia\Response\Model\ColumnEmail;
@@ -56,6 +58,11 @@ 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;
use Appwrite\Utopia\Response\Model\ConsoleVariables;
use Appwrite\Utopia\Response\Model\Continent;
use Appwrite\Utopia\Response\Model\Country;
@@ -68,6 +75,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;
@@ -84,6 +92,8 @@ use Appwrite\Utopia\Response\Model\HealthTime;
use Appwrite\Utopia\Response\Model\HealthVersion;
use Appwrite\Utopia\Response\Model\Identity;
use Appwrite\Utopia\Response\Model\Index;
+use Appwrite\Utopia\Response\Model\Insight;
+use Appwrite\Utopia\Response\Model\InsightCTA;
use Appwrite\Utopia\Response\Model\Installation;
use Appwrite\Utopia\Response\Model\JWT;
use Appwrite\Utopia\Response\Model\Key;
@@ -105,6 +115,47 @@ use Appwrite\Utopia\Response\Model\MigrationReport;
use Appwrite\Utopia\Response\Model\Mock;
use Appwrite\Utopia\Response\Model\MockNumber;
use Appwrite\Utopia\Response\Model\None;
+use Appwrite\Utopia\Response\Model\OAuth2Amazon;
+use Appwrite\Utopia\Response\Model\OAuth2Apple;
+use Appwrite\Utopia\Response\Model\OAuth2Auth0;
+use Appwrite\Utopia\Response\Model\OAuth2Authentik;
+use Appwrite\Utopia\Response\Model\OAuth2Autodesk;
+use Appwrite\Utopia\Response\Model\OAuth2Bitbucket;
+use Appwrite\Utopia\Response\Model\OAuth2Bitly;
+use Appwrite\Utopia\Response\Model\OAuth2Box;
+use Appwrite\Utopia\Response\Model\OAuth2Dailymotion;
+use Appwrite\Utopia\Response\Model\OAuth2Discord;
+use Appwrite\Utopia\Response\Model\OAuth2Disqus;
+use Appwrite\Utopia\Response\Model\OAuth2Dropbox;
+use Appwrite\Utopia\Response\Model\OAuth2Etsy;
+use Appwrite\Utopia\Response\Model\OAuth2Facebook;
+use Appwrite\Utopia\Response\Model\OAuth2Figma;
+use Appwrite\Utopia\Response\Model\OAuth2FusionAuth;
+use Appwrite\Utopia\Response\Model\OAuth2GitHub;
+use Appwrite\Utopia\Response\Model\OAuth2Gitlab;
+use Appwrite\Utopia\Response\Model\OAuth2Google;
+use Appwrite\Utopia\Response\Model\OAuth2Keycloak;
+use Appwrite\Utopia\Response\Model\OAuth2Kick;
+use Appwrite\Utopia\Response\Model\OAuth2Linkedin;
+use Appwrite\Utopia\Response\Model\OAuth2Microsoft;
+use Appwrite\Utopia\Response\Model\OAuth2Notion;
+use Appwrite\Utopia\Response\Model\OAuth2Oidc;
+use Appwrite\Utopia\Response\Model\OAuth2Okta;
+use Appwrite\Utopia\Response\Model\OAuth2Paypal;
+use Appwrite\Utopia\Response\Model\OAuth2Podio;
+use Appwrite\Utopia\Response\Model\OAuth2ProviderList;
+use Appwrite\Utopia\Response\Model\OAuth2Salesforce;
+use Appwrite\Utopia\Response\Model\OAuth2Slack;
+use Appwrite\Utopia\Response\Model\OAuth2Spotify;
+use Appwrite\Utopia\Response\Model\OAuth2Stripe;
+use Appwrite\Utopia\Response\Model\OAuth2Tradeshift;
+use Appwrite\Utopia\Response\Model\OAuth2Twitch;
+use Appwrite\Utopia\Response\Model\OAuth2WordPress;
+use Appwrite\Utopia\Response\Model\OAuth2X;
+use Appwrite\Utopia\Response\Model\OAuth2Yahoo;
+use Appwrite\Utopia\Response\Model\OAuth2Yandex;
+use Appwrite\Utopia\Response\Model\OAuth2Zoho;
+use Appwrite\Utopia\Response\Model\OAuth2Zoom;
use Appwrite\Utopia\Response\Model\Phone;
use Appwrite\Utopia\Response\Model\PlatformAndroid;
use Appwrite\Utopia\Response\Model\PlatformApple;
@@ -112,12 +163,29 @@ use Appwrite\Utopia\Response\Model\PlatformLinux;
use Appwrite\Utopia\Response\Model\PlatformList;
use Appwrite\Utopia\Response\Model\PlatformWeb;
use Appwrite\Utopia\Response\Model\PlatformWindows;
+use Appwrite\Utopia\Response\Model\PolicyList;
+use Appwrite\Utopia\Response\Model\PolicyMembershipPrivacy;
+use Appwrite\Utopia\Response\Model\PolicyPasswordDictionary;
+use Appwrite\Utopia\Response\Model\PolicyPasswordHistory;
+use Appwrite\Utopia\Response\Model\PolicyPasswordPersonalData;
+use Appwrite\Utopia\Response\Model\PolicySessionAlert;
+use Appwrite\Utopia\Response\Model\PolicySessionDuration;
+use Appwrite\Utopia\Response\Model\PolicySessionInvalidation;
+use Appwrite\Utopia\Response\Model\PolicySessionLimit;
+use Appwrite\Utopia\Response\Model\PolicyUserLimit;
use Appwrite\Utopia\Response\Model\Preferences;
+use Appwrite\Utopia\Response\Model\Presence;
use Appwrite\Utopia\Response\Model\Project;
+use Appwrite\Utopia\Response\Model\ProjectAuthMethod;
+use Appwrite\Utopia\Response\Model\ProjectProtocol;
+use Appwrite\Utopia\Response\Model\ProjectService;
use Appwrite\Utopia\Response\Model\Provider;
use Appwrite\Utopia\Response\Model\ProviderRepository;
use Appwrite\Utopia\Response\Model\ProviderRepositoryFramework;
+use Appwrite\Utopia\Response\Model\ProviderRepositoryFrameworkList;
use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntime;
+use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntimeList;
+use Appwrite\Utopia\Response\Model\Report;
use Appwrite\Utopia\Response\Model\ResourceToken;
use Appwrite\Utopia\Response\Model\Row;
use Appwrite\Utopia\Response\Model\Rule;
@@ -135,7 +203,6 @@ use Appwrite\Utopia\Response\Model\TemplateFramework;
use Appwrite\Utopia\Response\Model\TemplateFunction;
use Appwrite\Utopia\Response\Model\TemplateRuntime;
use Appwrite\Utopia\Response\Model\TemplateSite;
-use Appwrite\Utopia\Response\Model\TemplateSMS;
use Appwrite\Utopia\Response\Model\TemplateVariable;
use Appwrite\Utopia\Response\Model\Token;
use Appwrite\Utopia\Response\Model\Topic;
@@ -148,6 +215,7 @@ use Appwrite\Utopia\Response\Model\UsageDocumentsDB;
use Appwrite\Utopia\Response\Model\UsageDocumentsDBs;
use Appwrite\Utopia\Response\Model\UsageFunction;
use Appwrite\Utopia\Response\Model\UsageFunctions;
+use Appwrite\Utopia\Response\Model\UsagePresence;
use Appwrite\Utopia\Response\Model\UsageProject;
use Appwrite\Utopia\Response\Model\UsageSite;
use Appwrite\Utopia\Response\Model\UsageSites;
@@ -171,6 +239,7 @@ Response::setModel(new ErrorDev());
// Lists
Response::setModel(new BaseList('Rows List', Response::MODEL_ROW_LIST, 'rows', Response::MODEL_ROW));
Response::setModel(new BaseList('Documents List', Response::MODEL_DOCUMENT_LIST, 'documents', Response::MODEL_DOCUMENT));
+Response::setModel(new BaseList('Presences List', Response::MODEL_PRESENCE_LIST, 'presences', Response::MODEL_PRESENCE));
Response::setModel(new BaseList('Tables List', Response::MODEL_TABLE_LIST, 'tables', Response::MODEL_TABLE));
Response::setModel(new BaseList('Collections List', Response::MODEL_COLLECTION_LIST, 'collections', Response::MODEL_COLLECTION));
Response::setModel(new BaseList('Databases List', Response::MODEL_DATABASE_LIST, 'databases', Response::MODEL_DATABASE));
@@ -190,14 +259,14 @@ Response::setModel(new BaseList('Site Templates List', Response::MODEL_TEMPLATE_
Response::setModel(new BaseList('Functions List', Response::MODEL_FUNCTION_LIST, 'functions', Response::MODEL_FUNCTION));
Response::setModel(new BaseList('Function Templates List', Response::MODEL_TEMPLATE_FUNCTION_LIST, 'templates', Response::MODEL_TEMPLATE_FUNCTION));
Response::setModel(new BaseList('Installations List', Response::MODEL_INSTALLATION_LIST, 'installations', Response::MODEL_INSTALLATION));
-Response::setModel(new BaseList('Framework Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, 'frameworkProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK));
-Response::setModel(new BaseList('Runtime Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, 'runtimeProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME));
+Response::setModel(new ProviderRepositoryFrameworkList());
+Response::setModel(new ProviderRepositoryRuntimeList());
Response::setModel(new BaseList('Branches List', Response::MODEL_BRANCH_LIST, 'branches', Response::MODEL_BRANCH));
Response::setModel(new BaseList('Frameworks List', Response::MODEL_FRAMEWORK_LIST, 'frameworks', Response::MODEL_FRAMEWORK));
Response::setModel(new BaseList('Runtimes List', Response::MODEL_RUNTIME_LIST, 'runtimes', Response::MODEL_RUNTIME));
Response::setModel(new BaseList('Deployments List', Response::MODEL_DEPLOYMENT_LIST, 'deployments', Response::MODEL_DEPLOYMENT));
Response::setModel(new BaseList('Executions List', Response::MODEL_EXECUTION_LIST, 'executions', Response::MODEL_EXECUTION));
-Response::setModel(new BaseList('Projects List', Response::MODEL_PROJECT_LIST, 'projects', Response::MODEL_PROJECT, true, false));
+Response::setModel(new BaseList('Projects List', Response::MODEL_PROJECT_LIST, 'projects', Response::MODEL_PROJECT, true, true));
Response::setModel(new BaseList('Webhooks List', Response::MODEL_WEBHOOK_LIST, 'webhooks', Response::MODEL_WEBHOOK, true, true));
Response::setModel(new BaseList('API Keys List', Response::MODEL_KEY_LIST, 'keys', Response::MODEL_KEY, true, true));
Response::setModel(new BaseList('Dev Keys List', Response::MODEL_DEV_KEY_LIST, 'devKeys', Response::MODEL_DEV_KEY, true, false));
@@ -209,6 +278,9 @@ Response::setModel(new BaseList('Currencies List', Response::MODEL_CURRENCY_LIST
Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phones', Response::MODEL_PHONE));
Response::setModel(new BaseList('Metric List', Response::MODEL_METRIC_LIST, 'metrics', Response::MODEL_METRIC, true, false));
Response::setModel(new BaseList('Variables List', Response::MODEL_VARIABLE_LIST, 'variables', Response::MODEL_VARIABLE));
+Response::setModel(new BaseList('Mock Numbers List', Response::MODEL_MOCK_NUMBER_LIST, 'mockNumbers', Response::MODEL_MOCK_NUMBER));
+Response::setModel(new PolicyList());
+Response::setModel(new BaseList('Email Templates List', Response::MODEL_EMAIL_TEMPLATE_LIST, 'templates', Response::MODEL_EMAIL_TEMPLATE));
Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS));
Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE));
Response::setModel(new BaseList('Schedules List', Response::MODEL_SCHEDULE_LIST, 'schedules', Response::MODEL_SCHEDULE));
@@ -225,6 +297,8 @@ Response::setModel(new BaseList('Specifications List', Response::MODEL_SPECIFICA
Response::setModel(new BaseList('VCS Content List', Response::MODEL_VCS_CONTENT_LIST, 'contents', Response::MODEL_VCS_CONTENT));
Response::setModel(new BaseList('VectorsDB Collections List', Response::MODEL_VECTORSDB_COLLECTION_LIST, 'collections', Response::MODEL_VECTORSDB_COLLECTION));
Response::setModel(new BaseList('Embedding list', Response::MODEL_EMBEDDING_LIST, 'embeddings', Response::MODEL_EMBEDDING));
+Response::setModel(new BaseList('Insights List', Response::MODEL_INSIGHT_LIST, 'insights', Response::MODEL_INSIGHT));
+Response::setModel(new BaseList('Reports List', Response::MODEL_REPORT_LIST, 'reports', Response::MODEL_REPORT));
// Entities
Response::setModel(new Database());
@@ -236,6 +310,7 @@ Response::setModel(new Attribute());
Response::setModel(new AttributeList());
Response::setModel(new AttributeString());
Response::setModel(new AttributeInteger());
+Response::setModel(new AttributeBigInt());
Response::setModel(new AttributeFloat());
Response::setModel(new AttributeBoolean());
Response::setModel(new AttributeEmail());
@@ -269,6 +344,7 @@ Response::setModel(new Column());
Response::setModel(new ColumnList());
Response::setModel(new ColumnString());
Response::setModel(new ColumnInteger());
+Response::setModel(new ColumnBigInt());
Response::setModel(new ColumnFloat());
Response::setModel(new ColumnBoolean());
Response::setModel(new ColumnEmail());
@@ -288,6 +364,7 @@ Response::setModel(new Index());
Response::setModel(new ColumnIndex());
Response::setModel(new Row());
Response::setModel(new ModelDocument());
+Response::setModel(new Presence());
Response::setModel(new Log());
Response::setModel(new User());
Response::setModel(new AlgoMd5());
@@ -332,10 +409,64 @@ Response::setModel(new FrameworkAdapter());
Response::setModel(new Deployment());
Response::setModel(new Execution());
Response::setModel(new Project());
+Response::setModel(new ProjectAuthMethod());
+Response::setModel(new ProjectService());
+Response::setModel(new ProjectProtocol());
Response::setModel(new Webhook());
Response::setModel(new Key());
+Response::setModel(new EphemeralKey());
Response::setModel(new DevKey());
Response::setModel(new MockNumber());
+Response::setModel(new OAuth2GitHub());
+Response::setModel(new OAuth2Discord());
+Response::setModel(new OAuth2Figma());
+Response::setModel(new OAuth2Dropbox());
+Response::setModel(new OAuth2Dailymotion());
+Response::setModel(new OAuth2Bitbucket());
+Response::setModel(new OAuth2Bitly());
+Response::setModel(new OAuth2Box());
+Response::setModel(new OAuth2Autodesk());
+Response::setModel(new OAuth2Google());
+Response::setModel(new OAuth2Zoom());
+Response::setModel(new OAuth2Zoho());
+Response::setModel(new OAuth2Yandex());
+Response::setModel(new OAuth2X());
+Response::setModel(new OAuth2WordPress());
+Response::setModel(new OAuth2Twitch());
+Response::setModel(new OAuth2Stripe());
+Response::setModel(new OAuth2Spotify());
+Response::setModel(new OAuth2Slack());
+Response::setModel(new OAuth2Podio());
+Response::setModel(new OAuth2Notion());
+Response::setModel(new OAuth2Salesforce());
+Response::setModel(new OAuth2Yahoo());
+Response::setModel(new OAuth2Linkedin());
+Response::setModel(new OAuth2Disqus());
+Response::setModel(new OAuth2Amazon());
+Response::setModel(new OAuth2Etsy());
+Response::setModel(new OAuth2Facebook());
+Response::setModel(new OAuth2Tradeshift());
+Response::setModel(new OAuth2Paypal());
+Response::setModel(new OAuth2Gitlab());
+Response::setModel(new OAuth2Authentik());
+Response::setModel(new OAuth2Auth0());
+Response::setModel(new OAuth2FusionAuth());
+Response::setModel(new OAuth2Keycloak());
+Response::setModel(new OAuth2Oidc());
+Response::setModel(new OAuth2Okta());
+Response::setModel(new OAuth2Kick());
+Response::setModel(new OAuth2Apple());
+Response::setModel(new OAuth2Microsoft());
+Response::setModel(new OAuth2ProviderList());
+Response::setModel(new PolicyPasswordDictionary());
+Response::setModel(new PolicyPasswordHistory());
+Response::setModel(new PolicyPasswordPersonalData());
+Response::setModel(new PolicySessionAlert());
+Response::setModel(new PolicySessionDuration());
+Response::setModel(new PolicySessionInvalidation());
+Response::setModel(new PolicySessionLimit());
+Response::setModel(new PolicyUserLimit());
+Response::setModel(new PolicyMembershipPrivacy());
Response::setModel(new AuthProvider());
Response::setModel(new PlatformWeb());
Response::setModel(new PlatformApple());
@@ -362,6 +493,7 @@ Response::setModel(new UsageDatabase());
Response::setModel(new UsageTable());
Response::setModel(new UsageCollection());
Response::setModel(new UsageUsers());
+Response::setModel(new UsagePresence());
Response::setModel(new UsageStorage());
Response::setModel(new UsageBuckets());
Response::setModel(new UsageFunctions());
@@ -373,9 +505,13 @@ Response::setModel(new Headers());
Response::setModel(new Specification());
Response::setModel(new Rule());
Response::setModel(new Schedule());
-Response::setModel(new TemplateSMS());
Response::setModel(new TemplateEmail());
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());
@@ -389,6 +525,9 @@ Response::setModel(new Target());
Response::setModel(new Migration());
Response::setModel(new MigrationReport());
Response::setModel(new MigrationFirebaseProject());
+Response::setModel(new Insight());
+Response::setModel(new InsightCTA());
+Response::setModel(new Report());
// Tests (keep last)
Response::setModel(new Mock());
diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php
index 0c1dbad923..a090635bb5 100644
--- a/app/init/realtime/connection.php
+++ b/app/init/realtime/connection.php
@@ -85,7 +85,7 @@ return function (Container $container): void {
return $dbForPlatform->findOne('rules', [
Query::equal('domain', [$domain]),
- ]) ?? new Document();
+ ]);
});
$permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence();
@@ -139,7 +139,7 @@ return function (Container $container): void {
$sdkValidator = new WhiteList($servers, true);
$sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN'));
- if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) {
+ if ($sdk !== 'unknown' && $sdkValidator->isValid($sdk)) {
$sdks = $key->getAttribute('sdks', []);
if (!\in_array($sdk, $sdks, true)) {
@@ -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;
diff --git a/app/init/registers.php b/app/init/registers.php
index c07bc9da8b..9ea19eee24 100644
--- a/app/init/registers.php
+++ b/app/init/registers.php
@@ -71,7 +71,7 @@ $register->set('logger', function () {
$providerConfig = match ($providerName) {
'sentry' => [ 'key' => $configChunks[0], 'projectId' => $configChunks[1] ?? '', 'host' => '',],
- 'logowl' => ['ticket' => $configChunks[0] ?? '', 'host' => ''],
+ 'logowl' => ['ticket' => $configChunks[0], 'host' => ''],
default => ['key' => $providerConfig],
};
}
@@ -240,6 +240,12 @@ $register->set('pools', function () {
'multiple' => true,
'schemes' => ['redis'],
],
+ 'lock' => [
+ 'type' => 'lock',
+ 'dsns' => $fallbackForRedis,
+ 'multiple' => false,
+ 'schemes' => ['redis'],
+ ],
];
$maxConnections = (int) System::getEnv('_APP_CONNECTIONS_MAX', 151);
@@ -249,11 +255,11 @@ $register->set('pools', function () {
$poolSize = max(1, (int)($instanceConnections / $workerCount));
foreach ($connections as $key => $connection) {
- $type = $connection['type'] ?? '';
- $multiple = $connection['multiple'] ?? false;
- $schemes = $connection['schemes'] ?? [];
+ $type = $connection['type'];
+ $multiple = $connection['multiple'];
+ $schemes = $connection['schemes'];
$config = [];
- $dsns = explode(',', $connection['dsns'] ?? '');
+ $dsns = explode(',', $connection['dsns']);
foreach ($dsns as &$dsn) {
$dsn = explode('=', $dsn);
$name = ($multiple) ? $key . '_' . $dsn[0] : $key;
@@ -318,7 +324,7 @@ $register->set('pools', function () {
));
});
},
- 'redis' => function () use ($dsnHost, $dsnPort, $dsnPass) {
+ default => function () use ($dsnHost, $dsnPort, $dsnPass) {
$redis = new \Redis();
@$redis->pconnect($dsnHost, (int)$dsnPort);
if ($dsnPass) {
@@ -328,12 +334,17 @@ $register->set('pools', function () {
return $redis;
},
- default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Invalid scheme'),
};
$poolAdapter = System::getEnv('_APP_POOL_ADAPTER', default: 'stack') === 'swoole' ? new SwoolePool() : new StackPool();
- $pool = new Pool($poolAdapter, $name, $poolSize, function () use ($type, $resource, $dsn) {
+ // PubSub workers hold one long-lived subscribed connection and also need
+ // spare capacity for publishes from the same process.
+ $connectionPoolSize = $type === 'pubsub'
+ ? max(2, $poolSize)
+ : $poolSize;
+
+ $pool = new Pool($poolAdapter, $name, $connectionPoolSize, function () use ($type, $resource, $dsn) {
// Get Adapter
switch ($type) {
case 'database':
@@ -370,6 +381,8 @@ $register->set('pools', function () {
}
return $adapter;
+ case 'lock':
+ return $resource();
default:
throw new Exception(Exception::GENERAL_SERVER_ERROR, "Server error: Missing adapter implementation.");
}
diff --git a/app/init/resources.php b/app/init/resources.php
index 32d6e0a45f..7b7e13482c 100644
--- a/app/init/resources.php
+++ b/app/init/resources.php
@@ -1,10 +1,20 @@
set('console', fn () => new Document(Config::getParam('console')), []);
+
+$container->set('executor', fn () => new Executor(), []);
+
+$container->set('telemetry', fn () => new NoTelemetry(), []);
+
+$container->set('publisher', fn (Group $pools) => new BrokerPool(publisher: $pools->get('publisher')), ['pools']);
+
+$container->set('publisherDatabases', fn (Publisher $publisher) => $publisher, ['publisher']);
+
+$container->set('publisherFunctions', fn (Publisher $publisher) => $publisher, ['publisher']);
+
+$container->set('publisherMigrations', fn (Publisher $publisher) => $publisher, ['publisher']);
+
+$container->set('publisherMails', fn (Publisher $publisher) => $publisher, ['publisher']);
+
+$container->set('publisherDeletes', fn (Publisher $publisher) => $publisher, ['publisher']);
+
+$container->set('publisherMessaging', fn (Publisher $publisher) => $publisher, ['publisher']);
+
+$container->set('publisherWebhooks', fn (Publisher $publisher) => $publisher, ['publisher']);
+
+$container->set('publisherForAudits', fn (Publisher $publisher) => new AuditPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME))
+), ['publisher']);
+
+$container->set('publisherForCertificates', fn (Publisher $publisher) => new CertificatePublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME))
+), ['publisher']);
+
+$container->set('publisherForScreenshots', fn (Publisher $publisher) => new ScreenshotPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME))
+), ['publisher']);
+
+$container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
+), ['publisher']);
+
+$container->set('publisherForExecutions', fn (Publisher $publisher) => new ExecutionPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_EXECUTIONS_QUEUE_NAME', Event::EXECUTIONS_QUEUE_NAME))
+), ['publisher']);
+
+$container->set('publisherForFunctions', fn (Publisher $publisher) => new FunctionPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME), 'utopia-queue', Event::FUNCTIONS_QUEUE_TTL)
+), ['publisher']);
+
+$container->set('publisherForMigrations', fn (Publisher $publisher) => new MigrationPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME))
+), ['publisher']);
+
+$container->set('publisherForStatsResources', fn (Publisher $publisher) => new StatsResourcesPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME))
+), ['publisher']);
+
+$container->set('publisherForBuilds', fn (Publisher $publisher) => new BuildPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME))
+), ['publisher']);
+
+$container->set('publisherForDatabase', fn (Publisher $publisherDatabases) => new DatabasePublisher(
+ $publisherDatabases,
+ new Queue(System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME))
+), ['publisherDatabases']);
+
+$container->set('publisherForDeletes', fn (Publisher $publisher) => new DeletePublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME))
+), ['publisher']);
+
+$container->set('publisherForMails', fn (Publisher $publisher) => new MailPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME))
+), ['publisher']);
+
+$container->set('publisherForMessaging', fn (Publisher $publisher) => new MessagingPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME))
+), ['publisher']);
+
$container->set('logger', function ($register) {
return $register->get('logger');
}, ['register']);
@@ -56,47 +154,6 @@ $container->set('localeCodes', function () {
return array_map(fn ($locale) => $locale['code'], Config::getParam('locale-codes', []));
});
-// Queues - shared infrastructure (stateless pool wrappers)
-$container->set('publisher', function (Group $pools) {
- return new BrokerPool(publisher: $pools->get('publisher'));
-}, ['pools']);
-$container->set('publisherDatabases', function (Publisher $publisher) {
- return $publisher;
-}, ['publisher']);
-$container->set('publisherFunctions', function (Publisher $publisher) {
- return $publisher;
-}, ['publisher']);
-$container->set('publisherMigrations', function (Publisher $publisher) {
- return $publisher;
-}, ['publisher']);
-$container->set('publisherMails', function (Publisher $publisher) {
- return $publisher;
-}, ['publisher']);
-$container->set('publisherDeletes', function (Publisher $publisher) {
- return $publisher;
-}, ['publisher']);
-$container->set('publisherMessaging', function (Publisher $publisher) {
- return $publisher;
-}, ['publisher']);
-$container->set('publisherWebhooks', function (Publisher $publisher) {
- return $publisher;
-}, ['publisher']);
-$container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
- $publisher,
- new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
-), ['publisher']);
-$container->set('publisherForExecutions', fn (Publisher $publisher) => new ExecutionPublisher(
- $publisher,
- new Queue(System::getEnv('_APP_EXECUTIONS_QUEUE_NAME', Event::EXECUTIONS_QUEUE_NAME))
-), ['publisher']);
-$container->set('publisherForMigrations', fn (Publisher $publisher) => new MigrationPublisher(
- $publisher,
- new Queue(System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME))
-), ['publisher']);
-$container->set('publisherForStatsResources', fn (Publisher $publisher) => new StatsResourcesPublisher(
- $publisher,
- new Queue(System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME))
-), ['publisher']);
/**
* Platform configuration
@@ -105,10 +162,6 @@ $container->set('platform', function () {
return Config::getParam('platform', []);
}, []);
-$container->set('console', function () {
- return new Document(Config::getParam('console'));
-}, []);
-
$container->set('authorization', function () {
return new Authorization();
}, []);
@@ -144,10 +197,16 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
$adapter = new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $logsCollections = $collections['logs'] ?? [];
+ $logsCollections = array_keys($logsCollections);
+
$database
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setSharedTables(true)
+ ->setGlobalCollections($logsCollections)
->setNamespace('logsV1')
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
@@ -161,8 +220,6 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
};
}, ['pools', 'cache', 'authorization']);
-$container->set('telemetry', fn () => new NoTelemetry());
-
$container->set('cache', function (Group $pools, Telemetry $telemetry) {
$list = Config::getParam('pools-cache', []);
$adapters = [];
@@ -177,6 +234,10 @@ $container->set('cache', function (Group $pools, Telemetry $telemetry) {
return $cache;
}, ['pools', 'telemetry']);
+$container->set('cacheControlForStorage', fn () => function (StorageCacheControl $config): string {
+ return \sprintf('private, max-age=%d', $config->maxAge);
+});
+
$container->set('redis', function () {
$host = System::getEnv('_APP_REDIS_HOST', 'localhost');
$port = System::getEnv('_APP_REDIS_PORT', 6379);
@@ -192,6 +253,16 @@ $container->set('redis', function () {
return $redis;
});
+$container->set('locks', function (Group $pools) {
+ return function (string $key, int $ttl, callable $callback, float $timeout = 0.0) use ($pools): mixed {
+ return $pools->get('lock')->use(function (\Redis $redis) use ($key, $ttl, $callback, $timeout) {
+ $lock = new Distributed($redis, $key, ttl: $ttl);
+
+ return $lock->withLock($callback, timeout: $timeout);
+ });
+ };
+}, ['pools']);
+
$container->set('timelimit', function (\Redis $redis) {
return function (string $key, int $limit, int $time) use ($redis) {
return new TimeLimitRedis($key, $limit, $time, $redis);
@@ -251,7 +322,7 @@ function getDevice(string $root, string $connection = ''): Device
return new Local($root);
}
} else {
- switch (strtolower(System::getEnv('_APP_STORAGE_DEVICE', Storage::DEVICE_LOCAL) ?? '')) {
+ switch (strtolower(System::getEnv('_APP_STORAGE_DEVICE', Storage::DEVICE_LOCAL))) {
case Storage::DEVICE_LOCAL:
default:
return new Local($root);
@@ -349,5 +420,3 @@ $container->set(
'isResourceBlocked',
fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false
);
-
-$container->set('executor', fn () => new Executor());
diff --git a/app/init/resources/request.php b/app/init/resources/request.php
index 63e58e92f7..85d8db3698 100644
--- a/app/init/resources/request.php
+++ b/app/init/resources/request.php
@@ -4,17 +4,11 @@ use Ahc\Jwt\JWT;
use Ahc\Jwt\JWTException;
use Appwrite\Auth\Key;
use Appwrite\Databases\TransactionState;
-use Appwrite\Event\Audit as AuditEvent;
-use Appwrite\Event\Build;
-use Appwrite\Event\Certificate;
-use Appwrite\Event\Database as EventDatabase;
-use Appwrite\Event\Delete;
+use Appwrite\Event\Context\Audit as AuditContext;
use Appwrite\Event\Event;
-use Appwrite\Event\Func;
-use Appwrite\Event\Mail;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Message\Func as FunctionMessage;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Event\Realtime;
-use Appwrite\Event\Screenshot;
use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception;
use Appwrite\Functions\EventProcessor;
@@ -53,6 +47,7 @@ use Utopia\Locale\Locale;
use Utopia\Logger\Log;
use Utopia\Pools\Group;
use Utopia\Queue\Publisher;
+use Utopia\Queue\Queue;
use Utopia\Storage\Device;
use Utopia\System\System;
use Utopia\Telemetry\Adapter as Telemetry;
@@ -64,26 +59,18 @@ use Utopia\Validator\WhiteList;
* These resources depend (directly or transitively) on request/response
* and must be fresh for each HTTP request.
*/
-return function (Container $container): void {
- $container->set('utopia:graphql', function ($utopia) {
- return $utopia;
- }, ['utopia']);
+return function (Container $context): void {
+ $context->set('utopia:graphql', fn ($utopia) => $utopia, ['utopia']);
- $container->set('log', fn () => new Log(), []);
+ $context->set('log', fn () => new Log(), []);
- $container->set('logger', function ($register) {
- return $register->get('logger');
- }, ['register']);
+ $context->set('logger', fn ($register) => $register->get('logger'), ['register']);
- $container->set('authorization', function () {
- return new Authorization();
- }, []);
+ $context->set('authorization', fn () => new Authorization(), []);
- $container->set('store', function (): Store {
- return new Store();
- }, []);
+ $context->set('store', fn (): Store => new Store(), []);
- $container->set('proofForPassword', function (): Password {
+ $context->set('proofForPassword', function (): Password {
$hash = new Argon2();
$hash
->setMemoryCost(7168)
@@ -97,21 +84,21 @@ return function (Container $container): void {
return $password;
});
- $container->set('proofForToken', function (): Token {
+ $context->set('proofForToken', function (): Token {
$token = new Token();
$token->setHash(new Sha());
return $token;
});
- $container->set('proofForCode', function (): Code {
+ $context->set('proofForCode', function (): Code {
$code = new Code();
$code->setHash(new Sha());
return $code;
});
- $container->set('locale', function () {
+ $context->set('locale', function () {
$locale = new Locale(System::getEnv('_APP_LOCALE', 'en'));
$locale->setFallback(System::getEnv('_APP_LOCALE', 'en'));
@@ -119,49 +106,17 @@ return function (Container $container): void {
});
// Per-request queue resources (stateful, accumulate event data during request)
- $container->set('queueForMessaging', function (Publisher $publisher) {
- return new Messaging($publisher);
- }, ['publisher']);
- $container->set('queueForMails', function (Publisher $publisher) {
- return new Mail($publisher);
- }, ['publisher']);
- $container->set('queueForBuilds', function (Publisher $publisher) {
- return new Build($publisher);
- }, ['publisher']);
- $container->set('queueForScreenshots', function (Publisher $publisher) {
- return new Screenshot($publisher);
- }, ['publisher']);
- $container->set('queueForDatabase', function (Publisher $publisher) {
- return new EventDatabase($publisher);
- }, ['publisher']);
- $container->set('queueForDeletes', function (Publisher $publisher) {
- return new Delete($publisher);
- }, ['publisher']);
- $container->set('queueForEvents', function (Publisher $publisher) {
- return new Event($publisher);
- }, ['publisher']);
- $container->set('queueForWebhooks', function (Publisher $publisher) {
- return new Webhook($publisher);
- }, ['publisher']);
- $container->set('queueForRealtime', function () {
- return new Realtime();
- }, []);
- $container->set('usage', function () {
- return new UsageContext();
- }, []);
- $container->set('queueForAudits', function (Publisher $publisher) {
- return new AuditEvent($publisher);
- }, ['publisher']);
- $container->set('queueForFunctions', function (Publisher $publisher) {
- return new Func($publisher);
- }, ['publisher']);
- $container->set('eventProcessor', function () {
- return new EventProcessor();
- }, []);
- $container->set('queueForCertificates', function (Publisher $publisher) {
- return new Certificate($publisher);
- }, ['publisher']);
- $container->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) {
+ $context->set('queueForEvents', fn (Publisher $publisher) => new Event($publisher), ['publisher']);
+ $context->set('queueForWebhooks', fn (Publisher $publisher) => new Webhook($publisher), ['publisher']);
+ $context->set('queueForRealtime', fn () => new Realtime(), []);
+ $context->set('usage', fn () => new UsageContext(), []);
+ $context->set('auditContext', fn () => new AuditContext(), []);
+ $context->set('publisherForFunctions', fn (Publisher $publisher) => new FunctionPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME), 'utopia-queue', Event::FUNCTIONS_QUEUE_TTL)
+ ), ['publisher']);
+ $context->set('eventProcessor', fn () => new EventProcessor(), []);
+ $context->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) {
$adapter = new DatabasePool($pools->get('console'));
$database = new Database($adapter, $cache);
@@ -179,7 +134,7 @@ return function (Container $container): void {
return $database;
}, ['pools', 'cache', 'authorization']);
- $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform, Cache $cache, Authorization $authorization) {
+ $context->set('getProjectDB', function (Group $pools, Database $dbForPlatform, Cache $cache, Authorization $authorization) {
$adapters = [];
return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$adapters) {
@@ -214,9 +169,16 @@ return function (Container $container): void {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
$database
->setSharedTables(true)
->setTenant($project->getSequence())
+ ->setGlobalCollections($projectsGlobalCollections)
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -229,10 +191,15 @@ return function (Container $container): void {
};
}, ['pools', 'dbForPlatform', 'cache', 'authorization']);
- $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
+ $context->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
$adapter = null;
return function (?Document $project = null) use ($pools, $cache, $authorization, &$adapter) {
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $logsCollections = $collections['logs'] ?? [];
+ $logsCollections = array_keys($logsCollections);
+
$adapter ??= new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
@@ -240,6 +207,7 @@ return function (Container $container): void {
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setSharedTables(true)
+ ->setGlobalCollections($logsCollections)
->setNamespace('logsV1')
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
@@ -255,7 +223,7 @@ return function (Container $container): void {
/**
* List of allowed request hostnames for the request.
*/
- $container->set('allowedHostnames', function (array $platform, Document $project, Document $rule, Document $devKey, Request $request) {
+ $context->set('allowedHostnames', function (array $platform, Document $project, Document $rule, Document $devKey, Request $request) {
$allowed = [...($platform['hostnames'] ?? [])];
/* Add platform configured hostnames */
@@ -299,7 +267,7 @@ return function (Container $container): void {
/**
* List of allowed request schemes for the request.
*/
- $container->set('allowedSchemes', function (array $platform, Document $project) {
+ $context->set('allowedSchemes', function (array $platform, Document $project) {
$allowed = [...($platform['schemas'] ?? [])];
if (! $project->isEmpty() && $project->getId() !== 'console') {
@@ -319,7 +287,7 @@ return function (Container $container): void {
/**
* Whether the request origin is verified against the request hostname.
*/
- $container->set('domainVerification', function (Request $request) {
+ $context->set('domainVerification', function (Request $request) {
$origin = \parse_url($request->getOrigin($request->getReferer('')), PHP_URL_HOST);
$selfDomain = new Domain($request->getHostname());
$endDomain = new Domain((string) $origin);
@@ -331,7 +299,7 @@ return function (Container $container): void {
/**
* Cookie domain for the current request.
*/
- $container->set('cookieDomain', function (Request $request, Document $project) {
+ $context->set('cookieDomain', function (Request $request, Document $project) {
$localHosts = ['localhost', 'localhost:' . $request->getPort()];
$migrationHost = System::getEnv('_APP_MIGRATION_HOST');
@@ -365,7 +333,7 @@ return function (Container $container): void {
/**
* Rule associated with a request origin.
*/
- $container->set('rule', function (Request $request, Database $dbForPlatform, Document $project, Authorization $authorization) {
+ $context->set('rule', function (Request $request, Database $dbForPlatform, Document $project, Authorization $authorization) {
$domain = \parse_url($request->getOrigin(), PHP_URL_HOST);
if (empty($domain)) {
@@ -385,7 +353,7 @@ return function (Container $container): void {
return $dbForPlatform->findOne('rules', [
Query::equal('domain', [$domain]),
- ]) ?? new Document();
+ ]);
});
$permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence();
@@ -415,7 +383,7 @@ return function (Container $container): void {
/**
* CORS service
*/
- $container->set('cors', function (array $allowedHostnames) {
+ $context->set('cors', function (array $allowedHostnames) {
$corsConfig = Config::getParam('cors');
return new Cors(
@@ -427,23 +395,23 @@ return function (Container $container): void {
);
}, ['allowedHostnames']);
- $container->set('originValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) {
- if (! $devKey->isEmpty()) {
- return new URL();
- }
+ $context->set(
+ 'originValidator',
+ fn (Document $devKey, array $allowedHostnames, array $allowedSchemes) => $devKey->isEmpty()
+ ? new Origin($allowedHostnames, $allowedSchemes)
+ : new URL(),
+ ['devKey', 'allowedHostnames', 'allowedSchemes']
+ );
- return new Origin($allowedHostnames, $allowedSchemes);
- }, ['devKey', 'allowedHostnames', 'allowedSchemes']);
+ $context->set(
+ 'redirectValidator',
+ fn (Document $devKey, array $allowedHostnames, array $allowedSchemes) => $devKey->isEmpty()
+ ? new Redirect($allowedHostnames, $allowedSchemes)
+ : new URL(),
+ ['devKey', 'allowedHostnames', 'allowedSchemes']
+ );
- $container->set('redirectValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) {
- if (! $devKey->isEmpty()) {
- return new URL();
- }
-
- return new Redirect($allowedHostnames, $allowedSchemes);
- }, ['devKey', 'allowedHostnames', 'allowedSchemes']);
-
- $container->set('user', function (string $mode, Document $project, Document $console, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Store $store, Token $proofForToken, $authorization) {
+ $context->set('user', function (string $mode, Document $project, Document $console, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Store $store, Token $proofForToken, $authorization) {
/**
* Handles user authentication and session validation.
*
@@ -488,14 +456,10 @@ return function (Container $container): void {
}
// Get fallback session from old clients (no SameSite support) or clients who block 3rd-party cookies
- if ($response) { // if in http context - add debug header
- $response->addHeader('X-Debug-Fallback', 'false');
- }
+ $response->addHeader('X-Debug-Fallback', 'false');
if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) {
- if ($response) {
- $response->addHeader('X-Debug-Fallback', 'true');
- }
+ $response->addHeader('X-Debug-Fallback', 'true');
$fallback = $request->getHeader('x-fallback-cookies', '');
$fallback = \json_decode($fallback, true);
$store->decode(((is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : ''));
@@ -585,10 +549,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;
@@ -616,7 +582,7 @@ return function (Container $container): void {
return $user;
}, ['mode', 'project', 'console', 'request', 'response', 'dbForProject', 'dbForPlatform', 'store', 'proofForToken', 'authorization']);
- $container->set('project', function ($dbForPlatform, $request, $console, $authorization, Http $utopia) {
+ $context->set('project', function ($dbForPlatform, $request, $console, $authorization, Http $utopia) {
/** @var Appwrite\Utopia\Request $request */
/** @var Utopia\Database\Database $dbForPlatform */
/** @var Utopia\Database\Document $console */
@@ -649,7 +615,7 @@ return function (Container $container): void {
return $project;
}, ['dbForPlatform', 'request', 'console', 'authorization', 'utopia']);
- $container->set('session', function (User $user, Store $store, Token $proofForToken) {
+ $context->set('session', function (User $user, Store $store, Token $proofForToken) {
if ($user->isEmpty()) {
return;
}
@@ -670,7 +636,7 @@ return function (Container $container): void {
return;
}, ['user', 'store', 'proofForToken']);
- $container->set('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, UsageContext $usage, Authorization $authorization, Request $request) {
+ $context->set('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, FunctionPublisher $publisherForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, UsageContext $usage, Authorization $authorization, Request $request) {
if ($project->isEmpty() || $project->getId() === 'console') {
return $dbForPlatform;
}
@@ -702,8 +668,15 @@ return function (Container $container): void {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
$database
->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -719,7 +692,7 @@ return function (Container $container): void {
* Accounts can be created in many ways beyond `createAccount`
* (anonymous, OAuth, phone, etc.), and those flows are probably not covered in event tests; so we handle this here.
*/
- $eventDatabaseListener = function (Document $project, Document $document, Response $response, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime) {
+ $eventDatabaseListener = function (Document $project, Document $document, Response $response, Event $queueForEvents, FunctionPublisher $publisherForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime) {
// Only trigger events for user creation with the database listener.
if ($document->getCollection() !== 'users') {
return;
@@ -731,9 +704,15 @@ return function (Container $container): void {
->setPayload($response->output($document, Response::MODEL_USER));
// Trigger functions, webhooks, and realtime events
- $queueForFunctions
- ->from($queueForEvents)
- ->trigger();
+ $publisherForFunctions->enqueue(FunctionMessage::fromEvent(
+ event: $queueForEvents->getEvent(),
+ params: $queueForEvents->getParams(),
+ project: $queueForEvents->getProject(),
+ user: $queueForEvents->getUser(),
+ userId: $queueForEvents->getUserId(),
+ payload: $queueForEvents->getPayload(),
+ platform: $queueForEvents->getPlatform(),
+ ));
/** Trigger webhooks events only if a project has them enabled */
if (! empty($project->getAttribute('webhooks'))) {
@@ -913,7 +892,6 @@ return function (Container $container): void {
// Clone the queues, to prevent events triggered by the database listener
// from overwriting the events that are supposed to be triggered in the shutdown hook.
$queueForEventsClone = new Event($publisher);
- $queueForFunctions = new Func($publisherFunctions);
$queueForWebhooks = new Webhook($publisherWebhooks);
$queueForRealtime = new Realtime();
@@ -928,7 +906,7 @@ return function (Container $container): void {
$document,
$response,
$queueForEventsClone->from($queueForEvents),
- $queueForFunctions->from($queueForEvents),
+ $publisherForFunctions,
$queueForWebhooks->from($queueForEvents),
$queueForRealtime->from($queueForEvents)
))
@@ -937,9 +915,9 @@ return function (Container $container): void {
->on(Database::EVENT_DOCUMENT_DELETE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database));
return $database;
- }, ['pools', 'dbForPlatform', 'cache', 'project', 'response', 'publisher', 'publisherFunctions', 'publisherWebhooks', 'queueForEvents', 'queueForFunctions', 'queueForWebhooks', 'queueForRealtime', 'usage', 'authorization', 'request']);
+ }, ['pools', 'dbForPlatform', 'cache', 'project', 'response', 'publisher', 'publisherFunctions', 'publisherWebhooks', 'queueForEvents', 'publisherForFunctions', 'queueForWebhooks', 'queueForRealtime', 'usage', 'authorization', 'request']);
- $container->set('schema', function ($utopia, $dbForProject, $authorization) {
+ $context->set('schema', function ($utopia, $dbForProject, $authorization) {
$complexity = function (int $complexity, array $args) {
$queries = Query::parseQueries($args['queries'] ?? []);
@@ -1026,13 +1004,9 @@ return function (Container $container): void {
);
}, ['utopia', 'dbForProject', 'authorization']);
- $container->set('audit', function ($dbForProject) {
- $adapter = new AdapterDatabase($dbForProject);
+ $context->set('audit', fn ($dbForProject) => new Audit(new AdapterDatabase($dbForProject)), ['dbForProject']);
- return new Audit($adapter);
- }, ['dbForProject']);
-
- $container->set('mode', function ($request, Document $project) {
+ $context->set('mode', function ($request, Document $project) {
/** @var Appwrite\Utopia\Request $request */
/**
@@ -1050,7 +1024,7 @@ return function (Container $container): void {
return $mode;
}, ['request', 'project']);
- $container->set('requestTimestamp', function ($request) {
+ $context->set('requestTimestamp', function ($request) {
// TODO: Move this to the Request class itself
$timestampHeader = $request->getHeader('x-appwrite-timestamp');
$requestTimestamp = null;
@@ -1065,7 +1039,7 @@ return function (Container $container): void {
return $requestTimestamp;
}, ['request']);
- $container->set('devKey', function (Request $request, Document $project, array $servers, Database $dbForPlatform, Authorization $authorization) {
+ $context->set('devKey', function (Request $request, Document $project, array $servers, Database $dbForPlatform, Authorization $authorization) {
$devKey = $request->getHeader('x-appwrite-dev-key', $request->getParam('devKey', ''));
// Check if given key match project's development keys
@@ -1094,7 +1068,7 @@ return function (Container $container): void {
$sdkValidator = new WhiteList($servers, true);
$sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN'));
- if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) {
+ if ($sdk !== 'unknown' && $sdkValidator->isValid($sdk)) {
$sdks = $key->getAttribute('sdks', []);
if (! in_array($sdk, $sdks)) {
@@ -1114,7 +1088,7 @@ return function (Container $container): void {
return $key;
}, ['request', 'project', 'servers', 'dbForPlatform', 'authorization']);
- $container->set('team', function (Document $project, Database $dbForPlatform, Http $utopia, Request $request, Authorization $authorization) {
+ $context->set('team', function (Document $project, Database $dbForPlatform, Http $utopia, Request $request, Authorization $authorization) {
$teamInternalId = '';
if ($project->getId() !== 'console') {
$teamInternalId = $project->getAttribute('teamInternalId', '');
@@ -1157,7 +1131,7 @@ return function (Container $container): void {
return $team;
}, ['project', 'dbForPlatform', 'utopia', 'request', 'authorization']);
- $container->set('previewHostname', function (Request $request, ?Key $apiKey) {
+ $context->set('previewHostname', function (Request $request, ?Key $apiKey) {
$allowed = false;
if (Http::isDevelopment()) {
@@ -1176,7 +1150,7 @@ return function (Container $container): void {
return '';
}, ['request', 'apiKey']);
- $container->set('apiKey', function (Request $request, Document $project, Document $team, Document $user): ?Key {
+ $context->set('apiKey', function (Request $request, Document $project, Document $team, Document $user): ?Key {
$key = $request->getHeader('x-appwrite-key');
if (empty($key)) {
@@ -1210,7 +1184,7 @@ return function (Container $container): void {
return $key;
}, ['request', 'project', 'team', 'user']);
- $container->set('resourceToken', function ($project, $dbForProject, $request, Authorization $authorization) {
+ $context->set('resourceToken', function ($project, $dbForProject, $request, Authorization $authorization) {
$tokenJWT = $request->getParam('token');
if (! empty($tokenJWT) && ! $project->isEmpty()) { // JWT authentication
@@ -1277,10 +1251,10 @@ return function (Container $container): void {
return new Document([]);
}, ['project', 'dbForProject', 'request', 'authorization']);
- $container->set('getDatabasesDB', function (Group $pools, Cache $cache, Document $project, Request $request, UsageContext $usage, Authorization $authorization) {
+ $context->set('getDatabasesDB', function (Group $pools, Cache $cache, Document $project, Request $request, UsageContext $usage, Authorization $authorization) {
return function (Document $database) use ($pools, $cache, $project, $request, $usage, $authorization): Database {
- $databaseDSN = $database->getAttribute('database', $project->getAttribute('database', ''));
+ $databaseDSN = $database->getAttribute('database') ?: $project->getAttribute('database', '');
$databaseType = $database->getAttribute('type', '');
try {
@@ -1304,6 +1278,12 @@ return function (Container $container): void {
$database = new Database($adapter, $cache);
$sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')));
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
$database
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
@@ -1326,6 +1306,7 @@ return function (Container $container): void {
if (\in_array($databaseHost, $dbTypeSharedTables)) {
$database
->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($databaseDSN->getParam('namespace'));
} else {
@@ -1337,6 +1318,7 @@ return function (Container $container): void {
} elseif (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -1431,35 +1413,27 @@ return function (Container $container): void {
}, ['pools', 'cache', 'project', 'request', 'usage', 'authorization']);
- $container->set('transactionState', function (Database $dbForProject, Authorization $authorization, callable $getDatabasesDB) {
- return new TransactionState($dbForProject, $authorization, $getDatabasesDB);
- }, ['dbForProject', 'authorization', 'getDatabasesDB']);
+ $context->set(
+ 'transactionState',
+ fn (Database $dbForProject, Authorization $authorization, callable $getDatabasesDB) => new TransactionState($dbForProject, $authorization, $getDatabasesDB),
+ ['dbForProject', 'authorization', 'getDatabasesDB']
+ );
- $container->set('executionsRetentionCount', function (Document $project, array $plan) {
- if ($project->getId() === 'console' || empty($plan)) {
- return 0;
- }
+ $context->set(
+ 'executionsRetentionCount',
+ fn (Document $project, array $plan) => ($project->getId() === 'console' || empty($plan))
+ ? 0
+ : (int) ($plan['executionsRetentionCount'] ?? 100),
+ ['project', 'plan']
+ );
- return (int) ($plan['executionsRetentionCount'] ?? 100);
- }, ['project', 'plan']);
+ $context->set('deviceForFiles', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId())), ['project', 'telemetry']);
+ $context->set('deviceForSites', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId())), ['project', 'telemetry']);
+ $context->set('deviceForMigrations', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId())), ['project', 'telemetry']);
+ $context->set('deviceForFunctions', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId())), ['project', 'telemetry']);
+ $context->set('deviceForBuilds', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId())), ['project', 'telemetry']);
- $container->set('deviceForFiles', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId()));
- }, ['project', 'telemetry']);
- $container->set('deviceForSites', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId()));
- }, ['project', 'telemetry']);
- $container->set('deviceForMigrations', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId()));
- }, ['project', 'telemetry']);
- $container->set('deviceForFunctions', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId()));
- }, ['project', 'telemetry']);
- $container->set('deviceForBuilds', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId()));
- }, ['project', 'telemetry']);
-
- $container->set('embeddingAgent', function ($register) {
+ $context->set('embeddingAgent', function ($register) {
$adapter = new Ollama();
$adapter->setEndpoint(System::getEnv('_APP_EMBEDDING_ENDPOINT', 'http://ollama:11434/api/embed'));
$adapter->setTimeout((int) System::getEnv('_APP_EMBEDDING_TIMEOUT', '30000'));
diff --git a/app/init/span.php b/app/init/span.php
index 8afa01b2df..f6871badfa 100644
--- a/app/init/span.php
+++ b/app/init/span.php
@@ -3,11 +3,30 @@
use Utopia\Span\Exporter;
use Utopia\Span\Span;
use Utopia\Span\Storage;
+use Utopia\System\System;
Span::setStorage(new Storage\Coroutine());
-Span::addExporter(new Exporter\Pretty(), function (Span $span): bool {
+
+// Resolve trace filters once at boot to avoid repeated env lookups per span.
+$traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', '');
+$traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', '');
+$traceEnabled = $traceProjectId !== '' || $traceFunctionId !== '';
+
+Span::addExporter(new Exporter\Pretty(), function (Span $span) use ($traceEnabled, $traceProjectId, $traceFunctionId): bool {
if (\str_starts_with($span->getAction(), 'listener.')) {
return $span->getError() !== null;
}
+
+ // Selective tracing: when _APP_TRACE_PROJECT_ID / _APP_TRACE_FUNCTION_ID are set,
+ // only export spans tagged with matching project.id / function.id.
+ if ($traceEnabled) {
+ if ($traceProjectId !== '' && $span->get('project.id') !== $traceProjectId) {
+ return false;
+ }
+ if ($traceFunctionId !== '' && $span->get('function.id') !== $traceFunctionId) {
+ return false;
+ }
+ }
+
return true;
});
diff --git a/app/init/worker/message.php b/app/init/worker/message.php
index f893c84858..5cabfc7859 100644
--- a/app/init/worker/message.php
+++ b/app/init/worker/message.php
@@ -1,22 +1,14 @@
getHost(), $sharedTables)) {
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
$database
->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -133,8 +132,15 @@ return function (Container $container): void {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
$database
->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -155,8 +161,15 @@ return function (Container $container): void {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
$database
->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -213,6 +226,14 @@ return function (Container $container): void {
$sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')));
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
+ $database->setGlobalCollections($projectsGlobalCollections);
+
// For separate pools (documentsdb/vectorsdb), check their own shared tables config.
// If not configured, use dedicated mode to avoid cross-engine tenant type mismatches.
if ($databaseHost !== $dsn->getHost()) {
@@ -225,6 +246,7 @@ return function (Container $container): void {
if (\in_array($databaseHost, $dbTypeSharedTables)) {
$database
->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
->setTenant($projectDocument->getSequence())
->setNamespace($databaseDSN->getParam('namespace'));
} else {
@@ -236,6 +258,7 @@ return function (Container $container): void {
} elseif (\in_array($dsn->getHost(), $sharedTables, true)) {
$database
->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
->setTenant($projectDocument->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -260,6 +283,11 @@ return function (Container $container): void {
return $database;
}
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $logsCollections = $collections['logs'] ?? [];
+ $logsCollections = array_keys($logsCollections);
+
$adapter = new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
@@ -267,6 +295,7 @@ return function (Container $container): void {
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setSharedTables(true)
+ ->setGlobalCollections($logsCollections)
->setNamespace('logsV1')
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES_WORKER);
@@ -295,54 +324,18 @@ return function (Container $container): void {
return DateTime::addSeconds(new \DateTime(), -1 * (int) System::getEnv('_APP_MAINTENANCE_RETENTION_EXECUTION', 1209600)); // 14 days
}, []);
- $container->set('queueForDatabase', function (Publisher $publisher) {
- return new EventDatabase($publisher);
- }, ['publisher']);
-
- $container->set('queueForMessaging', function (Publisher $publisher) {
- return new Messaging($publisher);
- }, ['publisher']);
-
- $container->set('queueForMails', function (Publisher $publisher) {
- return new Mail($publisher);
- }, ['publisher']);
-
- $container->set('queueForBuilds', function (Publisher $publisher) {
- return new Build($publisher);
- }, ['publisher']);
-
- $container->set('queueForScreenshots', function (Publisher $publisher) {
- return new Screenshot($publisher);
- }, ['publisher']);
-
- $container->set('queueForDeletes', function (Publisher $publisher) {
- return new Delete($publisher);
- }, ['publisher']);
-
$container->set('queueForEvents', function (Publisher $publisher) {
return new Event($publisher);
}, ['publisher']);
- $container->set('queueForAudits', function (Publisher $publisher) {
- return new Audit($publisher);
- }, ['publisher']);
-
$container->set('queueForWebhooks', function (Publisher $publisher) {
return new Webhook($publisher);
}, ['publisher']);
- $container->set('queueForFunctions', function (Publisher $publisher) {
- return new Func($publisher);
- }, ['publisher']);
-
$container->set('queueForRealtime', function () {
return new Realtime();
}, []);
- $container->set('queueForCertificates', function (Publisher $publisher) {
- return new Certificate($publisher);
- }, ['publisher']);
-
$container->set('deviceForSites', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
@@ -383,7 +376,7 @@ return function (Container $container): void {
$log->addTag('code', $error->getCode());
$log->addTag('verboseType', \get_class($error));
- $log->addTag('projectId', $project->getId() ?? '');
+ $log->addTag('projectId', $project->getId());
$log->addExtra('file', $error->getFile());
$log->addExtra('line', $error->getLine());
diff --git a/app/realtime.php b/app/realtime.php
index 955832e93a..d8b70960b8 100644
--- a/app/realtime.php
+++ b/app/realtime.php
@@ -1,10 +1,20 @@
has('pools')) {
+ $container->set('pools', function ($register) {
+ return $register->get('pools');
+ }, ['register']);
+}
+
+if (!$container->has('publisherForUsage')) {
+ $container->set('publisherForUsage', function (Group $pools): UsagePublisher {
+ $statsUsageConnection = System::getEnv('_APP_CONNECTIONS_QUEUE_STATS_USAGE', '');
+ $publisherPoolName = 'publisher';
+
+ if (!empty($statsUsageConnection)) {
+ try {
+ $pools->get('publisher_' . $statsUsageConnection);
+ $publisherPoolName = 'publisher_' . $statsUsageConnection;
+ } catch (Throwable) {
+ // Fallback to default publisher pool when custom one is unavailable.
+ }
+ }
+
+ return new UsagePublisher(
+ new BrokerPool(publisher: $pools->get($publisherPoolName)),
+ new Queue(System::getEnv(
+ '_APP_STATS_USAGE_QUEUE_NAME',
+ QueueEvent::STATS_USAGE_QUEUE_NAME
+ ))
+ );
+ }, ['pools']);
+}
+
// Allows overriding
if (!function_exists('getConsoleDB')) {
function getConsoleDB(): Database
@@ -125,8 +173,14 @@ if (!function_exists('getProjectDB')) {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
$database
->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -223,6 +277,7 @@ if (!function_exists('getRealtime')) {
}
}
+
if (!function_exists('getTelemetry')) {
function getTelemetry(int $workerId): Utopia\Telemetry\Adapter
{
@@ -236,18 +291,58 @@ if (!function_exists('getTelemetry')) {
}
}
+if (!function_exists('getQueueForEvents')) {
+ function getQueueForEvents(): QueueEvent
+ {
+ $ctx = Coroutine::getContext();
+
+ if (!isset($ctx['queueForEvents'])) {
+ global $register;
+ /** @var Group $pools */
+ $pools = $register->get('pools');
+ $ctx['queueForEvents'] = new QueueEvent(new BrokerPool(
+ publisher: $pools->get('publisher')
+ ));
+ }
+
+ return $ctx['queueForEvents'];
+ }
+}
+
+if (!function_exists('getQueueForRealtime')) {
+ function getQueueForRealtime(): QueueRealtime
+ {
+ $ctx = Coroutine::getContext();
+
+ if (!isset($ctx['queueForRealtime'])) {
+ $ctx['queueForRealtime'] = new QueueRealtime();
+ }
+
+ return $ctx['queueForRealtime'];
+ }
+}
+
if (!function_exists('triggerStats')) {
function triggerStats(array $event, string $projectId): void
{
}
}
-global $container;
-$container->set('pools', function ($register) {
- return $register->get('pools');
-}, ['register']);
+if (!function_exists('checkForProjectUsage')) {
+ function checkForProjectUsage(Document $project): void
+ {
+ }
+}
$realtime = getRealtime();
+$presenceState = new PresenceState();
+
+$messageDispatcher = (new MessageDispatcher())
+ ->addHandler(new PingHandler())
+ ->addHandler(new AuthenticationHandler())
+ ->addHandler(new SubscribeHandler())
+ ->addHandler(new UnsubscribeHandler())
+ ->addHandler(new PresenceHandler());
/**
* Table for statistics across all workers.
@@ -262,7 +357,9 @@ $stats->create();
$containerId = uniqid();
$statsDocument = null;
-$workerNumber = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6));
+
+$workerNumber = intval(System::getEnv('_APP_WORKERS_NUM', 0))
+ ?: intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6));
$adapter = new Adapter\Swoole(port: System::getEnv('PORT', 80));
$adapter
@@ -279,7 +376,16 @@ if (!function_exists('logError')) {
$logger = $register->get('realtimeLogger');
- if ($logger && !$error instanceof Exception) {
+ // Match HTTP semantics (app/controllers/general.php): AppwriteException uses its
+ // configured publish flag; everything else publishes only for code 0 or >= 500.
+ // Without this, expected client errors (e.g. Utopia DB Authorization) hit Sentry.
+ if ($error instanceof AppwriteException) {
+ $publish = $error->isPublishable();
+ } else {
+ $publish = $error->getCode() === 0 || $error->getCode() >= 500;
+ }
+
+ if ($logger && $publish) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
$log = new Log();
@@ -394,10 +500,27 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
Console::success('Worker ' . $workerId . ' started successfully');
$telemetry = getTelemetry($workerId);
+ $realtimeDelayBuckets = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000];
+ $workerTelemetryAttributes = ['workerId' => (string) $workerId];
$register->set('telemetry', fn () => $telemetry);
+ $register->set('telemetry.workerAttributes', fn () => $workerTelemetryAttributes);
+ $register->set('telemetry.workerCounter', fn () => $telemetry->createUpDownCounter('realtime.server.active_workers'));
+ $register->set('telemetry.workerClientCounter', fn () => $telemetry->createUpDownCounter('realtime.server.worker_clients'));
+ $register->set('telemetry.workerSubscriptionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.worker_subscriptions'));
$register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections'));
$register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created'));
$register->set('telemetry.messageSentCounter', fn () => $telemetry->createCounter('realtime.server.message.sent'));
+ $register->set('telemetry.deliveryDelayHistogram', fn () => $telemetry->createHistogram(
+ name: 'realtime.server.delivery_delay',
+ unit: 'ms',
+ advisory: ['ExplicitBucketBoundaries' => $realtimeDelayBuckets],
+ ));
+ $register->set('telemetry.arrivalDelayHistogram', fn () => $telemetry->createHistogram(
+ name: 'realtime.server.arrival_delay',
+ unit: 'ms',
+ advisory: ['ExplicitBucketBoundaries' => $realtimeDelayBuckets],
+ ));
+ $register->get('telemetry.workerCounter')->add(1);
$attempts = 0;
$start = time();
@@ -514,12 +637,28 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
$pubsub->subscribe(['realtime'], function (mixed $redis, string $channel, string $payload) use ($server, $workerId, $stats, $register, $realtime) {
$event = json_decode($payload, true);
+ $eventTimestamp = $event['data']['timestamp'] ?? null;
+ if (\is_string($eventTimestamp)) {
+ try {
+ $eventDate = new \DateTimeImmutable($eventTimestamp, new \DateTimeZone('UTC'));
+ $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
+ $eventTimestampMs = (float) $eventDate->format('U.u') * 1000;
+ $nowTimestampMs = (float) $now->format('U.u') * 1000;
+ $arrivalDelayMs = (int) \max(0, $nowTimestampMs - $eventTimestampMs);
+
+ $register->get('telemetry.arrivalDelayHistogram')->record($arrivalDelayMs);
+ } catch (\Throwable) {
+ // Ignore invalid timestamp payloads.
+ }
+ }
+
if ($event['permissionsChanged'] && isset($event['userId'])) {
$projectId = $event['project'];
$userId = $event['userId'];
if ($realtime->hasSubscriber($projectId, 'user:' . $userId)) {
$connection = array_key_first(reset($realtime->subscriptions[$projectId]['user:' . $userId]));
+ $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection));
$consoleDatabase = getConsoleDB();
$project = $consoleDatabase->getAuthorization()->skip(fn () => $consoleDatabase->getDocument('projects', $projectId));
$database = getProjectDB($project);
@@ -529,6 +668,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);
@@ -536,13 +676,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
);
}
@@ -550,9 +696,25 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
if ($authorization !== null) {
$realtime->connections[$connection]['authorization'] = $authorization;
}
+
+ $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection));
+ $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
+ if ($subscriptionDelta !== 0) {
+ $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
+ }
}
}
+ // Strip deleted presences from in-memory connection state so onClose doesn't
+ // re-fire delete events for rows already removed via HTTP DELETE.
+ $deletedPresenceId = Realtime::extractDeletedPresenceId($event);
+ if ($deletedPresenceId !== null) {
+ $realtime->removePresenceFromConnections(
+ (string) ($event['project'] ?? ''),
+ $deletedPresenceId,
+ );
+ }
+
$receivers = $realtime->getSubscribers($event);
if (System::getEnv('_APP_ENV', 'production') === 'development' && !empty($receivers)) {
@@ -592,6 +754,20 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
if ($total > 0) {
$register->get('telemetry.messageSentCounter')->add($total);
$stats->incr($event['project'], 'messages', $total);
+ $updatedAt = $event['data']['payload']['$updatedAt'] ?? null;
+ if (\is_string($updatedAt)) {
+ try {
+ $updatedAtDate = new \DateTimeImmutable($updatedAt, new \DateTimeZone('UTC'));
+ $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
+ $updatedAtTimestampMs = (float) $updatedAtDate->format('U.u') * 1000;
+ $nowTimestampMs = (float) $now->format('U.u') * 1000;
+ $delayMs = (int) \max(0, $nowTimestampMs - $updatedAtTimestampMs);
+
+ $register->get('telemetry.deliveryDelayHistogram')->record($delayMs);
+ } catch (\Throwable) {
+ // Ignore invalid timestamp payloads.
+ }
+ }
$projectId = $event['project'] ?? null;
@@ -621,6 +797,16 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
Console::error('Failed to restart pub/sub...');
});
+$server->onWorkerStop(function (int $workerId) use ($register) {
+ Console::warning('Worker ' . $workerId . ' stopping');
+
+ try {
+ $register->get('telemetry.workerCounter')->add(-1);
+ } catch (\Throwable $th) {
+ Console::error('Realtime onWorkerStop telemetry error: ' . $th->getMessage());
+ }
+});
+
$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime, $registerConnectionResources) {
global $container;
$request = new Request($request);
@@ -636,6 +822,20 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
$project = null;
$logUser = null;
$authorization = null;
+ $rawSize = $request->getSize();
+ $channelCount = 0;
+ $subscriptionCount = 0;
+ $outboundBytes = 0;
+ $responseCode = 200;
+ $subscriptionMode = 'message';
+ $success = false;
+
+ Span::init('realtime.open');
+ Span::add('realtime.connection.id', $connection);
+ Span::add('realtime.inbound_bytes', $rawSize);
+ if (!empty($request->getOrigin())) {
+ Span::add('realtime.origin', $request->getOrigin());
+ }
try {
/** @var Document $project */
@@ -685,8 +885,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
throw new Exception(Exception::REALTIME_TOO_MANY_MESSAGES, 'Too many requests');
}
- $rawSize = $request->getSize();
-
triggerStats([
METRIC_REALTIME_INBOUND => $rawSize,
], $project->getId());
@@ -706,9 +904,11 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
$roles = $user->getRoles($authorization);
$channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId());
+ $channelCount = \count($channels);
$updateStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void {
$register->get('telemetry.connectionCounter')->add(1);
+ $register->get('telemetry.workerClientCounter')->add(1, $register->get('telemetry.workerAttributes'));
$register->get('telemetry.connectionCreatedCounter')->add(1);
$stats->set($projectId, [
@@ -742,11 +942,15 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
$realtime->subscribe($project->getId(), $connection, '', $roles, [], [], $user->getId());
$realtime->connections[$connection]['authorization'] = $authorization;
$server->send([$connection], $connectedPayloadJson);
+ $outboundBytes += \strlen($connectedPayloadJson);
$updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson);
+ $subscriptionMode = 'message';
+ $success = true;
return;
}
$names = array_keys($channels);
+ $subscriptionMode = 'url';
try {
$subscriptions = Realtime::constructSubscriptions(
@@ -773,6 +977,10 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
$mapping[$index] = $subscriptionId;
}
+ $subscriptionCount = \count($subscriptions);
+ if (!empty($subscriptions)) {
+ $register->get('telemetry.workerSubscriptionCounter')->add(\count($subscriptions), $register->get('telemetry.workerAttributes'));
+ }
$realtime->connections[$connection]['authorization'] = $authorization;
@@ -788,10 +996,21 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
]);
$server->send([$connection], $connectedPayloadJson);
+ $outboundBytes += \strlen($connectedPayloadJson);
$updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson);
-
+ $success = true;
} catch (Throwable $th) {
+ Span::error($th);
+
+ // Convert known Utopia DB exceptions to AppwriteException so isPublishable()
+ // suppresses expected client errors (permission denied, query timeout) from Sentry.
+ if ($th instanceof AuthorizationException) {
+ $th = new AppwriteException(AppwriteException::USER_UNAUTHORIZED, previous: $th);
+ } elseif ($th instanceof TimeoutException) {
+ $th = new AppwriteException(AppwriteException::DATABASE_TIMEOUT, previous: $th);
+ }
+
logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization);
// Handle SQL error code is 'HY000'
@@ -799,6 +1018,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
if (!\is_int($code)) {
$code = 500;
}
+ $responseCode = $code;
$message = $th->getMessage();
@@ -816,7 +1036,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
]
];
- $server->send([$connection], json_encode($response));
+ $responsePayloadJson = json_encode($response);
+ $server->send([$connection], $responsePayloadJson);
+ $outboundBytes += \strlen($responsePayloadJson);
$server->close($connection, $code);
if (System::getEnv('_APP_ENV', 'production') === 'development') {
@@ -824,28 +1046,79 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
Console::error('[Error] Code: ' . $response['data']['code']);
Console::error('[Error] Message: ' . $response['data']['message']);
}
+ } finally {
+ Span::add('realtime.success', $success);
+ Span::add('realtime.response_code', $responseCode);
+ Span::add('realtime.subscription_mode', $subscriptionMode);
+ Span::add('realtime.channel_count', $channelCount);
+ Span::add('realtime.subscription_count', $subscriptionCount);
+ Span::add('realtime.outbound_bytes', $outboundBytes);
+ if (!empty($project?->getId())) {
+ Span::add('project.id', $project->getId());
+ }
+ if (!empty($logUser?->getId())) {
+ Span::add('user.id', $logUser->getId());
+ }
+ Span::current()?->finish();
}
});
-$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId) {
+$server->onMessage(function (int $connection, string $message) use ($container, $server, $realtime, $containerId, $register, $presenceState, $messageDispatcher) {
$project = null;
$authorization = null;
- try {
- $rawSize = \strlen($message);
- $response = new Response(new SwooleResponse());
- $projectId = $realtime->connections[$connection]['projectId'] ?? null;
+ $projectId = $realtime->connections[$connection]['projectId'] ?? null;
+ $rawSize = \strlen($message);
+ $messageType = 'invalid';
+ $outboundBytes = 0;
+ $responseCode = 200;
+ $success = false;
- // Get authorization from connection (stored during onOpen)
- $authorization = $realtime->connections[$connection]['authorization'] ?? null;
- if ($authorization === null) {
- $authorization = new Authorization();
+ Span::init('realtime.message');
+ Span::add('realtime.connection.id', $connection);
+ Span::add('realtime.inbound_bytes', $rawSize);
+ Span::add('realtime.container.id', $containerId);
+
+ try {
+ $response = new Response(new SwooleResponse());
+
+ // Build a fresh Authorization per message. The connection-scoped instance is shared
+ // across coroutines, and `Authorization::skip()` toggles instance state — concurrent
+ // messages on the same connection (e.g. `authentication` + `presence` sent back-to-back)
+ // would interleave skip/restore and leak permission checks into supposedly-skipped lookups.
+ $authorization = new Authorization();
+ $connectionAuthorization = $realtime->connections[$connection]['authorization'] ?? null;
+ if ($connectionAuthorization !== null) {
+ foreach ($connectionAuthorization->getRoles() as $role) {
+ $authorization->addRole($role);
+ }
+ }
+ $connectionRoles = $realtime->connections[$connection]['roles'] ?? [];
+ foreach ($connectionRoles as $role) {
+ if ($authorization->hasRole($role)) {
+ continue;
+ }
+ $authorization->addRole($role);
}
$database = getConsoleDB();
$database->setAuthorization($authorization);
if (!empty($projectId) && $projectId !== 'console') {
- $project = $authorization->skip(fn () => $database->getDocument('projects', $projectId));
+ // Negative-cache race: if any prior code path queried projects:$projectId
+ // before this project existed (e.g. a router probe during connection
+ // setup), the Database's shared cache may hold an empty result. Try the
+ // cached read first, and only purge/retry when the first lookup reports
+ // not-found so the shared cache remains effective for normal traffic.
+ try {
+ $project = $authorization->skip(fn () => $database->getDocument('projects', $projectId));
+ } catch (AppwriteException $e) {
+ if ($e->getCode() !== 404) {
+ throw $e;
+ }
+
+ $database->purgeCachedDocument('projects', $projectId);
+ $project = $authorization->skip(fn () => $database->getDocument('projects', $projectId));
+ }
$database = getProjectDB($project);
$database->setAuthorization($authorization);
@@ -853,6 +1126,10 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
$project = null;
}
+ if ($project !== null) {
+ checkForProjectUsage($project);
+ }
+
/*
* Abuse Check
*
@@ -871,6 +1148,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
}
// Record realtime inbound bytes for this project
+ // not making this a part of the dispatcher as we need to get the inbound bytes as well even if we dont enter the dispatcher
if ($project !== null && !$project->isEmpty()) {
triggerStats([
METRIC_REALTIME_INBOUND => $rawSize,
@@ -883,215 +1161,66 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message format is not valid.');
}
- // Ping does not require project context; other messages do (e.g. after unsubscribe during auth)
- if (empty($projectId) && ($message['type'] ?? '') !== 'ping') {
- throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing project context. Reconnect to the project first.');
+ $messageType = $message['type'] ?? 'invalid';
+
+ if (!\is_scalar($messageType)) {
+ throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.');
}
- switch ($message['type']) {
- case 'ping':
- $pongPayloadJson = json_encode([
- 'type' => 'pong'
- ]);
+ // Child of the global container: per-message values like $connection and $project
+ // live on this scope so concurrent message coroutines don't clobber each other,
+ // while globally-registered services (pools, ...) remain reachable via the parent.
+ $messageContainer = new Container($container);
+ $messageContainer->set('connectionId', fn () => $connection);
+ $messageContainer->set('server', fn () => $server);
+ $messageContainer->set('realtime', fn () => $realtime);
+ $messageContainer->set('register', fn () => $register);
+ $messageContainer->set('response', fn () => $response);
+ $messageContainer->set('presenceState', fn () => $presenceState);
+ $messageContainer->set('database', fn () => $database);
+ $messageContainer->set('authorization', fn () => $authorization);
+ $messageContainer->set('project', fn () => $project);
+ $messageContainer->set('projectId', fn () => $projectId);
+ $messageContainer->set('queueForEvents', fn () => getQueueForEvents());
+ $messageContainer->set('queueForRealtime', fn () => getQueueForRealtime());
- $server->send([$connection], $pongPayloadJson);
+ $responsePayload = $messageDispatcher->dispatch($messageContainer, $message);
- if ($project !== null && !$project->isEmpty()) {
- $pongOutboundBytes = \strlen($pongPayloadJson);
+ if ($responsePayload !== null) {
+ $responseJson = json_encode($responsePayload);
+ if ($responseJson === false) {
+ throw new \RuntimeException(
+ 'Failed to encode realtime response payload: ' . json_last_error_msg()
+ );
+ }
- if ($pongOutboundBytes > 0) {
- triggerStats([
- METRIC_REALTIME_OUTBOUND => $pongOutboundBytes,
- ], $project->getId());
- }
- }
+ $server->send([$connection], $responseJson);
+ $bytes = \strlen($responseJson);
+ $outboundBytes += $bytes;
- break;
- case 'authentication':
- if (!array_key_exists('session', $message['data'])) {
- throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.');
- }
-
- $store = new Store();
-
- $store->decode($message['data']['session']);
-
- /** @var User $user */
- $user = $database->getDocument('users', $store->getProperty('id', ''));
-
- /**
- * TODO:
- * Moving forward, we should try to use our dependency injection container
- * to inject the proof for token.
- * This way we will have one source of truth for the proof for token.
- */
- $proofForToken = new Token();
- $proofForToken->setHash(new Sha());
-
- if (
- empty($user->getId()) // Check a document has been found in the DB
- || !$user->sessionVerify($store->getProperty('secret', ''), $proofForToken) // Validate user has valid login token
- ) {
- // cookie not valid
- throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Session is not valid.');
- }
-
- $roles = $user->getRoles($database->getAuthorization());
-
- $authorization = $realtime->connections[$connection]['authorization'] ?? null;
- $projectId = $realtime->connections[$connection]['projectId'] ?? null;
-
- $meta = $realtime->getSubscriptionMetadata($connection);
-
- $realtime->unsubscribe($connection);
-
- if (!empty($projectId)) {
- foreach ($meta as $subscriptionId => $subscription) {
- $queries = Query::parseQueries($subscription['queries'] ?? []);
-
- $realtime->subscribe(
- $projectId,
- $connection,
- $subscriptionId,
- $roles,
- $subscription['channels'] ?? [],
- $queries,
- $user->getId()
- );
- }
- }
-
- if ($authorization !== null) {
- $realtime->connections[$connection]['authorization'] = $authorization;
- }
-
- $user = $response->output($user, Response::MODEL_ACCOUNT);
-
- $authResponsePayloadJson = json_encode([
- 'type' => 'response',
- 'data' => [
- 'to' => 'authentication',
- 'success' => true,
- 'user' => $user
- ]
- ]);
-
- $server->send([$connection], $authResponsePayloadJson);
-
- if ($project !== null && !$project->isEmpty()) {
- $authOutboundBytes = \strlen($authResponsePayloadJson);
-
- if ($authOutboundBytes > 0) {
- triggerStats([
- METRIC_REALTIME_OUTBOUND => $authOutboundBytes,
- ], $project->getId());
- }
- }
-
- break;
-
- case 'subscribe':
- /**
- * Message based upsertion of a subscription
- * If subscriptionId is given then it will match subId of the connection and update the subscription with channels and queries
- * If non-existing subid is given or not given a new subid will be generated
- * Similar to what we have now -> two subscribe() block with same channels and queries still two different subscriptions
- *
- * structure of the payload -> array of maps
- * 'data' : [subscriptionId:"" , channels:[] , queries:[]]
- */
- if (!is_array($message['data']) || !array_is_list($message['data'])) {
- throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.');
- }
-
- $roles = $realtime->connections[$connection]['roles'] ?? [Role::guests()->toString()];
- $userId = $realtime->connections[$connection]['userId'] ?? '';
-
- // bulk validation + parsing before subscribing
- $parsedPayloads = [];
- foreach ($message['data'] as $payload) {
- if (!\is_array($payload)) {
- throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each subscribe payload must be an object.');
- }
- if (!array_key_exists('channels', $payload)) {
- throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not present in payload.');
- }
- if (!is_array($payload['channels']) || !array_is_list($payload['channels'])) {
- throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not a valid array.');
- }
- // registering the queries if not present and check in the same payload later on
- if (!array_key_exists('queries', $payload)) {
- $payload['queries'] = [];
- }
- if (!is_array($payload['queries']) || !array_is_list($payload['queries'])) {
- throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'queries is not a valid array.');
- }
-
- $subscriptionId = \array_key_exists('subscriptionId', $payload)
- ? $payload['subscriptionId']
- : ID::unique();
-
- try {
- $convertedQueries = Realtime::convertQueries($payload['queries']);
- } catch (QueryException $e) {
- throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Invalid query: ' . $e->getMessage());
- }
-
- $parsedPayloads[] = [
- 'subscriptionId' => $subscriptionId,
- 'channels' => $payload['channels'],
- 'queries' => $convertedQueries,
- ];
- }
-
- foreach ($parsedPayloads as $parsedPayload) {
- $subscriptionId = $parsedPayload['subscriptionId'];
- $channels = \array_keys(Realtime::convertChannels($parsedPayload['channels'], $userId));
- $queries = $parsedPayload['queries'];
- $realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries);
- }
-
- // subscribe() overwrites the connection entry; restore auth so later onMessage uses the same context.
- $realtime->connections[$connection]['authorization'] = $authorization;
-
- $responsePayload = json_encode([
- 'type' => 'response',
- 'data' => [
- 'to' => 'subscribe',
- 'success' => true,
- 'subscriptions' => \array_map(function (array $parsedPayload) {
- return [
- 'subscriptionId' => $parsedPayload['subscriptionId'],
- 'channels' => $parsedPayload['channels'],
- 'queries' => \array_map(fn ($q) => $q->toString(), $parsedPayload['queries']),
- ];
- }, $parsedPayloads),
- ]
- ]);
-
- $server->send([$connection], $responsePayload);
-
- if ($project !== null && !$project->isEmpty()) {
- $subscribeOutboundBytes = \strlen($responsePayload);
-
- if ($subscribeOutboundBytes > 0) {
- triggerStats([
- METRIC_REALTIME_OUTBOUND => $subscribeOutboundBytes,
- ], $project->getId());
- }
- }
-
- break;
-
- default:
- throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.');
+ if ($project !== null && !$project->isEmpty()) {
+ triggerStats([METRIC_REALTIME_OUTBOUND => $bytes], $project->getId());
+ }
}
+
+ $success = true;
} catch (Throwable $th) {
+ Span::error($th);
+
+ // Convert known Utopia DB exceptions to AppwriteException so isPublishable()
+ // suppresses expected client errors (permission denied, query timeout) from Sentry.
+ if ($th instanceof AuthorizationException) {
+ $th = new AppwriteException(AppwriteException::USER_UNAUTHORIZED, previous: $th);
+ } elseif ($th instanceof TimeoutException) {
+ $th = new AppwriteException(AppwriteException::DATABASE_TIMEOUT, previous: $th);
+ }
+
logError($th, 'realtimeMessage', project: $project, authorization: $authorization);
$code = $th->getCode();
if (!is_int($code)) {
$code = 500;
}
+ $responseCode = $code;
$message = $th->getMessage();
@@ -1108,32 +1237,172 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
]
];
- $server->send([$connection], json_encode($response));
+ $responsePayloadJson = json_encode($response);
+ $server->send([$connection], $responsePayloadJson);
+ $outboundBytes += \strlen($responsePayloadJson);
if ($th->getCode() === 1008) {
$server->close($connection, $th->getCode());
}
+ } finally {
+ Span::add('realtime.success', $success);
+ Span::add('realtime.response_code', $responseCode);
+ Span::add('realtime.outbound_bytes', $outboundBytes);
+ Span::add('project.id', $project?->getId() ?? $projectId);
+ Span::add('user.id', $realtime->connections[$connection]['userId'] ?? null);
+ Span::add('realtime.message_type', $messageType);
+ Span::current()?->finish();
}
});
-$server->onClose(function (int $connection) use ($realtime, $stats, $register) {
+$server->onClose(function (int $connection) use ($realtime, $stats, $register, $container, $presenceState) {
+ $projectId = null;
+ $userId = null;
+ $subscriptionsBeforeClose = 0;
+ $success = false;
+
+ Span::init('realtime.close');
+ Span::add('realtime.connection.id', $connection);
+
+ if (array_key_exists($connection, $realtime->connections)) {
+ $projectId = $realtime->connections[$connection]['projectId'] ?? null;
+ $userId = $realtime->connections[$connection]['userId'] ?? null;
+ }
+
try {
if (array_key_exists($connection, $realtime->connections)) {
$stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal');
$register->get('telemetry.connectionCounter')->add(-1);
+ $register->get('telemetry.workerClientCounter')->add(-1, $register->get('telemetry.workerAttributes'));
+ $subscriptionsBeforeClose = \count($realtime->getSubscriptionMetadata($connection));
+ if ($subscriptionsBeforeClose > 0) {
+ $register->get('telemetry.workerSubscriptionCounter')->add(-$subscriptionsBeforeClose, $register->get('telemetry.workerAttributes'));
+ }
$projectId = $realtime->connections[$connection]['projectId'];
+ /** @var array $presencesById */
+ $presencesById = $realtime->connections[$connection]['presences'] ?? [];
+
+ if (
+ !empty($presencesById)
+ && $projectId !== 'console'
+ ) {
+ go(function () use ($presencesById, $projectId, $userId, $container, $presenceState): void {
+ // Fresh span: the parent realtime.close span finishes before this coroutine
+ Span::init('realtime.close.presenceCleanup');
+ Span::add('realtime.projectId', $projectId);
+ Span::add('realtime.presenceCount', \count($presencesById));
+
+ try {
+ $dbForPlatform = getConsoleDB();
+ $project = $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
+
+ if ($project->isEmpty()) {
+ return;
+ }
+
+ $presenceIds = \array_keys($presencesById);
+ $presences = \array_values($presencesById);
+ $dbForProject = getProjectDB($project);
+
+ $user = new User([]);
+ if (!empty($userId)) {
+ try {
+ $fetched = $dbForProject->getAuthorization()->skip(
+ fn () => $dbForProject->getDocument('users', $userId)
+ );
+ if (!$fetched->isEmpty()) {
+ $user = new User($fetched->getArrayCopy());
+ }
+ } catch (Throwable) {
+ // Fall back to empty User if lookup fails.
+ }
+ }
+
+ /** @var UsagePublisher $publisherForUsage */
+ $publisherForUsage = $container->get('publisherForUsage');
+
+ /** @var array $deletedIds */
+ $deletedIds = [];
+ try {
+ $deletionCount = $dbForProject->getAuthorization()->skip(
+ function () use ($dbForProject, $presenceIds, &$deletedIds): int {
+ return $dbForProject->deleteDocuments(
+ 'presenceLogs',
+ [Query::equal('$id', $presenceIds)],
+ onNext: function (Document $deleted) use (&$deletedIds): void {
+ $deletedIds[$deleted->getId()] = true;
+ },
+ );
+ }
+ );
+ $presenceState->triggerUsage($publisherForUsage, $project, -$deletionCount);
+ } catch (Throwable $th) {
+ Span::error($th);
+ logError($th, 'realtimeOnClosePresenceDeletion', tags: [
+ 'projectId' => $projectId,
+ 'presences' => \count($presences)
+ ]);
+ }
+
+ $queueForEvents = getQueueForEvents();
+ $queueForRealtime = getQueueForRealtime();
+ foreach ($presences as $presence) {
+ if (!isset($deletedIds[$presence->getId()])) {
+ continue;
+ }
+ try {
+ $presenceState->triggerEvent(
+ $queueForEvents,
+ $queueForRealtime,
+ $project,
+ $user,
+ 'presences.[presenceId].delete',
+ $presence,
+ );
+ } catch (Throwable) {
+ // Swallow errors to avoid breaking disconnect cleanup
+ }
+ }
+ } catch (Throwable $th) {
+ Span::error($th);
+ logError($th, 'realtimeOnClosePresenceCleanup', tags: [
+ 'projectId' => $projectId,
+ ]);
+ } finally {
+ Span::current()?->finish();
+ }
+ });
+ }
triggerStats([
METRIC_REALTIME_CONNECTIONS => -1,
], $projectId);
}
+ $success = true;
} catch (\Throwable $th) {
// Log only; do not rethrow. If we let this bubble, Swoole dumps full coroutine
// backtraces and unsubscribe() below would never run (connection cleanup would fail).
Console::error('Realtime onClose error: ' . $th->getMessage());
+ Span::error($th);
+ } finally {
+ try {
+ $realtime->unsubscribe($connection);
+ } catch (\Throwable $th) {
+ Console::error('Realtime onClose unsubscribe error: ' . $th->getMessage());
+ Span::error($th);
+ }
+
+ Span::add('realtime.success', $success);
+ if (!empty($projectId)) {
+ Span::add('project.id', $projectId);
+ }
+ if (!empty($userId)) {
+ Span::add('user.id', $userId);
+ }
+ Span::add('realtime.subscriptions_before_close', $subscriptionsBeforeClose);
+ Span::current()?->finish();
}
- $realtime->unsubscribe($connection);
Console::info('Connection close: ' . $connection);
});
diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml
index ef4d4a1fe4..6ce1fb5cea 100644
--- a/app/views/install/compose.phtml
+++ b/app/views/install/compose.phtml
@@ -120,7 +120,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_SMTP_HOST
- _APP_SMTP_PORT
- _APP_SMTP_SECURE
@@ -256,7 +255,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_USAGE_STATS
- _APP_LOGGING_CONFIG
@@ -287,7 +285,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
appwrite-worker-webhooks:
@@ -315,7 +312,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -356,7 +352,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_STORAGE_DEVICE
- _APP_STORAGE_S3_ACCESS_KEY
- _APP_STORAGE_S3_SECRET
@@ -416,7 +411,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
appwrite-worker-builds:
@@ -453,7 +447,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
- _APP_VCS_GITHUB_APP_NAME
- _APP_VCS_GITHUB_PRIVATE_KEY
@@ -529,7 +522,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
appwrite-worker-executions:
@@ -592,7 +584,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_FUNCTIONS_TIMEOUT
- _APP_SITES_TIMEOUT
- _APP_COMPUTE_BUILD_TIMEOUT
@@ -630,7 +621,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -673,7 +663,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
- _APP_SMS_FROM
- _APP_SMS_PROVIDER
@@ -734,7 +723,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
- _APP_MIGRATIONS_FIREBASE_CLIENT_ID
- _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET
@@ -773,7 +761,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_MAINTENANCE_INTERVAL
- _APP_MAINTENANCE_RETENTION_EXECUTION
- _APP_MAINTENANCE_RETENTION_CACHE
@@ -806,7 +793,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -839,7 +825,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -871,7 +856,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -897,6 +881,13 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_OPENSSL_KEY_V1
+ - _APP_OPTIONS_FORCE_HTTPS
+ - _APP_DOMAIN
+ - _APP_CONSOLE_DOMAIN
+ - _APP_DOMAIN_FUNCTIONS
+ - _APP_DOMAIN_SITES
+ - _APP_MIGRATION_HOST
+ - _APP_CONSOLE_SCHEMA
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -907,7 +898,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
appwrite-task-scheduler-executions:
image: /:
@@ -926,6 +916,13 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_OPENSSL_KEY_V1
+ - _APP_OPTIONS_FORCE_HTTPS
+ - _APP_DOMAIN
+ - _APP_CONSOLE_DOMAIN
+ - _APP_DOMAIN_FUNCTIONS
+ - _APP_DOMAIN_SITES
+ - _APP_MIGRATION_HOST
+ - _APP_CONSOLE_SCHEMA
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -936,7 +933,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
appwrite-task-scheduler-messages:
image: /:
@@ -965,7 +961,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
appwrite-assistant:
@@ -1068,13 +1063,12 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
image: mongo:8.2.5
container_name: appwrite-mongodb
<<: *x-logging
+ restart: unless-stopped
networks:
- appwrite
volumes:
- appwrite-mongodb:/data/db
- appwrite-mongodb-keyfile:/data/keyfile
- ports:
- - "27017:27017"
environment:
- MONGO_INITDB_ROOT_USERNAME=root
- MONGO_INITDB_ROOT_PASSWORD=${_APP_DB_ROOT_PASS}
@@ -1205,7 +1199,6 @@ volumes:
appwrite-mongodb:
appwrite-mongodb-keyfile:
- appwrite-mongodb-config:
appwrite-redis:
appwrite-cache:
diff --git a/app/worker.php b/app/worker.php
index 7cc34f397c..169b8e9770 100644
--- a/app/worker.php
+++ b/app/worker.php
@@ -16,6 +16,7 @@ use Utopia\Pools\Group;
use Utopia\Queue\Adapter\Swoole;
use Utopia\Queue\Broker\Pool as BrokerPool;
use Utopia\Queue\Server;
+use Utopia\Span\Span;
use Utopia\System\System;
Runtime::enableCoroutine();
@@ -91,8 +92,13 @@ $adapter = new Swoole(
$worker = new Server($adapter, $container);
try {
- $worker->init()->action(function () use ($worker, $registerWorkerMessageResources) {
+ $worker->init()->action(function () use ($worker, $registerWorkerMessageResources, $queueName) {
$registerWorkerMessageResources($worker->getContainer());
+ Span::init("worker.{$queueName}");
+ });
+
+ $worker->shutdown()->action(function () {
+ Span::current()?->finish();
});
$container->set('bus', function ($register) use ($worker) {
@@ -120,6 +126,8 @@ $worker
->action(function (Throwable $error, ?Logger $logger, Log $log, Document $project, Authorization $authorization) use ($queueName) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
+ Span::error($error);
+
if ($logger) {
$log->setNamespace('appwrite-worker');
$log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
@@ -129,7 +137,7 @@ $worker
$log->setAction('appwrite-queue-' . $queueName);
$log->addTag('verboseType', get_class($error));
$log->addTag('code', $error->getCode());
- $log->addTag('projectId', $project->getId() ?? 'n/a');
+ $log->addTag('projectId', $project->getId());
$log->addExtra('file', $error->getFile());
$log->addExtra('line', $error->getLine());
$log->addExtra('trace', $error->getTraceAsString());
diff --git a/composer.json b/composer.json
index 3aa6d157cf..34a0238b7a 100644
--- a/composer.json
+++ b/composer.json
@@ -49,42 +49,44 @@
"ext-openssl": "*",
"ext-zlib": "*",
"ext-sockets": "*",
- "appwrite/php-runtimes": "0.19.*",
+ "appwrite/php-runtimes": "0.20.*",
"appwrite/php-clamav": "2.0.*",
- "utopia-php/abuse": "1.2.*",
+ "utopia-php/abuse": "1.3.*",
"utopia-php/agents": "1.2.*",
"utopia-php/analytics": "0.15.*",
- "utopia-php/audit": "2.2.*",
+ "utopia-php/audit": "2.3.*",
"utopia-php/auth": "0.5.*",
- "utopia-php/cache": "1.0.*",
+ "utopia-php/cache": "^3.0",
"utopia-php/cli": "0.23.*",
"utopia-php/compression": "0.1.*",
"utopia-php/config": "1.*",
"utopia-php/console": "0.1.*",
"utopia-php/database": "5.*",
"utopia-php/detector": "0.2.*",
- "utopia-php/domains": "1.*",
- "utopia-php/emails": "0.6.*",
- "utopia-php/dns": "1.6.*",
+ "utopia-php/domains": "^2.1",
+ "utopia-php/emails": "0.7.*",
+ "utopia-php/dns": "1.7.*",
"utopia-php/dsn": "0.2.1",
- "utopia-php/http": "0.34.*",
- "utopia-php/fetch": "0.5.*",
+ "utopia-php/http": "2.0.0-rc1",
+ "utopia-php/fetch": "^1.1",
+ "utopia-php/validators": "0.2.*",
"utopia-php/image": "0.8.*",
"utopia-php/locale": "0.8.*",
- "utopia-php/logger": "0.6.*",
+ "utopia-php/lock": "0.2.*",
+ "utopia-php/logger": "0.8.*",
"utopia-php/messaging": "0.22.*",
- "utopia-php/migration": "1.9.*",
- "utopia-php/platform": "0.12.*",
+ "utopia-php/migration": "1.*",
+ "utopia-php/platform": "1.0.0-rc2",
"utopia-php/pools": "1.*",
"utopia-php/span": "1.1.*",
"utopia-php/preloader": "0.2.*",
- "utopia-php/queue": "0.17.*",
- "utopia-php/servers": "0.3.*",
+ "utopia-php/queue": "0.18.*",
+ "utopia-php/servers": "0.4.*",
"utopia-php/registry": "0.5.*",
- "utopia-php/storage": "1.0.*",
+ "utopia-php/storage": "2.*",
"utopia-php/system": "0.10.*",
"utopia-php/telemetry": "0.2.*",
- "utopia-php/vcs": "3.*",
+ "utopia-php/vcs": "4.*",
"utopia-php/websocket": "1.0.*",
"matomo/device-detector": "6.4.*",
"dragonmantank/cron-expression": "3.4.*",
@@ -92,7 +94,7 @@
"chillerlan/php-qrcode": "4.3.*",
"adhocore/jwt": "1.1.*",
"spomky-labs/otphp": "11.*",
- "webonyx/graphql-php": "15.31.*",
+ "webonyx/graphql-php": "15.32.*",
"league/csv": "9.14.*",
"enshrined/svg-sanitize": "0.22.*"
},
diff --git a/composer.lock b/composer.lock
index 4afe17abff..07f8594b55 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "f6a87c1012b316e614258f8f57a28e48",
+ "content-hash": "597066d71be48add0c649828d820a505",
"packages": [
{
"name": "adhocore/jwt",
@@ -69,25 +69,25 @@
},
{
"name": "appwrite/appwrite",
- "version": "19.1.0",
+ "version": "23.1.1",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-for-php.git",
- "reference": "8738e812062f899c85b2598eef43d6a247f08a56"
+ "reference": "fd7c0f0bf5ddf334533534b20ed967cfb400f6ea"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/8738e812062f899c85b2598eef43d6a247f08a56",
- "reference": "8738e812062f899c85b2598eef43d6a247f08a56",
+ "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/fd7c0f0bf5ddf334533534b20ed967cfb400f6ea",
+ "reference": "fd7c0f0bf5ddf334533534b20ed967cfb400f6ea",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
- "php": ">=7.1.0"
+ "php": ">=8.2.0"
},
"require-dev": {
- "mockery/mockery": "^1.6.12",
+ "mockery/mockery": "1.6.12",
"phpunit/phpunit": "^10"
},
"type": "library",
@@ -100,14 +100,14 @@
"license": [
"BSD-3-Clause"
],
- "description": "Appwrite is an open-source self-hosted backend server that abstract and simplify complex and repetitive development tasks behind a very simple REST API",
+ "description": "Appwrite is an open-source self-hosted backend server that abstracts and simplifies complex and repetitive development tasks behind a very simple REST API",
"support": {
"email": "team@appwrite.io",
"issues": "https://github.com/appwrite/sdk-for-php/issues",
- "source": "https://github.com/appwrite/sdk-for-php/tree/19.1.0",
+ "source": "https://github.com/appwrite/sdk-for-php/tree/23.1.1",
"url": "https://appwrite.io/support"
},
- "time": "2025-12-18T08:07:43+00:00"
+ "time": "2026-05-12T11:03:36+00:00"
},
{
"name": "appwrite/php-clamav",
@@ -161,16 +161,16 @@
},
{
"name": "appwrite/php-runtimes",
- "version": "0.19.5",
+ "version": "0.20.0",
"source": {
"type": "git",
"url": "https://github.com/appwrite/runtimes.git",
- "reference": "aa2f7760cd0493c0880209b92df812c9386b3546"
+ "reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/appwrite/runtimes/zipball/aa2f7760cd0493c0880209b92df812c9386b3546",
- "reference": "aa2f7760cd0493c0880209b92df812c9386b3546",
+ "url": "https://api.github.com/repos/appwrite/runtimes/zipball/7d9b7f4eef5c0a142a60907b06de2219d025c5c3",
+ "reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3",
"shasum": ""
},
"require": {
@@ -210,9 +210,9 @@
],
"support": {
"issues": "https://github.com/appwrite/runtimes/issues",
- "source": "https://github.com/appwrite/runtimes/tree/0.19.5"
+ "source": "https://github.com/appwrite/runtimes/tree/0.20.0"
},
- "time": "2026-04-01T01:39:23+00:00"
+ "time": "2026-05-01T07:47:07+00:00"
},
{
"name": "brick/math",
@@ -1996,16 +1996,16 @@
},
{
"name": "phpseclib/phpseclib",
- "version": "3.0.51",
+ "version": "3.0.52",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
- "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748"
+ "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d59c94077f9c9915abb51ddb52ce85188ece1748",
- "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748",
+ "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce",
+ "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce",
"shasum": ""
},
"require": {
@@ -2086,7 +2086,7 @@
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
- "source": "https://github.com/phpseclib/phpseclib/tree/3.0.51"
+ "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52"
},
"funding": [
{
@@ -2102,7 +2102,7 @@
"type": "tidelift"
}
],
- "time": "2026-04-10T01:33:53+00:00"
+ "time": "2026-04-27T07:02:15+00:00"
},
{
"name": "psr/clock",
@@ -2641,16 +2641,16 @@
},
{
"name": "symfony/deprecation-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
- "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
- "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
"shasum": ""
},
"require": {
@@ -2663,7 +2663,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -2688,7 +2688,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -2699,25 +2699,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-25T14:21:43+00:00"
+ "time": "2026-04-13T15:52:40+00:00"
},
{
"name": "symfony/http-client",
- "version": "v7.4.8",
+ "version": "v7.4.9",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
- "reference": "01933e626c3de76bea1e22641e205e78f6a34342"
+ "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client/zipball/01933e626c3de76bea1e22641e205e78f6a34342",
- "reference": "01933e626c3de76bea1e22641e205e78f6a34342",
+ "url": "https://api.github.com/repos/symfony/http-client/zipball/7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6",
+ "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6",
"shasum": ""
},
"require": {
@@ -2785,7 +2789,7 @@
"http"
],
"support": {
- "source": "https://github.com/symfony/http-client/tree/v7.4.8"
+ "source": "https://github.com/symfony/http-client/tree/v7.4.9"
},
"funding": [
{
@@ -2805,20 +2809,20 @@
"type": "tidelift"
}
],
- "time": "2026-03-30T12:55:43+00:00"
+ "time": "2026-04-29T13:25:15+00:00"
},
{
"name": "symfony/http-client-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
- "reference": "75d7043853a42837e68111812f4d964b01e5101c"
+ "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
- "reference": "75d7043853a42837e68111812f4d964b01e5101c",
+ "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d",
+ "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d",
"shasum": ""
},
"require": {
@@ -2831,7 +2835,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -2867,7 +2871,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -2878,16 +2882,20 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-04-29T11:18:49+00:00"
+ "time": "2026-03-06T13:17:50+00:00"
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.34.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
@@ -2948,7 +2956,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0"
},
"funding": [
{
@@ -2972,7 +2980,7 @@
},
{
"name": "symfony/polyfill-php82",
- "version": "v1.34.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php82.git",
@@ -3028,7 +3036,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php82/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-php82/tree/v1.37.0"
},
"funding": [
{
@@ -3052,7 +3060,7 @@
},
{
"name": "symfony/polyfill-php83",
- "version": "v1.34.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php83.git",
@@ -3108,7 +3116,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php83/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0"
},
"funding": [
{
@@ -3132,16 +3140,16 @@
},
{
"name": "symfony/polyfill-php85",
- "version": "v1.34.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php85.git",
- "reference": "2c408a6bb0313e6001a83628dc5506100474254e"
+ "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/2c408a6bb0313e6001a83628dc5506100474254e",
- "reference": "2c408a6bb0313e6001a83628dc5506100474254e",
+ "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee",
+ "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee",
"shasum": ""
},
"require": {
@@ -3188,7 +3196,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php85/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0"
},
"funding": [
{
@@ -3208,20 +3216,20 @@
"type": "tidelift"
}
],
- "time": "2026-04-10T16:50:15+00:00"
+ "time": "2026-04-26T13:10:57+00:00"
},
{
"name": "symfony/service-contracts",
- "version": "v3.6.1",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
- "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
+ "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
- "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a",
+ "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a",
"shasum": ""
},
"require": {
@@ -3239,7 +3247,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -3275,7 +3283,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
+ "source": "https://github.com/symfony/service-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -3295,7 +3303,7 @@
"type": "tidelift"
}
],
- "time": "2025-07-15T11:30:57+00:00"
+ "time": "2026-03-28T09:44:51+00:00"
},
{
"name": "tbachert/spi",
@@ -3351,24 +3359,24 @@
},
{
"name": "utopia-php/abuse",
- "version": "1.2.2",
+ "version": "1.3.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/abuse.git",
- "reference": "20bee84fd14dbe81d50ecabf1ffd81cceca06152"
+ "reference": "5d7efbe5c6b0cf7d06003114fd86e24ba785582f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/abuse/zipball/20bee84fd14dbe81d50ecabf1ffd81cceca06152",
- "reference": "20bee84fd14dbe81d50ecabf1ffd81cceca06152",
+ "url": "https://api.github.com/repos/utopia-php/abuse/zipball/5d7efbe5c6b0cf7d06003114fd86e24ba785582f",
+ "reference": "5d7efbe5c6b0cf7d06003114fd86e24ba785582f",
"shasum": ""
},
"require": {
- "appwrite/appwrite": "19.*",
+ "appwrite/appwrite": "23.*",
"ext-curl": "*",
"ext-pdo": "*",
"ext-redis": "*",
- "php": ">=8.0",
+ "php": ">=8.2",
"utopia-php/database": "5.*"
},
"require-dev": {
@@ -3397,27 +3405,27 @@
],
"support": {
"issues": "https://github.com/utopia-php/abuse/issues",
- "source": "https://github.com/utopia-php/abuse/tree/1.2.2"
+ "source": "https://github.com/utopia-php/abuse/tree/1.3.0"
},
- "time": "2026-02-02T10:43:10+00:00"
+ "time": "2026-05-11T08:07:02+00:00"
},
{
"name": "utopia-php/agents",
- "version": "1.2.1",
+ "version": "1.2.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/agents.git",
- "reference": "052227953678a30ecc4b5467401fcb0b2386471e"
+ "reference": "0703f4cae02261e09a1bf0d39a4b1ce649cae634"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/agents/zipball/052227953678a30ecc4b5467401fcb0b2386471e",
- "reference": "052227953678a30ecc4b5467401fcb0b2386471e",
+ "url": "https://api.github.com/repos/utopia-php/agents/zipball/0703f4cae02261e09a1bf0d39a4b1ce649cae634",
+ "reference": "0703f4cae02261e09a1bf0d39a4b1ce649cae634",
"shasum": ""
},
"require": {
"php": ">=8.3",
- "utopia-php/fetch": "0.5.*"
+ "utopia-php/fetch": "^1.1.0"
},
"require-dev": {
"laravel/pint": "^1.18",
@@ -3450,9 +3458,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/agents/issues",
- "source": "https://github.com/utopia-php/agents/tree/1.2.1"
+ "source": "https://github.com/utopia-php/agents/tree/1.2.2"
},
- "time": "2026-02-24T06:03:55+00:00"
+ "time": "2026-05-08T10:38:23+00:00"
},
{
"name": "utopia-php/analytics",
@@ -3502,22 +3510,23 @@
},
{
"name": "utopia-php/audit",
- "version": "2.2.1",
+ "version": "2.3.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/audit.git",
- "reference": "e3e2d6ad5c7f6377d9237df296a12eb7943892fd"
+ "reference": "e7b4049fc2ee9be34bcc18771fa593db3b0e9fe3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/audit/zipball/e3e2d6ad5c7f6377d9237df296a12eb7943892fd",
- "reference": "e3e2d6ad5c7f6377d9237df296a12eb7943892fd",
+ "url": "https://api.github.com/repos/utopia-php/audit/zipball/e7b4049fc2ee9be34bcc18771fa593db3b0e9fe3",
+ "reference": "e7b4049fc2ee9be34bcc18771fa593db3b0e9fe3",
"shasum": ""
},
"require": {
- "php": ">=8.0",
+ "php": ">=8.4",
"utopia-php/database": "5.*",
- "utopia-php/fetch": "0.5.*",
+ "utopia-php/fetch": "^1.1",
+ "utopia-php/query": "0.1.*",
"utopia-php/validators": "0.2.*"
},
"require-dev": {
@@ -3545,9 +3554,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/audit/issues",
- "source": "https://github.com/utopia-php/audit/tree/2.2.1"
+ "source": "https://github.com/utopia-php/audit/tree/2.3.2"
},
- "time": "2026-02-02T10:39:25+00:00"
+ "time": "2026-05-14T04:00:37+00:00"
},
{
"name": "utopia-php/auth",
@@ -3606,23 +3615,24 @@
},
{
"name": "utopia-php/cache",
- "version": "1.0.1",
+ "version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/cache.git",
- "reference": "05ceba981436a4022553f7aaa2a05fa049d0f71c"
+ "reference": "086687d7ae23dd1dae67b943161e8cef143539e1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/cache/zipball/05ceba981436a4022553f7aaa2a05fa049d0f71c",
- "reference": "05ceba981436a4022553f7aaa2a05fa049d0f71c",
+ "url": "https://api.github.com/repos/utopia-php/cache/zipball/086687d7ae23dd1dae67b943161e8cef143539e1",
+ "reference": "086687d7ae23dd1dae67b943161e8cef143539e1",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-memcached": "*",
"ext-redis": "*",
- "php": ">=8.0",
+ "php": ">=8.3",
+ "utopia-php/circuit-breaker": "0.3.*",
"utopia-php/pools": "1.*",
"utopia-php/telemetry": "*"
},
@@ -3630,6 +3640,7 @@
"laravel/pint": "1.2.*",
"phpstan/phpstan": "^1.12",
"phpunit/phpunit": "^9.3",
+ "swoole/ide-helper": "^6.0",
"vimeo/psalm": "4.13.1"
},
"type": "library",
@@ -3652,27 +3663,89 @@
],
"support": {
"issues": "https://github.com/utopia-php/cache/issues",
- "source": "https://github.com/utopia-php/cache/tree/1.0.1"
+ "source": "https://github.com/utopia-php/cache/tree/3.0.2"
},
- "time": "2026-03-12T03:39:09+00:00"
+ "time": "2026-05-19T22:38:16+00:00"
},
{
- "name": "utopia-php/cli",
- "version": "0.23.1",
+ "name": "utopia-php/circuit-breaker",
+ "version": "0.3.0",
"source": {
"type": "git",
- "url": "https://github.com/utopia-php/cli.git",
- "reference": "8d1955b8bc4dc631f45d7c7df689ed7b63f70621"
+ "url": "https://github.com/utopia-php/circuit-breaker.git",
+ "reference": "064243c1667778c00abf027ff53a735a228776de"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/cli/zipball/8d1955b8bc4dc631f45d7c7df689ed7b63f70621",
- "reference": "8d1955b8bc4dc631f45d7c7df689ed7b63f70621",
+ "url": "https://api.github.com/repos/utopia-php/circuit-breaker/zipball/064243c1667778c00abf027ff53a735a228776de",
+ "reference": "064243c1667778c00abf027ff53a735a228776de",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.29",
+ "phpstan/phpstan": "^2.1",
+ "phpunit/phpunit": "^10.0",
+ "utopia-php/telemetry": "0.2.*"
+ },
+ "suggest": {
+ "ext-opentelemetry": "Required by utopia-php/telemetry when using OpenTelemetry metrics.",
+ "ext-protobuf": "Required by utopia-php/telemetry when using OpenTelemetry metrics.",
+ "ext-redis": "Required when using Utopia\\CircuitBreaker\\Adapter\\Redis with the phpredis extension.",
+ "ext-swoole": "Required when using Utopia\\CircuitBreaker\\Adapter\\SwooleTable.",
+ "utopia-php/telemetry": "Required when passing telemetry adapters or running the local telemetry demo."
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Utopia\\CircuitBreaker\\": "src/CircuitBreaker"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Team Appwrite",
+ "email": "team@appwrite.io"
+ }
+ ],
+ "description": "Light & simple Circuit Breaker for PHP to prevent cascading failures in distributed systems.",
+ "keywords": [
+ "circuit-breaker",
+ "fault-tolerance",
+ "framework",
+ "php",
+ "resilience",
+ "upf",
+ "utopia"
+ ],
+ "support": {
+ "issues": "https://github.com/utopia-php/circuit-breaker/issues",
+ "source": "https://github.com/utopia-php/circuit-breaker/tree/0.3.0"
+ },
+ "time": "2026-05-12T04:27:08+00:00"
+ },
+ {
+ "name": "utopia-php/cli",
+ "version": "0.23.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/utopia-php/cli.git",
+ "reference": "3c45ae5bcdcd3c7916e1909d74c60b8e771610db"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/utopia-php/cli/zipball/3c45ae5bcdcd3c7916e1909d74c60b8e771610db",
+ "reference": "3c45ae5bcdcd3c7916e1909d74c60b8e771610db",
"shasum": ""
},
"require": {
"php": ">=7.4",
- "utopia-php/servers": "0.3.*"
+ "utopia-php/servers": "0.4.0"
},
"require-dev": {
"laravel/pint": "1.2.*",
@@ -3703,9 +3776,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/cli/issues",
- "source": "https://github.com/utopia-php/cli/tree/0.23.1"
+ "source": "https://github.com/utopia-php/cli/tree/0.23.3"
},
- "time": "2026-04-05T15:27:35+00:00"
+ "time": "2026-05-05T04:38:59+00:00"
},
{
"name": "utopia-php/compression",
@@ -3850,24 +3923,25 @@
},
{
"name": "utopia-php/database",
- "version": "5.3.20",
+ "version": "5.9.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/database.git",
- "reference": "fad8e6b93c4d08cc611e41a828df3bbe0d9cfa24"
+ "reference": "477bae83e27631f78c159f45b0441c0c7dc69050"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/database/zipball/fad8e6b93c4d08cc611e41a828df3bbe0d9cfa24",
- "reference": "fad8e6b93c4d08cc611e41a828df3bbe0d9cfa24",
+ "url": "https://api.github.com/repos/utopia-php/database/zipball/477bae83e27631f78c159f45b0441c0c7dc69050",
+ "reference": "477bae83e27631f78c159f45b0441c0c7dc69050",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-mongodb": "*",
"ext-pdo": "*",
+ "ext-redis": "*",
"php": ">=8.4",
- "utopia-php/cache": "1.*",
+ "utopia-php/cache": "^3.0",
"utopia-php/console": "0.1.*",
"utopia-php/mongo": "1.*",
"utopia-php/pools": "1.*",
@@ -3903,9 +3977,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/database/issues",
- "source": "https://github.com/utopia-php/database/tree/5.3.20"
+ "source": "https://github.com/utopia-php/database/tree/5.9.0"
},
- "time": "2026-04-10T08:27:41+00:00"
+ "time": "2026-05-17T15:57:21+00:00"
},
{
"name": "utopia-php/detector",
@@ -4005,29 +4079,29 @@
},
{
"name": "utopia-php/dns",
- "version": "1.6.6",
+ "version": "1.7.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/dns.git",
- "reference": "917901ecfe5f09a540e4f689b6cbb80b9f55035d"
+ "reference": "5225f52a82d4128e69ad17c2a81fcfea6aa00ae1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/dns/zipball/917901ecfe5f09a540e4f689b6cbb80b9f55035d",
- "reference": "917901ecfe5f09a540e4f689b6cbb80b9f55035d",
+ "url": "https://api.github.com/repos/utopia-php/dns/zipball/5225f52a82d4128e69ad17c2a81fcfea6aa00ae1",
+ "reference": "5225f52a82d4128e69ad17c2a81fcfea6aa00ae1",
"shasum": ""
},
"require": {
"php": ">=8.3",
- "utopia-php/domains": "1.0.*",
+ "utopia-php/domains": "^2.0",
"utopia-php/span": "1.1.*",
"utopia-php/telemetry": "*",
"utopia-php/validators": "0.*"
},
"require-dev": {
- "laravel/pint": "1.25.*",
+ "laravel/pint": "1.29.*",
"phpstan/phpstan": "2.0.*",
- "phpunit/phpunit": "12.4.*",
+ "phpunit/phpunit": "12.5.*",
"swoole/ide-helper": "5.1.8"
},
"type": "library",
@@ -4056,27 +4130,27 @@
],
"support": {
"issues": "https://github.com/utopia-php/dns/issues",
- "source": "https://github.com/utopia-php/dns/tree/1.6.6"
+ "source": "https://github.com/utopia-php/dns/tree/1.7.2"
},
- "time": "2026-03-27T11:13:50+00:00"
+ "time": "2026-05-20T04:49:11+00:00"
},
{
"name": "utopia-php/domains",
- "version": "1.0.5",
+ "version": "2.1.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/domains.git",
- "reference": "0edf6bb2b07f30db849a267027077bf5abb994c6"
+ "reference": "1b1fea8674e8712e0344d3abb5a7acd558dede50"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/domains/zipball/0edf6bb2b07f30db849a267027077bf5abb994c6",
- "reference": "0edf6bb2b07f30db849a267027077bf5abb994c6",
+ "url": "https://api.github.com/repos/utopia-php/domains/zipball/1b1fea8674e8712e0344d3abb5a7acd558dede50",
+ "reference": "1b1fea8674e8712e0344d3abb5a7acd558dede50",
"shasum": ""
},
"require": {
- "php": ">=8.2",
- "utopia-php/cache": "1.0.*",
+ "php": ">=8.3",
+ "utopia-php/cache": "^3.0",
"utopia-php/validators": "0.*"
},
"require-dev": {
@@ -4118,9 +4192,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/domains/issues",
- "source": "https://github.com/utopia-php/domains/tree/1.0.5"
+ "source": "https://github.com/utopia-php/domains/tree/2.1.0"
},
- "time": "2026-03-03T09:20:50+00:00"
+ "time": "2026-05-14T14:33:46+00:00"
},
{
"name": "utopia-php/dsn",
@@ -4171,22 +4245,21 @@
},
{
"name": "utopia-php/emails",
- "version": "0.6.9",
+ "version": "0.7.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/emails.git",
- "reference": "3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf"
+ "reference": "a5f1d111e5023918731f2de96d348f5b6a0de143"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/emails/zipball/3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf",
- "reference": "3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf",
+ "url": "https://api.github.com/repos/utopia-php/emails/zipball/a5f1d111e5023918731f2de96d348f5b6a0de143",
+ "reference": "a5f1d111e5023918731f2de96d348f5b6a0de143",
"shasum": ""
},
"require": {
"php": ">=8.0",
- "utopia-php/domains": "^1.0",
- "utopia-php/fetch": "^0.5",
+ "utopia-php/domains": "^2.0",
"utopia-php/validators": "0.*"
},
"require-dev": {
@@ -4194,7 +4267,8 @@
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^9.3",
"utopia-php/cli": "^0.22",
- "utopia-php/console": "0.*"
+ "utopia-php/console": "0.*",
+ "utopia-php/fetch": "^1.1"
},
"type": "library",
"autoload": {
@@ -4226,22 +4300,22 @@
],
"support": {
"issues": "https://github.com/utopia-php/emails/issues",
- "source": "https://github.com/utopia-php/emails/tree/0.6.9"
+ "source": "https://github.com/utopia-php/emails/tree/0.7.1"
},
- "time": "2026-03-14T13:52:56+00:00"
+ "time": "2026-05-20T13:05:30+00:00"
},
{
"name": "utopia-php/fetch",
- "version": "0.5.1",
+ "version": "1.1.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/fetch.git",
- "reference": "a96a010e1c273f3888765449687baf58cbc61fcd"
+ "reference": "64f2b3a789480f1deb102ce684dac4217d8e98d5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/fetch/zipball/a96a010e1c273f3888765449687baf58cbc61fcd",
- "reference": "a96a010e1c273f3888765449687baf58cbc61fcd",
+ "url": "https://api.github.com/repos/utopia-php/fetch/zipball/64f2b3a789480f1deb102ce684dac4217d8e98d5",
+ "reference": "64f2b3a789480f1deb102ce684dac4217d8e98d5",
"shasum": ""
},
"require": {
@@ -4250,7 +4324,8 @@
"require-dev": {
"laravel/pint": "^1.5.0",
"phpstan/phpstan": "^1.10",
- "phpunit/phpunit": "^9.5"
+ "phpunit/phpunit": "^9.5",
+ "swoole/ide-helper": "^6.0"
},
"type": "library",
"autoload": {
@@ -4265,41 +4340,43 @@
"description": "A simple library that provides an interface for making HTTP Requests.",
"support": {
"issues": "https://github.com/utopia-php/fetch/issues",
- "source": "https://github.com/utopia-php/fetch/tree/0.5.1"
+ "source": "https://github.com/utopia-php/fetch/tree/1.1.2"
},
- "time": "2025-12-18T16:25:10+00:00"
+ "time": "2026-04-29T11:19:19+00:00"
},
{
"name": "utopia-php/http",
- "version": "0.34.20",
+ "version": "2.0.0-rc1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/http.git",
- "reference": "d6b360d555022d16c16d40be51f86180364819f8"
+ "reference": "3e3b431d443844c6bf810120dee735f45880856f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/http/zipball/d6b360d555022d16c16d40be51f86180364819f8",
- "reference": "d6b360d555022d16c16d40be51f86180364819f8",
+ "url": "https://api.github.com/repos/utopia-php/http/zipball/3e3b431d443844c6bf810120dee735f45880856f",
+ "reference": "3e3b431d443844c6bf810120dee735f45880856f",
"shasum": ""
},
"require": {
- "ext-swoole": "*",
- "php": ">=8.2",
+ "php": ">=8.3",
"utopia-php/compression": "0.1.*",
"utopia-php/di": "0.3.*",
- "utopia-php/servers": "0.3.*",
+ "utopia-php/servers": "0.4.0",
"utopia-php/telemetry": "0.2.*",
"utopia-php/validators": "0.2.*"
},
"require-dev": {
"doctrine/instantiator": "^1.5",
"laravel/pint": "1.*",
- "phpbench/phpbench": "^1.2",
- "phpstan/phpstan": "1.*",
- "phpunit/phpunit": "^9.5.25",
+ "phpstan/phpstan": "^2.1",
+ "phpunit/phpunit": "^12.0",
+ "rector/rector": "^2.4",
"swoole/ide-helper": "4.8.3"
},
+ "suggest": {
+ "ext-swoole": "Required to use the Swoole server adapter (\\Utopia\\Http\\Adapter\\Swoole\\Server)."
+ },
"type": "library",
"autoload": {
"psr-4": {
@@ -4319,22 +4396,22 @@
],
"support": {
"issues": "https://github.com/utopia-php/http/issues",
- "source": "https://github.com/utopia-php/http/tree/0.34.20"
+ "source": "https://github.com/utopia-php/http/tree/2.0.0-rc1"
},
- "time": "2026-04-12T14:25:22+00:00"
+ "time": "2026-05-05T15:00:03+00:00"
},
{
"name": "utopia-php/image",
- "version": "0.8.4",
+ "version": "0.8.6",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/image.git",
- "reference": "ce788ff0121a79286fdbe3ef3eba566de646df65"
+ "reference": "85ab7027873e11bc901110d8f7830252247ba724"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/image/zipball/ce788ff0121a79286fdbe3ef3eba566de646df65",
- "reference": "ce788ff0121a79286fdbe3ef3eba566de646df65",
+ "url": "https://api.github.com/repos/utopia-php/image/zipball/85ab7027873e11bc901110d8f7830252247ba724",
+ "reference": "85ab7027873e11bc901110d8f7830252247ba724",
"shasum": ""
},
"require": {
@@ -4343,10 +4420,12 @@
"php": ">=8.1"
},
"require-dev": {
- "laravel/pint": "1.2.*",
- "phpstan/phpstan": "^1.10.0",
- "phpunit/phpunit": "^9.3",
- "vimeo/psalm": "4.13.1"
+ "laravel/pint": "1.24.*",
+ "phpstan/phpstan": "2.1.*",
+ "phpunit/phpunit": "10.5.*"
+ },
+ "suggest": {
+ "ext-imagick": "Imagick extension is required for Imagick adapter"
},
"type": "library",
"autoload": {
@@ -4368,9 +4447,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/image/issues",
- "source": "https://github.com/utopia-php/image/tree/0.8.4"
+ "source": "https://github.com/utopia-php/image/tree/0.8.6"
},
- "time": "2025-06-03T08:32:20+00:00"
+ "time": "2026-04-19T12:52:59+00:00"
},
{
"name": "utopia-php/locale",
@@ -4420,21 +4499,73 @@
"time": "2025-08-12T12:58:26+00:00"
},
{
- "name": "utopia-php/logger",
- "version": "0.6.2",
+ "name": "utopia-php/lock",
+ "version": "0.2.0",
"source": {
"type": "git",
- "url": "https://github.com/utopia-php/logger.git",
- "reference": "25b5bd2ad8bb51292f76332faa7034644fd0941d"
+ "url": "https://github.com/utopia-php/lock.git",
+ "reference": "49317c9493d8f747e4299aa24c22862aa5f6e106"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/logger/zipball/25b5bd2ad8bb51292f76332faa7034644fd0941d",
- "reference": "25b5bd2ad8bb51292f76332faa7034644fd0941d",
+ "url": "https://api.github.com/repos/utopia-php/lock/zipball/49317c9493d8f747e4299aa24c22862aa5f6e106",
+ "reference": "49317c9493d8f747e4299aa24c22862aa5f6e106",
"shasum": ""
},
"require": {
- "php": ">=8.0"
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "laravel/pint": "1.*",
+ "phpstan/phpstan": "2.*",
+ "phpunit/phpunit": "11.*",
+ "swoole/ide-helper": "*"
+ },
+ "suggest": {
+ "ext-pcntl": "Required to run the File lock tests",
+ "ext-redis": "Required for the Distributed lock",
+ "ext-swoole": "Required for the Mutex and Semaphore locks (>=6.0)"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Utopia\\Lock\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Appwrite Team",
+ "email": "team@appwrite.io"
+ }
+ ],
+ "description": "Mutex, semaphore, file and distributed locks for PHP — one interface, four backends.",
+ "support": {
+ "issues": "https://github.com/utopia-php/lock/issues",
+ "source": "https://github.com/utopia-php/lock/tree/0.2.0"
+ },
+ "time": "2026-04-24T10:47:56+00:00"
+ },
+ {
+ "name": "utopia-php/logger",
+ "version": "0.8.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/utopia-php/logger.git",
+ "reference": "132236c42222cd614cb882938a48f8729ef3118b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/utopia-php/logger/zipball/132236c42222cd614cb882938a48f8729ef3118b",
+ "reference": "132236c42222cd614cb882938a48f8729ef3118b",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "utopia-php/fetch": "^1.1"
},
"require-dev": {
"laravel/pint": "1.2.*",
@@ -4469,22 +4600,22 @@
],
"support": {
"issues": "https://github.com/utopia-php/logger/issues",
- "source": "https://github.com/utopia-php/logger/tree/0.6.2"
+ "source": "https://github.com/utopia-php/logger/tree/0.8.0"
},
- "time": "2024-10-14T16:02:49+00:00"
+ "time": "2026-05-05T06:04:27+00:00"
},
{
"name": "utopia-php/messaging",
- "version": "0.22.0",
+ "version": "0.22.3",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/messaging.git",
- "reference": "a6ac04fd204fb6a16bf8c75a84d0b9fc10aa5030"
+ "reference": "67366d5f45cc92efe7adb6aab5d6dcd2342f2f9e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/messaging/zipball/a6ac04fd204fb6a16bf8c75a84d0b9fc10aa5030",
- "reference": "a6ac04fd204fb6a16bf8c75a84d0b9fc10aa5030",
+ "url": "https://api.github.com/repos/utopia-php/messaging/zipball/67366d5f45cc92efe7adb6aab5d6dcd2342f2f9e",
+ "reference": "67366d5f45cc92efe7adb6aab5d6dcd2342f2f9e",
"shasum": ""
},
"require": {
@@ -4520,33 +4651,33 @@
],
"support": {
"issues": "https://github.com/utopia-php/messaging/issues",
- "source": "https://github.com/utopia-php/messaging/tree/0.22.0"
+ "source": "https://github.com/utopia-php/messaging/tree/0.22.3"
},
- "time": "2026-04-02T04:09:19+00:00"
+ "time": "2026-05-19T05:31:20+00:00"
},
{
"name": "utopia-php/migration",
- "version": "1.9.1",
+ "version": "1.12.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
- "reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2"
+ "reference": "3ee6e12af256726bddc3a0402c94535132abecc6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/migration/zipball/7a86aeadf182b63a9f4ceba7e137588b31c5d2e2",
- "reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2",
+ "url": "https://api.github.com/repos/utopia-php/migration/zipball/3ee6e12af256726bddc3a0402c94535132abecc6",
+ "reference": "3ee6e12af256726bddc3a0402c94535132abecc6",
"shasum": ""
},
"require": {
- "appwrite/appwrite": "19.*",
+ "appwrite/appwrite": "23.*",
"ext-curl": "*",
"ext-openssl": "*",
"halaxa/json-machine": "^1.2",
- "php": ">=8.1",
+ "php": ">=8.2",
"utopia-php/database": "5.*",
"utopia-php/dsn": "0.2.*",
- "utopia-php/storage": "1.0.*"
+ "utopia-php/storage": "2.*"
},
"require-dev": {
"ext-pdo": "*",
@@ -4575,22 +4706,22 @@
],
"support": {
"issues": "https://github.com/utopia-php/migration/issues",
- "source": "https://github.com/utopia-php/migration/tree/1.9.1"
+ "source": "https://github.com/utopia-php/migration/tree/1.12.0"
},
- "time": "2026-03-25T07:05:27+00:00"
+ "time": "2026-05-14T07:30:09+00:00"
},
{
"name": "utopia-php/mongo",
- "version": "1.0.2",
+ "version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/mongo.git",
- "reference": "677a21c53f7a1316c528b4b45b3fce886cee7223"
+ "reference": "73593682deee4696525a04e26524c1c1226e1530"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/mongo/zipball/677a21c53f7a1316c528b4b45b3fce886cee7223",
- "reference": "677a21c53f7a1316c528b4b45b3fce886cee7223",
+ "url": "https://api.github.com/repos/utopia-php/mongo/zipball/73593682deee4696525a04e26524c1c1226e1530",
+ "reference": "73593682deee4696525a04e26524c1c1226e1530",
"shasum": ""
},
"require": {
@@ -4636,32 +4767,32 @@
],
"support": {
"issues": "https://github.com/utopia-php/mongo/issues",
- "source": "https://github.com/utopia-php/mongo/tree/1.0.2"
+ "source": "https://github.com/utopia-php/mongo/tree/1.1.0"
},
- "time": "2026-03-18T02:45:50+00:00"
+ "time": "2026-04-24T06:15:10+00:00"
},
{
"name": "utopia-php/platform",
- "version": "0.12.1",
+ "version": "1.0.0-rc2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/platform.git",
- "reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc"
+ "reference": "a67e5037007ee7fdca5359ab4577b82917e55452"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/platform/zipball/2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc",
- "reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc",
+ "url": "https://api.github.com/repos/utopia-php/platform/zipball/a67e5037007ee7fdca5359ab4577b82917e55452",
+ "reference": "a67e5037007ee7fdca5359ab4577b82917e55452",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-redis": "*",
- "php": ">=8.1",
+ "php": ">=8.3",
"utopia-php/cli": "0.23.*",
- "utopia-php/http": "0.34.*",
- "utopia-php/queue": "0.17.*",
- "utopia-php/servers": "0.3.*"
+ "utopia-php/http": "^2.0@RC",
+ "utopia-php/queue": "0.18.*",
+ "utopia-php/servers": "0.4.*"
},
"require-dev": {
"laravel/pint": "1.2.*",
@@ -4687,9 +4818,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/platform/issues",
- "source": "https://github.com/utopia-php/platform/tree/0.12.1"
+ "source": "https://github.com/utopia-php/platform/tree/1.0.0-rc2"
},
- "time": "2026-04-08T04:11:31+00:00"
+ "time": "2026-05-15T06:19:20+00:00"
},
{
"name": "utopia-php/pools",
@@ -4798,26 +4929,71 @@
"time": "2020-10-24T07:04:59+00:00"
},
{
- "name": "utopia-php/queue",
- "version": "0.17.0",
+ "name": "utopia-php/query",
+ "version": "0.1.1",
"source": {
"type": "git",
- "url": "https://github.com/utopia-php/queue.git",
- "reference": "0fbc7d7312f5cf76ec112513fb93317000901f5f"
+ "url": "https://github.com/utopia-php/query.git",
+ "reference": "964a10ed3185490505f4c0062f2eb7b89287fb27"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/queue/zipball/0fbc7d7312f5cf76ec112513fb93317000901f5f",
- "reference": "0fbc7d7312f5cf76ec112513fb93317000901f5f",
+ "url": "https://api.github.com/repos/utopia-php/query/zipball/964a10ed3185490505f4c0062f2eb7b89287fb27",
+ "reference": "964a10ed3185490505f4c0062f2eb7b89287fb27",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "laravel/pint": "*",
+ "phpstan/phpstan": "*",
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Utopia\\Query\\": "src/Query"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A simple library providing a query abstraction for filtering, ordering, and pagination",
+ "keywords": [
+ "framework",
+ "php",
+ "query",
+ "upf",
+ "utopia"
+ ],
+ "support": {
+ "issues": "https://github.com/utopia-php/query/issues",
+ "source": "https://github.com/utopia-php/query/tree/0.1.1"
+ },
+ "time": "2026-03-03T09:05:14+00:00"
+ },
+ {
+ "name": "utopia-php/queue",
+ "version": "0.18.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/utopia-php/queue.git",
+ "reference": "141aad162b90728353f3aa834684b1f2affed045"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/utopia-php/queue/zipball/141aad162b90728353f3aa834684b1f2affed045",
+ "reference": "141aad162b90728353f3aa834684b1f2affed045",
"shasum": ""
},
"require": {
"php": ">=8.3",
"php-amqplib/php-amqplib": "^3.7",
"utopia-php/di": "0.3.*",
- "utopia-php/fetch": "0.5.*",
"utopia-php/pools": "1.*",
- "utopia-php/servers": "0.3.*",
+ "utopia-php/servers": "0.4.0",
"utopia-php/telemetry": "0.2.*",
"utopia-php/validators": "0.2.*"
},
@@ -4860,9 +5036,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/queue/issues",
- "source": "https://github.com/utopia-php/queue/tree/0.17.0"
+ "source": "https://github.com/utopia-php/queue/tree/0.18.3"
},
- "time": "2026-03-23T16:21:31+00:00"
+ "time": "2026-05-14T08:53:35+00:00"
},
{
"name": "utopia-php/registry",
@@ -4918,16 +5094,16 @@
},
{
"name": "utopia-php/servers",
- "version": "0.3.0",
+ "version": "0.4.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/servers.git",
- "reference": "235be31200df9437fc96a1c270ffef4c64fafe52"
+ "reference": "7db346ef377503efe0acafe0791085270cd9ed70"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/servers/zipball/235be31200df9437fc96a1c270ffef4c64fafe52",
- "reference": "235be31200df9437fc96a1c270ffef4c64fafe52",
+ "url": "https://api.github.com/repos/utopia-php/servers/zipball/7db346ef377503efe0acafe0791085270cd9ed70",
+ "reference": "7db346ef377503efe0acafe0791085270cd9ed70",
"shasum": ""
},
"require": {
@@ -4966,9 +5142,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/servers/issues",
- "source": "https://github.com/utopia-php/servers/tree/0.3.0"
+ "source": "https://github.com/utopia-php/servers/tree/0.4.0"
},
- "time": "2026-03-13T11:31:42+00:00"
+ "time": "2026-05-05T04:08:30+00:00"
},
{
"name": "utopia-php/span",
@@ -5016,16 +5192,16 @@
},
{
"name": "utopia-php/storage",
- "version": "1.0.1",
+ "version": "2.0.3",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/storage.git",
- "reference": "f014be445f0baa635d0764e1673196f412511618"
+ "reference": "37129cf0bfcc03210172000e4388d4d3495ae013"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/storage/zipball/f014be445f0baa635d0764e1673196f412511618",
- "reference": "f014be445f0baa635d0764e1673196f412511618",
+ "url": "https://api.github.com/repos/utopia-php/storage/zipball/37129cf0bfcc03210172000e4388d4d3495ae013",
+ "reference": "37129cf0bfcc03210172000e4388d4d3495ae013",
"shasum": ""
},
"require": {
@@ -5039,9 +5215,8 @@
"utopia-php/validators": "0.2.*"
},
"require-dev": {
- "laravel/pint": "1.2.*",
- "phpunit/phpunit": "^9.3",
- "vimeo/psalm": "4.0.1"
+ "laravel/pint": "^1.21",
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"autoload": {
@@ -5063,22 +5238,22 @@
],
"support": {
"issues": "https://github.com/utopia-php/storage/issues",
- "source": "https://github.com/utopia-php/storage/tree/1.0.1"
+ "source": "https://github.com/utopia-php/storage/tree/2.0.3"
},
- "time": "2026-02-23T05:59:32+00:00"
+ "time": "2026-05-15T09:42:32+00:00"
},
{
"name": "utopia-php/system",
- "version": "0.10.1",
+ "version": "0.10.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/system.git",
- "reference": "7c1669533bb9c285de19191270c8c1439161a78a"
+ "reference": "04229a822b147c1abaf1a92fb42c2d7aad4625df"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/system/zipball/7c1669533bb9c285de19191270c8c1439161a78a",
- "reference": "7c1669533bb9c285de19191270c8c1439161a78a",
+ "url": "https://api.github.com/repos/utopia-php/system/zipball/04229a822b147c1abaf1a92fb42c2d7aad4625df",
+ "reference": "04229a822b147c1abaf1a92fb42c2d7aad4625df",
"shasum": ""
},
"require": {
@@ -5119,9 +5294,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/system/issues",
- "source": "https://github.com/utopia-php/system/tree/0.10.1"
+ "source": "https://github.com/utopia-php/system/tree/0.10.2"
},
- "time": "2026-03-15T21:07:41+00:00"
+ "time": "2026-05-05T14:33:41+00:00"
},
{
"name": "utopia-php/telemetry",
@@ -5180,16 +5355,16 @@
},
{
"name": "utopia-php/validators",
- "version": "0.2.0",
+ "version": "0.2.4",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/validators.git",
- "reference": "30b6030a5b100fc1dff34506e5053759594b2a20"
+ "reference": "b4ee60db4dbae5ffbe53968d01f69b6941251576"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/validators/zipball/30b6030a5b100fc1dff34506e5053759594b2a20",
- "reference": "30b6030a5b100fc1dff34506e5053759594b2a20",
+ "url": "https://api.github.com/repos/utopia-php/validators/zipball/b4ee60db4dbae5ffbe53968d01f69b6941251576",
+ "reference": "b4ee60db4dbae5ffbe53968d01f69b6941251576",
"shasum": ""
},
"require": {
@@ -5219,29 +5394,29 @@
],
"support": {
"issues": "https://github.com/utopia-php/validators/issues",
- "source": "https://github.com/utopia-php/validators/tree/0.2.0"
+ "source": "https://github.com/utopia-php/validators/tree/0.2.4"
},
- "time": "2026-01-13T09:16:51+00:00"
+ "time": "2026-05-21T12:47:43+00:00"
},
{
"name": "utopia-php/vcs",
- "version": "3.2.0",
+ "version": "4.2.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/vcs.git",
- "reference": "44a84ab52b42fc12f812b4d7331286b519d39db3"
+ "reference": "49d7751f0ae94634b00057177d9823928f6777c6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/vcs/zipball/44a84ab52b42fc12f812b4d7331286b519d39db3",
- "reference": "44a84ab52b42fc12f812b4d7331286b519d39db3",
+ "url": "https://api.github.com/repos/utopia-php/vcs/zipball/49d7751f0ae94634b00057177d9823928f6777c6",
+ "reference": "49d7751f0ae94634b00057177d9823928f6777c6",
"shasum": ""
},
"require": {
"adhocore/jwt": "^1.1",
- "php": ">=8.0",
- "utopia-php/cache": "1.0.*",
- "utopia-php/fetch": "0.5.*"
+ "php": ">=8.2",
+ "utopia-php/cache": "^3.0",
+ "utopia-php/fetch": "^1.1"
},
"require-dev": {
"laravel/pint": "1.*.*",
@@ -5268,9 +5443,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/vcs/issues",
- "source": "https://github.com/utopia-php/vcs/tree/3.2.0"
+ "source": "https://github.com/utopia-php/vcs/tree/4.2.0"
},
- "time": "2026-04-08T16:00:31+00:00"
+ "time": "2026-05-17T15:58:27+00:00"
},
{
"name": "utopia-php/websocket",
@@ -5381,16 +5556,16 @@
},
{
"name": "webonyx/graphql-php",
- "version": "v15.31.5",
+ "version": "v15.32.3",
"source": {
"type": "git",
"url": "https://github.com/webonyx/graphql-php.git",
- "reference": "089c4ef7e112df85788cfe06596278a8f99f4aa9"
+ "reference": "993bf0bea17f870412ad8a90f60c41cb8d5f1145"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/089c4ef7e112df85788cfe06596278a8f99f4aa9",
- "reference": "089c4ef7e112df85788cfe06596278a8f99f4aa9",
+ "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/993bf0bea17f870412ad8a90f60c41cb8d5f1145",
+ "reference": "993bf0bea17f870412ad8a90f60c41cb8d5f1145",
"shasum": ""
},
"require": {
@@ -5399,16 +5574,16 @@
"php": "^7.4 || ^8"
},
"require-dev": {
- "amphp/amp": "^2.6",
- "amphp/http-server": "^2.1",
+ "amphp/amp": "^2.6 || ^3",
+ "amphp/http-server": "^2.1 || ^3",
"dms/phpunit-arraysubset-asserts": "dev-master",
"ergebnis/composer-normalize": "^2.28",
- "friendsofphp/php-cs-fixer": "3.94.2",
+ "friendsofphp/php-cs-fixer": "3.95.1",
"mll-lab/php-cs-fixer-config": "5.13.0",
"nyholm/psr7": "^1.5",
"phpbench/phpbench": "^1.2",
"phpstan/extension-installer": "^1.1",
- "phpstan/phpstan": "2.1.46",
+ "phpstan/phpstan": "2.1.51",
"phpstan/phpstan-phpunit": "2.0.16",
"phpstan/phpstan-strict-rules": "2.0.10",
"phpunit/phpunit": "^9.5 || ^10.5.21 || ^11",
@@ -5422,6 +5597,7 @@
"ticketswap/phpstan-error-formatter": "1.3.0"
},
"suggest": {
+ "amphp/amp": "To leverage async resolving on AMPHP platform (v3 with AmpFutureAdapter, v2 with AmpPromiseAdapter)",
"amphp/http-server": "To leverage async resolving with webserver on AMPHP platform",
"psr/http-message": "To use standard GraphQL server",
"react/promise": "To leverage async resolving on React PHP platform"
@@ -5444,7 +5620,7 @@
],
"support": {
"issues": "https://github.com/webonyx/graphql-php/issues",
- "source": "https://github.com/webonyx/graphql-php/tree/v15.31.5"
+ "source": "https://github.com/webonyx/graphql-php/tree/v15.32.3"
},
"funding": [
{
@@ -5456,22 +5632,22 @@
"type": "open_collective"
}
],
- "time": "2026-04-11T18:06:15+00:00"
+ "time": "2026-04-24T13:49:35+00:00"
}
],
"packages-dev": [
{
"name": "appwrite/sdk-generator",
- "version": "1.17.11",
+ "version": "1.31.1",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-generator.git",
- "reference": "c714ee52659ef5968b3372ff4da0e407140a6250"
+ "reference": "5699f6da951aef9378fabdcf12f40a9a54fb3128"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/c714ee52659ef5968b3372ff4da0e407140a6250",
- "reference": "c714ee52659ef5968b3372ff4da0e407140a6250",
+ "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/5699f6da951aef9378fabdcf12f40a9a54fb3128",
+ "reference": "5699f6da951aef9378fabdcf12f40a9a54fb3128",
"shasum": ""
},
"require": {
@@ -5480,7 +5656,7 @@
"ext-mbstring": "*",
"matthiasmullie/minify": "1.3.*",
"php": ">=8.3",
- "twig/twig": "3.14.*"
+ "twig/twig": "3.26.*"
},
"require-dev": {
"brianium/paratest": "7.*",
@@ -5507,9 +5683,9 @@
"description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms",
"support": {
"issues": "https://github.com/appwrite/sdk-generator/issues",
- "source": "https://github.com/appwrite/sdk-generator/tree/1.17.11"
+ "source": "https://github.com/appwrite/sdk-generator/tree/1.31.1"
},
- "time": "2026-04-11T02:42:32+00:00"
+ "time": "2026-05-20T22:22:59+00:00"
},
{
"name": "brianium/paratest",
@@ -5791,16 +5967,16 @@
},
{
"name": "laravel/pint",
- "version": "v1.29.0",
+ "version": "v1.29.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/pint.git",
- "reference": "bdec963f53172c5e36330f3a400604c69bf02d39"
+ "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39",
- "reference": "bdec963f53172c5e36330f3a400604c69bf02d39",
+ "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80",
+ "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80",
"shasum": ""
},
"require": {
@@ -5811,14 +5987,14 @@
"php": "^8.2.0"
},
"require-dev": {
- "friendsofphp/php-cs-fixer": "^3.94.2",
- "illuminate/view": "^12.54.1",
- "larastan/larastan": "^3.9.3",
- "laravel-zero/framework": "^12.0.5",
+ "friendsofphp/php-cs-fixer": "^3.95.1",
+ "illuminate/view": "^12.56.0",
+ "larastan/larastan": "^3.9.6",
+ "laravel-zero/framework": "^12.1.0",
"mockery/mockery": "^1.6.12",
"nunomaduro/termwind": "^2.4.0",
"pestphp/pest": "^3.8.6",
- "shipfastlabs/agent-detector": "^1.1.0"
+ "shipfastlabs/agent-detector": "^1.1.3"
},
"bin": [
"builds/pint"
@@ -5855,7 +6031,7 @@
"issues": "https://github.com/laravel/pint/issues",
"source": "https://github.com/laravel/pint"
},
- "time": "2026-03-12T15:51:39+00:00"
+ "time": "2026-04-20T15:26:14+00:00"
},
{
"name": "matthiasmullie/minify",
@@ -6218,11 +6394,11 @@
},
{
"name": "phpstan/phpstan",
- "version": "2.1.46",
+ "version": "2.1.55",
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25",
- "reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9eaac3826ed5e9b8427350a43cac825eeca3f566",
+ "reference": "9eaac3826ed5e9b8427350a43cac825eeca3f566",
"shasum": ""
},
"require": {
@@ -6267,20 +6443,20 @@
"type": "github"
}
],
- "time": "2026-04-01T09:25:14+00:00"
+ "time": "2026-05-18T11:57:34+00:00"
},
{
"name": "phpunit/php-code-coverage",
- "version": "12.5.3",
+ "version": "12.5.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
- "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d"
+ "reference": "876099a072646c7745f673d7aeab5382c4439691"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d",
- "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691",
+ "reference": "876099a072646c7745f673d7aeab5382c4439691",
"shasum": ""
},
"require": {
@@ -6289,7 +6465,6 @@
"ext-xmlwriter": "*",
"nikic/php-parser": "^5.7.0",
"php": ">=8.3",
- "phpunit/php-file-iterator": "^6.0",
"phpunit/php-text-template": "^5.0",
"sebastian/complexity": "^5.0",
"sebastian/environment": "^8.0.3",
@@ -6336,7 +6511,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
- "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3"
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6"
},
"funding": [
{
@@ -6356,7 +6531,7 @@
"type": "tidelift"
}
],
- "time": "2026-02-06T06:01:44+00:00"
+ "time": "2026-04-15T08:23:17+00:00"
},
{
"name": "phpunit/php-file-iterator",
@@ -6617,16 +6792,16 @@
},
{
"name": "phpunit/phpunit",
- "version": "12.5.17",
+ "version": "12.5.25",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "85b62adab1a340982df64e66daa4a4435eb5723b"
+ "reference": "792c2980442dfce319226b88fa845b8b6de3b333"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/85b62adab1a340982df64e66daa4a4435eb5723b",
- "reference": "85b62adab1a340982df64e66daa4a4435eb5723b",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/792c2980442dfce319226b88fa845b8b6de3b333",
+ "reference": "792c2980442dfce319226b88fa845b8b6de3b333",
"shasum": ""
},
"require": {
@@ -6640,15 +6815,15 @@
"phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1",
"php": ">=8.3",
- "phpunit/php-code-coverage": "^12.5.3",
+ "phpunit/php-code-coverage": "^12.5.6",
"phpunit/php-file-iterator": "^6.0.1",
"phpunit/php-invoker": "^6.0.0",
"phpunit/php-text-template": "^5.0.0",
"phpunit/php-timer": "^8.0.0",
"sebastian/cli-parser": "^4.2.0",
- "sebastian/comparator": "^7.1.4",
+ "sebastian/comparator": "^7.1.6",
"sebastian/diff": "^7.0.0",
- "sebastian/environment": "^8.0.4",
+ "sebastian/environment": "^8.1.0",
"sebastian/exporter": "^7.0.2",
"sebastian/global-state": "^8.0.2",
"sebastian/object-enumerator": "^7.0.0",
@@ -6695,7 +6870,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.17"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.25"
},
"funding": [
{
@@ -6703,27 +6878,27 @@
"type": "other"
}
],
- "time": "2026-04-08T03:04:19+00:00"
+ "time": "2026-05-13T03:56:57+00:00"
},
{
"name": "sebastian/cli-parser",
- "version": "4.2.0",
+ "version": "4.2.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/cli-parser.git",
- "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04"
+ "reference": "7d05781b13f7dec9043a629a21d086ed74582a15"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04",
- "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15",
+ "reference": "7d05781b13f7dec9043a629a21d086ed74582a15",
"shasum": ""
},
"require": {
"php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^12.0"
+ "phpunit/phpunit": "^12.5.25"
},
"type": "library",
"extra": {
@@ -6752,7 +6927,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/cli-parser/issues",
"security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
- "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0"
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1"
},
"funding": [
{
@@ -6772,20 +6947,20 @@
"type": "tidelift"
}
],
- "time": "2025-09-14T09:36:45+00:00"
+ "time": "2026-05-17T05:29:34+00:00"
},
{
"name": "sebastian/comparator",
- "version": "7.1.5",
+ "version": "7.1.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
- "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63"
+ "reference": "c769009dee98f494e0edc3fd4f4087501688f11e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c284f55811f43d555e51e8e5c166ac40d3e33c63",
- "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c769009dee98f494e0edc3fd4f4087501688f11e",
+ "reference": "c769009dee98f494e0edc3fd4f4087501688f11e",
"shasum": ""
},
"require": {
@@ -6844,7 +7019,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues",
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
- "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.5"
+ "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.6"
},
"funding": [
{
@@ -6864,7 +7039,7 @@
"type": "tidelift"
}
],
- "time": "2026-04-08T04:43:00+00:00"
+ "time": "2026-04-14T08:23:15+00:00"
},
{
"name": "sebastian/complexity",
@@ -6993,16 +7168,16 @@
},
{
"name": "sebastian/environment",
- "version": "8.0.4",
+ "version": "8.1.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/environment.git",
- "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11"
+ "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/7b8842c2d8e85d0c3a5831236bf5869af6ab2a11",
- "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6",
+ "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6",
"shasum": ""
},
"require": {
@@ -7017,7 +7192,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "8.0-dev"
+ "dev-main": "8.1-dev"
}
},
"autoload": {
@@ -7045,7 +7220,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/environment/issues",
"security": "https://github.com/sebastianbergmann/environment/security/policy",
- "source": "https://github.com/sebastianbergmann/environment/tree/8.0.4"
+ "source": "https://github.com/sebastianbergmann/environment/tree/8.1.0"
},
"funding": [
{
@@ -7065,29 +7240,29 @@
"type": "tidelift"
}
],
- "time": "2026-03-15T07:05:40+00:00"
+ "time": "2026-04-15T12:13:01+00:00"
},
{
"name": "sebastian/exporter",
- "version": "7.0.2",
+ "version": "7.0.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/exporter.git",
- "reference": "016951ae10980765e4e7aee491eb288c64e505b7"
+ "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7",
- "reference": "016951ae10980765e4e7aee491eb288c64e505b7",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23",
+ "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": ">=8.3",
- "sebastian/recursion-context": "^7.0"
+ "sebastian/recursion-context": "^7.0.1"
},
"require-dev": {
- "phpunit/phpunit": "^12.0"
+ "phpunit/phpunit": "^12.5.25"
},
"type": "library",
"extra": {
@@ -7135,7 +7310,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/exporter/issues",
"security": "https://github.com/sebastianbergmann/exporter/security/policy",
- "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2"
+ "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3"
},
"funding": [
{
@@ -7155,7 +7330,7 @@
"type": "tidelift"
}
],
- "time": "2025-09-24T06:16:11+00:00"
+ "time": "2026-05-20T04:37:17+00:00"
},
{
"name": "sebastian/global-state",
@@ -7233,24 +7408,24 @@
},
{
"name": "sebastian/lines-of-code",
- "version": "4.0.0",
+ "version": "4.0.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/lines-of-code.git",
- "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f"
+ "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f",
- "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e",
+ "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e",
"shasum": ""
},
"require": {
- "nikic/php-parser": "^5.0",
+ "nikic/php-parser": "^5.7.0",
"php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^12.0"
+ "phpunit/phpunit": "^12.5.25"
},
"type": "library",
"extra": {
@@ -7279,15 +7454,27 @@
"support": {
"issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
"security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
- "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0"
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code",
+ "type": "tidelift"
}
],
- "time": "2025-02-07T04:57:28+00:00"
+ "time": "2026-05-19T16:22:07+00:00"
},
{
"name": "sebastian/object-enumerator",
@@ -7481,23 +7668,23 @@
},
{
"name": "sebastian/type",
- "version": "6.0.3",
+ "version": "6.0.4",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/type.git",
- "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d"
+ "reference": "82ff822c2edc46724be9f7411d3163021f602773"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d",
- "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773",
+ "reference": "82ff822c2edc46724be9f7411d3163021f602773",
"shasum": ""
},
"require": {
"php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^12.0"
+ "phpunit/phpunit": "^12.5.25"
},
"type": "library",
"extra": {
@@ -7526,7 +7713,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/type/issues",
"security": "https://github.com/sebastianbergmann/type/security/policy",
- "source": "https://github.com/sebastianbergmann/type/tree/6.0.3"
+ "source": "https://github.com/sebastianbergmann/type/tree/6.0.4"
},
"funding": [
{
@@ -7546,7 +7733,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-09T06:57:12+00:00"
+ "time": "2026-05-20T06:45:45+00:00"
},
{
"name": "sebastian/version",
@@ -7688,16 +7875,16 @@
},
{
"name": "symfony/console",
- "version": "v8.0.8",
+ "version": "v8.0.11",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7"
+ "reference": "3156577f46a38aa1b9323aad223de7a9cd426782"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7",
- "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7",
+ "url": "https://api.github.com/repos/symfony/console/zipball/3156577f46a38aa1b9323aad223de7a9cd426782",
+ "reference": "3156577f46a38aa1b9323aad223de7a9cd426782",
"shasum": ""
},
"require": {
@@ -7754,7 +7941,7 @@
"terminal"
],
"support": {
- "source": "https://github.com/symfony/console/tree/v8.0.8"
+ "source": "https://github.com/symfony/console/tree/v8.0.11"
},
"funding": [
{
@@ -7774,11 +7961,11 @@
"type": "tidelift"
}
],
- "time": "2026-03-30T15:14:47+00:00"
+ "time": "2026-05-13T12:07:53+00:00"
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.34.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
@@ -7837,7 +8024,7 @@
"portable"
],
"support": {
- "source": "https://github.com/symfony/polyfill-ctype/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
},
"funding": [
{
@@ -7861,16 +8048,16 @@
},
{
"name": "symfony/polyfill-intl-grapheme",
- "version": "v1.34.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
- "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df"
+ "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/ad1b7b9092976d6c948b8a187cec9faaea9ec1df",
- "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e",
+ "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e",
"shasum": ""
},
"require": {
@@ -7919,7 +8106,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0"
},
"funding": [
{
@@ -7939,11 +8126,11 @@
"type": "tidelift"
}
],
- "time": "2026-04-10T16:19:22+00:00"
+ "time": "2026-04-26T13:13:48+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.34.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
@@ -8004,87 +8191,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.34.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://github.com/nicolas-grekas",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2024-09-09T11:45:10+00:00"
- },
- {
- "name": "symfony/polyfill-php81",
- "version": "v1.34.0",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/polyfill-php81.git",
- "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
- "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
- "shasum": ""
- },
- "require": {
- "php": ">=7.2"
- },
- "type": "library",
- "extra": {
- "thanks": {
- "url": "https://github.com/symfony/polyfill",
- "name": "symfony/polyfill"
- }
- },
- "autoload": {
- "files": [
- "bootstrap.php"
- ],
- "psr-4": {
- "Symfony\\Polyfill\\Php81\\": ""
- },
- "classmap": [
- "Resources/stubs"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions",
- "homepage": "https://symfony.com",
- "keywords": [
- "compatibility",
- "polyfill",
- "portable",
- "shim"
- ],
- "support": {
- "source": "https://github.com/symfony/polyfill-php81/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0"
},
"funding": [
{
@@ -8108,16 +8215,16 @@
},
{
"name": "symfony/process",
- "version": "v8.0.8",
+ "version": "v8.0.11",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc"
+ "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc",
- "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc",
+ "url": "https://api.github.com/repos/symfony/process/zipball/26d89e459f037d2873300605d0a07e7a8ef84db0",
+ "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0",
"shasum": ""
},
"require": {
@@ -8149,7 +8256,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/process/tree/v8.0.8"
+ "source": "https://github.com/symfony/process/tree/v8.0.11"
},
"funding": [
{
@@ -8169,20 +8276,20 @@
"type": "tidelift"
}
],
- "time": "2026-03-30T15:14:47+00:00"
+ "time": "2026-05-11T16:56:32+00:00"
},
{
"name": "symfony/string",
- "version": "v8.0.8",
+ "version": "v8.0.11",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
- "reference": "ae9488f874d7603f9d2dfbf120203882b645d963"
+ "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963",
- "reference": "ae9488f874d7603f9d2dfbf120203882b645d963",
+ "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff",
+ "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff",
"shasum": ""
},
"require": {
@@ -8239,7 +8346,7 @@
"utf8"
],
"support": {
- "source": "https://github.com/symfony/string/tree/v8.0.8"
+ "source": "https://github.com/symfony/string/tree/v8.0.11"
},
"funding": [
{
@@ -8259,7 +8366,7 @@
"type": "tidelift"
}
],
- "time": "2026-03-30T15:14:47+00:00"
+ "time": "2026-05-13T12:07:53+00:00"
},
{
"name": "textalk/websocket",
@@ -8362,26 +8469,27 @@
},
{
"name": "twig/twig",
- "version": "v3.14.2",
+ "version": "v3.26.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/Twig.git",
- "reference": "0b6f9d8370bb3b7f1ce5313ed8feb0fafd6e399a"
+ "reference": "1fcae487b180d78e6351f4e0afa91f9eab96a2bc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/twigphp/Twig/zipball/0b6f9d8370bb3b7f1ce5313ed8feb0fafd6e399a",
- "reference": "0b6f9d8370bb3b7f1ce5313ed8feb0fafd6e399a",
+ "url": "https://api.github.com/repos/twigphp/Twig/zipball/1fcae487b180d78e6351f4e0afa91f9eab96a2bc",
+ "reference": "1fcae487b180d78e6351f4e0afa91f9eab96a2bc",
"shasum": ""
},
"require": {
- "php": ">=8.0.2",
+ "php": ">=8.1.0",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "^1.8",
- "symfony/polyfill-mbstring": "^1.3",
- "symfony/polyfill-php81": "^1.29"
+ "symfony/polyfill-mbstring": "^1.3"
},
"require-dev": {
+ "php-cs-fixer/shim": "^3.0@stable",
+ "phpstan/phpstan": "^2.0@stable",
"psr/container": "^1.0|^2.0",
"symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0"
},
@@ -8425,7 +8533,7 @@
],
"support": {
"issues": "https://github.com/twigphp/Twig/issues",
- "source": "https://github.com/twigphp/Twig/tree/v3.14.2"
+ "source": "https://github.com/twigphp/Twig/tree/v3.26.0"
},
"funding": [
{
@@ -8437,7 +8545,7 @@
"type": "tidelift"
}
],
- "time": "2024-11-07T12:36:22+00:00"
+ "time": "2026-05-20T07:31:59+00:00"
}
],
"aliases": [],
diff --git a/docker-compose.yml b/docker-compose.yml
index aa2bfdd16a..76f06c672a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -242,19 +242,20 @@ services:
- _APP_EXPERIMENT_LOGGING_PROVIDER
- _APP_EXPERIMENT_LOGGING_CONFIG
- _APP_DATABASE_SHARED_TABLES
- - _APP_DATABASE_SHARED_TABLES_V1
- _APP_DATABASE_SHARED_NAMESPACE
- _APP_FUNCTIONS_CREATION_ABUSE_LIMIT
- _APP_CUSTOM_DOMAIN_DENY_LIST
- _APP_TRUSTED_HEADERS
- _APP_MIGRATION_HOST
+ - _TESTS_OAUTH2_GITHUB_CLIENT_ID
+ - _TESTS_OAUTH2_GITHUB_CLIENT_SECRET
extra_hosts:
- "host.docker.internal:host-gateway"
appwrite-console:
<<: *x-logging
container_name: appwrite-console
- image: appwrite/console:7.8.26
+ image: appwrite/console:8
restart: unless-stopped
networks:
- appwrite
@@ -462,7 +463,6 @@ services:
- _APP_EXECUTOR_SECRET
- _APP_EXECUTOR_HOST
- _APP_DATABASE_SHARED_TABLES
- - _APP_DATABASE_SHARED_TABLES_V1
- _APP_EMAIL_CERTIFICATES
- _APP_MAINTENANCE_RETENTION_AUDIT
- _APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE
@@ -1114,6 +1114,13 @@ services:
- _APP_WORKER_PER_CORE
- _APP_POOL_ADAPTER
- _APP_OPENSSL_KEY_V1
+ - _APP_OPTIONS_FORCE_HTTPS
+ - _APP_DOMAIN
+ - _APP_CONSOLE_DOMAIN
+ - _APP_DOMAIN_FUNCTIONS
+ - _APP_DOMAIN_SITES
+ - _APP_MIGRATION_HOST
+ - _APP_CONSOLE_SCHEMA
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -1145,6 +1152,13 @@ services:
- _APP_WORKER_PER_CORE
- _APP_POOL_ADAPTER
- _APP_OPENSSL_KEY_V1
+ - _APP_OPTIONS_FORCE_HTTPS
+ - _APP_DOMAIN
+ - _APP_CONSOLE_DOMAIN
+ - _APP_DOMAIN_FUNCTIONS
+ - _APP_DOMAIN_SITES
+ - _APP_MIGRATION_HOST
+ - _APP_CONSOLE_SCHEMA
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -1288,6 +1302,7 @@ services:
image: mongo:8.2.5
container_name: appwrite-mongodb
<<: *x-logging
+ restart: on-failure:3
networks:
- appwrite
volumes:
@@ -1477,4 +1492,4 @@ volumes:
appwrite-sites:
appwrite-builds:
appwrite-config:
- appwrite-models:
\ No newline at end of file
+ appwrite-models:
diff --git a/docs/references/account/create-2fa-challenge.md b/docs/references/account/create-2fa-challenge.md
deleted file mode 100644
index ee6ef2f2ac..0000000000
--- a/docs/references/account/create-2fa-challenge.md
+++ /dev/null
@@ -1 +0,0 @@
-Initialize an MFA challenge of the specified factor. The factor must be available on the account.
\ No newline at end of file
diff --git a/docs/references/account/delete-session-current.md b/docs/references/account/delete-session-current.md
deleted file mode 100644
index d38520f479..0000000000
--- a/docs/references/account/delete-session-current.md
+++ /dev/null
@@ -1 +0,0 @@
-Use this endpoint to log out the currently logged in user from their account. When successful this endpoint will delete the user session and remove the session secret cookie from the user client.
\ No newline at end of file
diff --git a/docs/references/advisor/delete-report.md b/docs/references/advisor/delete-report.md
new file mode 100644
index 0000000000..b32ba845e2
--- /dev/null
+++ b/docs/references/advisor/delete-report.md
@@ -0,0 +1 @@
+Delete an analyzer report by its unique ID. Nested insights and CTA metadata are removed asynchronously by the deletes worker.
diff --git a/docs/references/advisor/get-insight.md b/docs/references/advisor/get-insight.md
new file mode 100644
index 0000000000..7e1e795c22
--- /dev/null
+++ b/docs/references/advisor/get-insight.md
@@ -0,0 +1 @@
+Get an insight by its unique ID, scoped to its parent report.
diff --git a/docs/references/advisor/get-report.md b/docs/references/advisor/get-report.md
new file mode 100644
index 0000000000..731c10dc8a
--- /dev/null
+++ b/docs/references/advisor/get-report.md
@@ -0,0 +1 @@
+Get an analyzer report by its unique ID. The response includes the report's metadata and the nested insights it produced.
diff --git a/docs/references/advisor/list-insights.md b/docs/references/advisor/list-insights.md
new file mode 100644
index 0000000000..56d6a2fca0
--- /dev/null
+++ b/docs/references/advisor/list-insights.md
@@ -0,0 +1 @@
+List the insights produced under a single analyzer report. You can use the query params to filter your results further.
diff --git a/docs/references/advisor/list-reports.md b/docs/references/advisor/list-reports.md
new file mode 100644
index 0000000000..04b91c541a
--- /dev/null
+++ b/docs/references/advisor/list-reports.md
@@ -0,0 +1 @@
+Get a list of all the project's analyzer reports. You can use the query params to filter your results.
diff --git a/docs/references/console/variables.md b/docs/references/console/variables.md
deleted file mode 100644
index ddfa2b9b72..0000000000
--- a/docs/references/console/variables.md
+++ /dev/null
@@ -1 +0,0 @@
-Get all Environment Variables that are relevant for the console.
\ No newline at end of file
diff --git a/docs/references/databases/create-bigint-attribute.md b/docs/references/databases/create-bigint-attribute.md
new file mode 100644
index 0000000000..6fb607304b
--- /dev/null
+++ b/docs/references/databases/create-bigint-attribute.md
@@ -0,0 +1 @@
+Create a bigint attribute. Optionally, minimum and maximum values can be provided.
diff --git a/docs/references/databases/update-bigint-attribute.md b/docs/references/databases/update-bigint-attribute.md
new file mode 100644
index 0000000000..4a301c2216
--- /dev/null
+++ b/docs/references/databases/update-bigint-attribute.md
@@ -0,0 +1 @@
+Update a bigint attribute. Changing the `default` value will not update already existing documents.
diff --git a/docs/references/documentsdb/get-collection-logs.md b/docs/references/documentsdb/get-collection-logs.md
deleted file mode 100644
index 8578cef03c..0000000000
--- a/docs/references/documentsdb/get-collection-logs.md
+++ /dev/null
@@ -1 +0,0 @@
-Get the collection activity logs list by its unique ID.
\ No newline at end of file
diff --git a/docs/references/documentsdb/get-document-logs.md b/docs/references/documentsdb/get-document-logs.md
deleted file mode 100644
index 9b96df5ad4..0000000000
--- a/docs/references/documentsdb/get-document-logs.md
+++ /dev/null
@@ -1 +0,0 @@
-Get the document activity logs list by its unique ID.
\ No newline at end of file
diff --git a/docs/references/documentsdb/list-attributes.md b/docs/references/documentsdb/list-attributes.md
deleted file mode 100644
index 72ad6d727f..0000000000
--- a/docs/references/documentsdb/list-attributes.md
+++ /dev/null
@@ -1 +0,0 @@
-List attributes in the collection.
\ No newline at end of file
diff --git a/docs/references/functions/create-build.md b/docs/references/functions/create-build.md
deleted file mode 100644
index 160a04c291..0000000000
--- a/docs/references/functions/create-build.md
+++ /dev/null
@@ -1 +0,0 @@
-Create a new build for an existing function deployment. This endpoint allows you to rebuild a deployment with the updated function configuration, including its entrypoint and build commands if they have been modified. The build process will be queued and executed asynchronously. The original deployment's code will be preserved and used for the new build.
\ No newline at end of file
diff --git a/docs/references/functions/create-deployment.md b/docs/references/functions/create-deployment.md
deleted file mode 100644
index 3bbdbfc848..0000000000
--- a/docs/references/functions/create-deployment.md
+++ /dev/null
@@ -1,5 +0,0 @@
-Create a new function code deployment. Use this endpoint to upload a new version of your code function. To execute your newly uploaded code, you'll need to update the function's deployment to use your new deployment UID.
-
-This endpoint accepts a tar.gz file compressed with your code. Make sure to include any dependencies your code has within the compressed file. You can learn more about code packaging in the [Appwrite Cloud Functions tutorial](https://appwrite.io/docs/functions).
-
-Use the "command" param to set the entrypoint used to execute your code.
\ No newline at end of file
diff --git a/docs/references/functions/create-execution.md b/docs/references/functions/create-execution.md
deleted file mode 100644
index 6089c4ff01..0000000000
--- a/docs/references/functions/create-execution.md
+++ /dev/null
@@ -1 +0,0 @@
-Trigger a function execution. The returned object will return you the current execution status. You can ping the `Get Execution` endpoint to get updates on the current execution status. Once this endpoint is called, your function execution process will start asynchronously.
\ No newline at end of file
diff --git a/docs/references/functions/create-function.md b/docs/references/functions/create-function.md
deleted file mode 100644
index 1ac9143f45..0000000000
--- a/docs/references/functions/create-function.md
+++ /dev/null
@@ -1 +0,0 @@
-Create a new function. You can pass a list of [permissions](https://appwrite.io/docs/permissions) to allow different project users or team with access to execute the function using the client API.
\ No newline at end of file
diff --git a/docs/references/functions/create-variable.md b/docs/references/functions/create-variable.md
deleted file mode 100644
index 40fabd75a8..0000000000
--- a/docs/references/functions/create-variable.md
+++ /dev/null
@@ -1 +0,0 @@
-Create a new function environment variable. These variables can be accessed in the function at runtime as environment variables.
\ No newline at end of file
diff --git a/docs/references/functions/delete-deployment.md b/docs/references/functions/delete-deployment.md
deleted file mode 100644
index 19c74965bd..0000000000
--- a/docs/references/functions/delete-deployment.md
+++ /dev/null
@@ -1 +0,0 @@
-Delete a code deployment by its unique ID.
\ No newline at end of file
diff --git a/docs/references/functions/delete-execution.md b/docs/references/functions/delete-execution.md
deleted file mode 100644
index d7cad98ac1..0000000000
--- a/docs/references/functions/delete-execution.md
+++ /dev/null
@@ -1 +0,0 @@
-Delete a function execution by its unique ID.
diff --git a/docs/references/functions/delete-function.md b/docs/references/functions/delete-function.md
deleted file mode 100644
index 92835e3c82..0000000000
--- a/docs/references/functions/delete-function.md
+++ /dev/null
@@ -1 +0,0 @@
-Delete a function by its unique ID.
\ No newline at end of file
diff --git a/docs/references/functions/delete-variable.md b/docs/references/functions/delete-variable.md
deleted file mode 100644
index 9b1326d96f..0000000000
--- a/docs/references/functions/delete-variable.md
+++ /dev/null
@@ -1 +0,0 @@
-Delete a variable by its unique ID.
\ No newline at end of file
diff --git a/docs/references/functions/get-deployment-download.md b/docs/references/functions/get-deployment-download.md
deleted file mode 100644
index e662ae2733..0000000000
--- a/docs/references/functions/get-deployment-download.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a Deployment's contents by its unique ID. This endpoint supports range requests for partial or streaming file download.
\ No newline at end of file
diff --git a/docs/references/functions/get-deployment.md b/docs/references/functions/get-deployment.md
deleted file mode 100644
index 6d73976eb1..0000000000
--- a/docs/references/functions/get-deployment.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a code deployment by its unique ID.
\ No newline at end of file
diff --git a/docs/references/functions/get-execution.md b/docs/references/functions/get-execution.md
deleted file mode 100644
index fc38260bdb..0000000000
--- a/docs/references/functions/get-execution.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a function execution log by its unique ID.
\ No newline at end of file
diff --git a/docs/references/functions/get-function-usage.md b/docs/references/functions/get-function-usage.md
deleted file mode 100644
index 4498abb05b..0000000000
--- a/docs/references/functions/get-function-usage.md
+++ /dev/null
@@ -1 +0,0 @@
-Get usage metrics and statistics for a for a specific function. View statistics including total deployments, builds, executions, storage usage, and compute time. The response includes both current totals and historical data for each metric. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, defaults to 30 days.
\ No newline at end of file
diff --git a/docs/references/functions/get-function.md b/docs/references/functions/get-function.md
deleted file mode 100644
index 557ec316ba..0000000000
--- a/docs/references/functions/get-function.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a function by its unique ID.
\ No newline at end of file
diff --git a/docs/references/functions/get-functions-usage.md b/docs/references/functions/get-functions-usage.md
deleted file mode 100644
index 14427d335d..0000000000
--- a/docs/references/functions/get-functions-usage.md
+++ /dev/null
@@ -1 +0,0 @@
-Get usage metrics and statistics for a for all functions. View statistics including total functions, deployments, builds, executions, storage usage, and compute time. The response includes both current totals and historical data for each metric. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, defaults to 30 days.
\ No newline at end of file
diff --git a/docs/references/functions/get-template.md b/docs/references/functions/get-template.md
deleted file mode 100644
index ccdcce7352..0000000000
--- a/docs/references/functions/get-template.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a function template using ID. You can use template details in [createFunction](/docs/references/cloud/server-nodejs/functions#create) method.
\ No newline at end of file
diff --git a/docs/references/functions/get-variable.md b/docs/references/functions/get-variable.md
deleted file mode 100644
index f0fa853655..0000000000
--- a/docs/references/functions/get-variable.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a variable by its unique ID.
\ No newline at end of file
diff --git a/docs/references/functions/list-deployments.md b/docs/references/functions/list-deployments.md
deleted file mode 100644
index 80bbba1bf6..0000000000
--- a/docs/references/functions/list-deployments.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a list of all the function's code deployments. You can use the query params to filter your results.
\ No newline at end of file
diff --git a/docs/references/functions/list-executions.md b/docs/references/functions/list-executions.md
deleted file mode 100644
index 168c795b20..0000000000
--- a/docs/references/functions/list-executions.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a list of all the current user function execution logs. You can use the query params to filter your results.
\ No newline at end of file
diff --git a/docs/references/functions/list-functions.md b/docs/references/functions/list-functions.md
deleted file mode 100644
index 9ad432fdc0..0000000000
--- a/docs/references/functions/list-functions.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a list of all the project's functions. You can use the query params to filter your results.
\ No newline at end of file
diff --git a/docs/references/functions/list-runtimes.md b/docs/references/functions/list-runtimes.md
deleted file mode 100644
index d4d3d23b18..0000000000
--- a/docs/references/functions/list-runtimes.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a list of all runtimes that are currently active on your instance.
\ No newline at end of file
diff --git a/docs/references/functions/list-specifications.md b/docs/references/functions/list-specifications.md
deleted file mode 100644
index d65a215827..0000000000
--- a/docs/references/functions/list-specifications.md
+++ /dev/null
@@ -1 +0,0 @@
-List allowed function specifications for this instance.
diff --git a/docs/references/functions/list-templates.md b/docs/references/functions/list-templates.md
deleted file mode 100644
index ed43b9cbf4..0000000000
--- a/docs/references/functions/list-templates.md
+++ /dev/null
@@ -1 +0,0 @@
-List available function templates. You can use template details in [createFunction](/docs/references/cloud/server-nodejs/functions#create) method.
\ No newline at end of file
diff --git a/docs/references/functions/list-variables.md b/docs/references/functions/list-variables.md
deleted file mode 100644
index 68bd5e17e1..0000000000
--- a/docs/references/functions/list-variables.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a list of all variables of a specific function.
\ No newline at end of file
diff --git a/docs/references/functions/update-deployment-build.md b/docs/references/functions/update-deployment-build.md
deleted file mode 100644
index d047990adf..0000000000
--- a/docs/references/functions/update-deployment-build.md
+++ /dev/null
@@ -1 +0,0 @@
-Cancel an ongoing function deployment build. If the build is already in progress, it will be stopped and marked as canceled. If the build hasn't started yet, it will be marked as canceled without executing. You cannot cancel builds that have already completed (status 'ready') or failed. The response includes the final build status and details.
\ No newline at end of file
diff --git a/docs/references/functions/update-function-deployment.md b/docs/references/functions/update-function-deployment.md
deleted file mode 100644
index 7a85188842..0000000000
--- a/docs/references/functions/update-function-deployment.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the function code deployment ID using the unique function ID. Use this endpoint to switch the code deployment that should be executed by the execution endpoint.
\ No newline at end of file
diff --git a/docs/references/functions/update-function.md b/docs/references/functions/update-function.md
deleted file mode 100644
index 5a9a84ad94..0000000000
--- a/docs/references/functions/update-function.md
+++ /dev/null
@@ -1 +0,0 @@
-Update function by its unique ID.
\ No newline at end of file
diff --git a/docs/references/functions/update-variable.md b/docs/references/functions/update-variable.md
deleted file mode 100644
index af2c38aea2..0000000000
--- a/docs/references/functions/update-variable.md
+++ /dev/null
@@ -1 +0,0 @@
-Update variable by its unique ID.
\ No newline at end of file
diff --git a/docs/references/health/get-queue-stats-usage-dump.md b/docs/references/health/get-queue-stats-usage-dump.md
deleted file mode 100644
index 3c95da1b8a..0000000000
--- a/docs/references/health/get-queue-stats-usage-dump.md
+++ /dev/null
@@ -1 +0,0 @@
-Get the number of projects containing metrics that are waiting to be processed in the Appwrite internal queue server.
\ No newline at end of file
diff --git a/docs/references/health/get-queue-tasks.md b/docs/references/health/get-queue-tasks.md
deleted file mode 100644
index ea6fa22087..0000000000
--- a/docs/references/health/get-queue-tasks.md
+++ /dev/null
@@ -1 +0,0 @@
-Get the number of tasks that are waiting to be processed in the Appwrite internal queue server.
\ No newline at end of file
diff --git a/docs/references/health/get-queue.md b/docs/references/health/get-queue.md
deleted file mode 100644
index e4558f941f..0000000000
--- a/docs/references/health/get-queue.md
+++ /dev/null
@@ -1 +0,0 @@
-Check the Appwrite queue messaging servers are up and connection is successful.
\ No newline at end of file
diff --git a/docs/references/messaging/delete.md b/docs/references/messaging/delete.md
deleted file mode 100644
index b07d020900..0000000000
--- a/docs/references/messaging/delete.md
+++ /dev/null
@@ -1 +0,0 @@
-Delete a message by its unique ID.
\ No newline at end of file
diff --git a/docs/references/presences/delete.md b/docs/references/presences/delete.md
new file mode 100644
index 0000000000..70220709c8
--- /dev/null
+++ b/docs/references/presences/delete.md
@@ -0,0 +1 @@
+Delete a presence log by its unique ID.
diff --git a/docs/references/presences/get-usage.md b/docs/references/presences/get-usage.md
new file mode 100644
index 0000000000..efbf31ef58
--- /dev/null
+++ b/docs/references/presences/get-usage.md
@@ -0,0 +1 @@
+Get presence usage metrics, including the current total of online users and historical online user counts for the selected time range.
diff --git a/docs/references/presences/get.md b/docs/references/presences/get.md
new file mode 100644
index 0000000000..f5f4a82489
--- /dev/null
+++ b/docs/references/presences/get.md
@@ -0,0 +1 @@
+Get a presence log by its unique ID. Entries whose `expiresAt` is in the past are treated as not found.
diff --git a/docs/references/presences/list.md b/docs/references/presences/list.md
new file mode 100644
index 0000000000..b00285b361
--- /dev/null
+++ b/docs/references/presences/list.md
@@ -0,0 +1 @@
+List presence logs. Expired entries are filtered out automatically.
diff --git a/docs/references/presences/update.md b/docs/references/presences/update.md
new file mode 100644
index 0000000000..af642d3307
--- /dev/null
+++ b/docs/references/presences/update.md
@@ -0,0 +1 @@
+Update a presence log by its unique ID. Using the patch method you can pass only specific fields that will get updated.
diff --git a/docs/references/presences/upsert.md b/docs/references/presences/upsert.md
new file mode 100644
index 0000000000..873dc793f5
--- /dev/null
+++ b/docs/references/presences/upsert.md
@@ -0,0 +1 @@
+Create or update a presence log by its user ID.
diff --git a/docs/references/project/create-variable.md b/docs/references/project/create-variable.md
deleted file mode 100644
index 2bbee5bf99..0000000000
--- a/docs/references/project/create-variable.md
+++ /dev/null
@@ -1 +0,0 @@
-Create a new project variable. This variable will be accessible in all Appwrite Functions at runtime.
\ No newline at end of file
diff --git a/docs/references/project/delete-variable.md b/docs/references/project/delete-variable.md
deleted file mode 100644
index 9be15f83ca..0000000000
--- a/docs/references/project/delete-variable.md
+++ /dev/null
@@ -1 +0,0 @@
-Delete a project variable by its unique ID.
\ No newline at end of file
diff --git a/docs/references/project/get-variable.md b/docs/references/project/get-variable.md
deleted file mode 100644
index 8636768434..0000000000
--- a/docs/references/project/get-variable.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a project variable by its unique ID.
\ No newline at end of file
diff --git a/docs/references/project/list-variables.md b/docs/references/project/list-variables.md
deleted file mode 100644
index fbe191178a..0000000000
--- a/docs/references/project/list-variables.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a list of all project variables. These variables will be accessible in all Appwrite Functions at runtime.
\ No newline at end of file
diff --git a/docs/references/project/update-variable.md b/docs/references/project/update-variable.md
deleted file mode 100644
index 603622b2c7..0000000000
--- a/docs/references/project/update-variable.md
+++ /dev/null
@@ -1 +0,0 @@
-Update project variable by its unique ID. This variable will be accessible in all Appwrite Functions at runtime.
\ No newline at end of file
diff --git a/docs/references/projects/create-jwt.md b/docs/references/projects/create-jwt.md
deleted file mode 100644
index 9a6f8ebf6b..0000000000
--- a/docs/references/projects/create-jwt.md
+++ /dev/null
@@ -1 +0,0 @@
-Create a new JWT token. This token can be used to authenticate users with custom scopes and expiration time.
\ No newline at end of file
diff --git a/docs/references/projects/create-key.md b/docs/references/projects/create-key.md
deleted file mode 100644
index d6633d936d..0000000000
--- a/docs/references/projects/create-key.md
+++ /dev/null
@@ -1 +0,0 @@
-Create a new API key. It's recommended to have multiple API keys with strict scopes for separate functions within your project.
\ No newline at end of file
diff --git a/docs/references/projects/create-platform.md b/docs/references/projects/create-platform.md
deleted file mode 100644
index b5d8be0ff9..0000000000
--- a/docs/references/projects/create-platform.md
+++ /dev/null
@@ -1 +0,0 @@
-Create a new platform for your project. Use this endpoint to register a new platform where your users will run your application which will interact with the Appwrite API.
\ No newline at end of file
diff --git a/docs/references/projects/create-smtp-test.md b/docs/references/projects/create-smtp-test.md
deleted file mode 100644
index 63cea9d21f..0000000000
--- a/docs/references/projects/create-smtp-test.md
+++ /dev/null
@@ -1 +0,0 @@
-Send a test email to verify SMTP configuration.
\ No newline at end of file
diff --git a/docs/references/projects/create-webhook.md b/docs/references/projects/create-webhook.md
deleted file mode 100644
index cd0e93332b..0000000000
--- a/docs/references/projects/create-webhook.md
+++ /dev/null
@@ -1 +0,0 @@
-Create a new webhook. Use this endpoint to configure a URL that will receive events from Appwrite when specific events occur.
\ No newline at end of file
diff --git a/docs/references/projects/create.md b/docs/references/projects/create.md
deleted file mode 100644
index d502c269ef..0000000000
--- a/docs/references/projects/create.md
+++ /dev/null
@@ -1 +0,0 @@
-Create a new project. You can create a maximum of 100 projects per account.
\ No newline at end of file
diff --git a/docs/references/projects/delete-email-template.md b/docs/references/projects/delete-email-template.md
deleted file mode 100644
index 332b1d6117..0000000000
--- a/docs/references/projects/delete-email-template.md
+++ /dev/null
@@ -1 +0,0 @@
-Reset a custom email template to its default value. This endpoint removes any custom content and restores the template to its original state.
\ No newline at end of file
diff --git a/docs/references/projects/delete-key.md b/docs/references/projects/delete-key.md
deleted file mode 100644
index 9f3774b419..0000000000
--- a/docs/references/projects/delete-key.md
+++ /dev/null
@@ -1 +0,0 @@
-Delete a key by its unique ID. Once deleted, the key can no longer be used to authenticate API calls.
\ No newline at end of file
diff --git a/docs/references/projects/delete-platform.md b/docs/references/projects/delete-platform.md
deleted file mode 100644
index 7d538cac26..0000000000
--- a/docs/references/projects/delete-platform.md
+++ /dev/null
@@ -1 +0,0 @@
-Delete a platform by its unique ID. This endpoint removes the platform and all its configurations from the project.
\ No newline at end of file
diff --git a/docs/references/projects/delete-sms-template.md b/docs/references/projects/delete-sms-template.md
deleted file mode 100644
index c5a7e6cac9..0000000000
--- a/docs/references/projects/delete-sms-template.md
+++ /dev/null
@@ -1 +0,0 @@
-Reset a custom SMS template to its default value. This endpoint removes any custom message and restores the template to its original state.
\ No newline at end of file
diff --git a/docs/references/projects/delete-webhook.md b/docs/references/projects/delete-webhook.md
deleted file mode 100644
index 74fee2bcec..0000000000
--- a/docs/references/projects/delete-webhook.md
+++ /dev/null
@@ -1 +0,0 @@
-Delete a webhook by its unique ID. Once deleted, the webhook will no longer receive project events.
\ No newline at end of file
diff --git a/docs/references/projects/delete.md b/docs/references/projects/delete.md
deleted file mode 100644
index 4a8070c082..0000000000
--- a/docs/references/projects/delete.md
+++ /dev/null
@@ -1 +0,0 @@
-Delete a project by its unique ID.
\ No newline at end of file
diff --git a/docs/references/projects/get-email-template.md b/docs/references/projects/get-email-template.md
deleted file mode 100644
index 6119a0a183..0000000000
--- a/docs/references/projects/get-email-template.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a custom email template for the specified locale and type. This endpoint returns the template content, subject, and other configuration details.
\ No newline at end of file
diff --git a/docs/references/projects/get-key.md b/docs/references/projects/get-key.md
deleted file mode 100644
index bd6351f420..0000000000
--- a/docs/references/projects/get-key.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a key by its unique ID. This endpoint returns details about a specific API key in your project including it's scopes.
\ No newline at end of file
diff --git a/docs/references/projects/get-platform.md b/docs/references/projects/get-platform.md
deleted file mode 100644
index 87129b829d..0000000000
--- a/docs/references/projects/get-platform.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a platform by its unique ID. This endpoint returns the platform's details, including its name, type, and key configurations.
\ No newline at end of file
diff --git a/docs/references/projects/get-sms-template.md b/docs/references/projects/get-sms-template.md
deleted file mode 100644
index 6ef1d93029..0000000000
--- a/docs/references/projects/get-sms-template.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a custom SMS template for the specified locale and type returning it's contents.
\ No newline at end of file
diff --git a/docs/references/projects/get-webhook.md b/docs/references/projects/get-webhook.md
deleted file mode 100644
index 559c73c748..0000000000
--- a/docs/references/projects/get-webhook.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a webhook by its unique ID. This endpoint returns details about a specific webhook configured for a project.
\ No newline at end of file
diff --git a/docs/references/projects/get.md b/docs/references/projects/get.md
deleted file mode 100644
index b7a1165adc..0000000000
--- a/docs/references/projects/get.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a project by its unique ID. This endpoint allows you to retrieve the project's details, including its name, description, team, region, and other metadata.
\ No newline at end of file
diff --git a/docs/references/projects/list-keys.md b/docs/references/projects/list-keys.md
deleted file mode 100644
index a7b701b0d7..0000000000
--- a/docs/references/projects/list-keys.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a list of all API keys from the current project.
\ No newline at end of file
diff --git a/docs/references/projects/list-platforms.md b/docs/references/projects/list-platforms.md
deleted file mode 100644
index ed9ade0852..0000000000
--- a/docs/references/projects/list-platforms.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a list of all platforms in the project. This endpoint returns an array of all platforms and their configurations.
\ No newline at end of file
diff --git a/docs/references/projects/list-webhooks.md b/docs/references/projects/list-webhooks.md
deleted file mode 100644
index bbbf4c7376..0000000000
--- a/docs/references/projects/list-webhooks.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a list of all webhooks belonging to the project. You can use the query params to filter your results.
\ No newline at end of file
diff --git a/docs/references/projects/update-api-status-all.md b/docs/references/projects/update-api-status-all.md
deleted file mode 100644
index 654070759f..0000000000
--- a/docs/references/projects/update-api-status-all.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the status of all API types. Use this endpoint to enable or disable API types such as REST, GraphQL and Realtime all at once.
\ No newline at end of file
diff --git a/docs/references/projects/update-api-status.md b/docs/references/projects/update-api-status.md
deleted file mode 100644
index af10a0d4f4..0000000000
--- a/docs/references/projects/update-api-status.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the status of a specific API type. Use this endpoint to enable or disable API types such as REST, GraphQL and Realtime.
\ No newline at end of file
diff --git a/docs/references/projects/update-auth-duration.md b/docs/references/projects/update-auth-duration.md
deleted file mode 100644
index bdc75fa6f0..0000000000
--- a/docs/references/projects/update-auth-duration.md
+++ /dev/null
@@ -1 +0,0 @@
-Update how long sessions created within a project should stay active for.
\ No newline at end of file
diff --git a/docs/references/projects/update-auth-limit.md b/docs/references/projects/update-auth-limit.md
deleted file mode 100644
index c8faa3fe37..0000000000
--- a/docs/references/projects/update-auth-limit.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the maximum number of users allowed in this project. Set to 0 for unlimited users.
\ No newline at end of file
diff --git a/docs/references/projects/update-auth-password-dictionary.md b/docs/references/projects/update-auth-password-dictionary.md
deleted file mode 100644
index 1d47d30bb5..0000000000
--- a/docs/references/projects/update-auth-password-dictionary.md
+++ /dev/null
@@ -1 +0,0 @@
-Enable or disable checking user passwords against common passwords dictionary. This helps ensure users don't use common and insecure passwords.
\ No newline at end of file
diff --git a/docs/references/projects/update-auth-password-history.md b/docs/references/projects/update-auth-password-history.md
deleted file mode 100644
index 3a892915d5..0000000000
--- a/docs/references/projects/update-auth-password-history.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the authentication password history requirement. Use this endpoint to require new passwords to be different than the last X amount of previously used ones.
\ No newline at end of file
diff --git a/docs/references/projects/update-auth-sessions-limit.md b/docs/references/projects/update-auth-sessions-limit.md
deleted file mode 100644
index 7d5fdffae7..0000000000
--- a/docs/references/projects/update-auth-sessions-limit.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the maximum number of sessions allowed per user within the project, if the limit is hit the oldest session will be deleted to make room for new sessions.
\ No newline at end of file
diff --git a/docs/references/projects/update-auth-status.md b/docs/references/projects/update-auth-status.md
deleted file mode 100644
index 5d39ec29c4..0000000000
--- a/docs/references/projects/update-auth-status.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the status of a specific authentication method. Use this endpoint to enable or disable different authentication methods such as email, magic urls or sms in your project.
\ No newline at end of file
diff --git a/docs/references/projects/update-email-template.md b/docs/references/projects/update-email-template.md
deleted file mode 100644
index d2bf124541..0000000000
--- a/docs/references/projects/update-email-template.md
+++ /dev/null
@@ -1 +0,0 @@
-Update a custom email template for the specified locale and type. Use this endpoint to modify the content of your email templates.
\ No newline at end of file
diff --git a/docs/references/projects/update-key.md b/docs/references/projects/update-key.md
deleted file mode 100644
index 4934a51497..0000000000
--- a/docs/references/projects/update-key.md
+++ /dev/null
@@ -1 +0,0 @@
-Update a key by its unique ID. Use this endpoint to update the name, scopes, or expiration time of an API key.
\ No newline at end of file
diff --git a/docs/references/projects/update-memberships-privacy.md b/docs/references/projects/update-memberships-privacy.md
deleted file mode 100644
index a1affc1166..0000000000
--- a/docs/references/projects/update-memberships-privacy.md
+++ /dev/null
@@ -1 +0,0 @@
-Update project membership privacy settings. Use this endpoint to control what user information is visible to other team members, such as user name, email, and MFA status.
\ No newline at end of file
diff --git a/docs/references/projects/update-mock-numbers.md b/docs/references/projects/update-mock-numbers.md
deleted file mode 100644
index 7fa92455c1..0000000000
--- a/docs/references/projects/update-mock-numbers.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the list of mock phone numbers for testing. Use these numbers to bypass SMS verification in development.
\ No newline at end of file
diff --git a/docs/references/projects/update-oauth2.md b/docs/references/projects/update-oauth2.md
deleted file mode 100644
index 2285135991..0000000000
--- a/docs/references/projects/update-oauth2.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the OAuth2 provider configurations. Use this endpoint to set up or update the OAuth2 provider credentials or enable/disable providers.
\ No newline at end of file
diff --git a/docs/references/projects/update-personal-data-check.md b/docs/references/projects/update-personal-data-check.md
deleted file mode 100644
index 42847fdbfc..0000000000
--- a/docs/references/projects/update-personal-data-check.md
+++ /dev/null
@@ -1 +0,0 @@
-Enable or disable checking user passwords against their personal data. This helps prevent users from using personal information in their passwords.
\ No newline at end of file
diff --git a/docs/references/projects/update-platform.md b/docs/references/projects/update-platform.md
deleted file mode 100644
index d04b07bafd..0000000000
--- a/docs/references/projects/update-platform.md
+++ /dev/null
@@ -1 +0,0 @@
-Update a platform by its unique ID. Use this endpoint to update the platform's name, key, platform store ID, or hostname.
\ No newline at end of file
diff --git a/docs/references/projects/update-service-status-all.md b/docs/references/projects/update-service-status-all.md
deleted file mode 100644
index f05e7d8c5c..0000000000
--- a/docs/references/projects/update-service-status-all.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the status of all services. Use this endpoint to enable or disable all optional services at once.
\ No newline at end of file
diff --git a/docs/references/projects/update-service-status.md b/docs/references/projects/update-service-status.md
deleted file mode 100644
index 9d3b0743a8..0000000000
--- a/docs/references/projects/update-service-status.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the status of a specific service. Use this endpoint to enable or disable a service in your project.
\ No newline at end of file
diff --git a/docs/references/projects/update-session-alerts.md b/docs/references/projects/update-session-alerts.md
deleted file mode 100644
index 36859e0c1e..0000000000
--- a/docs/references/projects/update-session-alerts.md
+++ /dev/null
@@ -1 +0,0 @@
-Enable or disable session email alerts. When enabled, users will receive email notifications when new sessions are created.
\ No newline at end of file
diff --git a/docs/references/projects/update-session-invalidation.md b/docs/references/projects/update-session-invalidation.md
deleted file mode 100644
index cbaf378624..0000000000
--- a/docs/references/projects/update-session-invalidation.md
+++ /dev/null
@@ -1 +0,0 @@
-Invalidate all existing sessions. An optional auth security setting for projects, and enabled by default for console project.
\ No newline at end of file
diff --git a/docs/references/projects/update-sms-template.md b/docs/references/projects/update-sms-template.md
deleted file mode 100644
index 3e67f613b7..0000000000
--- a/docs/references/projects/update-sms-template.md
+++ /dev/null
@@ -1 +0,0 @@
-Update a custom SMS template for the specified locale and type. Use this endpoint to modify the content of your SMS templates.
\ No newline at end of file
diff --git a/docs/references/projects/update-smtp.md b/docs/references/projects/update-smtp.md
deleted file mode 100644
index 7d898e1ed1..0000000000
--- a/docs/references/projects/update-smtp.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the SMTP configuration for your project. Use this endpoint to configure your project's SMTP provider with your custom settings for sending transactional emails.
\ No newline at end of file
diff --git a/docs/references/projects/update-webhook-signature.md b/docs/references/projects/update-webhook-signature.md
deleted file mode 100644
index 8525a05777..0000000000
--- a/docs/references/projects/update-webhook-signature.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the webhook signature key. This endpoint can be used to regenerate the signature key used to sign and validate payload deliveries for a specific webhook.
\ No newline at end of file
diff --git a/docs/references/projects/update-webhook.md b/docs/references/projects/update-webhook.md
deleted file mode 100644
index 745e4aebe1..0000000000
--- a/docs/references/projects/update-webhook.md
+++ /dev/null
@@ -1 +0,0 @@
-Update a webhook by its unique ID. Use this endpoint to update the URL, events, or status of an existing webhook.
\ No newline at end of file
diff --git a/docs/references/projects/update.md b/docs/references/projects/update.md
deleted file mode 100644
index 60c072c477..0000000000
--- a/docs/references/projects/update.md
+++ /dev/null
@@ -1 +0,0 @@
-Update a project by its unique ID.
\ No newline at end of file
diff --git a/docs/references/tablesdb/create-bigint-column.md b/docs/references/tablesdb/create-bigint-column.md
new file mode 100644
index 0000000000..7bbbb5aac6
--- /dev/null
+++ b/docs/references/tablesdb/create-bigint-column.md
@@ -0,0 +1 @@
+Create a bigint column. Optionally, minimum and maximum values can be provided.
diff --git a/docs/references/tablesdb/get-database.md b/docs/references/tablesdb/get-database.md
deleted file mode 100644
index 24183f6f6b..0000000000
--- a/docs/references/tablesdb/get-database.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a database by its unique ID. This endpoint response returns a JSON object with the database metadata.
\ No newline at end of file
diff --git a/docs/references/tablesdb/update-bigint-column.md b/docs/references/tablesdb/update-bigint-column.md
new file mode 100644
index 0000000000..0dde070f6f
--- /dev/null
+++ b/docs/references/tablesdb/update-bigint-column.md
@@ -0,0 +1 @@
+Update a bigint column. Changing the `default` value will not update already existing rows.
diff --git a/docs/references/vcs/list-repository-branches.md b/docs/references/vcs/list-repository-branches.md
index eea1795a3e..b614c2ad13 100644
--- a/docs/references/vcs/list-repository-branches.md
+++ b/docs/references/vcs/list-repository-branches.md
@@ -1 +1 @@
-Get a list of all branches from a GitHub repository in your installation. This endpoint returns the names of all branches in the repository and their total count. The GitHub installation must be properly configured and have access to the requested repository for this endpoint to work.
+Get a list of branches from a GitHub repository in your installation. This endpoint supports filtering by a search term and pagination using query strings such as `Query.limit()`, `Query.offset()`, `Query.cursorAfter()`, and `Query.cursorBefore()`. It returns branch names along with the total number of matches. The GitHub installation must be properly configured and have access to the requested repository for this endpoint to work.
diff --git a/docs/references/vectorsdb/decrement-document-attribute.md b/docs/references/vectorsdb/decrement-document-attribute.md
deleted file mode 100644
index b7b32d6148..0000000000
--- a/docs/references/vectorsdb/decrement-document-attribute.md
+++ /dev/null
@@ -1 +0,0 @@
-Decrement a specific column of a row by a given value.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/get-collection-logs.md b/docs/references/vectorsdb/get-collection-logs.md
deleted file mode 100644
index 8578cef03c..0000000000
--- a/docs/references/vectorsdb/get-collection-logs.md
+++ /dev/null
@@ -1 +0,0 @@
-Get the collection activity logs list by its unique ID.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/get-document-logs.md b/docs/references/vectorsdb/get-document-logs.md
deleted file mode 100644
index 9b96df5ad4..0000000000
--- a/docs/references/vectorsdb/get-document-logs.md
+++ /dev/null
@@ -1 +0,0 @@
-Get the document activity logs list by its unique ID.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/increment-document-attribute.md b/docs/references/vectorsdb/increment-document-attribute.md
deleted file mode 100644
index 7a19b3fbc7..0000000000
--- a/docs/references/vectorsdb/increment-document-attribute.md
+++ /dev/null
@@ -1 +0,0 @@
-Increment a specific column of a row by a given value.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/list-attributes.md b/docs/references/vectorsdb/list-attributes.md
deleted file mode 100644
index 72ad6d727f..0000000000
--- a/docs/references/vectorsdb/list-attributes.md
+++ /dev/null
@@ -1 +0,0 @@
-List attributes in the collection.
\ No newline at end of file
diff --git a/docs/sdks/unity/GETTING_STARTED.md b/docs/sdks/unity/GETTING_STARTED.md
new file mode 100644
index 0000000000..7e1f37879c
--- /dev/null
+++ b/docs/sdks/unity/GETTING_STARTED.md
@@ -0,0 +1,116 @@
+## Getting Started
+
+Before you begin, create an Appwrite project and add a Unity platform in your Appwrite Console.
+
+This SDK requires the following Unity packages and libraries:
+
+- [**UniTask**](https://github.com/Cysharp/UniTask): For async/await support in Unity.
+- [**NativeWebSocket**](https://github.com/endel/NativeWebSocket): For WebSocket realtime subscriptions.
+- **System.Text.Json**: For JSON serialization, provided as a DLL in the project.
+
+After installing the SDK, open **Appwrite → Setup Assistant** in Unity and install the required dependencies.
+
+### Configure the SDK
+
+Create an Appwrite configuration using the **QuickStart** window in the **Appwrite Setup Assistant**, or through **Appwrite → Create Configuration**.
+
+### Using AppwriteManager
+
+```csharp
+[SerializeField] private AppwriteConfig config;
+private AppwriteManager _manager;
+
+private async UniTask ExampleWithManager()
+{
+ _manager = AppwriteManager.Instance ?? new GameObject("AppwriteManager").AddComponent();
+ _manager.SetConfig(config);
+
+ var success = await _manager.Initialize(needRealtime: true);
+ if (!success)
+ {
+ Debug.LogError("Failed to initialize AppwriteManager");
+ return;
+ }
+
+ var client = _manager.Client;
+ var pingResult = await client.Ping();
+ Debug.Log($"Ping result: {pingResult}");
+
+ var realtime = _manager.Realtime;
+ var subscription = realtime.Subscribe(
+ new[] { "databases.*.collections.*.documents" },
+ response =>
+ {
+ var eventName = response.Events != null && response.Events.Length > 0
+ ? response.Events[0]
+ : "unknown";
+
+ Debug.Log($"Realtime event: {eventName}");
+ }
+ );
+
+ // Keep a reference to close the subscription when your MonoBehaviour is destroyed.
+ // subscription.Close();
+}
+```
+
+### Using Client directly
+
+```csharp
+private async UniTask ExampleWithDirectClient()
+{
+ var client = Client.From(
+ projectId: "",
+ endpoint: "https://.cloud.appwrite.io/v1",
+ endpointRealtime: "wss://.cloud.appwrite.io/v1");
+
+ var pingResult = await client.Ping();
+ Debug.Log($"Direct client ping: {pingResult}");
+}
+```
+
+You can also create authenticated clients with `Client.FromSession`, `Client.FromDevKey`, or `Client.FromImpersonation` when those authentication flows are needed.
+
+### Error handling
+
+```csharp
+try
+{
+ var result = await client.Ping();
+}
+catch (AppwriteException ex)
+{
+ Debug.LogError($"Appwrite Error: {ex.Message}");
+ Debug.LogError($"Status Code: {ex.Code}");
+ Debug.LogError($"Response: {ex.Response}");
+}
+```
+
+## Preparing Models for Databases API
+
+When working with the Databases API in Unity, models should be prepared for serialization using the System.Text.Json library. System.Text.Json uses CLR property names by default unless a naming policy is configured. If your project or SDK configuration serializes property names differently from your Appwrite collection attributes, this can cause errors due to mismatches between serialized property names and actual attribute names in your collection.
+
+To avoid this, add the `JsonPropertyName` attribute to each property in your model class to match the attribute name in Appwrite:
+
+```csharp
+using System.Text.Json.Serialization;
+
+public class TestModel
+{
+ [JsonPropertyName("name")]
+ public string Name { get; set; }
+
+ [JsonPropertyName("release_date")]
+ public System.DateTime ReleaseDate { get; set; }
+}
+```
+
+The `JsonPropertyName` attribute ensures your data object is serialized with the correct attribute names for Appwrite databases.
+
+### Learn more
+You can use the following resources to learn more and get help:
+
+- 🚀 [Getting Started Tutorial](https://appwrite.io/docs/getting-started-for-client)
+- 📜 [Appwrite Docs](https://appwrite.io/docs)
+- 💬 [Discord Community](https://appwrite.io/discord)
+- 🧰 [Appwrite SDK Generator](https://github.com/appwrite/sdk-generator)
diff --git a/docs/services/advisor.md b/docs/services/advisor.md
new file mode 100644
index 0000000000..2fa3943829
--- /dev/null
+++ b/docs/services/advisor.md
@@ -0,0 +1,3 @@
+The Advisor service provides read access to analyzer reports and their nested insights for a project.
+
+Use the reports endpoints to list and fetch analyzer runs, then use the insights endpoints to inspect individual findings attached to a report.
diff --git a/phpstan.neon b/phpstan.neon
index 85d18fd44d..0b8761c19e 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -1,5 +1,5 @@
parameters:
- level: 3
+ level: 4
tmpDir: .phpstan-cache
paths:
- src
diff --git a/phpunit.xml b/phpunit.xml
index 9748c5a5c8..32e865fe35 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -38,6 +38,7 @@
./tests/e2e/Services/Messaging
./tests/e2e/Services/Migrations
./tests/e2e/Services/Project
+ ./tests/e2e/Services/Advisor
./tests/e2e/Services/Functions/FunctionsBase.php
./tests/e2e/Services/Functions/FunctionsCustomServerTest.php
./tests/e2e/Services/Functions/FunctionsCustomClientTest.php
diff --git a/public/images/sites/templates/crm-dashboard-react-admin-dark.png b/public/images/sites/templates/dashboard-react-admin-dark.png
similarity index 100%
rename from public/images/sites/templates/crm-dashboard-react-admin-dark.png
rename to public/images/sites/templates/dashboard-react-admin-dark.png
diff --git a/public/images/sites/templates/crm-dashboard-react-admin-light.png b/public/images/sites/templates/dashboard-react-admin-light.png
similarity index 100%
rename from public/images/sites/templates/crm-dashboard-react-admin-light.png
rename to public/images/sites/templates/dashboard-react-admin-light.png
diff --git a/src/Appwrite/Advisor/Validator/CTAs.php b/src/Appwrite/Advisor/Validator/CTAs.php
new file mode 100644
index 0000000000..14f7d788e7
--- /dev/null
+++ b/src/Appwrite/Advisor/Validator/CTAs.php
@@ -0,0 +1,83 @@
+allowedServices = $allowedServices ?? ADVISOR_CTA_SERVICES;
+ $this->allowedMethods = $allowedMethods ?? ADVISOR_CTA_METHODS;
+ }
+
+ public function getDescription(): string
+ {
+ return $this->message;
+ }
+
+ public function isArray(): bool
+ {
+ return true;
+ }
+
+ public function getType(): string
+ {
+ return self::TYPE_ARRAY;
+ }
+
+ public function isValid($value): bool
+ {
+ if (!\is_array($value)) {
+ return false;
+ }
+
+ if (\count($value) > $this->maxCount) {
+ $this->message = "A maximum of {$this->maxCount} CTAs are allowed per insight.";
+ return false;
+ }
+
+ foreach ($value as $entry) {
+ if (!\is_array($entry)) {
+ return false;
+ }
+
+ $maxLengths = ['label' => 256, 'service' => 64, 'method' => 64];
+ foreach ($maxLengths as $required => $maxLength) {
+ if (!isset($entry[$required]) || !\is_string($entry[$required]) || $entry[$required] === '') {
+ return false;
+ }
+ if (\strlen($entry[$required]) > $maxLength) {
+ $this->message = "CTA `{$required}` must not exceed {$maxLength} characters.";
+ return false;
+ }
+ }
+
+ if (!empty($this->allowedServices) && !\in_array($entry['service'], $this->allowedServices, true)) {
+ $this->message = "CTA `service` must be one of: " . \implode(', ', $this->allowedServices) . '.';
+ return false;
+ }
+
+ if (!empty($this->allowedMethods) && !\in_array($entry['method'], $this->allowedMethods, true)) {
+ $this->message = "CTA `method` must be one of: " . \implode(', ', $this->allowedMethods) . '.';
+ return false;
+ }
+
+ if (isset($entry['params']) && !\is_array($entry['params']) && !\is_object($entry['params'])) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/Appwrite/Auth/Key.php b/src/Appwrite/Auth/Key.php
index 8f645f6f08..0cbaefa4b3 100644
--- a/src/Appwrite/Auth/Key.php
+++ b/src/Appwrite/Auth/Key.php
@@ -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;
diff --git a/src/Appwrite/Auth/OAuth2.php b/src/Appwrite/Auth/OAuth2.php
index a8a2d175b5..958b28ed18 100644
--- a/src/Appwrite/Auth/OAuth2.php
+++ b/src/Appwrite/Auth/OAuth2.php
@@ -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);
}
diff --git a/src/Appwrite/Auth/OAuth2/Apple.php b/src/Appwrite/Auth/OAuth2/Apple.php
index 0b4ec50881..bae3446fcb 100644
--- a/src/Appwrite/Auth/OAuth2/Apple.php
+++ b/src/Appwrite/Auth/OAuth2/Apple.php
@@ -165,9 +165,9 @@ class Apple extends OAuth2
protected function getAppSecret(): string
{
- try {
- $secret = \json_decode($this->appSecret, true);
- } catch (\Throwable $th) {
+ $secret = \json_decode($this->appSecret, true);
+
+ if (!\is_array($secret)) {
throw new Exception('Invalid secret');
}
diff --git a/src/Appwrite/Auth/OAuth2/Authentik.php b/src/Appwrite/Auth/OAuth2/Authentik.php
index 5d2445088b..aa4b126ae8 100644
--- a/src/Appwrite/Auth/OAuth2/Authentik.php
+++ b/src/Appwrite/Auth/OAuth2/Authentik.php
@@ -37,6 +37,13 @@ class Authentik extends OAuth2
return 'authentik';
}
+ public function verifyCredentials(): void
+ {
+ if (empty($this->getAuthentikDomain())) {
+ throw new \Exception('Authentik endpoint is required.');
+ }
+ }
+
/**
* @return string
*/
diff --git a/src/Appwrite/Auth/OAuth2/Etsy.php b/src/Appwrite/Auth/OAuth2/Etsy.php
index 7ff16fcb78..6e0da14437 100644
--- a/src/Appwrite/Auth/OAuth2/Etsy.php
+++ b/src/Appwrite/Auth/OAuth2/Etsy.php
@@ -11,11 +11,6 @@ class Etsy extends OAuth2
*/
private string $endpoint = 'https://api.etsy.com/v3/public';
- /**
- * @var string
- */
- private string $version = '2022-07-14';
-
/**
* @var array
*/
diff --git a/src/Appwrite/Auth/OAuth2/FusionAuth.php b/src/Appwrite/Auth/OAuth2/FusionAuth.php
new file mode 100644
index 0000000000..fa8b45dc72
--- /dev/null
+++ b/src/Appwrite/Auth/OAuth2/FusionAuth.php
@@ -0,0 +1,233 @@
+getFusionAuthDomain())) {
+ throw new \Exception('FusionAuth endpoint is required.');
+ }
+ }
+
+ /**
+ * @return string
+ */
+ public function getLoginURL(): string
+ {
+ return 'https://' . $this->getFusionAuthDomain() . '/oauth2/authorize?' . \http_build_query([
+ 'client_id' => $this->appID,
+ 'redirect_uri' => $this->callback,
+ 'state' => \json_encode($this->state),
+ 'scope' => \implode(' ', $this->getScopes()),
+ 'response_type' => 'code'
+ ]);
+ }
+
+ /**
+ * @param string $code
+ *
+ * @return array
+ */
+ protected function getTokens(string $code): array
+ {
+ if (empty($this->tokens)) {
+ $headers = ['Content-Type: application/x-www-form-urlencoded'];
+ $this->tokens = \json_decode($this->request(
+ 'POST',
+ 'https://' . $this->getFusionAuthDomain() . '/oauth2/token',
+ $headers,
+ \http_build_query([
+ 'code' => $code,
+ 'client_id' => $this->appID,
+ 'client_secret' => $this->getClientSecret(),
+ 'redirect_uri' => $this->callback,
+ 'scope' => \implode(' ', $this->getScopes()),
+ 'grant_type' => 'authorization_code'
+ ])
+ ), true);
+ }
+ return $this->tokens;
+ }
+
+ /**
+ * @param string $refreshToken
+ *
+ * @return array
+ */
+ public function refreshTokens(string $refreshToken): array
+ {
+ $headers = ['Content-Type: application/x-www-form-urlencoded'];
+ $this->tokens = \json_decode($this->request(
+ 'POST',
+ 'https://' . $this->getFusionAuthDomain() . '/oauth2/token',
+ $headers,
+ \http_build_query([
+ 'refresh_token' => $refreshToken,
+ 'client_id' => $this->appID,
+ 'client_secret' => $this->getClientSecret(),
+ 'grant_type' => 'refresh_token'
+ ])
+ ), true);
+
+ if (empty($this->tokens['refresh_token'])) {
+ $this->tokens['refresh_token'] = $refreshToken;
+ }
+
+ return $this->tokens;
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserID(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ if (isset($user['sub'])) {
+ return $user['sub'];
+ }
+
+ return '';
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserEmail(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ if (isset($user['email'])) {
+ return $user['email'];
+ }
+
+ return '';
+ }
+
+ /**
+ * Check if the User email is verified
+ *
+ * @param string $accessToken
+ *
+ * @return bool
+ */
+ public function isEmailVerified(string $accessToken): bool
+ {
+ $user = $this->getUser($accessToken);
+
+ if ($user['email_verified'] ?? false) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserName(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ if (isset($user['name'])) {
+ return $user['name'];
+ }
+
+ return '';
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return array
+ */
+ protected function getUser(string $accessToken): array
+ {
+ if (empty($this->user)) {
+ $headers = ['Authorization: Bearer ' . \urlencode($accessToken)];
+ $user = $this->request('GET', 'https://' . $this->getFusionAuthDomain() . '/oauth2/userinfo', $headers);
+ $this->user = \json_decode($user, true);
+ }
+
+ return $this->user;
+ }
+
+ /**
+ * Extracts the Client Secret from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getClientSecret(): string
+ {
+ $secret = $this->getAppSecret();
+
+ return $secret['clientSecret'] ?? '';
+ }
+
+ /**
+ * Extracts the FusionAuth Domain from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getFusionAuthDomain(): string
+ {
+ $secret = $this->getAppSecret();
+ return $secret['fusionAuthDomain'] ?? '';
+ }
+
+ /**
+ * Decode the JSON stored in appSecret
+ *
+ * @return array
+ */
+ protected function getAppSecret(): array
+ {
+ try {
+ $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR);
+ } catch (\Throwable $th) {
+ throw new \Exception('Invalid secret');
+ }
+ return $secret;
+ }
+}
diff --git a/src/Appwrite/Auth/OAuth2/Github.php b/src/Appwrite/Auth/OAuth2/Github.php
index 1cefc397c5..d5d3b07918 100644
--- a/src/Appwrite/Auth/OAuth2/Github.php
+++ b/src/Appwrite/Auth/OAuth2/Github.php
@@ -3,6 +3,7 @@
namespace Appwrite\Auth\OAuth2;
use Appwrite\Auth\OAuth2;
+use Utopia\Fetch\Client as FetchClient;
class Github extends OAuth2
{
@@ -219,4 +220,34 @@ class Github extends OAuth2
$repository = \json_decode($repository, true);
return $repository;
}
+
+ public function verifyCredentials(): void
+ {
+ $client = new FetchClient();
+ $client->addHeader('Accept', 'application/json');
+
+ $response = $client->fetch(
+ url: 'https://github.com/login/oauth/access_token',
+ method: FetchClient::METHOD_POST,
+ query: [
+ 'client_id' => $this->appID,
+ 'client_secret' => $this->appSecret,
+ 'code' => 'intentionally-invalid-code',
+ 'redirect_uri' => 'intentionally-invalid-redirect',
+ ]
+ );
+
+ $json = \json_decode($response->getBody(), true);
+
+ if (isset($json['error']) && $json['error'] === "Not Found") {
+ throw new \Exception('GitHub application with provided Client ID is does not exist.');
+ }
+
+ if (isset($json['error']) && $json['error'] === "incorrect_client_credentials") {
+ throw new \Exception('GitHub application with provided Client ID is valid, but the provided Client Secret is incorrect.');
+ }
+
+ // We still expect error, like redirect_uri_mismatch or bad_verification_code,
+ // but that indicates valid credentials
+ }
}
diff --git a/src/Appwrite/Auth/OAuth2/Google.php b/src/Appwrite/Auth/OAuth2/Google.php
index 79894c2422..1166a313c6 100644
--- a/src/Appwrite/Auth/OAuth2/Google.php
+++ b/src/Appwrite/Auth/OAuth2/Google.php
@@ -55,7 +55,7 @@ class Google extends OAuth2
'state' => \json_encode($this->state),
'response_type' => 'code',
'access_type' => 'offline',
- 'prompt' => 'consent'
+ 'prompt' => $this->getPrompt()
]);
}
@@ -72,7 +72,7 @@ class Google extends OAuth2
'https://oauth2.googleapis.com/token?' . \http_build_query([
'code' => $code,
'client_id' => $this->appID,
- 'client_secret' => $this->appSecret,
+ 'client_secret' => $this->getClientSecret(),
'redirect_uri' => $this->callback,
'scope' => null,
'grant_type' => 'authorization_code'
@@ -95,7 +95,7 @@ class Google extends OAuth2
'https://oauth2.googleapis.com/token?' . \http_build_query([
'refresh_token' => $refreshToken,
'client_id' => $this->appID,
- 'client_secret' => $this->appSecret,
+ 'client_secret' => $this->getClientSecret(),
'grant_type' => 'refresh_token'
])
), true);
@@ -177,4 +177,54 @@ class Google extends OAuth2
return $this->user;
}
+
+ /**
+ * Extracts the Client Secret from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getClientSecret(): string
+ {
+ $secret = $this->getAppSecret();
+
+ return $secret['clientSecret'] ?? $this->appSecret;
+ }
+
+ /**
+ * Extracts the prompt values from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getPrompt(): string
+ {
+ $secret = $this->getAppSecret();
+ $prompt = $secret['prompt'] ?? [];
+
+ if (empty($prompt)) {
+ $prompt = ['consent'];
+ }
+
+ return \implode(' ', $prompt);
+ }
+
+ /**
+ * Decode the JSON stored in appSecret.
+ * Falls back to treating the raw string as the client secret for backwards compatibility.
+ *
+ * @return array
+ */
+ protected function getAppSecret(): array
+ {
+ try {
+ $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR);
+ } catch (\Throwable $th) {
+ return ['clientSecret' => $this->appSecret];
+ }
+
+ if (!\is_array($secret)) {
+ return ['clientSecret' => $this->appSecret];
+ }
+
+ return $secret;
+ }
}
diff --git a/src/Appwrite/Auth/OAuth2/Keycloak.php b/src/Appwrite/Auth/OAuth2/Keycloak.php
new file mode 100644
index 0000000000..b53b08e2d9
--- /dev/null
+++ b/src/Appwrite/Auth/OAuth2/Keycloak.php
@@ -0,0 +1,260 @@
+getKeycloakDomain())) {
+ throw new \Exception('Keycloak endpoint is required.');
+ }
+
+ if (empty($this->getKeycloakRealm())) {
+ throw new \Exception('Keycloak realm name is required.');
+ }
+ }
+
+ /**
+ * @return string
+ */
+ public function getLoginURL(): string
+ {
+ return $this->getRealmBaseURL() . '/protocol/openid-connect/auth?' . \http_build_query([
+ 'client_id' => $this->appID,
+ 'redirect_uri' => $this->callback,
+ 'state' => \json_encode($this->state),
+ 'scope' => \implode(' ', $this->getScopes()),
+ 'response_type' => 'code'
+ ]);
+ }
+
+ /**
+ * @param string $code
+ *
+ * @return array
+ */
+ protected function getTokens(string $code): array
+ {
+ if (empty($this->tokens)) {
+ $headers = ['Content-Type: application/x-www-form-urlencoded'];
+ $this->tokens = \json_decode($this->request(
+ 'POST',
+ $this->getRealmBaseURL() . '/protocol/openid-connect/token',
+ $headers,
+ \http_build_query([
+ 'code' => $code,
+ 'client_id' => $this->appID,
+ 'client_secret' => $this->getClientSecret(),
+ 'redirect_uri' => $this->callback,
+ 'scope' => \implode(' ', $this->getScopes()),
+ 'grant_type' => 'authorization_code'
+ ])
+ ), true);
+ }
+ return $this->tokens;
+ }
+
+ /**
+ * @param string $refreshToken
+ *
+ * @return array
+ */
+ public function refreshTokens(string $refreshToken): array
+ {
+ $headers = ['Content-Type: application/x-www-form-urlencoded'];
+ $this->tokens = \json_decode($this->request(
+ 'POST',
+ $this->getRealmBaseURL() . '/protocol/openid-connect/token',
+ $headers,
+ \http_build_query([
+ 'refresh_token' => $refreshToken,
+ 'client_id' => $this->appID,
+ 'client_secret' => $this->getClientSecret(),
+ 'grant_type' => 'refresh_token'
+ ])
+ ), true);
+
+ if (empty($this->tokens['refresh_token'])) {
+ $this->tokens['refresh_token'] = $refreshToken;
+ }
+
+ return $this->tokens;
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserID(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ if (isset($user['sub'])) {
+ return $user['sub'];
+ }
+
+ return '';
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserEmail(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ if (isset($user['email'])) {
+ return $user['email'];
+ }
+
+ return '';
+ }
+
+ /**
+ * Check if the User email is verified
+ *
+ * @param string $accessToken
+ *
+ * @return bool
+ */
+ public function isEmailVerified(string $accessToken): bool
+ {
+ $user = $this->getUser($accessToken);
+
+ if ($user['email_verified'] ?? false) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserName(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ if (isset($user['name'])) {
+ return $user['name'];
+ }
+
+ return '';
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return array
+ */
+ protected function getUser(string $accessToken): array
+ {
+ if (empty($this->user)) {
+ $headers = ['Authorization: Bearer ' . \urlencode($accessToken)];
+ $user = $this->request('GET', $this->getRealmBaseURL() . '/protocol/openid-connect/userinfo', $headers);
+ $this->user = \json_decode($user, true);
+ }
+
+ return $this->user;
+ }
+
+ /**
+ * Extracts the Client Secret from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getClientSecret(): string
+ {
+ $secret = $this->getAppSecret();
+
+ return $secret['clientSecret'] ?? '';
+ }
+
+ /**
+ * Extracts the Keycloak Domain from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getKeycloakDomain(): string
+ {
+ $secret = $this->getAppSecret();
+ return $secret['keycloakDomain'] ?? '';
+ }
+
+ /**
+ * Extracts the Keycloak Realm from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getKeycloakRealm(): string
+ {
+ $secret = $this->getAppSecret();
+ return $secret['keycloakRealm'] ?? '';
+ }
+
+ /**
+ * Build the realm-scoped base URL: `https://{domain}/realms/{realm}`.
+ * Keycloak realm names allow spaces and other characters that must be
+ * percent-encoded in URLs (e.g. `my realm` → `my%20realm`).
+ *
+ * @return string
+ */
+ protected function getRealmBaseURL(): string
+ {
+ return 'https://' . $this->getKeycloakDomain() . '/realms/' . \rawurlencode($this->getKeycloakRealm());
+ }
+
+ /**
+ * Decode the JSON stored in appSecret
+ *
+ * @return array
+ */
+ protected function getAppSecret(): array
+ {
+ try {
+ $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR);
+ } catch (\Throwable $th) {
+ throw new \Exception('Invalid secret');
+ }
+ return $secret;
+ }
+}
diff --git a/src/Appwrite/Auth/OAuth2/Kick.php b/src/Appwrite/Auth/OAuth2/Kick.php
new file mode 100644
index 0000000000..85b447fcd8
--- /dev/null
+++ b/src/Appwrite/Auth/OAuth2/Kick.php
@@ -0,0 +1,230 @@
+state;
+ $state[self::PKCE_STATE_KEY] = $this->getPKCEVerifier();
+
+ return 'https://id.kick.com/oauth/authorize?' . \http_build_query([
+ 'response_type' => 'code',
+ 'client_id' => $this->appID,
+ 'redirect_uri' => $this->callback,
+ 'scope' => \implode(' ', $this->getScopes()),
+ 'state' => \json_encode($state),
+ 'code_challenge' => $this->getPKCEChallenge(),
+ 'code_challenge_method' => 'S256',
+ ]);
+ }
+
+ /**
+ * @param string $code
+ *
+ * @return array
+ */
+ protected function getTokens(string $code): array
+ {
+ if (empty($this->tokens)) {
+ $headers = ['Content-Type: application/x-www-form-urlencoded'];
+ $this->tokens = \json_decode($this->request(
+ 'POST',
+ 'https://id.kick.com/oauth/token',
+ $headers,
+ \http_build_query([
+ 'grant_type' => 'authorization_code',
+ 'client_id' => $this->appID,
+ 'client_secret' => $this->appSecret,
+ 'redirect_uri' => $this->callback,
+ 'code_verifier' => $this->getPKCEVerifier(),
+ 'code' => $code,
+ ])
+ ), true);
+ }
+
+ return $this->tokens;
+ }
+
+ /**
+ * @param string $refreshToken
+ *
+ * @return array
+ */
+ public function refreshTokens(string $refreshToken): array
+ {
+ $headers = ['Content-Type: application/x-www-form-urlencoded'];
+ $this->tokens = \json_decode($this->request(
+ 'POST',
+ 'https://id.kick.com/oauth/token',
+ $headers,
+ \http_build_query([
+ 'grant_type' => 'refresh_token',
+ 'client_id' => $this->appID,
+ 'client_secret' => $this->appSecret,
+ 'refresh_token' => $refreshToken,
+ ])
+ ), true);
+
+ if (empty($this->tokens['refresh_token'])) {
+ $this->tokens['refresh_token'] = $refreshToken;
+ }
+
+ return $this->tokens;
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserID(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ return isset($user['user_id']) ? (string)$user['user_id'] : '';
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserEmail(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ return $user['email'] ?? '';
+ }
+
+ /**
+ * Check if the OAuth email is verified.
+ *
+ * Kick only returns an email when the user has granted the `user:read`
+ * scope and the account email is verified, so a non-empty email is
+ * treated as verified.
+ *
+ * @param string $accessToken
+ *
+ * @return bool
+ */
+ public function isEmailVerified(string $accessToken): bool
+ {
+ return !empty($this->getUserEmail($accessToken));
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserName(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ return $user['name'] ?? '';
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return array
+ */
+ protected function getUser(string $accessToken): array
+ {
+ if (empty($this->user)) {
+ $headers = ['Authorization: Bearer ' . $accessToken];
+ $response = \json_decode($this->request(
+ 'GET',
+ 'https://api.kick.com/public/v1/users',
+ $headers
+ ), true);
+
+ $this->user = $response['data'][0] ?? [];
+ }
+
+ return $this->user;
+ }
+
+ /**
+ * Extract the PKCE verifier from the state on the callback so the same
+ * value generated in getLoginURL() can be sent to the token endpoint.
+ *
+ * @param string $state
+ *
+ * @return array|null
+ */
+ public function parseState(string $state): ?array
+ {
+ $parsed = \json_decode($state, true);
+
+ if (!\is_array($parsed)) {
+ return null;
+ }
+
+ $verifier = $parsed[self::PKCE_STATE_KEY] ?? null;
+ if (\is_string($verifier)) {
+ $this->pkceVerifier = $verifier;
+ }
+
+ unset($parsed[self::PKCE_STATE_KEY]);
+
+ return $parsed;
+ }
+
+ private function getPKCEVerifier(): string
+ {
+ if ($this->pkceVerifier === '') {
+ $this->pkceVerifier = \rtrim(\strtr(\base64_encode(\random_bytes(64)), '+/', '-_'), '=');
+ }
+
+ return $this->pkceVerifier;
+ }
+
+ private function getPKCEChallenge(): string
+ {
+ return \rtrim(\strtr(\base64_encode(\hash('sha256', $this->getPKCEVerifier(), true)), '+/', '-_'), '=');
+ }
+}
diff --git a/src/Appwrite/Auth/OAuth2/Microsoft.php b/src/Appwrite/Auth/OAuth2/Microsoft.php
index bc05843b37..19966ec1ac 100644
--- a/src/Appwrite/Auth/OAuth2/Microsoft.php
+++ b/src/Appwrite/Auth/OAuth2/Microsoft.php
@@ -36,6 +36,13 @@ class Microsoft extends OAuth2
return 'microsoft';
}
+ public function verifyCredentials(): void
+ {
+ if (empty($this->getTenantID())) {
+ throw new \Exception('Microsoft tenant is required.');
+ }
+ }
+
/**
* @return string
*/
@@ -201,7 +208,7 @@ class Microsoft extends OAuth2
}
/**
- * Extracts the Tenant Id from the JSON stored in appSecret. Defaults to 'common' as a fallback
+ * Extracts the Tenant Id from the JSON stored in appSecret.
*
* @return string
*/
@@ -209,6 +216,6 @@ class Microsoft extends OAuth2
{
$secret = $this->getAppSecret();
- return $secret['tenantID'] ?? 'common';
+ return $secret['tenantID'] ?? '';
}
}
diff --git a/src/Appwrite/Auth/OAuth2/Podio.php b/src/Appwrite/Auth/OAuth2/Podio.php
index 0b1f35414b..6a977da854 100644
--- a/src/Appwrite/Auth/OAuth2/Podio.php
+++ b/src/Appwrite/Auth/OAuth2/Podio.php
@@ -121,7 +121,7 @@ class Podio extends OAuth2
{
$user = $this->getUser($accessToken);
- return \strval($user['user_id']) ?? '';
+ return \strval($user['user_id']);
}
/**
diff --git a/src/Appwrite/Auth/OAuth2/Zoom.php b/src/Appwrite/Auth/OAuth2/Zoom.php
index 9dad22212a..a4967741a9 100644
--- a/src/Appwrite/Auth/OAuth2/Zoom.php
+++ b/src/Appwrite/Auth/OAuth2/Zoom.php
@@ -11,11 +11,6 @@ class Zoom extends OAuth2
*/
private string $endpoint = 'https://zoom.us';
- /**
- * @var string
- */
- private string $version = '2022-03-26';
-
/**
* @var array
*/
diff --git a/src/Appwrite/Auth/Validator/PersonalData.php b/src/Appwrite/Auth/Validator/PersonalData.php
index 3b09839bd1..b047e5dd2f 100644
--- a/src/Appwrite/Auth/Validator/PersonalData.php
+++ b/src/Appwrite/Auth/Validator/PersonalData.php
@@ -59,7 +59,7 @@ class PersonalData extends Password
return false;
}
- if ($this->email && strpos($password, explode('@', $this->email)[0] ?? '') !== false) {
+ if ($this->email && strpos($password, explode('@', $this->email)[0]) !== false) {
return false;
}
diff --git a/src/Appwrite/Bus/Listeners/Log.php b/src/Appwrite/Bus/Listeners/Log.php
index 585d4b09a7..e0376a4f81 100644
--- a/src/Appwrite/Bus/Listeners/Log.php
+++ b/src/Appwrite/Bus/Listeners/Log.php
@@ -8,7 +8,6 @@ use Appwrite\Event\Publisher\Execution as ExecutionPublisher;
use Utopia\Bus\Listener;
use Utopia\Database\Document;
use Utopia\Span\Span;
-use Utopia\System\System;
class Log extends Listener
{
@@ -34,20 +33,13 @@ class Log extends Listener
{
$project = new Document($event->project);
$execution = new Document($event->execution);
+
if ($execution->getAttribute('resourceType', '') === 'functions') {
- $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', '');
- $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', '');
- $resourceId = $execution->getAttribute('resourceId', '');
- if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $resourceId === $traceFunctionId) {
- Span::init('execution.trace.v1_executions_enqueue');
- Span::add('datetime', gmdate('c'));
- Span::add('projectId', $project->getId());
- Span::add('functionId', $resourceId);
- Span::add('executionId', $execution->getId());
- Span::add('deploymentId', $execution->getAttribute('deploymentId', ''));
- Span::add('status', $execution->getAttribute('status', ''));
- Span::current()?->finish();
- }
+ Span::add('project.id', $project->getId());
+ Span::add('function.id', $execution->getAttribute('resourceId', ''));
+ Span::add('execution.id', $execution->getId());
+ Span::add('deployment.id', $execution->getAttribute('deploymentId', ''));
+ Span::add('execution.status', $execution->getAttribute('status', ''));
}
$publisherForExecutions->enqueue(new ExecutionMessage(
diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php
index 2ffcbc9aa4..eb36e0d394 100644
--- a/src/Appwrite/Bus/Listeners/Mails.php
+++ b/src/Appwrite/Bus/Listeners/Mails.php
@@ -4,14 +4,14 @@ namespace Appwrite\Bus\Listeners;
use Appwrite\Auth\MFA\Type;
use Appwrite\Bus\Events\SessionCreated;
-use Appwrite\Event\Mail;
+use Appwrite\Event\Message\Mail as MailMessage;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
use Appwrite\Template\Template;
use Utopia\Bus\Listener;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;
use Utopia\Locale\Locale;
-use Utopia\Queue\Publisher;
use Utopia\Storage\Validator\FileName;
use Utopia\System\System;
@@ -31,14 +31,14 @@ class Mails extends Listener
{
$this
->desc('Sends session alert emails')
- ->inject('publisher')
+ ->inject('publisherForMails')
->inject('locale')
->inject('platform')
->inject('dbForProject')
->callback($this->handle(...));
}
- public function handle(SessionCreated $event, Publisher $publisher, Locale $locale, array $platform, Database $dbForProject): void
+ public function handle(SessionCreated $event, MailPublisher $publisherForMails, Locale $locale, array $platform, Database $dbForProject): void
{
$project = new Document($event->project);
@@ -71,7 +71,9 @@ class Mails extends Listener
throw new \Exception('Invalid template path');
}
- $customTemplate = $project->getAttribute('templates', [])["email.sessionAlert-$event->locale"] ?? [];
+ $customTemplate =
+ $project->getAttribute('templates', [])["email.sessionAlert-" . $locale->default] ??
+ $project->getAttribute('templates', [])['email.sessionAlert-' . $locale->fallback] ?? [];
$isBranded = $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE;
$subject = $customTemplate['subject'] ?? $locale->getText('emails.sessionAlert.subject');
@@ -122,33 +124,32 @@ class Mails extends Listener
];
}
- $queueForMails = new Mail($publisher);
-
+ $smtpConfig = [];
if ($smtp['enabled'] ?? false) {
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '')
- ->setSmtpReplyTo($customTemplate['replyTo'] ?? $smtp['replyTo'] ?? '')
- ->setSmtpSenderEmail($customTemplate['senderEmail'] ?? $smtp['senderEmail'] ?? System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM))
- ->setSmtpSenderName($customTemplate['senderName'] ?? $smtp['senderName'] ?? System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'));
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '', // Includes backwards compatibility
+ 'replyToName' => $customTemplate['replyToName'] ?? $smtp['replyToName'] ?? '',
+ 'senderEmail' => $customTemplate['senderEmail'] ?? $smtp['senderEmail'] ?? System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM),
+ 'senderName' => $customTemplate['senderName'] ?? $smtp['senderName'] ?? System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'),
+ ];
}
- $queueForMails
- ->setProject($project)
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body)
- ->setBodyTemplate(__DIR__ . '/../../../../app/config/locale/templates/' . $smtpBaseTemplate . '.tpl')
- ->appendVariables($emailVariables)
- ->setRecipient($event->user['email']);
-
- if ($isBranded) {
- $queueForMails->setSenderName($platform['emailSenderName']);
- }
-
- $queueForMails->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $event->user['email'],
+ subject: $subject,
+ bodyTemplate: __DIR__ . '/../../../../app/config/locale/templates/' . $smtpBaseTemplate . '.tpl',
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ customMailOptions: $isBranded ? ['senderName' => $platform['emailSenderName']] : [],
+ platform: $platform,
+ ));
}
}
diff --git a/src/Appwrite/Docker/Compose/Service.php b/src/Appwrite/Docker/Compose/Service.php
index 87699aaeba..e7993d6927 100644
--- a/src/Appwrite/Docker/Compose/Service.php
+++ b/src/Appwrite/Docker/Compose/Service.php
@@ -21,7 +21,7 @@ class Service
array_walk($ports, function (&$value, $key) {
$split = explode(':', $value);
$this->service['ports'][
- (isset($split[0])) ? $split[0] : ''
+ $split[0]
] = (isset($split[1])) ? $split[1] : '';
});
diff --git a/src/Appwrite/Docker/Env.php b/src/Appwrite/Docker/Env.php
index af5e4f11e2..7e44a6c5cf 100644
--- a/src/Appwrite/Docker/Env.php
+++ b/src/Appwrite/Docker/Env.php
@@ -15,7 +15,7 @@ class Env
foreach ($data as &$row) {
$row = explode('=', $row, 2);
- $key = (isset($row[0])) ? trim($row[0]) : null;
+ $key = trim($row[0]);
$value = (isset($row[1])) ? (function (string $v): string {
$v = trim($v);
if (
diff --git a/src/Appwrite/Event/Build.php b/src/Appwrite/Event/Build.php
deleted file mode 100644
index 4eaf108f15..0000000000
--- a/src/Appwrite/Event/Build.php
+++ /dev/null
@@ -1,146 +0,0 @@
-setQueue(System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME))
- ->setClass(System::getEnv('_APP_BUILDS_CLASS_NAME', Event::BUILDS_CLASS_NAME));
- }
-
- /**
- * Sets template for the build event.
- *
- * @param Document $template
- * @return self
- */
- public function setTemplate(Document $template): self
- {
- $this->template = $template;
-
- return $this;
- }
-
- /**
- * Sets resource document for the build event.
- *
- * @param Document $resource
- * @return self
- */
- public function setResource(Document $resource): self
- {
- $this->resource = $resource;
-
- return $this;
- }
-
- /**
- * Returns set resource document for the build event.
- *
- * @return null|Document
- */
- public function getResource(): ?Document
- {
- return $this->resource;
- }
-
- /**
- * Sets deployment for the build event.
- *
- * @param Document $deployment
- * @return self
- */
- public function setDeployment(Document $deployment): self
- {
- $this->deployment = $deployment;
-
- return $this;
- }
-
- /**
- * Returns set deployment for the build event.
- *
- * @return null|Document
- */
- public function getDeployment(): ?Document
- {
- return $this->deployment;
- }
-
- /**
- * Sets type for the build event.
- *
- * @param string $type Can be `BUILD_TYPE_DEPLOYMENT` or `BUILD_TYPE_RETRY`.
- * @return self
- */
- public function setType(string $type): self
- {
- $this->type = $type;
-
- return $this;
- }
-
- /**
- * Returns set type for the function event.
- *
- * @return string
- */
- public function getType(): string
- {
- return $this->type;
- }
-
- /**
- * Prepare payload for queue.
- *
- * @return array
- */
- protected function preparePayload(): array
- {
- $platform = $this->platform;
- if (empty($platform)) {
- $platform = Config::getParam('platform', []);
- }
-
- return [
- 'project' => $this->project,
- 'resource' => $this->resource,
- 'deployment' => $this->deployment,
- 'type' => $this->type,
- 'template' => $this->template,
- 'platform' => $platform,
- ];
- }
-
- /**
- * Resets event.
- *
- * @return self
- */
- public function reset(): self
- {
- $this->type = '';
- $this->resource = null;
- $this->deployment = null;
- $this->template = null;
- $this->platform = [];
- parent::reset();
-
- return $this;
- }
-}
diff --git a/src/Appwrite/Event/Context/Audit.php b/src/Appwrite/Event/Context/Audit.php
new file mode 100644
index 0000000000..1d41890476
--- /dev/null
+++ b/src/Appwrite/Event/Context/Audit.php
@@ -0,0 +1,34 @@
+project === null
+ && $this->user === null
+ && $this->mode === ''
+ && $this->userAgent === ''
+ && $this->ip === ''
+ && $this->hostname === ''
+ && $this->event === ''
+ && $this->resource === ''
+ && $this->payload === [];
+ }
+}
diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php
index ae75e3924f..357442a07c 100644
--- a/src/Appwrite/Event/Event.php
+++ b/src/Appwrite/Event/Event.php
@@ -459,7 +459,7 @@ class Event
/**
* Identify all sections of the pattern.
*/
- $type = $parts[0] ?? false;
+ $type = $parts[0];
$resource = $parts[1] ?? false;
$hasSubResource = $count > 3 && \str_starts_with($parts[3], '[');
$hasSubSubResource = $count > 5 && \str_starts_with($parts[5], '[') && $hasSubResource;
@@ -637,9 +637,11 @@ class Event
*/
$eventValues = \array_values($events);
- /**
- * Return a combined list of table, collection events and if tablesdb present then include all for backward compatibility
- */
+ $databaseType = $database?->getAttribute('type', 'legacy');
+ if ($database !== null && !\in_array($databaseType, ['legacy', 'tablesdb'], true)) {
+ return $eventValues;
+ }
+
return Event::mirrorCollectionEvents($pattern, $eventValues[0], $eventValues);
}
@@ -662,21 +664,30 @@ class Event
}
/**
- * Adds `table` events for `collection` events.
+ * Adds table/collection counterpart events for backward compatibility.
*
* Example:
*
* `databases.*.collections.*.documents.*.update` →\
* `[databases.*.collections.*.documents.*.update, databases.*.tables.*.rows.*.update]`
+ *
+ * `databases.*.tables.*.rows.*.update` →\
+ * `[databases.*.tables.*.rows.*.update, databases.*.collections.*.documents.*.update]`
*/
private static function mirrorCollectionEvents(string $pattern, string $firstEvent, array $events): array
{
- $tableEventMap = [
+ $collectionsToTablesMap = [
'documents' => 'rows',
'collections' => 'tables',
'attributes' => 'columns',
];
+ $tablesToCollectionsMap = [
+ 'rows' => 'documents',
+ 'tables' => 'collections',
+ 'columns' => 'attributes',
+ ];
+
$databasesEventMap = [
'tablesdb' => 'databases',
'tables' => 'collections',
@@ -687,7 +698,10 @@ class Event
if (
(
str_contains($pattern, 'databases.') &&
- str_contains($firstEvent, 'collections')
+ (
+ str_contains($firstEvent, 'collections') ||
+ str_contains($firstEvent, 'tables')
+ )
) ||
(
str_contains($firstEvent, 'tablesdb.')
@@ -698,25 +712,16 @@ class Event
$pairedEvents[] = $event;
// tablesdb needs databases event with tables and collections
if (str_contains($event, 'tablesdb')) {
- $databasesSideEvent = str_replace(
- array_keys($databasesEventMap),
- array_values($databasesEventMap),
- $event
- );
+ $databasesSideEvent = self::replaceEventSegments($event, $databasesEventMap);
$pairedEvents[] = $databasesSideEvent;
- $tableSideEvent = str_replace(
- array_keys($tableEventMap),
- array_values($tableEventMap),
- $databasesSideEvent
- );
+ $tableSideEvent = self::replaceEventSegments($databasesSideEvent, $collectionsToTablesMap);
$pairedEvents[] = $tableSideEvent;
} elseif (str_contains($event, 'collections')) {
- $tableSideEvent = str_replace(
- array_keys($tableEventMap),
- array_values($tableEventMap),
- $event
- );
+ $tableSideEvent = self::replaceEventSegments($event, $collectionsToTablesMap);
$pairedEvents[] = $tableSideEvent;
+ } elseif (str_contains($event, 'tables')) {
+ $collectionSideEvent = self::replaceEventSegments($event, $tablesToCollectionsMap);
+ $pairedEvents[] = $collectionSideEvent;
}
}
@@ -728,6 +733,20 @@ class Event
return array_values(array_unique($events));
}
+ /**
+ * Replace only exact event path segments, never partial substrings.
+ */
+ private static function replaceEventSegments(string $event, array $map): string
+ {
+ $parts = \explode('.', $event);
+ $parts = \array_map(
+ fn (string $part) => $map[$part] ?? $part,
+ $parts
+ );
+
+ return \implode('.', $parts);
+ }
+
/**
* Maps event terminology based on database type
*/
diff --git a/src/Appwrite/Event/Mail.php b/src/Appwrite/Event/Mail.php
deleted file mode 100644
index d8f25489c6..0000000000
--- a/src/Appwrite/Event/Mail.php
+++ /dev/null
@@ -1,554 +0,0 @@
-setQueue(System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME))
- ->setClass(System::getEnv('_APP_MAILS_CLASS_NAME', Event::MAILS_CLASS_NAME));
- }
-
- /**
- * Sets subject for the mail event.
- *
- * @param string $subject
- * @return self
- */
- public function setSubject(string $subject): self
- {
- $this->subject = $subject;
-
- return $this;
- }
-
- /**
- * Returns subject for the mail event.
- *
- * @return string
- */
- public function getSubject(): string
- {
- return $this->subject;
- }
-
- /**
- * Sets recipient for the mail event.
- *
- * @param string $recipient
- * @return self
- */
- public function setRecipient(string $recipient): self
- {
- $this->recipient = $recipient;
-
- return $this;
- }
-
- /**
- * Returns set recipient for mail event.
- *
- * @return string
- */
- public function getRecipient(): string
- {
- return $this->recipient;
- }
-
- /**
- * Sets body for the mail event.
- *
- * @param string $body
- * @return self
- */
- public function setBody(string $body): self
- {
- $this->body = $body;
-
- return $this;
- }
-
- /**
- * Returns body for the mail event.
- *
- * @return string
- */
- public function getBody(): string
- {
- return $this->body;
- }
-
- /**
- * Sets preview for the mail event.
- *
- * @return self
- */
- public function setPreview(string $preview): self
- {
- $this->preview = $preview;
-
- return $this;
- }
-
- /**
- * Returns preview for the mail event.
- *
- * @return string
- */
- public function getPreview(): string
- {
- return $this->preview;
- }
-
- /**
- * Sets name for the mail event.
- *
- * @param string $name
- * @return self
- */
- public function setName(string $name): self
- {
- $this->name = $name;
-
- return $this;
- }
-
- /**
- * Returns set name for the mail event.
- *
- * @return string
- */
- public function getName(): string
- {
- return $this->name;
- }
-
- /**
- * Sets bodyTemplate for the mail event.
- *
- * @param string $bodyTemplate
- * @return self
- */
- public function setBodyTemplate(string $bodyTemplate): self
- {
- $this->bodyTemplate = $bodyTemplate;
-
- return $this;
- }
-
- /**
- * Returns subject for the mail event.
- *
- * @return string
- */
- public function getBodyTemplate(): string
- {
- return $this->bodyTemplate;
- }
-
- /**
- * Set SMTP Host
- *
- * @param string $host
- * @return self
- */
- public function setSmtpHost(string $host): self
- {
- $this->smtp['host'] = $host;
- return $this;
- }
-
- /**
- * Set SMTP port
- *
- * @param int $port
- * @return self
- */
- public function setSmtpPort(int $port): self
- {
- $this->smtp['port'] = $port;
- return $this;
- }
-
- /**
- * Set SMTP username
- *
- * @param string $username
- * @return self
- */
- public function setSmtpUsername(string $username): self
- {
- $this->smtp['username'] = $username;
- return $this;
- }
-
- /**
- * Set SMTP password
- *
- * @param string $password
- * @return self
- */
- public function setSmtpPassword(string $password): self
- {
- $this->smtp['password'] = $password;
- return $this;
- }
-
- /**
- * Set SMTP secure
- *
- * @param string $secure
- * @return self
- */
- public function setSmtpSecure(string $secure): self
- {
- $this->smtp['secure'] = $secure;
- return $this;
- }
-
- /**
- * Set SMTP sender email
- *
- * @param string $senderEmail
- * @return self
- */
- public function setSmtpSenderEmail(string $senderEmail): self
- {
- $this->smtp['senderEmail'] = $senderEmail;
- return $this;
- }
-
- /**
- * Set SMTP sender name
- *
- * @param string $senderName
- * @return self
- */
- public function setSmtpSenderName(string $senderName): self
- {
- $this->smtp['senderName'] = $senderName;
- return $this;
- }
-
- /**
- * Set SMTP reply to
- *
- * @param string $replyTo
- * @return self
- */
- public function setSmtpReplyTo(string $replyTo): self
- {
- $this->smtp['replyTo'] = $replyTo;
- return $this;
- }
-
- /**
- * Get SMTP
- *
- * @return string
- */
- public function getSmtpHost(): string
- {
- return $this->smtp['host'] ?? '';
- }
-
- /**
- * Get SMTP port
- *
- * @return integer
- */
- public function getSmtpPort(): int
- {
- return $this->smtp['port'] ?? 0;
- }
-
- /**
- * Get SMTP username
- *
- * @return string
- */
- public function getSmtpUsername(): string
- {
- return $this->smtp['username'] ?? '';
- }
-
- /**
- * Get SMTP password
- *
- * @return string
- */
- public function getSmtpPassword(): string
- {
- return $this->smtp['password'] ?? '';
- }
-
- /**
- * Get SMTP secure
- *
- * @return string
- */
- public function getSmtpSecure(): string
- {
- return $this->smtp['secure'] ?? '';
- }
-
- /**
- * Get SMTP sender email
- *
- * @return string
- */
- public function getSmtpSenderEmail(): string
- {
- return $this->smtp['senderEmail'] ?? '';
- }
-
- /**
- * Get SMTP sender name
- *
- * @return string
- */
- public function getSmtpSenderName(): string
- {
- return $this->smtp['senderName'] ?? '';
- }
-
- /**
- * Get SMTP reply to
- *
- * @return string
- */
- public function getSmtpReplyTo(): string
- {
- return $this->smtp['replyTo'] ?? '';
- }
-
- /**
- * Get Email Variables
- *
- * @return array
- */
- public function getVariables(): array
- {
- return $this->variables;
- }
-
- /**
- * Set Email Variables
- *
- * @param array $variables
- * @return self
- */
- public function setVariables(array $variables): self
- {
- $this->variables = $variables;
- return $this;
- }
-
- /**
- * Append variables to the email event.
- *
- * @param array $variables
- * @return self
- */
- public function appendVariables(array $variables): self
- {
- $this->variables = \array_merge($this->variables, $variables);
- return $this;
- }
-
- /**
- * Set attachment
- * @param string $content
- * @param string $filename
- * @param string $encoding
- * @param string $type
- * @return self
- */
- public function setAttachment(string $content, string $filename, string $encoding = 'base64', string $type = 'plain/text')
- {
- $this->attachment = [
- 'content' => base64_encode($content),
- 'filename' => $filename,
- 'encoding' => $encoding,
- 'type' => $type,
- ];
- return $this;
- }
-
- /**
- * Get attachment
- *
- * @return array
- */
- public function getAttachment(): array
- {
- return $this->attachment;
- }
-
- /**
- * Reset attachment
- *
- * @return self
- */
- public function resetAttachment(): self
- {
- $this->attachment = [];
- return $this;
- }
-
- /**
- * Set sender email
- *
- * @param string $email
- * @return self
- */
- public function setSenderEmail(string $email): self
- {
- $this->customMailOptions['senderEmail'] = $email;
- return $this;
- }
-
- /**
- * Get sender email
- *
- * @return string
- */
- public function getSenderEmail(): string
- {
- return $this->customMailOptions['senderEmail'] ?? '';
- }
-
- /**
- * Set sender name
- *
- * @param string $name
- * @return self
- */
- public function setSenderName(string $name): self
- {
- $this->customMailOptions['senderName'] = $name;
- return $this;
- }
-
- /**
- * Get sender name
- *
- * @return string
- */
- public function getSenderName(): string
- {
- return $this->customMailOptions['senderName'] ?? '';
- }
-
- /**
- * Set reply-to email
- *
- * @param string $email
- * @return self
- */
- public function setReplyToEmail(string $email): self
- {
- $this->customMailOptions['replyToEmail'] = $email;
- return $this;
- }
-
- /**
- * Get reply-to email
- *
- * @return string
- */
- public function getReplyToEmail(): string
- {
- return $this->customMailOptions['replyToEmail'] ?? '';
- }
-
- /**
- * Set reply-to name
- *
- * @param string $name
- * @return self
- */
- public function setReplyToName(string $name): self
- {
- $this->customMailOptions['replyToName'] = $name;
- return $this;
- }
-
- /**
- * Get reply-to name
- *
- * @return string
- */
- public function getReplyToName(): string
- {
- return $this->customMailOptions['replyToName'] ?? '';
- }
-
- /**
- * Reset
- *
- * @return self
- */
- public function reset(): self
- {
- $this->project = null;
- $this->recipient = '';
- $this->name = '';
- $this->subject = '';
- $this->body = '';
- $this->variables = [];
- $this->bodyTemplate = '';
- $this->attachment = [];
- $this->customMailOptions = [];
- return $this;
- }
-
- /**
- * Prepare the payload for the event
- *
- * @return array
- */
- protected function preparePayload(): array
- {
- $platform = $this->platform;
- if (empty($platform)) {
- $platform = Config::getParam('platform', []);
- }
-
- return [
- 'project' => $this->project,
- 'recipient' => $this->recipient,
- 'name' => $this->name,
- 'subject' => $this->subject,
- 'bodyTemplate' => $this->bodyTemplate,
- 'body' => $this->body,
- 'preview' => $this->preview,
- 'smtp' => $this->smtp,
- 'variables' => $this->variables,
- 'attachment' => $this->attachment,
- 'customMailOptions' => $this->customMailOptions,
- 'events' => Event::generateEvents($this->getEvent(), $this->getParams()),
- 'platform' => $platform,
- ];
- }
-}
diff --git a/src/Appwrite/Event/Message/Audit.php b/src/Appwrite/Event/Message/Audit.php
new file mode 100644
index 0000000000..ae5831c3b9
--- /dev/null
+++ b/src/Appwrite/Event/Message/Audit.php
@@ -0,0 +1,71 @@
+ [
+ '$id' => $this->project->getId(),
+ '$sequence' => $this->project->getSequence(),
+ 'database' => $this->project->getAttribute('database', ''),
+ ],
+ 'user' => $this->user->getArrayCopy(),
+ 'payload' => $this->payload,
+ 'resource' => $this->resource,
+ 'mode' => $this->mode,
+ 'ip' => $this->ip,
+ 'userAgent' => $this->userAgent,
+ 'event' => $this->event,
+ 'hostname' => $this->hostname,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ event: $data['event'] ?? '',
+ payload: $data['payload'] ?? [],
+ project: new Document($data['project'] ?? []),
+ user: new Document($data['user'] ?? []),
+ resource: $data['resource'] ?? '',
+ mode: $data['mode'] ?? '',
+ ip: $data['ip'] ?? '',
+ userAgent: $data['userAgent'] ?? '',
+ hostname: $data['hostname'] ?? '',
+ );
+ }
+
+ public static function fromContext(AuditContext $context): static
+ {
+ return new self(
+ event: $context->event,
+ payload: $context->payload,
+ project: $context->project ?? new Document(),
+ user: $context->user ?? new Document(),
+ resource: $context->resource,
+ mode: $context->mode,
+ ip: $context->ip,
+ userAgent: $context->userAgent,
+ hostname: $context->hostname,
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/Build.php b/src/Appwrite/Event/Message/Build.php
new file mode 100644
index 0000000000..0c8967aff6
--- /dev/null
+++ b/src/Appwrite/Event/Message/Build.php
@@ -0,0 +1,45 @@
+platform) ? $this->platform : Config::getParam('platform', []);
+
+ return [
+ 'project' => $this->project->getArrayCopy(),
+ 'resource' => $this->resource->getArrayCopy(),
+ 'deployment' => $this->deployment->getArrayCopy(),
+ 'type' => $this->type,
+ 'template' => $this->template?->getArrayCopy(),
+ 'platform' => $platform,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ project: new Document($data['project'] ?? []),
+ resource: new Document($data['resource'] ?? []),
+ deployment: new Document($data['deployment'] ?? []),
+ type: $data['type'] ?? '',
+ template: !empty($data['template']) ? new Document($data['template']) : null,
+ platform: $data['platform'] ?? [],
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/Certificate.php b/src/Appwrite/Event/Message/Certificate.php
new file mode 100644
index 0000000000..a189bb8187
--- /dev/null
+++ b/src/Appwrite/Event/Message/Certificate.php
@@ -0,0 +1,43 @@
+ [
+ '$id' => $this->project->getId(),
+ '$sequence' => $this->project->getSequence(),
+ 'database' => $this->project->getAttribute('database', ''),
+ ],
+ 'domain' => $this->domain->getArrayCopy(),
+ 'skipRenewCheck' => $this->skipRenewCheck,
+ 'validationDomain' => $this->validationDomain,
+ 'action' => $this->action,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ project: new Document($data['project'] ?? []),
+ domain: new Document($data['domain'] ?? []),
+ skipRenewCheck: $data['skipRenewCheck'] ?? false,
+ validationDomain: $data['validationDomain'] ?? null,
+ action: $data['action'] ?? \Appwrite\Event\Certificate::ACTION_GENERATION,
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/Database.php b/src/Appwrite/Event/Message/Database.php
new file mode 100644
index 0000000000..1178dcf5c7
--- /dev/null
+++ b/src/Appwrite/Event/Message/Database.php
@@ -0,0 +1,51 @@
+ $this->project?->getArrayCopy(),
+ 'user' => $this->user?->getArrayCopy(),
+ 'type' => $this->type,
+ 'table' => $this->table?->getArrayCopy(),
+ 'row' => $this->row?->getArrayCopy(),
+ 'collection' => $this->collection?->getArrayCopy(),
+ 'document' => $this->document?->getArrayCopy(),
+ 'database' => $this->database?->getArrayCopy(),
+ 'events' => $this->events,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ project: !empty($data['project']) ? new Document($data['project']) : null,
+ user: !empty($data['user']) ? new Document($data['user']) : null,
+ type: $data['type'] ?? '',
+ table: !empty($data['table']) ? new Document($data['table']) : null,
+ row: !empty($data['row']) ? new Document($data['row']) : null,
+ collection: !empty($data['collection']) ? new Document($data['collection']) : null,
+ document: !empty($data['document']) ? new Document($data['document']) : null,
+ database: !empty($data['database']) ? new Document($data['database']) : null,
+ events: $data['events'] ?? [],
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/Delete.php b/src/Appwrite/Event/Message/Delete.php
new file mode 100644
index 0000000000..6866cf3f02
--- /dev/null
+++ b/src/Appwrite/Event/Message/Delete.php
@@ -0,0 +1,45 @@
+ $this->project?->getArrayCopy(),
+ 'type' => $this->type,
+ 'document' => $this->document?->getArrayCopy(),
+ 'resource' => $this->resource,
+ 'resourceType' => $this->resourceType,
+ 'datetime' => $this->datetime,
+ 'hourlyUsageRetentionDatetime' => $this->hourlyUsageRetentionDatetime,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ project: !empty($data['project']) ? new Document($data['project']) : null,
+ type: $data['type'] ?? '',
+ document: !empty($data['document']) ? new Document($data['document']) : null,
+ resource: $data['resource'] ?? null,
+ resourceType: $data['resourceType'] ?? null,
+ datetime: $data['datetime'] ?? null,
+ hourlyUsageRetentionDatetime: $data['hourlyUsageRetentionDatetime'] ?? null,
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/Func.php b/src/Appwrite/Event/Message/Func.php
new file mode 100644
index 0000000000..2a2ae9d90f
--- /dev/null
+++ b/src/Appwrite/Event/Message/Func.php
@@ -0,0 +1,92 @@
+platform) ? $this->platform : Config::getParam('platform', []);
+
+ return [
+ 'project' => $this->project?->getArrayCopy(),
+ 'user' => $this->user?->getArrayCopy(),
+ 'userId' => $this->userId,
+ 'function' => $this->function?->getArrayCopy(),
+ 'functionId' => $this->functionId,
+ 'execution' => $this->execution?->getArrayCopy(),
+ 'type' => $this->type,
+ 'jwt' => $this->jwt,
+ 'payload' => $this->payload,
+ 'events' => $this->events,
+ 'body' => $this->body,
+ 'path' => $this->path,
+ 'headers' => $this->headers,
+ 'method' => $this->method,
+ 'platform' => $platform,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ project: !empty($data['project']) ? new Document($data['project']) : null,
+ user: !empty($data['user']) ? new Document($data['user']) : null,
+ userId: $data['userId'] ?? null,
+ function: !empty($data['function']) ? new Document($data['function']) : null,
+ functionId: $data['functionId'] ?? null,
+ execution: !empty($data['execution']) ? new Document($data['execution']) : null,
+ type: $data['type'] ?? '',
+ jwt: $data['jwt'] ?? '',
+ payload: $data['payload'] ?? [],
+ events: $data['events'] ?? [],
+ body: $data['body'] ?? '',
+ path: $data['path'] ?? '',
+ headers: $data['headers'] ?? [],
+ method: $data['method'] ?? '',
+ platform: $data['platform'] ?? [],
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/Mail.php b/src/Appwrite/Event/Message/Mail.php
new file mode 100644
index 0000000000..aeeea8a616
--- /dev/null
+++ b/src/Appwrite/Event/Message/Mail.php
@@ -0,0 +1,66 @@
+platform) ? $this->platform : Config::getParam('platform', []);
+
+ return [
+ 'project' => $this->project?->getArrayCopy(),
+ 'recipient' => $this->recipient,
+ 'name' => $this->name,
+ 'subject' => $this->subject,
+ 'bodyTemplate' => $this->bodyTemplate,
+ 'body' => $this->body,
+ 'preview' => $this->preview,
+ 'smtp' => $this->smtp,
+ 'variables' => $this->variables,
+ 'attachment' => $this->attachment,
+ 'customMailOptions' => $this->customMailOptions,
+ 'events' => $this->events,
+ 'platform' => $platform,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ project: !empty($data['project']) ? new Document($data['project']) : null,
+ recipient: $data['recipient'] ?? '',
+ name: $data['name'] ?? '',
+ subject: $data['subject'] ?? '',
+ bodyTemplate: $data['bodyTemplate'] ?? '',
+ body: $data['body'] ?? '',
+ preview: $data['preview'] ?? '',
+ smtp: $data['smtp'] ?? [],
+ variables: $data['variables'] ?? [],
+ attachment: $data['attachment'] ?? [],
+ customMailOptions: $data['customMailOptions'] ?? [],
+ events: $data['events'] ?? [],
+ platform: $data['platform'] ?? [],
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/Messaging.php b/src/Appwrite/Event/Message/Messaging.php
new file mode 100644
index 0000000000..7f0f918217
--- /dev/null
+++ b/src/Appwrite/Event/Message/Messaging.php
@@ -0,0 +1,45 @@
+ $this->type,
+ 'project' => $this->project->getArrayCopy(),
+ 'user' => $this->user?->getArrayCopy(),
+ 'messageId' => $this->messageId,
+ 'message' => $this->message?->getArrayCopy(),
+ 'recipients' => $this->recipients,
+ 'providerType' => $this->providerType,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ type: $data['type'] ?? '',
+ project: new Document($data['project'] ?? []),
+ user: !empty($data['user']) ? new Document($data['user']) : null,
+ messageId: $data['messageId'] ?? null,
+ message: !empty($data['message']) ? new Document($data['message']) : null,
+ recipients: $data['recipients'] ?? null,
+ providerType: $data['providerType'] ?? null,
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/Screenshot.php b/src/Appwrite/Event/Message/Screenshot.php
new file mode 100644
index 0000000000..a06cdfbfc0
--- /dev/null
+++ b/src/Appwrite/Event/Message/Screenshot.php
@@ -0,0 +1,34 @@
+ [
+ '$id' => $this->project->getId(),
+ '$sequence' => $this->project->getSequence(),
+ 'database' => $this->project->getAttribute('database', ''),
+ ],
+ 'deploymentId' => $this->deploymentId,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ project: new Document($data['project'] ?? []),
+ deploymentId: $data['deploymentId'] ?? '',
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/StatsResources.php b/src/Appwrite/Event/Message/StatsResources.php
index 584cbc137a..dfca818130 100644
--- a/src/Appwrite/Event/Message/StatsResources.php
+++ b/src/Appwrite/Event/Message/StatsResources.php
@@ -6,8 +6,13 @@ use Utopia\Database\Document;
final class StatsResources extends Base
{
+ /**
+ * @param Document $project
+ * @param array $gauges
+ */
public function __construct(
public readonly Document $project,
+ public readonly array $gauges = [],
) {
}
@@ -15,6 +20,7 @@ final class StatsResources extends Base
{
return [
'project' => $this->project->getArrayCopy(),
+ 'gauges' => $this->gauges,
];
}
@@ -22,6 +28,7 @@ final class StatsResources extends Base
{
return new self(
project: new Document($data['project'] ?? []),
+ gauges: $data['gauges'] ?? [],
);
}
}
diff --git a/src/Appwrite/Event/Messaging.php b/src/Appwrite/Event/Messaging.php
deleted file mode 100644
index 9895d52ec2..0000000000
--- a/src/Appwrite/Event/Messaging.php
+++ /dev/null
@@ -1,182 +0,0 @@
-setQueue(System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME))
- ->setClass(System::getEnv('_APP_MESSAGING_CLASS_NAME', Event::MESSAGING_CLASS_NAME));
- }
-
- /**
- * Sets type for the build event.
- *
- * @param string $type Can be `MESSAGE_SEND_TYPE_INTERNAL` or `MESSAGE_SEND_TYPE_EXTERNAL`.
- * @return self
- */
- public function setType(string $type): self
- {
- $this->type = $type;
-
- return $this;
- }
-
- /**
- * Returns set type for the function event.
- *
- * @return string
- */
- public function getType(): string
- {
- return $this->type;
- }
-
- /**
- * Sets recipient for the messaging event.
- *
- * @param string[] $recipients
- * @return self
- */
- public function setRecipients(array $recipients): self
- {
- $this->recipients = $recipients;
-
- return $this;
- }
-
- /**
- * Returns set recipient for messaging event.
- *
- * @return string[]
- */
- public function getRecipient(): array
- {
- return $this->recipients;
- }
-
- /**
- * Sets message document for the messaging event.
- *
- * @param Document $message
- * @return self
- */
- public function setMessage(Document $message): self
- {
- $this->message = $message;
-
- return $this;
- }
-
- /**
- * Returns message document for the messaging event.
- *
- * @return Document
- */
- public function getMessage(): Document
- {
- return $this->message;
- }
-
- /**
- * Sets message ID for the messaging event.
- *
- * @param string $messageId
- * @return self
- */
- public function setMessageId(string $messageId): self
- {
- $this->messageId = $messageId;
-
- return $this;
- }
-
- /**
- * Returns set message ID for the messaging event.
- *
- * @return string
- */
- public function getMessageId(): string
- {
- return $this->messageId;
- }
-
- /**
- * Sets provider type for the messaging event.
- *
- * @param string $providerType
- * @return self
- */
- public function setProviderType(string $providerType): self
- {
- $this->providerType = $providerType;
-
- return $this;
- }
-
- /**
- * Returns set provider type for the messaging event.
- *
- * @return string
- */
- public function getProviderType(): string
- {
- return $this->providerType;
- }
-
- /**
- * Sets Scheduled delivery time for the messaging event.
- *
- * @param string $scheduledAt
- * @return self
- */
- public function setScheduledAt(string $scheduledAt): self
- {
- $this->scheduledAt = $scheduledAt;
-
- return $this;
- }
-
- /**
- * Returns set Delivery Time for the messaging event.
- *
- * @return string
- */
- public function getScheduledAt(): string
- {
- return $this->scheduledAt;
- }
-
- /**
- * Prepare the payload for the event
- *
- * @return array
- */
- protected function preparePayload(): array
- {
- return [
- 'type' => $this->type,
- 'project' => $this->project,
- 'user' => $this->user,
- 'messageId' => $this->messageId,
- 'message' => $this->message,
- 'recipients' => $this->recipients,
- 'providerType' => $this->providerType,
- ];
- }
-}
diff --git a/src/Appwrite/Event/Publisher/Audit.php b/src/Appwrite/Event/Publisher/Audit.php
new file mode 100644
index 0000000000..daa9a01fce
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Audit.php
@@ -0,0 +1,35 @@
+publish($this->queue, $message);
+ } catch (\Throwable $th) {
+ Console::error('[Audit] Failed to publish audit message: ' . $th->getMessage());
+
+ return false;
+ }
+ }
+
+ public function getSize(bool $failed = false): int
+ {
+ return $this->getQueueSize($this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Event/Publisher/Build.php b/src/Appwrite/Event/Publisher/Build.php
new file mode 100644
index 0000000000..9b2a3b68a0
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Build.php
@@ -0,0 +1,27 @@
+publish($queue ?? $this->queue, $message);
+ }
+
+ public function getSize(bool $failed = false, ?Queue $queue = null): int
+ {
+ return $this->getQueueSize($queue ?? $this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Event/Publisher/Certificate.php b/src/Appwrite/Event/Publisher/Certificate.php
new file mode 100644
index 0000000000..472fb0d701
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Certificate.php
@@ -0,0 +1,27 @@
+publish($this->queue, $message);
+ }
+
+ public function getSize(bool $failed = false): int
+ {
+ return $this->getQueueSize($this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Event/Publisher/Database.php b/src/Appwrite/Event/Publisher/Database.php
new file mode 100644
index 0000000000..09d5c33f03
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Database.php
@@ -0,0 +1,45 @@
+publish($queue ?? $this->getQueueFromProject($message->project), $message);
+ }
+
+ public function getSize(bool $failed = false, ?Queue $queue = null): int
+ {
+ return $this->getQueueSize($queue ?? $this->queue, $failed);
+ }
+
+ private function getQueueFromProject(?Document $project): Queue
+ {
+ $database = $project?->getAttribute('database', '');
+ if (empty($database)) {
+ return $this->queue;
+ }
+
+ try {
+ $dsn = new DSN($database);
+ } catch (\InvalidArgumentException) {
+ $dsn = new DSN('mysql://' . $database);
+ }
+
+ return new Queue($dsn->getHost());
+ }
+}
diff --git a/src/Appwrite/Event/Publisher/Delete.php b/src/Appwrite/Event/Publisher/Delete.php
new file mode 100644
index 0000000000..fb3b46c647
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Delete.php
@@ -0,0 +1,27 @@
+publish($queue ?? $this->queue, $message);
+ }
+
+ public function getSize(bool $failed = false, ?Queue $queue = null): int
+ {
+ return $this->getQueueSize($queue ?? $this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Event/Publisher/Func.php b/src/Appwrite/Event/Publisher/Func.php
new file mode 100644
index 0000000000..46f748a59f
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Func.php
@@ -0,0 +1,27 @@
+publish($queue ?? $this->queue, $message);
+ }
+
+ public function getSize(bool $failed = false, ?Queue $queue = null): int
+ {
+ return $this->getQueueSize($queue ?? $this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Event/Publisher/Mail.php b/src/Appwrite/Event/Publisher/Mail.php
new file mode 100644
index 0000000000..16d48be044
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Mail.php
@@ -0,0 +1,27 @@
+publish($queue ?? $this->queue, $message);
+ }
+
+ public function getSize(bool $failed = false, ?Queue $queue = null): int
+ {
+ return $this->getQueueSize($queue ?? $this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Event/Publisher/Messaging.php b/src/Appwrite/Event/Publisher/Messaging.php
new file mode 100644
index 0000000000..69863566a1
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Messaging.php
@@ -0,0 +1,27 @@
+publish($queue ?? $this->queue, $message);
+ }
+
+ public function getSize(bool $failed = false, ?Queue $queue = null): int
+ {
+ return $this->getQueueSize($queue ?? $this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Event/Publisher/Screenshot.php b/src/Appwrite/Event/Publisher/Screenshot.php
new file mode 100644
index 0000000000..2a0fa1e0f8
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Screenshot.php
@@ -0,0 +1,27 @@
+publish($this->queue, $message);
+ }
+
+ public function getSize(bool $failed = false): int
+ {
+ return $this->getQueueSize($this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Event/StatsResources.php b/src/Appwrite/Event/StatsResources.php
index 07f23feda8..42259f76b9 100644
--- a/src/Appwrite/Event/StatsResources.php
+++ b/src/Appwrite/Event/StatsResources.php
@@ -9,6 +9,18 @@ class StatsResources extends Event
{
protected bool $critical = false;
+ /**
+ * Pre-computed gauge metric snapshots to write to the stats collection. When non-empty,
+ * the StatsResources worker takes the fast path: it writes these directly via
+ * upsertDocuments (replace semantics) and skips the standard counting work.
+ *
+ * Each entry is a tuple of (metric key, value). The worker writes one stats document per
+ * (metric, period) tuple using the project's region.
+ *
+ * @var array
+ */
+ protected array $gauges = [];
+
public function __construct(protected Publisher $publisher)
{
parent::__construct($publisher);
@@ -18,6 +30,35 @@ class StatsResources extends Event
->setClass(System::getEnv('_APP_STATS_RESOURCES_CLASS_NAME', Event::STATS_RESOURCES_CLASS_NAME));
}
+ /**
+ * Set the full set of pre-computed gauge metrics for this message. Replaces any
+ * previously-set gauges.
+ *
+ * @param array $gauges
+ */
+ public function setGauges(array $gauges): self
+ {
+ $this->gauges = $gauges;
+ return $this;
+ }
+
+ /**
+ * Append a single pre-computed gauge metric to this message.
+ */
+ public function addGauge(string $metric, int $value): self
+ {
+ $this->gauges[] = ['metric' => $metric, 'value' => $value];
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function getGauges(): array
+ {
+ return $this->gauges;
+ }
+
/**
* Prepare the payload for the usage event.
*
@@ -26,7 +67,8 @@ class StatsResources extends Event
protected function preparePayload(): array
{
return [
- 'project' => $this->project
+ 'project' => $this->project,
+ 'gauges' => $this->gauges,
];
}
}
diff --git a/src/Appwrite/Event/Validator/Event.php b/src/Appwrite/Event/Validator/Event.php
index a3605e4df5..7a4f4fbcf8 100644
--- a/src/Appwrite/Event/Validator/Event.php
+++ b/src/Appwrite/Event/Validator/Event.php
@@ -44,7 +44,7 @@ class Event extends Validator
/**
* Identify all sections of the pattern.
*/
- $type = $parts[0] ?? false;
+ $type = $parts[0];
$resource = $parts[1] ?? false;
$hasSubResource = $count > 3 && ($events[$type]['$resource'] ?? false) && ($events[$type][$parts[2]]['$resource'] ?? false);
$hasSubSubResource = $count > 5 && $hasSubResource && ($events[$type][$parts[2]][$parts[4]]['$resource'] ?? false);
@@ -61,9 +61,6 @@ class Event extends Validator
if ($hasSubSubResource) {
$subSubType = $parts[4];
$subSubResource = $parts[5];
- if ($count === 8) {
- $attribute = $parts[7];
- }
}
if ($hasSubResource && !$hasSubSubResource) {
diff --git a/src/Appwrite/Event/Webhook.php b/src/Appwrite/Event/Webhook.php
index f6d16c8b14..5cd773a18f 100644
--- a/src/Appwrite/Event/Webhook.php
+++ b/src/Appwrite/Event/Webhook.php
@@ -24,7 +24,7 @@ class Webhook extends Event
public function trimPayload(): array
{
$trimmed = parent::trimPayload();
- if (isset($this->context)) {
+ if (!empty($this->context)) {
$trimmed['context'] = [];
}
return $trimmed;
diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php
index 58a21b5517..6506127c59 100644
--- a/src/Appwrite/Extend/Exception.php
+++ b/src/Appwrite/Extend/Exception.php
@@ -178,6 +178,7 @@ class Exception extends \Exception
public const string FUNCTION_RUNTIME_UNSUPPORTED = 'function_runtime_unsupported';
public const string FUNCTION_ENTRYPOINT_MISSING = 'function_entrypoint_missing';
public const string FUNCTION_SYNCHRONOUS_TIMEOUT = 'function_synchronous_timeout';
+ public const string FUNCTION_ASYNCHRONOUS_TIMEOUT = 'function_asynchronous_timeout';
public const string FUNCTION_TEMPLATE_NOT_FOUND = 'function_template_not_found';
public const string FUNCTION_RUNTIME_NOT_DETECTED = 'function_runtime_not_detected';
public const string FUNCTION_EXECUTE_PERMISSION_MISSING = 'function_execute_permission_missing';
@@ -192,6 +193,7 @@ class Exception extends \Exception
public const string BUILD_ALREADY_COMPLETED = 'build_already_completed';
public const string BUILD_CANCELED = 'build_canceled';
public const string BUILD_FAILED = 'build_failed';
+ public const string BUILD_TIMEOUT = 'build_timeout';
/** Execution */
public const string EXECUTION_NOT_FOUND = 'execution_not_found';
@@ -200,6 +202,10 @@ class Exception extends \Exception
/** Log */
public const string LOG_NOT_FOUND = 'log_not_found';
+ /** Presence */
+ public const string PRESENCE_NOT_FOUND = 'presence_not_found';
+ public const string PRESENCE_ALREADY_EXISTS = 'presence_already_exists';
+
/** Databases */
public const string DATABASE_NOT_FOUND = 'database_not_found';
public const string DATABASE_ALREADY_EXISTS = 'database_already_exists';
@@ -346,6 +352,10 @@ class Exception extends \Exception
public const string MIGRATION_IN_PROGRESS = 'migration_in_progress';
public const string MIGRATION_PROVIDER_ERROR = 'migration_provider_error';
public const string MIGRATION_DATABASE_TYPE_UNSUPPORTED = 'migration_database_type_unsupported';
+ public const string MIGRATION_SOURCE_PROJECT_ID_REQUIRED = 'migration_source_project_id_required';
+ public const string MIGRATION_SOURCE_PROJECT_NOT_FOUND = 'migration_source_project_not_found';
+ public const string MIGRATION_SOURCE_TYPE_INVALID = 'migration_source_type_invalid';
+ public const string MIGRATION_DESTINATION_TYPE_INVALID = 'migration_destination_type_invalid';
/** Realtime */
public const string REALTIME_MESSAGE_FORMAT_INVALID = 'realtime_message_format_invalid';
@@ -384,6 +394,11 @@ class Exception extends \Exception
public const string MESSAGE_TARGET_NOT_PUSH = 'message_target_not_push';
public const string MESSAGE_MISSING_SCHEDULE = 'message_missing_schedule';
+ /** Mocks */
+ public const string MOCK_NUMBER_ALREADY_EXISTS = 'mock_number_already_exists';
+ public const string MOCK_NUMBER_NOT_FOUND = 'mock_number_not_found';
+ public const string MOCK_NUMBER_LIMIT_EXCEEDED = 'mock_number_limit_exceeded';
+
/** Targets */
public const string TARGET_PROVIDER_INVALID_TYPE = 'target_provider_invalid_type';
@@ -395,6 +410,14 @@ class Exception extends \Exception
public const string TOKEN_EXPIRED = 'token_expired';
public const string TOKEN_RESOURCE_TYPE_INVALID = 'token_resource_type_invalid';
+ /** Advisor */
+ public const string INSIGHT_NOT_FOUND = 'insight_not_found';
+ public const string INSIGHT_ALREADY_EXISTS = 'insight_already_exists';
+
+ /** Reports */
+ public const string REPORT_NOT_FOUND = 'report_not_found';
+ public const string REPORT_ALREADY_EXISTS = 'report_already_exists';
+
protected string $type = '';
protected array $errors = [];
protected bool $publish;
diff --git a/src/Appwrite/GraphQL/ResolverLock.php b/src/Appwrite/GraphQL/ResolverLock.php
new file mode 100644
index 0000000000..b1cdcf3d53
--- /dev/null
+++ b/src/Appwrite/GraphQL/ResolverLock.php
@@ -0,0 +1,55 @@
+channel = new Channel(1);
+ }
+
+ /**
+ * Acquire the lock. Re-entering from the same coroutine only
+ * increments depth to avoid self-deadlock.
+ */
+ public function acquire(): void
+ {
+ $cid = Coroutine::getCid();
+
+ if ($this->owner === $cid) {
+ $this->depth++;
+ return;
+ }
+
+ $this->channel->push(true);
+ $this->owner = $cid;
+ $this->depth = 1;
+ }
+
+ /**
+ * Release the lock.
+ */
+ public function release(): void
+ {
+ if ($this->owner !== Coroutine::getCid()) {
+ return;
+ }
+
+ $this->depth--;
+
+ if ($this->depth > 0) {
+ return;
+ }
+
+ $this->owner = null;
+ $this->channel->pop();
+ }
+}
diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php
index 0de97f4953..4471ab53a7 100644
--- a/src/Appwrite/GraphQL/Resolvers.php
+++ b/src/Appwrite/GraphQL/Resolvers.php
@@ -13,6 +13,59 @@ use Utopia\System\System;
class Resolvers
{
+ /**
+ * Request-scoped locks keyed by the per-request GraphQL Http instance.
+ *
+ * @var array
+ */
+ private static array $locks = [];
+
+ /**
+ * Preserve response side effects that callers depend on, such as session
+ * cookies created by account auth routes.
+ */
+ private static function mergeResponseSideEffects(Response $from, Response $to): void
+ {
+ foreach ($from->getCookies() as $cookie) {
+ $to->removeCookie($cookie['name']);
+ $to->addCookie(
+ $cookie['name'],
+ $cookie['value'],
+ $cookie['expire'],
+ $cookie['path'],
+ $cookie['domain'],
+ $cookie['secure'],
+ $cookie['httponly'],
+ $cookie['samesite']
+ );
+ }
+
+ $headers = $from->getHeaders();
+ $fallbackCookies = $headers['X-Fallback-Cookies'] ?? null;
+ if ($fallbackCookies === null) {
+ return;
+ }
+
+ $to->removeHeader('X-Fallback-Cookies');
+ foreach ((array) $fallbackCookies as $value) {
+ $to->addHeader('X-Fallback-Cookies', $value);
+ }
+ }
+
+ /**
+ * Get the request-scoped lock shared by GraphQL resolver coroutines
+ * for the current HTTP request.
+ */
+ private static function getLock(Http $utopia): ResolverLock
+ {
+ $key = \spl_object_hash($utopia);
+ if (!isset(self::$locks[$key])) {
+ self::$locks[$key] = new ResolverLock();
+ }
+
+ return self::$locks[$key];
+ }
+
/**
* Create a resolver for a given API {@see Route}.
*
@@ -24,34 +77,39 @@ class Resolvers
Http $utopia,
?Route $route,
): callable {
- return static fn ($type, $args, $context, $info) => new Swoole(
- function (callable $resolve, callable $reject) use ($utopia, $route, $args) {
- $utopia = $utopia->getResource('utopia:graphql');
- $request = $utopia->getResource('request');
- $response = $utopia->getResource('response');
+ return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $route, $args) {
+ $utopia = $utopia->context()->get('utopia:graphql');
+ $request = $utopia->context()->get('request');
+ $response = $utopia->context()->get('response');
- $path = $route->getPath();
- foreach ($args as $key => $value) {
- if (\str_contains($path, '/:' . $key)) {
- $path = \str_replace(':' . $key, $value, $path);
+ self::resolve(
+ $utopia,
+ $request,
+ $response,
+ $resolve,
+ $reject,
+ prepareRequest: static function (Request $request) use ($route, $args): void {
+ $path = $route->getPath();
+ foreach ($args as $key => $value) {
+ if (\str_contains($path, '/:' . $key)) {
+ $path = \str_replace(':' . $key, $value, $path);
+ }
+ }
+
+ $request->setMethod($route->getMethod());
+ $request->setURI($path);
+
+ switch ($route->getMethod()) {
+ case 'GET':
+ $request->setQueryString($args);
+ break;
+ default:
+ $request->setPayload($args);
+ break;
}
}
-
- $request->setMethod($route->getMethod());
- $request->setURI($path);
-
- switch ($route->getMethod()) {
- case 'GET':
- $request->setQueryString($args);
- break;
- default:
- $request->setPayload($args);
- break;
- }
-
- self::resolve($utopia, $request, $response, $resolve, $reject);
- }
- );
+ );
+ });
}
/**
@@ -91,18 +149,23 @@ class Resolvers
string $collectionId,
callable $url,
): callable {
- return static fn ($type, $args, $context, $info) => new Swoole(
- function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) {
- $utopia = $utopia->getResource('utopia:graphql');
- $request = $utopia->getResource('request');
- $response = $utopia->getResource('response');
+ return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) {
+ $utopia = $utopia->context()->get('utopia:graphql');
+ $request = $utopia->context()->get('request');
+ $response = $utopia->context()->get('response');
- $request->setMethod('GET');
- $request->setURI($url($databaseId, $collectionId, $args));
-
- self::resolve($utopia, $request, $response, $resolve, $reject);
- }
- );
+ self::resolve(
+ $utopia,
+ $request,
+ $response,
+ $resolve,
+ $reject,
+ prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $args): void {
+ $request->setMethod('GET');
+ $request->setURI($url($databaseId, $collectionId, $args));
+ }
+ );
+ });
}
/**
@@ -122,23 +185,29 @@ class Resolvers
callable $url,
callable $params,
): callable {
- return static fn ($type, $args, $context, $info) => new Swoole(
- function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) {
- $utopia = $utopia->getResource('utopia:graphql');
- $request = $utopia->getResource('request');
- $response = $utopia->getResource('response');
+ return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) {
+ $utopia = $utopia->context()->get('utopia:graphql');
+ $request = $utopia->context()->get('request');
+ $response = $utopia->context()->get('response');
- $request->setMethod('GET');
- $request->setURI($url($databaseId, $collectionId, $args));
- $request->setQueryString($params($databaseId, $collectionId, $args));
+ $beforeResolve = function ($payload) {
+ return $payload['documents'];
+ };
- $beforeResolve = function ($payload) {
- return $payload['documents'];
- };
-
- self::resolve($utopia, $request, $response, $resolve, $reject, $beforeResolve);
- }
- );
+ self::resolve(
+ $utopia,
+ $request,
+ $response,
+ $resolve,
+ $reject,
+ beforeResolve: $beforeResolve,
+ prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void {
+ $request->setMethod('GET');
+ $request->setURI($url($databaseId, $collectionId, $args));
+ $request->setQueryString($params($databaseId, $collectionId, $args));
+ }
+ );
+ });
}
/**
@@ -158,19 +227,24 @@ class Resolvers
callable $url,
callable $params,
): callable {
- return static fn ($type, $args, $context, $info) => new Swoole(
- function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) {
- $utopia = $utopia->getResource('utopia:graphql');
- $request = $utopia->getResource('request');
- $response = $utopia->getResource('response');
+ return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) {
+ $utopia = $utopia->context()->get('utopia:graphql');
+ $request = $utopia->context()->get('request');
+ $response = $utopia->context()->get('response');
- $request->setMethod('POST');
- $request->setURI($url($databaseId, $collectionId, $args));
- $request->setPayload($params($databaseId, $collectionId, $args));
-
- self::resolve($utopia, $request, $response, $resolve, $reject);
- }
- );
+ self::resolve(
+ $utopia,
+ $request,
+ $response,
+ $resolve,
+ $reject,
+ prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void {
+ $request->setMethod('POST');
+ $request->setURI($url($databaseId, $collectionId, $args));
+ $request->setPayload($params($databaseId, $collectionId, $args));
+ }
+ );
+ });
}
/**
@@ -190,19 +264,24 @@ class Resolvers
callable $url,
callable $params,
): callable {
- return static fn ($type, $args, $context, $info) => new Swoole(
- function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) {
- $utopia = $utopia->getResource('utopia:graphql');
- $request = $utopia->getResource('request');
- $response = $utopia->getResource('response');
+ return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) {
+ $utopia = $utopia->context()->get('utopia:graphql');
+ $request = $utopia->context()->get('request');
+ $response = $utopia->context()->get('response');
- $request->setMethod('PATCH');
- $request->setURI($url($databaseId, $collectionId, $args));
- $request->setPayload($params($databaseId, $collectionId, $args));
-
- self::resolve($utopia, $request, $response, $resolve, $reject);
- }
- );
+ self::resolve(
+ $utopia,
+ $request,
+ $response,
+ $resolve,
+ $reject,
+ prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void {
+ $request->setMethod('PATCH');
+ $request->setURI($url($databaseId, $collectionId, $args));
+ $request->setPayload($params($databaseId, $collectionId, $args));
+ }
+ );
+ });
}
/**
@@ -220,18 +299,23 @@ class Resolvers
string $collectionId,
callable $url,
): callable {
- return static fn ($type, $args, $context, $info) => new Swoole(
- function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) {
- $utopia = $utopia->getResource('utopia:graphql');
- $request = $utopia->getResource('request');
- $response = $utopia->getResource('response');
+ return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) {
+ $utopia = $utopia->context()->get('utopia:graphql');
+ $request = $utopia->context()->get('request');
+ $response = $utopia->context()->get('response');
- $request->setMethod('DELETE');
- $request->setURI($url($databaseId, $collectionId, $args));
-
- self::resolve($utopia, $request, $response, $resolve, $reject);
- }
- );
+ self::resolve(
+ $utopia,
+ $request,
+ $response,
+ $resolve,
+ $reject,
+ prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $args): void {
+ $request->setMethod('DELETE');
+ $request->setURI($url($databaseId, $collectionId, $args));
+ }
+ );
+ });
}
/**
@@ -241,7 +325,7 @@ class Resolvers
* @param callable $resolve
* @param callable $reject
* @param callable|null $beforeResolve
- * @param callable|null $beforeReject
+ * @param callable|null $prepareRequest
* @return void
* @throws Exception
*/
@@ -252,45 +336,67 @@ class Resolvers
callable $resolve,
callable $reject,
?callable $beforeResolve = null,
- ?callable $beforeReject = null,
+ ?callable $prepareRequest = null,
): void {
- // Drop json content type so post args are used directly
- if (\str_starts_with($request->getHeader('content-type'), 'application/json')) {
- $request->removeHeader('content-type');
- }
+ $lock = self::getLock($utopia);
- $request = clone $request;
- $utopia->setResource('request', static fn () => $request);
- $response->setContentType(Response::CONTENT_TYPE_NULL);
- $response->clearSent();
+ $lock->acquire();
$original = $utopia->getRoute();
-
try {
- $route = $utopia->match($request, fresh: true);
+ $request = clone $request;
+ $request->addHeader('x-appwrite-source', 'graphql');
- $utopia->execute($route, $request, $response);
- } catch (\Throwable $e) {
- if ($beforeReject) {
- $e = $beforeReject($e);
+ // Drop json content type so post args are used directly.
+ if (\str_starts_with($request->getHeader('content-type'), 'application/json')) {
+ $request->removeHeader('content-type');
}
+
+ if ($prepareRequest) {
+ $prepareRequest($request);
+ }
+
+ /** @var Response $resolverResponse */
+ $resolverResponse = clone $utopia->context()->get('response');
+ $utopia->context()->set('request', static fn () => $request);
+ $utopia->context()->set('response', static fn () => $resolverResponse);
+ $resolverResponse->setContentType(Response::CONTENT_TYPE_NULL);
+ $resolverResponse->setSent(false);
+
+ $route = $utopia->match($request, fresh: true);
+ $request->setRoute($route);
+
+ $utopia->execute($route, $request, $resolverResponse);
+
+ self::mergeResponseSideEffects($resolverResponse, $response);
+
+ if ($resolverResponse->isSent()) {
+ $response
+ ->setStatusCode($resolverResponse->getStatusCode())
+ ->setSent(true);
+
+ $resolve(null);
+ return;
+ }
+
+ $payload = $resolverResponse->getPayload();
+ $statusCode = $resolverResponse->getStatusCode();
+ } catch (\Throwable $e) {
$reject($e);
return;
} finally {
if ($original !== null) {
$utopia->setRoute($original);
}
+
+ $lock->release();
+ unset(self::$locks[\spl_object_hash($utopia)]);
}
- $payload = $response->getPayload();
-
- if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 400) {
- if ($beforeReject) {
- $payload = $beforeReject($payload);
- }
+ if ($statusCode < 200 || $statusCode >= 400) {
$reject(new GQLException(
message: $payload['message'],
- code: $response->getStatusCode()
+ code: $statusCode
));
return;
}
diff --git a/src/Appwrite/GraphQL/Schema.php b/src/Appwrite/GraphQL/Schema.php
index 4ff96fb635..a689655e31 100644
--- a/src/Appwrite/GraphQL/Schema.php
+++ b/src/Appwrite/GraphQL/Schema.php
@@ -84,7 +84,7 @@ class Schema
protected static function api(Http $utopia, callable $complexity): array
{
Mapper::init($utopia
- ->getResource('response')
+ ->context()->get('response')
->getModels());
$queries = [];
diff --git a/src/Appwrite/GraphQL/Types/Mapper.php b/src/Appwrite/GraphQL/Types/Mapper.php
index 53474b855a..92c7753ad2 100644
--- a/src/Appwrite/GraphQL/Types/Mapper.php
+++ b/src/Appwrite/GraphQL/Types/Mapper.php
@@ -91,26 +91,20 @@ class Mapper
}
}
- $responses = $method->getResponses() ?? [];
+ $responses = $method->getResponses();
- // If responses is an array, map each response to its model
- if (\is_array($responses)) {
- $models = [];
- foreach ($responses as $response) {
- $modelName = $response->getModel();
+ // Map each response to its model
+ $models = [];
+ foreach ($responses as $response) {
+ $modelName = $response->getModel();
- if (\is_array($modelName)) {
- foreach ($modelName as $name) {
- $models[] = self::$models[$name];
- }
- } else {
- $models[] = self::$models[$modelName];
+ if (\is_array($modelName)) {
+ foreach ($modelName as $name) {
+ $models[] = self::$models[$name];
}
+ } else {
+ $models[] = self::$models[$modelName];
}
- } else {
- // If single response, get its model and wrap in array
- $modelName = $responses->getModel();
- $models = [self::$models[$modelName]];
}
foreach ($models as $model) {
@@ -260,7 +254,7 @@ class Mapper
array $injections
): Type {
$validator = \is_callable($validator)
- ? \call_user_func_array($validator, $utopia->getResources($injections))
+ ? \call_user_func_array($validator, \array_map($utopia->context()->get(...), $injections))
: $validator;
$isNullable = $validator instanceof Nullable;
diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php
index f1d806bcc5..dbb2c826bd 100644
--- a/src/Appwrite/Messaging/Adapter/Realtime.php
+++ b/src/Appwrite/Messaging/Adapter/Realtime.php
@@ -14,6 +14,29 @@ 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',
+ 'presences'
+ ];
+
/**
* Connection Tree
*
@@ -22,6 +45,7 @@ class Realtime extends MessagingAdapter
* 'roles' -> [ROLE_x, ROLE_Y]
* 'userId' -> [USER_ID]
* 'channels' -> [CHANNEL_NAME_X, CHANNEL_NAME_Y, CHANNEL_NAME_Z]
+ * 'presences' -> [PRESENCE_ID_1, PRESENCE_ID_2, ...]
*/
public array $connections = [];
@@ -45,8 +69,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 +76,7 @@ class Realtime extends MessagingAdapter
global $register;
$this->pubSubPool = new PubSubPool($register->get('pools')->get('pubsub'));
}
+
return $this->pubSubPool;
}
@@ -114,14 +137,25 @@ class Realtime extends MessagingAdapter
}
}
- // Keep userId from onOpen/authentication when provided.
- // Fallback to existing stored value for subsequent subscribe upserts.
- $this->connections[$identifier] = [
+ // Union channels/roles across all subscriptions on the connection; overwriting would
+ // leave getSubscriptionMetadata and full unsubscribe operating on stale state.
+ $existing = $this->connections[$identifier] ?? [];
+ $existingChannels = $existing['channels'] ?? [];
+ $existingRoles = $existing['roles'] ?? [];
+
+ $entry = [
'projectId' => $projectId,
- 'roles' => $roles,
- 'userId' => $userId ?? ($this->connections[$identifier]['userId'] ?? ''),
- 'channels' => $channels
+ 'roles' => \array_values(\array_unique(\array_merge($existingRoles, $roles))),
+ 'userId' => $userId ?? ($existing['userId'] ?? ''),
+ 'channels' => \array_values(\array_unique(\array_merge($existingChannels, $channels))),
+ 'presences' => $this->connections[$identifier]['presences'] ?? []
];
+
+ if (\array_key_exists('authorization', $existing)) {
+ $entry['authorization'] = $existing['authorization'];
+ }
+
+ $this->connections[$identifier] = $entry;
}
/**
@@ -137,7 +171,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 [];
}
@@ -158,7 +192,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'])) {
@@ -171,6 +205,74 @@ class Realtime extends MessagingAdapter
return $subscriptions;
}
+ /**
+ * Dedup delete presence triggers.
+ * Scenario: when client is connected to realtime and a delete call is made throught rest.
+ * If not dedupe then two delete events will get triggered. So remove the presenceIds
+ *
+ * @param string $projectId
+ * @param string $presenceId
+ * @return int Number of connections whose presences map was updated.
+ */
+ public function removePresenceFromConnections(string $projectId, string $presenceId): int
+ {
+ if ($projectId === '' || $presenceId === '') {
+ return 0;
+ }
+
+ $removed = 0;
+ foreach ($this->connections as $connectionId => $connection) {
+ if (($connection['projectId'] ?? null) !== $projectId) {
+ continue;
+ }
+ if (!isset($connection['presences'][$presenceId])) {
+ continue;
+ }
+ unset($this->connections[$connectionId]['presences'][$presenceId]);
+ $removed++;
+ }
+
+ return $removed;
+ }
+
+ /**
+ * Returns the presence ID carried by a `presences.{id}.delete` event payload,
+ * or null when the event is not a presence delete.
+ *
+ * @param array $event Decoded pubsub payload produced by self::send().
+ * @return string|null
+ */
+ public static function extractDeletedPresenceId(array $event): ?string
+ {
+ $events = $event['data']['events'] ?? [];
+ if (!\is_array($events)) {
+ return null;
+ }
+
+ $isPresenceDelete = false;
+ foreach ($events as $eventName) {
+ if (
+ \is_string($eventName)
+ && \str_starts_with($eventName, 'presences.')
+ && \str_ends_with($eventName, '.delete')
+ ) {
+ $isPresenceDelete = true;
+ break;
+ }
+ }
+
+ if (!$isPresenceDelete) {
+ return null;
+ }
+
+ $presenceId = $event['data']['payload']['$id'] ?? null;
+ if (!\is_string($presenceId) || $presenceId === '') {
+ return null;
+ }
+
+ return $presenceId;
+ }
+
/**
* Removes all subscriptions for a connection.
*
@@ -206,6 +308,87 @@ class Realtime extends MessagingAdapter
}
}
+ /**
+ * Removes a single subscription from a connection, keeping the connection alive so
+ * the client can resubscribe. Idempotent — returns true only when something was removed.
+ *
+ * @param mixed $connection
+ * @param string $subscriptionId
+ * @return bool
+ */
+ public function unsubscribeSubscription(mixed $connection, string $subscriptionId): bool
+ {
+ $projectId = $this->connections[$connection]['projectId'] ?? '';
+ if ($projectId === '' || !isset($this->subscriptions[$projectId])) {
+ return false;
+ }
+
+ $removed = false;
+
+ foreach ($this->subscriptions[$projectId] as $role => $byChannel) {
+ foreach ($byChannel as $channel => $byConnection) {
+ if (!isset($byConnection[$connection][$subscriptionId])) {
+ continue;
+ }
+
+ unset($this->subscriptions[$projectId][$role][$channel][$connection][$subscriptionId]);
+ $removed = true;
+
+ if (empty($this->subscriptions[$projectId][$role][$channel][$connection])) {
+ unset($this->subscriptions[$projectId][$role][$channel][$connection]);
+ }
+ if (empty($this->subscriptions[$projectId][$role][$channel])) {
+ unset($this->subscriptions[$projectId][$role][$channel]);
+ }
+ }
+ if (empty($this->subscriptions[$projectId][$role])) {
+ unset($this->subscriptions[$projectId][$role]);
+ }
+ }
+
+ if (empty($this->subscriptions[$projectId])) {
+ unset($this->subscriptions[$projectId]);
+ }
+
+ if ($removed) {
+ $this->recomputeConnectionState($connection);
+ }
+
+ return $removed;
+ }
+
+ /**
+ * Recomputes the cached channels on the connection entry from the subscriptions tree.
+ * Called after per-subscription removal so stale channel entries do not linger for later reads.
+ *
+ * Roles are deliberately NOT recomputed here. They represent the connection's authorization
+ * context (set at onOpen, replaced on `authentication` / permission-change) and must survive
+ * per-subscription removal — otherwise a client that unsubscribes every subscription and then
+ * resubscribes would subscribe with an empty roles array and silently receive nothing.
+ *
+ * @param mixed $connection
+ * @return void
+ */
+ private function recomputeConnectionState(mixed $connection): void
+ {
+ if (!isset($this->connections[$connection])) {
+ return;
+ }
+
+ $projectId = $this->connections[$connection]['projectId'] ?? '';
+ $channels = [];
+
+ foreach ($this->subscriptions[$projectId] ?? [] as $byChannel) {
+ foreach ($byChannel as $channel => $byConnection) {
+ if (isset($byConnection[$connection])) {
+ $channels[$channel] = true;
+ }
+ }
+ }
+
+ $this->connections[$connection]['channels'] = \array_keys($channels);
+ }
+
/**
* Checks if Channel has a subscriber.
* @param string $projectId
@@ -215,7 +398,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]);
@@ -236,6 +419,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
@@ -256,8 +440,8 @@ class Realtime extends MessagingAdapter
'events' => $events,
'channels' => $channels,
'timestamp' => DateTime::formatTz(DateTime::now()),
- 'payload' => $payload
- ]
+ 'payload' => $payload,
+ ],
]));
}
@@ -270,7 +454,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 Map of connection IDs to matched query groups
*/
public function getSubscribers(array $event): array
@@ -286,7 +469,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;
@@ -319,6 +502,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
@@ -330,27 +519,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
@@ -361,6 +617,7 @@ class Realtime extends MessagingAdapter
* Reserved channel params with expected type
* If matched the expected type then skip the query parsing like in project
*/
+ /** @var array $reservedParamExpectedTypes */
$reservedParamExpectedTypes = [
'project' => 'string',
];
@@ -374,7 +631,6 @@ class Realtime extends MessagingAdapter
$isExpectedType = match ($expectedType) {
'array' => \is_array($params),
'string' => \is_string($params),
- default => false,
};
// If the value matches the expected type dont use it the queries
@@ -391,10 +647,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];
}
@@ -421,6 +678,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
@@ -433,7 +691,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}"
);
@@ -587,13 +845,75 @@ class Realtime extends MessagingAdapter
$roles = [Role::team($project->getAttribute('teamId'))->toString()];
}
break;
+ case 'reports':
+ // Plain report event: `reports.{reportId}.{action}`
+ $channels[] = 'reports';
+ if (isset($parts[1])) {
+ $channels[] = 'reports.' . $parts[1];
+ }
+ // Nested insight event: `reports.{reportId}.insights.{insightId}.{action}`
+ if (isset($parts[2]) && $parts[2] === 'insights') {
+ $channels[] = 'reports.' . $parts[1] . '.insights';
+ if (isset($parts[3])) {
+ $channels[] = 'reports.' . $parts[1] . '.insights.' . $parts[3];
+ }
+ }
+ $roles = [Role::team($project->getAttribute('teamId'))->toString()];
+ break;
+ case 'presences':
+ $channels[] = 'presences';
+ $channels[] = 'presences.' . $parts[1];
+ $roles = $payload->getRead();
+ 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,
];
}
diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php
index a01031de9b..77c62bce96 100644
--- a/src/Appwrite/Migration/Migration.php
+++ b/src/Appwrite/Migration/Migration.php
@@ -94,6 +94,10 @@ abstract class Migration
'1.8.1' => 'V23',
'1.9.0' => 'V24',
'1.9.1' => 'V24',
+ '1.9.2' => 'V24',
+ '1.9.3' => 'V24',
+ '1.9.4' => 'V24',
+ '1.9.5' => 'V24',
];
/**
diff --git a/src/Appwrite/Migration/Version/V17.php b/src/Appwrite/Migration/Version/V17.php
index 3297206ccd..862ab7f26c 100644
--- a/src/Appwrite/Migration/Version/V17.php
+++ b/src/Appwrite/Migration/Version/V17.php
@@ -262,7 +262,7 @@ class V17 extends Migration
* Set default maxSessions
*/
$document->setAttribute('auths', array_merge($document->getAttribute('auths', []), [
- 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT
+ 'maxSessions' => 10
]));
break;
case 'users':
diff --git a/src/Appwrite/Migration/Version/V24.php b/src/Appwrite/Migration/Version/V24.php
index a2d9d7907b..0aa67cb74e 100644
--- a/src/Appwrite/Migration/Version/V24.php
+++ b/src/Appwrite/Migration/Version/V24.php
@@ -55,6 +55,9 @@ class V24 extends Migration
if ($this->project->getSequence() != 'console') {
Console::info('Migrating Databases');
$this->migrateDatabases();
+
+ Console::info('Creating presence logs collection');
+ $this->createPresenceLogsCollection();
}
Console::info('Migrating Buckets');
@@ -330,6 +333,30 @@ class V24 extends Migration
});
}
+ /**
+ * Ensure the presenceLogs collection exists for project databases.
+ *
+ * @return void
+ * @throws Throwable
+ */
+ private function createPresenceLogsCollection(): void
+ {
+ $collectionId = 'presenceLogs';
+
+ try {
+ Console::info("Ensuring collection \"{$collectionId}\" exists for project \"{$this->project->getId()}\".");
+ $this->dbForProject->purgeCachedCollection($collectionId);
+ $this->dbForProject->purgeCachedDocument(Database::METADATA, $collectionId);
+
+ $this->createCollection($collectionId);
+ } catch (Throwable $th) {
+ Console::warning("Failed to create collection \"{$collectionId}\": {$th->getMessage()}");
+
+ // Re-throw so the migration fails fast and doesn't leave the system in a partially migrated state.
+ throw $th;
+ }
+ }
+
/**
* Migrate all Bucket tables
*
diff --git a/src/Appwrite/OpenSSL/OpenSSL.php b/src/Appwrite/OpenSSL/OpenSSL.php
index 787feb0904..89c52f069e 100644
--- a/src/Appwrite/OpenSSL/OpenSSL.php
+++ b/src/Appwrite/OpenSSL/OpenSSL.php
@@ -16,7 +16,7 @@ class OpenSSL
* @param string $aad
* @param int $tag_length
*
- * @return string
+ * @return string|false
*/
public static function encrypt($data, $method, $key, $options = 0, $iv = '', ?string &$tag = null, $aad = '', $tag_length = 16)
{
diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php
index 06312d9cb2..4bf3ed4768 100644
--- a/src/Appwrite/Platform/Appwrite.php
+++ b/src/Appwrite/Platform/Appwrite.php
@@ -3,12 +3,16 @@
namespace Appwrite\Platform;
use Appwrite\Platform\Modules\Account;
+use Appwrite\Platform\Modules\Advisor;
use Appwrite\Platform\Modules\Avatars;
use Appwrite\Platform\Modules\Console;
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\Organization;
+use Appwrite\Platform\Modules\Presences;
use Appwrite\Platform\Modules\Project;
use Appwrite\Platform\Modules\Projects;
use Appwrite\Platform\Modules\Proxy;
@@ -29,6 +33,7 @@ class Appwrite extends Platform
$this->addModule(new Avatars\Module());
$this->addModule(new Databases\Module());
$this->addModule(new Projects\Module());
+ $this->addModule(new Presences\Module());
$this->addModule(new Functions\Module());
$this->addModule(new Health\Module());
$this->addModule(new Sites\Module());
@@ -39,6 +44,9 @@ 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 Organization\Module());
$this->addModule(new Project\Module());
+ $this->addModule(new Advisor\Module());
}
}
diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php b/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php
index ab0037f4b2..876dc00215 100644
--- a/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php
+++ b/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php
@@ -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;
}
diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Install.php b/src/Appwrite/Platform/Installer/Http/Installer/Install.php
index 8aaaf621bb..e7e9008e3b 100644
--- a/src/Appwrite/Platform/Installer/Http/Installer/Install.php
+++ b/src/Appwrite/Platform/Installer/Http/Installer/Install.php
@@ -240,9 +240,7 @@ class Install extends Action
$inputValue = trim($inputValue);
}
if ($storedValue !== $inputValue) {
- if ($installId !== '') {
- $state->updateGlobalLock($installId, Server::STATUS_ERROR);
- }
+ $state->updateGlobalLock($installId, Server::STATUS_ERROR);
$this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch');
return;
}
@@ -262,16 +260,12 @@ class Install extends Action
$incomingHash = $state->hashSensitiveValue($incomingValue);
if (isset($stored[$hashField])) {
if (!hash_equals((string) $stored[$hashField], $incomingHash)) {
- if ($installId !== '') {
- $state->updateGlobalLock($installId, Server::STATUS_ERROR);
- }
+ $state->updateGlobalLock($installId, Server::STATUS_ERROR);
$this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch');
return;
}
} elseif (isset($stored[$field]) && $incomingValue !== '' && (string) $stored[$field] !== $incomingValue) {
- if ($installId !== '') {
- $state->updateGlobalLock($installId, Server::STATUS_ERROR);
- }
+ $state->updateGlobalLock($installId, Server::STATUS_ERROR);
$this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch');
return;
}
@@ -430,7 +424,7 @@ class Install extends Action
private function deriveNameFromEmail(string $email): string
{
$parts = explode('@', $email);
- $username = $parts[0] ?? '';
+ $username = $parts[0];
$cleaned = preg_replace('/[^a-zA-Z0-9]/', '', $username);
return ucfirst($cleaned);
}
diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Status.php b/src/Appwrite/Platform/Installer/Http/Installer/Status.php
index d6ffa64c8f..204ace077c 100644
--- a/src/Appwrite/Platform/Installer/Http/Installer/Status.php
+++ b/src/Appwrite/Platform/Installer/Http/Installer/Status.php
@@ -45,7 +45,7 @@ class Status extends Action
}
$data = $state->readProgressFile($installId);
- if (is_array($data) && isset($data['payload']) && is_array($data['payload'])) {
+ if (isset($data['payload']) && is_array($data['payload'])) {
unset(
$data['payload']['opensslKey'],
$data['payload']['assistantOpenAIKey'],
@@ -54,7 +54,7 @@ class Status extends Action
);
}
// Strip sensitive data from step details
- if (is_array($data) && isset($data['details']) && is_array($data['details'])) {
+ if (isset($data['details']) && is_array($data['details'])) {
foreach ($data['details'] as $stepKey => &$stepDetails) {
if (is_array($stepDetails)) {
unset($stepDetails['sessionSecret'], $stepDetails['trace']);
diff --git a/src/Appwrite/Platform/Installer/Runtime/Config.php b/src/Appwrite/Platform/Installer/Runtime/Config.php
index 99db12dfed..6142e47152 100644
--- a/src/Appwrite/Platform/Installer/Runtime/Config.php
+++ b/src/Appwrite/Platform/Installer/Runtime/Config.php
@@ -218,7 +218,7 @@ final class Config
}
/**
- * @param string[] $value
+ * @param array $value
*/
public function setEnabledDatabases(array $value): void
{
diff --git a/src/Appwrite/Platform/Installer/Runtime/State.php b/src/Appwrite/Platform/Installer/Runtime/State.php
index 75efd7027c..3cbcc51fa6 100644
--- a/src/Appwrite/Platform/Installer/Runtime/State.php
+++ b/src/Appwrite/Platform/Installer/Runtime/State.php
@@ -19,13 +19,11 @@ class State
private const int PORT_MIN = 1;
private const int PORT_MAX = 65535;
- private array $paths;
private bool $bootstrapped = false;
private int $lastStaleLockClearAt = 0;
- public function __construct(array $paths)
+ public function __construct()
{
- $this->paths = $paths;
}
public function buildConfig(array $overrides = [], bool $useEnv = true): Config
@@ -180,7 +178,7 @@ class State
if (!preg_match(self::PATTERN_IPV6_WITH_PORT, $value, $matches)) {
return false;
}
- $host = $matches[1] ?? '';
+ $host = $matches[1];
$port = $matches[2] ?? null;
} else {
$parts = explode(':', $value);
diff --git a/src/Appwrite/Platform/Installer/Server.php b/src/Appwrite/Platform/Installer/Server.php
index 99ec9e65d2..26d82adf24 100644
--- a/src/Appwrite/Platform/Installer/Server.php
+++ b/src/Appwrite/Platform/Installer/Server.php
@@ -60,7 +60,7 @@ class Server
{
$this->initPaths();
- $this->state = new State($this->paths);
+ $this->state = new State();
if (PHP_SAPI === 'cli') {
$this->runCli();
@@ -154,7 +154,7 @@ class Server
$nativeServer = $adapter->getNativeServer();
- $container = $adapter->getContainer();
+ $container = $adapter->resources();
$container->set('installerState', fn () => $state);
$container->set('installerConfig', fn () => $config);
$container->set('installerPaths', fn () => $paths);
diff --git a/src/Appwrite/Platform/Installer/Validator/AppDomain.php b/src/Appwrite/Platform/Installer/Validator/AppDomain.php
index f631015654..5d18b5214a 100644
--- a/src/Appwrite/Platform/Installer/Validator/AppDomain.php
+++ b/src/Appwrite/Platform/Installer/Validator/AppDomain.php
@@ -47,7 +47,7 @@ class AppDomain extends Validator
if (!preg_match(self::PATTERN_IPV6_WITH_PORT, $value, $matches)) {
return false;
}
- $host = $matches[1] ?? '';
+ $host = $matches[1];
$port = $matches[2] ?? null;
} else {
$parts = explode(':', $value);
diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php
index 754255be15..5765c5bf6e 100644
--- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php
+++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php
@@ -37,8 +37,8 @@ class Delete extends Action
->label('event', 'users.[userId].delete.mfa')
->label('scope', 'account')
->label('audits.event', 'user.update')
- ->label('audits.resource', 'user/{response.$id}')
- ->label('audits.userId', '{response.$id}')
+ ->label('audits.resource', 'user/{user.$id}')
+ ->label('audits.userId', '{user.$id}')
->label('sdk', [
new Method(
namespace: 'account',
diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php
index 20a6afed2e..285875eb35 100644
--- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php
+++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php
@@ -5,8 +5,10 @@ namespace Appwrite\Platform\Modules\Account\Http\Account\MFA\Challenges;
use Appwrite\Auth\MFA\Type;
use Appwrite\Detector\Detector;
use Appwrite\Event\Event;
-use Appwrite\Event\Mail;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Message\Mail as MailMessage;
+use Appwrite\Event\Message\Messaging as MessagingMessage;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -101,8 +103,8 @@ class Create extends Action
->inject('platform')
->inject('request')
->inject('queueForEvents')
- ->inject('queueForMessaging')
- ->inject('queueForMails')
+ ->inject('publisherForMessaging')
+ ->inject('publisherForMails')
->inject('timelimit')
->inject('usage')
->inject('plan')
@@ -121,8 +123,8 @@ class Create extends Action
array $platform,
Request $request,
Event $queueForEvents,
- Messaging $queueForMessaging,
- Mail $queueForMails,
+ MessagingPublisher $publisherForMessaging,
+ MailPublisher $publisherForMails,
callable $timelimit,
Context $usage,
array $plan,
@@ -170,11 +172,6 @@ class Create extends Action
$message = Template::fromFile($templatesPath . '/sms-base.tpl');
- $customTemplate = $project->getAttribute('templates', [])['sms.mfaChallenge-' . $locale->default] ?? [];
- if (!empty($customTemplate)) {
- $message = $customTemplate['message'] ?? $message;
- }
-
$messageContent = Template::fromString($locale->getText("sms.verification.body"));
$messageContent
->setParam('{{project}}', $projectName)
@@ -185,16 +182,18 @@ class Create extends Action
$message = $message->render();
$phone = $user->getAttribute('phone');
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_INTERNAL)
- ->setMessage(new Document([
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_INTERNAL,
+ project: $project,
+ message: new Document([
'$id' => $challenge->getId(),
'data' => [
'content' => $code,
],
- ]))
- ->setRecipients([$phone])
- ->setProviderType(MESSAGE_TYPE_SMS);
+ ]),
+ recipients: [$phone],
+ providerType: MESSAGE_TYPE_SMS,
+ ));
$helper = PhoneNumberUtil::getInstance();
try {
@@ -223,7 +222,9 @@ class Create extends Action
$preview = $locale->getText("emails.mfaChallenge.preview");
$heading = $locale->getText("emails.mfaChallenge.heading");
- $customTemplate = $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->default] ?? [];
+ $customTemplate =
+ $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->default] ??
+ $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->fallback] ?? [];
$smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base');
$validator = new FileName();
@@ -253,7 +254,9 @@ class Create extends Action
$senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
- $replyTo = "";
+ $replyToEmail = '';
+ $replyToName = '';
+ $smtpConfig = [];
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -262,16 +265,14 @@ class Create extends Action
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
- if (!empty($smtp['replyTo'])) {
- $replyTo = $smtp['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '';
+ if (!empty($smtpReplyToEmail)) {
+ $replyToEmail = $smtpReplyToEmail;
+ }
+ if (!empty($smtp['replyToName'])) {
+ $replyToName = $smtp['replyToName'];
}
-
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '');
if (!empty($customTemplate)) {
if (!empty($customTemplate['senderEmail'])) {
@@ -280,18 +281,30 @@ class Create extends Action
if (!empty($customTemplate['senderName'])) {
$senderName = $customTemplate['senderName'];
}
- if (!empty($customTemplate['replyTo'])) {
- $replyTo = $customTemplate['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? '';
+ if (!empty($customReplyToEmail)) {
+ $replyToEmail = $customReplyToEmail;
+ }
+ if (!empty($customTemplate['replyToName'])) {
+ $replyToName = $customTemplate['replyToName'];
}
$body = $customTemplate['message'] ?? '';
$subject = $customTemplate['subject'] ?? $subject;
}
- $queueForMails
- ->setSmtpReplyTo($replyTo)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName);
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ];
}
$emailVariables = [
@@ -318,20 +331,18 @@ class Create extends Action
]);
}
- $queueForMails
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body)
- ->setBodyTemplate($bodyTemplate)
- ->appendVariables($emailVariables)
- ->setRecipient($user->getAttribute('email'));
-
- // since this is console project, set email sender name!
- if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) {
- $queueForMails->setSenderName($platform['emailSenderName']);
- }
-
- $queueForMails->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $user->getAttribute('email'),
+ subject: $subject,
+ bodyTemplate: $bodyTemplate,
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ customMailOptions: $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE ? ['senderName' => $platform['emailSenderName']] : [],
+ platform: $platform,
+ ));
break;
}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Enums/InsightCTAMethod.php b/src/Appwrite/Platform/Modules/Advisor/Enums/InsightCTAMethod.php
new file mode 100644
index 0000000000..31d578a991
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Enums/InsightCTAMethod.php
@@ -0,0 +1,8 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/reports/:reportId/insights/:insightId')
+ ->desc('Get insight')
+ ->groups(['api', 'advisor'])
+ ->label('scope', 'insights.read')
+ ->label('resourceType', RESOURCE_TYPE_INSIGHTS)
+ ->label('sdk', new Method(
+ namespace: 'advisor',
+ group: 'insights',
+ name: 'getInsight',
+ description: '/docs/references/advisor/get-insight.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_INSIGHT,
+ ),
+ ]
+ ))
+ ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Parent report ID.', false, ['dbForPlatform'])
+ ->param('insightId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForPlatform'])
+ ->inject('response')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $reportId,
+ string $insightId,
+ Response $response,
+ Document $project,
+ Database $dbForPlatform
+ ) {
+ // Skip the insights subquery — we only need ownership metadata.
+ $report = $dbForPlatform->skipFilters(
+ fn () => $dbForPlatform->getDocument('reports', $reportId),
+ ['subQueryReportInsights'],
+ );
+
+ if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
+ throw new Exception(Exception::REPORT_NOT_FOUND);
+ }
+
+ $insight = $dbForPlatform->getDocument('insights', $insightId);
+
+ if (
+ $insight->isEmpty()
+ || $insight->getAttribute('projectInternalId') !== $project->getSequence()
+ || $insight->getAttribute('reportInternalId') !== $report->getSequence()
+ ) {
+ throw new Exception(Exception::INSIGHT_NOT_FOUND);
+ }
+
+ $response->dynamic($insight, Response::MODEL_INSIGHT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Insights/XList.php b/src/Appwrite/Platform/Modules/Advisor/Http/Insights/XList.php
new file mode 100644
index 0000000000..6cf779bfb9
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Http/Insights/XList.php
@@ -0,0 +1,126 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/reports/:reportId/insights')
+ ->desc('List insights')
+ ->groups(['api', 'advisor'])
+ ->label('scope', 'insights.read')
+ ->label('resourceType', RESOURCE_TYPE_INSIGHTS)
+ ->label('sdk', new Method(
+ namespace: 'advisor',
+ group: 'insights',
+ name: 'listInsights',
+ description: '/docs/references/advisor/list-insights.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_INSIGHT_LIST,
+ ),
+ ]
+ ))
+ ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Parent report ID.', false, ['dbForPlatform'])
+ ->param('queries', [], new Insights(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Insights::ALLOWED_ATTRIBUTES), 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('project')
+ ->inject('dbForPlatform')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $reportId,
+ array $queries,
+ bool $includeTotal,
+ Response $response,
+ Document $project,
+ Database $dbForPlatform
+ ) {
+ // Skip the insights subquery — we're about to fetch a filtered, paginated slice ourselves.
+ $report = $dbForPlatform->skipFilters(
+ fn () => $dbForPlatform->getDocument('reports', $reportId),
+ ['subQueryReportInsights'],
+ );
+
+ if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
+ throw new Exception(Exception::REPORT_NOT_FOUND);
+ }
+
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $queries[] = Query::equal('projectInternalId', [$project->getSequence()]);
+ $queries[] = Query::equal('reportInternalId', [$report->getSequence()]);
+
+ $cursor = Query::getCursorQueries($queries, false);
+ $cursor = \reset($cursor);
+
+ if ($cursor !== false) {
+ $validator = new Cursor();
+ if (!$validator->isValid($cursor)) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
+ }
+
+ $insightId = $cursor->getValue();
+ $cursorDocument = $dbForPlatform->getDocument('insights', $insightId);
+
+ if (
+ $cursorDocument->isEmpty()
+ || $cursorDocument->getAttribute('projectInternalId') !== $project->getSequence()
+ || $cursorDocument->getAttribute('reportInternalId') !== $report->getSequence()
+ ) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Insight '{$insightId}' for the 'cursor' value not found.");
+ }
+
+ $cursor->setValue($cursorDocument);
+ }
+
+ $filterQueries = Query::groupByType($queries)['filters'];
+
+ try {
+ $insights = $dbForPlatform->find('insights', $queries);
+ $total = $includeTotal ? $dbForPlatform->count('insights', $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([
+ 'insights' => $insights,
+ 'total' => $total,
+ ]), Response::MODEL_INSIGHT_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Delete.php b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Delete.php
new file mode 100644
index 0000000000..1efc029c17
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Delete.php
@@ -0,0 +1,100 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/reports/:reportId')
+ ->desc('Delete report')
+ ->groups(['api', 'advisor'])
+ ->label('scope', 'reports.write')
+ ->label('event', 'reports.[reportId].delete')
+ ->label('resourceType', RESOURCE_TYPE_REPORTS)
+ ->label('audits.event', 'report.delete')
+ ->label('audits.resource', 'report/{request.reportId}')
+ ->label('abuse-key', 'projectId:{projectId},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', new Method(
+ namespace: 'advisor',
+ group: 'reports',
+ name: 'deleteReport',
+ description: '/docs/references/advisor/delete-report.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_NOCONTENT,
+ model: Response::MODEL_NONE,
+ ),
+ ],
+ contentType: ContentType::NONE
+ ))
+ ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Report ID.', false, ['dbForPlatform'])
+ ->inject('response')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->inject('publisherForDeletes')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $reportId,
+ Response $response,
+ Document $project,
+ Database $dbForPlatform,
+ DeletePublisher $publisherForDeletes,
+ Event $queueForEvents
+ ): void {
+ $report = $dbForPlatform->skipFilters(
+ fn () => $dbForPlatform->getDocument('reports', $reportId),
+ ['subQueryReportInsights'],
+ );
+
+ if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
+ throw new Exception(Exception::REPORT_NOT_FOUND);
+ }
+
+ if (!$dbForPlatform->deleteDocument('reports', $report->getId())) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove report from DB');
+ }
+
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $project,
+ type: DELETE_TYPE_REPORT,
+ document: $report,
+ ));
+
+ $queueForEvents
+ ->setParam('reportId', $report->getId())
+ ->setPayload($response->output($report, Response::MODEL_REPORT));
+
+ $response->noContent();
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Get.php b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Get.php
new file mode 100644
index 0000000000..e912161e26
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Get.php
@@ -0,0 +1,80 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/reports/:reportId')
+ ->desc('Get report')
+ ->groups(['api', 'advisor'])
+ ->label('scope', 'reports.read')
+ ->label('resourceType', RESOURCE_TYPE_REPORTS)
+ ->label('sdk', new Method(
+ namespace: 'advisor',
+ group: 'reports',
+ name: 'getReport',
+ description: '/docs/references/advisor/get-report.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_REPORT,
+ ),
+ ]
+ ))
+ ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Report ID.', false, ['dbForPlatform'])
+ ->inject('response')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $reportId,
+ Response $response,
+ Document $project,
+ Database $dbForPlatform
+ ) {
+ $report = $dbForPlatform->skipFilters(
+ fn () => $dbForPlatform->getDocument('reports', $reportId),
+ ['subQueryReportInsights'],
+ );
+
+ if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
+ throw new Exception(Exception::REPORT_NOT_FOUND);
+ }
+
+ $insights = $dbForPlatform->find('insights', [
+ Query::equal('projectInternalId', [$project->getSequence()]),
+ Query::equal('reportInternalId', [$report->getSequence()]),
+ Query::limit(APP_LIMIT_SUBQUERY),
+ ]);
+
+ $report->setAttribute('insights', $insights);
+
+ $response->dynamic($report, Response::MODEL_REPORT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Reports/XList.php b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/XList.php
new file mode 100644
index 0000000000..1440d09142
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/XList.php
@@ -0,0 +1,133 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/reports')
+ ->desc('List reports')
+ ->groups(['api', 'advisor'])
+ ->label('scope', 'reports.read')
+ ->label('resourceType', RESOURCE_TYPE_REPORTS)
+ ->label('sdk', new Method(
+ namespace: 'advisor',
+ group: 'reports',
+ name: 'listReports',
+ description: '/docs/references/advisor/list-reports.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_REPORT_LIST,
+ ),
+ ]
+ ))
+ ->param('queries', [], new Reports(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Reports::ALLOWED_ATTRIBUTES), 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('project')
+ ->inject('dbForPlatform')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ array $queries,
+ bool $includeTotal,
+ Response $response,
+ Document $project,
+ Database $dbForPlatform
+ ) {
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $queries[] = Query::equal('projectInternalId', [$project->getSequence()]);
+
+ $cursor = Query::getCursorQueries($queries, false);
+ $cursor = \reset($cursor);
+
+ if ($cursor !== false) {
+ $validator = new Cursor();
+ if (!$validator->isValid($cursor)) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
+ }
+
+ $reportId = $cursor->getValue();
+ $cursorDocument = $dbForPlatform->skipFilters(
+ fn () => $dbForPlatform->getDocument('reports', $reportId),
+ ['subQueryReportInsights'],
+ );
+
+ if ($cursorDocument->isEmpty() || $cursorDocument->getAttribute('projectInternalId') !== $project->getSequence()) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Report '{$reportId}' for the 'cursor' value not found.");
+ }
+
+ $cursor->setValue($cursorDocument);
+ }
+
+ $filterQueries = Query::groupByType($queries)['filters'];
+
+ try {
+ $reports = $dbForPlatform->skipFilters(
+ fn () => $dbForPlatform->find('reports', $queries),
+ ['subQueryReportInsights'],
+ );
+ $total = $includeTotal ? $dbForPlatform->count('reports', $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.");
+ }
+
+ if (!empty($reports)) {
+ $reportSequences = \array_map(fn (Document $r) => $r->getSequence(), $reports);
+
+ $insights = $dbForPlatform->find('insights', [
+ Query::equal('projectInternalId', [$project->getSequence()]),
+ Query::equal('reportInternalId', $reportSequences),
+ Query::limit(APP_LIMIT_SUBQUERY),
+ ]);
+
+ $insightsByReport = [];
+ foreach ($insights as $insight) {
+ $insightsByReport[$insight->getAttribute('reportInternalId')][] = $insight;
+ }
+
+ foreach ($reports as $report) {
+ $report->setAttribute('insights', $insightsByReport[$report->getSequence()] ?? []);
+ }
+ }
+
+ $response->dynamic(new Document([
+ 'reports' => $reports,
+ 'total' => $total,
+ ]), Response::MODEL_REPORT_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Module.php b/src/Appwrite/Platform/Modules/Advisor/Module.php
new file mode 100644
index 0000000000..b28a2421c2
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Module.php
@@ -0,0 +1,14 @@
+addService('http', new Http());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Services/Http.php b/src/Appwrite/Platform/Modules/Advisor/Services/Http.php
new file mode 100644
index 0000000000..2558b00247
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Services/Http.php
@@ -0,0 +1,25 @@
+type = Service::TYPE_HTTP;
+
+ $this->addAction(GetReport::getName(), new GetReport());
+ $this->addAction(ListReports::getName(), new ListReports());
+ $this->addAction(DeleteReport::getName(), new DeleteReport());
+
+ $this->addAction(GetInsight::getName(), new GetInsight());
+ $this->addAction(ListInsights::getName(), new ListInsights());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php
index f8e7a35b05..d0c600192b 100644
--- a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php
+++ b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php
@@ -86,10 +86,10 @@ class Get extends Action
}
if (!$isEmployee && !empty($githubName)) {
- $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub']) ?? '', $employees));
+ $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub'] ?? ''), $employees));
if (!empty($employeeGitHub)) {
$isEmployee = true;
- $employeeNumber = $isEmployee ? $employees[$employeeGitHub]['spot'] : '';
+ $employeeNumber = $employees[$employeeGitHub]['spot'];
$createdAt = new \DateTime($employees[$employeeGitHub]['memberSince'] ?? '');
}
}
diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php
index 37776a3466..ad74d6c192 100644
--- a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php
+++ b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php
@@ -90,10 +90,10 @@ class Get extends Action
}
if (!$isEmployee && !empty($githubName)) {
- $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub']) ?? '', $employees));
+ $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub'] ?? ''), $employees));
if (!empty($employeeGitHub)) {
$isEmployee = true;
- $employeeNumber = $isEmployee ? $employees[$employeeGitHub]['spot'] : '';
+ $employeeNumber = $employees[$employeeGitHub]['spot'];
$createdAt = new \DateTime($employees[$employeeGitHub]['memberSince'] ?? '');
}
}
diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php
index b6cc408dde..31ad572f18 100644
--- a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php
+++ b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php
@@ -94,11 +94,14 @@ class Get extends Action
throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED);
}
+ $body = $res->getBody();
$doc = new DOMDocument();
$doc->strictErrorChecking = false;
- @$doc->loadHTML($res->getBody());
+ if (!empty($body)) {
+ @$doc->loadHTML($body);
+ }
- $links = $doc->getElementsByTagName('link') ?? [];
+ $links = $doc->getElementsByTagName('link');
$outputHref = '';
$outputExt = '';
$space = 0;
@@ -128,7 +131,7 @@ class Get extends Action
case 'jpeg':
$size = \explode('x', \strtolower($sizes));
- $sizeWidth = (int) ($size[0] ?? 0);
+ $sizeWidth = (int) $size[0];
$sizeHeight = (int) ($size[1] ?? 0);
if (($sizeWidth * $sizeHeight) >= $space) {
diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php
index 27fd8708d9..f3448f5264 100644
--- a/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php
+++ b/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php
@@ -60,7 +60,6 @@ class Get extends Action
public function action(string $text, int $size, int $margin, bool $download, Response $response)
{
- $download = ($download === '1' || $download === 'true' || $download === 1 || $download === true);
$options = new QROptions([
'addQuietzone' => true,
'quietzoneSize' => $margin,
diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php
index 2df12b17d1..f33bfa938a 100644
--- a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php
+++ b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php
@@ -72,7 +72,7 @@ class Get extends Action
->param('userAgent', '', new Text(512), 'Custom user agent string. Defaults to browser default.', true, example: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15')
->param('fullpage', false, new Boolean(true), 'Capture full page scroll. Pass 0 for viewport only, or 1 for full page. Defaults to 0.', true, example: 'true')
->param('locale', '', new Text(10), 'Browser locale (e.g., "en-US", "fr-FR"). Defaults to browser default.', true, example: 'en-US')
- ->param('timezone', '', new WhiteList(timezone_identifiers_list()), 'IANA timezone identifier (e.g., "America/New_York", "Europe/London"). Defaults to browser default.', true, example: 'america/new_york')
+ ->param('timezone', '', new WhiteList(timezone_identifiers_list()), 'IANA timezone identifier (e.g., "America/New_York", "Europe/London"). Defaults to browser default.', true, example: 'America/New_York')
->param('latitude', 0, new Range(-90, 90, Range::TYPE_FLOAT), 'Geolocation latitude. Pass a number between -90 to 90. Defaults to 0.', true, example: '37.7749')
->param('longitude', 0, new Range(-180, 180, Range::TYPE_FLOAT), 'Geolocation longitude. Pass a number between -180 to 180. Defaults to 0.', true, example: '-122.4194')
->param('accuracy', 0, new Range(0, 100000, Range::TYPE_FLOAT), 'Geolocation accuracy in meters. Pass a number between 0 to 100000. Defaults to 0.', true, example: '100')
@@ -105,7 +105,7 @@ class Get extends Action
$client->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON);
// Convert indexed array to empty array (should not happen due to Assoc validator)
- if (is_array($headers) && count($headers) > 0 && array_keys($headers) === range(0, count($headers) - 1)) {
+ if (count($headers) > 0 && array_keys($headers) === range(0, count($headers) - 1)) {
$headers = [];
}
diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php
index f388e46f83..b0efac3829 100644
--- a/src/Appwrite/Platform/Modules/Compute/Base.php
+++ b/src/Appwrite/Platform/Modules/Compute/Base.php
@@ -2,7 +2,8 @@
namespace Appwrite\Platform\Modules\Compute;
-use Appwrite\Event\Build;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
use Appwrite\Platform\Action;
@@ -57,7 +58,7 @@ class Base extends Action
return $allowedSpecifications[0] ?? APP_COMPUTE_SPECIFICATION_DEFAULT;
}
- public function redeployVcsFunction(Request $request, Document $function, Document $project, Document $installation, Database $dbForProject, Build $queueForBuilds, Document $template, GitHub $github, bool $activate, string $referenceType = 'branch', string $reference = ''): Document
+ public function redeployVcsFunction(Request $request, Document $function, Document $project, Document $installation, Database $dbForProject, BuildPublisher $publisherForBuilds, Document $template, GitHub $github, bool $activate, array $platform = [], string $referenceType = 'branch', string $reference = ''): Document
{
$deploymentId = ID::unique();
$entrypoint = $function->getAttribute('entrypoint', '');
@@ -68,7 +69,7 @@ class Base extends Action
$owner = $github->getOwnerName($providerInstallationId);
$providerRepositoryId = $function->getAttribute('providerRepositoryId', '');
try {
- $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
+ $repositoryName = $github->getRepositoryName($providerRepositoryId);
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
@@ -150,16 +151,19 @@ class Base extends Action
'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
]));
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
return $deployment;
}
- public function redeployVcsSite(Request $request, Document $site, Document $project, Document $installation, Database $dbForProject, Database $dbForPlatform, Build $queueForBuilds, Document $template, GitHub $github, bool $activate, Authorization $authorization, array $platform, string $referenceType = 'branch', string $reference = ''): Document
+ public function redeployVcsSite(Request $request, Document $site, Document $project, Document $installation, Database $dbForProject, Database $dbForPlatform, BuildPublisher $publisherForBuilds, Document $template, GitHub $github, bool $activate, Authorization $authorization, array $platform, string $referenceType = 'branch', string $reference = ''): Document
{
$deploymentId = ID::unique();
$providerInstallationId = $installation->getAttribute('providerInstallationId', '');
@@ -169,7 +173,7 @@ class Base extends Action
$owner = $github->getOwnerName($providerInstallationId);
$providerRepositoryId = $site->getAttribute('providerRepositoryId', '');
try {
- $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
+ $repositoryName = $github->getRepositoryName($providerRepositoryId);
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
@@ -358,11 +362,14 @@ class Base extends Action
$this->updateEmptyManualRule($project, $site, $deployment, $dbForPlatform, $authorization);
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($site)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $site,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
return $deployment;
}
diff --git a/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php b/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php
index 554456b041..8953f682d5 100644
--- a/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php
+++ b/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php
@@ -85,8 +85,6 @@ class Create extends Action
curl_exec($ch);
- curl_close($ch);
-
$response->chunk('', true);
}
}
diff --git a/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php b/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php
new file mode 100644
index 0000000000..e253292ca9
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php
@@ -0,0 +1,80 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/console/oauth2-providers')
+ ->desc('List OAuth2 providers')
+ ->groups(['api'])
+ ->label('scope', 'public')
+ ->label('sdk', new Method(
+ namespace: 'console',
+ group: 'console',
+ name: 'listOAuth2Providers',
+ 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(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_CONSOLE_OAUTH2_PROVIDER_LIST,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->inject('response')
+ ->callback($this->action(...));
+ }
+
+ public function action(Response $response): void
+ {
+ $providersConfig = Config::getParam('oAuthProviders', []);
+ $actions = OAuth2Base::getProviderActions();
+
+ $providers = [];
+ foreach ($providersConfig as $providerId => $config) {
+ $updateClass = $actions[$providerId] ?? null;
+ if ($updateClass === null) {
+ continue;
+ }
+ if (!($config['enabled'] ?? false)) {
+ continue;
+ }
+ if ($config['mock'] ?? false) {
+ continue;
+ }
+
+ $providers[] = new Document([
+ '$id' => $providerId,
+ 'parameters' => $updateClass::getParameters(),
+ ]);
+ }
+
+ $response->dynamic(new Document([
+ 'total' => \count($providers),
+ 'oAuth2Providers' => $providers,
+ ]), Response::MODEL_CONSOLE_OAUTH2_PROVIDER_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Console/Http/Scopes/Organization/XList.php b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Organization/XList.php
new file mode 100644
index 0000000000..4f88df6948
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Organization/XList.php
@@ -0,0 +1,69 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/console/scopes/organization')
+ ->desc('List organization scopes')
+ ->groups(['api'])
+ ->label('scope', 'public')
+ ->label('sdk', new Method(
+ namespace: 'console',
+ group: 'console',
+ name: 'listOrganizationScopes',
+ description: 'List all scopes available for organization 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('organizationScopes', []);
+
+ $scopes = [];
+ foreach ($scopesConfig as $scopeId => $scope) {
+ $scopes[] = new Document([
+ '$id' => $scopeId,
+ 'description' => $scope['description'] ?? '',
+ 'category' => $scope['category'] ?? '',
+ 'deprecated' => $scope['deprecated'] ?? false,
+ ]);
+ }
+
+ $response->dynamic(new Document([
+ 'total' => \count($scopes),
+ 'scopes' => $scopes,
+ ]), Response::MODEL_CONSOLE_KEY_SCOPE_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Console/Http/Scopes/Project/XList.php b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Project/XList.php
new file mode 100644
index 0000000000..3e6eceb26c
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Project/XList.php
@@ -0,0 +1,69 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/console/scopes/project')
+ ->desc('List project scopes')
+ ->groups(['api'])
+ ->label('scope', 'public')
+ ->label('sdk', new Method(
+ namespace: 'console',
+ group: 'console',
+ name: 'listProjectScopes',
+ 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'] ?? '',
+ 'category' => $scope['category'] ?? '',
+ 'deprecated' => $scope['deprecated'] ?? false,
+ ]);
+ }
+
+ $response->dynamic(new Document([
+ 'total' => \count($scopes),
+ 'scopes' => $scopes,
+ ]), Response::MODEL_CONSOLE_KEY_SCOPE_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php
new file mode 100644
index 0000000000..6906c1fd79
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php
@@ -0,0 +1,123 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/console/templates/email/:templateId')
+ ->desc('Get email template')
+ ->groups(['api'])
+ ->label('scope', 'public')
+ ->label('sdk', new Method(
+ namespace: 'console',
+ group: null,
+ name: 'getEmailTemplate',
+ description: <<param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Email template type. Can be one of: ' . \implode(', ', Config::getParam('locale-templates')['email'] ?? []))
+ ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes'])
+ ->inject('response')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $templateId,
+ string $locale,
+ Response $response,
+ ): void {
+ $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en');
+
+ $localeObj = new Locale($locale);
+ $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en'));
+
+ $response->dynamic(new Document([
+ 'templateId' => $templateId,
+ 'locale' => $locale,
+ 'subject' => $localeObj->getText('emails.' . $templateId . '.subject'),
+ 'message' => $this->getDefaultMessage($templateId, $localeObj),
+ 'senderName' => '',
+ 'senderEmail' => '',
+ 'replyToEmail' => '',
+ 'replyToName' => '',
+ ]), Response::MODEL_EMAIL_TEMPLATE);
+ }
+
+ private function getDefaultMessage(string $templateId, Locale $localeObj): string
+ {
+ $templateConfigs = [
+ 'magicSession' => [
+ 'file' => 'email-magic-url.tpl',
+ 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase']
+ ],
+ 'mfaChallenge' => [
+ 'file' => 'email-mfa-challenge.tpl',
+ 'placeholders' => ['description', 'clientInfo']
+ ],
+ 'otpSession' => [
+ 'file' => 'email-otp.tpl',
+ 'placeholders' => ['description', 'clientInfo', 'securityPhrase']
+ ],
+ 'sessionAlert' => [
+ 'file' => 'email-session-alert.tpl',
+ 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer']
+ ],
+ ];
+
+ $config = $templateConfigs[$templateId] ?? [
+ 'file' => 'email-inner-base.tpl',
+ 'placeholders' => ['buttonText', 'body', 'footer']
+ ];
+
+ $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']);
+ $message = Template::fromString($templateString);
+
+ foreach ($config['placeholders'] as $param) {
+ $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']);
+ if ($templateId === 'magicSession' && $param === 'securityPhrase') {
+ $message->setParam('{{securityPhrase}}', '');
+ continue;
+ }
+
+ $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml);
+ }
+
+ $message
+ ->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello"))
+ ->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks"))
+ ->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature"));
+
+ return $message->render(useContent: true);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php b/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php
index 8368b272f1..d39049a409 100644
--- a/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php
+++ b/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php
@@ -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(
diff --git a/src/Appwrite/Platform/Modules/Console/Services/Http.php b/src/Appwrite/Platform/Modules/Console/Services/Http.php
index f3ca6218f2..78b2835402 100644
--- a/src/Appwrite/Platform/Modules/Console/Services/Http.php
+++ b/src/Appwrite/Platform/Modules/Console/Services/Http.php
@@ -5,6 +5,7 @@ namespace Appwrite\Platform\Modules\Console\Services;
use Appwrite\Platform\Modules\Console\Http\Assistant\Create as CreateAssistantQuery;
use Appwrite\Platform\Modules\Console\Http\Init\API;
use Appwrite\Platform\Modules\Console\Http\Init\Web;
+use Appwrite\Platform\Modules\Console\Http\OAuth2Providers\XList as ListOAuth2Providers;
use Appwrite\Platform\Modules\Console\Http\Redirects\Auth\Get as RedirectAuth;
use Appwrite\Platform\Modules\Console\Http\Redirects\Card\Get as RedirectCard;
use Appwrite\Platform\Modules\Console\Http\Redirects\Invite\Get as RedirectInvite;
@@ -14,6 +15,9 @@ 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\Organization\XList as ListOrganizationScopes;
+use Appwrite\Platform\Modules\Console\Http\Scopes\Project\XList as ListKeyScopes;
+use Appwrite\Platform\Modules\Console\Http\Templates\Email\Get as GetEmailTemplate;
use Appwrite\Platform\Modules\Console\Http\Variables\Get as GetVariables;
use Utopia\Platform\Service;
@@ -28,6 +32,10 @@ class Http extends Service
$this->addAction(Web::getName(), new Web());
$this->addAction(GetVariables::getName(), new GetVariables());
+ $this->addAction(GetEmailTemplate::getName(), new GetEmailTemplate());
+ $this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers());
+ $this->addAction(ListKeyScopes::getName(), new ListKeyScopes());
+ $this->addAction(ListOrganizationScopes::getName(), new ListOrganizationScopes());
$this->addAction(CreateAssistantQuery::getName(), new CreateAssistantQuery());
$this->addAction(GetResourceAvailability::getName(), new GetResourceAvailability());
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php
index 4afab449c0..1f730fa543 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php
@@ -12,7 +12,7 @@ abstract class Action extends DatabasesAction
/**
* The current API context (either 'table' or 'collection').
*/
- private ?string $context = COLLECTIONS;
+ private string $context = COLLECTIONS;
/**
* Get the response model used in the SDK and HTTP responses.
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php
index 0d562a2894..a07a4be561 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Database as DatabaseMessage;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response as UtopiaResponse;
@@ -26,9 +27,9 @@ use Utopia\Validator\Range;
abstract class Action extends UtopiaAction
{
/**
- * @var string|null The current context (either 'column' or 'attribute')
+ * @var string The current context (either 'column' or 'attribute')
*/
- private ?string $context = ATTRIBUTES;
+ private string $context = ATTRIBUTES;
/**
* Get the correct response model.
@@ -241,6 +242,10 @@ abstract class Action extends UtopiaAction
? UtopiaResponse::MODEL_ATTRIBUTE_INTEGER
: UtopiaResponse::MODEL_COLUMN_INTEGER,
+ Database::VAR_BIGINT => $isCollections
+ ? UtopiaResponse::MODEL_ATTRIBUTE_BIGINT
+ : UtopiaResponse::MODEL_COLUMN_BIGINT,
+
Database::VAR_FLOAT => $isCollections
? UtopiaResponse::MODEL_ATTRIBUTE_FLOAT
: UtopiaResponse::MODEL_COLUMN_FLOAT,
@@ -308,7 +313,7 @@ abstract class Action extends UtopiaAction
};
}
- protected function createAttribute(string $databaseId, string $collectionId, Document $attribute, Response $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): Document
+ protected function createAttribute(string $databaseId, string $collectionId, Document $attribute, Response $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): Document
{
$key = $attribute->getAttribute('key');
$type = $attribute->getAttribute('type', '');
@@ -460,20 +465,6 @@ abstract class Action extends UtopiaAction
$dbForProject->purgeCachedCollection('database_' . $db->getSequence() . '_collection_' . $relatedCollection->getSequence());
}
- $queueForDatabase
- ->setType(DATABASE_TYPE_CREATE_ATTRIBUTE)
- ->setDatabase($db);
-
- if ($this->isCollectionsAPI()) {
- $queueForDatabase
- ->setDocument($attribute)
- ->setCollection($collection);
- } else {
- $queueForDatabase
- ->setRow($attribute)
- ->setTable($collection);
- }
-
$queueForEvents
->setContext('database', $db)
->setParam('databaseId', $databaseId)
@@ -483,6 +474,18 @@ abstract class Action extends UtopiaAction
->setParam('columnId', $attribute->getId())
->setContext($this->getCollectionsEventsContext(), $collection);
+ $publisherForDatabase->enqueue(new DatabaseMessage(
+ project: $queueForEvents->getProject(),
+ user: $queueForEvents->getUser(),
+ type: DATABASE_TYPE_CREATE_ATTRIBUTE,
+ database: $db,
+ collection: $this->isCollectionsAPI() ? $collection : null,
+ document: $this->isCollectionsAPI() ? $attribute : null,
+ table: $this->isCollectionsAPI() ? null : $collection,
+ row: $this->isCollectionsAPI() ? null : $attribute,
+ events: Event::generateEvents($queueForEvents->getEvent(), $queueForEvents->getParams()),
+ ));
+
$response->setStatusCode(SwooleResponse::STATUS_CODE_CREATED);
return $attribute;
@@ -540,6 +543,7 @@ abstract class Action extends UtopiaAction
switch ($attribute->getAttribute('format')) {
case APP_DATABASE_ATTRIBUTE_INT_RANGE:
+ case APP_DATABASE_ATTRIBUTE_BIGINT_RANGE:
case APP_DATABASE_ATTRIBUTE_FLOAT_RANGE:
$min ??= $attribute->getAttribute('formatOptions')['min'];
$max ??= $attribute->getAttribute('formatOptions')['max'];
@@ -548,14 +552,15 @@ abstract class Action extends UtopiaAction
throw new Exception($this->getInvalidValueException(), 'Minimum value must be lesser than maximum value');
}
- if ($attribute->getAttribute('format') === APP_DATABASE_ATTRIBUTE_INT_RANGE) {
- $validator = new Range($min, $max, Database::VAR_INTEGER);
- } else {
+ if ($attribute->getAttribute('format') === APP_DATABASE_ATTRIBUTE_FLOAT_RANGE) {
$validator = new Range($min, $max, Database::VAR_FLOAT);
if (!is_null($default)) {
$default = \floatval($default);
}
+ } else {
+ // intRange and bigintRange share the same integer range semantics
+ $validator = new Range($min, $max, Range::TYPE_INTEGER);
}
if (!is_null($default) && !$validator->isValid($default)) {
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/BigInt/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/BigInt/Create.php
new file mode 100644
index 0000000000..11d3ada810
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/BigInt/Create.php
@@ -0,0 +1,117 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/databases/:databaseId/collections/:collectionId/attributes/bigint')
+ ->desc('Create bigint attribute')
+ ->groups(['api', 'database', 'schema'])
+ ->label('scope', 'collections.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].collections.[collectionId].attributes.[attributeId].create')
+ ->label('audits.event', 'attribute.create')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('sdk', new Method(
+ namespace: $this->getSDKNamespace(),
+ group: $this->getSDKGroup(),
+ name: self::getName(),
+ description: '/docs/references/databases/create-bigint-attribute.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_ACCEPTED,
+ model: $this->getResponseModel(),
+ )
+ ],
+ deprecated: new Deprecated(
+ since: '1.8.0',
+ replaceWith: 'tablesDB.createBigIntColumn',
+ ),
+ ))
+ ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
+ ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject'])
+ ->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Attribute Key.', false, ['dbForProject'])
+ ->param('required', null, new Boolean(), 'Is attribute required?')
+ ->param('min', null, new Nullable(new Integer(false, 64)), 'Minimum value', true)
+ ->param('max', null, new Nullable(new Integer(false, 64)), 'Maximum value', true)
+ ->param('default', null, new Nullable(new Integer(false, 64)), 'Default value. Cannot be set when attribute is required.', true)
+ ->param('array', false, new Boolean(), 'Is attribute an array?', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('publisherForDatabase')
+ ->inject('queueForEvents')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?int $min, ?int $max, ?int $default, bool $array, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void
+ {
+ $min ??= \PHP_INT_MIN;
+ $max ??= \PHP_INT_MAX;
+
+ if ($min > $max) {
+ throw new Exception($this->getInvalidValueException(), 'Minimum value must be lesser than maximum value');
+ }
+
+ $validator = new Range($min, $max, Range::TYPE_INTEGER);
+ if (!\is_null($default) && !$validator->isValid($default)) {
+ throw new Exception($this->getInvalidValueException(), $validator->getDescription());
+ }
+
+ $attribute = $this->createAttribute($databaseId, $collectionId, new Document([
+ 'key' => $key,
+ 'type' => Database::VAR_BIGINT,
+ 'size' => 8,
+ 'required' => $required,
+ 'default' => $default,
+ 'array' => $array,
+ 'format' => APP_DATABASE_ATTRIBUTE_BIGINT_RANGE,
+ 'formatOptions' => ['min' => $min, 'max' => $max],
+ ]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization);
+
+ $formatOptions = $attribute->getAttribute('formatOptions', []);
+ if (!empty($formatOptions)) {
+ $attribute->setAttribute('min', \intval($formatOptions['min']));
+ $attribute->setAttribute('max', \intval($formatOptions['max']));
+ }
+
+ $response
+ ->setStatusCode(SwooleResponse::STATUS_CODE_ACCEPTED)
+ ->dynamic($attribute, $this->getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/BigInt/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/BigInt/Update.php
new file mode 100644
index 0000000000..5d8e8bf3a5
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/BigInt/Update.php
@@ -0,0 +1,106 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/databases/:databaseId/collections/:collectionId/attributes/bigint/:key')
+ ->desc('Update bigint attribute')
+ ->groups(['api', 'database', 'schema'])
+ ->label('scope', 'collections.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].collections.[collectionId].attributes.[attributeId].update')
+ ->label('audits.event', 'attribute.update')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('sdk', new Method(
+ namespace: $this->getSDKNamespace(),
+ group: $this->getSDKGroup(),
+ name: self::getName(),
+ description: '/docs/references/databases/update-bigint-attribute.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: $this->getResponseModel(),
+ )
+ ],
+ contentType: ContentType::JSON,
+ deprecated: new Deprecated(
+ since: '1.8.0',
+ replaceWith: 'tablesDB.updateBigIntColumn',
+ ),
+ ))
+ ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
+ ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject'])
+ ->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Attribute Key.', false, ['dbForProject'])
+ ->param('required', null, new Boolean(), 'Is attribute required?')
+ ->param('min', null, new Nullable(new Integer(false, 64)), 'Minimum value', true)
+ ->param('max', null, new Nullable(new Integer(false, 64)), 'Maximum value', true)
+ ->param('default', null, new Nullable(new Integer(false, 64)), 'Default value. Cannot be set when attribute is required.')
+ ->param('newKey', null, fn (Database $dbForProject) => new Nullable(new Key(false, $dbForProject->getAdapter()->getMaxUIDLength())), 'New Attribute Key.', true, ['dbForProject'])
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('queueForEvents')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?int $min, ?int $max, ?int $default, ?string $newKey, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, Authorization $authorization): void
+ {
+ $attribute = $this->updateAttribute(
+ databaseId: $databaseId,
+ collectionId: $collectionId,
+ key: $key,
+ dbForProject: $dbForProject,
+ queueForEvents: $queueForEvents,
+ authorization: $authorization,
+ type: Database::VAR_BIGINT,
+ default: $default,
+ required: $required,
+ min: $min,
+ max: $max,
+ newKey: $newKey
+ );
+
+ $formatOptions = $attribute->getAttribute('formatOptions', []);
+ if (!empty($formatOptions)) {
+ $attribute->setAttribute('min', \intval($formatOptions['min']));
+ $attribute->setAttribute('max', \intval($formatOptions['max']));
+ }
+
+ $response
+ ->setStatusCode(SwooleResponse::STATUS_CODE_OK)
+ ->dynamic($attribute, $this->getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Boolean/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Boolean/Create.php
index a19b1626c9..475b43f569 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Boolean/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Boolean/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Boolean;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Deprecated;
@@ -68,13 +68,13 @@ class Create extends Action
->param('array', false, new Boolean(), 'Is attribute an array?', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?bool $default, bool $array, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?bool $default, bool $array, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void
{
$attribute = $this->createAttribute($databaseId, $collectionId, new Document([
'key' => $key,
@@ -83,7 +83,7 @@ class Create extends Action
'required' => $required,
'default' => $default,
'array' => $array,
- ]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization);
+ ]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization);
$response
->setStatusCode(SwooleResponse::STATUS_CODE_ACCEPTED)
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Datetime/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Datetime/Create.php
index 4162b50daf..7a0776751b 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Datetime/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Datetime/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Datetime;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Deprecated;
@@ -69,13 +69,13 @@ class Create extends Action
->param('array', false, new Boolean(), 'Is attribute an array?', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?string $default, bool $array, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?string $default, bool $array, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void
{
$attribute = $this->createAttribute(
$databaseId,
@@ -91,7 +91,7 @@ class Create extends Action
]),
$response,
$dbForProject,
- $queueForDatabase,
+ $publisherForDatabase,
$queueForEvents,
$authorization
);
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Delete.php
index 38b96e67bc..ff1636ae60 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Delete.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Database as DatabaseMessage;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -66,13 +67,13 @@ class Delete extends Action
->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Attribute Key.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string $key, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, string $key, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void
{
$db = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($db->isEmpty()) {
@@ -129,20 +130,6 @@ class Delete extends Action
}
}
- $queueForDatabase
- ->setDatabase($db)
- ->setType(DATABASE_TYPE_DELETE_ATTRIBUTE);
-
- if ($this->isCollectionsAPI()) {
- $queueForDatabase
- ->setRow($attribute)
- ->setTable($collection);
- } else {
- $queueForDatabase
- ->setDocument($attribute)
- ->setCollection($collection);
- }
-
$type = $attribute->getAttribute('type');
$format = $attribute->getAttribute('format');
@@ -158,6 +145,18 @@ class Delete extends Action
->setPayload($response->output($attribute, $model))
->setContext($this->getCollectionsEventsContext(), $collection);
+ $publisherForDatabase->enqueue(new DatabaseMessage(
+ project: $queueForEvents->getProject(),
+ user: $queueForEvents->getUser(),
+ type: DATABASE_TYPE_DELETE_ATTRIBUTE,
+ database: $db,
+ collection: $this->isCollectionsAPI() ? null : $collection,
+ document: $this->isCollectionsAPI() ? null : $attribute,
+ table: $this->isCollectionsAPI() ? $collection : null,
+ row: $this->isCollectionsAPI() ? $attribute : null,
+ events: Event::generateEvents($queueForEvents->getEvent(), $queueForEvents->getParams()),
+ ));
+
$response->noContent();
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Email/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Email/Create.php
index 6530cdb1dd..098083bea6 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Email/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Email/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Email;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Deprecated;
@@ -69,13 +69,13 @@ class Create extends Action
->param('array', false, new Boolean(), 'Is attribute an array?', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?string $default, bool $array, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?string $default, bool $array, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void
{
$attribute = $this->createAttribute(
$databaseId,
@@ -91,7 +91,7 @@ class Create extends Action
]),
$response,
$dbForProject,
- $queueForDatabase,
+ $publisherForDatabase,
$queueForEvents,
$authorization
);
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Enum/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Enum/Create.php
index fbc2d08cd1..602189e881 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Enum/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Enum/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Enum;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
use Appwrite\SDK\AuthType;
@@ -72,13 +72,13 @@ class Create extends Action
->param('array', false, new Boolean(), 'Is attribute an array?', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string $key, array $elements, ?bool $required, ?string $default, bool $array, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, string $key, array $elements, ?bool $required, ?string $default, bool $array, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void
{
if (!is_null($default) && !\in_array($default, $elements, true)) {
throw new Exception($this->getInvalidValueException(), 'Default value not found in elements');
@@ -99,7 +99,7 @@ class Create extends Action
]),
$response,
$dbForProject,
- $queueForDatabase,
+ $publisherForDatabase,
$queueForEvents,
$authorization
);
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Float/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Float/Create.php
index e1585be169..a715b51b5a 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Float/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Float/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Float;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
use Appwrite\SDK\AuthType;
@@ -73,13 +73,13 @@ class Create extends Action
->param('array', false, new Boolean(), 'Is attribute an array?', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?float $min, ?float $max, ?float $default, bool $array, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?float $min, ?float $max, ?float $default, bool $array, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void
{
$min ??= -PHP_FLOAT_MAX;
$max ??= PHP_FLOAT_MAX;
@@ -102,7 +102,7 @@ class Create extends Action
'array' => $array,
'format' => APP_DATABASE_ATTRIBUTE_FLOAT_RANGE,
'formatOptions' => ['min' => $min, 'max' => $max],
- ]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization);
+ ]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization);
$formatOptions = $attribute->getAttribute('formatOptions', []);
if (!empty($formatOptions)) {
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/IP/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/IP/Create.php
index 8b02339252..9a142b1a86 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/IP/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/IP/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\IP;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Deprecated;
@@ -69,13 +69,13 @@ class Create extends Action
->param('array', false, new Boolean(), 'Is attribute an array?', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?string $default, bool $array, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?string $default, bool $array, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void
{
$attribute = $this->createAttribute(
$databaseId,
@@ -91,7 +91,7 @@ class Create extends Action
]),
$response,
$dbForProject,
- $queueForDatabase,
+ $publisherForDatabase,
$queueForEvents,
$authorization
);
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Integer/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Integer/Create.php
index 3d2fa68797..89aefb87e6 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Integer/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Integer/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Integer;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
use Appwrite\SDK\AuthType;
@@ -73,13 +73,13 @@ class Create extends Action
->param('array', false, new Boolean(), 'Is attribute an array?', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?int $min, ?int $max, ?int $default, bool $array, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?int $min, ?int $max, ?int $default, bool $array, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void
{
$min ??= \PHP_INT_MIN;
$max ??= \PHP_INT_MAX;
@@ -104,7 +104,7 @@ class Create extends Action
'array' => $array,
'format' => APP_DATABASE_ATTRIBUTE_INT_RANGE,
'formatOptions' => ['min' => $min, 'max' => $max],
- ]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization);
+ ]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization);
$formatOptions = $attribute->getAttribute('formatOptions', []);
if (!empty($formatOptions)) {
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Line/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Line/Create.php
index d2578a963f..d3f82cd109 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Line/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Line/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Line;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
use Appwrite\SDK\AuthType;
@@ -69,13 +69,13 @@ class Create extends Action
->param('default', null, new Nullable(new Spatial(Database::VAR_LINESTRING)), 'Default value for attribute when not provided, two-dimensional array of coordinate pairs, [[longitude, latitude], [longitude, latitude], …], listing the vertices of the line in order. Cannot be set when attribute is required.', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?array $default, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?array $default, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void
{
if (!$dbForProject->getAdapter()->getSupportForSpatialAttributes()) {
throw new Exception(Exception::GENERAL_FEATURE_UNSUPPORTED, 'Spatial columns are not supported by this database.');
@@ -86,7 +86,7 @@ class Create extends Action
'type' => Database::VAR_LINESTRING,
'required' => $required,
'default' => $default
- ]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization);
+ ]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization);
$response
->setStatusCode(SwooleResponse::STATUS_CODE_ACCEPTED)
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Longtext/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Longtext/Create.php
index 2fc9de8699..90591b43fb 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Longtext/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Longtext/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Longtext;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
use Appwrite\SDK\AuthType;
@@ -67,7 +67,7 @@ class Create extends Action
->param('encrypt', false, new Boolean(), 'Toggle encryption for the attribute. Encryption enhances security by not storing any plain text values in the database. However, encrypted attributes cannot be queried.', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('plan')
->inject('authorization')
@@ -84,7 +84,7 @@ class Create extends Action
bool $encrypt,
UtopiaResponse $response,
Database $dbForProject,
- EventDatabase $queueForDatabase,
+ DatabasePublisher $publisherForDatabase,
Event $queueForEvents,
array $plan,
Authorization $authorization
@@ -112,7 +112,7 @@ class Create extends Action
]),
$response,
$dbForProject,
- $queueForDatabase,
+ $publisherForDatabase,
$queueForEvents,
$authorization
);
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Mediumtext/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Mediumtext/Create.php
index 5776e51917..0f7b386fd5 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Mediumtext/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Mediumtext/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Mediumtext;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
use Appwrite\SDK\AuthType;
@@ -67,7 +67,7 @@ class Create extends Action
->param('encrypt', false, new Boolean(), 'Toggle encryption for the attribute. Encryption enhances security by not storing any plain text values in the database. However, encrypted attributes cannot be queried.', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('plan')
->inject('authorization')
@@ -84,7 +84,7 @@ class Create extends Action
bool $encrypt,
UtopiaResponse $response,
Database $dbForProject,
- EventDatabase $queueForDatabase,
+ DatabasePublisher $publisherForDatabase,
Event $queueForEvents,
array $plan,
Authorization $authorization
@@ -112,7 +112,7 @@ class Create extends Action
]),
$response,
$dbForProject,
- $queueForDatabase,
+ $publisherForDatabase,
$queueForEvents,
$authorization
);
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Point/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Point/Create.php
index 527b4330b9..38082b46da 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Point/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Point/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Point;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
use Appwrite\SDK\AuthType;
@@ -69,13 +69,13 @@ class Create extends Action
->param('default', null, new Nullable(new Spatial(Database::VAR_POINT)), 'Default value for attribute when not provided, array of two numbers [longitude, latitude], representing a single coordinate. Cannot be set when attribute is required.', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?array $default, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?array $default, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void
{
if (!$dbForProject->getAdapter()->getSupportForSpatialAttributes()) {
throw new Exception(Exception::GENERAL_FEATURE_UNSUPPORTED, 'Spatial columns are not supported by this database.');
@@ -86,7 +86,7 @@ class Create extends Action
'type' => Database::VAR_POINT,
'required' => $required,
'default' => $default,
- ]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization);
+ ]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization);
$response
->setStatusCode(SwooleResponse::STATUS_CODE_ACCEPTED)
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Polygon/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Polygon/Create.php
index 4c3e725f3e..3063d1938a 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Polygon/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Polygon/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Polygon;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
use Appwrite\SDK\AuthType;
@@ -69,13 +69,13 @@ class Create extends Action
->param('default', null, new Nullable(new Spatial(Database::VAR_POLYGON)), 'Default value for attribute when not provided, three-dimensional array where the outer array holds one or more linear rings, [[[longitude, latitude], …], …], the first ring is the exterior boundary, any additional rings are interior holes, and each ring must start and end with the same coordinate pair. Cannot be set when attribute is required.', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?array $default, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?array $default, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void
{
if (!$dbForProject->getAdapter()->getSupportForSpatialAttributes()) {
throw new Exception(Exception::GENERAL_FEATURE_UNSUPPORTED, 'Spatial columns are not supported by this database.');
@@ -86,7 +86,7 @@ class Create extends Action
'type' => Database::VAR_POLYGON,
'required' => $required,
'default' => $default,
- ]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization);
+ ]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization);
$response
->setStatusCode(SwooleResponse::STATUS_CODE_ACCEPTED)
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Relationship/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Relationship/Create.php
index fdd40aaa8f..ace48a5c56 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Relationship/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Relationship/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Relationship;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
use Appwrite\SDK\AuthType;
@@ -81,13 +81,13 @@ class Create extends Action
], true), 'Constraints option', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string $relatedCollectionId, string $type, bool $twoWay, ?string $key, ?string $twoWayKey, string $onDelete, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, string $relatedCollectionId, string $type, bool $twoWay, ?string $key, ?string $twoWayKey, string $onDelete, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void
{
if (!$dbForProject->getAdapter()->getSupportForRelationships()) {
throw new Exception(Exception::GENERAL_FEATURE_UNSUPPORTED, 'Relationships are not supported by this database.');
@@ -159,7 +159,7 @@ class Create extends Action
'twoWayKey' => $twoWayKey,
'onDelete' => $onDelete,
]
- ]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization);
+ ]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization);
foreach ($attribute->getAttribute('options', []) as $k => $option) {
$attribute->setAttribute($k, $option);
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/String/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/String/Create.php
index c8917c3deb..a32a3083ab 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/String/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/String/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\String;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
use Appwrite\SDK\AuthType;
@@ -75,7 +75,7 @@ class Create extends Action
->param('encrypt', false, new Boolean(), 'Toggle encryption for the attribute. Encryption enhances security by not storing any plain text values in the database. However, encrypted attributes cannot be queried.', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('plan')
->inject('authorization')
@@ -93,7 +93,7 @@ class Create extends Action
bool $encrypt,
UtopiaResponse $response,
Database $dbForProject,
- EventDatabase $queueForDatabase,
+ DatabasePublisher $publisherForDatabase,
Event $queueForEvents,
array $plan,
Authorization $authorization
@@ -134,7 +134,7 @@ class Create extends Action
]),
$response,
$dbForProject,
- $queueForDatabase,
+ $publisherForDatabase,
$queueForEvents,
$authorization
);
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Text/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Text/Create.php
index eb6b2f9691..79968d0feb 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Text/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Text/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Text;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
use Appwrite\SDK\AuthType;
@@ -67,7 +67,7 @@ class Create extends Action
->param('encrypt', false, new Boolean(), 'Toggle encryption for the attribute. Encryption enhances security by not storing any plain text values in the database. However, encrypted attributes cannot be queried.', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('plan')
->inject('authorization')
@@ -84,7 +84,7 @@ class Create extends Action
bool $encrypt,
UtopiaResponse $response,
Database $dbForProject,
- EventDatabase $queueForDatabase,
+ DatabasePublisher $publisherForDatabase,
Event $queueForEvents,
array $plan,
Authorization $authorization
@@ -112,7 +112,7 @@ class Create extends Action
]),
$response,
$dbForProject,
- $queueForDatabase,
+ $publisherForDatabase,
$queueForEvents,
$authorization
);
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/URL/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/URL/Create.php
index 7ada8c7f7d..7338bdbd1d 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/URL/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/URL/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\URL;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Deprecated;
@@ -69,7 +69,7 @@ class Create extends Action
->param('array', false, new Boolean(), 'Is attribute an array?', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
@@ -84,7 +84,7 @@ class Create extends Action
bool $array,
UtopiaResponse $response,
Database $dbForProject,
- EventDatabase $queueForDatabase,
+ DatabasePublisher $publisherForDatabase,
Event $queueForEvents,
Authorization $authorization
): void {
@@ -96,7 +96,7 @@ class Create extends Action
'default' => $default,
'array' => $array,
'format' => APP_DATABASE_ATTRIBUTE_URL,
- ]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization);
+ ]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization);
$response
->setStatusCode(SwooleResponse::STATUS_CODE_ACCEPTED)
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Varchar/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Varchar/Create.php
index 24a36725c8..89690de4e9 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Varchar/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Varchar/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Varchar;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
use Appwrite\SDK\AuthType;
@@ -70,7 +70,7 @@ class Create extends Action
->param('encrypt', false, new Boolean(), 'Toggle encryption for the attribute. Encryption enhances security by not storing any plain text values in the database. However, encrypted attributes cannot be queried.', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('plan')
->inject('authorization')
@@ -88,7 +88,7 @@ class Create extends Action
bool $encrypt,
UtopiaResponse $response,
Database $dbForProject,
- EventDatabase $queueForDatabase,
+ DatabasePublisher $publisherForDatabase,
Event $queueForEvents,
array $plan,
Authorization $authorization
@@ -129,7 +129,7 @@ class Create extends Action
]),
$response,
$dbForProject,
- $queueForDatabase,
+ $publisherForDatabase,
$queueForEvents,
$authorization
);
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php
index fd309a413c..3a53a49579 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php
@@ -290,13 +290,15 @@ class Create extends Action
}
if (isset($attribute['min']) || isset($attribute['max'])) {
- $format = $type === Database::VAR_INTEGER
- ? APP_DATABASE_ATTRIBUTE_INT_RANGE
- : APP_DATABASE_ATTRIBUTE_FLOAT_RANGE;
+ $format = match($type) {
+ Database::VAR_INTEGER => APP_DATABASE_ATTRIBUTE_INT_RANGE,
+ Database::VAR_BIGINT => APP_DATABASE_ATTRIBUTE_BIGINT_RANGE,
+ default => APP_DATABASE_ATTRIBUTE_FLOAT_RANGE,
+ };
$formatOptions = [
- 'min' => $attribute['min'] ?? ($type === Database::VAR_INTEGER ? \PHP_INT_MIN : -\PHP_FLOAT_MAX),
- 'max' => $attribute['max'] ?? ($type === Database::VAR_INTEGER ? \PHP_INT_MAX : \PHP_FLOAT_MAX),
+ 'min' => $attribute['min'] ?? ($type === Database::VAR_INTEGER || $type === Database::VAR_BIGINT ? \PHP_INT_MIN : -\PHP_FLOAT_MAX),
+ 'max' => $attribute['max'] ?? ($type === Database::VAR_INTEGER || $type === Database::VAR_BIGINT ? \PHP_INT_MAX : \PHP_FLOAT_MAX),
];
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Delete.php
index 7a5b73f7db..87171fb2fe 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Delete.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Database as DatabaseMessage;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -63,13 +64,13 @@ class Delete extends Action
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void
{
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty()) {
@@ -89,22 +90,22 @@ class Delete extends Action
$dbForDatabases = $getDatabasesDB($database);
$dbForDatabases->purgeCachedCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence());
- $queueForDatabase
- ->setType(DATABASE_TYPE_DELETE_COLLECTION)
- ->setDatabase($database);
-
- if ($this->isCollectionsAPI()) {
- $queueForDatabase->setCollection($collection);
- } else {
- $queueForDatabase->setTable($collection);
- }
-
$queueForEvents
->setParam('databaseId', $databaseId)
->setContext('database', $database)
->setParam($this->getEventsParamKey(), $collection->getId())
->setPayload($response->output($collection, $this->getResponseModel()));
+ $publisherForDatabase->enqueue(new DatabaseMessage(
+ project: $queueForEvents->getProject(),
+ user: $queueForEvents->getUser(),
+ type: DATABASE_TYPE_DELETE_COLLECTION,
+ database: $database,
+ collection: $this->isCollectionsAPI() ? $collection : null,
+ table: $this->isCollectionsAPI() ? null : $collection,
+ events: Event::generateEvents($queueForEvents->getEvent(), $queueForEvents->getParams()),
+ ));
+
$response->noContent();
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php
index 91dd9c603c..d62782f95e 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php
@@ -3,6 +3,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Func as FunctionMessage;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Functions\EventProcessor;
use Appwrite\Platform\Modules\Databases\Http\Databases\Action as DatabasesAction;
@@ -14,10 +16,10 @@ use Utopia\Database\Validator\Authorization;
abstract class Action extends DatabasesAction
{
/**
- * @var string|null The current context (either 'row' or 'document')
+ * @var string The current context (either 'row' or 'document')
*/
- private ?string $context = DOCUMENTS;
- private ?string $databaseType = DATABASE_TYPE_LEGACY;
+ private string $context = DOCUMENTS;
+ private string $databaseType = DATABASE_TYPE_LEGACY;
/**
* Get the response model used in the SDK and HTTP responses.
@@ -421,7 +423,7 @@ abstract class Action extends DatabasesAction
* @param Document[] $documents
* @param Event $queueForEvents
* @param Event $queueForRealtime
- * @param Event $queueForFunctions
+ * @param FunctionPublisher $publisherForFunctions
* @param Event $queueForWebhooks
* @param Database $dbForProject
* @param EventProcessor $eventProcessor
@@ -434,7 +436,7 @@ abstract class Action extends DatabasesAction
array $documents,
Event $queueForEvents,
Event $queueForRealtime,
- Event $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Event $queueForWebhooks,
Database $dbForProject,
EventProcessor $eventProcessor
@@ -472,9 +474,15 @@ abstract class Action extends DatabasesAction
if (!empty($functionsEvents)) {
foreach ($generatedEvents as $event) {
if (isset($functionsEvents[$event])) {
- $queueForFunctions
- ->from($queueForEvents)
- ->trigger();
+ $publisherForFunctions->enqueue(FunctionMessage::fromEvent(
+ event: $queueForEvents->getEvent(),
+ params: $queueForEvents->getParams(),
+ project: $queueForEvents->getProject(),
+ user: $queueForEvents->getUser(),
+ userId: $queueForEvents->getUserId(),
+ payload: $queueForEvents->getPayload(),
+ platform: $queueForEvents->getPlatform(),
+ ));
break;
}
}
@@ -494,7 +502,6 @@ abstract class Action extends DatabasesAction
$queueForEvents->reset();
$queueForRealtime->reset();
- $queueForFunctions->reset();
$queueForWebhooks->reset();
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php
index 267a54adb0..2dc3100046 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php
@@ -3,6 +3,7 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Bulk;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Functions\EventProcessor;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Action;
@@ -80,14 +81,14 @@ class Delete extends Action
->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Context $usage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void
+ public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Context $usage, Event $queueForEvents, Event $queueForRealtime, FunctionPublisher $publisherForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void
{
$database = $dbForProject->getDocument('databases', $databaseId);
if ($database->isEmpty()) {
@@ -206,7 +207,7 @@ class Delete extends Action
$documents,
$queueForEvents,
$queueForRealtime,
- $queueForFunctions,
+ $publisherForFunctions,
$queueForWebhooks,
$dbForProject,
$eventProcessor
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php
index da3adf1192..393590d1e6 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php
@@ -3,6 +3,7 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Bulk;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Functions\EventProcessor;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Action;
@@ -84,14 +85,14 @@ class Update extends Action
->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string|array $data, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Context $usage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void
+ public function action(string $databaseId, string $collectionId, string|array $data, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Context $usage, Event $queueForEvents, Event $queueForRealtime, FunctionPublisher $publisherForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void
{
$data = \is_string($data)
? \json_decode($data, true)
@@ -237,7 +238,7 @@ class Update extends Action
$documents,
$queueForEvents,
$queueForRealtime,
- $queueForFunctions,
+ $publisherForFunctions,
$queueForWebhooks,
$dbForProject,
$eventProcessor
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php
index 5a5ebf48ee..d69298919b 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php
@@ -3,6 +3,7 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Bulk;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Functions\EventProcessor;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Action;
@@ -82,14 +83,14 @@ class Upsert extends Action
->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Context $usage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void
+ public function action(string $databaseId, string $collectionId, array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Context $usage, Event $queueForEvents, Event $queueForRealtime, FunctionPublisher $publisherForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void
{
$database = $dbForProject->getDocument('databases', $databaseId);
if ($database->isEmpty()) {
@@ -212,7 +213,7 @@ class Upsert extends Action
$upserted,
$queueForEvents,
$queueForRealtime,
- $queueForFunctions,
+ $publisherForFunctions,
$queueForWebhooks,
$dbForProject,
$eventProcessor
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php
index 38c84c4ae1..2ade0b2b79 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php
@@ -3,6 +3,7 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Functions\EventProcessor;
use Appwrite\SDK\AuthType;
@@ -50,6 +51,11 @@ class Create extends Action
return UtopiaResponse::MODEL_DOCUMENT_LIST;
}
+ protected function getSupportForEmptyDocument()
+ {
+ return false;
+ }
+
public function __construct()
{
$this
@@ -132,37 +138,49 @@ class Create extends Action
->inject('queueForEvents')
->inject('usage')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('authorization')
->inject('eventProcessor')
->callback($this->action(...));
}
- public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, ?array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, User $user, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, Authorization $authorization, EventProcessor $eventProcessor): void
+
+ public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, ?array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, User $user, Event $queueForEvents, Context $usage, Event $queueForRealtime, FunctionPublisher $publisherForFunctions, Event $queueForWebhooks, array $plan, Authorization $authorization, EventProcessor $eventProcessor): void
{
$data = \is_string($data)
? \json_decode($data, true)
: $data;
+ $supportsEmptyDocument = $this->getSupportForEmptyDocument();
+ $hasData = !empty($data);
+ $hasDocuments = !empty($documents);
+
/**
* Determine which internal path to call, single or bulk
*/
- if (empty($data) && empty($documents)) {
+ if (!$supportsEmptyDocument && !$hasData && !$hasDocuments) {
// No single or bulk documents provided
throw new Exception($this->getMissingDataException());
}
- if (!empty($data) && !empty($documents)) {
+
+ // When empty documents are supported, an empty payload should still be treated as single create.
+ if ($supportsEmptyDocument && !$hasData && !$hasDocuments) {
+ $data = [];
+ $hasData = true;
+ }
+
+ if ($hasData && $hasDocuments) {
// Both single and bulk documents provided
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'You can only send one of the following parameters: data, ' . $this->getSDKGroup());
}
- if (!empty($data) && empty($documentId)) {
+ if ($hasData && empty($documentId)) {
// Single document provided without document ID
$document = $this->isCollectionsAPI() ? 'Document' : 'Row';
$message = "$document ID is required when creating a single " . strtolower($document) . '.';
throw new Exception($this->getMissingDataException(), $message);
}
- if (!empty($documents) && !empty($documentId)) {
+ if ($hasDocuments && !empty($documentId)) {
// Bulk documents provided with document ID
$documentId = $this->isCollectionsAPI() ? 'documentId' : 'rowId';
throw new Exception(
@@ -170,13 +188,13 @@ class Create extends Action
"Param \"$documentId\" is not allowed when creating multiple " . $this->getSDKGroup() . ', set "$id" on each instead.'
);
}
- if (!empty($documents) && !empty($permissions)) {
+ if ($hasDocuments && !empty($permissions)) {
// Bulk documents provided with permissions
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "permissions" is disallowed when creating multiple ' . $this->getSDKGroup() . ', set "$permissions" on each instead');
}
- $isBulk = true;
- if (!empty($data)) {
+ $isBulk = $hasDocuments;
+ if ($hasData) {
// Single document provided, convert to single item array
// But remember that it was single to respond with a single document
$isBulk = false;
@@ -276,16 +294,6 @@ class Create extends Action
throw new Exception(Exception::USER_UNAUTHORIZED, $authorization->getDescription());
}
- if ($permission === Database::PERMISSION_UPDATE) {
- $validDocument = $authorization->isValid(
- new Input($permission, $document->getUpdate())
- );
- $valid = $validCollection || $validDocument;
- if ($documentSecurity && !$valid) {
- throw new Exception(Exception::USER_UNAUTHORIZED, $authorization->getDescription());
- }
- }
-
$relationships = \array_filter(
$collection->getAttribute('attributes', []),
fn ($attribute) => $attribute->getAttribute('type') === Database::VAR_RELATIONSHIP
@@ -510,7 +518,7 @@ class Create extends Action
$created,
$queueForEvents,
$queueForRealtime,
- $queueForFunctions,
+ $publisherForFunctions,
$queueForWebhooks,
$dbForProject,
$eventProcessor
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php
index b48df136ee..06f0e9cf1c 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php
@@ -100,7 +100,7 @@ class Get extends Action
}
try {
- $selects = Query::groupByType($queries)['selections'] ?? [];
+ $selects = Query::groupByType($queries)['selections'];
$collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence();
$collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence();
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php
index ef89b80e97..fb3d414097 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php
@@ -353,12 +353,7 @@ class Upsert extends Action
$collectionsCache = [];
if (empty($upserted[0])) {
- if ($transactionId !== null) {
- // For transactions, get the document with transaction changes applied
- $upserted[0] = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId);
- } else {
- $upserted[0] = $dbForDatabases->getDocument($collectionTableId, $documentId);
- }
+ $upserted[0] = $dbForDatabases->getDocument($collectionTableId, $documentId);
}
$document = $upserted[0];
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php
index aeee280615..fdcbced6f3 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php
@@ -14,6 +14,7 @@ use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Response as UtopiaResponse;
use Utopia\Database\Database;
use Utopia\Database\Document;
+use Utopia\Database\Exception\NotFound as NotFoundException;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Exception\Timeout;
@@ -22,6 +23,7 @@ use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Database\Validator\UID;
use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
+use Utopia\Http\Http;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Nullable;
@@ -80,10 +82,11 @@ class XList extends Action
->inject('usage')
->inject('transactionState')
->inject('authorization')
+ ->inject('utopia')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, ?Http $utopia = null): void
{
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
@@ -116,7 +119,14 @@ class XList extends Action
$documentId = $cursor->getValue();
- $cursorDocument = $authorization->skip(fn () => $dbForDatabases->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId));
+ try {
+ $cursorDocument = $authorization->skip(fn () => $dbForDatabases->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId));
+ } catch (NotFoundException) {
+ // The collection metadata document exists but the backing store (e.g. a
+ // dedicated DocumentsDB shard) has no table for it. Treat this as a
+ // not-found on the collection so the caller sees a 404 instead of a 500.
+ throw new Exception($this->getParentNotFoundException(), params: [$collectionId]);
+ }
if ($cursorDocument->isEmpty()) {
$type = ucfirst($this->getContext());
@@ -126,8 +136,10 @@ class XList extends Action
$cursor->setValue($cursorDocument);
}
+ $dbStart = \microtime(true);
+
try {
- $hasSelects = ! empty(Query::groupByType($queries)['selections'] ?? []);
+ $hasSelects = ! empty(Query::groupByType($queries)['selections']);
$collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence();
// When there are no select queries, relationship loading is skipped on the
// underlying find() to avoid pulling related documents the caller did not ask for.
@@ -178,7 +190,7 @@ class XList extends Action
$cachedTotal = null;
}
if ($cachedTotal !== null && $cachedTotal !== false) {
- $total = $cachedTotal;
+ $total = (int) $cachedTotal;
} else {
$total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT);
try {
@@ -195,6 +207,11 @@ class XList extends Action
$documents = $find();
$total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0;
}
+ } catch (NotFoundException) {
+ // The collection metadata document exists but the backing store (e.g. a
+ // dedicated DocumentsDB shard) has no table for it. Treat this as a
+ // not-found on the collection so the caller sees a 404 instead of a 500.
+ throw new Exception($this->getParentNotFoundException(), params: [$collectionId]);
} catch (OrderException $e) {
$documents = $this->isCollectionsAPI() ? 'documents' : 'rows';
$attribute = $this->isCollectionsAPI() ? 'attribute' : 'column';
@@ -206,6 +223,8 @@ class XList extends Action
throw new Exception(Exception::DATABASE_TIMEOUT);
}
+ $dbDurationMs = (\microtime(true) - $dbStart) * 1000;
+
$operations = 0;
$collectionsCache = [];
foreach ($documents as $document) {
@@ -229,5 +248,20 @@ class XList extends Action
// rows or documents
$this->getSDKGroup() => $documents,
]), $this->getResponseModel());
+
+ try {
+ $this->afterQuery($dbDurationMs, $database, $collection, $queries, $utopia);
+ } catch (\Throwable) {
+ // Observers must never break the response.
+ }
+ }
+
+ /**
+ * After query hook.
+ *
+ * @param array $queries
+ */
+ protected function afterQuery(float $dbDurationMs, Document $database, Document $collection, array $queries, ?Http $utopia): void
+ {
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php
index 400d716e41..251e493cb6 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php
@@ -10,7 +10,7 @@ abstract class Action extends UtopiaAction
/**
* The current API context (either 'columnIndex' or 'index').
*/
- private ?string $context = INDEX;
+ private string $context = INDEX;
/**
* Get the response model used in the SDK and HTTP responses.
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php
index 7e073c95d4..6c13a5c33c 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Indexes;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Database as DatabaseMessage;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -78,13 +79,13 @@ class Create extends Action
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string $key, string $type, array $attributes, array $orders, array $lengths, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, string $key, string $type, array $attributes, array $orders, array $lengths, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void
{
$db = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
@@ -228,20 +229,6 @@ class Create extends Action
$dbForProject->purgeCachedDocument('database_' . $db->getSequence(), $collectionId);
- $queueForDatabase
- ->setType(DATABASE_TYPE_CREATE_INDEX)
- ->setDatabase($db);
-
- if ($this->isCollectionsAPI()) {
- $queueForDatabase
- ->setCollection($collection)
- ->setDocument($index);
- } else {
- $queueForDatabase
- ->setTable($collection)
- ->setRow($index);
- }
-
$queueForEvents
->setContext('database', $db)
->setParam('databaseId', $databaseId)
@@ -250,6 +237,18 @@ class Create extends Action
->setParam('tableId', $collection->getId())
->setContext($this->getCollectionsEventsContext(), $collection);
+ $publisherForDatabase->enqueue(new DatabaseMessage(
+ project: $queueForEvents->getProject(),
+ user: $queueForEvents->getUser(),
+ type: DATABASE_TYPE_CREATE_INDEX,
+ database: $db,
+ collection: $this->isCollectionsAPI() ? $collection : null,
+ document: $this->isCollectionsAPI() ? $index : null,
+ table: $this->isCollectionsAPI() ? null : $collection,
+ row: $this->isCollectionsAPI() ? null : $index,
+ events: Event::generateEvents($queueForEvents->getEvent(), $queueForEvents->getParams()),
+ ));
+
$response
->setStatusCode(SwooleResponse::STATUS_CODE_ACCEPTED)
->dynamic($index, $this->getResponseModel());
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Delete.php
index dea62bfc16..82cada6e0d 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Delete.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Indexes;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Database as DatabaseMessage;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -69,13 +70,13 @@ class Delete extends Action
->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Index Key.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string $key, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, string $key, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void
{
$db = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
@@ -103,20 +104,6 @@ class Delete extends Action
$dbForProject->purgeCachedDocument('database_' . $db->getSequence(), $collectionId);
- $queueForDatabase
- ->setType(DATABASE_TYPE_DELETE_INDEX)
- ->setDatabase($db);
-
- if ($this->isCollectionsAPI()) {
- $queueForDatabase
- ->setCollection($collection)
- ->setDocument($index);
- } else {
- $queueForDatabase
- ->setTable($collection)
- ->setRow($index);
- }
-
$queueForEvents
->setContext('database', $db)
->setParam('databaseId', $databaseId)
@@ -126,6 +113,18 @@ class Delete extends Action
->setContext($this->getCollectionsEventsContext(), $collection)
->setPayload($response->output($index, $this->getResponseModel()));
+ $publisherForDatabase->enqueue(new DatabaseMessage(
+ project: $queueForEvents->getProject(),
+ user: $queueForEvents->getUser(),
+ type: DATABASE_TYPE_DELETE_INDEX,
+ database: $db,
+ collection: $this->isCollectionsAPI() ? $collection : null,
+ document: $this->isCollectionsAPI() ? $index : null,
+ table: $this->isCollectionsAPI() ? null : $collection,
+ row: $this->isCollectionsAPI() ? null : $index,
+ events: Event::generateEvents($queueForEvents->getEvent(), $queueForEvents->getParams()),
+ ));
+
$response->noContent();
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php
index 37213f1061..bea367af36 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php
@@ -119,6 +119,7 @@ class Get extends Action
$format = match ($days['period']) {
'1h' => 'Y-m-d\TH:00:00.000P',
'1d' => 'Y-m-d\T00:00:00.000P',
+ default => throw new \LogicException('Unexpected period: ' . $days['period']),
};
foreach ($metrics as $metric) {
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php
index 3d07c65250..294a6712a9 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php
@@ -49,11 +49,6 @@ class Create extends Action
$databaseOverride = '';
$dbScheme = '';
$databaseSharedTables = [];
- $databaseSharedTablesV1 = [];
- $databaseSharedTablesV2 = [];
- $projectSharedTables = [];
- $projectSharedTablesV1 = [];
- $projectSharedTablesV2 = [];
switch ($databasetype) {
case DOCUMENTSDB:
@@ -62,7 +57,6 @@ class Create extends Action
$databaseOverride = System::getEnv('_APP_DATABASE_DOCUMENTSDB_OVERRIDE');
$dbScheme = System::getEnv('_APP_DB_HOST_DOCUMENTSDB', 'mongodb');
$databaseSharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', '')));
- $databaseSharedTablesV1 = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', '')));
break;
case VECTORSDB:
$databases = Config::getParam('pools-vectorsdb', []);
@@ -70,7 +64,6 @@ class Create extends Action
$databaseOverride = System::getEnv('_APP_DATABASE_VECTORSDB_OVERRIDE');
$dbScheme = System::getEnv('_APP_DB_HOST_VECTORSDB', 'postgresql');
$databaseSharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', '')));
- $databaseSharedTablesV1 = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', '')));
break;
default:
// legacy/tablesdb
@@ -78,8 +71,7 @@ class Create extends Action
return $dsn;
}
- $isSharedTablesV1 = false;
- $isSharedTablesV2 = false;
+ $isSharedTables = false;
if (!empty($dsn)) {
try {
@@ -90,10 +82,7 @@ class Create extends Action
}
$projectSharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
- $projectSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', ''));
- $projectSharedTablesV2 = \array_diff($projectSharedTables, $projectSharedTablesV1);
- $isSharedTablesV1 = \in_array($dsnHost, $projectSharedTablesV1);
- $isSharedTablesV2 = \in_array($dsnHost, $projectSharedTablesV2);
+ $isSharedTables = \in_array($dsnHost, $projectSharedTables);
}
if ($region !== 'default') {
@@ -102,18 +91,14 @@ class Create extends Action
return str_contains($value, $region);
});
}
- $databaseSharedTablesV2 = \array_diff($databaseSharedTables, $databaseSharedTablesV1);
$index = \array_search($databaseOverride, $databases);
if ($index !== false) {
$selectedDsn = $databases[$index];
} else {
if (!empty($dsn) && !empty($databaseSharedTables)) {
- $beforeFilter = \array_values($databases);
- if ($isSharedTablesV1) {
- $databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTablesV1));
- } elseif ($isSharedTablesV2) {
- $databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTablesV2));
+ if ($isSharedTables) {
+ $databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTables));
} else {
$databases = array_filter($databases, fn ($value) => !\in_array($value, $databaseSharedTables));
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Delete.php
index 1046d7e566..058c48d68f 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Delete.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases;
-use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Database as DatabaseMessage;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -58,12 +59,12 @@ class Delete extends Action
->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->callback($this->action(...));
}
- public function action(string $databaseId, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents): void
+ public function action(string $databaseId, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents): void
{
$database = $dbForProject->getDocument('databases', $databaseId);
@@ -78,14 +79,18 @@ class Delete extends Action
$dbForProject->purgeCachedDocument('databases', $database->getId());
$dbForProject->purgeCachedCollection('databases_' . $database->getSequence());
- $queueForDatabase
- ->setType(DATABASE_TYPE_DELETE_DATABASE)
- ->setDatabase($database);
-
$queueForEvents
->setParam('databaseId', $database->getId())
->setPayload($response->output($database, UtopiaResponse::MODEL_DATABASE));
+ $publisherForDatabase->enqueue(new DatabaseMessage(
+ project: $queueForEvents->getProject(),
+ user: $queueForEvents->getUser(),
+ type: DATABASE_TYPE_DELETE_DATABASE,
+ database: $database,
+ events: Event::generateEvents($queueForEvents->getEvent(), $queueForEvents->getParams()),
+ ));
+
$response->noContent();
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php
index 1ed7e6a63f..a13c6c4903 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php
@@ -2,6 +2,7 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Logs;
+use Appwrite\Detector\Detector;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -9,7 +10,6 @@ use Appwrite\SDK\Deprecated;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response as UtopiaResponse;
-use DeviceDetector\DeviceDetector as Detector;
use MaxMind\Db\Reader;
use Utopia\Audit\Audit;
use Utopia\Database\Database;
@@ -103,9 +103,9 @@ class XList extends Action
$os = $detector->getOS();
$client = $detector->getClient();
$device = $detector->getDevice();
- $deviceName = \is_array($device) ? ($device['deviceName'] ?? '') : '';
- $deviceBrand = \is_array($device) ? ($device['deviceBrand'] ?? '') : '';
- $deviceModel = \is_array($device) ? ($device['deviceModel'] ?? '') : '';
+ $deviceName = $device['deviceName'] ?? '';
+ $deviceBrand = $device['deviceBrand'] ?? '';
+ $deviceModel = $device['deviceModel'] ?? '';
$output[$i] = new Document([
'event' => $log['event'],
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php
index 91bc1a3ccf..ccf9632fef 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php
@@ -9,8 +9,8 @@ abstract class Action extends DatabasesAction
/**
* The current API context (either 'table' or 'collection').
*/
- private ?string $context = COLLECTIONS;
- private ?string $databaseType = LEGACY;
+ private string $context = COLLECTIONS;
+ private string $databaseType = LEGACY;
public function getDatabaseType(): string
{
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Delete.php
index d57cebbe4a..072cb21bbc 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Delete.php
@@ -2,7 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Transactions;
-use Appwrite\Event\Delete as DeleteEvent;
+use Appwrite\Event\Message\Delete as DeleteMessage;
+use Appwrite\Event\Publisher\Delete as DeletePublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -10,6 +11,7 @@ use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response as UtopiaResponse;
use Utopia\Database\Database;
+use Utopia\Database\Document;
use Utopia\Database\Validator\UID;
use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
@@ -51,11 +53,12 @@ class Delete extends Action
->param('transactionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Transaction ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
+ ->inject('project')
->callback($this->action(...));
}
- public function action(string $transactionId, UtopiaResponse $response, Database $dbForProject, DeleteEvent $queueForDeletes): void
+ public function action(string $transactionId, UtopiaResponse $response, Database $dbForProject, DeletePublisher $publisherForDeletes, Document $project): void
{
$transaction = $dbForProject->getDocument('transactions', $transactionId);
@@ -65,9 +68,11 @@ class Delete extends Action
$dbForProject->deleteDocument('transactions', $transactionId);
- $queueForDeletes
- ->setType(DELETE_TYPE_DOCUMENT)
- ->setDocument($transaction);
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $project,
+ type: DELETE_TYPE_DOCUMENT,
+ document: $transaction,
+ ));
$response->noContent();
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php
index c4d51e6c64..fe2ad8dbae 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php
@@ -3,8 +3,11 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Transactions;
use Appwrite\Databases\TransactionState;
-use Appwrite\Event\Delete;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Delete as DeleteMessage;
+use Appwrite\Event\Message\Func as FunctionMessage;
+use Appwrite\Event\Publisher\Delete as DeletePublisher;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Functions\EventProcessor;
use Appwrite\SDK\AuthType;
@@ -73,11 +76,11 @@ class Update extends Action
->inject('getDatabasesDB')
->inject('user')
->inject('transactionState')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('queueForEvents')
->inject('usage')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('authorization')
->inject('eventProcessor')
@@ -93,11 +96,11 @@ class Update extends Action
* @param callable $getDatabasesDB
* @param User $user
* @param TransactionState $transactionState
- * @param Delete $queueForDeletes
+ * @param DeletePublisher $publisherForDeletes
* @param Event $queueForEvents
* @param Context $usage
* @param Event $queueForRealtime
- * @param Event $queueForFunctions
+ * @param FunctionPublisher $publisherForFunctions
* @param Event $queueForWebhooks
* @param EventProcessor $eventProcessor
* @return void
@@ -108,7 +111,7 @@ class Update extends Action
* @throws StructureException
* @throws \Utopia\Http\Exception
*/
- public function action(string $transactionId, bool $commit, bool $rollback, Document $project, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, User $user, TransactionState $transactionState, Delete $queueForDeletes, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, Authorization $authorization, EventProcessor $eventProcessor): void
+ public function action(string $transactionId, bool $commit, bool $rollback, Document $project, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, User $user, TransactionState $transactionState, DeletePublisher $publisherForDeletes, Event $queueForEvents, Context $usage, Event $queueForRealtime, FunctionPublisher $publisherForFunctions, Event $queueForWebhooks, Authorization $authorization, EventProcessor $eventProcessor): void
{
if (!$commit && !$rollback) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Either commit or rollback must be true');
@@ -154,9 +157,11 @@ class Update extends Action
new Document(['status' => 'committed'])
));
- $queueForDeletes
- ->setType(DELETE_TYPE_DOCUMENT)
- ->setDocument($transaction);
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $project,
+ type: DELETE_TYPE_DOCUMENT,
+ document: $transaction,
+ ));
$response
->setStatusCode(SwooleResponse::STATUS_CODE_OK)
@@ -293,9 +298,11 @@ class Update extends Action
new Document(['status' => 'committed'])
));
- $queueForDeletes
- ->setType(DELETE_TYPE_DOCUMENT)
- ->setDocument($transaction);
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $project,
+ type: DELETE_TYPE_DOCUMENT,
+ document: $transaction,
+ ));
} catch (NotFoundException $e) {
$authorization->skip(fn () => $dbForProject->updateDocument('transactions', $transactionId, new Document([
'status' => 'failed',
@@ -461,7 +468,15 @@ class Update extends Action
if (!empty($functionsEvents)) {
foreach ($generatedEvents as $event) {
if (isset($functionsEvents[$event])) {
- $queueForFunctions->from($queueForEvents)->trigger();
+ $publisherForFunctions->enqueue(FunctionMessage::fromEvent(
+ event: $queueForEvents->getEvent(),
+ params: $queueForEvents->getParams(),
+ project: $queueForEvents->getProject(),
+ user: $queueForEvents->getUser(),
+ userId: $queueForEvents->getUserId(),
+ payload: $queueForEvents->getPayload(),
+ platform: $queueForEvents->getPlatform(),
+ ));
break;
}
}
@@ -480,7 +495,6 @@ class Update extends Action
$queueForEvents->reset();
$queueForRealtime->reset();
- $queueForFunctions->reset();
$queueForWebhooks->reset();
}
}
@@ -492,9 +506,11 @@ class Update extends Action
new Document(['status' => 'failed'])
));
- $queueForDeletes
- ->setType(DELETE_TYPE_DOCUMENT)
- ->setDocument($transaction);
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $project,
+ type: DELETE_TYPE_DOCUMENT,
+ document: $transaction,
+ ));
}
$response
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php
index 18e6fd7a8b..240e7d400c 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php
@@ -144,6 +144,7 @@ class Get extends Action
$format = match ($days['period']) {
'1h' => 'Y-m-d\TH:00:00.000P',
'1d' => 'Y-m-d\T00:00:00.000P',
+ default => throw new \LogicException('Unexpected period: ' . $days['period']),
};
foreach ($metrics as $metric) {
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php
index b8cb774a3e..db73954e7f 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php
@@ -133,6 +133,7 @@ class XList extends Action
$format = match ($days['period']) {
'1h' => 'Y-m-d\TH:00:00.000P',
'1d' => 'Y-m-d\T00:00:00.000P',
+ default => throw new \LogicException('Unexpected period: ' . $days['period']),
};
foreach ($metrics as $metric) {
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Delete.php
index d698b40203..043f74998d 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Delete.php
@@ -54,7 +54,7 @@ class Delete extends CollectionDelete
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Delete.php
index 09ad9a5741..6b2910aac4 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Delete.php
@@ -63,7 +63,7 @@ class Delete extends DocumentsDelete
->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Update.php
index c723f1bc30..f395d0b490 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Update.php
@@ -65,7 +65,7 @@ class Update extends DocumentsUpdate
->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Upsert.php
index d5b62ec903..5acc4626af 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Upsert.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Upsert.php
@@ -65,7 +65,7 @@ class Upsert extends DocumentsUpsert
->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php
index 039a05ff50..2df96958ad 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php
@@ -34,6 +34,12 @@ class Create extends DocumentCreate
return UtopiaResponse::MODEL_DOCUMENT_LIST;
}
+ protected function getSupportForEmptyDocument()
+ {
+ return true;
+ }
+
+
public function __construct()
{
$this
@@ -106,7 +112,7 @@ class Create extends DocumentCreate
->inject('queueForEvents')
->inject('usage')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('authorization')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php
deleted file mode 100644
index cc7fe41555..0000000000
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php
+++ /dev/null
@@ -1,59 +0,0 @@
-setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
- ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId/logs')
- ->desc('List document logs')
- ->groups(['api', 'database'])
- ->label('scope', 'documents.read')
- ->label('resourceType', RESOURCE_TYPE_DATABASES)
- ->label('sdk', new Method(
- namespace: 'documentsDB',
- group: 'logs',
- name: 'listDocumentLogs',
- description: '/docs/references/documentsdb/get-document-logs.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: SwooleResponse::STATUS_CODE_OK,
- model: $this->getResponseModel(),
- )
- ],
- contentType: ContentType::JSON,
- ))
- ->param('databaseId', '', new UID(), 'Database ID.')
- ->param('collectionId', '', new UID(), 'Collection ID.')
- ->param('documentId', '', new UID(), 'Document ID.')
- ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true)
- ->inject('response')
- ->inject('dbForProject')
- ->inject('getDatabasesDB')
- ->inject('locale')
- ->inject('geodb')
- ->inject('authorization')
- ->inject('audit')
- ->callback($this->action(...));
- }
-}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php
index 9e0d0b10d9..51c0d67e8a 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php
@@ -63,6 +63,7 @@ class XList extends DocumentXList
->inject('usage')
->inject('transactionState')
->inject('authorization')
+ ->inject('utopia')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php
index dc3ce34605..637255f16a 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php
@@ -65,7 +65,7 @@ class Create extends IndexCreate
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Delete.php
index d4464f171d..1e3c012b4f 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Delete.php
@@ -59,7 +59,7 @@ class Delete extends IndexDelete
->param('key', '', new Key(), 'Index Key.')
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php
deleted file mode 100644
index 51695ea165..0000000000
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php
+++ /dev/null
@@ -1,58 +0,0 @@
-setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
- ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/logs')
- ->desc('List collection logs')
- ->groups(['api', 'database'])
- ->label('scope', 'collections.read')
- ->label('resourceType', RESOURCE_TYPE_DATABASES)
- ->label('sdk', new Method(
- namespace: 'documentsDB',
- group: $this->getSdkGroup(),
- name: 'listCollectionLogs',
- description: '/docs/references/documentsdb/get-collection-logs.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: SwooleResponse::STATUS_CODE_OK,
- model: $this->getResponseModel(),
- )
- ],
- contentType: ContentType::JSON
- ))
- ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
- ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject'])
- ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true)
- ->inject('response')
- ->inject('dbForProject')
- ->inject('locale')
- ->inject('geodb')
- ->inject('authorization')
- ->inject('audit')
- ->callback($this->action(...));
- }
-}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php
index 1708656c98..5e63ab8a7f 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php
@@ -48,7 +48,7 @@ class Delete extends DatabaseDelete
->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('usage')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Delete.php
index 036f2e9600..94ff3fa214 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Delete.php
@@ -49,7 +49,8 @@ class Delete extends TransactionsDelete
->param('transactionId', '', new UID(), 'Transaction ID.')
->inject('response')
->inject('dbForProject')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
+ ->inject('project')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Update.php
index b4c0c2ffab..1b9cdee137 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Update.php
@@ -56,11 +56,11 @@ class Update extends TransactionsUpdate
->inject('getDatabasesDB')
->inject('user')
->inject('transactionState')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('queueForEvents')
->inject('usage')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('authorization')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Delete.php
index 7873d369e6..70dc8430f2 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Delete.php
@@ -48,7 +48,7 @@ class Delete extends DatabaseDelete
->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->callback($this->action(...));
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php
index 81822df208..ccb421b36d 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php
@@ -2,13 +2,13 @@
namespace Appwrite\Platform\Modules\Databases\Http\TablesDB\Logs;
+use Appwrite\Detector\Detector;
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 as UtopiaResponse;
-use DeviceDetector\DeviceDetector as Detector;
use MaxMind\Db\Reader;
use Utopia\Audit\Audit;
use Utopia\Database\Database;
@@ -97,9 +97,9 @@ class XList extends Action
$os = $detector->getOS();
$client = $detector->getClient();
$device = $detector->getDevice();
- $deviceName = \is_array($device) ? ($device['deviceName'] ?? '') : '';
- $deviceBrand = \is_array($device) ? ($device['deviceBrand'] ?? '') : '';
- $deviceModel = \is_array($device) ? ($device['deviceModel'] ?? '') : '';
+ $deviceName = $device['deviceName'] ?? '';
+ $deviceBrand = $device['deviceBrand'] ?? '';
+ $deviceModel = $device['deviceModel'] ?? '';
$output[$i] = new Document([
'event' => $log['event'],
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/BigInt/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/BigInt/Create.php
new file mode 100644
index 0000000000..9d882e09a6
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/BigInt/Create.php
@@ -0,0 +1,70 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/bigint')
+ ->desc('Create bigint column')
+ ->groups(['api', 'database', 'schema'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
+ ->label('audits.event', 'column.create')
+ ->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
+ ->label('sdk', new Method(
+ namespace: $this->getSDKNamespace(),
+ group: $this->getSDKGroup(),
+ name: self::getName(),
+ description: '/docs/references/tablesdb/create-bigint-column.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_ACCEPTED,
+ model: $this->getResponseModel(),
+ )
+ ]
+ ))
+ ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
+ ->param('tableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Table ID.', false, ['dbForProject'])
+ ->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Column Key.', false, ['dbForProject'])
+ ->param('required', null, new Boolean(), 'Is column required?')
+ ->param('min', null, new Nullable(new Integer(false, 64)), 'Minimum value', true)
+ ->param('max', null, new Nullable(new Integer(false, 64)), 'Maximum value', true)
+ ->param('default', null, new Nullable(new Integer(false, 64)), 'Default value. Cannot be set when column is required.', true)
+ ->param('array', false, new Boolean(), 'Is column an array?', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('publisherForDatabase')
+ ->inject('queueForEvents')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/BigInt/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/BigInt/Update.php
new file mode 100644
index 0000000000..b2754a2b7d
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/BigInt/Update.php
@@ -0,0 +1,71 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/bigint/:key')
+ ->desc('Update bigint column')
+ ->groups(['api', 'database', 'schema'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
+ ->label('audits.event', 'column.update')
+ ->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
+ ->label('sdk', new Method(
+ namespace: $this->getSDKNamespace(),
+ group: $this->getSDKGroup(),
+ name: self::getName(),
+ description: '/docs/references/tablesdb/update-bigint-column.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: $this->getResponseModel(),
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
+ ->param('tableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Table ID.', false, ['dbForProject'])
+ ->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Column Key.', false, ['dbForProject'])
+ ->param('required', null, new Boolean(), 'Is column required?')
+ ->param('min', null, new Nullable(new Integer(false, 64)), 'Minimum value', true)
+ ->param('max', null, new Nullable(new Integer(false, 64)), 'Maximum value', true)
+ ->param('default', null, new Nullable(new Integer(false, 64)), 'Default value. Cannot be set when column is required.')
+ ->param('newKey', null, fn (Database $dbForProject) => new Nullable(new Key(false, $dbForProject->getAdapter()->getMaxUIDLength())), 'New Column Key.', true, ['dbForProject'])
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('queueForEvents')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Create.php
index ddfb023d25..334c8b5124 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Create.php
@@ -34,7 +34,7 @@ class Create extends BooleanCreate
->desc('Create boolean column')
->groups(['api', 'database', 'schema'])
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'column.create')
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
@@ -59,7 +59,7 @@ class Create extends BooleanCreate
->param('array', false, new Boolean(), 'Is column an array?', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Update.php
index c808021796..1e0fe04bdc 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Update.php
@@ -34,7 +34,7 @@ class Update extends BooleanUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/boolean/:key')
->desc('Update boolean column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Create.php
index 0698002f61..922e071f35 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Create.php
@@ -34,7 +34,7 @@ class Create extends DatetimeCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/datetime')
->desc('Create datetime column')
->groups(['api', 'database'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -60,7 +60,7 @@ class Create extends DatetimeCreate
->param('array', false, new Boolean(), 'Is column an array?', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Update.php
index 035893f33f..44c1a06da8 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Update.php
@@ -35,7 +35,7 @@ class Update extends DatetimeUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/datetime/:key')
->desc('Update dateTime column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Delete.php
index 81e71df07a..8e0abf211f 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Delete.php
@@ -33,7 +33,7 @@ class Delete extends AttributesDelete
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key')
->desc('Delete column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.delete')
@@ -57,7 +57,7 @@ class Delete extends AttributesDelete
->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Column Key.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php
index b0e81ed6b7..072e334b4b 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php
@@ -34,7 +34,7 @@ class Create extends EmailCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/email')
->desc('Create email column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -60,7 +60,7 @@ class Create extends EmailCreate
->param('array', false, new Boolean(), 'Is column an array?', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php
index d1278376c1..c116d8c5b1 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php
@@ -35,7 +35,7 @@ class Update extends EmailUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/email/:key')
->desc('Update email column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php
index 9aeb9b2d4b..9d24f310bd 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php
@@ -35,7 +35,7 @@ class Create extends EnumCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/enum')
->desc('Create enum column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -62,7 +62,7 @@ class Create extends EnumCreate
->param('array', false, new Boolean(), 'Is column an array?', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php
index 43503ee8ed..208fa9c8cf 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php
@@ -36,7 +36,7 @@ class Update extends EnumUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/enum/:key')
->desc('Update enum column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php
index 0dd0ef39e1..d68b3a4921 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php
@@ -34,7 +34,7 @@ class Create extends FloatCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/float')
->desc('Create float column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -62,7 +62,7 @@ class Create extends FloatCreate
->param('array', false, new Boolean(), 'Is column an array?', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php
index 716923cc63..9ab61e642b 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php
@@ -35,7 +35,7 @@ class Update extends FloatUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/float/:key')
->desc('Update float column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php
index 0fe5fa062a..b0ef9e8a85 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php
@@ -42,7 +42,7 @@ class Get extends AttributesGet
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key')
->desc('Get column')
->groups(['api', 'database'])
- ->label('scope', ['tables.read', 'collections.read'])
+ ->label('scope', ['tables.read', 'collections.read', 'columns.read', 'attributes.read'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: $this->getSDKNamespace(),
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php
index c359feaab4..ff5828e749 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php
@@ -34,7 +34,7 @@ class Create extends IPCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/ip')
->desc('Create IP address column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -60,7 +60,7 @@ class Create extends IPCreate
->param('array', false, new Boolean(), 'Is column an array?', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php
index 0c7cc6644b..dcc4160580 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php
@@ -35,7 +35,7 @@ class Update extends IPUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/ip/:key')
->desc('Update IP address column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php
index bbb1710866..dec399cdb2 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php
@@ -34,7 +34,7 @@ class Create extends IntegerCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/integer')
->desc('Create integer column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -62,7 +62,7 @@ class Create extends IntegerCreate
->param('array', false, new Boolean(), 'Is column an array?', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php
index a9348f51e0..58dea7c848 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php
@@ -35,7 +35,7 @@ class Update extends IntegerUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/integer/:key')
->desc('Update integer column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php
index fb2c4fd1a8..71548c74da 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php
@@ -35,7 +35,7 @@ class Create extends LineCreate
->desc('Create line column')
->groups(['api', 'database', 'schema'])
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'column.create')
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
@@ -59,7 +59,7 @@ class Create extends LineCreate
->param('default', null, new Nullable(new Spatial(Database::VAR_LINESTRING)), 'Default value for column when not provided, two-dimensional array of coordinate pairs, [[longitude, latitude], [longitude, latitude], …], listing the vertices of the line in order. Cannot be set when column is required.', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php
index 564b743a2a..e2e8c59121 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php
@@ -35,7 +35,7 @@ class Update extends LineUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/line/:key')
->desc('Update line column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php
index da9471f37c..ec0f633400 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php
@@ -33,7 +33,7 @@ class Create extends LongtextCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/longtext')
->desc('Create longtext column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -60,7 +60,7 @@ class Create extends LongtextCreate
->param('encrypt', false, new Boolean(), 'Toggle encryption for the column. Encryption enhances security by not storing any plain text values in the database. However, encrypted columns cannot be queried.', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('plan')
->inject('authorization')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php
index fe93530cfb..9b90b745a2 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php
@@ -34,7 +34,7 @@ class Update extends LongtextUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/longtext/:key')
->desc('Update longtext column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php
index 585856cab9..2728caa58f 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php
@@ -33,7 +33,7 @@ class Create extends MediumtextCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/mediumtext')
->desc('Create mediumtext column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -60,7 +60,7 @@ class Create extends MediumtextCreate
->param('encrypt', false, new Boolean(), 'Toggle encryption for the column. Encryption enhances security by not storing any plain text values in the database. However, encrypted columns cannot be queried.', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('plan')
->inject('authorization')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php
index 733159d1d4..03009da25c 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php
@@ -34,7 +34,7 @@ class Update extends MediumtextUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/mediumtext/:key')
->desc('Update mediumtext column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php
index 9736e33158..601e19299b 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php
@@ -35,7 +35,7 @@ class Create extends PointCreate
->desc('Create point column')
->groups(['api', 'database', 'schema'])
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'column.create')
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
@@ -59,7 +59,7 @@ class Create extends PointCreate
->param('default', null, new Nullable(new Spatial(Database::VAR_POINT)), 'Default value for column when not provided, array of two numbers [longitude, latitude], representing a single coordinate. Cannot be set when column is required.', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php
index f104b170bd..66fb451a1f 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php
@@ -35,7 +35,7 @@ class Update extends PointUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/point/:key')
->desc('Update point column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php
index 177399396c..36972d5da2 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php
@@ -35,7 +35,7 @@ class Create extends PolygonCreate
->desc('Create polygon column')
->groups(['api', 'database', 'schema'])
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'column.create')
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
@@ -59,7 +59,7 @@ class Create extends PolygonCreate
->param('default', null, new Nullable(new Spatial(Database::VAR_POLYGON)), 'Default value for column when not provided, three-dimensional array where the outer array holds one or more linear rings, [[[longitude, latitude], …], …], the first ring is the exterior boundary, any additional rings are interior holes, and each ring must start and end with the same coordinate pair. Cannot be set when column is required.', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php
index e66e19a7b9..7a2fd8a5de 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php
@@ -35,7 +35,7 @@ class Update extends PolygonUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/polygon/:key')
->desc('Update polygon column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php
index 84ee3e6863..414cf03b3d 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php
@@ -34,7 +34,7 @@ class Create extends RelationshipCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/relationship')
->desc('Create relationship column')
->groups(['api', 'database'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -71,7 +71,7 @@ class Create extends RelationshipCreate
], true), 'Constraints option', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php
index da5c8ca477..47884eda80 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php
@@ -34,7 +34,7 @@ class Update extends RelationshipUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key/relationship')
->desc('Update relationship column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php
index 122c8625f9..8151b3e8da 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php
@@ -37,7 +37,7 @@ class Create extends StringCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/string')
->desc('Create string column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -69,7 +69,7 @@ class Create extends StringCreate
->param('encrypt', false, new Boolean(), 'Toggle encryption for the column. Encryption enhances security by not storing any plain text values in the database. However, encrypted columns cannot be queried.', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('plan')
->inject('authorization')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php
index 0974a44d5d..2ec806d4fe 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php
@@ -37,7 +37,7 @@ class Update extends StringUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/string/:key')
->desc('Update string column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php
index 2c68431d8c..bffdc96001 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php
@@ -33,7 +33,7 @@ class Create extends TextCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/text')
->desc('Create text column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -60,7 +60,7 @@ class Create extends TextCreate
->param('encrypt', false, new Boolean(), 'Toggle encryption for the column. Encryption enhances security by not storing any plain text values in the database. However, encrypted columns cannot be queried.', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('plan')
->inject('authorization')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php
index 599c93988d..4c1477fb9e 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php
@@ -34,7 +34,7 @@ class Update extends TextUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/text/:key')
->desc('Update text column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php
index 0b386c23f6..2edf4a62f6 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php
@@ -34,7 +34,7 @@ class Create extends URLCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/url')
->desc('Create URL column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -60,7 +60,7 @@ class Create extends URLCreate
->param('array', false, new Boolean(), 'Is column an array?', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php
index df6117ea77..d680389d9e 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php
@@ -35,7 +35,7 @@ class Update extends URLUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/url/:key')
->desc('Update URL column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php
index 0ee04f5f63..307a1fd5e3 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php
@@ -35,7 +35,7 @@ class Create extends VarcharCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/varchar')
->desc('Create varchar column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
@@ -63,7 +63,7 @@ class Create extends VarcharCreate
->param('encrypt', false, new Boolean(), 'Toggle encryption for the column. Encryption enhances security by not storing any plain text values in the database. However, encrypted columns cannot be queried.', true)
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('plan')
->inject('authorization')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php
index 2b8eb9fbd7..dd170a0a19 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php
@@ -36,7 +36,7 @@ class Update extends VarcharUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/varchar/:key')
->desc('Update varchar column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php
index b38edf6218..56c436a13e 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php
@@ -33,7 +33,7 @@ class XList extends AttributesXList
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns')
->desc('List columns')
->groups(['api', 'database'])
- ->label('scope', ['tables.read', 'collections.read'])
+ ->label('scope', ['tables.read', 'collections.read', 'columns.read', 'attributes.read'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: $this->getSDKNamespace(),
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php
index 97c5465fe3..3a6d6666f2 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php
@@ -55,7 +55,7 @@ class Delete extends CollectionDelete
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php
index e683aafba1..77496fea59 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php
@@ -37,7 +37,7 @@ class Create extends IndexCreate
->desc('Create index')
->groups(['api', 'database'])
->label('event', 'databases.[databaseId].tables.[tableId].indexes.[indexId].create')
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'indexes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'index.create')
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
@@ -65,7 +65,7 @@ class Create extends IndexCreate
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Delete.php
index 7750408e29..6cd5cfe78f 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Delete.php
@@ -36,7 +36,7 @@ class Delete extends IndexDelete
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/indexes/:key')
->desc('Delete index')
->groups(['api', 'database'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'indexes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].indexes.[indexId].update')
->label('audits.event', 'index.delete')
@@ -60,7 +60,7 @@ class Delete extends IndexDelete
->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Index Key.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Get.php
index 8f721abf0e..9918bcb2b8 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Get.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Get.php
@@ -32,7 +32,7 @@ class Get extends IndexGet
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/indexes/:key')
->desc('Get index')
->groups(['api', 'database'])
- ->label('scope', ['tables.read', 'collections.read'])
+ ->label('scope', ['tables.read', 'collections.read', 'indexes.read'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: $this->getSDKNamespace(),
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/XList.php
index ff1e736c31..5fe3be4c05 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/XList.php
@@ -33,7 +33,7 @@ class XList extends IndexXList
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/indexes')
->desc('List indexes')
->groups(['api', 'database'])
- ->label('scope', ['tables.read', 'collections.read'])
+ ->label('scope', ['tables.read', 'collections.read', 'indexes.read'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: $this->getSDKNamespace(),
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php
index 37a3db01db..8315a8d04b 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php
@@ -65,7 +65,7 @@ class Delete extends DocumentsDelete
->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Update.php
index bb839b752e..a31ebc15e0 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Update.php
@@ -67,7 +67,7 @@ class Update extends DocumentsUpdate
->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Upsert.php
index 364bf4a928..543de8c4bc 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Upsert.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Upsert.php
@@ -67,7 +67,7 @@ class Upsert extends DocumentsUpsert
->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Create.php
index 26649accfb..ea9e3e0b03 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Create.php
@@ -109,7 +109,7 @@ class Create extends DocumentCreate
->inject('queueForEvents')
->inject('usage')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('authorization')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php
index 91c62aea05..87e276719e 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php
@@ -65,6 +65,7 @@ class XList extends DocumentXList
->inject('usage')
->inject('transactionState')
->inject('authorization')
+ ->inject('utopia')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Delete.php
index 9ee85ff153..988bfc3d1d 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Delete.php
@@ -50,7 +50,8 @@ class Delete extends TransactionsDelete
->param('transactionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Transaction ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
+ ->inject('project')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php
index 872927d533..bd06f475b2 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php
@@ -57,11 +57,11 @@ class Update extends TransactionsUpdate
->inject('getDatabasesDB')
->inject('user')
->inject('transactionState')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('queueForEvents')
->inject('usage')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('authorization')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Delete.php
index f1188868aa..6ee83b2530 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Delete.php
@@ -54,7 +54,7 @@ class Delete extends CollectionDelete
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Delete.php
index a4d640b423..4c7d97aa55 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Delete.php
@@ -63,7 +63,7 @@ class Delete extends DocumentsDelete
->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Update.php
index 2784fa220a..18e441ede7 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Update.php
@@ -65,7 +65,7 @@ class Update extends DocumentsUpdate
->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Upsert.php
index cfbf6c9158..c26e61d716 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Upsert.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Upsert.php
@@ -65,7 +65,7 @@ class Upsert extends DocumentsUpsert
->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Create.php
index 563b5f60ef..dee8d8e85f 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Create.php
@@ -106,7 +106,7 @@ class Create extends DocumentCreate
->inject('queueForEvents')
->inject('usage')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('authorization')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php
deleted file mode 100644
index dea9d30119..0000000000
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php
+++ /dev/null
@@ -1,59 +0,0 @@
-setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
- ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/:documentId/logs')
- ->desc('List document logs')
- ->groups(['api', 'database'])
- ->label('scope', 'documents.read')
- ->label('resourceType', RESOURCE_TYPE_DATABASES)
- ->label('sdk', new Method(
- namespace: 'vectorsDB',
- group: 'logs',
- name: 'listDocumentLogs',
- description: '/docs/references/vectorsdb/get-document-logs.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: SwooleResponse::STATUS_CODE_OK,
- model: $this->getResponseModel(),
- )
- ],
- contentType: ContentType::JSON,
- ))
- ->param('databaseId', '', new UID(), 'Database ID.')
- ->param('collectionId', '', new UID(), 'Collection ID.')
- ->param('documentId', '', new UID(), 'Document ID.')
- ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true)
- ->inject('response')
- ->inject('dbForProject')
- ->inject('getDatabasesDB')
- ->inject('locale')
- ->inject('geodb')
- ->inject('authorization')
- ->inject('audit')
- ->callback($this->action(...));
- }
-}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Create.php
index a535dd5724..bba7ee0579 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Create.php
@@ -65,7 +65,7 @@ class Create extends IndexCreate
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Delete.php
index 5c7fc47ee0..67e13dd26a 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Delete.php
@@ -59,7 +59,7 @@ class Delete extends IndexDelete
->param('key', '', new Key(), 'Index Key.')
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php
deleted file mode 100644
index cd0e45eb47..0000000000
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php
+++ /dev/null
@@ -1,57 +0,0 @@
-setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
- ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/logs')
- ->desc('List collection logs')
- ->groups(['api', 'database'])
- ->label('scope', 'collections.read')
- ->label('resourceType', RESOURCE_TYPE_DATABASES)
- ->label('sdk', new Method(
- namespace: 'vectorsDB',
- group: $this->getSdkGroup(),
- name: 'listCollectionLogs',
- description: '/docs/references/vectorsdb/get-collection-logs.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: SwooleResponse::STATUS_CODE_OK,
- model: $this->getResponseModel(),
- )
- ],
- contentType: ContentType::JSON
- ))
- ->param('databaseId', '', new UID(), 'Database ID.')
- ->param('collectionId', '', new UID(), 'Collection ID.')
- ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true)
- ->inject('response')
- ->inject('dbForProject')
- ->inject('locale')
- ->inject('geodb')
- ->inject('authorization')
- ->inject('audit')
- ->callback($this->action(...));
- }
-}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php
index c9d36904a9..a33eedccd5 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php
@@ -47,7 +47,7 @@ class Delete extends DatabaseDelete
->param('databaseId', '', new UID(), 'Database ID.')
->inject('response')
->inject('dbForProject')
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('queueForEvents')
->inject('usage')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php
index d9b378774b..8a7137e38b 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php
@@ -98,7 +98,7 @@ class Create extends CreateDocumentAction
$error = '';
try {
$embedResult = $embeddingAgent->embed($text);
- $embedding = $embedResult['embedding'] ?? [];
+ $embedding = $embedResult['embedding'];
$totalDuration += $embedResult['totalDuration'] ?? 0;
$totalTokens += $embedResult['tokensProcessed'] ?? 0;
} catch (\Exception $e) {
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Delete.php
index 0ac2caecba..2de71fc904 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Delete.php
@@ -49,7 +49,8 @@ class Delete extends TransactionsDelete
->param('transactionId', '', new UID(), 'Transaction ID.')
->inject('response')
->inject('dbForProject')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
+ ->inject('project')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Update.php
index f4bd4d67f5..cebfcb42e8 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Update.php
@@ -56,11 +56,11 @@ class Update extends TransactionsUpdate
->inject('getDatabasesDB')
->inject('user')
->inject('transactionState')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('queueForEvents')
->inject('usage')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('authorization')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php
index a1e3538cac..5d41ed3e2b 100644
--- a/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php
+++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php
@@ -12,7 +12,6 @@ use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\B
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Create as CreateRow;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Delete as DeleteRow;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Get as GetRow;
-use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Logs\XList as ListRowLogs;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Update as UpdateRow;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Upsert as UpsertRow;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\XList as ListRows;
@@ -21,7 +20,6 @@ use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\Cre
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\Delete as DeleteColumnIndex;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\Get as GetColumnIndex;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\XList as ListColumnIndexes;
-use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Logs\XList as ListTableLogs;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Update as UpdateTable;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Usage\Get as GetTableUsage;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\XList as ListTables;
@@ -69,7 +67,6 @@ class DocumentsDB extends Base
$service->addAction(UpdateTable::getName(), new UpdateTable());
$service->addAction(DeleteTable::getName(), new DeleteTable());
$service->addAction(ListTables::getName(), new ListTables());
- $service->addAction(ListTableLogs::getName(), new ListTableLogs());
$service->addAction(GetTableUsage::getName(), new GetTableUsage());
}
@@ -92,7 +89,6 @@ class DocumentsDB extends Base
$service->addAction(DeleteRow::getName(), new DeleteRow());
$service->addAction(DeleteRows::getName(), new DeleteRows());
$service->addAction(ListRows::getName(), new ListRows());
- $service->addAction(ListRowLogs::getName(), new ListRowLogs());
$service->addAction(IncrementRowColumn::getName(), new IncrementRowColumn());
$service->addAction(DecrementRowColumn::getName(), new DecrementRowColumn());
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/Legacy.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/Legacy.php
index a8d2205236..a2fba9efb3 100644
--- a/src/Appwrite/Platform/Modules/Databases/Services/Registry/Legacy.php
+++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/Legacy.php
@@ -2,6 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Services\Registry;
+use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\BigInt\Create as CreateBigIntAttribute;
+use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\BigInt\Update as UpdateBigIntAttribute;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Boolean\Create as CreateBooleanAttribute;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Boolean\Update as UpdateBooleanAttribute;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Datetime\Create as CreateDatetimeAttribute;
@@ -171,6 +173,10 @@ class Legacy extends Base
$service->addAction(CreateIntegerAttribute::getName(), new CreateIntegerAttribute());
$service->addAction(UpdateIntegerAttribute::getName(), new UpdateIntegerAttribute());
+ // Attribute: BigInt
+ $service->addAction(CreateBigIntAttribute::getName(), new CreateBigIntAttribute());
+ $service->addAction(UpdateBigIntAttribute::getName(), new UpdateBigIntAttribute());
+
// Attribute: IP
$service->addAction(CreateIPAttribute::getName(), new CreateIPAttribute());
$service->addAction(UpdateIPAttribute::getName(), new UpdateIPAttribute());
diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/TablesDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/TablesDB.php
index 965e0929fb..765fbd4421 100644
--- a/src/Appwrite/Platform/Modules/Databases/Services/Registry/TablesDB.php
+++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/TablesDB.php
@@ -5,6 +5,8 @@ namespace Appwrite\Platform\Modules\Databases\Services\Registry;
use Appwrite\Platform\Modules\Databases\Http\TablesDB\Create as CreateTablesDatabase;
use Appwrite\Platform\Modules\Databases\Http\TablesDB\Delete as DeleteTablesDatabase;
use Appwrite\Platform\Modules\Databases\Http\TablesDB\Get as GetTablesDatabase;
+use Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Columns\BigInt\Create as CreateBigInt;
+use Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Columns\BigInt\Update as UpdateBigInt;
use Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Columns\Boolean\Create as CreateBoolean;
use Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Columns\Boolean\Update as UpdateBoolean;
use Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Columns\Datetime\Create as CreateDatetime;
@@ -151,6 +153,10 @@ class TablesDB extends Base
$service->addAction(CreateInteger::getName(), new CreateInteger());
$service->addAction(UpdateInteger::getName(), new UpdateInteger());
+ // Column: BigInt
+ $service->addAction(CreateBigInt::getName(), new CreateBigInt());
+ $service->addAction(UpdateBigInt::getName(), new UpdateBigInt());
+
// Column: IP
$service->addAction(CreateIP::getName(), new CreateIP());
$service->addAction(UpdateIP::getName(), new UpdateIP());
diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php
index 5d12b14b1a..fe96d51d20 100644
--- a/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php
+++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php
@@ -10,7 +10,6 @@ use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Bul
use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Create as CreateDocument;
use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Delete as DeleteDocument;
use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Get as GetDocument;
-use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Logs\XList as ListDocumentLogs;
use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Update as UpdateDocument;
use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Upsert as UpsertDocument;
use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\XList as ListDocuments;
@@ -19,7 +18,6 @@ use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\Creat
use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\Delete as DeleteIndex;
use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\Get as GetIndex;
use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\XList as ListIndexes;
-use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Logs\XList as ListCollectionLogs;
use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Update as UpdateCollection;
use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Usage\Get as GetCollectionUsage;
use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\XList as ListCollections;
@@ -69,7 +67,6 @@ class VectorsDB extends Base
$service->addAction(UpdateCollection::getName(), new UpdateCollection());
$service->addAction(DeleteCollection::getName(), new DeleteCollection());
$service->addAction(ListCollections::getName(), new ListCollections());
- $service->addAction(ListCollectionLogs::getName(), new ListCollectionLogs());
$service->addAction(GetCollectionUsage::getName(), new GetCollectionUsage());
}
@@ -92,7 +89,6 @@ class VectorsDB extends Base
$service->addAction(UpdateDocuments::getName(), new UpdateDocuments());
$service->addAction(UpsertDocuments::getName(), new UpsertDocuments());
$service->addAction(DeleteDocuments::getName(), new DeleteDocuments());
- $service->addAction(ListDocumentLogs::getName(), new ListDocumentLogs());
}
private function registerTransactionActions(Service $service): void
diff --git a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php
index a50e8f8bdf..ee8494b382 100644
--- a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php
+++ b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php
@@ -2,6 +2,7 @@
namespace Appwrite\Platform\Modules\Databases\Workers;
+use Appwrite\Event\Message\Database as DatabaseMessage;
use Appwrite\Event\Realtime;
use Exception;
use Utopia\Console;
@@ -54,16 +55,17 @@ class Databases extends Action
*/
public function action(Message $message, Document $project, Database $dbForPlatform, Database $dbForProject, callable $getDatabasesDB, Realtime $queueForRealtime, Log $log): void
{
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new Exception('Missing payload');
}
- $type = $payload['type'];
- $document = new Document($payload['row'] ?? $payload['document'] ?? []);
- $collection = new Document($payload['table'] ?? $payload['collection'] ?? []);
- $database = new Document($payload['database'] ?? []);
+ $databaseMessage = DatabaseMessage::fromArray($payload);
+ $type = $databaseMessage->type;
+ $document = $databaseMessage->row ?? $databaseMessage->document ?? new Document();
+ $collection = $databaseMessage->table ?? $databaseMessage->collection ?? new Document();
+ $database = $databaseMessage->database ?? new Document();
/**
* @var Database $dbForDatabases
*/
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php
index 65b6ffd5bb..9af5491598 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Functions\Http\Deployments;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -20,6 +21,7 @@ use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Http\Adapter\Swoole\Request;
+use Utopia\Lock\Exception\Contention as LockContention;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Storage\Device;
@@ -87,9 +89,11 @@ class Create extends Action
->inject('project')
->inject('deviceForFunctions')
->inject('deviceForLocal')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('plan')
->inject('authorization')
+ ->inject('platform')
+ ->inject('locks')
->callback($this->action(...));
}
@@ -106,9 +110,11 @@ class Create extends Action
Document $project,
Device $deviceForFunctions,
Device $deviceForLocal,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
array $plan,
- Authorization $authorization
+ Authorization $authorization,
+ array $platform,
+ callable $locks
) {
$activate = \strval($activate) === 'true' || \strval($activate) === '1';
@@ -175,15 +181,8 @@ class Create extends Action
throw new Exception(Exception::STORAGE_INVALID_CONTENT_RANGE);
}
- // TODO remove the condition that checks `$end === $fileSize` in next breaking version
- if ($end === $fileSize - 1 || $end === $fileSize) {
- //if it's a last chunks the chunk size might differ, so we set the $chunks and $chunk to notify it's last chunk
- $chunks = $chunk = -1;
- } else {
- // Calculate total number of chunks based on the chunk size i.e ($rangeEnd - $rangeStart)
- $chunks = (int) ceil($fileSize / ($end + 1 - $start));
- $chunk = (int) ($start / ($end + 1 - $start)) + 1;
- }
+ $chunks = (int) ceil($fileSize / APP_LIMIT_UPLOAD_CHUNK_SIZE);
+ $chunk = (int) ($start / APP_LIMIT_UPLOAD_CHUNK_SIZE) + 1;
}
if (!$fileSizeValidator->isValid($fileSize) && $functionSizeLimit !== 0) { // Check if file size is exceeding allowed limit
@@ -197,15 +196,38 @@ class Create extends Action
// Save to storage
$fileSize ??= $deviceForLocal->getFileSize($fileTmpName);
$path = $deviceForFunctions->getPath($deploymentId . '.' . \pathinfo($fileName, PATHINFO_EXTENSION));
- $deployment = $dbForProject->getDocument('deployments', $deploymentId);
+
+ $lockKey = 'functions:deployment:' . $project->getId() . ':' . $functionId . ':' . $deploymentId;
$metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)];
- if (!$deployment->isEmpty()) {
- $chunks = $deployment->getAttribute('sourceChunksTotal', 1);
- $metadata = $deployment->getAttribute('sourceMetadata', []);
- if ($chunk === -1) {
- $chunk = $chunks;
- }
+ $completed = false;
+
+ try {
+ $locks($lockKey, 600, function () use (&$chunks, $dbForProject, $deploymentId, &$metadata, &$completed, $response): void {
+ $deployment = $dbForProject->getDocument('deployments', $deploymentId);
+
+ if (!$deployment->isEmpty()) {
+ $chunks = $deployment->getAttribute('sourceChunksTotal', 1);
+ $uploaded = $deployment->getAttribute('sourceChunksUploaded', 0);
+ $metadata = $deployment->getAttribute('sourceMetadata', []);
+
+ if ($uploaded === $chunks) {
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($deployment, Response::MODEL_DEPLOYMENT);
+
+ $completed = true;
+ return;
+ }
+ }
+ }, timeout: 120.0);
+ } catch (LockContention) {
+ $response->addHeader('Retry-After', '5');
+ throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Deployment upload is busy. Try again.');
+ }
+
+ if ($completed) {
+ return;
}
$chunksUploaded = $deviceForFunctions->upload($fileTmpName, $path, $chunk, $chunks, $metadata);
@@ -216,112 +238,144 @@ class Create extends Action
$type = $request->getHeader('x-sdk-language') === 'cli' ? 'cli' : 'manual';
- if ($chunksUploaded === $chunks) {
- if ($activate) {
- // Remove deploy for all other deployments.
- $activeDeployments = $dbForProject->find('deployments', [
- Query::equal('activate', [true]),
- Query::equal('resourceId', [$functionId]),
- Query::equal('resourceType', ['functions'])
- ]);
+ try {
+ $locks($lockKey, 600, function () use ($activate, &$chunks, $chunksUploaded, $commands, $dbForProject, $deploymentId, $deviceForFunctions, $entrypoint, $fileSize, &$function, $functionId, $path, &$metadata, $platform, $project, $publisherForBuilds, $queueForEvents, $response, $type): void {
+ $deployment = $dbForProject->getDocument('deployments', $deploymentId);
+ $uploaded = 0;
- foreach ($activeDeployments as $activeDeployment) {
- $activeDeployment->setAttribute('activate', false);
- $dbForProject->updateDocument('deployments', $activeDeployment->getId(), new Document([
- 'activate' => false,
- ]));
+ if (!$deployment->isEmpty()) {
+ $chunks = $deployment->getAttribute('sourceChunksTotal', 1);
+ $uploaded = $deployment->getAttribute('sourceChunksUploaded', 0);
+ $metadata = \array_merge($deployment->getAttribute('sourceMetadata', []), $metadata);
+
+ if ($uploaded === $chunks) {
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($deployment, Response::MODEL_DEPLOYMENT);
+ return;
+ }
}
- }
- $fileSize = $deviceForFunctions->getFileSize($path);
+ $chunksUploaded = max($uploaded, $chunksUploaded);
- if ($deployment->isEmpty()) {
- $deployment = $dbForProject->createDocument('deployments', new Document([
- '$id' => $deploymentId,
- '$permissions' => [
- Permission::read(Role::any()),
- Permission::update(Role::any()),
- Permission::delete(Role::any()),
- ],
- 'resourceInternalId' => $function->getSequence(),
- 'resourceId' => $function->getId(),
- 'resourceType' => 'functions',
- 'entrypoint' => $entrypoint,
- 'buildCommands' => $commands,
- 'startCommand' => $function->getAttribute('startCommand', ''),
- 'sourcePath' => $path,
- 'sourceSize' => $fileSize,
- 'totalSize' => $fileSize,
- 'activate' => $activate,
- 'sourceMetadata' => $metadata,
- 'type' => $type
- ]));
+ if ($chunksUploaded === $chunks && $uploaded < $chunks) {
+ if ($activate) {
+ // Remove deploy for all other deployments.
+ $activeDeployments = $dbForProject->find('deployments', [
+ Query::equal('activate', [true]),
+ Query::equal('resourceId', [$functionId]),
+ Query::equal('resourceType', ['functions'])
+ ]);
- $function = $dbForProject->updateDocument('functions', $function->getId(), new Document([
- 'latestDeploymentId' => $deployment->getId(),
- 'latestDeploymentInternalId' => $deployment->getSequence(),
- 'latestDeploymentCreatedAt' => $deployment->getCreatedAt(),
- 'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
- ]));
- } else {
- $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
- 'sourceSize' => $fileSize,
- 'sourceMetadata' => $metadata,
- ]));
- }
+ foreach ($activeDeployments as $activeDeployment) {
+ $dbForProject->updateDocument('deployments', $activeDeployment->getId(), new Document([
+ 'activate' => false,
+ ]));
+ }
+ }
- // Start the build
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment);
- } else {
- if ($deployment->isEmpty()) {
- $deployment = $dbForProject->createDocument('deployments', new Document([
- '$id' => $deploymentId,
- '$permissions' => [
- Permission::read(Role::any()),
- Permission::update(Role::any()),
- Permission::delete(Role::any()),
- ],
- 'resourceInternalId' => $function->getSequence(),
- 'resourceId' => $function->getId(),
- 'resourceType' => 'functions',
- 'entrypoint' => $entrypoint,
- 'buildCommands' => $commands,
- 'startCommand' => $function->getAttribute('startCommand', ''),
- 'sourcePath' => $path,
- 'sourceSize' => $fileSize,
- 'totalSize' => $fileSize,
- 'sourceChunksTotal' => $chunks,
- 'sourceChunksUploaded' => $chunksUploaded,
- 'activate' => $activate,
- 'sourceMetadata' => $metadata,
- 'type' => $type
- ]));
+ $fileSize = $deviceForFunctions->getFileSize($path);
- $function = $dbForProject->updateDocument('functions', $function->getId(), new Document([
- 'latestDeploymentId' => $deployment->getId(),
- 'latestDeploymentInternalId' => $deployment->getSequence(),
- 'latestDeploymentCreatedAt' => $deployment->getCreatedAt(),
- 'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
- ]));
- } else {
- $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
- 'sourceChunksUploaded' => $chunksUploaded,
- 'sourceMetadata' => $metadata,
- ]));
- }
+ if ($deployment->isEmpty()) {
+ $deployment = $dbForProject->createDocument('deployments', new Document([
+ '$id' => $deploymentId,
+ '$permissions' => [
+ Permission::read(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ 'resourceInternalId' => $function->getSequence(),
+ 'resourceId' => $function->getId(),
+ 'resourceType' => 'functions',
+ 'entrypoint' => $entrypoint,
+ 'buildCommands' => $commands,
+ 'startCommand' => $function->getAttribute('startCommand', ''),
+ 'sourcePath' => $path,
+ 'sourceSize' => $fileSize,
+ 'totalSize' => $fileSize,
+ 'sourceChunksTotal' => $chunks,
+ 'sourceChunksUploaded' => $chunksUploaded,
+ 'activate' => $activate,
+ 'sourceMetadata' => $metadata,
+ 'type' => $type
+ ]));
+
+ $function = $dbForProject->updateDocument('functions', $function->getId(), new Document([
+ 'latestDeploymentId' => $deployment->getId(),
+ 'latestDeploymentInternalId' => $deployment->getSequence(),
+ 'latestDeploymentCreatedAt' => $deployment->getCreatedAt(),
+ 'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
+ ]));
+ } else {
+ $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
+ 'sourceSize' => $fileSize,
+ 'sourceChunksUploaded' => $chunksUploaded,
+ 'sourceMetadata' => $metadata,
+ ]));
+ }
+
+ // Start the build
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ));
+ } else {
+ if ($deployment->isEmpty()) {
+ $deployment = $dbForProject->createDocument('deployments', new Document([
+ '$id' => $deploymentId,
+ '$permissions' => [
+ Permission::read(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ 'resourceInternalId' => $function->getSequence(),
+ 'resourceId' => $function->getId(),
+ 'resourceType' => 'functions',
+ 'entrypoint' => $entrypoint,
+ 'buildCommands' => $commands,
+ 'startCommand' => $function->getAttribute('startCommand', ''),
+ 'sourcePath' => $path,
+ 'sourceSize' => $fileSize,
+ 'totalSize' => $fileSize,
+ 'sourceChunksTotal' => $chunks,
+ 'sourceChunksUploaded' => $chunksUploaded,
+ 'activate' => $activate,
+ 'sourceMetadata' => $metadata,
+ 'type' => $type
+ ]));
+
+ $function = $dbForProject->updateDocument('functions', $function->getId(), new Document([
+ 'latestDeploymentId' => $deployment->getId(),
+ 'latestDeploymentInternalId' => $deployment->getSequence(),
+ 'latestDeploymentCreatedAt' => $deployment->getCreatedAt(),
+ 'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
+ ]));
+ } else {
+ $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
+ 'sourceChunksUploaded' => $chunksUploaded,
+ 'sourceMetadata' => $metadata,
+ ]));
+ }
+ }
+
+ $metadata = null;
+
+ if ($chunksUploaded === $chunks) {
+ $queueForEvents
+ ->setParam('functionId', $function->getId())
+ ->setParam('deploymentId', $deployment->getId());
+ }
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($deployment, Response::MODEL_DEPLOYMENT);
+ }, timeout: 120.0);
+ } catch (LockContention) {
+ $response->addHeader('Retry-After', '5');
+ throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Deployment upload is busy. Try again.');
}
-
- $metadata = null;
-
- $queueForEvents
- ->setParam('functionId', $function->getId())
- ->setParam('deploymentId', $deployment->getId());
-
- $response
- ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
- ->dynamic($deployment, Response::MODEL_DEPLOYMENT);
}
}
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Delete.php
index 3d75919eb8..be4437ffe3 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Delete.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Delete.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Functions\Http\Deployments;
-use Appwrite\Event\Delete as DeleteEvent;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Delete as DeleteMessage;
+use Appwrite\Event\Publisher\Delete as DeletePublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -59,7 +60,7 @@ class Delete extends Action
->param('deploymentId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Deployment ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('queueForEvents')
->inject('deviceForFunctions')
->callback($this->action(...));
@@ -70,7 +71,7 @@ class Delete extends Action
string $deploymentId,
Response $response,
Database $dbForProject,
- DeleteEvent $queueForDeletes,
+ DeletePublisher $publisherForDeletes,
Event $queueForEvents,
Device $deviceForFunctions
) {
@@ -128,9 +129,11 @@ class Delete extends Action
->setParam('functionId', $function->getId())
->setParam('deploymentId', $deployment->getId());
- $queueForDeletes
- ->setType(DELETE_TYPE_DOCUMENT)
- ->setDocument($deployment);
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $queueForEvents->getProject(),
+ type: DELETE_TYPE_DOCUMENT,
+ document: $deployment,
+ ));
$response->noContent();
}
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php
index 50c901e4c8..d3e7155dc6 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php
@@ -31,7 +31,7 @@ class Get extends Action
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/functions/:functionId/deployments/:deploymentId/download')
- ->httpAlias('/v1/functions/:functionId/deployments/:deploymentId/build/download', ['type' => 'output'])
+ ->httpAlias('/v1/functions/:functionId/deployments/:deploymentId/build/download')
->groups(['api', 'functions'])
->desc('Get deployment download')
->label('scope', 'functions.read')
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php
index 9884b12dba..76070c8bf5 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Functions\Http\Deployments\Duplicate;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
@@ -61,8 +62,10 @@ class Create extends Action
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('deviceForFunctions')
+ ->inject('project')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -73,8 +76,10 @@ class Create extends Action
Response $response,
Database $dbForProject,
Event $queueForEvents,
- Build $queueForBuilds,
- Device $deviceForFunctions
+ BuildPublisher $publisherForBuilds,
+ Device $deviceForFunctions,
+ Document $project,
+ array $platform
) {
$function = $dbForProject->getDocument('functions', $functionId);
@@ -127,10 +132,13 @@ class Create extends Action
'latestDeploymentStatus' => $function->getAttribute('latestDeploymentStatus'),
]));
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ));
$queueForEvents
->setParam('functionId', $function->getId())
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php
index 53af82e701..f18543c60e 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Functions\Http\Deployments\Template;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -76,9 +77,10 @@ class Create extends Base
->inject('dbForPlatform')
->inject('queueForEvents')
->inject('project')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('gitHub')
->inject('authorization')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -96,9 +98,10 @@ class Create extends Base
Database $dbForPlatform,
Event $queueForEvents,
Document $project,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
GitHub $github,
- Authorization $authorization
+ Authorization $authorization,
+ array $platform
) {
$function = $dbForProject->getDocument('functions', $functionId);
@@ -127,10 +130,11 @@ class Create extends Base
project: $project,
installation: $installation,
dbForProject: $dbForProject,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: $activate,
+ platform: $platform,
referenceType: $type,
reference: $reference
);
@@ -184,11 +188,14 @@ class Create extends Base
$this->updateEmptyManualRule($project, $function, $deployment, $dbForPlatform, $authorization);
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
$queueForEvents
->setParam('functionId', $function->getId())
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php
index 587c09beba..a74fc12593 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Functions\Http\Deployments\Vcs;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -70,8 +70,9 @@ class Create extends Base
->inject('dbForPlatform')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('gitHub')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -86,8 +87,9 @@ class Create extends Base
Database $dbForPlatform,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
GitHub $github,
+ array $platform,
) {
$function = $dbForProject->getDocument('functions', $functionId);
@@ -105,10 +107,11 @@ class Create extends Base
project: $project,
installation: $installation,
dbForProject: $dbForProject,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: $activate,
+ platform: $platform,
reference: $reference,
referenceType: $type
);
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php
index fef0708931..e8e9ea9a18 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php
@@ -116,7 +116,7 @@ class XList extends Base
$grouped = Query::groupByType($queries);
$filterQueries = $grouped['filters'];
- $selectQueries = $grouped['selections'] ?? [];
+ $selectQueries = $grouped['selections'];
try {
$results = $dbForProject->find('deployments', $queries);
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php
index 72474b03f9..35264730f8 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php
@@ -3,9 +3,11 @@
namespace Appwrite\Platform\Modules\Functions\Http\Executions;
use Ahc\Jwt\JWT;
-use Appwrite\Event\Delete as DeleteEvent;
use Appwrite\Event\Event;
-use Appwrite\Event\Func;
+use Appwrite\Event\Message\Delete as DeleteMessage;
+use Appwrite\Event\Message\Func as FunctionMessage;
+use Appwrite\Event\Publisher\Delete as DeletePublisher;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Functions\Validator\Headers;
@@ -17,6 +19,7 @@ use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Usage\Context;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Response;
+use Executor\Exception\Timeout as ExecutorTimeout;
use Executor\Executor;
use MaxMind\Db\Reader;
use Utopia\Auth\Proofs\Token;
@@ -60,7 +63,7 @@ class Create extends Base
->setHttpPath('/v1/functions/:functionId/executions')
->desc('Create execution')
->groups(['api', 'functions'])
- ->label('scope', 'execution.write')
+ ->label('scope', ['executions.write', 'execution.write'])
->label('resourceType', RESOURCE_TYPE_FUNCTIONS)
->label('event', 'functions.[functionId].executions.[executionId].create')
->label('sdk', new Method(
@@ -94,14 +97,14 @@ class Create extends Base
->inject('user')
->inject('queueForEvents')
->inject('usage')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('geodb')
->inject('store')
->inject('proofForToken')
->inject('executor')
->inject('platform')
->inject('authorization')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('executionsRetentionCount')
->callback($this->action(...));
}
@@ -122,14 +125,14 @@ class Create extends Base
User $user,
Event $queueForEvents,
Context $usage,
- Func $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Reader $geodb,
Store $store,
Token $proofForToken,
Executor $executor,
array $platform,
Authorization $authorization,
- DeleteEvent $queueForDeletes,
+ DeletePublisher $publisherForDeletes,
int $executionsRetentionCount,
) {
$async = \strval($async) === 'true' || \strval($async) === '1';
@@ -145,21 +148,8 @@ class Create extends Base
}
}
- /**
- * @var array $headers
- */
- $assocParams = ['headers'];
- foreach ($assocParams as $assocParam) {
- if (!empty('headers') && !is_array($$assocParam)) {
- $$assocParam = \json_decode($$assocParam, true);
- }
- }
-
- $booleanParams = ['async'];
- foreach ($booleanParams as $booleamParam) {
- if (!empty($$booleamParam) && !is_bool($$booleamParam)) {
- $$booleamParam = $$booleamParam === "true" ? true : false;
- }
+ if (!is_array($headers)) {
+ $headers = \json_decode($headers, true);
}
// 'headers' validator
@@ -241,7 +231,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;
@@ -306,20 +296,19 @@ class Create extends Base
if ($async) {
if (is_null($scheduledAt)) {
$execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution));
- $queueForFunctions
- ->setType('http')
- ->setExecution($execution)
- ->setFunction($function)
- ->setBody($body)
- ->setHeaders($headers)
- ->setPath($path)
- ->setMethod($method)
- ->setJWT($jwt)
- ->setProject($project)
- ->setUser($user)
- ->setParam('functionId', $function->getId())
- ->setParam('executionId', $execution->getId())
- ->trigger();
+ $publisherForFunctions->enqueue(new FunctionMessage(
+ project: $project,
+ user: $user,
+ function: $function,
+ functionId: $function->getId(),
+ execution: $execution,
+ type: 'http',
+ jwt: $jwt,
+ body: $body,
+ path: $path,
+ headers: $headers,
+ method: $method,
+ ));
} else {
$data = [
'headers' => $headers,
@@ -350,12 +339,12 @@ class Create extends Base
}
if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) {
- $queueForDeletes
- ->setProject($project)
- ->setResource($function->getSequence())
- ->setResourceType(RESOURCE_TYPE_FUNCTIONS)
- ->setType(DELETE_TYPE_EXECUTIONS_LIMIT)
- ->trigger();
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $project,
+ type: DELETE_TYPE_EXECUTIONS_LIMIT,
+ resource: (string) $function->getSequence(),
+ resourceType: RESOURCE_TYPE_FUNCTIONS,
+ ));
}
$response->setStatusCode(Response::STATUS_CODE_ACCEPTED);
@@ -370,10 +359,10 @@ class Create extends Base
// V2 vars
if ($version === 'v2') {
$vars = \array_merge($vars, [
- 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '',
+ 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'],
'APPWRITE_FUNCTION_DATA' => $body,
- 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '',
- 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? ''
+ 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'],
+ 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt']
]);
}
@@ -430,25 +419,29 @@ class Create extends Base
$source = $deployment->getAttribute('buildPath', '');
$extension = str_ends_with($source, '.tar') ? 'tar' : 'tar.gz';
$command = $version === 'v2' ? '' : "cp /tmp/code.$extension /mnt/code/code.$extension && nohup helpers/start.sh \"$command\"";
- $executionResponse = $executor->createExecution(
- projectId: $project->getId(),
- deploymentId: $deployment->getId(),
- body: \strlen($body) > 0 ? $body : null,
- variables: $vars,
- timeout: $function->getAttribute('timeout', 0),
- image: $runtime['image'],
- source: $source,
- entrypoint: $deployment->getAttribute('entrypoint', ''),
- version: $version,
- path: $path,
- method: $method,
- headers: $headers,
- runtimeEntrypoint: $command,
- cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT,
- memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT,
- logging: $function->getAttribute('logging', true),
- requestTimeout: 30
- );
+ try {
+ $executionResponse = $executor->createExecution(
+ projectId: $project->getId(),
+ deploymentId: $deployment->getId(),
+ body: \strlen($body) > 0 ? $body : null,
+ variables: $vars,
+ timeout: $function->getAttribute('timeout', 0),
+ image: $runtime['image'],
+ source: $source,
+ entrypoint: $deployment->getAttribute('entrypoint', ''),
+ version: $version,
+ path: $path,
+ method: $method,
+ headers: $headers,
+ runtimeEntrypoint: $command,
+ cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT,
+ memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT,
+ logging: $function->getAttribute('logging', true),
+ requestTimeout: 30
+ );
+ } catch (ExecutorTimeout $th) {
+ throw new AppwriteException(AppwriteException::FUNCTION_SYNCHRONOUS_TIMEOUT, previous: $th);
+ }
$headersFiltered = [];
foreach ($executionResponse['headers'] as $key => $value) {
@@ -537,12 +530,12 @@ class Create extends Base
}
if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) {
- $queueForDeletes
- ->setProject($project)
- ->setResource($function->getSequence())
- ->setResourceType(RESOURCE_TYPE_FUNCTIONS)
- ->setType(DELETE_TYPE_EXECUTIONS_LIMIT)
- ->trigger();
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $project,
+ type: DELETE_TYPE_EXECUTIONS_LIMIT,
+ resource: (string) $function->getSequence(),
+ resourceType: RESOURCE_TYPE_FUNCTIONS,
+ ));
}
$response
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php
index 21ec3c66ce..9ecb5c0bf0 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php
@@ -35,7 +35,7 @@ class Delete extends Base
->setHttpPath('/v1/functions/:functionId/executions/:executionId')
->desc('Delete execution')
->groups(['api', 'functions'])
- ->label('scope', 'execution.write')
+ ->label('scope', ['executions.write', 'execution.write'])
->label('resourceType', RESOURCE_TYPE_FUNCTIONS)
->label('event', 'functions.[functionId].executions.[executionId].delete')
->label('audits.event', 'executions.delete')
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php
index aec9d56543..0a9dd01b7e 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php
@@ -31,7 +31,7 @@ class Get extends Base
->setHttpPath('/v1/functions/:functionId/executions/:executionId')
->desc('Get execution')
->groups(['api', 'functions'])
- ->label('scope', 'execution.read')
+ ->label('scope', ['executions.read', 'execution.read'])
->label('resourceType', RESOURCE_TYPE_FUNCTIONS)
->label('sdk', new Method(
namespace: 'functions',
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php
index b12980b222..6ad2a5ae55 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php
@@ -39,7 +39,7 @@ class XList extends Base
->setHttpPath('/v1/functions/:functionId/executions')
->desc('List executions')
->groups(['api', 'functions'])
- ->label('scope', 'execution.read')
+ ->label('scope', ['executions.read', 'execution.read'])
->label('resourceType', RESOURCE_TYPE_FUNCTIONS)
->label('sdk', new Method(
namespace: 'functions',
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php
index 8d4ad5d403..3f980275a2 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php
@@ -2,9 +2,11 @@
namespace Appwrite\Platform\Modules\Functions\Http\Functions;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
-use Appwrite\Event\Func;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Message\Func as FunctionMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Validator\FunctionEvent;
use Appwrite\Event\Webhook;
@@ -93,6 +95,8 @@ class Create extends Base
->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function.', true)
->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the function? In silent mode, comments will not be made on commits and pull requests.', true)
->param('providerRootDirectory', '', new Text(128, 0), 'Path to function code in the linked repo.', true)
+ ->param('providerBranches', [], new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of branch name patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all branches.', true)
+ ->param('providerPaths', [], new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of file path patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all file changes.', true)
->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification(
$plan,
Config::getParam('specifications', []),
@@ -115,10 +119,10 @@ class Create extends Base
->inject('timelimit')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('queueForRealtime')
->inject('queueForWebhooks')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('dbForPlatform')
->inject('request')
->inject('gitHub')
@@ -145,6 +149,8 @@ class Create extends Base
string $providerBranch,
bool $providerSilentMode,
string $providerRootDirectory,
+ array $providerBranches,
+ array $providerPaths,
string $buildSpecification,
string $runtimeSpecification,
string $templateRepository,
@@ -157,10 +163,10 @@ class Create extends Base
callable $timelimit,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
Realtime $queueForRealtime,
Webhook $queueForWebhooks,
- Func $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Database $dbForPlatform,
Request $request,
GitHub $github,
@@ -246,6 +252,8 @@ class Create extends Base
'providerBranch' => $providerBranch,
'providerRootDirectory' => $providerRootDirectory,
'providerSilentMode' => $providerSilentMode,
+ 'providerBranches' => $providerBranches,
+ 'providerPaths' => $providerPaths,
'buildSpecification' => $buildSpecification,
'runtimeSpecification' => $runtimeSpecification,
]));
@@ -326,10 +334,11 @@ class Create extends Base
project: $project,
installation: $installation,
dbForProject: $dbForProject,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: true,
+ platform: $platform,
reference: $providerBranch,
referenceType: 'branch'
);
@@ -367,15 +376,18 @@ class Create extends Base
'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
]));
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
}
$functionsDomain = $platform['functionsDomain'];
- if (!empty($functionsDomain)) {
+ if (!empty($functionsDomain) && isset($deployment) && !$deployment->isEmpty()) {
$routeSubdomain = ID::unique();
$domain = "{$routeSubdomain}.{$functionsDomain}";
// TODO: (@Meldiron) Remove after 1.7.x migration
@@ -391,8 +403,8 @@ class Create extends Base
'status' => 'verified',
'type' => 'deployment',
'trigger' => 'manual',
- 'deploymentId' => !isset($deployment) || $deployment->isEmpty() ? '' : $deployment->getId(),
- 'deploymentInternalId' => !isset($deployment) || $deployment->isEmpty() ? '' : $deployment->getSequence(),
+ 'deploymentId' => $deployment->getId(),
+ 'deploymentInternalId' => $deployment->getSequence(),
'deploymentResourceType' => 'function',
'deploymentResourceId' => $function->getId(),
'deploymentResourceInternalId' => $function->getSequence(),
@@ -418,9 +430,15 @@ class Create extends Base
->trigger();
/** Trigger Functions */
- $queueForFunctions
- ->from($ruleCreate)
- ->trigger();
+ $publisherForFunctions->enqueue(FunctionMessage::fromEvent(
+ event: $ruleCreate->getEvent(),
+ params: $ruleCreate->getParams(),
+ project: $ruleCreate->getProject(),
+ user: $ruleCreate->getUser(),
+ userId: $ruleCreate->getUserId(),
+ payload: $ruleCreate->getPayload(),
+ platform: $ruleCreate->getPlatform(),
+ ));
/** Trigger Realtime Events */
$queueForRealtime
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Delete.php
index fb45cee82f..1517ee7793 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Delete.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Delete.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Functions\Http\Functions;
-use Appwrite\Event\Delete as DeleteEvent;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Delete as DeleteMessage;
+use Appwrite\Event\Publisher\Delete as DeletePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -59,7 +60,7 @@ class Delete extends Base
->param('functionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Function ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('queueForEvents')
->inject('dbForPlatform')
->inject('authorization')
@@ -70,7 +71,7 @@ class Delete extends Base
string $functionId,
Response $response,
Database $dbForProject,
- DeleteEvent $queueForDeletes,
+ DeletePublisher $publisherForDeletes,
Event $queueForEvents,
Database $dbForPlatform,
Authorization $authorization
@@ -97,9 +98,11 @@ class Delete extends Base
])));
}
- $queueForDeletes
- ->setType(DELETE_TYPE_DOCUMENT)
- ->setDocument($function);
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $queueForEvents->getProject(),
+ type: DELETE_TYPE_DOCUMENT,
+ document: $function,
+ ));
$queueForEvents->setParam('functionId', $function->getId());
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php
index 71fc99a30e..aca4ead98e 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Functions\Http\Functions;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Event\Validator\FunctionEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
@@ -87,6 +87,8 @@ class Update extends Base
->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function', true)
->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the function? In silent mode, comments will not be made on commits and pull requests.', true)
->param('providerRootDirectory', '', new Text(128, 0), 'Path to function code in the linked repo.', true)
+ ->param('providerBranches', null, new Nullable(new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'List of branch name patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all branches.', true)
+ ->param('providerPaths', null, new Nullable(new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'List of file path patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all file changes.', true)
->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification(
$plan,
Config::getParam('specifications', []),
@@ -105,11 +107,12 @@ class Update extends Base
->inject('dbForProject')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('dbForPlatform')
->inject('gitHub')
->inject('executor')
->inject('authorization')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -131,6 +134,8 @@ class Update extends Base
string $providerBranch,
bool $providerSilentMode,
string $providerRootDirectory,
+ ?array $providerBranches,
+ ?array $providerPaths,
string $buildSpecification,
string $runtimeSpecification,
int $deploymentRetention,
@@ -139,11 +144,12 @@ class Update extends Base
Database $dbForProject,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
Database $dbForPlatform,
GitHub $github,
Executor $executor,
- Authorization $authorization
+ Authorization $authorization,
+ array $platform
) {
// TODO: If only branch changes, re-deploy
$function = $dbForProject->getDocument('functions', $functionId);
@@ -162,10 +168,6 @@ class Update extends Base
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When connecting to VCS (Version Control System), you need to provide "installationId" and "providerBranch".');
}
- if ($function->isEmpty()) {
- throw new Exception(Exception::FUNCTION_NOT_FOUND);
- }
-
if (empty($runtime)) {
$runtime = $function->getAttribute('runtime');
}
@@ -278,6 +280,8 @@ class Update extends Base
'providerBranch' => $providerBranch,
'providerRootDirectory' => $providerRootDirectory,
'providerSilentMode' => $providerSilentMode,
+ 'providerBranches' => $providerBranches ?? $function->getAttribute('providerBranches', []),
+ 'providerPaths' => $providerPaths ?? $function->getAttribute('providerPaths', []),
'buildSpecification' => $buildSpecification,
'runtimeSpecification' => $runtimeSpecification,
'search' => implode(' ', [$functionId, $name, $runtime]),
@@ -285,11 +289,33 @@ class Update extends Base
// Redeploy logic
if (!$isConnected && !empty($providerRepositoryId)) {
- $this->redeployVcsFunction($request, $function, $project, $installation, $dbForProject, $queueForBuilds, new Document(), $github, true);
+ $this->redeployVcsFunction($request, $function, $project, $installation, $dbForProject, $publisherForBuilds, new Document(), $github, true, $platform);
}
// Inform scheduler if function is still active
- $schedule = $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId'));
+ $schedule = $authorization->skip(fn () => $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId')));
+
+ // Re-create schedule if missing
+ if ($schedule->isEmpty()) {
+ $schedule = $authorization->skip(
+ fn () => $dbForPlatform->createDocument('schedules', new Document([
+ 'region' => $project->getAttribute('region'),
+ 'resourceType' => SCHEDULE_RESOURCE_TYPE_FUNCTION,
+ 'resourceId' => $function->getId(),
+ 'resourceInternalId' => $function->getSequence(),
+ 'resourceUpdatedAt' => DateTime::now(),
+ 'projectId' => $project->getId(),
+ 'schedule' => $function->getAttribute('schedule'),
+ 'active' => false,
+ ]))
+ );
+
+ $function = $dbForProject->updateDocument('functions', $function->getId(), new Document([
+ 'scheduleId' => $schedule->getId(),
+ 'scheduleInternalId' => $schedule->getSequence(),
+ ]));
+ }
+
$schedule
->setAttribute('resourceUpdatedAt', DateTime::now())
->setAttribute('schedule', $function->getAttribute('schedule'))
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php
index 19476329bf..7016d600cb 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php
@@ -112,6 +112,7 @@ class Get extends Base
$format = match ($days['period']) {
'1h' => 'Y-m-d\TH:00:00.000P',
'1d' => 'Y-m-d\T00:00:00.000P',
+ default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period "' . $days['period'] . '".'),
};
foreach ($metrics as $metric) {
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php
index 38a95d4469..70b7b8e058 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php
@@ -2,6 +2,7 @@
namespace Appwrite\Platform\Modules\Functions\Http\Usage;
+use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
@@ -104,6 +105,7 @@ class XList extends Base
$format = match ($days['period']) {
'1h' => 'Y-m-d\TH:00:00.000P',
'1d' => 'Y-m-d\T00:00:00.000P',
+ default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period "' . $days['period'] . '".'),
};
foreach ($metrics as $metric) {
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php
index fee5b0095d..de572cd41e 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php
@@ -2,11 +2,13 @@
namespace Appwrite\Platform\Modules\Functions\Http\Variables;
+use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
+use Appwrite\Utopia\Database\Validator\CustomId;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
@@ -38,6 +40,7 @@ class Create extends Base
->groups(['api', 'functions'])
->label('scope', 'functions.write')
->label('resourceType', RESOURCE_TYPE_FUNCTIONS)
+ ->label('event', 'variables.[variableId].create')
->label('audits.event', 'variable.create')
->label('audits.resource', 'function/{request.functionId}')
->label('sdk', new Method(
@@ -56,10 +59,12 @@ class Create extends Base
]
))
->param('functionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Function unique ID.', false, ['dbForProject'])
+ ->param('variableId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject'])
->param('key', null, new Text(Database::LENGTH_KEY), 'Variable key. Max length: ' . Database::LENGTH_KEY . ' chars.', false)
->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.', false)
->param('secret', true, new Boolean(), 'Secret variables can be updated or deleted, but only functions can read them during build and runtime.', true)
->inject('response')
+ ->inject('queueForEvents')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
@@ -69,10 +74,12 @@ class Create extends Base
public function action(
string $functionId,
+ string $variableId,
string $key,
string $value,
bool $secret,
Response $response,
+ QueueEvent $queueForEvents,
Database $dbForProject,
Database $dbForPlatform,
Document $project,
@@ -84,7 +91,7 @@ class Create extends Base
throw new Exception(Exception::FUNCTION_NOT_FOUND);
}
- $variableId = ID::unique();
+ $variableId = ($variableId === 'unique()') ? ID::unique() : $variableId;
$teamId = $project->getAttribute('teamId', '');
$variable = new Document([
@@ -120,6 +127,8 @@ class Create extends Base
'active' => $schedule->getAttribute('active'),
])));
+ $queueForEvents->setParam('variableId', $variable->getId());
+
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($variable, Response::MODEL_VARIABLE);
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php
index 5648596826..fa9f19ba8f 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php
@@ -2,6 +2,7 @@
namespace Appwrite\Platform\Modules\Functions\Http\Variables;
+use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -35,6 +36,7 @@ class Delete extends Base
->groups(['api', 'functions'])
->label('scope', 'functions.write')
->label('resourceType', RESOURCE_TYPE_FUNCTIONS)
+ ->label('event', 'variables.[variableId].delete')
->label('audits.event', 'variable.delete')
->label('audits.resource', 'function/{request.functionId}')
->label('sdk', new Method(
@@ -56,6 +58,7 @@ class Delete extends Base
->param('functionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Function unique ID.', false, ['dbForProject'])
->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
->inject('response')
+ ->inject('queueForEvents')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('authorization')
@@ -66,6 +69,7 @@ class Delete extends Base
string $functionId,
string $variableId,
Response $response,
+ QueueEvent $queueForEvents,
Database $dbForProject,
Database $dbForPlatform,
Authorization $authorization
@@ -77,11 +81,7 @@ class Delete extends Base
}
$variable = $dbForProject->getDocument('variables', $variableId);
- if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') {
- throw new Exception(Exception::VARIABLE_NOT_FOUND);
- }
-
- if ($variable === false || $variable->isEmpty()) {
+ if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') {
throw new Exception(Exception::VARIABLE_NOT_FOUND);
}
@@ -102,6 +102,8 @@ class Delete extends Base
'active' => $schedule->getAttribute('active'),
])));
+ $queueForEvents->setParam('variableId', $variable->getId());
+
$response->noContent();
}
}
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php
index 19c345fbc2..13ce73e751 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php
@@ -66,7 +66,6 @@ class Get extends Base
$variable = $dbForProject->getDocument('variables', $variableId);
if (
- $variable === false ||
$variable->isEmpty() ||
$variable->getAttribute('resourceInternalId') !== $function->getSequence() ||
$variable->getAttribute('resourceType') !== 'function'
@@ -74,10 +73,6 @@ class Get extends Base
throw new Exception(Exception::VARIABLE_NOT_FOUND);
}
- if ($variable === false || $variable->isEmpty()) {
- throw new Exception(Exception::VARIABLE_NOT_FOUND);
- }
-
$response->dynamic($variable, Response::MODEL_VARIABLE);
}
}
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php
index acb066ca9c..6413b29f82 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php
@@ -2,6 +2,7 @@
namespace Appwrite\Platform\Modules\Functions\Http\Variables;
+use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -38,6 +39,7 @@ class Update extends Base
->groups(['api', 'functions'])
->label('scope', 'functions.write')
->label('resourceType', RESOURCE_TYPE_FUNCTIONS)
+ ->label('event', 'variables.[variableId].update')
->label('audits.event', 'variable.update')
->label('audits.resource', 'function/{request.functionId}')
->label('sdk', new Method(
@@ -57,10 +59,11 @@ class Update extends Base
))
->param('functionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Function unique ID.', false, ['dbForProject'])
->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
- ->param('key', null, new Text(255), 'Variable key. Max length: 255 chars.', false)
+ ->param('key', null, new Nullable(new Text(255, 0)), 'Variable key. Max length: 255 chars.', true)
->param('value', null, new Nullable(new Text(8192, 0)), 'Variable value. Max length: 8192 chars.', true)
->param('secret', null, new Nullable(new Boolean()), 'Secret variables can be updated or deleted, but only functions can read them during build and runtime.', true)
->inject('response')
+ ->inject('queueForEvents')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('authorization')
@@ -70,10 +73,11 @@ class Update extends Base
public function action(
string $functionId,
string $variableId,
- string $key,
+ ?string $key,
?string $value,
?bool $secret,
Response $response,
+ QueueEvent $queueForEvents,
Database $dbForProject,
Database $dbForPlatform,
Authorization $authorization
@@ -85,7 +89,7 @@ class Update extends Base
}
$variable = $dbForProject->getDocument('variables', $variableId);
- if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') {
+ if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') {
throw new Exception(Exception::VARIABLE_NOT_FOUND);
}
@@ -93,19 +97,27 @@ class Update extends Base
throw new Exception(Exception::VARIABLE_CANNOT_UNSET_SECRET);
}
- $variable
- ->setAttribute('key', $key)
- ->setAttribute('value', $value ?? $variable->getAttribute('value'))
- ->setAttribute('secret', $secret ?? $variable->getAttribute('secret'))
- ->setAttribute('search', implode(' ', [$variableId, $function->getId(), $key, 'function']));
+ if (\is_null($key) && \is_null($value) && \is_null($secret)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID);
+ }
+
+ $updates = new Document();
+
+ if (!\is_null($key)) {
+ $updates->setAttribute('key', $key);
+ $updates->setAttribute('search', implode(' ', [$variableId, $function->getId(), $key, 'function']));
+ }
+
+ if (!\is_null($value)) {
+ $updates->setAttribute('value', $value);
+ }
+
+ if (!\is_null($secret)) {
+ $updates->setAttribute('secret', $secret);
+ }
try {
- $dbForProject->updateDocument('variables', $variable->getId(), new Document([
- 'key' => $key,
- 'value' => $value ?? $variable->getAttribute('value'),
- 'secret' => $secret ?? $variable->getAttribute('secret'),
- 'search' => implode(' ', [$variableId, $function->getId(), $key, 'function']),
- ]));
+ $variable = $dbForProject->updateDocument('variables', $variable->getId(), $updates);
} catch (DuplicateException $th) {
throw new Exception(Exception::VARIABLE_ALREADY_EXISTS);
}
@@ -125,6 +137,8 @@ class Update extends Base
'active' => $schedule->getAttribute('active'),
])));
+ $queueForEvents->setParam('variableId', $variable->getId());
+
$response->dynamic($variable, Response::MODEL_VARIABLE);
}
}
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/XList.php
index 55dea3be1e..b330812b96 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/XList.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/XList.php
@@ -7,12 +7,18 @@ use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
+use Appwrite\Utopia\Database\Validator\Queries\Variables;
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\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
+use Utopia\Validator\Boolean;
class XList extends Base
{
@@ -51,22 +57,74 @@ class XList extends Base
)
)
->param('functionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Function unique ID.', false, ['dbForProject'])
+ ->param('queries', [], new Variables(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Variables::ALLOWED_ATTRIBUTES), 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(string $functionId, Response $response, Database $dbForProject)
- {
+ /**
+ * @param array $queries
+ */
+ public function action(
+ string $functionId,
+ array $queries,
+ bool $includeTotal,
+ Response $response,
+ Database $dbForProject
+ ) {
$function = $dbForProject->getDocument('functions', $functionId);
if ($function->isEmpty()) {
throw new Exception(Exception::FUNCTION_NOT_FOUND);
}
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $queries[] = Query::equal('resourceType', ['function']);
+ $queries[] = Query::equal('resourceInternalId', [$function->getSequence()]);
+ $queries[] = Query::orderAsc();
+
+ $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());
+ }
+
+ $variableId = $cursor->getValue();
+ $cursorDocument = $dbForProject->findOne('variables', [
+ Query::equal('$id', [$variableId]),
+ Query::equal('resourceType', ['function']),
+ Query::equal('resourceInternalId', [$function->getSequence()]),
+ ]);
+
+ if ($cursorDocument->isEmpty()) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Variable '{$variableId}' for the 'cursor' value not found.");
+ }
+
+ $cursor->setValue($cursorDocument);
+ }
+
+ $filterQueries = Query::groupByType($queries)['filters'];
+
+ try {
+ $variables = $dbForProject->find('variables', $queries);
+ $total = $includeTotal ? $dbForProject->count('variables', $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([
- 'variables' => $function->getAttribute('vars', []),
- 'total' => \count($function->getAttribute('vars', [])),
+ 'variables' => $variables,
+ 'total' => $total,
]), Response::MODEL_VARIABLE_LIST);
}
}
diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php
index c6c4a0b38c..5aa95d3bf2 100644
--- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php
+++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php
@@ -4,17 +4,20 @@ namespace Appwrite\Platform\Modules\Functions\Workers;
use Ahc\Jwt\JWT;
use Appwrite\Event\Event;
-use Appwrite\Event\Func;
+use Appwrite\Event\Message\Func as FunctionMessage;
use Appwrite\Event\Message\Usage as UsageMessage;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
+use Appwrite\Event\Publisher\Screenshot;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Realtime;
-use Appwrite\Event\Screenshot;
use Appwrite\Event\Webhook;
+use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
use Appwrite\Usage\Context;
use Appwrite\Utopia\Response\Model\Deployment;
use Appwrite\Vcs\Comment;
use Exception;
+use Executor\Exception\Timeout as ExecutorTimeout;
use Executor\Executor;
use Swoole\Coroutine as Co;
use Utopia\Cache\Cache;
@@ -34,6 +37,7 @@ use Utopia\Detector\Detector\Rendering;
use Utopia\Logger\Log;
use Utopia\Platform\Action;
use Utopia\Queue\Message;
+use Utopia\Span\Span;
use Utopia\Storage\Device;
use Utopia\Storage\Device\Local;
use Utopia\System\System;
@@ -58,9 +62,9 @@ class Builds extends Action
->inject('project')
->inject('dbForPlatform')
->inject('queueForEvents')
- ->inject('queueForScreenshots')
+ ->inject('publisherForScreenshots')
->inject('queueForWebhooks')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForRealtime')
->inject('usage')
->inject('publisherForUsage')
@@ -84,9 +88,9 @@ class Builds extends Action
Document $project,
Database $dbForPlatform,
Event $queueForEvents,
- Screenshot $queueForScreenshots,
+ Screenshot $publisherForScreenshots,
Webhook $queueForWebhooks,
- Func $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Realtime $queueForRealtime,
Context $usage,
UsagePublisher $publisherForUsage,
@@ -102,7 +106,7 @@ class Builds extends Action
): void {
Console::log('Build action started');
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new \Exception('Missing payload');
@@ -126,9 +130,9 @@ class Builds extends Action
$deviceForFunctions,
$deviceForSites,
$deviceForFiles,
- $queueForScreenshots,
+ $publisherForScreenshots,
$queueForWebhooks,
- $queueForFunctions,
+ $publisherForFunctions,
$queueForRealtime,
$queueForEvents,
$usage,
@@ -144,7 +148,8 @@ class Builds extends Action
$log,
$executor,
$plan,
- $platform
+ $platform,
+ (int) ($payload['timeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900))
);
break;
@@ -161,9 +166,9 @@ class Builds extends Action
Device $deviceForFunctions,
Device $deviceForSites,
Device $deviceForFiles,
- Screenshot $queueForScreenshots,
+ Screenshot $publisherForScreenshots,
Webhook $queueForWebhooks,
- Func $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Realtime $queueForRealtime,
Event $queueForEvents,
Context $usage,
@@ -179,8 +184,15 @@ class Builds extends Action
Log $log,
Executor $executor,
array $plan,
- array $platform
+ array $platform,
+ int $timeout
): void {
+ Span::add('project.id', $project->getId());
+ Span::add('resource.id', $resource->getId());
+ Span::add('resource.type', $resource->getCollection());
+ Span::add('deployment.id', $deployment->getId());
+ Span::add('build.timeout', $timeout);
+
Console::info('Deployment action started');
$startTime = DateTime::now();
@@ -204,7 +216,7 @@ class Builds extends Action
throw new \Exception('Resource not found');
}
- if ($isResourceBlocked($project, $resourceKey === 'functions' ? RESOURCE_TYPE_FUNCTIONS : RESOURCE_TYPE_SITES, $resource->getId())) {
+ if ($isResourceBlocked($project, $resource->getCollection() === 'functions' ? RESOURCE_TYPE_FUNCTIONS : RESOURCE_TYPE_SITES, $resource->getId())) {
throw new \Exception('Resource is blocked');
}
@@ -221,12 +233,12 @@ class Builds extends Action
$version = $this->getVersion($resource);
$runtime = $this->getRuntime($resource, $version);
+ Span::add('build.runtime', $resource->getAttribute($resource->getCollection() === 'sites' ? 'buildRuntime' : 'runtime', ''));
+ Span::add('build.version', $version);
$spec = Config::getParam('specifications')[$resource->getAttribute('buildSpecification', APP_COMPUTE_SPECIFICATION_DEFAULT)];
-
- if ($resource->getCollection() === 'functions' && \is_null($runtime)) {
- throw new \Exception('Runtime "' . $resource->getAttribute('runtime', '') . '" is not supported');
- }
+ Span::add('build.cpus', (float) ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT));
+ Span::add('build.memory', (int) ($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT));
// Realtime preparation
$event = "{$resource->getCollection()}.[{$resourceKey}].deployments.[deploymentId].update";
@@ -559,9 +571,15 @@ class Builds extends Action
->trigger();
/** Trigger Functions */
- $queueForFunctions
- ->from($deploymentUpdate)
- ->trigger();
+ $publisherForFunctions->enqueue(FunctionMessage::fromEvent(
+ event: $deploymentUpdate->getEvent(),
+ params: $deploymentUpdate->getParams(),
+ project: $deploymentUpdate->getProject(),
+ user: $deploymentUpdate->getUser(),
+ userId: $deploymentUpdate->getUserId(),
+ payload: $deploymentUpdate->getPayload(),
+ platform: $deploymentUpdate->getPlatform(),
+ ));
/** Trigger Realtime Event */
$queueForRealtime
@@ -592,10 +610,7 @@ class Builds extends Action
$cpus = $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT;
$memory = max($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, $minMemory);
- $timeout = (int) System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900);
-
- $jwtExpiry = (int) System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900);
- $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0);
+ $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $timeout, 0);
$apiKey = $jwtObj->encode([
'projectId' => $project->getId(),
@@ -629,7 +644,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(),
@@ -644,7 +659,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(),
@@ -725,6 +740,9 @@ class Builds extends Action
);
Console::log('createRuntime finished');
+ } catch (ExecutorTimeout $error) {
+ Console::warning('createRuntime timed out');
+ $err = new AppwriteException(AppwriteException::BUILD_TIMEOUT, previous: $error);
} catch (\Throwable $error) {
Console::warning('createRuntime failed');
$err = $error;
@@ -830,7 +848,8 @@ class Builds extends Action
Console::log('Runtime creation finished');
- if ($dbForProject->getDocument('deployments', $deploymentId)->getAttribute('status') === 'canceled') {
+ $latestDeployment = $dbForProject->getDocument('deployments', $deploymentId);
+ if ($latestDeployment->getAttribute('status') === 'canceled') {
$this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime);
return;
@@ -1120,10 +1139,10 @@ class Builds extends Action
/** Screenshot site */
if ($resource->getCollection() === 'sites') {
- $queueForScreenshots
- ->setDeploymentId($deployment->getId())
- ->setProject($project)
- ->trigger();
+ $publisherForScreenshots->enqueue(new \Appwrite\Event\Message\Screenshot(
+ project: $project,
+ deploymentId: $deployment->getId(),
+ ));
Console::log('Site screenshot queued');
}
@@ -1151,13 +1170,11 @@ class Builds extends Action
$message = \str_replace('{APPWRITE_DETECTION_SEPARATOR_START}', '', $message);
$message = \str_replace('{APPWRITE_DETECTION_SEPARATOR_END}', '', $message);
- // Combine with previous logs if deployment got past build process
- $previousLogs = '';
- if (! is_null($deployment->getAttribute('buildSize', null))) {
- $previousLogs = $deployment->getAttribute('buildLogs', '');
- if (! empty($previousLogs)) {
- $message = $previousLogs . "\n" . $message;
- }
+ // Append error to whatever build logs were already streamed
+ $deployment = $dbForProject->getDocument('deployments', $deploymentId);
+ $previousLogs = $deployment->getAttribute('buildLogs', '');
+ if (! empty($previousLogs)) {
+ $message = $previousLogs . "\n" . $message;
}
$endTime = DateTime::now();
@@ -1260,21 +1277,6 @@ class Builds extends Action
*/
protected function afterBuildSuccess(Realtime $queueForRealtime, Database $dbForProject, Document &$deployment, array $runtime, ?string $adapter): void
{
- if (! ($queueForRealtime instanceof Realtime)) {
- throw new Exception('queueForRealtime must be an instance of Realtime');
- }
- if (! ($dbForProject instanceof Database)) {
- throw new Exception('dbForProject must be an instance of Database');
- }
- if (! ($deployment instanceof Document)) {
- throw new Exception('deployment must be an instance of Document');
- }
- if (! is_array($runtime)) {
- throw new Exception('runtime must be an array');
- }
- if (! is_string($adapter) && ! is_null($adapter)) {
- throw new Exception('adapter must be a string or null');
- }
}
/**
@@ -1284,13 +1286,6 @@ class Builds extends Action
Document $project,
Document $deployment,
): void {
- if (! ($project instanceof Document)) {
- throw new Exception('project must be an instance of Document');
- }
-
- if (! ($deployment instanceof Document)) {
- throw new Exception('deployment must be an instance of Document');
- }
}
protected function getRuntime(Document $resource, string $version): array
@@ -1314,6 +1309,7 @@ class Builds extends Action
return match ($resource->getCollection()) {
'functions' => $resource->getAttribute('version', 'v2'),
'sites' => 'v5',
+ default => throw new \Exception('Unsupported resource type "' . $resource->getCollection() . '".'),
};
}
@@ -1446,11 +1442,10 @@ class Builds extends Action
]);
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https';
- $previewUrl = match ($resource->getCollection()) {
- 'functions' => '',
- 'sites' => !$rule->isEmpty() ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : '',
- default => throw new \Exception('Invalid resource type')
- };
+ $previewUrl = '';
+ if ($resource->getCollection() === 'sites' && !$rule->isEmpty()) {
+ $previewUrl = "{$protocol}://" . $rule->getAttribute('domain', '');
+ }
$comment = new Comment($platform);
$comment->parseComment($github->getComment($owner, $repositoryName, $commentId));
diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php
index 065fe477eb..c766f73929 100644
--- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php
+++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php
@@ -3,6 +3,7 @@
namespace Appwrite\Platform\Modules\Functions\Workers;
use Ahc\Jwt\JWT;
+use Appwrite\Event\Message\Screenshot;
use Appwrite\Event\Realtime;
use Appwrite\Permission;
use Appwrite\Role;
@@ -19,6 +20,8 @@ use Utopia\Platform\Action;
use Utopia\Queue\Message;
use Utopia\Storage\Device;
use Utopia\System\System;
+use Utopia\Telemetry\Adapter as Telemetry;
+use Utopia\Telemetry\Counter;
use function Swoole\Coroutine\batch;
@@ -43,6 +46,7 @@ class Screenshots extends Action
->inject('dbForProject')
->inject('project')
->inject('deviceForFiles')
+ ->inject('telemetry')
->callback($this->action(...));
}
@@ -52,19 +56,23 @@ class Screenshots extends Action
Database $dbForPlatform,
Database $dbForProject,
Document $project,
- Device $deviceForFiles
+ Device $deviceForFiles,
+ Telemetry $telemetry
): void {
Console::log('Screenshot action started');
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new \Exception('Missing payload');
}
+ $screenshotMessage = Screenshot::fromArray($payload);
+ $counter = $telemetry->createCounter('worker.screenshots.capture');
+
Console::log('Site screenshot started');
- $deploymentId = $payload['deploymentId'] ?? null;
+ $deploymentId = $screenshotMessage->deploymentId;
$deployment = $dbForProject->getDocument('deployments', $deploymentId);
if ($deployment->isEmpty()) {
@@ -101,9 +109,7 @@ class Screenshots extends Action
throw new \Exception("Rule for deployment not found");
}
- $client = new FetchClient();
- $client->setTimeout(\intval($site->getAttribute('timeout', '15')) * 1000);
- $client->addHeader('content-type', FetchClient::CONTENT_TYPE_APPLICATION_JSON);
+ $timeout = \intval($site->getAttribute('timeout', '15')) * 1000;
$bucket = $dbForPlatform->getDocument('buckets', 'screenshots');
@@ -154,13 +160,13 @@ class Screenshots extends Action
]);
$screenshotError = null;
- $screenshots = batch(\array_map(function ($key) use ($configs, $apiKey, $site, $client, &$screenshotError) {
- return function () use ($key, $configs, $apiKey, $site, $client, &$screenshotError) {
+ $screenshots = batch(\array_map(function ($key) use ($configs, $apiKey, $site, $timeout, &$screenshotError) {
+ return function () use ($key, $configs, $apiKey, $site, $timeout, &$screenshotError) {
try {
$config = $configs[$key];
- $config['headers'] = \array_merge($config['headers'] ?? [], [
- 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey
+ $config['headers'] = \array_merge($config['headers'], [
+ 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey
]);
$config['sleep'] = 3000;
@@ -171,6 +177,10 @@ class Screenshots extends Action
}
$browserEndpoint = System::getEnv('_APP_BROWSER_HOST', 'http://appwrite-browser:3000/v1');
+ $client = new FetchClient();
+ $client->setTimeout($timeout);
+ $client->addHeader('content-type', FetchClient::CONTENT_TYPE_APPLICATION_JSON);
+
$fetchResponse = $client->fetch(
url: $browserEndpoint . '/screenshots',
method: 'POST',
@@ -265,8 +275,24 @@ class Screenshots extends Action
$date = \date('H:i:s');
$this->appendToLogs($dbForProject, $deployment->getId(), $queueForRealtime, "[90m[$date] [90m[[0mappwrite[90m][33m Screenshot capturing failed. Deployment will continue. [0m\n");
+ $this->recordTelemetry($counter, 'failure');
+
throw $th;
}
+
+ $this->recordTelemetry($counter, 'success');
+ }
+
+ protected function recordTelemetry(Counter $counter, string $result): void
+ {
+ try {
+ $counter->add(1, [
+ 'resourceType' => RESOURCE_TYPE_SITES,
+ 'result' => $result,
+ ]);
+ } catch (\Throwable) {
+ // Telemetry should never affect screenshot processing.
+ }
}
protected function appendToLogs(Database $dbForProject, string $deploymentId, Realtime $queueForRealtime, string $logs)
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php
index bf7c3c4889..8d717eb9ab 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php
@@ -8,12 +8,10 @@ use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
-use Utopia\Cache\Adapter\Pool as CachePool;
-use Utopia\Config\Config;
+use Utopia\Cache\Cache;
use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
-use Utopia\Pools\Group;
class Get extends Action
{
@@ -47,45 +45,32 @@ class Get extends Action
contentType: ContentType::JSON
))
->inject('response')
- ->inject('pools')
+ ->inject('cache')
->callback($this->action(...));
}
- public function action(Response $response, Group $pools): void
+ public function action(Response $response, Cache $cache): void
{
$output = [];
- $failures = [];
- $configs = [
- 'Cache' => Config::getParam('pools-cache'),
- ];
+ $checkStart = \microtime(true);
- foreach ($configs as $key => $config) {
- foreach ($config as $cache) {
- try {
- $adapter = new CachePool($pools->get($cache));
-
- $checkStart = \microtime(true);
-
- if ($adapter->ping()) {
- $output[] = new Document([
- 'name' => $key . " ($cache)",
- 'status' => 'pass',
- 'ping' => \round((\microtime(true) - $checkStart) * 1000),
- ]);
- } else {
- $failures[] = $cache;
- }
- } catch (\Throwable) {
- $failures[] = $cache;
- }
- }
+ try {
+ $ok = $cache->ping();
+ } catch (\Throwable) {
+ $ok = false;
}
- if (!empty($failures)) {
- throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Cache failure on: ' . \implode(', ', $failures));
+ if (!$ok) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Cache failure on: cache');
}
+ $output[] = new Document([
+ 'name' => 'Cache',
+ 'status' => 'pass',
+ 'ping' => \round((\microtime(true) - $checkStart) * 1000),
+ ]);
+
$response->dynamic(new Document([
'statuses' => $output,
'total' => \count($output),
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php
index 60cf5d00d4..728ffb8b71 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php
@@ -82,7 +82,7 @@ class Get extends Action
}
$certificatePayload = @openssl_x509_parse($peerCertificate);
- if ($certificatePayload === false || !\is_array($certificatePayload)) {
+ if ($certificatePayload === false) {
throw new Exception(Exception::HEALTH_INVALID_HOST, 'Failed to parse peer certificate for ' . $domain);
}
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php
index e01e89641d..76c34a0a2a 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Audits;
-use Appwrite\Event\Audit;
+use Appwrite\Event\Publisher\Audit;
use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -42,16 +42,16 @@ class Get extends Base
contentType: ContentType::JSON
))
->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true)
- ->inject('queueForAudits')
+ ->inject('publisherForAudits')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Audit $queueForAudits, Response $response): void
+ public function action(int|string $threshold, Audit $publisherForAudits, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForAudits->getSize();
+ $size = $publisherForAudits->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php
index 8ae7c8687a..98e65e37e5 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Builds;
-use Appwrite\Event\Build;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -42,16 +42,16 @@ class Get extends Base
contentType: ContentType::JSON
))
->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true)
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Build $queueForBuilds, Response $response): void
+ public function action(int|string $threshold, BuildPublisher $publisherForBuilds, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForBuilds->getSize();
+ $size = $publisherForBuilds->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php
index 6724f25094..82c45db172 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Certificates;
-use Appwrite\Event\Certificate;
+use Appwrite\Event\Publisher\Certificate;
use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -42,16 +42,16 @@ class Get extends Base
contentType: ContentType::JSON
))
->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true)
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Certificate $queueForCertificates, Response $response): void
+ public function action(int|string $threshold, Certificate $publisherForCertificates, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForCertificates->getSize();
+ $size = $publisherForCertificates->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Databases/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Databases/Get.php
index 213bd8b36c..3bd42b64c6 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Databases/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Databases/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Databases;
-use Appwrite\Event\Database;
+use Appwrite\Event\Publisher\Database;
use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -10,6 +10,7 @@ use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Document;
+use Utopia\Queue\Queue;
use Utopia\Validator\Integer;
use Utopia\Validator\Text;
@@ -44,15 +45,15 @@ class Get extends Base
))
->param('name', 'database_db_main', new Text(256), 'Queue name for which to check the queue size', true)
->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true)
- ->inject('queueForDatabase')
+ ->inject('publisherForDatabase')
->inject('response')
->callback($this->action(...));
}
- public function action(string $name, int|string $threshold, Database $queueForDatabase, Response $response): void
+ public function action(string $name, int|string $threshold, Database $publisherForDatabase, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForDatabase->setQueue($name)->getSize();
+ $size = $publisherForDatabase->getSize(queue: new Queue($name));
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Deletes/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Deletes/Get.php
index 816583fc47..c1bcc900e0 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Deletes/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Deletes/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Deletes;
-use Appwrite\Event\Delete;
+use Appwrite\Event\Publisher\Delete;
use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -42,16 +42,16 @@ class Get extends Base
contentType: ContentType::JSON
))
->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true)
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Delete $queueForDeletes, Response $response): void
+ public function action(int|string $threshold, Delete $publisherForDeletes, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForDeletes->getSize();
+ $size = $publisherForDeletes->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php
index 1f7cc0bf33..d3b760d01b 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php
@@ -2,20 +2,21 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Failed;
-use Appwrite\Event\Audit;
-use Appwrite\Event\Build;
-use Appwrite\Event\Certificate;
-use Appwrite\Event\Database;
-use Appwrite\Event\Delete;
use Appwrite\Event\Event;
-use Appwrite\Event\Func;
-use Appwrite\Event\Mail;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Publisher\Audit;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
+use Appwrite\Event\Publisher\Certificate;
+use Appwrite\Event\Publisher\Database as DatabasePublisher;
+use Appwrite\Event\Publisher\Delete as DeletePublisher;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Appwrite\Event\Publisher\Migration as MigrationPublisher;
+use Appwrite\Event\Publisher\Screenshot;
use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
-use Appwrite\Event\Screenshot;
use Appwrite\Event\Webhook;
+use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -73,19 +74,19 @@ class Get extends Base
]), 'The name of the queue')
->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true)
->inject('response')
- ->inject('queueForDatabase')
- ->inject('queueForDeletes')
- ->inject('queueForAudits')
- ->inject('queueForMails')
- ->inject('queueForFunctions')
+ ->inject('publisherForDatabase')
+ ->inject('publisherForDeletes')
+ ->inject('publisherForAudits')
+ ->inject('publisherForMails')
+ ->inject('publisherForFunctions')
->inject('publisherForStatsResources')
->inject('publisherForUsage')
->inject('queueForWebhooks')
- ->inject('queueForCertificates')
- ->inject('queueForBuilds')
- ->inject('queueForMessaging')
+ ->inject('publisherForCertificates')
+ ->inject('publisherForBuilds')
+ ->inject('publisherForMessaging')
->inject('publisherForMigrations')
- ->inject('queueForScreenshots')
+ ->inject('publisherForScreenshots')
->callback($this->action(...));
}
@@ -93,36 +94,37 @@ class Get extends Base
string $name,
int|string $threshold,
Response $response,
- Database $queueForDatabase,
- Delete $queueForDeletes,
- Audit $queueForAudits,
- Mail $queueForMails,
- Func $queueForFunctions,
+ DatabasePublisher $publisherForDatabase,
+ DeletePublisher $publisherForDeletes,
+ Audit $publisherForAudits,
+ MailPublisher $publisherForMails,
+ FunctionPublisher $publisherForFunctions,
StatsResourcesPublisher $publisherForStatsResources,
UsagePublisher $publisherForUsage,
Webhook $queueForWebhooks,
- Certificate $queueForCertificates,
- Build $queueForBuilds,
- Messaging $queueForMessaging,
+ Certificate $publisherForCertificates,
+ BuildPublisher $publisherForBuilds,
+ MessagingPublisher $publisherForMessaging,
MigrationPublisher $publisherForMigrations,
- Screenshot $queueForScreenshots,
+ Screenshot $publisherForScreenshots,
): void {
$threshold = (int) $threshold;
$queue = match ($name) {
- System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME) => $queueForDatabase,
- System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME) => $queueForDeletes,
- System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME) => $queueForAudits,
- System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME) => $queueForMails,
- System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME) => $queueForFunctions,
+ System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME) => $publisherForDatabase,
+ System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME) => $publisherForDeletes,
+ System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME) => $publisherForAudits,
+ System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME) => $publisherForMails,
+ System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME) => $publisherForFunctions,
System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME) => $publisherForStatsResources,
System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME) => $publisherForUsage,
System::getEnv('_APP_WEBHOOK_QUEUE_NAME', Event::WEBHOOK_QUEUE_NAME) => $queueForWebhooks,
- System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME) => $queueForCertificates,
- System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME) => $queueForBuilds,
- System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $queueForScreenshots,
- System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $queueForMessaging,
+ System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME) => $publisherForCertificates,
+ System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME) => $publisherForBuilds,
+ System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $publisherForScreenshots,
+ System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $publisherForMessaging,
System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $publisherForMigrations,
+ default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unknown queue name: ' . $name),
};
$failed = $queue->getSize(failed: true);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Functions/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Functions/Get.php
index 1d10b8d1a0..29c7a7c859 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Functions/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Functions/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Functions;
-use Appwrite\Event\Func;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -42,16 +42,16 @@ class Get extends Base
contentType: ContentType::JSON
))
->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true)
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Func $queueForFunctions, Response $response): void
+ public function action(int|string $threshold, FunctionPublisher $publisherForFunctions, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForFunctions->getSize();
+ $size = $publisherForFunctions->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php
index dd05aebc39..0a655662de 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Logs;
-use Appwrite\Event\Audit;
+use Appwrite\Event\Publisher\Audit;
use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -42,16 +42,16 @@ class Get extends Base
contentType: ContentType::JSON
))
->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true)
- ->inject('queueForAudits')
+ ->inject('publisherForAudits')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Audit $queueForAudits, Response $response): void
+ public function action(int|string $threshold, Audit $publisherForAudits, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForAudits->getSize();
+ $size = $publisherForAudits->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Mails/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Mails/Get.php
index 3b9c06b5f9..2dd36e8111 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Mails/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Mails/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Mails;
-use Appwrite\Event\Mail;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -42,16 +42,16 @@ class Get extends Base
contentType: ContentType::JSON
))
->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true)
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Mail $queueForMails, Response $response): void
+ public function action(int|string $threshold, MailPublisher $publisherForMails, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForMails->getSize();
+ $size = $publisherForMails->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Messaging/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Messaging/Get.php
index db2d7d7172..a2a829b1d5 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Messaging/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Messaging/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Messaging;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -42,16 +42,16 @@ class Get extends Base
contentType: ContentType::JSON
))
->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true)
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Messaging $queueForMessaging, Response $response): void
+ public function action(int|string $threshold, MessagingPublisher $publisherForMessaging, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForMessaging->getSize();
+ $size = $publisherForMessaging->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php
new file mode 100644
index 0000000000..fa700877a1
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php
@@ -0,0 +1,116 @@
+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')
+ ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', true)
+ ->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,
+ string $onDuplicate,
+ 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' => [],
+ 'options' => [
+ 'onDuplicate' => $onDuplicate,
+ ],
+ ]));
+
+ $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);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Report/Get.php
new file mode 100644
index 0000000000..32d8a62ec3
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Report/Get.php
@@ -0,0 +1,80 @@
+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);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Exports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Exports/Create.php
new file mode 100644
index 0000000000..0ab3cecf1a
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Exports/Create.php
@@ -0,0 +1,213 @@
+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,
+ };
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php
new file mode 100644
index 0000000000..4b47ed7d58
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php
@@ -0,0 +1,225 @@
+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)
+ ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', 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,
+ string $onDuplicate,
+ 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,
+ 'onDuplicate' => $onDuplicate,
+ ],
+ ]));
+
+ $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,
+ };
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Delete.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Delete.php
new file mode 100644
index 0000000000..f9c989b5bf
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Delete.php
@@ -0,0 +1,74 @@
+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();
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Create.php
new file mode 100644
index 0000000000..a8347858b4
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Create.php
@@ -0,0 +1,114 @@
+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);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Report/Get.php
new file mode 100644
index 0000000000..ef8084795e
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Report/Get.php
@@ -0,0 +1,80 @@
+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);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Get.php
new file mode 100644
index 0000000000..14b40e2306
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Get.php
@@ -0,0 +1,61 @@
+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);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Exports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Exports/Create.php
new file mode 100644
index 0000000000..d968bd91f6
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Exports/Create.php
@@ -0,0 +1,198 @@
+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,
+ };
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php
new file mode 100644
index 0000000000..c5d936711e
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php
@@ -0,0 +1,226 @@
+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)
+ ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', 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,
+ string $onDuplicate,
+ 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,
+ 'onDuplicate' => $onDuplicate,
+ ],
+ ]));
+
+ $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,
+ };
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Create.php
new file mode 100644
index 0000000000..fb97b1c16c
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Create.php
@@ -0,0 +1,122 @@
+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);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Report/Get.php
new file mode 100644
index 0000000000..964f2dc347
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Report/Get.php
@@ -0,0 +1,86 @@
+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);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Create.php
new file mode 100644
index 0000000000..98b33e379d
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Create.php
@@ -0,0 +1,120 @@
+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);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Report/Get.php
new file mode 100644
index 0000000000..423e611430
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Report/Get.php
@@ -0,0 +1,85 @@
+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);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Update.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Update.php
new file mode 100644
index 0000000000..8ecc53c2a3
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Update.php
@@ -0,0 +1,90 @@
+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();
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/XList.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/XList.php
new file mode 100644
index 0000000000..1a1252be79
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/XList.php
@@ -0,0 +1,104 @@
+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);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Module.php b/src/Appwrite/Platform/Modules/Migrations/Module.php
new file mode 100644
index 0000000000..6ec1e49a88
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Module.php
@@ -0,0 +1,14 @@
+addService('http', new Http());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Services/Http.php b/src/Appwrite/Platform/Modules/Migrations/Services/Http.php
new file mode 100644
index 0000000000..1e2c95a78b
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Services/Http.php
@@ -0,0 +1,59 @@
+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());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Init.php b/src/Appwrite/Platform/Modules/Organization/Http/Init.php
new file mode 100644
index 0000000000..56eb6db3a0
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Organization/Http/Init.php
@@ -0,0 +1,28 @@
+setType(Action::TYPE_INIT)
+ ->groups(['organization'])
+ ->inject('team')
+ ->callback(function (Document $team) {
+ if ($team->isEmpty()) {
+ throw new Exception(Exception::TEAM_NOT_FOUND);
+ }
+ });
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Action.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Action.php
new file mode 100644
index 0000000000..0160e2aa04
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Action.php
@@ -0,0 +1,11 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/organization/projects')
+ ->desc('Create organization project')
+ ->groups(['api', 'organization'])
+ ->label('audits.event', 'projects.create')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('scope', 'projects.write')
+ ->label('sdk', new Method(
+ namespace: 'organization',
+ group: 'projects',
+ name: 'createProject',
+ description: <<param('projectId', '', new ProjectId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, and hyphen. Can\'t start with a special char. Max length is 36 chars.')
+ ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.')
+ ->param('region', System::getEnv('_APP_REGION', 'default'), new WhiteList(array_keys(array_filter(Config::getParam('regions'), fn ($config) => !$config['disabled']))), 'Project Region.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('cache')
+ ->inject('pools')
+ ->inject('hooks')
+ ->inject('team')
+ ->callback($this->action(...));
+ }
+
+ public function action(string $projectId, string $name, string $region, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks, Document $team)
+ {
+ $allowList = \array_filter(\explode(',', System::getEnv('_APP_PROJECT_REGIONS', '')));
+
+ if (!empty($allowList) && !\in_array($region, $allowList)) {
+ throw new Exception(Exception::PROJECT_REGION_UNSUPPORTED, 'Region "' . $region . '" is not supported');
+ }
+
+ $auth = Config::getParam('auth', []);
+ $auths = [
+ 'limit' => 0,
+ 'maxSessions' => 0,
+ 'passwordHistory' => 0,
+ 'passwordDictionary' => false,
+ 'duration' => TOKEN_EXPIRATION_LOGIN_LONG,
+ 'personalDataCheck' => false,
+ 'disposableEmails' => false,
+ 'canonicalEmails' => false,
+ 'freeEmails' => false,
+ 'mockNumbers' => [],
+ 'sessionAlerts' => false,
+ 'membershipsUserName' => false,
+ 'membershipsUserEmail' => false,
+ 'membershipsMfa' => false,
+ 'membershipsUserId' => false,
+ 'membershipsUserPhone' => false,
+ 'invalidateSessions' => true
+ ];
+
+ foreach ($auth as $method) {
+ $auths[$method['key'] ?? ''] = true;
+ }
+
+ $projectId = ($projectId == 'unique()') ? ID::unique() : $projectId;
+
+ if ($projectId === 'console') {
+ throw new Exception(Exception::PROJECT_RESERVED_PROJECT, "'console' is a reserved project.");
+ }
+
+ $databases = Config::getParam('pools-database', []);
+
+ if ($region !== 'default') {
+ $databaseKeys = System::getEnv('_APP_DATABASE_KEYS', '');
+ $keys = explode(',', $databaseKeys);
+ $databases = array_filter($keys, function ($value) use ($region) {
+ return str_contains($value, $region);
+ });
+ }
+
+ $databaseOverride = System::getEnv('_APP_DATABASE_OVERRIDE');
+ $index = \array_search($databaseOverride, $databases);
+ if ($index !== false) {
+ $dsn = $databases[$index];
+ } else {
+ $dsn = $databases[array_rand($databases)];
+ }
+
+ // TODO: Temporary until all projects are using shared tables.
+ $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
+
+ if (\in_array($dsn, $sharedTables)) {
+ $schema = 'appwrite';
+ $database = 'appwrite';
+ $namespace = System::getEnv('_APP_DATABASE_SHARED_NAMESPACE', '');
+ $dsn = $schema . '://' . $dsn . '?database=' . $database;
+
+ if (!empty($namespace)) {
+ $dsn .= '&namespace=' . $namespace;
+ }
+ }
+
+ try {
+ $project = $dbForPlatform->createDocument('projects', new Document([
+ '$id' => $projectId,
+ '$permissions' => $this->getPermissions($team->getId(), $projectId),
+ 'name' => $name,
+ 'teamInternalId' => $team->getSequence(),
+ 'teamId' => $team->getId(),
+ 'region' => $region,
+ 'version' => APP_VERSION_STABLE,
+ 'services' => new \stdClass(),
+ 'platforms' => null,
+ 'oAuthProviders' => [],
+ 'webhooks' => null,
+ 'keys' => null,
+ 'auths' => $auths,
+ 'accessedAt' => DateTime::now(),
+ 'search' => implode(' ', [$projectId, $name]),
+ 'database' => $dsn,
+ 'labels' => [],
+ 'status' => PROJECT_STATUS_ACTIVE,
+ ]));
+ } catch (Duplicate) {
+ throw new Exception(Exception::PROJECT_ALREADY_EXISTS);
+ }
+
+ try {
+ $dsn = new DSN($dsn);
+ } catch (\InvalidArgumentException) {
+ // TODO: Temporary until all projects are using shared tables
+ $dsn = new DSN('mysql://' . $dsn);
+ }
+
+ $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
+ $projectTables = !\in_array($dsn->getHost(), $sharedTables);
+
+ if ($projectTables) {
+ $adapter = new DatabasePool($pools->get($dsn->getHost()));
+ $dbForProject = new Database($adapter, $cache);
+ $dbForProject
+ ->setDatabase(APP_DATABASE)
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $project->getSequence());
+
+ $create = true;
+
+ try {
+ $dbForProject->create();
+ } catch (Duplicate) {
+ $create = false;
+ }
+
+ $adapter = new AdapterDatabase($dbForProject);
+ $audit = new Audit($adapter);
+ $audit->setup();
+
+ if ($create) {
+ /** @var array $collections */
+ $collections = Config::getParam('collections', [])['projects'] ?? [];
+
+ foreach ($collections as $key => $collection) {
+ if (($collection['$collection'] ?? '') !== Database::METADATA) {
+ continue;
+ }
+
+ $attributes = \array_map(fn ($attribute) => new Document($attribute), $collection['attributes']);
+ $indexes = \array_map(fn (array $index) => new Document($index), $collection['indexes']);
+
+ try {
+ $dbForProject->createCollection($key, $attributes, $indexes);
+ } catch (Duplicate) {
+ // Collection already exists
+ }
+ }
+ }
+ }
+
+ // Hook allowing instant project mirroring during migration
+ // Outside of migration, hook is not registered and has no effect
+ $hooks->trigger('afterProjectCreation', [$project, $pools, $cache]);
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_CREATED)
+ ->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Delete.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Delete.php
new file mode 100644
index 0000000000..fc8d5cccfc
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Delete.php
@@ -0,0 +1,93 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/organization/projects/:projectId')
+ ->desc('Delete organization project')
+ ->groups(['api', 'organization'])
+ ->label('scope', 'projects.write')
+ ->label('audits.event', 'projects.delete')
+ ->label('audits.resource', 'project/{request.projectId}')
+ ->label('sdk', new Method(
+ namespace: 'organization',
+ group: 'projects',
+ name: 'deleteProject',
+ description: <<param('projectId', '', new UID(), 'Project unique ID.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('publisherForDeletes')
+ ->inject('authorization')
+ ->inject('team')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $projectId,
+ Response $response,
+ Database $dbForPlatform,
+ DeletePublisher $publisherForDeletes,
+ Authorization $authorization,
+ Document $team,
+ ) {
+ $project = $dbForPlatform->getDocument('projects', $projectId);
+
+ if ($project->isEmpty()) {
+ throw new Exception(Exception::PROJECT_NOT_FOUND);
+ }
+
+ if ($project->getAttribute('teamInternalId') !== $team->getSequence()) {
+ throw new Exception(Exception::PROJECT_NOT_FOUND);
+ }
+
+ if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('projects', $project->getId()))) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove project from DB');
+ }
+
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $project,
+ type: DELETE_TYPE_DOCUMENT,
+ document: $project,
+ ));
+
+ $response->noContent();
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php
new file mode 100644
index 0000000000..37f2dd417a
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Get.php
@@ -0,0 +1,76 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/organization/projects/:projectId')
+ ->desc('Get organization project')
+ ->groups(['api', 'organization'])
+ ->label('scope', 'projects.read')
+ ->label('sdk', new Method(
+ namespace: 'organization',
+ group: 'projects',
+ name: 'getProject',
+ description: <<param('projectId', '', new UID(), 'Project unique ID.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('team')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $projectId,
+ Response $response,
+ Database $dbForPlatform,
+ Document $team,
+ ) {
+ $project = $dbForPlatform->getDocument('projects', $projectId);
+
+ if ($project->isEmpty()) {
+ throw new Exception(Exception::PROJECT_NOT_FOUND);
+ }
+
+ if ($project->getAttribute('teamInternalId') !== $team->getSequence()) {
+ throw new Exception(Exception::PROJECT_NOT_FOUND);
+ }
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_OK)
+ ->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php
new file mode 100644
index 0000000000..c364a5d6df
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/Update.php
@@ -0,0 +1,81 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/organization/projects/:projectId')
+ ->desc('Update organization project')
+ ->groups(['api', 'organization'])
+ ->label('scope', 'projects.write')
+ ->label('audits.event', 'projects.update')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'organization',
+ group: 'projects',
+ name: 'updateProject',
+ description: <<param('projectId', '', new UID(), 'Project unique ID.')
+ ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('team')
+ ->callback($this->action(...));
+ }
+
+ public function action(string $projectId, string $name, Response $response, Database $dbForPlatform, Document $team)
+ {
+ $project = $dbForPlatform->getDocument('projects', $projectId);
+
+ if ($project->isEmpty()) {
+ throw new Exception(Exception::PROJECT_NOT_FOUND);
+ }
+
+ if ($project->getAttribute('teamInternalId') !== $team->getSequence()) {
+ throw new Exception(Exception::PROJECT_NOT_FOUND);
+ }
+
+ $project = $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
+ 'name' => $name,
+ 'search' => implode(' ', [$projectId, $name]),
+ ]));
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_OK)
+ ->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php
new file mode 100644
index 0000000000..6b45d92175
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Organization/Http/Projects/XList.php
@@ -0,0 +1,196 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/organization/projects')
+ ->desc('List organization projects')
+ ->groups(['api', 'organization'])
+ ->label('scope', 'projects.read')
+ ->label('sdk', new Method(
+ namespace: 'organization',
+ group: 'projects',
+ name: 'listProjects',
+ description: <<param('queries', [], $this->getQueriesValidator(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Projects::ALLOWED_ATTRIBUTES), true)
+ ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
+ ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('team')
+ ->callback($this->action(...));
+ }
+
+ public function action(array $queries, string $search, bool $includeTotal, Response $response, Database $dbForPlatform, Document $team)
+ {
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ if (!empty($search)) {
+ $queries[] = Query::search('search', $search);
+ }
+
+ $queries[] = Query::equal('teamInternalId', [$team->getSequence()]);
+
+ $cursor = Query::getCursorQueries($queries, false);
+ $cursor = \reset($cursor);
+
+ if ($cursor !== false) {
+ $validator = new Cursor();
+ if (!$validator->isValid($cursor)) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
+ }
+
+ $projectId = $cursor->getValue();
+ $cursorDocument = $dbForPlatform->getDocument('projects', $projectId);
+
+ if ($cursorDocument->isEmpty()) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Project '{$projectId}' for the 'cursor' value not found.");
+ }
+
+ $cursor->setValue($cursorDocument);
+ }
+
+ try {
+ $selectQueries = Query::groupByType($queries)['selections'];
+ $filterQueries = Query::groupByType($queries)['filters'];
+
+ $projects = $this->find($dbForPlatform, $queries, $selectQueries);
+ $total = $includeTotal ? $dbForPlatform->count('projects', $filterQueries, APP_LIMIT_COUNT) : 0;
+ } catch (Order $e) {
+ throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
+ }
+
+ $response->addFilter(new ListSelection($selectQueries, 'projects'));
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_OK)
+ ->dynamic(new Document([
+ 'projects' => $projects,
+ 'total' => $total,
+ ]), Response::MODEL_PROJECT_LIST);
+ }
+
+ // Build mapping of columns to their subQuery filters
+ private static function getAttributeToSubQueryFilters(): array
+ {
+ if (self::$attributeToSubQueryFilters !== null) {
+ return self::$attributeToSubQueryFilters;
+ }
+
+ self::$attributeToSubQueryFilters = [];
+
+ $collections = Config::getParam('collections', []);
+ $projectAttributes = $collections['platform']['projects']['attributes'] ?? [];
+
+ foreach ($projectAttributes as $attribute) {
+ $attributeId = $attribute['$id'] ?? null;
+ $filters = $attribute['filters'] ?? [];
+
+ if ($attributeId === null || empty($filters)) {
+ continue;
+ }
+
+ // extract only subQuery filters
+ $subQueryFilters = \array_filter($filters, function ($filter) {
+ return \str_starts_with($filter, 'subQuery');
+ });
+
+ if (!empty($subQueryFilters)) {
+ self::$attributeToSubQueryFilters[$attributeId] = \array_values($subQueryFilters);
+ }
+ }
+
+ return self::$attributeToSubQueryFilters;
+ }
+
+ private function find(Database $dbForPlatform, array $queries, array $selectQueries): array
+ {
+ if (empty($selectQueries)) {
+ return $dbForPlatform->find('projects', $queries);
+ }
+
+ $selectedAttributes = [];
+ foreach ($selectQueries as $query) {
+ foreach ($query->getValues() as $value) {
+ $selectedAttributes[] = $value;
+ }
+ }
+
+ if (\in_array('*', $selectedAttributes)) {
+ return $dbForPlatform->find('projects', $queries);
+ }
+
+ $filtersToSkipMap = [];
+ $selectedAttributesMap = \array_flip($selectedAttributes);
+ $attributeToSubQueryFilters = self::getAttributeToSubQueryFilters();
+
+ foreach ($attributeToSubQueryFilters as $attributeName => $subQueryFilters) {
+ if (!isset($selectedAttributesMap[$attributeName])) {
+ foreach ($subQueryFilters as $filter) {
+ $filtersToSkipMap[$filter] = true;
+ }
+ }
+ }
+
+ $filtersToSkip = \array_keys($filtersToSkipMap);
+
+ return empty($filtersToSkip)
+ ? $dbForPlatform->find('projects', $queries)
+ : $dbForPlatform->skipFilters(fn () => $dbForPlatform->find('projects', $queries), $filtersToSkip);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Organization/Module.php b/src/Appwrite/Platform/Modules/Organization/Module.php
new file mode 100644
index 0000000000..eb7a2dc433
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Organization/Module.php
@@ -0,0 +1,14 @@
+addService('http', new Http());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Organization/Services/Http.php b/src/Appwrite/Platform/Modules/Organization/Services/Http.php
new file mode 100644
index 0000000000..49a8f7d832
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Organization/Services/Http.php
@@ -0,0 +1,29 @@
+type = Service::TYPE_HTTP;
+
+ // Init hook
+ $this->addAction(Init::getName(), new Init());
+
+ // Projects
+ $this->addAction(CreateProject::getName(), new CreateProject());
+ $this->addAction(ListProjects::getName(), new ListProjects());
+ $this->addAction(GetProject::getName(), new GetProject());
+ $this->addAction(UpdateProject::getName(), new UpdateProject());
+ $this->addAction(DeleteProject::getName(), new DeleteProject());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Delete.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Delete.php
new file mode 100644
index 0000000000..66ab00c3a7
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Delete.php
@@ -0,0 +1,88 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/presences/:presenceId')
+ ->desc('Delete presence')
+ ->groups(['api', 'presences'])
+ ->label('scope', 'presences.write')
+ ->label('event', 'presences.[presenceId].delete')
+ ->label('audits.event', 'presence.delete')
+ ->label('audits.resource', 'presence/{request.presenceId}')
+ ->label('sdk', new Method(
+ namespace: 'presences',
+ group: 'presences',
+ name: 'delete',
+ desc: 'Delete presence',
+ description: '/docs/references/presences/delete.md',
+ auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_NOCONTENT,
+ model: Response::MODEL_NONE,
+ ),
+ ],
+ contentType: ContentType::NONE,
+ ))
+ ->param('presenceId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Presence unique ID.', false, ['dbForProject'])
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('queueForEvents')
+ ->inject('usage')
+ ->callback($this->action(...));
+ }
+
+ public function action(string $presenceId, Response $response, Database $dbForProject, Event $queueForEvents, Context $usage): void
+ {
+ $presence = $dbForProject->getDocument('presenceLogs', $presenceId);
+
+ if ($presence->isEmpty()) {
+ throw new Exception(Exception::PRESENCE_NOT_FOUND);
+ }
+
+ try {
+ $dbForProject->deleteDocument('presenceLogs', $presenceId);
+ } catch (ConflictException) {
+ throw new Exception(Exception::DOCUMENT_UPDATE_CONFLICT);
+ } catch (RestrictedException) {
+ throw new Exception(Exception::DOCUMENT_DELETE_RESTRICTED);
+ }
+
+ (new PresenceState())->purgeListCache($dbForProject);
+
+ $usage->addMetric(METRIC_USERS_PRESENCE, -1);
+
+ $queueForEvents
+ ->setParam('presenceId', $presence->getId())
+ ->setPayload($response->output($presence, Response::MODEL_PRESENCE));
+
+ $response->noContent();
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Get.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Get.php
new file mode 100644
index 0000000000..ba6b769f70
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Get.php
@@ -0,0 +1,69 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/presences/:presenceId')
+ ->desc('Get presence')
+ ->groups(['api', 'presences'])
+ ->label('scope', 'presences.read')
+ ->label('sdk', new Method(
+ namespace: 'presences',
+ group: 'presences',
+ name: 'get',
+ desc: 'Get presence',
+ description: '/docs/references/presences/get.md',
+ auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_PRESENCE,
+ ),
+ ],
+ ))
+ ->param('presenceId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Presence unique ID.', false, ['dbForProject'])
+ ->inject('response')
+ ->inject('dbForProject')
+ ->callback($this->action(...));
+ }
+
+ public function action(string $presenceId, Response $response, Database $dbForProject): void
+ {
+ $presence = $dbForProject->getDocument('presenceLogs', $presenceId);
+ if ($presence->isEmpty()) {
+ throw new Exception(Exception::PRESENCE_NOT_FOUND);
+ }
+
+ $presenceExpiresAt = $presence->getAttribute('expiresAt');
+
+ if (!empty($presenceExpiresAt) && DateTime::formatTz($presenceExpiresAt) < DateTime::formatTz(DateTime::now())) {
+ throw new Exception(Exception::PRESENCE_NOT_FOUND);
+ }
+
+ $response->dynamic($presence, Response::MODEL_PRESENCE);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php
new file mode 100644
index 0000000000..5387d3a91e
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php
@@ -0,0 +1,206 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/presences/:presenceId')
+ ->desc('Update presence')
+ ->groups(['api', 'presences'])
+ ->label('scope', 'presences.write')
+ ->label('event', 'presences.[presenceId].update')
+ ->label('audits.event', 'presence.update')
+ ->label('audits.resource', 'presence/{response.$id}')
+ ->label('sdk', [
+ // Client-side SDK: `userId` is not accepted (session callers can only update their own presence).
+ new Method(
+ namespace: 'presences',
+ group: 'presences',
+ name: 'update',
+ desc: 'Update presence',
+ description: '/docs/references/presences/update.md',
+ auth: [AuthType::SESSION],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_PRESENCE,
+ ),
+ ],
+ parameters: [
+ new Parameter('presenceId', optional: false),
+ new Parameter('status', optional: true),
+ new Parameter('expiresAt', optional: true),
+ new Parameter('metadata', optional: true),
+ new Parameter('permissions', optional: true),
+ new Parameter('purge', optional: true),
+ ],
+ ),
+ // Server-side SDK: `userId` is required when authenticating with API keys/JWT.
+ new Method(
+ namespace: 'presences',
+ group: 'presences',
+ name: 'updatePresence',
+ desc: 'Update presence',
+ description: '/docs/references/presences/update.md',
+ auth: [AuthType::KEY, AuthType::JWT, AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_PRESENCE,
+ ),
+ ],
+ parameters: [
+ new Parameter('presenceId', optional: false),
+ new Parameter('userId', optional: false),
+ new Parameter('status', optional: true),
+ new Parameter('expiresAt', optional: true),
+ new Parameter('metadata', optional: true),
+ new Parameter('permissions', optional: true),
+ new Parameter('purge', optional: true),
+ ],
+ ),
+ ])
+ ->param('presenceId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Presence unique ID.', false, ['dbForProject'])
+ ->param('userId', null, new UID(), 'User ID.', true)
+ ->param('status', null, new Text(Database::LENGTH_KEY), 'Presence status.', true)
+ ->param('expiresAt', null, new DatetimeValidator(
+ new \DateTime(),
+ (new \DateTime())->modify('+30 days'),
+ requireDateInFuture: true
+ ), 'Presence expiry datetime.', true)
+ ->param('metadata', null, new JSON(), 'Presence metadata object.', true)
+ ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true)
+ ->param('purge', false, new Boolean(true), 'When true, purge cached responses used by list presences endpoint.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('user')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $presenceId,
+ ?string $userId,
+ ?string $status,
+ ?string $expiresAt,
+ ?array $metadata,
+ ?array $permissions,
+ bool $purge,
+ Response $response,
+ Database $dbForProject,
+ User $user,
+ Authorization $authorization,
+ Event $queueForEvents
+ ): void {
+ $presenceState = new PresenceState();
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
+
+ if ($userId && !$isAPIKey && !$isPrivilegedUser) {
+ throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, 'userId is not allowed for non-API key and non-privileged users');
+ }
+
+ $presence = $dbForProject->getDocument('presenceLogs', $presenceId);
+
+ if ($presence->isEmpty()) {
+ throw new Exception(Exception::PRESENCE_NOT_FOUND, params: [$presenceId]);
+ }
+
+ $presenceExpiresAt = $presence->getAttribute('expiresAt');
+ if (!empty($presenceExpiresAt) && DateTime::formatTz($presenceExpiresAt) < DateTime::formatTz(DateTime::now())) {
+ throw new Exception(Exception::PRESENCE_NOT_FOUND, params: [$presenceId]);
+ }
+
+ $updateData = [];
+
+ if ($userId !== null) {
+ $updateData['userId'] = $userId;
+ $userDoc = $dbForProject->getDocument('users', $userId);
+ if ($userDoc->isEmpty()) {
+ throw new Exception(Exception::USER_NOT_FOUND, params: [$userId]);
+ }
+ $updateData['userInternalId'] = $userDoc->getSequence();
+ }
+
+ if ($status !== null) {
+ $updateData['status'] = $status;
+ }
+
+ if ($expiresAt !== null) {
+ $updateData['expiresAt'] = $expiresAt;
+ }
+
+ if ($metadata !== null) {
+ $updateData['metadata'] = $metadata;
+ }
+
+ $updates = new Document($updateData);
+
+ if ($permissions !== null) {
+ $presenceState->setPermissions($updates, $permissions, $user, $authorization);
+ } elseif ($userId !== null && $userId !== $presence->getAttribute('userId')) {
+ $presenceState->setPermissions($updates, null, $user, $authorization, ownerOverride: $userId);
+ }
+
+ if (empty($updateData) && $permissions === null) {
+ if ($purge) {
+ $presenceState->purgeListCache($dbForProject);
+ }
+ $response->dynamic($presence, Response::MODEL_PRESENCE);
+ return;
+ }
+
+ try {
+ $presence = $dbForProject->updateDocument('presenceLogs', $presenceId, $updates);
+ } catch (Duplicate $e) {
+ throw new Exception(Exception::PRESENCE_ALREADY_EXISTS, params: [$presenceId], previous: $e);
+ } catch (StructureException $e) {
+ throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, $e->getMessage(), previous: $e);
+ } catch (ConflictException $e) {
+ throw new Exception(Exception::DOCUMENT_UPDATE_CONFLICT, $e->getMessage(), previous: $e);
+ }
+
+ if ($purge) {
+ $presenceState->purgeListCache($dbForProject);
+ }
+
+ $queueForEvents->setParam('presenceId', $presence->getId());
+
+ $response->dynamic($presence, Response::MODEL_PRESENCE);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Upsert.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Upsert.php
new file mode 100644
index 0000000000..c85cb15f17
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Upsert.php
@@ -0,0 +1,192 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
+ ->setHttpPath('/v1/presences/:presenceId')
+ ->desc('Upsert presence')
+ ->groups(['api', 'presences'])
+ ->label('scope', 'presences.write')
+ ->label('event', 'presences.[presenceId].upsert')
+ ->label('audits.event', 'presence.upsert')
+ ->label('audits.resource', 'presence/{response.$id}')
+ ->label('sdk', [
+ // Client-side SDK: `userId` is not accepted (session callers should just upsert their own presence).
+ new Method(
+ namespace: 'presences',
+ group: 'presences',
+ name: 'upsert',
+ desc: 'Upsert presence',
+ description: '/docs/references/presences/upsert.md',
+ auth: [AuthType::SESSION],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_PRESENCE,
+ ),
+ ],
+ parameters: [
+ new Parameter('presenceId', optional: false),
+ new Parameter('status', optional: false),
+ new Parameter('permissions', optional: true),
+ new Parameter('expiresAt', optional: true),
+ new Parameter('metadata', optional: true),
+ ],
+ ),
+ // Server-side SDK: `userId` is required when authenticating with API keys/JWT.
+ new Method(
+ namespace: 'presences',
+ group: 'presences',
+ name: 'upsert',
+ desc: 'Upsert presence',
+ description: '/docs/references/presences/upsert.md',
+ auth: [AuthType::KEY, AuthType::JWT, AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_PRESENCE,
+ ),
+ ],
+ parameters: [
+ new Parameter('presenceId', optional: false),
+ new Parameter('userId', optional: false),
+ new Parameter('status', optional: false),
+ new Parameter('permissions', optional: true),
+ new Parameter('expiresAt', optional: true),
+ new Parameter('metadata', optional: true),
+ ],
+ ),
+ ])
+ ->param('presenceId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Presence unique ID.', false, ['dbForProject'])
+ ->param('userId', null, new UID(), 'User ID.', true)
+ ->param('status', '', new Text(Database::LENGTH_KEY), 'Presence status.', false)
+ ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true)
+ ->param('expiresAt', null, new DatetimeValidator(
+ new \DateTime(),
+ (new \DateTime())->modify('+30 days'),
+ requireDateInFuture: true
+ ), 'Presence expiry datetime.', true)
+ ->param('metadata', [], new JSON(), 'Presence metadata object.', true)
+ ->inject('response')
+ ->inject('request')
+ ->inject('dbForProject')
+ ->inject('user')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->inject('usage')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $presenceId,
+ ?string $userId,
+ ?string $status,
+ ?array $permissions,
+ ?string $expiresAt,
+ array $metadata,
+ Response $response,
+ Request $request,
+ Database $dbForProject,
+ User $user,
+ Authorization $authorization,
+ Event $queueForEvents,
+ Context $usage
+ ): void {
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
+ if ($userId && !$isAPIKey && !$isPrivilegedUser) {
+ throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, "userId is not allowed for non-API key and non-privileged users");
+ }
+
+ if (($isAPIKey || $isPrivilegedUser) && !$userId) {
+ throw new Exception(Exception::GENERAL_BAD_REQUEST, "userId is required for API key and privileged users");
+ }
+ $userInternalId = null;
+ $resolvedUserId = $userId;
+ if (!$isAPIKey && !$isPrivilegedUser) {
+ $userInternalId = $user->getSequence();
+ $resolvedUserId = $user->getId();
+ } else {
+ $fetchedUser = $dbForProject->getDocument('users', $userId);
+ if ($fetchedUser->isEmpty()) {
+ throw new Exception(Exception::USER_NOT_FOUND, params: [$userId]);
+ }
+
+ $userInternalId = (string) $fetchedUser->getSequence();
+ $resolvedUserId = $fetchedUser->getId();
+ }
+
+ if (empty($userInternalId)) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to resolve valid user internal ID.');
+ }
+ $isGraphQL = $request->getHeader('x-appwrite-source') === 'graphql';
+
+ $presenceData = [
+ 'userInternalId' => $userInternalId,
+ 'userId' => $resolvedUserId,
+ 'status' => $status,
+ 'source' => $isGraphQL ? 'graphql' : 'rest',
+ 'expiresAt' => $expiresAt ?? DateTime::addSeconds(new \DateTime(), 15 * 60),
+ 'metadata' => $metadata,
+ ];
+
+ $presenceState = new PresenceState();
+ $presenceDocument = new Document($presenceData);
+ $ownerOverride = $permissions === null && ($isAPIKey || $isPrivilegedUser)
+ ? $resolvedUserId
+ : null;
+ $presenceState->setPermissions(
+ $presenceDocument,
+ $permissions,
+ $user,
+ $authorization,
+ ownerOverride: $ownerOverride,
+ );
+ $presence = $presenceState->upsertForUser(
+ $dbForProject,
+ $presenceDocument,
+ $presenceId,
+ $userInternalId,
+ fn () => $usage->addMetric(METRIC_USERS_PRESENCE, 1)
+ );
+ $queueForEvents->setParam('presenceId', $presence->getId());
+
+ $response->dynamic($presence, Response::MODEL_PRESENCE);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Usage/Get.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Usage/Get.php
new file mode 100644
index 0000000000..636010e765
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Usage/Get.php
@@ -0,0 +1,120 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/presences/usage')
+ ->desc('Get presence usage')
+ ->groups(['api', 'presences', 'usage'])
+ ->label('scope', 'presences.read')
+ ->label('sdk', new Method(
+ namespace: 'presences',
+ group: null,
+ name: 'getUsage',
+ desc: 'Get presence usage',
+ description: '/docs/references/presences/get-usage.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_USAGE_PRESENCE,
+ ),
+ ],
+ ))
+ ->param('range', '30d', new WhiteList(['24h', '30d', '90d']), 'Date range.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $range,
+ Response $response,
+ Database $dbForProject,
+ Authorization $authorization
+ ): void {
+ $periods = Config::getParam('usage', []);
+ $days = $periods[$range];
+ $metric = METRIC_USERS_PRESENCE;
+ $stats = [
+ 'total' => 0,
+ 'data' => [],
+ ];
+ $hasTotal = false;
+
+ $authorization->skip(function () use ($dbForProject, $days, $metric, &$stats, &$hasTotal): void {
+ $result = $dbForProject->findOne('stats', [
+ Query::equal('metric', [$metric]),
+ Query::equal('period', ['inf']),
+ ]);
+
+ $hasTotal = !$result->isEmpty();
+ $stats['total'] = $result['value'] ?? 0;
+
+ $results = $dbForProject->find('stats', [
+ Query::equal('metric', [$metric]),
+ Query::equal('period', [$days['period']]),
+ Query::limit($days['limit']),
+ Query::orderDesc('time'),
+ ]);
+
+ foreach ($results as $result) {
+ $stats['data'][$result->getAttribute('time')] = [
+ 'value' => $result->getAttribute('value'),
+ ];
+ }
+ });
+
+ if (!$hasTotal && !empty($stats['data'])) {
+ $stats['total'] = \end($stats['data'])['value'] ?? 0;
+ }
+
+ $format = match ($days['period']) {
+ '1h' => 'Y-m-d\TH:00:00.000P',
+ '1d' => 'Y-m-d\T00:00:00.000P',
+ default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']),
+ };
+
+ $usage = [];
+ $leap = time() - ($days['limit'] * $days['factor']);
+ while ($leap < time()) {
+ $leap += $days['factor'];
+ $formatDate = date($format, $leap);
+ $usage[] = [
+ 'value' => $stats['data'][$formatDate]['value'] ?? 0,
+ 'date' => $formatDate,
+ ];
+ }
+
+ $response->dynamic(new Document([
+ 'range' => $range,
+ 'usersOnlineTotal' => $stats['total'],
+ 'presences' => $usage,
+ ]), Response::MODEL_USAGE_PRESENCE);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/XList.php b/src/Appwrite/Platform/Modules/Presences/HTTP/XList.php
new file mode 100644
index 0000000000..94dca8c4ee
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Presences/HTTP/XList.php
@@ -0,0 +1,178 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/presences')
+ ->desc('List presences')
+ ->groups(['api', 'presences'])
+ ->label('scope', 'presences.read')
+ ->label('sdk', new Method(
+ namespace: 'presences',
+ group: 'presences',
+ name: 'list',
+ desc: 'List presences',
+ description: '/docs/references/presences/list.md',
+ auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_PRESENCE_LIST,
+ ),
+ ],
+ ))
+ ->param('queries', [], new PresencesQueries(), 'Array of query strings generated using the Query class provided by the SDK.', true)
+ ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
+ ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for caching list responses. Responses are stored in an in-memory key-value cache, keyed per project, collection, schema version (attributes and indexes), caller authorization roles, and the exact query — so users with different permissions never share cached entries. Schema changes invalidate cached entries automatically; document writes do not, so choose a TTL you are comfortable serving as stale data. Set to 0 to disable caching. Must be between 0 and 86400 (24 hours).', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->callback($this->action(...));
+ }
+
+ public function action(array $queries, bool $includeTotal, int $ttl, Response $response, Database $dbForProject): void
+ {
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $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());
+ }
+
+ $presenceId = $cursor->getValue();
+ $cursorDocument = $dbForProject->getDocument('presenceLogs', $presenceId);
+
+ if ($cursorDocument->isEmpty()) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Presence '{$presenceId}' for the 'cursor' value not found.");
+ }
+
+ $cursor->setValue($cursorDocument);
+ }
+
+ $groupedQueries = Query::groupByType($queries);
+ $filterQueries = $groupedQueries['filters'];
+
+ // should be excluded from the user provided query as user query would be used for caching only
+ // otherwise cache will always miss due to the datetime now
+ $expiryFilter = Query::greaterThan('expiresAt', DateTime::now());
+
+ try {
+ if ((int)$ttl > 0) {
+ $presenceState = new PresenceState();
+ $roles = $dbForProject->getAuthorization()->getRoles();
+
+ $documentsCacheHit = false;
+ $cachedDocuments = $presenceState->getListCacheField(
+ $dbForProject,
+ $roles,
+ $queries,
+ PresenceState::LIST_CACHE_FIELD_PRESENCES,
+ $ttl
+ );
+
+ if ($cachedDocuments !== null &&
+ $cachedDocuments !== false &&
+ \is_array($cachedDocuments)) {
+ $documents = \array_map(function ($doc) {
+ return new Document($doc);
+ }, $cachedDocuments);
+ $documentsCacheHit = true;
+ } else {
+ $documents = $dbForProject->find('presenceLogs', [...$queries, $expiryFilter]);
+ $documentsArray = \array_map(function ($doc) {
+ return $doc->getArrayCopy();
+ }, $documents);
+ $presenceState->setListCacheField(
+ $dbForProject,
+ $roles,
+ $queries,
+ PresenceState::LIST_CACHE_FIELD_PRESENCES,
+ $documentsArray
+ );
+ }
+
+ if ($includeTotal) {
+ $cachedTotal = $presenceState->getListCacheField(
+ $dbForProject,
+ $roles,
+ $filterQueries,
+ PresenceState::LIST_CACHE_FIELD_TOTAL,
+ $ttl
+ );
+ if ($cachedTotal !== null && $cachedTotal !== false) {
+ $total = (int) $cachedTotal;
+ } else {
+ $total = $dbForProject->count('presenceLogs', [...$filterQueries, $expiryFilter], APP_LIMIT_COUNT);
+ $presenceState->setListCacheField(
+ $dbForProject,
+ $roles,
+ $filterQueries,
+ PresenceState::LIST_CACHE_FIELD_TOTAL,
+ $total
+ );
+ }
+ } else {
+ $total = 0;
+ }
+
+ $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss');
+ } else {
+ $documents = $dbForProject->find('presenceLogs', [...$queries, $expiryFilter]);
+ $total = $includeTotal ? $dbForProject->count('presenceLogs', [...$filterQueries, $expiryFilter], 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.");
+ } catch (StructureException $e) {
+ throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, $e->getMessage(), previous: $e);
+ } catch (RelationshipException $e) {
+ throw new Exception(Exception::RELATIONSHIP_VALUE_INVALID, $e->getMessage(), previous: $e);
+ }
+
+ $response->dynamic(new Document([
+ 'presences' => $documents,
+ 'total' => $total,
+ ]), Response::MODEL_PRESENCE_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Presences/Module.php b/src/Appwrite/Platform/Modules/Presences/Module.php
new file mode 100644
index 0000000000..26be38e58c
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Presences/Module.php
@@ -0,0 +1,14 @@
+addService('http', new Http());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Presences/Services/Http.php b/src/Appwrite/Platform/Modules/Presences/Services/Http.php
new file mode 100644
index 0000000000..40aafd6610
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Presences/Services/Http.php
@@ -0,0 +1,27 @@
+type = Service::TYPE_HTTP;
+
+ $this
+ ->addAction(UpsertPresence::getName(), new UpsertPresence())
+ ->addAction(GetUsage::getName(), new GetUsage())
+ ->addAction(GetPresence::getName(), new GetPresence())
+ ->addAction(ListPresences::getName(), new ListPresences())
+ ->addAction(UpdatePresence::getName(), new UpdatePresence())
+ ->addAction(DeletePresence::getName(), new DeletePresence());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php
new file mode 100644
index 0000000000..5335036cde
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php
@@ -0,0 +1,89 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/auth-methods/:methodId')
+ ->httpAlias('/v1/projects/:projectId/auth/:methodId')
+ ->desc('Update project auth method status')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.write')
+ ->label('event', 'authMethod.[methodId].update')
+ ->label('audits.event', 'project.authMethods.[methodId].update')
+ ->label('audits.resource', 'project.authMethods/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: null,
+ name: 'updateAuthMethod',
+ description: <<param('methodId', '', new WhiteList(\array_keys(Config::getParam('auth')), true), 'Auth Method ID. Possible values: ' . implode(',', \array_keys(Config::getParam('auth'))), false)
+ ->param('enabled', null, new Boolean(), 'Auth method status.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $methodId,
+ bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents
+ ): void {
+ $auth = Config::getParam('auth')[$methodId] ?? [];
+ $authKey = $auth['key'] ?? '';
+
+ $auths = $project->getAttribute('auths', []);
+ $auths[$authKey] = $enabled;
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
+ 'auths' => $auths,
+ ])));
+
+ $queueForEvents->setParam('methodId', $methodId);
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php
new file mode 100644
index 0000000000..201061dd62
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php
@@ -0,0 +1,82 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/project')
+ ->httpAlias('/v1/projects/:projectId')
+ ->desc('Delete project')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.write')
+ ->label('audits.event', 'project.delete')
+ ->label('audits.resource', 'project/{project.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: null,
+ name: 'delete',
+ description: <<inject('response')
+ ->inject('dbForPlatform')
+ ->inject('publisherForDeletes')
+ ->inject('authorization')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ Response $response,
+ Database $dbForPlatform,
+ DeletePublisher $publisherForDeletes,
+ Authorization $authorization,
+ Document $project,
+ ) {
+ if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('projects', $project->getId()))) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove project from DB');
+ }
+
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $project,
+ type: DELETE_TYPE_DOCUMENT,
+ document: $project,
+ ));
+
+ $response->noContent();
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Get.php
new file mode 100644
index 0000000000..197d82ef58
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Get.php
@@ -0,0 +1,64 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project')
+ ->httpAlias('/v1/projects/:projectId')
+ ->desc('Get project')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: null,
+ name: 'get',
+ description: <<inject('response')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ Response $response,
+ Document $project,
+ ) {
+ if ($project->isEmpty()) {
+ throw new Exception(Exception::PROJECT_NOT_FOUND);
+ }
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php
index 236c091c31..eebc0a7067 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php
@@ -51,6 +51,8 @@ class Create extends Base
name: 'createKey',
description: <<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: <<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', null, new Range(1, 3600), 'Time in seconds before ephemeral key expires. Maximum duration is 3600 seconds.', optional: false, example: 600)
+ ->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);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php
index 24d1c48cf1..8a3506eb13 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php
@@ -9,6 +9,7 @@ use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
+use Utopia\Database\Validator\Authorization;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Text;
@@ -31,7 +32,7 @@ class Update extends Action
->desc('Update project labels')
->groups(['api', 'project'])
->label('scope', 'project.write')
- ->label('event', 'labels.*.update')
+ // ->label('event', 'project.labels.update')
->label('audits.event', 'project.labels.update')
->label('audits.resource', 'project.labels/{response.$id}')
->label('sdk', new Method(
@@ -53,6 +54,7 @@ class Update extends Action
->inject('response')
->inject('dbForPlatform')
->inject('project')
+ ->inject('authorization')
->callback($this->action(...));
}
@@ -63,11 +65,12 @@ class Update extends Action
array $labels,
Response $response,
Database $dbForPlatform,
- Document $project
+ Document $project,
+ Authorization $authorization
): void {
$labels = (array) \array_values(\array_unique($labels));
- $project = $dbForPlatform->updateDocument('projects', $project->getId(), new Document(['labels' => $labels]));
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document(['labels' => $labels])));
$response->dynamic($project, Response::MODEL_PROJECT);
}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php
new file mode 100644
index 0000000000..f4002c60ef
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php
@@ -0,0 +1,111 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/project/mock-phones')
+ ->desc('Create project mock phone')
+ ->groups(['api', 'project'])
+ ->label('scope', 'mocks.write')
+ ->label('event', 'mock-phones.[number].create')
+ ->label('audits.event', 'project.mock-phone.create')
+ ->label('audits.resource', 'project.mock-phone/{response.number}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'mocks',
+ name: 'createMockPhone',
+ description: <<param('number', null, new Phone(), 'Phone number to associate with the mock phone. Must be a valid E.164 formatted phone number.')
+ ->param('otp', '', new Text(6, 6, Text::NUMBERS), 'One-time password (OTP) to associate with the mock phone. Must be a 6-digit numeric code.')
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $number,
+ string $otp,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Document $project,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ ) {
+ $auths = $project->getAttribute('auths', []);
+
+ $mockNumbers = $auths['mockNumbers'] ?? [];
+
+ if (\count($mockNumbers) >= APP_LIMIT_COUNT) {
+ throw new Exception(Exception::MOCK_NUMBER_LIMIT_EXCEEDED);
+ }
+
+ foreach ($mockNumbers as $mockNumber) {
+ if ($mockNumber['phone'] === $number) {
+ throw new Exception(Exception::MOCK_NUMBER_ALREADY_EXISTS);
+ }
+ }
+
+ // Set to now date
+ $mockNumber = [
+ 'phone' => $number,
+ 'otp' => $otp,
+ '$createdAt' => DateTime::now(),
+ '$updatedAt' => DateTime::now(),
+ ];
+
+ $mockNumbers[] = $mockNumber;
+ $auths['mockNumbers'] = $mockNumbers;
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents->setParam('number', $number);
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_CREATED)
+ ->dynamic(new Document($mockNumber), Response::MODEL_MOCK_NUMBER);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php
new file mode 100644
index 0000000000..0fb23e1764
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php
@@ -0,0 +1,103 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/project/mock-phones/:number')
+ ->desc('Delete project mock phone')
+ ->groups(['api', 'project'])
+ ->label('scope', 'mocks.write')
+ ->label('event', 'mock-phones.[number].delete')
+ ->label('audits.event', 'project.mock-phone.delete')
+ ->label('audits.resource', 'project.mock-phone/{request.number}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'mocks',
+ name: 'deleteMockPhone',
+ description: <<param('number', null, new Phone(), 'Phone number associated with the mock phone. Must be a valid E.164 formatted phone number.')
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $number,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Document $project,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ ) {
+ $auths = $project->getAttribute('auths', []);
+
+ $mockNumbers = $auths['mockNumbers'] ?? [];
+
+ $mockNumberIndex = null;
+ foreach ($mockNumbers as $index => $mock) {
+ if ($mock['phone'] === $number) {
+ $mockNumberIndex = $index;
+ break;
+ }
+ }
+
+ if (\is_null($mockNumberIndex)) {
+ throw new Exception(Exception::MOCK_NUMBER_NOT_FOUND);
+ }
+
+ unset($mockNumbers[$mockNumberIndex]);
+ $mockNumbers = array_values($mockNumbers);
+
+ $auths['mockNumbers'] = $mockNumbers;
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents->setParam('number', $number);
+
+ $response->noContent();
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php
new file mode 100644
index 0000000000..a51095b368
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php
@@ -0,0 +1,78 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/mock-phones/:number')
+ ->desc('Get project mock phone')
+ ->groups(['api', 'project'])
+ ->label('scope', 'mocks.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'mocks',
+ name: 'getMockPhone',
+ description: <<param('number', null, new Phone(), 'Phone number associated with the mock phone. Must be a valid E.164 formatted phone number.')
+ ->inject('response')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $number,
+ Response $response,
+ Document $project
+ ) {
+ $auths = $project->getAttribute('auths', []);
+
+ $mockNumbers = $auths['mockNumbers'] ?? [];
+
+ $mockNumberIndex = null;
+ foreach ($mockNumbers as $index => $mock) {
+ if ($mock['phone'] === $number) {
+ $mockNumberIndex = $index;
+ break;
+ }
+ }
+
+ if (\is_null($mockNumberIndex)) {
+ throw new Exception(Exception::MOCK_NUMBER_NOT_FOUND);
+ }
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_OK)
+ ->dynamic(new Document($mockNumbers[$mockNumberIndex]), Response::MODEL_MOCK_NUMBER);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php
new file mode 100644
index 0000000000..48b90a1b97
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php
@@ -0,0 +1,107 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
+ ->setHttpPath('/v1/project/mock-phones/:number')
+ ->desc('Update project mock phone')
+ ->groups(['api', 'project'])
+ ->label('scope', 'mocks.write')
+ ->label('event', 'mock-phones.[number].update')
+ ->label('audits.event', 'project.mock-phone.update')
+ ->label('audits.resource', 'project.mock-phone/{response.number}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'mocks',
+ name: 'updateMockPhone',
+ description: <<param('number', null, new Phone(), 'Phone number associated with the mock phone. Must be a valid E.164 formatted phone number.')
+ ->param('otp', '', new Text(6, 6, Text::NUMBERS), 'One-time password (OTP) to associate with the mock phone. Must be a 6-digit numeric code.')
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $number,
+ string $otp,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Document $project,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ ) {
+ $auths = $project->getAttribute('auths', []);
+
+ $mockNumbers = $auths['mockNumbers'] ?? [];
+
+ $mockNumberIndex = null;
+ foreach ($mockNumbers as $index => $mock) {
+ if ($mock['phone'] === $number) {
+ $mockNumberIndex = $index;
+ break;
+ }
+ }
+
+ if (\is_null($mockNumberIndex)) {
+ throw new Exception(Exception::MOCK_NUMBER_NOT_FOUND);
+ }
+
+ $mockNumbers[$mockNumberIndex]['otp'] = $otp;
+ $mockNumbers[$mockNumberIndex]['$updatedAt'] = DateTime::now();
+
+ $auths['mockNumbers'] = $mockNumbers;
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents->setParam('number', $number);
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_OK)
+ ->dynamic(new Document($mockNumbers[$mockNumberIndex]), Response::MODEL_MOCK_NUMBER);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php
new file mode 100644
index 0000000000..82aa7f1446
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php
@@ -0,0 +1,87 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/mock-phones')
+ ->desc('List project mock phones')
+ ->groups(['api', 'project'])
+ ->label('scope', 'mocks.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'mocks',
+ name: 'listMockPhones',
+ description: <<param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', 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('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ array $queries,
+ bool $includeTotal,
+ Response $response,
+ Document $project,
+ ) {
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $auths = $project->getAttribute('auths', []);
+ $mockNumbers = $auths['mockNumbers'] ?? [];
+ $grouped = Query::groupByType($queries);
+ $limit = $grouped['limit'] ?? null;
+ $offset = $grouped['offset'] ?? 0;
+
+ $total = $includeTotal ? \count($mockNumbers) : 0;
+ $mockNumbers = \array_slice($mockNumbers, $offset, $limit);
+
+ $mockNumbers = \array_map(fn ($mockNumber) => new Document($mockNumber), $mockNumbers);
+
+ $response->dynamic(new Document([
+ 'mockNumbers' => $mockNumbers,
+ 'total' => $total,
+ ]), Response::MODEL_MOCK_NUMBER_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php
new file mode 100644
index 0000000000..7c68ff4032
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php
@@ -0,0 +1,55 @@
+ static::getClientIdParamName(),
+ 'name' => static::getClientIdName(),
+ 'example' => static::getClientIdExample(),
+ 'hint' => '',
+ ],
+ [
+ '$id' => 'keyId',
+ 'name' => 'Key ID',
+ 'example' => 'P4000000N8',
+ 'hint' => '',
+ ],
+ [
+ '$id' => 'teamId',
+ 'name' => 'Team ID',
+ 'example' => 'D4000000R6',
+ 'hint' => '',
+ ],
+ [
+ '$id' => 'p8File',
+ 'name' => 'P8 File',
+ 'example' => '-----BEGIN PRIVATE KEY-----MIGTAg...jy2Xbna-----END PRIVATE KEY-----',
+ 'hint' => '',
+ ],
+ ];
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param('keyId', null, new Nullable(new Text(256, 0)), '\'Key ID\' of Apple OAuth2 app. For example: P4000000N8', optional: true)
+ ->param('teamId', null, new Nullable(new Text(256, 0)), '\'Team ID\' of Apple OAuth2 app. For example: D4000000R6', optional: true)
+ ->param('p8File', null, new Nullable(new Text(4096, 0)), 'Contents of the Apple OAuth2 app .p8 private key file. The secret key wrapped by the PEM markers is 200 characters long. For example: -----BEGIN PRIVATE KEY-----MIGTAg...jy2Xbna-----END PRIVATE KEY-----', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $storedSecret = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ 'keyId' => $storedSecret['keyID'] ?? '',
+ 'teamId' => $storedSecret['teamID'] ?? '',
+ 'p8File' => '',
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because Apple's
+ * client secret is composed of three fields (.p8 file contents, Key ID and
+ * Team ID) that must be JSON-encoded to match the shape Apple's OAuth2
+ * adapter expects in getAppSecret(). The method is named differently to
+ * avoid an LSP-incompatible override of Base::action().
+ */
+ public function handle(
+ ?string $serviceId,
+ ?string $keyId,
+ ?string $teamId,
+ ?string $p8File,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ // The secret is stored as JSON `{"p8": "...", "keyID": "...", "teamID": "..."}`
+ // to match the shape Apple's OAuth2 adapter expects in getAppSecret().
+ // Merge new values with what's already stored so that submitting only
+ // some of the fields leaves the rest untouched.
+ $encodedSecret = null;
+ if (!\is_null($keyId) || !\is_null($teamId) || !\is_null($p8File)) {
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = [];
+ if (!empty($storedRaw)) {
+ $existing = \json_decode($storedRaw, true) ?: [];
+ }
+ $encodedSecret = \json_encode([
+ 'p8' => $p8File ?? ($existing['p8'] ?? ''),
+ 'keyID' => $keyId ?? ($existing['keyID'] ?? ''),
+ 'teamID' => $teamId ?? ($existing['teamID'] ?? ''),
+ ]);
+ }
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $serviceId, $encodedSecret, $enabled);
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee keyId/teamId/p8File are write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php
new file mode 100644
index 0000000000..aa5f39b213
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php
@@ -0,0 +1,175 @@
+ 'endpoint',
+ 'name' => 'Domain',
+ 'example' => 'example.us.auth0.com',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of Auth0 instance. For example: example.us.auth0.com', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'endpoint' => $decoded['auth0Domain'] ?? '',
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because Auth0
+ * takes an additional optional `endpoint` parameter. The method is named
+ * differently to avoid an LSP-incompatible override of Base::action().
+ */
+ public function handle(
+ ?string $clientId,
+ ?string $clientSecret,
+ ?string $endpoint,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ // The secret is stored as JSON `{"clientSecret": "...", "auth0Domain": "..."}`
+ // to match the shape Auth0's OAuth2 adapter expects (getAuth0Domain()).
+ // Merge new values with existing storage so that submitting only one of
+ // `clientSecret`/`endpoint` leaves the other untouched.
+ $encodedSecret = null;
+ if (!\is_null($clientSecret) || !\is_null($endpoint)) {
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = [];
+ if (!empty($storedRaw)) {
+ $existing = \json_decode($storedRaw, true) ?: [];
+ }
+ $encodedSecret = \json_encode([
+ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
+ 'auth0Domain' => $endpoint ?? ($existing['auth0Domain'] ?? ''),
+ ]);
+ }
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee the clientSecret is write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php
new file mode 100644
index 0000000000..af6b12618a
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php
@@ -0,0 +1,172 @@
+ 'endpoint',
+ 'name' => 'Domain',
+ 'example' => 'example.authentik.com',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of Authentik instance. For example: example.authentik.com', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'endpoint' => $decoded['authentikDomain'] ?? '',
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because Authentik
+ * takes an additional required `endpoint` parameter. The method is named
+ * differently to avoid an LSP-incompatible override of Base::action().
+ */
+ public function handle(
+ ?string $clientId,
+ ?string $clientSecret,
+ ?string $endpoint,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ // The secret is stored as JSON `{"clientSecret": "...", "authentikDomain": "..."}`
+ // to match the shape Authentik's OAuth2 adapter expects (getAuthentikDomain()).
+ // The `endpoint` param is optional; if omitted, the existing stored endpoint is preserved.
+ // `clientSecret` is optional; if omitted, the existing stored secret is preserved.
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = [];
+ if (!empty($storedRaw)) {
+ $existing = \json_decode($storedRaw, true) ?: [];
+ }
+ $encodedSecret = \json_encode([
+ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
+ 'authentikDomain' => $endpoint ?? ($existing['authentikDomain'] ?? ''),
+ ]);
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee the clientSecret is write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php
new file mode 100644
index 0000000000..dd4f4f6faa
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php
@@ -0,0 +1,55 @@
+' of OAuth2 app. For example: [. ]".
+ * Returns an empty string when the name is empty.
+ */
+ private static function buildParamDescription(string $name, string $example, string $hint): string
+ {
+ if ($name === '') {
+ return '';
+ }
+
+ $description = '\'' . $name . '\' of ' . static::getProviderLabel() . ' OAuth2 app. For example: ' . $example;
+ if ($hint !== '') {
+ $description .= '. ' . $hint;
+ }
+
+ return $description;
+ }
+
+ /**
+ * Verbose, user-facing name of the clientId param. Includes alternate
+ * names when the provider exposes more than one (e.g. "Client ID or App
+ * ID", "Application ID (also known as Client ID)").
+ *
+ * @return string
+ */
+ abstract public static function getClientIdName(): string;
+
+ /**
+ * Example value of the clientId param. Used to build the public OAuth2
+ * providers metadata response.
+ *
+ * @return string
+ */
+ abstract public static function getClientIdExample(): string;
+
+ /**
+ * Optional hint for the clientId param. Typically used to call out a
+ * common wrong value (e.g. "Example of wrong value: 370006"). Defaults
+ * to an empty string.
+ */
+ public static function getClientIdHint(): string
+ {
+ return '';
+ }
+
+ /**
+ * Verbose, user-facing name of the clientSecret param. Returns an empty
+ * string for providers that don't have a single clientSecret param
+ * (e.g. Apple uses keyId/teamId/p8File instead).
+ *
+ * @return string
+ */
+ abstract public static function getClientSecretName(): string;
+
+ /**
+ * Example value of the clientSecret param. Returns an empty string for
+ * providers without a clientSecret param.
+ *
+ * @return string
+ */
+ abstract public static function getClientSecretExample(): string;
+
+ /**
+ * Optional hint for the clientSecret param. Defaults to an empty string.
+ */
+ public static function getClientSecretHint(): string
+ {
+ return '';
+ }
+
+ /**
+ * Public-facing parameter metadata for this provider. Used by the public
+ * console OAuth2 providers endpoint to describe the form fields a project
+ * owner must fill in to configure the provider.
+ *
+ * Default shape: clientId + clientSecret. Providers that take additional
+ * fields (Apple, Auth0, Authentik, Gitlab, Microsoft, Oidc, Okta)
+ * override this method to add or replace entries. Each parameter is an
+ * associative array with keys `$id`, `name`, `example`, `hint`.
+ *
+ * @return array>
+ */
+ public static function getParameters(): array
+ {
+ $parameters = [];
+
+ $clientIdName = static::getClientIdName();
+ if ($clientIdName !== '') {
+ $parameters[] = [
+ '$id' => static::getClientIdParamName(),
+ 'name' => $clientIdName,
+ 'example' => static::getClientIdExample(),
+ 'hint' => static::getClientIdHint(),
+ ];
+ }
+
+ $clientSecretName = static::getClientSecretName();
+ if ($clientSecretName !== '') {
+ $parameters[] = [
+ '$id' => static::getClientSecretParamName(),
+ 'name' => $clientSecretName,
+ 'example' => static::getClientSecretExample(),
+ 'hint' => static::getClientSecretHint(),
+ ];
+ }
+
+ return $parameters;
+ }
+
+ /**
+ * Public-facing name of the clientId param. Some providers use a different
+ * terminology (e.g. Dropbox calls it "App key"), so the param name and the
+ * corresponding response field can be customized by overriding this method.
+ *
+ * @return string e.g. 'clientId' (default), 'appKey'
+ */
+ public static function getClientIdParamName(): string
+ {
+ return 'clientId';
+ }
+
+ /**
+ * Public-facing name of the clientSecret param. Some providers use a
+ * different terminology (e.g. Dropbox calls it "App secret"), so the param
+ * name and the corresponding response field can be customized by
+ * overriding this method.
+ *
+ * @return string e.g. 'clientSecret' (default), 'appSecret'
+ */
+ public static function getClientSecretParamName(): string
+ {
+ return 'clientSecret';
+ }
+
+ /**
+ * SDK method name exposed to clients.
+ *
+ * @return string e.g. 'updateOAuth2GitHub'
+ */
+ abstract public static function getProviderSDKMethod(): string;
+
+ public static function getName()
+ {
+ return 'updateProjectOAuth2' . static::getProviderLabel();
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ /**
+ * Registry of provider ID -> Update action class. Mirrors the OAuth2
+ * actions registered in Project\Services\Http. Used by the Get and XList
+ * read endpoints to dispatch per-provider response shaping.
+ *
+ * @return array>
+ */
+ public static function getProviderActions(): array
+ {
+ return [
+ 'github' => GitHub\Update::class,
+ 'discord' => Discord\Update::class,
+ 'figma' => Figma\Update::class,
+ 'dropbox' => Dropbox\Update::class,
+ 'dailymotion' => Dailymotion\Update::class,
+ 'bitbucket' => Bitbucket\Update::class,
+ 'bitly' => Bitly\Update::class,
+ 'box' => Box\Update::class,
+ 'autodesk' => Autodesk\Update::class,
+ 'google' => Google\Update::class,
+ 'zoom' => Zoom\Update::class,
+ 'zoho' => Zoho\Update::class,
+ 'yandex' => Yandex\Update::class,
+ 'x' => X\Update::class,
+ 'wordpress' => WordPress\Update::class,
+ 'twitch' => Twitch\Update::class,
+ 'stripe' => Stripe\Update::class,
+ 'spotify' => Spotify\Update::class,
+ 'slack' => Slack\Update::class,
+ 'podio' => Podio\Update::class,
+ 'notion' => Notion\Update::class,
+ 'salesforce' => Salesforce\Update::class,
+ 'yahoo' => Yahoo\Update::class,
+ 'linkedin' => Linkedin\Update::class,
+ 'disqus' => Disqus\Update::class,
+ 'amazon' => Amazon\Update::class,
+ 'etsy' => Etsy\Update::class,
+ 'facebook' => Facebook\Update::class,
+ 'tradeshift' => Tradeshift\Update::class,
+ 'tradeshiftBox' => TradeshiftSandbox\Update::class,
+ 'paypal' => Paypal\Update::class,
+ 'paypalSandbox' => PaypalSandbox\Update::class,
+ 'gitlab' => Gitlab\Update::class,
+ 'authentik' => Authentik\Update::class,
+ 'auth0' => Auth0\Update::class,
+ 'fusionauth' => FusionAuth\Update::class,
+ 'keycloak' => Keycloak\Update::class,
+ 'oidc' => Oidc\Update::class,
+ 'okta' => Okta\Update::class,
+ 'kick' => Kick\Update::class,
+ 'apple' => Apple\Update::class,
+ 'microsoft' => Microsoft\Update::class,
+ ];
+ }
+
+ /**
+ * Build the read-only response document for this provider, with credential
+ * fields zeroed out (write-only). Default implementation handles providers
+ * that store a plain client ID + client secret. Special providers (Apple,
+ * Gitlab, Auth0, Authentik, Oidc, Okta) override to expose their
+ * non-secret extras (endpoint, domain, discovery URLs, ...) decoded from
+ * the JSON-encoded secret blob.
+ */
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ ]);
+ }
+
+ /**
+ * Decode the JSON-encoded secret blob stored under `{providerId}Secret`.
+ * Returns an empty array when the value is empty or not valid JSON.
+ */
+ protected function decodeStoredSecret(Document $project): array
+ {
+ $providerId = static::getProviderId();
+ $stored = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+
+ if (empty($stored)) {
+ return [];
+ }
+
+ $decoded = \json_decode($stored, true);
+ return \is_array($decoded) ? $decoded : [];
+ }
+
+ /**
+ * Apply the provided credential changes to the project's oAuthProviders map,
+ * run the optional credential verification hook, persist the project, and
+ * return the updated project document.
+ *
+ * Providers that need to serialize multiple values into a single secret
+ * (e.g. GitLab, which stores `{clientSecret, endpoint}` as JSON) should
+ * encode those values into `$clientSecret` before calling this method.
+ */
+ protected function persistCredentials(
+ Document $project,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ ?string $clientId,
+ ?string $clientSecret,
+ ?bool $enabled
+ ): Document {
+ $providerId = static::getProviderId();
+ if (!(\in_array($providerId, \array_keys(Config::getParam('oAuthProviders'))))) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Provider ' . $providerId . ' is not supported by server configuration.');
+ }
+
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+
+ $appIdKey = $providerId . 'Appid';
+ $appSecretKey = $providerId . 'Secret';
+ $enabledKey = $providerId . 'Enabled';
+
+ if (!\is_null($clientId)) {
+ $oAuthProviders[$appIdKey] = $clientId;
+ }
+
+ if (!\is_null($clientSecret)) {
+ $oAuthProviders[$appSecretKey] = $clientSecret;
+ }
+
+ if (!\is_null($enabled)) {
+ $oAuthProviders[$enabledKey] = $enabled;
+ }
+
+ if ($enabled === true || \is_null($enabled)) {
+ try {
+ if (empty($oAuthProviders[$appIdKey]) || empty($oAuthProviders[$appSecretKey])) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Client ID and Client Secret are required when enabling OAuth2 provider.');
+ }
+
+ $providerClass = static::getProviderClass();
+ $providerInstance = new $providerClass(appId: $oAuthProviders[$appIdKey], appSecret: $oAuthProviders[$appSecretKey], callback: '', state: [], scopes: []);
+
+ // E2E integration check
+ if (\method_exists($providerInstance, 'verifyCredentials')) {
+ $providerInstance->verifyCredentials();
+ }
+
+ $oAuthProviders[$enabledKey] = true;
+ } catch (\Throwable $err) {
+ if ($enabled === true) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Could not enable OAuth2 provider: ' . $err->getMessage());
+ }
+ }
+ }
+
+ $updates = new Document([
+ 'oAuthProviders' => $oAuthProviders
+ ]);
+
+ return $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+ }
+
+ public function action(
+ ?string $clientId,
+ ?string $clientSecret,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $clientSecret, $enabled);
+
+ $queueForEvents->setParam('providerId', static::getProviderId());
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee the clientSecret is write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php
new file mode 100644
index 0000000000..a477bfbefb
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php
@@ -0,0 +1,65 @@
+ 'endpoint',
+ 'name' => 'Domain',
+ 'example' => 'example.fusionauth.io',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of FusionAuth instance. For example: example.fusionauth.io', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'endpoint' => $decoded['fusionAuthDomain'] ?? '',
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because FusionAuth
+ * takes an additional required `endpoint` parameter. The method is named
+ * differently to avoid an LSP-incompatible override of Base::action().
+ */
+ public function handle(
+ ?string $clientId,
+ ?string $clientSecret,
+ ?string $endpoint,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ // The secret is stored as JSON `{"clientSecret": "...", "fusionAuthDomain": "..."}`
+ // to match the shape FusionAuth's OAuth2 adapter expects (getFusionAuthDomain()).
+ // The `endpoint` param is optional; if omitted, the existing stored endpoint is preserved.
+ // `clientSecret` is optional; if omitted, the existing stored secret is preserved.
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = [];
+ if (!empty($storedRaw)) {
+ $existing = \json_decode($storedRaw, true) ?: [];
+ }
+ $encodedSecret = \json_encode([
+ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
+ 'fusionAuthDomain' => $endpoint ?? ($existing['fusionAuthDomain'] ?? ''),
+ ]);
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee the clientSecret is write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php
new file mode 100644
index 0000000000..250a3e5df1
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php
@@ -0,0 +1,115 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/oauth2/:provider')
+ ->desc('Get project OAuth2 provider')
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: 'getOAuth2Provider',
+ description: <<param('providerId', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders', [])), true), 'OAuth2 provider key. For example: github, google, apple.', aliases: ['provider'])
+ ->inject('response')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $providerId,
+ Response $response,
+ Document $project,
+ ): void {
+ $providers = Config::getParam('oAuthProviders', []);
+ if (!\array_key_exists($providerId, $providers) || !($providers[$providerId]['enabled'] ?? false)) {
+ throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED);
+ }
+
+ $actions = Base::getProviderActions();
+ if (!isset($actions[$providerId])) {
+ throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED);
+ }
+
+ $updateClass = $actions[$providerId];
+ $action = new $updateClass();
+
+ $response->dynamic($action->buildReadResponse($project), $updateClass::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php
new file mode 100644
index 0000000000..7c680e5141
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php
@@ -0,0 +1,60 @@
+ 'endpoint',
+ 'name' => 'Endpoint',
+ 'example' => 'https://gitlab.com',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('endpoint', null, new Nullable(new URL(allowEmpty: true)), 'Endpoint URL of self-hosted GitLab instance. For example: https://gitlab.com', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'endpoint' => $decoded['endpoint'] ?? '',
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because Gitlab
+ * takes an additional `endpoint` parameter. The method is named
+ * differently to avoid an LSP-incompatible override of Base::action().
+ */
+ public function handle(
+ ?string $applicationId,
+ ?string $secret,
+ ?string $endpoint,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ // The secret is stored as JSON `{"clientSecret": "...", "endpoint": "..."}`
+ // so that the Gitlab OAuth2 adapter can extract the endpoint via getEndpoint().
+ // Merge the new values with what's already stored so that submitting only
+ // one of `secret`/`endpoint` leaves the other untouched.
+ $encodedSecret = null;
+ if (!\is_null($secret) || !\is_null($endpoint)) {
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = [];
+ if (!empty($storedRaw)) {
+ $existing = \json_decode($storedRaw, true) ?: [];
+ }
+ $encodedSecret = \json_encode([
+ 'clientSecret' => $secret ?? ($existing['clientSecret'] ?? ''),
+ 'endpoint' => $endpoint ?? ($existing['endpoint'] ?? ''),
+ ]);
+ }
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $applicationId, $encodedSecret, $enabled);
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee the secret is write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php
new file mode 100644
index 0000000000..2a061d09ce
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php
@@ -0,0 +1,183 @@
+ 'prompt',
+ 'name' => 'Prompt',
+ 'example' => '["consent"]',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('prompt', null, new Nullable(new ArrayList(new WhiteList(['none', 'consent', 'select_account'], true), 3)), 'Array of Google OAuth2 prompt values. If "none" is included, it must be the only element. "none" means: don\'t display any authentication or consent screens. Must not be specified with other values. "consent" means: prompt the user for consent. "select_account" means: prompt the user to select an account.', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'prompt' => $decoded['prompt'] ?? ['consent'],
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because Google
+ * takes an additional optional `prompt` parameter. The method is named
+ * differently to avoid an LSP-incompatible override of Base::action().
+ */
+ public function handle(
+ ?string $clientId,
+ ?string $clientSecret,
+ ?array $prompt,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ if ($prompt !== null) {
+ if (empty($prompt)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Prompt array cannot be empty.');
+ }
+
+ if (\in_array('none', $prompt) && \count($prompt) > 1) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When "none" is used as a prompt value, it must be the only element in the array.');
+ }
+ }
+
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = $this->decodeStoredSecret($project);
+
+ // Backwards compatibility: secrets stored before the prompt feature
+ // were saved as plain strings. Treat the raw value as clientSecret.
+ if (!empty($storedRaw) && empty($existing)) {
+ $existing = ['clientSecret' => $storedRaw];
+ }
+
+ $encodedSecret = \json_encode([
+ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
+ 'prompt' => $prompt ?? ($existing['prompt'] ?? ['consent']),
+ ]);
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
+
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php
new file mode 100644
index 0000000000..aa41e8a5e9
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php
@@ -0,0 +1,183 @@
+ 'endpoint',
+ 'name' => 'Domain',
+ 'example' => 'keycloak.example.com',
+ 'hint' => '',
+ ],
+ [
+ '$id' => 'realmName',
+ 'name' => 'Realm name',
+ 'example' => 'appwrite-realm',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of Keycloak instance. For example: keycloak.example.com', optional: true)
+ ->param('realmName', null, new Nullable(new Text(256, 0)), 'Keycloak realm name. For example: appwrite-realm', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'endpoint' => $decoded['keycloakDomain'] ?? '',
+ 'realmName' => $decoded['keycloakRealm'] ?? '',
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because Keycloak
+ * takes additional required `endpoint` and `realmName` parameters. The
+ * method is named differently to avoid an LSP-incompatible override of
+ * Base::action().
+ */
+ public function handle(
+ ?string $clientId,
+ ?string $clientSecret,
+ ?string $endpoint,
+ ?string $realmName,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ // The secret is stored as JSON `{"clientSecret": "...", "keycloakDomain": "...", "keycloakRealm": "..."}`
+ // to match the shape Keycloak's OAuth2 adapter expects (getKeycloakDomain(), getKeycloakRealm()).
+ // The `endpoint` and `realmName` params are optional; if omitted, existing stored values are preserved.
+ // `clientSecret` is optional; if omitted, the existing stored secret is preserved.
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = [];
+ if (!empty($storedRaw)) {
+ $existing = \json_decode($storedRaw, true) ?: [];
+ }
+ $encodedSecret = \json_encode([
+ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
+ 'keycloakDomain' => $endpoint ?? ($existing['keycloakDomain'] ?? ''),
+ 'keycloakRealm' => $realmName ?? ($existing['keycloakRealm'] ?? ''),
+ ]);
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee the clientSecret is write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php
new file mode 100644
index 0000000000..db4a20174f
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php
@@ -0,0 +1,55 @@
+ 'tenant',
+ 'name' => 'Tenant',
+ 'example' => 'common',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('tenant', null, new Nullable(new Text(256, 0)), 'Microsoft Entra ID tenant identifier. Use \'common\', \'organizations\', \'consumers\' or a specific tenant ID. For example: common', true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'tenant' => $decoded['tenantID'] ?? '',
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because Microsoft
+ * takes an additional required `tenant` parameter. The method is named
+ * differently to avoid an LSP-incompatible override of Base::action().
+ */
+ public function handle(
+ ?string $applicationId,
+ ?string $applicationSecret,
+ ?string $tenant,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ // The secret is stored as JSON `{"clientSecret": "...", "tenantID": "..."}`
+ // to match the shape Microsoft's OAuth2 adapter expects (getTenantID()).
+ // The `tenant` param is optional; if omitted, the existing stored tenant is preserved.
+ // `applicationSecret` is optional; if omitted, the existing stored secret is preserved.
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = [];
+ if (!empty($storedRaw)) {
+ $existing = \json_decode($storedRaw, true) ?: [];
+ }
+ $encodedSecret = \json_encode([
+ 'clientSecret' => $applicationSecret ?? ($existing['clientSecret'] ?? ''),
+ 'tenantID' => $tenant ?? ($existing['tenantID'] ?? ''),
+ ]);
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $applicationId, $encodedSecret, $enabled);
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee the applicationSecret is write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php
new file mode 100644
index 0000000000..4b048b0c0b
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php
@@ -0,0 +1,65 @@
+ 'wellKnownURL',
+ 'name' => 'Well-known URL',
+ 'example' => 'https://myoauth.com/.well-known/openid-configuration',
+ 'hint' => '',
+ ],
+ [
+ '$id' => 'authorizationURL',
+ 'name' => 'Authorization URL',
+ 'example' => 'https://myoauth.com/oauth2/authorize',
+ 'hint' => '',
+ ],
+ [
+ '$id' => 'tokenURL',
+ 'name' => 'Token URL',
+ 'example' => 'https://myoauth.com/oauth2/token',
+ 'hint' => '',
+ ],
+ [
+ '$id' => 'userInfoURL',
+ 'name' => 'User Info URL',
+ 'example' => 'https://myoauth.com/oauth2/userinfo',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('wellKnownURL', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect well-known configuration URL. When provided, authorization, token, and user info endpoints can be discovered automatically. For example: https://myoauth.com/.well-known/openid-configuration', optional: true)
+ ->param('authorizationURL', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect authorization endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/authorize', optional: true)
+ ->param('tokenURL', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect token endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/token', optional: true, aliases: ['tokenUrl'])
+ ->param('userInfoURL', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect user info endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/userinfo', optional: true, aliases: ['userInfoUrl'])
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'wellKnownURL' => $decoded['wellKnownEndpoint'] ?? '',
+ 'authorizationURL' => $decoded['authorizationEndpoint'] ?? '',
+ 'tokenURL' => $decoded['tokenEndpoint'] ?? '',
+ 'userInfoURL' => $decoded['userInfoEndpoint'] ?? '',
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because OIDC takes
+ * a well-known URL plus three discovery URLs (authorization, token, user
+ * info), all stored together with the client secret as JSON. The method is
+ * named differently to avoid an LSP-incompatible override of Base::action().
+ *
+ * Enabling the provider requires either a non-empty `wellKnownEndpoint`,
+ * or all three of `authorizationEndpoint`, `tokenEndpoint`, and
+ * `userInfoEndpoint` to be set. The check considers the merged state of
+ * existing stored values plus the new values from the request, so callers
+ * can enable the provider in a single request without re-sending fields
+ * that were configured previously.
+ */
+ public function handle(
+ ?string $clientId,
+ ?string $clientSecret,
+ ?string $wellKnownURL,
+ ?string $authorizationURL,
+ ?string $tokenURL,
+ ?string $userInfoURL,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ // The secret is stored as JSON
+ // `{"clientSecret": "...", "wellKnownEndpoint": "...", "authorizationEndpoint": "...", "tokenEndpoint": "...", "userInfoEndpoint": "..."}`
+ // so that the OIDC OAuth2 adapter can extract each endpoint individually.
+ // Merge new values with what's already stored so that submitting only a
+ // subset of fields leaves the others untouched.
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = [];
+ if (!empty($storedRaw)) {
+ $existing = \json_decode($storedRaw, true) ?: [];
+ }
+
+ $merged = [
+ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
+ 'wellKnownEndpoint' => $wellKnownURL ?? ($existing['wellKnownEndpoint'] ?? ''),
+ 'authorizationEndpoint' => $authorizationURL ?? ($existing['authorizationEndpoint'] ?? ''),
+ 'tokenEndpoint' => $tokenURL ?? ($existing['tokenEndpoint'] ?? ''),
+ 'userInfoEndpoint' => $userInfoURL ?? ($existing['userInfoEndpoint'] ?? ''),
+ ];
+
+ // When enabling, require either wellKnownEndpoint alone, or all three
+ // discovery URLs (authorization, token, user info). Skip this check
+ // when disabling or when leaving the enabled flag unchanged.
+ if ($enabled === true) {
+ $hasWellKnown = !empty($merged['wellKnownEndpoint']);
+ $hasAllDiscovery = !empty($merged['authorizationEndpoint'])
+ && !empty($merged['tokenEndpoint'])
+ && !empty($merged['userInfoEndpoint']);
+
+ if (!$hasWellKnown && !$hasAllDiscovery) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Enabling OpenID Connect requires either wellKnownURL, or all of authorizationURL, tokenURL, and userInfoURL.');
+ }
+ }
+
+ $encodedSecret = \json_encode($merged);
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee the clientSecret is write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php
new file mode 100644
index 0000000000..0344b6a14a
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php
@@ -0,0 +1,198 @@
+ 'domain',
+ 'name' => 'Domain',
+ 'example' => 'trial-6400025.okta.com',
+ 'hint' => 'Example of wrong value: trial-6400025-admin.okta.com, or https://trial-6400025.okta.com/',
+ ],
+ [
+ '$id' => 'authorizationServerId',
+ 'name' => 'Authorization Server ID',
+ 'example' => 'aus000000000000000h7z',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('domain', null, new Nullable(new ValidatorDomain(allowEmpty: true)), 'Okta company domain. Required when enabling the provider. For example: trial-6400025.okta.com. Example of wrong value: trial-6400025-admin.okta.com, or https://trial-6400025.okta.com/', optional: true)
+ ->param('authorizationServerId', null, new Nullable(new Text(256, 0)), 'Custom Authorization Servers. Optional, can be left empty or unconfigured. For example: aus000000000000000h7z', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'domain' => $decoded['oktaDomain'] ?? '',
+ 'authorizationServerId' => $decoded['authorizationServerId'] ?? '',
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because Okta
+ * takes additional optional `domain` and `authorizationServerId` parameters.
+ * The method is named differently to avoid an LSP-incompatible override of
+ * Base::action().
+ */
+ public function handle(
+ ?string $clientId,
+ ?string $clientSecret,
+ ?string $domain,
+ ?string $authorizationServerId,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ // The secret is stored as JSON `{"clientSecret": "...", "oktaDomain": "...", "authorizationServerId": "..."}`
+ // to match the shape Okta's OAuth2 adapter expects.
+ // Merge new values with existing storage so that submitting only some of
+ // the parameters leaves the others untouched.
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = [];
+ if (!empty($storedRaw)) {
+ $existing = \json_decode($storedRaw, true) ?: [];
+ }
+
+ $encodedSecret = null;
+ if (!\is_null($clientSecret) || !\is_null($domain) || !\is_null($authorizationServerId)) {
+ $encodedSecret = \json_encode([
+ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
+ 'oktaDomain' => $domain ?? ($existing['oktaDomain'] ?? ''),
+ 'authorizationServerId' => $authorizationServerId ?? ($existing['authorizationServerId'] ?? ''),
+ ]);
+ }
+
+ // Domain is required when enabling the provider, since Okta builds its
+ // authorization, token and userinfo URLs from it.
+ if ($enabled === true) {
+ $effectiveDomain = $domain ?? ($existing['oktaDomain'] ?? '');
+ if (empty($effectiveDomain)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Domain is required when enabling Okta OAuth2 provider.');
+ }
+ }
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee the clientSecret is write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php
new file mode 100644
index 0000000000..87b4e1576b
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php
@@ -0,0 +1,60 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/oauth2')
+ ->desc('List project OAuth2 providers')
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: 'listOAuth2Providers',
+ description: <<param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', 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('project')
+ ->callback($this->action(...));
+ }
+
+ /**
+ * @param array $queries
+ */
+ public function action(
+ array $queries,
+ bool $includeTotal,
+ Response $response,
+ Document $project,
+ ): void {
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $providers = Config::getParam('oAuthProviders', []);
+ $actions = Base::getProviderActions();
+
+ $documents = [];
+ foreach ($actions as $providerId => $updateClass) {
+ if (!($providers[$providerId]['enabled'] ?? false)) {
+ // Disabled by Appwrite configuration, exclude from response
+ continue;
+ }
+
+ $action = new $updateClass();
+ $documents[] = $action->buildReadResponse($project);
+ }
+
+ $total = $includeTotal ? \count($documents) : 0;
+
+ $grouped = Query::groupByType($queries);
+ $offset = $grouped['offset'] ?? 0;
+ $limit = $grouped['limit'] ?? null;
+
+ $documents = \array_slice($documents, $offset, $limit);
+
+ $response->dynamic(new Document([
+ 'total' => $total,
+ 'providers' => $documents,
+ ]), Response::MODEL_OAUTH2_PROVIDER_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php
new file mode 100644
index 0000000000..45cf1f5a66
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php
@@ -0,0 +1,55 @@
+label('scope', 'platforms.write')
->label('event', 'platforms.[platformId].delete')
->label('audits.event', 'project.platform.delete')
- ->label('audits.resource', 'project.platform/{response.$id}')
+ ->label('audits.resource', 'project.platform/{request.platformId}')
->label('sdk', new Method(
namespace: 'project',
group: 'platforms',
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php
index 2fca0ace6c..6c07727150 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php
@@ -139,7 +139,7 @@ class Create extends Action
if (empty($key) && empty($type)) {
// Modern request, validate hostname
if (empty($hostname)) {
- throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "hostname" is not optional.');
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "hostname" is not optional.');
}
}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php
new file mode 100644
index 0000000000..21342332d9
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php
@@ -0,0 +1,152 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/policies/:policyId')
+ ->desc('Get project policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.read', 'project.policies.read'])
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'getPolicy',
+ description: <<param('policyId', '', new WhiteList([
+ 'password-dictionary',
+ 'password-history',
+ 'password-personal-data',
+ 'session-alert',
+ 'session-duration',
+ 'session-invalidation',
+ 'session-limit',
+ 'user-limit',
+ 'membership-privacy',
+ ], true), 'Policy ID. Can be one of: password-dictionary, password-history, password-personal-data, session-alert, session-duration, session-invalidation, session-limit, user-limit, membership-privacy.')
+ ->inject('response')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $policyId,
+ Response $response,
+ Document $project,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+
+ [$policy, $model] = match ($policyId) {
+ 'password-dictionary' => [
+ new Document([
+ '$id' => 'password-dictionary',
+ 'enabled' => $auths['passwordDictionary'] ?? false,
+ ]),
+ Response::MODEL_POLICY_PASSWORD_DICTIONARY,
+ ],
+ 'password-history' => [
+ new Document([
+ '$id' => 'password-history',
+ 'total' => $auths['passwordHistory'] ?? 0,
+ ]),
+ Response::MODEL_POLICY_PASSWORD_HISTORY,
+ ],
+ 'password-personal-data' => [
+ new Document([
+ '$id' => 'password-personal-data',
+ 'enabled' => $auths['personalDataCheck'] ?? false,
+ ]),
+ Response::MODEL_POLICY_PASSWORD_PERSONAL_DATA,
+ ],
+ 'session-alert' => [
+ new Document([
+ '$id' => 'session-alert',
+ 'enabled' => $auths['sessionAlerts'] ?? false,
+ ]),
+ Response::MODEL_POLICY_SESSION_ALERT,
+ ],
+ 'session-duration' => [
+ new Document([
+ '$id' => 'session-duration',
+ 'duration' => $auths['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG,
+ ]),
+ Response::MODEL_POLICY_SESSION_DURATION,
+ ],
+ 'session-invalidation' => [
+ new Document([
+ '$id' => 'session-invalidation',
+ 'enabled' => $auths['invalidateSessions'] ?? true,
+ ]),
+ Response::MODEL_POLICY_SESSION_INVALIDATION,
+ ],
+ 'session-limit' => [
+ new Document([
+ '$id' => 'session-limit',
+ 'total' => $auths['maxSessions'] ?? 0,
+ ]),
+ Response::MODEL_POLICY_SESSION_LIMIT,
+ ],
+ 'user-limit' => [
+ new Document([
+ '$id' => 'user-limit',
+ 'total' => $auths['limit'] ?? 0,
+ ]),
+ Response::MODEL_POLICY_USER_LIMIT,
+ ],
+ 'membership-privacy' => [
+ new Document([
+ '$id' => 'membership-privacy',
+ 'userId' => $auths['membershipsUserId'] ?? false,
+ 'userEmail' => $auths['membershipsUserEmail'] ?? false,
+ 'userPhone' => $auths['membershipsUserPhone'] ?? false,
+ 'userName' => $auths['membershipsUserName'] ?? false,
+ 'userMFA' => $auths['membershipsMfa'] ?? false,
+ ]),
+ Response::MODEL_POLICY_MEMBERSHIP_PRIVACY,
+ ],
+ default => throw new \LogicException('Unknown policy ID: ' . $policyId),
+ };
+
+ $response->dynamic($policy, $model);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php
new file mode 100644
index 0000000000..41a6168b07
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php
@@ -0,0 +1,108 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/policies/membership-privacy')
+ ->httpAlias('/v1/projects/:projectId/auth/memberships-privacy')
+ ->desc('Update membership privacy policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.write', 'project.policies.write'])
+ ->label('event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'updateMembershipPrivacyPolicy',
+ description: <<param('userId', null, new Boolean(), 'Set to true if you want make user ID visible to all team members, or false to hide it.', optional: true)
+ ->param('userEmail', null, new Boolean(), 'Set to true if you want make user email visible to all team members, or false to hide it.', optional: true)
+ ->param('userPhone', null, new Boolean(), 'Set to true if you want make user phone number visible to all team members, or false to hide it.', optional: true)
+ ->param('userName', null, new Boolean(), 'Set to true if you want make user name visible to all team members, or false to hide it.', optional: true)
+ ->param('userMFA', null, new Boolean(), 'Set to true if you want make user MFA status visible to all team members, or false to hide it.', optional: true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ ?bool $userId,
+ ?bool $userEmail,
+ ?bool $userPhone,
+ ?bool $userName,
+ ?bool $userMFA,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+
+ if ($userId !== null) {
+ $auths['membershipsUserId'] = $userId;
+ }
+ if ($userEmail !== null) {
+ $auths['membershipsUserEmail'] = $userEmail;
+ }
+ if ($userPhone !== null) {
+ $auths['membershipsUserPhone'] = $userPhone;
+ }
+ if ($userName !== null) {
+ $auths['membershipsUserName'] = $userName;
+ }
+ if ($userMFA !== null) {
+ $auths['membershipsMfa'] = $userMFA;
+ }
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents
+ ->setParam('projectId', $project->getId())
+ ->setParam('policy', 'membership-privacy');
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php
new file mode 100644
index 0000000000..d7ee99fbfe
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php
@@ -0,0 +1,85 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/policies/password-dictionary')
+ ->httpAlias('/v1/projects/:projectId/auth/password-dictionary')
+ ->desc('Update password dictionary policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.write', 'project.policies.write'])
+ ->label('event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'updatePasswordDictionaryPolicy',
+ description: <<param('enabled', null, new Boolean(), 'Toggle password dictionary policy. Set to true if you want password change to block passwords in the dictionary, or false to allow them. When changing this policy, existing passwords remain valid.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+ $auths['passwordDictionary'] = $enabled;
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents
+ ->setParam('projectId', $project->getId())
+ ->setParam('policy', 'password-dictionary');
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php
new file mode 100644
index 0000000000..84861a19e1
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php
@@ -0,0 +1,93 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/policies/password-history')
+ ->httpAlias('/v1/projects/:projectId/auth/password-history')
+ ->desc('Update password history policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.write', 'project.policies.write'])
+ ->label('event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'updatePasswordHistoryPolicy',
+ description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the password history length per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ ?int $total,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+
+ if (\is_null($total)) {
+ $auths['passwordHistory'] = 0;
+ } else {
+ $auths['passwordHistory'] = $total;
+ }
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents
+ ->setParam('projectId', $project->getId())
+ ->setParam('policy', 'password-history');
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php
new file mode 100644
index 0000000000..435f00fc39
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php
@@ -0,0 +1,86 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/policies/password-personal-data')
+ ->httpAlias('/v1/projects/:projectId/auth/personal-data')
+ ->desc('Update password personal data policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.write', 'project.policies.write'])
+ ->label('event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'updatePasswordPersonalDataPolicy',
+ description: <<param('enabled', null, new Boolean(), 'Toggle password personal data policy. Set to true if you want to block passwords including user\'s personal data, or false to allow it. When changing this policy, existing passwords remain valid.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+ $auths['personalDataCheck'] = $enabled;
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents
+ ->setParam('projectId', $project->getId())
+ ->setParam('policy', 'password-personal-data');
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php
new file mode 100644
index 0000000000..79653d46ad
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php
@@ -0,0 +1,85 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/policies/session-alert')
+ ->httpAlias('/v1/projects/:projectId/auth/session-alerts')
+ ->desc('Update session alert policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.write', 'project.policies.write'])
+ ->label('event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'updateSessionAlertPolicy',
+ description: <<param('enabled', null, new Boolean(), 'Toggle session alert policy. Set to true if you want users to receive email notifications when a sessions are created for their users, or false to not send email alerts.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+ $auths['sessionAlerts'] = $enabled;
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents
+ ->setParam('projectId', $project->getId())
+ ->setParam('policy', 'session-alert');
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php
new file mode 100644
index 0000000000..0a7f33218a
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php
@@ -0,0 +1,85 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/policies/session-duration')
+ ->httpAlias('/v1/projects/:projectId/auth/duration')
+ ->desc('Update session duration policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.write', 'project.policies.write'])
+ ->label('event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'updateSessionDurationPolicy',
+ description: <<param('duration', null, new Range(5, 31536000), 'Maximum session length in seconds. Minium allowed value is 5 second, and maximum is 1 year, which is 31536000 seconds.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ int $duration,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+ $auths['duration'] = $duration;
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents
+ ->setParam('projectId', $project->getId())
+ ->setParam('policy', 'session-duration');
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php
new file mode 100644
index 0000000000..a1feb67346
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php
@@ -0,0 +1,85 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/policies/session-invalidation')
+ ->httpAlias('/v1/projects/:projectId/auth/session-invalidation')
+ ->desc('Update session invalidation policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.write', 'project.policies.write'])
+ ->label('event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'updateSessionInvalidationPolicy',
+ description: <<param('enabled', null, new Boolean(), 'Toggle session invalidation policy. Set to true if you want password change to invalidate all sessions of an user, or false to keep sessions active.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+ $auths['invalidateSessions'] = $enabled;
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents
+ ->setParam('projectId', $project->getId())
+ ->setParam('policy', 'session-invalidation');
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php
new file mode 100644
index 0000000000..936a541249
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php
@@ -0,0 +1,91 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/policies/session-limit')
+ ->httpAlias('/v1/projects/:projectId/auth/max-sessions')
+ ->desc('Update session limit policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.write', 'project.policies.write'])
+ ->label('event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'updateSessionLimitPolicy',
+ description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the maximum number of sessions allowed per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ ?int $total,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+
+ if (\is_null($total)) {
+ $auths['maxSessions'] = 0;
+ } else {
+ $auths['maxSessions'] = $total;
+ }
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents
+ ->setParam('projectId', $project->getId())
+ ->setParam('policy', 'session-limit');
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php
new file mode 100644
index 0000000000..2b7e704853
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php
@@ -0,0 +1,91 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/policies/user-limit')
+ ->httpAlias('/v1/projects/:projectId/auth/limit')
+ ->desc('Update user limit policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.write', 'project.policies.write'])
+ ->label('event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'updateUserLimitPolicy',
+ description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the maximum number of users allowed in the project. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ ?int $total,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+
+ if (\is_null($total)) {
+ $auths['limit'] = 0;
+ } else {
+ $auths['limit'] = $total;
+ }
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents
+ ->setParam('projectId', $project->getId())
+ ->setParam('policy', 'user-limit');
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php
new file mode 100644
index 0000000000..3020fa79dd
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php
@@ -0,0 +1,132 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/policies')
+ ->desc('List project policies')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.read', 'project.policies.read'])
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'listPolicies',
+ description: <<param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', 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('project')
+ ->callback($this->action(...));
+ }
+
+ /**
+ * @param array $queries
+ */
+ public function action(
+ array $queries,
+ bool $includeTotal,
+ Response $response,
+ Document $project,
+ ) {
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $auths = $project->getAttribute('auths', []);
+
+ $policies = [
+ new Document([
+ '$id' => 'password-dictionary',
+ 'enabled' => $auths['passwordDictionary'] ?? false,
+ ]),
+ new Document([
+ '$id' => 'password-history',
+ 'total' => $auths['passwordHistory'] ?? 0,
+ ]),
+ new Document([
+ '$id' => 'password-personal-data',
+ 'enabled' => $auths['personalDataCheck'] ?? false,
+ ]),
+ new Document([
+ '$id' => 'session-alert',
+ 'enabled' => $auths['sessionAlerts'] ?? false,
+ ]),
+ new Document([
+ '$id' => 'session-duration',
+ 'duration' => $auths['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG,
+ ]),
+ new Document([
+ '$id' => 'session-invalidation',
+ 'enabled' => $auths['invalidateSessions'] ?? true,
+ ]),
+ new Document([
+ '$id' => 'session-limit',
+ 'total' => $auths['maxSessions'] ?? 0,
+ ]),
+ new Document([
+ '$id' => 'user-limit',
+ 'total' => $auths['limit'] ?? 0,
+ ]),
+ new Document([
+ '$id' => 'membership-privacy',
+ 'userId' => $auths['membershipsUserId'] ?? false,
+ 'userEmail' => $auths['membershipsUserEmail'] ?? false,
+ 'userPhone' => $auths['membershipsUserPhone'] ?? false,
+ 'userName' => $auths['membershipsUserName'] ?? false,
+ 'userMFA' => $auths['membershipsMfa'] ?? false,
+ ]),
+ ];
+
+ $total = $includeTotal ? \count($policies) : 0;
+
+ $grouped = Query::groupByType($queries);
+ $offset = $grouped['offset'] ?? 0;
+ $limit = $grouped['limit'] ?? null;
+
+ $policies = \array_slice($policies, $offset, $limit);
+
+ $response->dynamic(new Document([
+ 'policies' => $policies,
+ 'total' => $total,
+ ]), Response::MODEL_POLICY_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Update.php
similarity index 80%
rename from src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php
rename to src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Update.php
index 1fa2df3566..ad5691c1e0 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Update.php
@@ -1,7 +1,8 @@
setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
- ->setHttpPath('/v1/project/protocols/:protocolId/status')
+ ->setHttpPath('/v1/project/protocols/:protocolId')
+ ->httpAlias('/v1/project/protocols/:protocolId/status')
->httpAlias('/v1/projects/:projectId/api')
- ->desc('Update project protocol status')
+ ->desc('Update project protocol')
->groups(['api', 'project'])
->label('scope', 'project.write')
- ->label('event', 'protocols.[protocol].update')
- ->label('audits.event', 'project.protocols.[protocol].update')
+ ->label('event', 'protocols.[protocolId].update')
+ ->label('audits.event', 'project.protocols.[protocolId].update')
->label('audits.resource', 'project.protocols/{response.$id}')
->label('sdk', new Method(
namespace: 'project',
group: null,
- name: 'updateProtocolStatus',
+ name: 'updateProtocol',
description: <<inject('dbForPlatform')
->inject('project')
->inject('authorization')
+ ->inject('queueForEvents')
->callback($this->action(...));
}
@@ -66,7 +69,8 @@ class Update extends Action
Response $response,
Database $dbForPlatform,
Document $project,
- Authorization $authorization
+ Authorization $authorization,
+ Event $queueForEvents,
): void {
$protocols = $project->getAttribute('apis', []);
$protocols[$protocolId] = $enabled;
@@ -75,6 +79,8 @@ class Update extends Action
'apis' => $protocols,
])));
+ $queueForEvents->setParam('protocolId', $protocolId);
+
$response->dynamic($project, Response::MODEL_PROJECT);
}
}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php
new file mode 100644
index 0000000000..8c87a41475
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php
@@ -0,0 +1,179 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/project/smtp/tests')
+ ->httpAlias('/v1/projects/:projectId/smtp/tests')
+ ->desc('Create project SMTP test')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.write')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'smtp',
+ name: 'createSMTPTest',
+ description: <<param('emails', [], new ArrayList(new Email(), 10), 'Array of emails to send test email to. Maximum of 10 emails are allowed.')
+ ->param('senderName', '', new Text(256), 'Name of the email sender', optional: true, deprecated: true) // Backwards compatibility
+ ->param('senderEmail', '', new Email(), 'Email of the sender', optional: true, deprecated: true) // Backwards compatibility
+ ->param('replyTo', '', new Email(), 'Reply to email', optional: true, deprecated: true) // Backwards compatibility
+ ->param('host', '', new Hostname(), 'SMTP server host name', optional: true, deprecated: true) // Backwards compatibility
+ ->param('port', null, new Integer(), 'SMTP server port', optional: true, deprecated: true) // Backwards compatibility
+ ->param('username', '', new Text(256), 'SMTP server username', optional: true, deprecated: true) // Backwards compatibility
+ ->param('password', '', new Text(256), 'SMTP server password', optional: true, deprecated: true) // Backwards compatibility
+ ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', optional: true, deprecated: true) // Backwards compatibility
+ ->inject('response')
+ ->inject('project')
+ ->inject('publisherForMails')
+ ->inject('plan')
+ ->callback($this->action(...));
+ }
+
+ /**
+ * @param array $emails
+ */
+ public function action(
+ array $emails,
+ string $paramSenderName, // Backwards compatibility
+ string $paramSenderEmail, // Backwards compatibility
+ string $paramReplyTo, // Backwards compatibility
+ string $paramHost, // Backwards compatibility
+ ?int $paramPort, // Backwards compatibility
+ string $paramUsername, // Backwards compatibility
+ string $paramPassword, // Backwards compatibility
+ string $paramSecure, // Backwards compatibility
+ Response $response,
+ Document $project,
+ MailPublisher $publisherForMails,
+ array $plan
+ ): void {
+ // Backwards compatibility: use inline params if provided, otherwise fall back to project SMTP config.
+ // When inline params are provided they are treated as self-contained — project config is ignored
+ // so legacy (1.9.1) callers do not get project state (e.g. replyToName) leaked into their request.
+ $hasInlineParams = !empty($paramHost);
+
+ $smtp = $project->getAttribute('smtp', []);
+
+ if (!$hasInlineParams && ($smtp['enabled'] ?? false) !== true) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to send a test email.');
+ }
+
+ if ($hasInlineParams) {
+ $senderName = $paramSenderName;
+ $senderEmail = $paramSenderEmail;
+ $replyToEmail = $paramReplyTo;
+ $replyToName = ''; // 1.9.1 inline params did not include replyToName
+ $host = $paramHost;
+ $port = $paramPort ?? 0;
+ $username = $paramUsername;
+ $password = $paramPassword;
+ $secure = $paramSecure;
+ } else {
+ $senderName = $smtp['senderName'] ?? '';
+ $senderEmail = $smtp['senderEmail'] ?? '';
+ $replyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; // Includes backwards compatibility
+ $replyToName = $smtp['replyToName'] ?? '';
+ $host = $smtp['host'] ?? '';
+ $port = $smtp['port'] ?? 0;
+ $username = $smtp['username'] ?? '';
+ $password = $smtp['password'] ?? '';
+ $secure = $smtp['secure'] ?? '';
+ }
+
+ if (empty($senderEmail)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender email must be configured on the project to send a test email.');
+ }
+
+ if (empty($host)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP host must be configured on the project to send a test email.');
+ }
+
+ if (empty($port)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP port must be configured on the project to send a test email.');
+ }
+
+ // Fallback to sender details when reply-to is not explicitly configured
+ $replyToEmailDisplay = !empty($replyToEmail) ? $replyToEmail : $senderEmail;
+ $replyToNameDisplay = !empty($replyToName) ? $replyToName : $senderName;
+
+ $subject = 'Custom SMTP email sample';
+ $template = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-smtp-test.tpl');
+ $template
+ ->setParam('{{from}}', "{$senderName} ({$senderEmail})")
+ ->setParam('{{replyTo}}', "{$replyToNameDisplay} ({$replyToEmailDisplay})")
+ ->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL)
+ ->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR)
+ ->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER)
+ ->setParam('{{discordUrl}}', $plan['discordUrl'] ?? APP_SOCIAL_DISCORD)
+ ->setParam('{{githubUrl}}', $plan['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE)
+ ->setParam('{{termsUrl}}', $plan['termsUrl'] ?? APP_EMAIL_TERMS_URL)
+ ->setParam('{{privacyUrl}}', $plan['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL);
+
+ foreach ($emails as $email) {
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $email,
+ subject: $subject,
+ bodyTemplate: APP_CE_CONFIG_DIR . '/locale/templates/email-base-styled.tpl',
+ body: $template->render(),
+ smtp: [
+ 'host' => $host,
+ 'port' => $port,
+ 'username' => $username,
+ 'password' => $password,
+ 'secure' => $secure,
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ],
+ ));
+ }
+
+ $response->noContent();
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php
new file mode 100644
index 0000000000..b99a9db3c2
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php
@@ -0,0 +1,174 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/smtp')
+ ->httpAlias('/v1/projects/:projectId/smtp')
+ ->desc('Update project SMTP configuration')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.write')
+ // ->label('event', 'project.smtp.update')
+ ->label('audits.event', 'project.smtp.update')
+ ->label('audits.resource', 'project.smtp/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'smtp',
+ name: 'updateSMTP',
+ description: <<param('host', null, new Nullable(new Hostname()), 'SMTP server hostname (domain)', optional: true)
+ ->param('port', null, new Nullable(new Integer()), 'SMTP server port', optional: true)
+ ->param('username', null, new Nullable(new Text(256, 0)), 'SMTP server username. Pass an empty string to clear a previously set value.', optional: true)
+ ->param('password', null, new Nullable(new Text(256, 0)), 'SMTP server password. Pass an empty string to clear a previously set value. This property is stored securely and cannot be read in future (write-only).', optional: true)
+ ->param('senderEmail', null, new Nullable(new Email(allowEmpty: true)), 'Email address shown in inbox as the sender of the email. Pass an empty string to clear a previously set value.', optional: true)
+ ->param('senderName', null, new Nullable(new Text(256, 0)), 'Name shown in inbox as the sender of the email. Pass an empty string to clear a previously set value.', optional: true)
+ ->param('replyToEmail', null, new Nullable(new Email(allowEmpty: true)), 'Email used when user replies to the email. Pass an empty string to clear a previously set value.', optional: true)
+ ->param('replyToName', null, new Nullable(new Text(256, 0)), 'Name used when user replies to the email. Pass an empty string to clear a previously set value.', optional: true)
+ ->param('secure', null, new Nullable(new WhiteList(['tls', 'ssl'], true)), 'Configures if communication with SMTP server is encrypted. Allowed values are: tls, ssl. Leave empty for no encryption.', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'Enable or disable custom SMTP. Custom SMTP is useful for branding purposes, but also allows use of custom email templates.', optional: true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+
+ public function action(
+ ?string $host,
+ ?int $port,
+ ?string $username,
+ ?string $password,
+ ?string $senderEmail,
+ ?string $senderName,
+ ?string $replyToEmail,
+ ?string $replyToName,
+ ?string $secure,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization
+ ): void {
+ // Fetch current configuration
+ $smtp = $project->getAttribute('smtp', []);
+
+ // Apply changes — null means "not provided, keep existing".
+ // Empty string explicitly clears a previously-set value.
+ $keys = ['host', 'port', 'username', 'password', 'senderEmail', 'senderName', 'replyToEmail', 'replyToName', 'secure', 'enabled'];
+ foreach ($keys as $key) {
+ if (!\is_null(${$key})) {
+ $smtp[$key] = ${$key};
+ }
+ }
+
+ // Backwards compatibility
+ $smtp['replyToEmail'] = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '';
+
+ if (($smtp['enabled'] ?? false) === true) {
+ // Ensure required fields are set
+ $requiredKeys = ['host', 'port', 'senderEmail'];
+ foreach ($requiredKeys as $key) {
+ if (empty($smtp[$key])) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "' . $key . '" is not optional.');
+ }
+ }
+ }
+
+ // Validate SMTP credentials
+ // Validate when the caller is explicitly enabling or hasn't expressed a preference
+ // (so a credentials-only PATCH can auto-enable). Skip only when the caller is
+ // explicitly keeping/turning SMTP off.
+ if ((\is_null($enabled) || $enabled === true) && !empty($smtp['senderEmail'] ?? '')) {
+ $mail = new PHPMailer(true);
+ $mail->isSMTP();
+
+ $mail->Host = $smtp['host'] ?? '';
+ $mail->Port = $smtp['port'] ?? '';
+ $mail->SMTPSecure = $smtp['secure'] ?? '';
+ $mail->setFrom($smtp['senderEmail'], $smtp['senderName'] ?? '');
+
+ if (!empty($smtp['username'] ?? '')) {
+ $mail->SMTPAuth = true;
+ $mail->Username = $smtp['username'];
+ $mail->Password = $smtp['password'] ?? '';
+ }
+
+ if (!empty($smtp['replyToEmail'] ?? '')) {
+ $mail->addReplyTo($smtp['replyToEmail'], $smtp['replyToName'] ?? '');
+ }
+
+ $mail->SMTPAutoTLS = false;
+ $mail->Timeout = 5;
+
+ try {
+ $valid = $mail->SmtpConnect();
+
+ if (!$valid) {
+ throw new \Exception('Connection is not valid.');
+ }
+
+ // Auto-enable if configuration is valid
+ // Dont do this if specifically request to mark disabled
+ if (\is_null($enabled)) {
+ $smtp['enabled'] = true;
+ }
+ } catch (Throwable $error) {
+ if (($smtp['enabled'] ?? null) === true) {
+ throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage());
+ }
+ }
+ }
+
+ // Save configuration
+ $updates = new Document([
+ 'smtp' => $smtp,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Update.php
similarity index 79%
rename from src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php
rename to src/Appwrite/Platform/Modules/Project/Http/Project/Services/Update.php
index 35be32a604..7aab6f5ad0 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Update.php
@@ -1,7 +1,8 @@
setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
- ->setHttpPath('/v1/project/services/:serviceId/status')
+ ->setHttpPath('/v1/project/services/:serviceId')
+ ->httpAlias('/v1/project/services/:serviceId/status')
->httpAlias('/v1/projects/:projectId/service')
- ->desc('Update project service status')
+ ->desc('Update project service')
->groups(['api', 'project'])
->label('scope', 'project.write')
- ->label('event', 'services.[service].update')
- ->label('audits.event', 'project.services.[service].update')
+ ->label('event', 'services.[serviceId].update')
+ ->label('audits.event', 'project.services.[serviceId].update')
->label('audits.resource', 'project.services/{response.$id}')
->label('sdk', new Method(
namespace: 'project',
group: null,
- name: 'updateServiceStatus',
+ name: 'updateService',
description: <<inject('dbForPlatform')
->inject('project')
->inject('authorization')
+ ->inject('queueForEvents')
->callback($this->action(...));
}
@@ -66,7 +69,8 @@ class Update extends Action
Response $response,
Database $dbForPlatform,
Document $project,
- Authorization $authorization
+ Authorization $authorization,
+ Event $queueForEvents
): void {
$services = $project->getAttribute('services', []);
$services[$serviceId] = $enabled;
@@ -75,6 +79,8 @@ class Update extends Action
'services' => $services,
])));
+ $queueForEvents->setParam('serviceId', $serviceId);
+
$response->dynamic($project, Response::MODEL_PROJECT);
}
}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php
new file mode 100644
index 0000000000..02ba431775
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php
@@ -0,0 +1,142 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/templates/email/:templateId')
+ ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale')
+ ->desc('Get project email template')
+ ->groups(['api', 'project'])
+ ->label('scope', 'templates.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'templates',
+ name: 'getEmailTemplate',
+ description: <<param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? []))
+ ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes'])
+ ->inject('response')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $templateId,
+ string $locale,
+ Response $response,
+ Document $project,
+ ) {
+ $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en');
+
+ // Get custom template if available
+ $templates = $project->getAttribute('templates', []);
+ $template = $templates['email.' . $templateId . '-' . $locale] ?? [];
+
+ // Enforced params
+ $template['templateId'] = $templateId;
+ $template['locale'] = $locale;
+
+ // Prepare default tempaltes
+ $localeObj = new Locale($locale);
+ $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en'));
+
+ $defaultSubject = $localeObj->getText('emails.' . $templateId . '.subject');
+ $defaultMessage = $this->getDefaultMessage($templateId, $localeObj);
+
+ // Apply defaults if needed
+ if (\is_null($template['message'] ?? null)) {
+ $template['message'] = $defaultMessage;
+ }
+
+ if (\is_null($template['subject'] ?? null)) {
+ $template['subject'] = $defaultSubject;
+ }
+
+ // Backwards compatibility
+ if (!\is_null($template['replyTo'] ?? null)) {
+ $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? '';
+ }
+
+ $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE);
+ }
+
+ protected function getDefaultMessage(string $templateId, Locale $localeObj): string
+ {
+ $templateConfigs = [
+ 'magicSession' => [
+ 'file' => 'email-magic-url.tpl',
+ 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase']
+ ],
+ 'mfaChallenge' => [
+ 'file' => 'email-mfa-challenge.tpl',
+ 'placeholders' => ['description', 'clientInfo']
+ ],
+ 'otpSession' => [
+ 'file' => 'email-otp.tpl',
+ 'placeholders' => ['description', 'clientInfo', 'securityPhrase']
+ ],
+ 'sessionAlert' => [
+ 'file' => 'email-session-alert.tpl',
+ 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer']
+ ],
+ ];
+
+ // fallback to the base template.
+ $config = $templateConfigs[$templateId] ?? [
+ 'file' => 'email-inner-base.tpl',
+ 'placeholders' => ['buttonText', 'body', 'footer']
+ ];
+
+ $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']);
+ $message = Template::fromString($templateString);
+
+ // Set type-specific parameters
+ foreach ($config['placeholders'] as $param) {
+ $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']);
+ $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml);
+ }
+
+ $message
+ ->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello"))
+ ->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks"))
+ ->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature"));
+
+ $message = $message->render(useContent: true);
+
+ return $message;
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php
new file mode 100644
index 0000000000..c9c64ebdfa
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php
@@ -0,0 +1,145 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/templates/email')
+ ->httpAlias('/v1/projects/:projectId/templates/email')
+ ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale')
+ ->desc('Update project email template')
+ ->groups(['api', 'project'])
+ ->label('scope', 'templates.write')
+ ->label('event', 'templates.[templateId].update')
+ ->label('audits.event', 'project.template.update')
+ ->label('audits.resource', 'project.template/{response.templateId}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'templates',
+ name: 'updateEmailTemplate',
+ description: <<param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? []))
+ ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes'])
+ ->param('subject', null, new Nullable(new Text(255)), 'Subject of the email template. Can be up to 255 characters.', optional: true)
+ ->param('message', null, new Nullable(new Text(10485760)), 'Plain or HTML body of the email template message. Can be up to 10MB of content.', optional: true)
+ ->param('senderName', null, new Nullable(new Text(255, 0)), 'Name of the email sender.', optional: true)
+ ->param('senderEmail', null, new Nullable(new Email(allowEmpty: true)), 'Email of the sender. Pass an empty string to clear a previously set value.', optional: true)
+ ->param('replyToEmail', null, new Nullable(new Email(allowEmpty: true)), 'Reply to email. Pass an empty string to clear a previously set value.', optional: true)
+ ->param('replyToName', null, new Nullable(new Text(255, 0)), 'Reply to name.', optional: true)
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $templateId,
+ string $locale,
+ ?string $subject,
+ ?string $message,
+ ?string $senderName,
+ ?string $senderEmail,
+ ?string $replyToEmail,
+ ?string $replyToName,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ Document $project,
+ ) {
+ $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en');
+
+ // Prevent template update if custom SMTP is not configured
+ $smtp = $project->getAttribute('smtp', []);
+ if (($smtp['enabled'] ?? false) !== true) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to configure custom email templates.');
+ }
+
+ // Fetch current configuration
+ $templates = $project->getAttribute('templates', []);
+ $template = $templates['email.' . $templateId . '-' . $locale] ?? [];
+
+ // Apply changes — null means "not provided, keep existing".
+ // Empty string explicitly clears a previously-set value.
+ $keys = ['senderName', 'senderEmail', 'replyToEmail', 'replyToName', 'message', 'subject'];
+ foreach ($keys as $key) {
+ if (!\is_null(${$key})) {
+ $template[$key] = ${$key};
+ }
+ }
+
+ // Backwards compatibility
+ if (!\is_null($template['replyTo'] ?? null)) {
+ $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? '';
+ }
+
+ // Ensure required fields are set
+ $requiredKeys = ['subject', 'message'];
+ foreach ($requiredKeys as $key) {
+ if (empty($template[$key])) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "' . $key . '" is not optional.');
+ }
+ }
+
+ // Save configuration
+ $templates['email.' . $templateId . '-' . $locale] = $template;
+ $updates = new Document([
+ 'templates' => $templates,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents->setParam('templateId', $templateId);
+
+ $response->dynamic(new Document([
+ 'templateId' => $templateId,
+ 'locale' => $locale,
+ 'subject' => $template['subject'],
+ 'message' => $template['message'],
+ 'senderName' => $template['senderName'] ?? '',
+ 'senderEmail' => $template['senderEmail'] ?? '',
+ 'replyToEmail' => $template['replyToEmail'] ?? '',
+ 'replyToName' => $template['replyToName'] ?? '',
+ ]), Response::MODEL_EMAIL_TEMPLATE);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php
new file mode 100644
index 0000000000..d15f2f856c
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php
@@ -0,0 +1,114 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/templates/email')
+ ->desc('List project email templates')
+ ->groups(['api', 'project'])
+ ->label('scope', 'templates.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'templates',
+ name: 'listEmailTemplates',
+ description: <<param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', 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('project')
+ ->callback($this->action(...));
+ }
+
+ /**
+ * @param array $queries
+ */
+ public function action(
+ array $queries,
+ bool $includeTotal,
+ Response $response,
+ Document $project,
+ ) {
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $templates = $project->getAttribute('templates', []);
+
+ $emailTemplates = [];
+ foreach ($templates as $key => $template) {
+ if (!\str_starts_with($key, 'email.')) {
+ continue;
+ }
+
+ $suffix = \substr($key, \strlen('email.'));
+ $parts = \explode('-', $suffix, 2);
+ if (\count($parts) !== 2) {
+ continue;
+ }
+
+ [$templateId, $locale] = $parts;
+
+ $template['templateId'] = $templateId;
+ $template['locale'] = $locale;
+
+ // Backwards compatibility
+ if (!\is_null($template['replyTo'] ?? null)) {
+ $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? '';
+ }
+
+ $emailTemplates[] = new Document($template);
+ }
+
+ $total = $includeTotal ? \count($emailTemplates) : 0;
+
+ $grouped = Query::groupByType($queries);
+ $offset = $grouped['offset'] ?? 0;
+ $limit = $grouped['limit'] ?? null;
+
+ $emailTemplates = \array_slice($emailTemplates, $offset, $limit);
+
+ $response->dynamic(new Document([
+ 'templates' => $emailTemplates,
+ 'total' => $total,
+ ]), Response::MODEL_EMAIL_TEMPLATE_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php
index 8dbc720045..8c76ed2a8e 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php
@@ -53,7 +53,7 @@ class Create extends Action
)
],
))
- ->param('variableId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject'])
+ ->param('variableId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject'])
->param('key', null, new Text(Database::LENGTH_KEY), 'Variable key. Max length: ' . Database::LENGTH_KEY . ' chars.')
->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.')
->param('secret', true, new Boolean(), 'Secret variables can be updated or deleted, but only projects can read them during build and runtime.', true)
@@ -72,7 +72,7 @@ class Create extends Action
QueueEvent $queueForEvents,
Database $dbForProject,
) {
- $variableId = ($variableId == 'unique()') ? ID::unique() : $variableId;
+ $variableId = ($variableId === 'unique()') ? ID::unique() : $variableId;
$variable = new Document([
'$id' => $variableId,
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php
index 2b0ae8feb1..553fb09e54 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php
@@ -51,7 +51,7 @@ class Delete extends Action
],
contentType: ContentType::NONE
))
- ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID.', false, ['dbForProject'])
+ ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php
index af14148c92..d9030421d7 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php
@@ -44,7 +44,7 @@ class Get extends Action
)
]
))
- ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID.', false, ['dbForProject'])
+ ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php
index 988a7c0849..6b05e19a78 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php
@@ -52,7 +52,7 @@ class Update extends Action
)
]
))
- ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID.', false, ['dbForProject'])
+ ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
->param('key', null, new Nullable(new Text(255, 0)), 'Variable key. Max length: 255 chars.', true)
->param('value', null, new Nullable(new Text(8192, 0)), 'Variable value. Max length: 8192 chars.', true)
->param('secret', null, new Nullable(new Boolean()), 'Secret variables can be updated or deleted, but only projects can read them during build and runtime.', true)
diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php
index a2c94928e2..3fe9f63d9e 100644
--- a/src/Appwrite/Platform/Modules/Project/Services/Http.php
+++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php
@@ -3,12 +3,65 @@
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\Get as GetProject;
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\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;
+use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Create as CreateMockPhone;
+use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Delete as DeleteMockPhone;
+use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Get as GetMockPhone;
+use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Update as UpdateMockPhone;
+use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\XList as ListMockPhones;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Amazon\Update as UpdateOAuth2Amazon;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Apple\Update as UpdateOAuth2Apple;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Auth0\Update as UpdateOAuth2Auth0;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Authentik\Update as UpdateOAuth2Authentik;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Autodesk\Update as UpdateOAuth2Autodesk;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Bitbucket\Update as UpdateOAuth2Bitbucket;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Bitly\Update as UpdateOAuth2Bitly;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Box\Update as UpdateOAuth2Box;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Dailymotion\Update as UpdateOAuth2Dailymotion;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Discord\Update as UpdateOAuth2Discord;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Disqus\Update as UpdateOAuth2Disqus;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Dropbox\Update as UpdateOAuth2Dropbox;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Etsy\Update as UpdateOAuth2Etsy;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Facebook\Update as UpdateOAuth2Facebook;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Figma\Update as UpdateOAuth2Figma;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\FusionAuth\Update as UpdateOAuth2FusionAuth;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Get as GetOAuth2Provider;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\GitHub\Update as UpdateOAuth2GitHub;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Gitlab\Update as UpdateOAuth2Gitlab;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Google\Update as UpdateOAuth2Google;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Keycloak\Update as UpdateOAuth2Keycloak;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Kick\Update as UpdateOAuth2Kick;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Linkedin\Update as UpdateOAuth2Linkedin;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Microsoft\Update as UpdateOAuth2Microsoft;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Notion\Update as UpdateOAuth2Notion;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Oidc\Update as UpdateOAuth2Oidc;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Okta\Update as UpdateOAuth2Okta;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Paypal\Update as UpdateOAuth2Paypal;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\PaypalSandbox\Update as UpdateOAuth2PaypalSandbox;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Podio\Update as UpdateOAuth2Podio;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Salesforce\Update as UpdateOAuth2Salesforce;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Slack\Update as UpdateOAuth2Slack;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Spotify\Update as UpdateOAuth2Spotify;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Stripe\Update as UpdateOAuth2Stripe;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Tradeshift\Update as UpdateOAuth2Tradeshift;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\TradeshiftSandbox\Update as UpdateOAuth2TradeshiftSandbox;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Twitch\Update as UpdateOAuth2Twitch;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\WordPress\Update as UpdateOAuth2WordPress;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\X\Update as UpdateOAuth2X;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\XList as ListOAuth2Providers;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Yahoo\Update as UpdateOAuth2Yahoo;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Yandex\Update as UpdateOAuth2Yandex;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Zoho\Update as UpdateOAuth2Zoho;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Zoom\Update as UpdateOAuth2Zoom;
use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Create as CreateAndroidPlatform;
use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Update as UpdateAndroidPlatform;
use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Apple\Create as CreateApplePlatform;
@@ -22,8 +75,24 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Update as Updat
use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as CreateWindowsPlatform;
use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform;
use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms;
-use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status\Update as UpdateProjectProtocolStatus;
-use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\Get as GetPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\MembershipPrivacy\Update as UpdateMembershipPrivacyPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordDictionary\Update as UpdatePasswordDictionaryPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordHistory\Update as UpdatePasswordHistoryPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordPersonalData\Update as UpdatePasswordPersonalDataPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionAlert\Update as UpdateSessionAlertPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionDuration\Update as UpdateSessionDurationPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionInvalidation\Update as UpdateSessionInvalidationPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionLimit\Update as UpdateSessionLimitPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\UserLimit\Update as UpdateUserLimitPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\XList as ListPolicies;
+use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdateProjectProtocol;
+use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService;
+use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest;
+use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP;
+use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Get as GetTemplate;
+use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Update as UpdateTemplate;
+use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\XList as ListTemplates;
use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable;
use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable;
use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable;
@@ -41,9 +110,20 @@ class Http extends Service
$this->addAction(Init::getName(), new Init());
// Project
+ $this->addAction(DeleteProject::getName(), new DeleteProject());
+ $this->addAction(GetProject::getName(), new GetProject());
$this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels());
- $this->addAction(UpdateProjectProtocolStatus::getName(), new UpdateProjectProtocolStatus());
- $this->addAction(UpdateProjectServiceStatus::getName(), new UpdateProjectServiceStatus());
+ $this->addAction(UpdateProjectProtocol::getName(), new UpdateProjectProtocol());
+ $this->addAction(UpdateProjectService::getName(), new UpdateProjectService());
+
+ // SMTP
+ $this->addAction(UpdateSMTP::getName(), new UpdateSMTP());
+ $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest());
+
+ // Templates
+ $this->addAction(ListTemplates::getName(), new ListTemplates());
+ $this->addAction(GetTemplate::getName(), new GetTemplate());
+ $this->addAction(UpdateTemplate::getName(), new UpdateTemplate());
// Variables
$this->addAction(CreateVariable::getName(), new CreateVariable());
@@ -54,24 +134,94 @@ class Http extends Service
// Keys
$this->addAction(CreateKey::getName(), new CreateKey());
+ $this->addAction(CreateEphemeralKey::getName(), new CreateEphemeralKey());
$this->addAction(ListKeys::getName(), new ListKeys());
$this->addAction(GetKey::getName(), new GetKey());
$this->addAction(DeleteKey::getName(), new DeleteKey());
$this->addAction(UpdateKey::getName(), new UpdateKey());
// Platforms
- $this->addAction(DeletePlatform::getName(), new DeletePlatform());
- $this->addAction(UpdateWebPlatform::getName(), new UpdateWebPlatform());
- $this->addAction(UpdateApplePlatform::getName(), new UpdateApplePlatform());
- $this->addAction(UpdateAndroidPlatform::getName(), new UpdateAndroidPlatform());
- $this->addAction(UpdateWindowsPlatform::getName(), new UpdateWindowsPlatform());
- $this->addAction(UpdateLinuxPlatform::getName(), new UpdateLinuxPlatform());
+ $this->addAction(ListPlatforms::getName(), new ListPlatforms());
+ $this->addAction(GetPlatform::getName(), new GetPlatform());
$this->addAction(CreateWebPlatform::getName(), new CreateWebPlatform());
$this->addAction(CreateApplePlatform::getName(), new CreateApplePlatform());
$this->addAction(CreateAndroidPlatform::getName(), new CreateAndroidPlatform());
$this->addAction(CreateWindowsPlatform::getName(), new CreateWindowsPlatform());
$this->addAction(CreateLinuxPlatform::getName(), new CreateLinuxPlatform());
- $this->addAction(GetPlatform::getName(), new GetPlatform());
- $this->addAction(ListPlatforms::getName(), new ListPlatforms());
+ $this->addAction(UpdateWebPlatform::getName(), new UpdateWebPlatform());
+ $this->addAction(UpdateApplePlatform::getName(), new UpdateApplePlatform());
+ $this->addAction(UpdateAndroidPlatform::getName(), new UpdateAndroidPlatform());
+ $this->addAction(UpdateWindowsPlatform::getName(), new UpdateWindowsPlatform());
+ $this->addAction(UpdateLinuxPlatform::getName(), new UpdateLinuxPlatform());
+ $this->addAction(DeletePlatform::getName(), new DeletePlatform());
+
+ // Mock Phones
+ $this->addAction(CreateMockPhone::getName(), new CreateMockPhone());
+ $this->addAction(ListMockPhones::getName(), new ListMockPhones());
+ $this->addAction(GetMockPhone::getName(), new GetMockPhone());
+ $this->addAction(UpdateMockPhone::getName(), new UpdateMockPhone());
+ $this->addAction(DeleteMockPhone::getName(), new DeleteMockPhone());
+
+ // Policies
+ $this->addAction(ListPolicies::getName(), new ListPolicies());
+ $this->addAction(GetPolicy::getName(), new GetPolicy());
+ $this->addAction(UpdateMembershipPrivacyPolicy::getName(), new UpdateMembershipPrivacyPolicy());
+ $this->addAction(UpdatePasswordDictionaryPolicy::getName(), new UpdatePasswordDictionaryPolicy());
+ $this->addAction(UpdatePasswordHistoryPolicy::getName(), new UpdatePasswordHistoryPolicy());
+ $this->addAction(UpdatePasswordPersonalDataPolicy::getName(), new UpdatePasswordPersonalDataPolicy());
+ $this->addAction(UpdateSessionAlertPolicy::getName(), new UpdateSessionAlertPolicy());
+ $this->addAction(UpdateSessionDurationPolicy::getName(), new UpdateSessionDurationPolicy());
+ $this->addAction(UpdateSessionInvalidationPolicy::getName(), new UpdateSessionInvalidationPolicy());
+ $this->addAction(UpdateSessionLimitPolicy::getName(), new UpdateSessionLimitPolicy());
+ $this->addAction(UpdateUserLimitPolicy::getName(), new UpdateUserLimitPolicy());
+
+ // Auth Methods
+ $this->addAction(UpdateAuthMethod::getName(), new UpdateAuthMethod());
+
+ // OAuth2
+ $this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers());
+ $this->addAction(GetOAuth2Provider::getName(), new GetOAuth2Provider());
+ $this->addAction(UpdateOAuth2GitHub::getName(), new UpdateOAuth2GitHub());
+ $this->addAction(UpdateOAuth2Discord::getName(), new UpdateOAuth2Discord());
+ $this->addAction(UpdateOAuth2Figma::getName(), new UpdateOAuth2Figma());
+ $this->addAction(UpdateOAuth2Dropbox::getName(), new UpdateOAuth2Dropbox());
+ $this->addAction(UpdateOAuth2Dailymotion::getName(), new UpdateOAuth2Dailymotion());
+ $this->addAction(UpdateOAuth2Bitbucket::getName(), new UpdateOAuth2Bitbucket());
+ $this->addAction(UpdateOAuth2Bitly::getName(), new UpdateOAuth2Bitly());
+ $this->addAction(UpdateOAuth2Box::getName(), new UpdateOAuth2Box());
+ $this->addAction(UpdateOAuth2Autodesk::getName(), new UpdateOAuth2Autodesk());
+ $this->addAction(UpdateOAuth2Google::getName(), new UpdateOAuth2Google());
+ $this->addAction(UpdateOAuth2Zoom::getName(), new UpdateOAuth2Zoom());
+ $this->addAction(UpdateOAuth2Zoho::getName(), new UpdateOAuth2Zoho());
+ $this->addAction(UpdateOAuth2Yandex::getName(), new UpdateOAuth2Yandex());
+ $this->addAction(UpdateOAuth2X::getName(), new UpdateOAuth2X());
+ $this->addAction(UpdateOAuth2WordPress::getName(), new UpdateOAuth2WordPress());
+ $this->addAction(UpdateOAuth2Twitch::getName(), new UpdateOAuth2Twitch());
+ $this->addAction(UpdateOAuth2Stripe::getName(), new UpdateOAuth2Stripe());
+ $this->addAction(UpdateOAuth2Spotify::getName(), new UpdateOAuth2Spotify());
+ $this->addAction(UpdateOAuth2Slack::getName(), new UpdateOAuth2Slack());
+ $this->addAction(UpdateOAuth2Podio::getName(), new UpdateOAuth2Podio());
+ $this->addAction(UpdateOAuth2Notion::getName(), new UpdateOAuth2Notion());
+ $this->addAction(UpdateOAuth2Salesforce::getName(), new UpdateOAuth2Salesforce());
+ $this->addAction(UpdateOAuth2Yahoo::getName(), new UpdateOAuth2Yahoo());
+ $this->addAction(UpdateOAuth2Linkedin::getName(), new UpdateOAuth2Linkedin());
+ $this->addAction(UpdateOAuth2Disqus::getName(), new UpdateOAuth2Disqus());
+ $this->addAction(UpdateOAuth2Amazon::getName(), new UpdateOAuth2Amazon());
+ $this->addAction(UpdateOAuth2Etsy::getName(), new UpdateOAuth2Etsy());
+ $this->addAction(UpdateOAuth2Facebook::getName(), new UpdateOAuth2Facebook());
+ $this->addAction(UpdateOAuth2Tradeshift::getName(), new UpdateOAuth2Tradeshift());
+ $this->addAction(UpdateOAuth2TradeshiftSandbox::getName(), new UpdateOAuth2TradeshiftSandbox());
+ $this->addAction(UpdateOAuth2Paypal::getName(), new UpdateOAuth2Paypal());
+ $this->addAction(UpdateOAuth2PaypalSandbox::getName(), new UpdateOAuth2PaypalSandbox());
+ $this->addAction(UpdateOAuth2Gitlab::getName(), new UpdateOAuth2Gitlab());
+ $this->addAction(UpdateOAuth2Authentik::getName(), new UpdateOAuth2Authentik());
+ $this->addAction(UpdateOAuth2Auth0::getName(), new UpdateOAuth2Auth0());
+ $this->addAction(UpdateOAuth2FusionAuth::getName(), new UpdateOAuth2FusionAuth());
+ $this->addAction(UpdateOAuth2Keycloak::getName(), new UpdateOAuth2Keycloak());
+ $this->addAction(UpdateOAuth2Oidc::getName(), new UpdateOAuth2Oidc());
+ $this->addAction(UpdateOAuth2Okta::getName(), new UpdateOAuth2Okta());
+ $this->addAction(UpdateOAuth2Kick::getName(), new UpdateOAuth2Kick());
+ $this->addAction(UpdateOAuth2Apple::getName(), new UpdateOAuth2Apple());
+ $this->addAction(UpdateOAuth2Microsoft::getName(), new UpdateOAuth2Microsoft());
}
}
diff --git a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php
index 5329585be3..76df8c2b45 100644
--- a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php
+++ b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php
@@ -63,7 +63,7 @@ class Delete extends Action
$key = $dbForPlatform->getDocument('devKeys', $keyId);
- if ($key === false || $key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) {
+ if ($key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::KEY_NOT_FOUND);
}
diff --git a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php
index 5cb3b0545f..ff4e348c8e 100644
--- a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php
+++ b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php
@@ -63,7 +63,7 @@ class Get extends Action
$key = $dbForPlatform->getDocument('devKeys', $keyId);
- if ($key === false || $key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) {
+ if ($key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::KEY_NOT_FOUND);
}
diff --git a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php
index f3e47f80ba..9704740bc4 100644
--- a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php
+++ b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php
@@ -66,7 +66,7 @@ class Update extends Action
$key = $dbForPlatform->getDocument('devKeys', $keyId);
- if ($key === false || $key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) {
+ if ($key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::KEY_NOT_FOUND);
}
diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php
index d43fe3e071..f94669597b 100644
--- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php
+++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php
@@ -4,9 +4,6 @@ namespace Appwrite\Platform\Modules\Projects\Http\Projects;
use Appwrite\Extend\Exception;
use Appwrite\Hooks\Hooks;
-use Appwrite\SDK\AuthType;
-use Appwrite\SDK\Method;
-use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\ProjectId;
use Appwrite\Utopia\Database\Validator\Queries\Projects;
use Appwrite\Utopia\Request;
@@ -21,8 +18,6 @@ use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
-use Utopia\Database\Helpers\Permission;
-use Utopia\Database\Helpers\Role;
use Utopia\Database\Validator\UID;
use Utopia\DSN\DSN;
use Utopia\Platform\Scope\HTTP;
@@ -30,7 +25,6 @@ use Utopia\Pools\Group;
use Utopia\System\System;
use Utopia\Validator;
use Utopia\Validator\Text;
-use Utopia\Validator\URL;
use Utopia\Validator\WhiteList;
class Create extends Action
@@ -57,32 +51,10 @@ class Create extends Action
->label('audits.event', 'projects.create')
->label('audits.resource', 'project/{response.$id}')
->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'projects',
- name: 'create',
- description: '/docs/references/projects/create.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_CREATED,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
->param('projectId', '', new ProjectId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, and hyphen. Can\'t start with a special char. Max length is 36 chars.')
->param('name', null, new Text(128), 'Project name. Max length: 128 chars.')
->param('teamId', '', new UID(), 'Team unique ID.')
->param('region', System::getEnv('_APP_REGION', 'default'), new WhiteList(array_keys(array_filter(Config::getParam('regions'), fn ($config) => !$config['disabled']))), 'Project Region.', true)
- ->param('description', '', new Text(256), 'Project description. Max length: 256 chars.', true)
- ->param('logo', '', new Text(1024), 'Project logo.', true)
- ->param('url', '', new URL(), 'Project URL.', true)
- ->param('legalName', '', new Text(256), 'Project legal Name. Max length: 256 chars.', true)
- ->param('legalCountry', '', new Text(256), 'Project legal Country. Max length: 256 chars.', true)
- ->param('legalState', '', new Text(256), 'Project legal State. Max length: 256 chars.', true)
- ->param('legalCity', '', new Text(256), 'Project legal City. Max length: 256 chars.', true)
- ->param('legalAddress', '', new Text(256), 'Project legal Address. Max length: 256 chars.', true)
- ->param('legalTaxId', '', new Text(256), 'Project legal Tax ID. Max length: 256 chars.', true)
->inject('request')
->inject('response')
->inject('dbForPlatform')
@@ -92,7 +64,7 @@ class Create extends Action
->callback($this->action(...));
}
- public function action(string $projectId, string $name, string $teamId, string $region, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks)
+ public function action(string $projectId, string $name, string $teamId, string $region, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks)
{
$team = $dbForPlatform->getDocument('teams', $teamId);
@@ -129,6 +101,8 @@ class Create extends Action
'membershipsUserName' => false,
'membershipsUserEmail' => false,
'membershipsMfa' => false,
+ 'membershipsUserId' => false,
+ 'membershipsUserPhone' => false,
'invalidateSessions' => true
];
@@ -182,16 +156,7 @@ class Create extends Action
'teamInternalId' => $team->getSequence(),
'teamId' => $team->getId(),
'region' => $region,
- 'description' => $description,
- 'logo' => $logo,
- 'url' => $url,
'version' => APP_VERSION_STABLE,
- 'legalName' => $legalName,
- 'legalCountry' => $legalCountry,
- 'legalState' => $legalState,
- 'legalCity' => $legalCity,
- 'legalAddress' => $legalAddress,
- 'legalTaxId' => ID::custom($legalTaxId),
'services' => new \stdClass(),
'platforms' => null,
'oAuthProviders' => [],
@@ -216,32 +181,16 @@ class Create extends Action
}
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
- $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', ''));
$projectTables = !\in_array($dsn->getHost(), $sharedTables);
- $sharedTablesV1 = \in_array($dsn->getHost(), $sharedTablesV1);
- $sharedTablesV2 = !$projectTables && !$sharedTablesV1;
- $sharedTables = $sharedTablesV1 || $sharedTablesV2;
- if (!$sharedTablesV2) {
+ if ($projectTables) {
$adapter = new DatabasePool($pools->get($dsn->getHost()));
$dbForProject = new Database($adapter, $cache);
- $dbForProject->setDatabase(APP_DATABASE);
-
- if ($sharedTables) {
- $tenant = null;
- if ($sharedTablesV1) {
- $tenant = $project->getSequence();
- }
- $dbForProject
- ->setSharedTables(true)
- ->setTenant($tenant)
- ->setNamespace($dsn->getParam('namespace'));
- } else {
- $dbForProject
- ->setSharedTables(false)
- ->setTenant(null)
- ->setNamespace('_' . $project->getSequence());
- }
+ $dbForProject
+ ->setDatabase(APP_DATABASE)
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $project->getSequence());
$create = true;
@@ -251,27 +200,11 @@ class Create extends Action
$create = false;
}
- if ($create || $projectTables) {
- $adapter = new AdapterDatabase($dbForProject);
- $audit = new Audit($adapter);
- $audit->setup();
- }
+ $adapter = new AdapterDatabase($dbForProject);
+ $audit = new Audit($adapter);
+ $audit->setup();
- if (!$create && $sharedTablesV1) {
- $adapter = new AdapterDatabase($dbForProject);
- $attributes = $adapter->getAttributeDocuments();
- $indexes = $adapter->getIndexDocuments();
- $dbForProject->createDocument(Database::METADATA, new Document([
- '$id' => ID::custom('audit'),
- '$permissions' => [Permission::create(Role::any())],
- 'name' => 'audit',
- 'attributes' => $attributes,
- 'indexes' => $indexes,
- 'documentSecurity' => true
- ]));
- }
-
- if ($create || $sharedTablesV1) {
+ if ($create) {
/** @var array $collections */
$collections = Config::getParam('collections', [])['projects'] ?? [];
@@ -286,37 +219,7 @@ class Create extends Action
try {
$dbForProject->createCollection($key, $attributes, $indexes);
} catch (Duplicate) {
- try {
- $dbForProject->createDocument(Database::METADATA, new Document([
- '$id' => ID::custom($key),
- '$permissions' => [Permission::create(Role::any())],
- 'name' => $key,
- 'attributes' => $attributes,
- 'indexes' => $indexes,
- 'documentSecurity' => true
- ]));
- } catch (Duplicate) {
- // Metadata already exists from concurrent creation
- }
- } catch (\Throwable $e) {
- // PostgreSQL adapter may throw a non-Duplicate exception when
- // a table or index already exists during concurrent project
- // creation in shared mode. Treat as duplicate if metadata
- // can be created successfully.
- try {
- $dbForProject->createDocument(Database::METADATA, new Document([
- '$id' => ID::custom($key),
- '$permissions' => [Permission::create(Role::any())],
- 'name' => $key,
- 'attributes' => $attributes,
- 'indexes' => $indexes,
- 'documentSecurity' => true
- ]));
- } catch (Duplicate) {
- // Metadata already exists from concurrent creation
- } catch (\Throwable) {
- throw $e; // Rethrow original if metadata creation also fails
- }
+ // Collection already exists
}
}
}
diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php
index 29c26b33ea..f6df843d07 100644
--- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php
+++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php
@@ -3,9 +3,6 @@
namespace Appwrite\Platform\Modules\Projects\Http\Projects;
use Appwrite\Extend\Exception;
-use Appwrite\SDK\AuthType;
-use Appwrite\SDK\Method;
-use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\Projects;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
@@ -39,19 +36,6 @@ class Update extends Action
->label('scope', 'projects.write')
->label('audits.event', 'projects.update')
->label('audits.resource', 'project/{request.projectId}')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'projects',
- name: 'update',
- description: '/docs/references/projects/update.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
->param('projectId', '', new UID(), 'Project unique ID.')
->param('name', null, new Text(128), 'Project name. Max length: 128 chars.')
->param('description', '', new Text(256), 'Project description. Max length: 256 chars.', true)
diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php
index 8e420e87f2..b967c29451 100644
--- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php
+++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php
@@ -4,10 +4,6 @@ namespace Appwrite\Platform\Modules\Projects\Http\Projects;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
-use Appwrite\SDK\AuthType;
-use Appwrite\SDK\ContentType;
-use Appwrite\SDK\Method;
-use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\Projects;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Filters\ListSelection;
@@ -48,22 +44,6 @@ class XList extends Action
->desc('List projects')
->groups(['api', 'projects'])
->label('scope', 'projects.read')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'projects',
- name: 'list',
- description: <<param('queries', [], $this->getQueriesValidator(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Projects::ALLOWED_ATTRIBUTES), true)
->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
@@ -109,7 +89,7 @@ class XList extends Action
}
try {
- $selectQueries = Query::groupByType($queries)['selections'] ?? [];
+ $selectQueries = Query::groupByType($queries)['selections'];
$filterQueries = Query::groupByType($queries)['filters'];
$projects = $this->find($dbForPlatform, $queries, $selectQueries);
diff --git a/src/Appwrite/Platform/Modules/Proxy/Action.php b/src/Appwrite/Platform/Modules/Proxy/Action.php
index 30ad140530..f2ffc58568 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Action.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Action.php
@@ -5,7 +5,11 @@ namespace Appwrite\Platform\Modules\Proxy;
use Appwrite\Extend\Exception;
use Appwrite\Network\Validator\DNS as ValidatorDNS;
use Appwrite\Platform\Action as PlatformAction;
+use Utopia\Database\Database;
use Utopia\Database\Document;
+use Utopia\Database\Exception\Duplicate;
+use Utopia\Database\Query;
+use Utopia\Database\Validator\Authorization;
use Utopia\DNS\Message\Record;
use Utopia\Domains\Domain;
use Utopia\Logger\Log;
@@ -20,6 +24,57 @@ class Action extends PlatformAction
{
}
+ protected function createRule(Document $rule, Database $dbForPlatform, Authorization $authorization): Document
+ {
+ try {
+ return $authorization->skip(fn () => $dbForPlatform->createDocument('rules', $rule));
+ } catch (Duplicate) {
+ if (!$this->deleteOrphanedRule($rule, $dbForPlatform, $authorization)) {
+ throw new Exception(Exception::RULE_ALREADY_EXISTS);
+ }
+ }
+
+ try {
+ return $authorization->skip(fn () => $dbForPlatform->createDocument('rules', $rule));
+ } catch (Duplicate) {
+ throw new Exception(Exception::RULE_ALREADY_EXISTS);
+ }
+ }
+
+ private function deleteOrphanedRule(Document $rule, Database $dbForPlatform, Authorization $authorization): bool
+ {
+ $existingRule = $authorization->skip(function () use ($rule, $dbForPlatform) {
+ $existingRule = $dbForPlatform->findOne('rules', [
+ Query::equal('domain', [$rule->getAttribute('domain', '')]),
+ ]);
+ if (!$existingRule->isEmpty()) {
+ return $existingRule;
+ }
+
+ return $dbForPlatform->getDocument('rules', $rule->getId());
+ });
+
+ if (
+ $existingRule->isEmpty() ||
+ $existingRule->getAttribute('domain', '') !== $rule->getAttribute('domain', '')
+ ) {
+ return false;
+ }
+
+ $projectId = $existingRule->getAttribute('projectId', '');
+ if (empty($projectId)) {
+ return false;
+ }
+
+ $project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
+ if (!$project->isEmpty()) {
+ return false;
+ }
+
+ $authorization->skip(fn () => $dbForPlatform->deleteDocument('rules', $existingRule->getId()));
+ return true;
+ }
+
/**
* Ensures domain is not in the deny list and is a valid domain
*
@@ -164,9 +219,7 @@ class Action extends PlatformAction
$validator = new AnyOf($cnameValidators);
$validators[] = $validator;
- if (\is_null($mainValidator)) {
- $mainValidator = $validator;
- }
+ $mainValidator = $validator;
}
// Ensure at least one of CNAME/A/AAAA record points to our servers properly
diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php
index bfa62ef920..9431d24cde 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Proxy\Http\Rules\API;
-use Appwrite\Event\Certificate;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Certificate;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Proxy\Action;
use Appwrite\SDK\AuthType;
@@ -12,8 +12,8 @@ use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
-use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
+use Utopia\Database\Validator\Authorization;
use Utopia\Logger\Log;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
@@ -43,12 +43,14 @@ class Create extends Action
->label('audits.resource', 'rule/{response.$id}')
->label('sdk', new Method(
namespace: 'proxy',
- group: null,
+ group: 'rules',
name: 'createAPIRule',
description: <<param('domain', null, new ValidatorDomain(), 'Domain name.')
->inject('response')
->inject('project')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->inject('queueForEvents')
->inject('dbForPlatform')
->inject('platform')
->inject('log')
+ ->inject('authorization')
->callback($this->action(...));
}
- public function action(string $domain, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, array $platform, Log $log)
- {
+ public function action(
+ string $domain,
+ Response $response,
+ Document $project,
+ Certificate $publisherForCertificates,
+ Event $queueForEvents,
+ Database $dbForPlatform,
+ array $platform,
+ Log $log,
+ Authorization $authorization,
+ ) {
$this->validateDomainRestrictions($domain, $platform);
// TODO: (@Meldiron) Remove after 1.7.x migration
@@ -107,24 +119,28 @@ class Create extends Action
}
}
- try {
- $rule = $dbForPlatform->createDocument('rules', $rule);
- } catch (Duplicate $e) {
- throw new Exception(Exception::RULE_ALREADY_EXISTS);
- }
+ $rule = $this->createRule($rule, $dbForPlatform, $authorization);
if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) {
- $queueForCertificates
- ->setDomain(new Document([
+ $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: $project,
+ domain: new Document([
'domain' => $rule->getAttribute('domain'),
'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')),
- ]))
- ->setAction(Certificate::ACTION_GENERATION)
- ->trigger();
+ ]),
+ action: \Appwrite\Event\Certificate::ACTION_GENERATION,
+ ));
}
$queueForEvents->setParam('ruleId', $rule->getId());
+ // Rename 'created' status to 'unverified' for consistency.
+ // 'verifying' and 'verified' statuses stay as is.
+ // 'unverified' in the meaning of failed certificate generation stays as is.
+ if ($rule->getAttribute('status') === 'created') {
+ $rule->setAttribute('status', 'unverified');
+ }
+
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($rule, Response::MODEL_PROXY_RULE);
diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Delete.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Delete.php
index 1d5b770496..991b8eb006 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Delete.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Delete.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Proxy\Http\Rules;
-use Appwrite\Event\Delete as DeleteEvent;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Delete as DeleteMessage;
+use Appwrite\Event\Publisher\Delete as DeletePublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -12,6 +13,7 @@ use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
+use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
@@ -38,12 +40,12 @@ class Delete extends Action
->label('audits.resource', 'rule/{request.ruleId}')
->label('sdk', new Method(
namespace: 'proxy',
- group: null,
+ group: 'rules',
name: 'deleteRule',
description: <<inject('response')
->inject('project')
->inject('dbForPlatform')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('queueForEvents')
+ ->inject('authorization')
->callback($this->action(...));
}
@@ -66,20 +69,23 @@ class Delete extends Action
Response $response,
Document $project,
Database $dbForPlatform,
- DeleteEvent $queueForDeletes,
- Event $queueForEvents
+ DeletePublisher $publisherForDeletes,
+ Event $queueForEvents,
+ Authorization $authorization,
) {
- $rule = $dbForPlatform->getDocument('rules', $ruleId);
+ $rule = $authorization->skip(fn () => $dbForPlatform->getDocument('rules', $ruleId));
if ($rule->isEmpty() || $rule->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::RULE_NOT_FOUND);
}
- $dbForPlatform->deleteDocument('rules', $rule->getId());
+ $authorization->skip(fn () => $dbForPlatform->deleteDocument('rules', $rule->getId()));
- $queueForDeletes
- ->setType(DELETE_TYPE_DOCUMENT)
- ->setDocument($rule);
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $project,
+ type: DELETE_TYPE_DOCUMENT,
+ document: $rule,
+ ));
$queueForEvents->setParam('ruleId', $rule->getId());
diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php
index a61ce80c4b..7cc8b5e59e 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Function;
-use Appwrite\Event\Certificate;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Certificate;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Proxy\Action;
use Appwrite\SDK\AuthType;
@@ -12,8 +12,8 @@ use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
-use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
+use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Logger\Log;
use Utopia\Platform\Scope\HTTP;
@@ -45,12 +45,14 @@ class Create extends Action
->label('audits.resource', 'rule/{response.$id}')
->label('sdk', new Method(
namespace: 'proxy',
- group: null,
+ group: 'rules',
name: 'createFunctionRule',
description: <<param('branch', '', new Text(255, 0), 'Name of VCS branch to deploy changes automatically', true)
->inject('response')
->inject('project')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->inject('queueForEvents')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('platform')
->inject('log')
+ ->inject('authorization')
->callback($this->action(...));
}
- public function action(string $domain, string $functionId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log)
- {
+ public function action(
+ string $domain,
+ string $functionId,
+ string $branch,
+ Response $response,
+ Document $project,
+ Certificate $publisherForCertificates,
+ Event $queueForEvents,
+ Database $dbForPlatform,
+ Database $dbForProject,
+ array $platform,
+ Log $log,
+ Authorization $authorization,
+ ) {
+
$this->validateDomainRestrictions($domain, $platform);
$function = $dbForProject->getDocument('functions', $functionId);
@@ -125,24 +141,28 @@ class Create extends Action
}
}
- try {
- $rule = $dbForPlatform->createDocument('rules', $rule);
- } catch (Duplicate $e) {
- throw new Exception(Exception::RULE_ALREADY_EXISTS);
- }
+ $rule = $this->createRule($rule, $dbForPlatform, $authorization);
if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) {
- $queueForCertificates
- ->setDomain(new Document([
+ $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: $project,
+ domain: new Document([
'domain' => $rule->getAttribute('domain'),
'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')),
- ]))
- ->setAction(Certificate::ACTION_GENERATION)
- ->trigger();
+ ]),
+ action: \Appwrite\Event\Certificate::ACTION_GENERATION,
+ ));
}
$queueForEvents->setParam('ruleId', $rule->getId());
+ // Rename 'created' status to 'unverified' for consistency.
+ // 'verifying' and 'verified' statuses stay as is.
+ // 'unverified' in the meaning of failed certificate generation stays as is.
+ if ($rule->getAttribute('status') === 'created') {
+ $rule->setAttribute('status', 'unverified');
+ }
+
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($rule, Response::MODEL_PROXY_RULE);
diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Get.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Get.php
index b88a4ffc06..103ab1fddc 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Get.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Get.php
@@ -10,6 +10,7 @@ use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
+use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
@@ -34,12 +35,12 @@ class Get extends Action
->label('scope', 'rules.read')
->label('sdk', new Method(
namespace: 'proxy',
- group: null,
+ group: 'rules',
name: 'getRule',
description: <<inject('response')
->inject('project')
->inject('dbForPlatform')
+ ->inject('authorization')
->callback($this->action(...));
}
@@ -58,15 +60,16 @@ class Get extends Action
string $ruleId,
Response $response,
Document $project,
- Database $dbForPlatform
+ Database $dbForPlatform,
+ Authorization $authorization,
) {
- $rule = $dbForPlatform->getDocument('rules', $ruleId);
+ $rule = $authorization->skip(fn () => $dbForPlatform->getDocument('rules', $ruleId));
if ($rule->isEmpty() || $rule->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::RULE_NOT_FOUND);
}
- $certificate = $dbForPlatform->getDocument('certificates', $rule->getAttribute('certificateId', ''));
+ $certificate = $authorization->skip(fn () => $dbForPlatform->getDocument('certificates', $rule->getAttribute('certificateId', '')));
// Give priority to certificate generation logs if present
if (!empty($certificate->getAttribute('logs', ''))) {
@@ -75,6 +78,13 @@ class Get extends Action
$rule->setAttribute('renewAt', $certificate->getAttribute('renewDate', ''));
+ // Rename 'created' status to 'unverified' for consistency.
+ // 'verifying' and 'verified' statuses stay as is.
+ // 'unverified' in the meaning of failed certificate generation stays as is.
+ if ($rule->getAttribute('status') === 'created') {
+ $rule->setAttribute('status', 'unverified');
+ }
+
$response->dynamic($rule, Response::MODEL_PROXY_RULE);
}
}
diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php
index 95c29f48e8..e8167b44a0 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Redirect;
-use Appwrite\Event\Certificate;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Certificate;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Proxy\Action;
use Appwrite\SDK\AuthType;
@@ -12,8 +12,8 @@ use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
-use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
+use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Logger\Log;
use Utopia\Platform\Scope\HTTP;
@@ -46,12 +46,14 @@ class Create extends Action
->label('audits.resource', 'rule/{response.$id}')
->label('sdk', new Method(
namespace: 'proxy',
- group: null,
+ group: 'rules',
name: 'createRedirectRule',
description: <<param('resourceType', '', new WhiteList(['site', 'function']), 'Type of parent resource.')
->inject('response')
->inject('project')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->inject('queueForEvents')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('platform')
->inject('log')
+ ->inject('authorization')
->callback($this->action(...));
}
- public function action(string $domain, string $url, int $statusCode, string $resourceId, string $resourceType, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log)
- {
+ public function action(
+ string $domain,
+ string $url,
+ int $statusCode,
+ string $resourceId,
+ string $resourceType,
+ Response $response,
+ Document $project,
+ Certificate $publisherForCertificates,
+ Event $queueForEvents,
+ Database $dbForPlatform,
+ Database $dbForProject,
+ array $platform,
+ Log $log,
+ Authorization $authorization,
+ ) {
+
$this->validateDomainRestrictions($domain, $platform);
$collection = match ($resourceType) {
'site' => 'sites',
- 'function' => 'functions'
+ 'function' => 'functions',
+ default => throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid resource type: ' . $resourceType),
};
$resource = $dbForProject->getDocument($collection, $resourceId);
if ($resource->isEmpty()) {
@@ -129,24 +148,28 @@ class Create extends Action
}
}
- try {
- $rule = $dbForPlatform->createDocument('rules', $rule);
- } catch (Duplicate $e) {
- throw new Exception(Exception::RULE_ALREADY_EXISTS);
- }
+ $rule = $this->createRule($rule, $dbForPlatform, $authorization);
if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) {
- $queueForCertificates
- ->setDomain(new Document([
+ $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: $project,
+ domain: new Document([
'domain' => $rule->getAttribute('domain'),
'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')),
- ]))
- ->setAction(Certificate::ACTION_GENERATION)
- ->trigger();
+ ]),
+ action: \Appwrite\Event\Certificate::ACTION_GENERATION,
+ ));
}
$queueForEvents->setParam('ruleId', $rule->getId());
+ // Rename 'created' status to 'unverified' for consistency.
+ // 'verifying' and 'verified' statuses stay as is.
+ // 'unverified' in the meaning of failed certificate generation stays as is.
+ if ($rule->getAttribute('status') === 'created') {
+ $rule->setAttribute('status', 'unverified');
+ }
+
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($rule, Response::MODEL_PROXY_RULE);
diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php
index ba99cefb42..ca45d73e13 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Site;
-use Appwrite\Event\Certificate;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Certificate;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Proxy\Action;
use Appwrite\SDK\AuthType;
@@ -12,8 +12,8 @@ use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
-use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
+use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Logger\Log;
use Utopia\Platform\Scope\HTTP;
@@ -45,12 +45,14 @@ class Create extends Action
->label('audits.resource', 'rule/{response.$id}')
->label('sdk', new Method(
namespace: 'proxy',
- group: null,
+ group: 'rules',
name: 'createSiteRule',
description: <<param('branch', '', new Text(255, 0), 'Name of VCS branch to deploy changes automatically', true)
->inject('response')
->inject('project')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->inject('queueForEvents')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('platform')
->inject('log')
+ ->inject('authorization')
->callback($this->action(...));
}
- public function action(string $domain, string $siteId, ?string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log)
- {
+ public function action(
+ string $domain,
+ string $siteId,
+ ?string $branch,
+ Response $response,
+ Document $project,
+ Certificate $publisherForCertificates,
+ Event $queueForEvents,
+ Database $dbForPlatform,
+ Database $dbForProject,
+ array $platform,
+ Log $log,
+ Authorization $authorization,
+ ) {
+
$this->validateDomainRestrictions($domain, $platform);
$site = $dbForProject->getDocument('sites', $siteId);
@@ -125,24 +141,28 @@ class Create extends Action
}
}
- try {
- $rule = $dbForPlatform->createDocument('rules', $rule);
- } catch (Duplicate $e) {
- throw new Exception(Exception::RULE_ALREADY_EXISTS);
- }
+ $rule = $this->createRule($rule, $dbForPlatform, $authorization);
if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) {
- $queueForCertificates
- ->setDomain(new Document([
+ $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: $project,
+ domain: new Document([
'domain' => $rule->getAttribute('domain'),
'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')),
- ]))
- ->setAction(Certificate::ACTION_GENERATION)
- ->trigger();
+ ]),
+ action: \Appwrite\Event\Certificate::ACTION_GENERATION,
+ ));
}
$queueForEvents->setParam('ruleId', $rule->getId());
+ // Rename 'created' status to 'unverified' for consistency.
+ // 'verifying' and 'verified' statuses stay as is.
+ // 'unverified' in the meaning of failed certificate generation stays as is.
+ if ($rule->getAttribute('status') === 'created') {
+ $rule->setAttribute('status', 'unverified');
+ }
+
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($rule, Response::MODEL_PROXY_RULE);
diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Status/Update.php
similarity index 68%
rename from src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php
rename to src/Appwrite/Platform/Modules/Proxy/Http/Rules/Status/Update.php
index 8a0d341132..1ad6f730b3 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Status/Update.php
@@ -1,9 +1,9 @@
setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
- ->setHttpPath('/v1/proxy/rules/:ruleId/verification')
- ->desc('Update rule verification status')
+ ->setHttpPath('/v1/proxy/rules/:ruleId/status')
+ ->httpAlias('/v1/proxy/rules/:ruleId/verification')
+ ->desc('Update rule status')
->groups(['api', 'proxy'])
->label('scope', 'rules.write')
->label('event', 'rules.[ruleId].update')
@@ -41,12 +43,12 @@ class Update extends Action
->label('audits.resource', 'rule/{response.$id}')
->label('sdk', new Method(
namespace: 'proxy',
- group: null,
- name: 'updateRuleVerification',
+ group: 'rules',
+ name: 'updateRuleStatus',
description: <<param('ruleId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Rule ID.', false, ['dbForProject'])
->inject('response')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->inject('queueForEvents')
->inject('project')
->inject('dbForPlatform')
->inject('log')
+ ->inject('authorization')
->callback($this->action(...));
}
public function action(
string $ruleId,
Response $response,
- Certificate $queueForCertificates,
+ Certificate $publisherForCertificates,
Event $queueForEvents,
Document $project,
Database $dbForPlatform,
- Log $log
+ Log $log,
+ Authorization $authorization,
) {
- $rule = $dbForPlatform->getDocument('rules', $ruleId);
+ $rule = $authorization->skip(fn () => $dbForPlatform->getDocument('rules', $ruleId));
if ($rule->isEmpty() || $rule->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::RULE_NOT_FOUND);
@@ -90,32 +94,33 @@ class Update extends Action
try {
$this->verifyRule($rule, $log);
// Reset logs and status for the rule
- $rule = $dbForPlatform->updateDocument('rules', $rule->getId(), new Document([
+ $rule = $authorization->skip(fn () => $dbForPlatform->updateDocument('rules', $rule->getId(), new Document([
'logs' => '',
'status' => RULE_STATUS_CERTIFICATE_GENERATING,
- ]));
+ ])));
$certificateId = $rule->getAttribute('certificateId', '');
// Reset logs for the associated certificate.
if (!empty($certificateId)) {
- $certificate = $dbForPlatform->updateDocument('certificates', $certificateId, new Document([
+ $certificate = $authorization->skip(fn () => $dbForPlatform->updateDocument('certificates', $certificateId, new Document([
'logs' => '',
- ]));
+ ])));
}
} catch (Exception $err) {
- $dbForPlatform->updateDocument('rules', $rule->getId(), new Document([
+ $authorization->skip(fn () => $dbForPlatform->updateDocument('rules', $rule->getId(), new Document([
'$updatedAt' => DateTime::now(),
- ]));
+ ])));
throw $err;
}
// Issue a TLS certificate when DNS verification is successful
- $queueForCertificates
- ->setDomain(new Document([
+ $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: $project,
+ domain: new Document([
'domain' => $rule->getAttribute('domain'),
'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')),
- ]))
- ->trigger();
+ ]),
+ ));
if (!empty($certificate)) {
$rule->setAttribute('renewAt', $certificate->getAttribute('renewDate', ''));
diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/XList.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/XList.php
index 19daf8c8d2..999b4c8d74 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/XList.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/XList.php
@@ -13,6 +13,7 @@ use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
+use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
@@ -39,12 +40,12 @@ class XList extends Action
->label('scope', 'rules.read')
->label('sdk', new Method(
namespace: 'proxy',
- group: null,
+ group: 'rules',
name: 'listRules',
description: <<param('queries', [], new Rules(), '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(', ', Rules::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)
+ ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true, deprecated: true)
->inject('response')
->inject('project')
->inject('dbForPlatform')
+ ->inject('authorization')
->callback($this->action(...));
}
public function action(
array $queries,
+ bool $total,
string $search,
- bool $includeTotal,
Response $response,
Document $project,
- Database $dbForPlatform
+ Database $dbForPlatform,
+ Authorization $authorization,
) {
try {
$queries = Query::parseQueries($queries);
@@ -91,7 +94,7 @@ class XList extends Action
}
$ruleId = $cursor->getValue();
- $cursorDocument = $dbForPlatform->getDocument('rules', $ruleId);
+ $cursorDocument = $authorization->skip(fn () => $dbForPlatform->getDocument('rules', $ruleId));
if ($cursorDocument->isEmpty()) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Rule '{$ruleId}' for the 'cursor' value not found.");
@@ -102,9 +105,9 @@ class XList extends Action
$filterQueries = Query::groupByType($queries)['filters'];
- $rules = $dbForPlatform->find('rules', $queries);
+ $rules = $authorization->skip(fn () => $dbForPlatform->find('rules', $queries));
foreach ($rules as $rule) {
- $certificate = $dbForPlatform->getDocument('certificates', $rule->getAttribute('certificateId', ''));
+ $certificate = $authorization->skip(fn () => $dbForPlatform->getDocument('certificates', $rule->getAttribute('certificateId', '')));
// Give priority to certificate generation logs if present
if (!empty($certificate->getAttribute('logs', ''))) {
@@ -112,11 +115,18 @@ class XList extends Action
}
$rule->setAttribute('renewAt', $certificate->getAttribute('renewDate', ''));
+
+ // Rename 'created' status to 'unverified' for consistency.
+ // 'verifying' and 'verified' statuses stay as is.
+ // 'unverified' in the meaning of failed certificate generation stays as is.
+ if ($rule->getAttribute('status') === 'created') {
+ $rule->setAttribute('status', 'unverified');
+ }
}
$response->dynamic(new Document([
'rules' => $rules,
- 'total' => $includeTotal ? $dbForPlatform->count('rules', $filterQueries, APP_LIMIT_COUNT) : 0,
+ 'total' => $total ? $authorization->skip(fn () => $dbForPlatform->count('rules', $filterQueries, APP_LIMIT_COUNT)) : 0,
]), Response::MODEL_PROXY_RULE_LIST);
}
}
diff --git a/src/Appwrite/Platform/Modules/Proxy/Services/Http.php b/src/Appwrite/Platform/Modules/Proxy/Services/Http.php
index 980c64cc54..b2a9de1933 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Services/Http.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Services/Http.php
@@ -8,7 +8,7 @@ use Appwrite\Platform\Modules\Proxy\Http\Rules\Function\Create as CreateFunction
use Appwrite\Platform\Modules\Proxy\Http\Rules\Get as GetRule;
use Appwrite\Platform\Modules\Proxy\Http\Rules\Redirect\Create as CreateRedirectRule;
use Appwrite\Platform\Modules\Proxy\Http\Rules\Site\Create as CreateSiteRule;
-use Appwrite\Platform\Modules\Proxy\Http\Rules\Verification\Update as UpdateRuleVerification;
+use Appwrite\Platform\Modules\Proxy\Http\Rules\Status\Update as UpdateRuleStatus;
use Appwrite\Platform\Modules\Proxy\Http\Rules\XList as ListRules;
use Utopia\Platform\Service;
@@ -26,6 +26,6 @@ class Http extends Service
$this->addAction(GetRule::getName(), new GetRule());
$this->addAction(ListRules::getName(), new ListRules());
$this->addAction(DeleteRule::getName(), new DeleteRule());
- $this->addAction(UpdateRuleVerification::getName(), new UpdateRuleVerification());
+ $this->addAction(UpdateRuleStatus::getName(), new UpdateRuleStatus());
}
}
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php
index 8a6964209f..d27755d106 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Sites\Http\Deployments;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -20,6 +21,7 @@ use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Http\Adapter\Swoole\Request;
+use Utopia\Lock\Exception\Contention as LockContention;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Storage\Device;
@@ -85,10 +87,11 @@ class Create extends Action
->inject('queueForEvents')
->inject('deviceForSites')
->inject('deviceForLocal')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('plan')
->inject('authorization')
->inject('platform')
+ ->inject('locks')
->callback($this->action(...));
}
@@ -107,10 +110,11 @@ class Create extends Action
Event $queueForEvents,
Device $deviceForSites,
Device $deviceForLocal,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
array $plan,
Authorization $authorization,
array $platform,
+ callable $locks,
) {
$activate = \strval($activate) === 'true' || \strval($activate) === '1';
@@ -177,15 +181,8 @@ class Create extends Action
throw new Exception(Exception::STORAGE_INVALID_CONTENT_RANGE);
}
- // TODO remove the condition that checks `$end === $fileSize` in next breaking version
- if ($end === $fileSize - 1 || $end === $fileSize) {
- //if it's a last chunks the chunk size might differ, so we set the $chunks and $chunk to notify it's last chunk
- $chunks = $chunk = -1;
- } else {
- // Calculate total number of chunks based on the chunk size i.e ($rangeEnd - $rangeStart)
- $chunks = (int) ceil($fileSize / ($end + 1 - $start));
- $chunk = (int) ($start / ($end + 1 - $start)) + 1;
- }
+ $chunks = (int) ceil($fileSize / APP_LIMIT_UPLOAD_CHUNK_SIZE);
+ $chunk = (int) ($start / APP_LIMIT_UPLOAD_CHUNK_SIZE) + 1;
}
if (!$fileSizeValidator->isValid($fileSize) && $siteSizeLimit !== 0) { // Check if file size is exceeding allowed limit
@@ -199,15 +196,38 @@ class Create extends Action
// Save to storage
$fileSize ??= $deviceForLocal->getFileSize($fileTmpName);
$path = $deviceForSites->getPath($deploymentId . '.' . \pathinfo($fileName, PATHINFO_EXTENSION));
- $deployment = $dbForProject->getDocument('deployments', $deploymentId);
+
+ $lockKey = 'sites:deployment:' . $project->getId() . ':' . $siteId . ':' . $deploymentId;
$metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)];
- if (!$deployment->isEmpty()) {
- $chunks = $deployment->getAttribute('sourceChunksTotal', 1);
- $metadata = $deployment->getAttribute('sourceMetadata', []);
- if ($chunk === -1) {
- $chunk = $chunks;
- }
+ $completed = false;
+
+ try {
+ $locks($lockKey, 600, function () use (&$chunks, $dbForProject, $deploymentId, &$metadata, &$completed, $response): void {
+ $deployment = $dbForProject->getDocument('deployments', $deploymentId);
+
+ if (!$deployment->isEmpty()) {
+ $chunks = $deployment->getAttribute('sourceChunksTotal', 1);
+ $uploaded = $deployment->getAttribute('sourceChunksUploaded', 0);
+ $metadata = $deployment->getAttribute('sourceMetadata', []);
+
+ if ($uploaded === $chunks) {
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($deployment, Response::MODEL_DEPLOYMENT);
+
+ $completed = true;
+ return;
+ }
+ }
+ }, timeout: 120.0);
+ } catch (LockContention) {
+ $response->addHeader('Retry-After', '5');
+ throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Deployment upload is busy. Try again.');
+ }
+
+ if ($completed) {
+ return;
}
$chunksUploaded = $deviceForSites->upload($fileTmpName, $path, $chunk, $chunks, $metadata);
@@ -226,178 +246,208 @@ class Create extends Action
$commands[] = $buildCommand;
}
- if ($chunksUploaded === $chunks) {
- if ($activate) {
- // Remove deploy for all other deployments.
- $activeDeployments = $dbForProject->find('deployments', [
- Query::equal('activate', [true]),
- Query::equal('resourceId', [$siteId]),
- Query::equal('resourceType', ['sites'])
- ]);
+ try {
+ $locks($lockKey, 600, function () use ($activate, $authorization, $commands, &$chunks, $chunksUploaded, $dbForPlatform, $dbForProject, $deploymentId, $deviceForSites, $fileSize, &$metadata, $outputDirectory, $path, $platform, $project, $publisherForBuilds, $queueForEvents, $response, &$site, $siteId, $type): void {
+ $deployment = $dbForProject->getDocument('deployments', $deploymentId);
+ $uploaded = 0;
- foreach ($activeDeployments as $activeDeployment) {
- $activeDeployment->setAttribute('activate', false);
- $dbForProject->updateDocument('deployments', $activeDeployment->getId(), new Document(['activate' => false]));
+ if (!$deployment->isEmpty()) {
+ $chunks = $deployment->getAttribute('sourceChunksTotal', 1);
+ $uploaded = $deployment->getAttribute('sourceChunksUploaded', 0);
+ $metadata = \array_merge($deployment->getAttribute('sourceMetadata', []), $metadata);
+
+ if ($uploaded === $chunks) {
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($deployment, Response::MODEL_DEPLOYMENT);
+ return;
+ }
}
- }
- $fileSize = $deviceForSites->getFileSize($path);
+ $chunksUploaded = max($uploaded, $chunksUploaded);
- if ($deployment->isEmpty()) {
- $deployment = $dbForProject->createDocument('deployments', new Document([
- '$id' => $deploymentId,
- '$permissions' => [
- Permission::read(Role::any()),
- Permission::update(Role::any()),
- Permission::delete(Role::any()),
- ],
- 'resourceInternalId' => $site->getSequence(),
- 'resourceId' => $site->getId(),
- 'resourceType' => 'sites',
- 'buildCommands' => \implode(' && ', $commands),
- 'startCommand' => $site->getAttribute('startCommand', ''),
- 'buildOutput' => $outputDirectory,
- 'adapter' => $site->getAttribute('adapter', ''),
- 'fallbackFile' => $site->getAttribute('fallbackFile', ''),
- 'sourcePath' => $path,
- 'sourceSize' => $fileSize,
- 'totalSize' => $fileSize,
- 'activate' => $activate,
- 'sourceMetadata' => $metadata,
- 'type' => $type,
- ]));
+ if ($chunksUploaded === $chunks && $uploaded < $chunks) {
+ if ($activate) {
+ // Remove deploy for all other deployments.
+ $activeDeployments = $dbForProject->find('deployments', [
+ Query::equal('activate', [true]),
+ Query::equal('resourceId', [$siteId]),
+ Query::equal('resourceType', ['sites'])
+ ]);
- $site = $site
- ->setAttribute('latestDeploymentId', $deployment->getId())
- ->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
- ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
- ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
- $dbForProject->updateDocument('sites', $site->getId(), new Document([
- 'latestDeploymentId' => $deployment->getId(),
- 'latestDeploymentInternalId' => $deployment->getSequence(),
- 'latestDeploymentCreatedAt' => $deployment->getCreatedAt(),
- 'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
- ]));
+ foreach ($activeDeployments as $activeDeployment) {
+ $dbForProject->updateDocument('deployments', $activeDeployment->getId(), new Document(['activate' => false]));
+ }
+ }
- $sitesDomain = $platform['sitesDomain'];
- $domain = ID::unique() . "." . $sitesDomain;
+ $fileSize = $deviceForSites->getFileSize($path);
- // TODO: (@Meldiron) Remove after 1.7.x migration
- $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5';
- $ruleId = $isMd5 ? md5($domain) : ID::unique();
+ if ($deployment->isEmpty()) {
+ $deployment = $dbForProject->createDocument('deployments', new Document([
+ '$id' => $deploymentId,
+ '$permissions' => [
+ Permission::read(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ 'resourceInternalId' => $site->getSequence(),
+ 'resourceId' => $site->getId(),
+ 'resourceType' => 'sites',
+ 'buildCommands' => \implode(' && ', $commands),
+ 'startCommand' => $site->getAttribute('startCommand', ''),
+ 'buildOutput' => $outputDirectory,
+ 'adapter' => $site->getAttribute('adapter', ''),
+ 'fallbackFile' => $site->getAttribute('fallbackFile', ''),
+ 'sourcePath' => $path,
+ 'sourceSize' => $fileSize,
+ 'totalSize' => $fileSize,
+ 'sourceChunksTotal' => $chunks,
+ 'sourceChunksUploaded' => $chunksUploaded,
+ 'activate' => $activate,
+ 'sourceMetadata' => $metadata,
+ 'type' => $type,
+ ]));
- $authorization->skip(
- fn () => $dbForPlatform->createDocument('rules', new Document([
- '$id' => $ruleId,
- 'projectId' => $project->getId(),
- 'projectInternalId' => $project->getSequence(),
- 'domain' => $domain,
- 'type' => 'deployment',
- 'trigger' => 'deployment',
- 'deploymentId' => $deployment->isEmpty() ? '' : $deployment->getId(),
- 'deploymentInternalId' => $deployment->isEmpty() ? '' : $deployment->getSequence(),
- 'deploymentResourceType' => 'site',
- 'deploymentResourceId' => $site->getId(),
- 'deploymentResourceInternalId' => $site->getSequence(),
- 'status' => 'verified',
- 'certificateId' => '',
- 'search' => implode(' ', [$ruleId, $domain]),
- 'owner' => 'Appwrite',
- 'region' => $project->getAttribute('region')
- ]))
- );
- } else {
- $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
- 'sourceSize' => $fileSize,
- 'sourceMetadata' => $metadata,
- ]));
- }
+ $site = $site
+ ->setAttribute('latestDeploymentId', $deployment->getId())
+ ->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
+ ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
+ ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
+ $dbForProject->updateDocument('sites', $site->getId(), new Document([
+ 'latestDeploymentId' => $deployment->getId(),
+ 'latestDeploymentInternalId' => $deployment->getSequence(),
+ 'latestDeploymentCreatedAt' => $deployment->getCreatedAt(),
+ 'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
+ ]));
- // Start the build
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($site)
- ->setDeployment($deployment);
- } else {
- if ($deployment->isEmpty()) {
- $deployment = $dbForProject->createDocument('deployments', new Document([
- '$id' => $deploymentId,
- '$permissions' => [
- Permission::read(Role::any()),
- Permission::update(Role::any()),
- Permission::delete(Role::any()),
- ],
- 'resourceInternalId' => $site->getSequence(),
- 'resourceId' => $site->getId(),
- 'resourceType' => 'sites',
- 'buildCommands' => \implode(' && ', $commands),
- 'startCommand' => $site->getAttribute('startCommand', ''),
- 'buildOutput' => $outputDirectory,
- 'adapter' => $site->getAttribute('adapter', ''),
- 'fallbackFile' => $site->getAttribute('fallbackFile', ''),
- 'sourcePath' => $path,
- 'sourceSize' => $fileSize,
- 'totalSize' => $fileSize,
- 'sourceChunksTotal' => $chunks,
- 'sourceChunksUploaded' => $chunksUploaded,
- 'activate' => $activate,
- 'sourceMetadata' => $metadata,
- 'type' => $type,
- ]));
+ $sitesDomain = $platform['sitesDomain'];
+ $domain = ID::unique() . "." . $sitesDomain;
- $site = $site
- ->setAttribute('latestDeploymentId', $deployment->getId())
- ->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
- ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
- ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
- $dbForProject->updateDocument('sites', $site->getId(), new Document([
- 'latestDeploymentId' => $site->getAttribute('latestDeploymentId'),
- 'latestDeploymentInternalId' => $site->getAttribute('latestDeploymentInternalId'),
- 'latestDeploymentCreatedAt' => $site->getAttribute('latestDeploymentCreatedAt'),
- 'latestDeploymentStatus' => $site->getAttribute('latestDeploymentStatus'),
- ]));
+ // TODO: (@Meldiron) Remove after 1.7.x migration
+ $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5';
+ $ruleId = $isMd5 ? md5($domain) : ID::unique();
- $sitesDomain = $platform['sitesDomain'];
- $domain = ID::unique() . "." . $sitesDomain;
- $ruleId = md5($domain);
- $authorization->skip(
- fn () => $dbForPlatform->createDocument('rules', new Document([
- '$id' => $ruleId,
- 'projectId' => $project->getId(),
- 'projectInternalId' => $project->getSequence(),
- 'domain' => $domain,
- 'type' => 'deployment',
- 'trigger' => 'deployment',
- 'deploymentId' => $deployment->isEmpty() ? '' : $deployment->getId(),
- 'deploymentInternalId' => $deployment->isEmpty() ? '' : $deployment->getSequence(),
- 'deploymentResourceType' => 'site',
- 'deploymentResourceId' => $site->getId(),
- 'deploymentResourceInternalId' => $site->getSequence(),
- 'status' => 'verified',
- 'certificateId' => '',
- 'search' => implode(' ', [$ruleId, $domain]),
- 'owner' => 'Appwrite',
- 'region' => $project->getAttribute('region')
- ]))
- );
- } else {
- $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
- 'sourceChunksUploaded' => $chunksUploaded,
- 'sourceMetadata' => $metadata,
- ]));
- }
+ $authorization->skip(
+ fn () => $dbForPlatform->createDocument('rules', new Document([
+ '$id' => $ruleId,
+ 'projectId' => $project->getId(),
+ 'projectInternalId' => $project->getSequence(),
+ 'domain' => $domain,
+ 'type' => 'deployment',
+ 'trigger' => 'deployment',
+ 'deploymentId' => $deployment->isEmpty() ? '' : $deployment->getId(),
+ 'deploymentInternalId' => $deployment->isEmpty() ? '' : $deployment->getSequence(),
+ 'deploymentResourceType' => 'site',
+ 'deploymentResourceId' => $site->getId(),
+ 'deploymentResourceInternalId' => $site->getSequence(),
+ 'status' => 'verified',
+ 'certificateId' => '',
+ 'search' => implode(' ', [$ruleId, $domain]),
+ 'owner' => 'Appwrite',
+ 'region' => $project->getAttribute('region')
+ ]))
+ );
+ } else {
+ $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
+ 'sourceSize' => $fileSize,
+ 'sourceChunksUploaded' => $chunksUploaded,
+ 'sourceMetadata' => $metadata,
+ ]));
+ }
+
+ // Start the build
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $site,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ));
+ } else {
+ if ($deployment->isEmpty()) {
+ $deployment = $dbForProject->createDocument('deployments', new Document([
+ '$id' => $deploymentId,
+ '$permissions' => [
+ Permission::read(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ 'resourceInternalId' => $site->getSequence(),
+ 'resourceId' => $site->getId(),
+ 'resourceType' => 'sites',
+ 'buildCommands' => \implode(' && ', $commands),
+ 'startCommand' => $site->getAttribute('startCommand', ''),
+ 'buildOutput' => $outputDirectory,
+ 'adapter' => $site->getAttribute('adapter', ''),
+ 'fallbackFile' => $site->getAttribute('fallbackFile', ''),
+ 'sourcePath' => $path,
+ 'sourceSize' => $fileSize,
+ 'totalSize' => $fileSize,
+ 'sourceChunksTotal' => $chunks,
+ 'sourceChunksUploaded' => $chunksUploaded,
+ 'activate' => $activate,
+ 'sourceMetadata' => $metadata,
+ 'type' => $type,
+ ]));
+
+ $site = $site
+ ->setAttribute('latestDeploymentId', $deployment->getId())
+ ->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
+ ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
+ ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
+ $dbForProject->updateDocument('sites', $site->getId(), new Document([
+ 'latestDeploymentId' => $site->getAttribute('latestDeploymentId'),
+ 'latestDeploymentInternalId' => $site->getAttribute('latestDeploymentInternalId'),
+ 'latestDeploymentCreatedAt' => $site->getAttribute('latestDeploymentCreatedAt'),
+ 'latestDeploymentStatus' => $site->getAttribute('latestDeploymentStatus'),
+ ]));
+
+ $sitesDomain = $platform['sitesDomain'];
+ $domain = ID::unique() . "." . $sitesDomain;
+ $ruleId = md5($domain);
+ $authorization->skip(
+ fn () => $dbForPlatform->createDocument('rules', new Document([
+ '$id' => $ruleId,
+ 'projectId' => $project->getId(),
+ 'projectInternalId' => $project->getSequence(),
+ 'domain' => $domain,
+ 'type' => 'deployment',
+ 'trigger' => 'deployment',
+ 'deploymentId' => $deployment->isEmpty() ? '' : $deployment->getId(),
+ 'deploymentInternalId' => $deployment->isEmpty() ? '' : $deployment->getSequence(),
+ 'deploymentResourceType' => 'site',
+ 'deploymentResourceId' => $site->getId(),
+ 'deploymentResourceInternalId' => $site->getSequence(),
+ 'status' => 'verified',
+ 'certificateId' => '',
+ 'search' => implode(' ', [$ruleId, $domain]),
+ 'owner' => 'Appwrite',
+ 'region' => $project->getAttribute('region')
+ ]))
+ );
+ } else {
+ $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
+ 'sourceChunksUploaded' => $chunksUploaded,
+ 'sourceMetadata' => $metadata,
+ ]));
+ }
+ }
+
+ $metadata = null;
+
+ if ($chunksUploaded === $chunks) {
+ $queueForEvents
+ ->setParam('siteId', $site->getId())
+ ->setParam('deploymentId', $deployment->getId());
+ }
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($deployment, Response::MODEL_DEPLOYMENT);
+ }, timeout: 120.0);
+ } catch (LockContention) {
+ $response->addHeader('Retry-After', '5');
+ throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Deployment upload is busy. Try again.');
}
-
-
-
- $metadata = null;
-
- $queueForEvents
- ->setParam('siteId', $site->getId())
- ->setParam('deploymentId', $deployment->getId());
-
- $response
- ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
- ->dynamic($deployment, Response::MODEL_DEPLOYMENT);
}
}
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Delete.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Delete.php
index efea79395f..b50e9b54f4 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Delete.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Delete.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Sites\Http\Deployments;
-use Appwrite\Event\Delete as DeleteEvent;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Delete as DeleteMessage;
+use Appwrite\Event\Publisher\Delete as DeletePublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -59,7 +60,7 @@ class Delete extends Action
->param('deploymentId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Deployment ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('queueForEvents')
->inject('deviceForSites')
->callback($this->action(...));
@@ -70,7 +71,7 @@ class Delete extends Action
string $deploymentId,
Response $response,
Database $dbForProject,
- DeleteEvent $queueForDeletes,
+ DeletePublisher $publisherForDeletes,
Event $queueForEvents,
Device $deviceForSites
) {
@@ -130,9 +131,11 @@ class Delete extends Action
->setParam('siteId', $site->getId())
->setParam('deploymentId', $deployment->getId());
- $queueForDeletes
- ->setType(DELETE_TYPE_DOCUMENT)
- ->setDocument($deployment);
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $queueForEvents->getProject(),
+ type: DELETE_TYPE_DOCUMENT,
+ document: $deployment,
+ ));
$response->noContent();
}
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php
index 546549604b..b3619c6017 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Sites\Http\Deployments\Duplicate;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
@@ -63,7 +64,7 @@ class Create extends Action
->inject('dbForProject')
->inject('dbForPlatform')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('deviceForSites')
->inject('authorization')
->inject('platform')
@@ -79,7 +80,7 @@ class Create extends Action
Database $dbForProject,
Database $dbForPlatform,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
Device $deviceForSites,
Authorization $authorization,
array $platform
@@ -177,10 +178,13 @@ class Create extends Action
]))
);
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($site)
- ->setDeployment($deployment);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $site,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ));
$queueForEvents
->setParam('siteId', $site->getId())
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php
index f648c57a83..29854d473b 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Sites\Http\Deployments\Template;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -77,7 +78,7 @@ class Create extends Base
->inject('dbForPlatform')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('gitHub')
->inject('authorization')
->inject('platform')
@@ -98,7 +99,7 @@ class Create extends Base
Database $dbForPlatform,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
GitHub $github,
Authorization $authorization,
array $platform
@@ -130,7 +131,7 @@ class Create extends Base
installation: $installation,
dbForProject: $dbForProject,
dbForPlatform: $dbForPlatform,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: $activate,
@@ -223,11 +224,14 @@ class Create extends Base
$this->updateEmptyManualRule($project, $site, $deployment, $dbForPlatform, $authorization);
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($site)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $site,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
$queueForEvents
->setParam('siteId', $site->getId())
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php
index 4351dd8dd9..d34b8c4055 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Sites\Http\Deployments\Vcs;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -71,7 +71,7 @@ class Create extends Base
->inject('dbForPlatform')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('gitHub')
->inject('authorization')
->inject('platform')
@@ -89,7 +89,7 @@ class Create extends Base
Database $dbForPlatform,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
GitHub $github,
Authorization $authorization,
array $platform
@@ -111,7 +111,7 @@ class Create extends Base
installation: $installation,
dbForProject: $dbForProject,
dbForPlatform: $dbForPlatform,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: $activate,
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php
index a9198f937b..3dccd687ea 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php
@@ -116,7 +116,7 @@ class XList extends Base
$grouped = Query::groupByType($queries);
$filterQueries = $grouped['filters'];
- $selectQueries = $grouped['selections'] ?? [];
+ $selectQueries = $grouped['selections'];
try {
$results = $dbForProject->find('deployments', $queries);
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php
index d01d0d8ca7..9f3d46ffd0 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php
@@ -19,6 +19,7 @@ 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\Boolean;
use Utopia\Validator\Range;
use Utopia\Validator\Text;
@@ -78,6 +79,8 @@ class Create extends Base
->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the site.', true)
->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the site? In silent mode, comments will not be made on commits and pull requests.', true)
->param('providerRootDirectory', '', new Text(128, 0), 'Path to site code in the linked repo.', true)
+ ->param('providerBranches', [], new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of branch name patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all branches.', true)
+ ->param('providerPaths', [], new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of file path patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all file changes.', true)
->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification(
$plan,
Config::getParam('specifications', []),
@@ -118,6 +121,8 @@ class Create extends Base
string $providerBranch,
bool $providerSilentMode,
string $providerRootDirectory,
+ array $providerBranches,
+ array $providerPaths,
string $buildSpecification,
string $runtimeSpecification,
int $deploymentRetention,
@@ -173,6 +178,8 @@ class Create extends Base
'providerBranch' => $providerBranch,
'providerRootDirectory' => $providerRootDirectory,
'providerSilentMode' => $providerSilentMode,
+ 'providerBranches' => $providerBranches,
+ 'providerPaths' => $providerPaths,
'buildSpecification' => $buildSpecification,
'runtimeSpecification' => $runtimeSpecification,
'buildRuntime' => $buildRuntime,
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Delete.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Delete.php
index ebc192b6e6..50b070d098 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Delete.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Delete.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Sites\Http\Sites;
-use Appwrite\Event\Delete as DeleteEvent;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Delete as DeleteMessage;
+use Appwrite\Event\Publisher\Delete as DeletePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -56,7 +57,7 @@ class Delete extends Base
->param('siteId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Site ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('queueForEvents')
->callback($this->action(...));
}
@@ -65,7 +66,7 @@ class Delete extends Base
string $siteId,
Response $response,
Database $dbForProject,
- DeleteEvent $queueForDeletes,
+ DeletePublisher $publisherForDeletes,
Event $queueForEvents
) {
$site = $dbForProject->getDocument('sites', $siteId);
@@ -78,9 +79,11 @@ class Delete extends Base
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove site from DB');
}
- $queueForDeletes
- ->setType(DELETE_TYPE_DOCUMENT)
- ->setDocument($site);
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $queueForEvents->getProject(),
+ type: DELETE_TYPE_DOCUMENT,
+ document: $site,
+ ));
$queueForEvents->setParam('siteId', $site->getId());
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php
index dd9bedffb5..bfd8c9f198 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Sites\Http\Sites;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\Platform\Modules\Compute\Validator\Specification;
@@ -22,7 +22,9 @@ use Utopia\Http\Adapter\Swoole\Request;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
+use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
+use Utopia\Validator\Nullable;
use Utopia\Validator\Range;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
@@ -81,6 +83,8 @@ class Update extends Base
->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the site.', true)
->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the site? In silent mode, comments will not be made on commits and pull requests.', true)
->param('providerRootDirectory', '', new Text(128, 0), 'Path to site code in the linked repo.', true)
+ ->param('providerBranches', null, new Nullable(new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'List of branch name patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all branches.', true)
+ ->param('providerPaths', null, new Nullable(new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'List of file path patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all file changes.', true)
->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification(
$plan,
Config::getParam('specifications', []),
@@ -99,10 +103,11 @@ class Update extends Base
->inject('dbForProject')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('dbForPlatform')
->inject('gitHub')
->inject('executor')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -125,6 +130,8 @@ class Update extends Base
string $providerBranch,
bool $providerSilentMode,
string $providerRootDirectory,
+ ?array $providerBranches,
+ ?array $providerPaths,
string $buildSpecification,
string $runtimeSpecification,
int $deploymentRetention,
@@ -133,10 +140,11 @@ class Update extends Base
Database $dbForProject,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
Database $dbForPlatform,
GitHub $github,
- Executor $executor
+ Executor $executor,
+ array $platform
) {
if (!empty($adapter)) {
$configFramework = Config::getParam('frameworks')[$framework] ?? [];
@@ -164,10 +172,6 @@ class Update extends Base
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When connecting to VCS (Version Control System), you need to provide "installationId" and "providerBranch".');
}
- if ($site->isEmpty()) {
- throw new Exception(Exception::SITE_NOT_FOUND);
- }
-
if (empty($framework)) {
$framework = $site->getAttribute('framework');
}
@@ -273,6 +277,8 @@ class Update extends Base
'providerBranch' => $providerBranch,
'providerRootDirectory' => $providerRootDirectory,
'providerSilentMode' => $providerSilentMode,
+ 'providerBranches' => $providerBranches ?? $site->getAttribute('providerBranches', []),
+ 'providerPaths' => $providerPaths ?? $site->getAttribute('providerPaths', []),
'buildSpecification' => $buildSpecification,
'runtimeSpecification' => $runtimeSpecification,
'search' => implode(' ', [$siteId, $name, $framework]),
@@ -283,7 +289,7 @@ class Update extends Base
// Redeploy logic
if (!$isConnected && !empty($providerRepositoryId)) {
- $this->redeployVcsFunction($request, $site, $project, $installation, $dbForProject, $queueForBuilds, new Document(), $github, true);
+ $this->redeployVcsFunction($request, $site, $project, $installation, $dbForProject, $publisherForBuilds, new Document(), $github, true, $platform);
}
$queueForEvents->setParam('siteId', $site->getId());
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php
index a6768462d1..85968c7550 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php
@@ -121,6 +121,7 @@ class Get extends Base
$format = match ($days['period']) {
'1h' => 'Y-m-d\TH:00:00.000P',
'1d' => 'Y-m-d\T00:00:00.000P',
+ default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']),
};
foreach ($metrics as $metric) {
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php
index a90cb0cab9..636889f6c0 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php
@@ -2,6 +2,7 @@
namespace Appwrite\Platform\Modules\Sites\Http\Usage;
+use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
@@ -107,6 +108,7 @@ class XList extends Base
$format = match ($days['period']) {
'1h' => 'Y-m-d\TH:00:00.000P',
'1d' => 'Y-m-d\T00:00:00.000P',
+ default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']),
};
foreach ($metrics as $metric) {
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php
index 04b30fbc9c..edd3412b8f 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php
@@ -2,11 +2,13 @@
namespace Appwrite\Platform\Modules\Sites\Http\Variables;
+use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
+use Appwrite\Utopia\Database\Validator\CustomId;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
@@ -36,6 +38,7 @@ class Create extends Base
->groups(['api', 'sites'])
->label('scope', 'sites.write')
->label('resourceType', RESOURCE_TYPE_SITES)
+ ->label('event', 'variables.[variableId].create')
->label('audits.event', 'variable.create')
->label('audits.resource', 'site/{request.siteId}')
->label('sdk', new Method(
@@ -54,16 +57,18 @@ class Create extends Base
]
))
->param('siteId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Site unique ID.', false, ['dbForProject'])
+ ->param('variableId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject'])
->param('key', null, new Text(Database::LENGTH_KEY), 'Variable key. Max length: ' . Database::LENGTH_KEY . ' chars.', false)
->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.', false)
->param('secret', true, new Boolean(), 'Secret variables can be updated or deleted, but only sites can read them during build and runtime.', true)
->inject('response')
+ ->inject('queueForEvents')
->inject('dbForProject')
->inject('project')
->callback($this->action(...));
}
- public function action(string $siteId, string $key, string $value, bool $secret, Response $response, Database $dbForProject, Document $project)
+ public function action(string $siteId, string $variableId, string $key, string $value, bool $secret, Response $response, QueueEvent $queueForEvents, Database $dbForProject, Document $project)
{
$site = $dbForProject->getDocument('sites', $siteId);
@@ -71,7 +76,7 @@ class Create extends Base
throw new Exception(Exception::SITE_NOT_FOUND);
}
- $variableId = ID::unique();
+ $variableId = ($variableId === 'unique()') ? ID::unique() : $variableId;
$teamId = $project->getAttribute('teamId', '');
$variable = new Document([
@@ -96,6 +101,8 @@ class Create extends Base
'live' => false,
]));
+ $queueForEvents->setParam('variableId', $variable->getId());
+
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($variable, Response::MODEL_VARIABLE);
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php
index 703806f1aa..74c638bddc 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php
@@ -2,6 +2,7 @@
namespace Appwrite\Platform\Modules\Sites\Http\Variables;
+use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -33,6 +34,7 @@ class Delete extends Base
->groups(['api', 'sites'])
->label('scope', 'sites.write')
->label('resourceType', RESOURCE_TYPE_SITES)
+ ->label('event', 'variables.[variableId].delete')
->label('audits.event', 'variable.delete')
->label('audits.resource', 'site/{request.siteId}')
->label('sdk', new Method(
@@ -54,11 +56,12 @@ class Delete extends Base
->param('siteId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Site unique ID.', false, ['dbForProject'])
->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
->inject('response')
+ ->inject('queueForEvents')
->inject('dbForProject')
->callback($this->action(...));
}
- public function action(string $siteId, string $variableId, Response $response, Database $dbForProject)
+ public function action(string $siteId, string $variableId, Response $response, QueueEvent $queueForEvents, Database $dbForProject)
{
$site = $dbForProject->getDocument('sites', $siteId);
@@ -67,11 +70,7 @@ class Delete extends Base
}
$variable = $dbForProject->getDocument('variables', $variableId);
- if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') {
- throw new Exception(Exception::VARIABLE_NOT_FOUND);
- }
-
- if ($variable === false || $variable->isEmpty()) {
+ if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') {
throw new Exception(Exception::VARIABLE_NOT_FOUND);
}
@@ -81,6 +80,8 @@ class Delete extends Base
'live' => false,
]));
+ $queueForEvents->setParam('variableId', $variable->getId());
+
$response->noContent();
}
}
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php
index 54522c0ec7..2fcb051996 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php
@@ -66,7 +66,6 @@ class Get extends Base
$variable = $dbForProject->getDocument('variables', $variableId);
if (
- $variable === false ||
$variable->isEmpty() ||
$variable->getAttribute('resourceInternalId') !== $site->getSequence() ||
$variable->getAttribute('resourceType') !== 'site'
@@ -74,10 +73,6 @@ class Get extends Base
throw new Exception(Exception::VARIABLE_NOT_FOUND);
}
- if ($variable === false || $variable->isEmpty()) {
- throw new Exception(Exception::VARIABLE_NOT_FOUND);
- }
-
$response->dynamic($variable, Response::MODEL_VARIABLE);
}
}
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php
index 99f68a45df..0ed7414b9d 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php
@@ -2,6 +2,7 @@
namespace Appwrite\Platform\Modules\Sites\Http\Variables;
+use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -35,6 +36,7 @@ class Update extends Base
->desc('Update variable')
->groups(['api', 'sites'])
->label('scope', 'sites.write')
+ ->label('event', 'variables.[variableId].update')
->label('audits.event', 'variable.update')
->label('audits.resource', 'site/{request.siteId}')
->label('resourceType', RESOURCE_TYPE_SITES)
@@ -55,10 +57,11 @@ class Update extends Base
))
->param('siteId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Site unique ID.', false, ['dbForProject'])
->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
- ->param('key', null, new Text(255), 'Variable key. Max length: 255 chars.', false)
+ ->param('key', null, new Nullable(new Text(255, 0)), 'Variable key. Max length: 255 chars.', true)
->param('value', null, new Nullable(new Text(8192, 0)), 'Variable value. Max length: 8192 chars.', true)
->param('secret', null, new Nullable(new Boolean()), 'Secret variables can be updated or deleted, but only sites can read them during build and runtime.', true)
->inject('response')
+ ->inject('queueForEvents')
->inject('dbForProject')
->callback($this->action(...));
}
@@ -66,10 +69,11 @@ class Update extends Base
public function action(
string $siteId,
string $variableId,
- string $key,
+ ?string $key,
?string $value,
?bool $secret,
Response $response,
+ QueueEvent $queueForEvents,
Database $dbForProject
) {
$site = $dbForProject->getDocument('sites', $siteId);
@@ -79,7 +83,7 @@ class Update extends Base
}
$variable = $dbForProject->getDocument('variables', $variableId);
- if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') {
+ if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') {
throw new Exception(Exception::VARIABLE_NOT_FOUND);
}
@@ -87,19 +91,27 @@ class Update extends Base
throw new Exception(Exception::VARIABLE_CANNOT_UNSET_SECRET);
}
- $variable
- ->setAttribute('key', $key)
- ->setAttribute('value', $value ?? $variable->getAttribute('value'))
- ->setAttribute('secret', $secret ?? $variable->getAttribute('secret'))
- ->setAttribute('search', implode(' ', [$variableId, $site->getId(), $key, 'site']));
+ if (\is_null($key) && \is_null($value) && \is_null($secret)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID);
+ }
+
+ $updates = new Document();
+
+ if (!\is_null($key)) {
+ $updates->setAttribute('key', $key);
+ $updates->setAttribute('search', implode(' ', [$variableId, $site->getId(), $key, 'site']));
+ }
+
+ if (!\is_null($value)) {
+ $updates->setAttribute('value', $value);
+ }
+
+ if (!\is_null($secret)) {
+ $updates->setAttribute('secret', $secret);
+ }
try {
- $dbForProject->updateDocument('variables', $variable->getId(), new Document([
- 'key' => $variable->getAttribute('key'),
- 'value' => $variable->getAttribute('value'),
- 'secret' => $variable->getAttribute('secret'),
- 'search' => $variable->getAttribute('search'),
- ]));
+ $variable = $dbForProject->updateDocument('variables', $variable->getId(), $updates);
} catch (DuplicateException $th) {
throw new Exception(Exception::VARIABLE_ALREADY_EXISTS);
}
@@ -108,6 +120,8 @@ class Update extends Base
'live' => false,
]));
+ $queueForEvents->setParam('variableId', $variable->getId());
+
$response->dynamic($variable, Response::MODEL_VARIABLE);
}
}
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/XList.php
index 669aa8be98..1270fe4925 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/XList.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/XList.php
@@ -7,12 +7,18 @@ use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
+use Appwrite\Utopia\Database\Validator\Queries\Variables;
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\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
+use Utopia\Validator\Boolean;
class XList extends Base
{
@@ -51,13 +57,20 @@ class XList extends Base
)
)
->param('siteId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Site unique ID.', false, ['dbForProject'])
+ ->param('queries', [], new Variables(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Variables::ALLOWED_ATTRIBUTES), 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(...));
}
+ /**
+ * @param array $queries
+ */
public function action(
string $siteId,
+ array $queries,
+ bool $includeTotal,
Response $response,
Database $dbForProject
) {
@@ -67,9 +80,51 @@ class XList extends Base
throw new Exception(Exception::SITE_NOT_FOUND);
}
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $queries[] = Query::equal('resourceType', ['site']);
+ $queries[] = Query::equal('resourceInternalId', [$site->getSequence()]);
+ $queries[] = Query::orderAsc();
+
+ $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());
+ }
+
+ $variableId = $cursor->getValue();
+ $cursorDocument = $dbForProject->findOne('variables', [
+ Query::equal('$id', [$variableId]),
+ Query::equal('resourceType', ['site']),
+ Query::equal('resourceInternalId', [$site->getSequence()]),
+ ]);
+
+ if ($cursorDocument->isEmpty()) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Variable '{$variableId}' for the 'cursor' value not found.");
+ }
+
+ $cursor->setValue($cursorDocument);
+ }
+
+ $filterQueries = Query::groupByType($queries)['filters'];
+
+ try {
+ $variables = $dbForProject->find('variables', $queries);
+ $total = $includeTotal ? $dbForProject->count('variables', $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([
- 'variables' => $site->getAttribute('vars', []),
- 'total' => \count($site->getAttribute('vars', [])),
+ 'variables' => $variables,
+ 'total' => $total,
]), Response::MODEL_VARIABLE_LIST);
}
}
diff --git a/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php b/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php
new file mode 100644
index 0000000000..ef2ace34ff
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php
@@ -0,0 +1,20 @@
+param('bucketId', '', new UID(), 'Bucket unique ID.')
->inject('response')
->inject('dbForProject')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('queueForEvents')
->callback($this->action(...));
}
@@ -62,7 +63,7 @@ class Delete extends Action
string $bucketId,
Response $response,
Database $dbForProject,
- DeleteEvent $queueForDeletes,
+ DeletePublisher $publisherForDeletes,
Event $queueForEvents
) {
$bucket = $dbForProject->getDocument('buckets', $bucketId);
@@ -75,9 +76,11 @@ class Delete extends Action
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove bucket from DB');
}
- $queueForDeletes
- ->setType(DELETE_TYPE_DOCUMENT)
- ->setDocument($bucket);
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $queueForEvents->getProject(),
+ type: DELETE_TYPE_DOCUMENT,
+ document: $bucket,
+ ));
$queueForEvents
->setParam('bucketId', $bucket->getId())
diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php
index c5f4f3dccd..8530475f0c 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php
@@ -29,6 +29,7 @@ use Utopia\Database\Validator\Authorization\Input;
use Utopia\Database\Validator\Permissions;
use Utopia\Database\Validator\UID;
use Utopia\Http\Adapter\Swoole\Request;
+use Utopia\Lock\Exception\Contention as LockContention;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Storage\Device;
@@ -86,12 +87,13 @@ class Create extends Action
->inject('request')
->inject('response')
->inject('dbForProject')
+ ->inject('project')
->inject('user')
->inject('queueForEvents')
- ->inject('mode')
->inject('deviceForFiles')
->inject('deviceForLocal')
->inject('authorization')
+ ->inject('locks')
->callback($this->action(...));
}
@@ -103,12 +105,13 @@ class Create extends Action
Request $request,
Response $response,
Database $dbForProject,
+ Document $project,
User $user,
Event $queueForEvents,
- string $mode,
Device $deviceForFiles,
Device $deviceForLocal,
- Authorization $authorization
+ Authorization $authorization,
+ callable $locks
) {
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
@@ -204,15 +207,8 @@ class Create extends Action
throw new Exception(Exception::STORAGE_INVALID_APPWRITE_ID);
}
- // TODO remove the condition that checks `$end === $fileSize` in next breaking version
- if ($end === $fileSize - 1 || $end === $fileSize) {
- //if it's a last chunks the chunk size might differ, so we set the $chunks and $chunk to -1 notify it's last chunk
- $chunks = $chunk = -1;
- } else {
- // Calculate total number of chunks based on the chunk size i.e ($rangeEnd - $rangeStart)
- $chunks = (int) ceil($fileSize / ($end + 1 - $start));
- $chunk = (int) ($start / ($end + 1 - $start)) + 1;
- }
+ $chunks = (int) ceil($fileSize / APP_LIMIT_UPLOAD_CHUNK_SIZE);
+ $chunk = (int) ($start / APP_LIMIT_UPLOAD_CHUNK_SIZE) + 1;
}
/**
@@ -241,223 +237,284 @@ class Create extends Action
$path = $deviceForFiles->getPath($fileId . '.' . \pathinfo($fileName, PATHINFO_EXTENSION));
$path = str_ireplace($deviceForFiles->getRoot(), $deviceForFiles->getRoot() . DIRECTORY_SEPARATOR . $bucket->getId(), $path); // Add bucket id to path after root
- $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId);
+ $lockKey = 'storage:file:' . $project->getId() . ':' . $bucket->getId() . ':' . $fileId;
$metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)];
- if (!$file->isEmpty()) {
- $chunks = $file->getAttribute('chunksTotal', 1);
- $uploaded = $file->getAttribute('chunksUploaded', 0);
- $metadata = $file->getAttribute('metadata', []);
+ $completed = false;
- if ($chunk === -1) {
- $chunk = $chunks;
+ $mergeUploadMetadata = function (array $stored, array $current): array {
+ $merged = \array_merge($stored, $current);
+
+ if (isset($stored['parts']) || isset($current['parts'])) {
+ $parts = $stored['parts'] ?? [];
+ foreach (($current['parts'] ?? []) as $part => $value) {
+ $parts[(int) $part] = $value;
+ }
+ \ksort($parts);
+
+ $merged['parts'] = $parts;
+ $merged['chunks'] = \count($parts);
}
- if ($uploaded === $chunks) {
- throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS);
- }
- } else {
- // Guard against manually setting range header for single chunk upload
- if ($chunks === -1) {
- $chunks = 1;
- $chunk = 1;
- }
+ return $merged;
+ };
+
+ try {
+ $locks($lockKey, 600, function () use ($bucket, &$chunks, $contentRange, $dbForProject, $deviceForFiles, $fileId, $fileName, $fileSize, &$metadata, $path, $permissions, $response, &$completed): void {
+ $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId);
+ if (!$file->isEmpty()) {
+ $chunks = $file->getAttribute('chunksTotal', 1);
+ $uploaded = $file->getAttribute('chunksUploaded', 0);
+ $metadata = $file->getAttribute('metadata', []);
+
+ if ($uploaded === $chunks) {
+ if (empty($contentRange)) {
+ throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS);
+ }
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_OK)
+ ->dynamic($file, Response::MODEL_FILE);
+
+ $completed = true;
+ return;
+ }
+ }
+
+ if ($file->isEmpty()) {
+ $deviceForFiles->prepareUpload($path, $metadata['content_type'] ?? '', $chunks, $metadata);
+
+ if (!empty($contentRange)) {
+ $doc = new Document([
+ '$id' => ID::custom($fileId),
+ '$permissions' => $permissions,
+ 'bucketId' => $bucket->getId(),
+ 'bucketInternalId' => $bucket->getSequence(),
+ 'name' => $fileName,
+ 'path' => $path,
+ 'signature' => '',
+ 'mimeType' => '',
+ 'sizeOriginal' => $fileSize,
+ 'sizeActual' => 0,
+ 'algorithm' => '',
+ 'comment' => '',
+ 'chunksTotal' => $chunks,
+ 'chunksUploaded' => 0,
+ 'search' => implode(' ', [$fileId, $fileName]),
+ 'metadata' => $metadata,
+ ]);
+
+ try {
+ $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc);
+ } catch (DuplicateException) {
+ throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS);
+ } catch (NotFoundException) {
+ throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
+ }
+ }
+ }
+ }, timeout: 120.0);
+ } catch (LockContention) {
+ $response->addHeader('Retry-After', '5');
+ throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'File upload is busy. Try again.');
}
- $chunksUploaded = $deviceForFiles->upload($fileTmpName, $path, $chunk, $chunks, $metadata);
-
- if (empty($chunksUploaded)) {
- throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed uploading file');
+ if ($completed) {
+ return;
}
- if ($chunksUploaded === $chunks) {
- if (System::getEnv('_APP_STORAGE_ANTIVIRUS') === 'enabled' && $bucket->getAttribute('antivirus', true) && $fileSize <= APP_LIMIT_ANTIVIRUS && $deviceForFiles->getType() === Storage::DEVICE_LOCAL) {
- $antivirus = new Network(
- System::getEnv('_APP_STORAGE_ANTIVIRUS_HOST', 'clamav'),
- (int) System::getEnv('_APP_STORAGE_ANTIVIRUS_PORT', 3310)
- );
+ $finalizeUpload = function (int $chunksUploaded) use ($authorization, $bucket, &$chunks, $contentRange, $dbForProject, $deviceForFiles, $fileId, $fileName, $fileSize, &$metadata, $mergeUploadMetadata, $path, $permissions, $queueForEvents, $response): void {
+ $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId);
+ $uploaded = 0;
- if (!$antivirus->fileScan($path)) {
- $deviceForFiles->delete($path);
- throw new Exception(Exception::STORAGE_INVALID_FILE);
+ if (!$file->isEmpty()) {
+ $chunks = $file->getAttribute('chunksTotal', 1);
+ $uploaded = $file->getAttribute('chunksUploaded', 0);
+ $metadata = $mergeUploadMetadata($file->getAttribute('metadata', []), $metadata);
+
+ if ($uploaded === $chunks) {
+ if (empty($contentRange)) {
+ throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS);
+ }
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_OK)
+ ->dynamic($file, Response::MODEL_FILE);
+
+ return;
}
}
- $mimeType = $deviceForFiles->getFileMimeType($path); // Get mime-type before compression and encryption
- $fileHash = $deviceForFiles->getFileHash($path); // Get file hash before compression and encryption
- $data = '';
- $iv = '';
- $tag = null;
- // Compression
- $algorithm = $bucket->getAttribute('compression', Compression::NONE);
- if ($fileSize <= APP_STORAGE_READ_BUFFER && $algorithm != Compression::NONE) {
- $data = $deviceForFiles->read($path);
- switch ($algorithm) {
- case Compression::ZSTD:
- $compressor = new Zstd();
- break;
- case Compression::GZIP:
- default:
- $compressor = new GZIP();
- break;
- }
- $data = $compressor->compress($data);
- } else {
- // reset the algorithm to none as we do not compress the file
- // if file size exceedes the APP_STORAGE_READ_BUFFER
- // regardless the bucket compression algoorithm
- $algorithm = Compression::NONE;
- }
+ $chunksUploaded = max($uploaded, $chunksUploaded, (int) ($metadata['chunks'] ?? 0));
- if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) {
- if (empty($data)) {
+ if ($chunksUploaded === $chunks && $uploaded < $chunks) {
+ $deviceForFiles->finalizeUpload($path, $chunks, $metadata);
+
+ if (System::getEnv('_APP_STORAGE_ANTIVIRUS') === 'enabled' && $bucket->getAttribute('antivirus', true) && $fileSize <= APP_LIMIT_ANTIVIRUS && $deviceForFiles->getType() === Storage::DEVICE_LOCAL) {
+ $antivirus = new Network(
+ System::getEnv('_APP_STORAGE_ANTIVIRUS_HOST', 'clamav'),
+ (int) System::getEnv('_APP_STORAGE_ANTIVIRUS_PORT', 3310)
+ );
+
+ if (!$antivirus->fileScan($path)) {
+ $deviceForFiles->delete($path);
+ throw new Exception(Exception::STORAGE_INVALID_FILE);
+ }
+ }
+
+ $mimeType = $deviceForFiles->getFileMimeType($path); // Get mime-type before compression and encryption
+ $fileHash = $deviceForFiles->getFileHash($path); // Get file hash before compression and encryption
+ $data = '';
+ $iv = '';
+ $tag = null;
+ // Compression
+ $algorithm = $bucket->getAttribute('compression', Compression::NONE);
+ if ($fileSize <= APP_STORAGE_READ_BUFFER && $algorithm != Compression::NONE) {
$data = $deviceForFiles->read($path);
+ switch ($algorithm) {
+ case Compression::ZSTD:
+ $compressor = new Zstd();
+ break;
+ case Compression::GZIP:
+ default:
+ $compressor = new GZIP();
+ break;
+ }
+ $data = $compressor->compress($data);
+ } else {
+ // reset the algorithm to none as we do not compress the file
+ // if file size exceedes the APP_STORAGE_READ_BUFFER
+ // regardless the bucket compression algoorithm
+ $algorithm = Compression::NONE;
}
- $key = System::getEnv('_APP_OPENSSL_KEY_V1');
- $iv = OpenSSL::randomPseudoBytes(OpenSSL::cipherIVLength(OpenSSL::CIPHER_AES_128_GCM));
- $data = OpenSSL::encrypt($data, OpenSSL::CIPHER_AES_128_GCM, $key, 0, $iv, $tag);
- }
- if (!empty($data)) {
- if (!$deviceForFiles->write($path, $data, $mimeType)) {
- throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to save file');
+ if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) {
+ if (empty($data)) {
+ $data = $deviceForFiles->read($path);
+ }
+ $key = System::getEnv('_APP_OPENSSL_KEY_V1');
+ $iv = OpenSSL::randomPseudoBytes(OpenSSL::cipherIVLength(OpenSSL::CIPHER_AES_128_GCM));
+ $data = OpenSSL::encrypt($data, OpenSSL::CIPHER_AES_128_GCM, $key, 0, $iv, $tag);
}
- }
- $sizeActual = $deviceForFiles->getFileSize($path);
-
- $openSSLVersion = null;
- $openSSLCipher = null;
- $openSSLTag = null;
- $openSSLIV = null;
-
- if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) {
- $openSSLVersion = '1';
- $openSSLCipher = OpenSSL::CIPHER_AES_128_GCM;
- $openSSLTag = \bin2hex($tag);
- $openSSLIV = \bin2hex($iv);
- }
-
- if ($file->isEmpty()) {
- $doc = new Document([
- '$id' => $fileId,
- '$permissions' => $permissions,
- 'bucketId' => $bucket->getId(),
- 'bucketInternalId' => $bucket->getSequence(),
- 'name' => $fileName,
- 'path' => $path,
- 'signature' => $fileHash,
- 'mimeType' => $mimeType,
- 'sizeOriginal' => $fileSize,
- 'sizeActual' => $sizeActual,
- 'algorithm' => $algorithm,
- 'comment' => '',
- 'chunksTotal' => $chunks,
- 'chunksUploaded' => $chunksUploaded,
- 'openSSLVersion' => $openSSLVersion,
- 'openSSLCipher' => $openSSLCipher,
- 'openSSLTag' => $openSSLTag,
- 'openSSLIV' => $openSSLIV,
- 'search' => implode(' ', [$fileId, $fileName]),
- 'metadata' => $metadata,
- ]);
-
- try {
- $file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc);
- } catch (DuplicateException) {
- throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS);
- } catch (NotFoundException) {
- throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
+ if (!empty($data)) {
+ if (!$deviceForFiles->write($path, $data, $mimeType)) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to save file');
+ }
}
+
+ $sizeActual = $deviceForFiles->getFileSize($path);
+
+ $openSSLVersion = null;
+ $openSSLCipher = null;
+ $openSSLTag = null;
+ $openSSLIV = null;
+
+ if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) {
+ $openSSLVersion = '1';
+ $openSSLCipher = OpenSSL::CIPHER_AES_128_GCM;
+ $openSSLTag = \bin2hex($tag);
+ $openSSLIV = \bin2hex($iv);
+ }
+
+ if ($file->isEmpty()) {
+ $doc = new Document([
+ '$id' => $fileId,
+ '$permissions' => $permissions,
+ 'bucketId' => $bucket->getId(),
+ 'bucketInternalId' => $bucket->getSequence(),
+ 'name' => $fileName,
+ 'path' => $path,
+ 'signature' => $fileHash,
+ 'mimeType' => $mimeType,
+ 'sizeOriginal' => $fileSize,
+ 'sizeActual' => $sizeActual,
+ 'algorithm' => $algorithm,
+ 'comment' => '',
+ 'chunksTotal' => $chunks,
+ 'chunksUploaded' => $chunksUploaded,
+ 'openSSLVersion' => $openSSLVersion,
+ 'openSSLCipher' => $openSSLCipher,
+ 'openSSLTag' => $openSSLTag,
+ 'openSSLIV' => $openSSLIV,
+ 'search' => implode(' ', [$fileId, $fileName]),
+ 'metadata' => $metadata,
+ ]);
+
+ try {
+ $file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc);
+ } catch (DuplicateException) {
+ throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS);
+ } catch (NotFoundException) {
+ throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
+ }
+ } else {
+ /**
+ * Skip authorization in updateDocument.
+ * Without this, the file creation will fail when user doesn't have update permission.
+ * However as with chunk upload even if we are updating, we are essentially creating a file
+ * adding it's new chunk so we rely on the create-permission check performed earlier.
+ */
+ $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, new Document([
+ '$permissions' => $permissions,
+ 'signature' => $fileHash,
+ 'mimeType' => $mimeType,
+ 'sizeActual' => $sizeActual,
+ 'algorithm' => $algorithm,
+ 'openSSLVersion' => $openSSLVersion,
+ 'openSSLCipher' => $openSSLCipher,
+ 'openSSLTag' => $openSSLTag,
+ 'openSSLIV' => $openSSLIV,
+ 'metadata' => $metadata,
+ 'chunksUploaded' => $chunksUploaded,
+ ])));
+ }
+
+ // Trigger after create success hook
+ $this->afterCreateSuccess($file);
} else {
- $file = $file
- ->setAttribute('$permissions', $permissions)
- ->setAttribute('signature', $fileHash)
- ->setAttribute('mimeType', $mimeType)
- ->setAttribute('sizeActual', $sizeActual)
- ->setAttribute('algorithm', $algorithm)
- ->setAttribute('openSSLVersion', $openSSLVersion)
- ->setAttribute('openSSLCipher', $openSSLCipher)
- ->setAttribute('openSSLTag', $openSSLTag)
- ->setAttribute('openSSLIV', $openSSLIV)
- ->setAttribute('metadata', $metadata)
- ->setAttribute('chunksUploaded', $chunksUploaded);
-
/**
- * Validate create permission and skip authorization in updateDocument
- * Without this, the file creation will fail when user doesn't have update permission
+ * Skip authorization in updateDocument.
+ * Without this, the file creation will fail when user doesn't have update permission.
* However as with chunk upload even if we are updating, we are essentially creating a file
- * adding it's new chunk so we validate create permission instead of update
+ * adding it's new chunk so we rely on the create-permission check performed earlier.
*/
- if (!$authorization->isValid(new Input(Database::PERMISSION_CREATE, $bucket->getCreate()))) {
- throw new Exception(Exception::USER_UNAUTHORIZED);
- }
- $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file));
- }
-
- // Trigger after create success hook
- $this->afterCreateSuccess($file);
- } else {
- if ($file->isEmpty()) {
- $doc = new Document([
- '$id' => ID::custom($fileId),
- '$permissions' => $permissions,
- 'bucketId' => $bucket->getId(),
- 'bucketInternalId' => $bucket->getSequence(),
- 'name' => $fileName,
- 'path' => $path,
- 'signature' => '',
- 'mimeType' => '',
- 'sizeOriginal' => $fileSize,
- 'sizeActual' => 0,
- 'algorithm' => '',
- 'comment' => '',
- 'chunksTotal' => $chunks,
- 'chunksUploaded' => $chunksUploaded,
- 'search' => implode(' ', [$fileId, $fileName]),
- 'metadata' => $metadata,
- ]);
-
try {
- $file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc);
- } catch (DuplicateException) {
- throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS);
- } catch (NotFoundException) {
- throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
- }
- } else {
- $file = $file
- ->setAttribute('chunksUploaded', $chunksUploaded)
- ->setAttribute('metadata', $metadata);
-
- /**
- * Validate create permission and skip authorization in updateDocument
- * Without this, the file creation will fail when user doesn't have update permission
- * However as with chunk upload even if we are updating, we are essentially creating a file
- * adding it's new chunk so we validate create permission instead of update
- */
- if (!$authorization->isValid(new Input(Database::PERMISSION_CREATE, $bucket->getCreate()))) {
- throw new Exception(Exception::USER_UNAUTHORIZED);
- }
-
- try {
- $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file));
+ $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, new Document([
+ 'chunksUploaded' => $chunksUploaded,
+ 'metadata' => $metadata,
+ ])));
} catch (NotFoundException) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
}
}
+
+ if ($chunksUploaded === $chunks) {
+ $queueForEvents
+ ->setParam('bucketId', $bucket->getId())
+ ->setParam('fileId', $file->getId())
+ ->setContext('bucket', $bucket);
+ }
+
+ $metadata = null; // was causing leaks as it was passed by reference
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_CREATED)
+ ->dynamic($file, Response::MODEL_FILE);
+ };
+
+ try {
+ $chunksUploaded = $deviceForFiles->uploadChunk($fileTmpName, $path, $chunk, $chunks, $metadata);
+
+ if (empty($chunksUploaded)) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed uploading file');
+ }
+
+ $locks($lockKey, 600, fn () => $finalizeUpload($chunksUploaded), timeout: 120.0);
+ } catch (LockContention) {
+ $response->addHeader('Retry-After', '5');
+ throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'File upload is busy. Try again.');
}
-
- $queueForEvents
- ->setParam('bucketId', $bucket->getId())
- ->setParam('fileId', $file->getId())
- ->setContext('bucket', $bucket);
-
- $metadata = null; // was causing leaks as it was passed by reference
-
- $response
- ->setStatusCode(Response::STATUS_CODE_CREATED)
- ->dynamic($file, Response::MODEL_FILE);
}
/**
@@ -468,8 +525,5 @@ class Create extends Action
*/
protected function afterCreateSuccess(Document $file)
{
- if (!($file instanceof Document)) {
- throw new Exception('file must be an instance of document');
- }
}
}
diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php
index 5b44c61d18..6d8781d484 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Storage\Http\Buckets\Files;
-use Appwrite\Event\Delete as DeleteEvent;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Delete as DeleteMessage;
+use Appwrite\Event\Publisher\Delete as DeletePublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -64,7 +65,7 @@ class Delete extends Action
->inject('dbForProject')
->inject('queueForEvents')
->inject('deviceForFiles')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('authorization')
->inject('user')
->callback($this->action(...));
@@ -77,7 +78,7 @@ class Delete extends Action
Database $dbForProject,
Event $queueForEvents,
Device $deviceForFiles,
- DeleteEvent $queueForDeletes,
+ DeletePublisher $publisherForDeletes,
Authorization $authorization,
User $user,
) {
@@ -126,11 +127,12 @@ class Delete extends Action
}
if ($deviceDeleted) {
- $queueForDeletes
- ->setType(DELETE_TYPE_CACHE_BY_RESOURCE)
- ->setResourceType('bucket/' . $bucket->getId())
- ->setResource('file/' . $fileId)
- ;
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $queueForEvents->getProject(),
+ type: DELETE_TYPE_CACHE_BY_RESOURCE,
+ resource: 'file/' . $fileId,
+ resourceType: 'bucket/' . $bucket->getId(),
+ ));
try {
if ($fileSecurity && !$valid) {
diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php
index f0ee045214..68bc2cabae 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php
@@ -4,6 +4,8 @@ namespace Appwrite\Platform\Modules\Storage\Http\Buckets\Files\Preview;
use Appwrite\Extend\Exception;
use Appwrite\OpenSSL\OpenSSL;
+use Appwrite\Platform\Modules\Storage\Config\CacheControl;
+use Appwrite\Platform\Modules\Storage\Config\StorageCacheControl;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
@@ -54,7 +56,7 @@ class Get extends Action
->label('cache', true)
->label('cache.resourceType', 'bucket/{request.bucketId}')
->label('cache.resource', 'file/{request.fileId}')
- ->label('cache.params', ['width', 'height', 'gravity', 'quality', 'borderWidth', 'borderColor', 'borderRadius', 'opacity', 'rotation', 'background', 'output'])
+ ->label('cache.params', ['width', 'height', 'gravity', 'quality', 'borderWidth', 'borderColor', 'borderRadius', 'opacity', 'rotation', 'background', 'output', 'project'])
->label('sdk', new Method(
namespace: 'storage',
group: 'files',
@@ -94,6 +96,7 @@ class Get extends Action
->inject('project')
->inject('authorization')
->inject('user')
+ ->inject('cacheControlForStorage')
->callback($this->action(...));
}
@@ -120,14 +123,14 @@ class Get extends Action
Device $deviceForLocal,
Document $project,
Authorization $authorization,
- User $user
+ User $user,
+ callable $cacheControlForStorage
) {
if (!\extension_loaded('imagick')) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing');
}
- /* @type Document $bucket */
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isAPIKey = $user->isApp($authorization->getRoles());
@@ -151,7 +154,6 @@ class Get extends Action
if ($fileSecurity && !$valid && !$isToken) {
$file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId);
} else {
- /* @type Document $file */
$file = $authorization->skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId));
}
@@ -200,7 +202,7 @@ class Get extends Action
// when file extension is not provided and the mime type is not one of our supported outputs
// we fallback to `jpg` output format
- $output = empty($type) ? (array_search($mime, $outputs) ?? 'jpg') : $type;
+ $output = empty($type) ? (array_search($mime, $outputs) ?: 'jpg') : $type;
}
$startTime = \microtime(true);
@@ -241,28 +243,43 @@ class Get extends Action
throw new Exception(Exception::STORAGE_FILE_TYPE_UNSUPPORTED, $e->getMessage());
}
- $image->crop((int) $width, (int) $height, $gravity);
+ if ($width > 0 || $height > 0 || $gravity !== Image::GRAVITY_CENTER) {
+ Span::add('storage.transform.crop.width', $width);
+ Span::add('storage.transform.crop.height', $height);
+ Span::add('storage.transform.crop.gravity', $gravity);
+ $image->crop($width, $height, $gravity);
+ }
- if (!empty($opacity) || $opacity === 0) {
+ if ($opacity !== 1.0) {
+ Span::add('storage.transform.opacity', $opacity);
$image->setOpacity($opacity);
}
if (!empty($background)) {
+ Span::add('storage.transform.background', $background);
$image->setBackground('#' . $background);
}
- if (!empty($borderWidth)) {
+ if ($borderWidth > 0) {
+ Span::add('storage.transform.border.width', $borderWidth);
+ Span::add('storage.transform.border.color', $borderColor);
$image->setBorder($borderWidth, '#' . $borderColor);
}
- if (!empty($borderRadius)) {
+ if ($borderRadius > 0) {
+ Span::add('storage.transform.borderRadius', $borderRadius);
$image->setBorderRadius($borderRadius);
}
- if (!empty($rotation)) {
+ if ($rotation !== 0) {
+ Span::add('storage.transform.rotation', $rotation);
$image->setRotation(($rotation + 360) % 360);
}
+ if ($quality !== -1) {
+ Span::add('storage.transform.quality', $quality);
+ }
+
$data = $image->output($output, $quality);
$renderingTime = \microtime(true) - $startTime - $downloadTime - $decryptionTime - $decompressionTime;
@@ -294,8 +311,20 @@ class Get extends Action
}
}
+ $maxAge = 2592000; // 30 days
+ $cacheControl = $cacheControlForStorage(new StorageCacheControl(
+ source: CacheControl::SOURCE_ACTION,
+ user: $user,
+ maxAge: $maxAge,
+ project: $project,
+ bucket: $bucket,
+ file: $file,
+ resourceToken: $resourceToken,
+ fileSecurity: $fileSecurity,
+ ));
+
$response
- ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days
+ ->addHeader('Cache-Control', $cacheControl)
->setContentType($contentType)
->file($data);
diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php
index 8e69468170..407f3766df 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php
@@ -130,7 +130,7 @@ class Update extends Action
}
if (\is_null($permissions)) {
- $permissions = $file->getPermissions() ?? [];
+ $permissions = $file->getPermissions();
}
$file->setAttribute('$permissions', $permissions);
diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php
index 8f2cd9bbac..d8e5cd5ad2 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php
@@ -143,11 +143,12 @@ class XList extends Action
});
foreach ($stats as $stat) {
- $bucket = $bucketByStatsId[$stat->getId()];
-
- if ($bucket) {
- $bucket->setAttribute('totalSize', $stat->getAttribute('value', 0));
+ if (!isset($bucketByStatsId[$stat->getId()])) {
+ continue;
}
+
+ $bucket = $bucketByStatsId[$stat->getId()];
+ $bucket->setAttribute('totalSize', $stat->getAttribute('value', 0));
}
} catch (\Throwable) {
// Stats may not be available, default to 0
diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php
index a7bda355da..10a603f5df 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php
@@ -109,6 +109,7 @@ class Get extends Action
$format = match ($days['period']) {
'1h' => 'Y-m-d\\TH:00:00.000P',
'1d' => 'Y-m-d\\T00:00:00.000P',
+ default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']),
};
foreach ($metrics as $metric) {
diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php
index 44fdd54e8c..04eac21754 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php
@@ -2,6 +2,7 @@
namespace Appwrite\Platform\Modules\Storage\Http\Usage;
+use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
@@ -92,6 +93,7 @@ class XList extends Action
$format = match ($days['period']) {
'1h' => 'Y-m-d\\TH:00:00.000P',
'1d' => 'Y-m-d\\T00:00:00.000P',
+ default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']),
};
foreach ($metrics as $metric) {
diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
index 5edc69f445..5500a56cbc 100644
--- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
+++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
@@ -4,8 +4,10 @@ namespace Appwrite\Platform\Modules\Teams\Http\Memberships;
use Appwrite\Auth\Validator\Phone;
use Appwrite\Event\Event;
-use Appwrite\Event\Mail;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Message\Mail as MailMessage;
+use Appwrite\Event\Message\Messaging as MessagingMessage;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
@@ -87,18 +89,19 @@ class Create extends Action
->inject('dbForProject')
->inject('authorization')
->inject('locale')
- ->inject('queueForMails')
- ->inject('queueForMessaging')
+ ->inject('publisherForMails')
+ ->inject('publisherForMessaging')
->inject('queueForEvents')
->inject('timelimit')
->inject('usage')
->inject('plan')
+ ->inject('platform')
->inject('proofForPassword')
->inject('proofForToken')
->callback($this->action(...));
}
- public function action(string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, User $user, Database $dbForProject, Authorization $authorization, Locale $locale, Mail $queueForMails, Messaging $queueForMessaging, Event $queueForEvents, callable $timelimit, Context $usage, array $plan, Password $proofForPassword, Token $proofForToken)
+ public function action(string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, User $user, Database $dbForProject, Authorization $authorization, Locale $locale, MailPublisher $publisherForMails, MessagingPublisher $publisherForMessaging, Event $queueForEvents, callable $timelimit, Context $usage, array $plan, array $platform, Password $proofForPassword, Token $proofForToken)
{
$isAppUser = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
@@ -189,15 +192,15 @@ class Create extends Action
} catch (\Throwable) {
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -324,7 +327,9 @@ class Create extends Action
$body = $locale->getText('emails.invitation.body');
$preview = $locale->getText('emails.invitation.preview');
$subject = $locale->getText('emails.invitation.subject');
- $customTemplate = $project->getAttribute('templates', [])['email.invitation-' . $locale->default] ?? [];
+ $customTemplate =
+ $project->getAttribute('templates', [])['email.invitation-' . $locale->default] ??
+ $project->getAttribute('templates', [])['email.invitation-' . $locale->fallback] ?? [];
$message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-inner-base.tpl');
$message
@@ -341,7 +346,9 @@ class Create extends Action
$senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
- $replyTo = '';
+ $replyToEmail = '';
+ $replyToName = '';
+ $smtpConfig = [];
if ($smtpEnabled) {
if (! empty($smtp['senderEmail'])) {
@@ -350,16 +357,14 @@ class Create extends Action
if (! empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
- if (! empty($smtp['replyTo'])) {
- $replyTo = $smtp['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '';
+ if (! empty($smtpReplyToEmail)) {
+ $replyToEmail = $smtpReplyToEmail;
+ }
+ if (! empty($smtp['replyToName'])) {
+ $replyToName = $smtp['replyToName'];
}
-
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '');
if (! empty($customTemplate)) {
if (! empty($customTemplate['senderEmail'])) {
@@ -368,18 +373,30 @@ class Create extends Action
if (! empty($customTemplate['senderName'])) {
$senderName = $customTemplate['senderName'];
}
- if (! empty($customTemplate['replyTo'])) {
- $replyTo = $customTemplate['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? '';
+ if (! empty($customReplyToEmail)) {
+ $replyToEmail = $customReplyToEmail;
+ }
+ if (! empty($customTemplate['replyToName'])) {
+ $replyToName = $customTemplate['replyToName'];
}
$body = $customTemplate['message'] ?? '';
$subject = $customTemplate['subject'] ?? $subject;
}
- $queueForMails
- ->setSmtpReplyTo($replyTo)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName);
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ];
}
$emailVariables = [
@@ -392,14 +409,17 @@ class Create extends Action
'project' => $projectName,
];
- $queueForMails
- ->setSubject($subject)
- ->setBody($body)
- ->setPreview($preview)
- ->setRecipient($invitee->getAttribute('email'))
- ->setName($invitee->getAttribute('name', ''))
- ->appendVariables($emailVariables)
- ->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $invitee->getAttribute('email'),
+ name: $invitee->getAttribute('name', ''),
+ subject: $subject,
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ platform: $platform,
+ ));
} elseif (! empty($phone)) {
if (empty(System::getEnv('_APP_SMS_PROVIDER'))) {
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
@@ -407,11 +427,6 @@ class Create extends Action
$message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/sms-base.tpl');
- $customTemplate = $project->getAttribute('templates', [])['sms.invitation-' . $locale->default] ?? [];
- if (! empty($customTemplate)) {
- $message = $customTemplate['message'];
- }
-
$message = $message->setParam('{{token}}', $url);
$message = $message->render();
@@ -422,11 +437,13 @@ class Create extends Action
],
]);
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_INTERNAL)
- ->setMessage($messageDoc)
- ->setRecipients([$phone])
- ->setProviderType('SMS');
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_INTERNAL,
+ project: $project,
+ message: $messageDoc,
+ recipients: [$phone],
+ providerType: 'SMS',
+ ));
$helper = PhoneNumberUtil::getInstance();
try {
diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php
index 3b516c2d60..d055ecb23f 100644
--- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php
+++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php
@@ -126,6 +126,7 @@ class Delete extends Action
if ($team->getAttribute('userInternalId') === $membership->getAttribute('userInternalId')) {
$membership = $dbForProject->findOne('memberships', [
Query::equal('teamInternalId', [$team->getSequence()]),
+ Query::equal('confirm', [true]),
]);
if (!$membership->isEmpty()) {
diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php
index f3fd9a4bb9..ef8d130855 100644
--- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php
+++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php
@@ -70,10 +70,13 @@ class Get extends Action
throw new Exception(Exception::MEMBERSHIP_NOT_FOUND);
}
+ // Default should be "false", but existing projects already rely on this being "true"
$membershipsPrivacy = [
'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true,
'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true,
'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true,
+ 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? true,
+ 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? true,
];
$roles = $authorization->getRoles();
@@ -113,6 +116,16 @@ class Get extends Action
$membership->setAttribute('userEmail', $memberUser->getAttribute('email'));
}
+ if ($membershipsPrivacy['userId']) {
+ $membership->setAttribute('userId', $memberUser->getId());
+ } else {
+ $membership->removeAttribute('userId');
+ }
+
+ if ($membershipsPrivacy['userPhone']) {
+ $membership->setAttribute('userPhone', $memberUser->getAttribute('phone'));
+ }
+
$membership->setAttribute('teamName', $team->getAttribute('name'));
$response->dynamic($membership, Response::MODEL_MEMBERSHIP);
diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php
index 364f92e1c5..7835c8051f 100644
--- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php
+++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php
@@ -123,10 +123,13 @@ class XList extends Action
$memberships = array_filter($memberships, fn (Document $membership) => !empty($membership->getAttribute('userId')));
+ // Default should be "false", but existing projects already rely on this being "true"
$membershipsPrivacy = [
'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true,
'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true,
'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true,
+ 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? true,
+ 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? true,
];
$roles = $authorization->getRoles();
@@ -167,6 +170,16 @@ class XList extends Action
$membership->setAttribute('userEmail', $memberUser->getAttribute('email'));
}
+ if ($membershipsPrivacy['userId']) {
+ $membership->setAttribute('userId', $memberUser->getId());
+ } else {
+ $membership->removeAttribute('userId');
+ }
+
+ if ($membershipsPrivacy['userPhone']) {
+ $membership->setAttribute('userPhone', $memberUser->getAttribute('phone'));
+ }
+
$membership->setAttribute('teamName', $team->getAttribute('name'));
return $membership;
diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Teams/Delete.php b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Delete.php
index 0cb7c54a26..3bae031e06 100644
--- a/src/Appwrite/Platform/Modules/Teams/Http/Teams/Delete.php
+++ b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Delete.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Teams\Http\Teams;
-use Appwrite\Event\Delete as DeleteEvent;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Delete as DeleteMessage;
+use Appwrite\Event\Publisher\Delete as DeletePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\Platform\Workers\Deletes;
@@ -55,13 +56,13 @@ class Delete extends Action
->inject('response')
->inject('getProjectDB')
->inject('dbForProject')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('queueForEvents')
->inject('project')
->callback($this->action(...));
}
- public function action(string $teamId, Response $response, callable $getProjectDB, Database $dbForProject, DeleteEvent $queueForDeletes, Event $queueForEvents, Document $project)
+ public function action(string $teamId, Response $response, callable $getProjectDB, Database $dbForProject, DeletePublisher $publisherForDeletes, Event $queueForEvents, Document $project)
{
$team = $dbForProject->getDocument('teams', $teamId);
@@ -79,15 +80,18 @@ class Delete extends Action
// Async delete
if ($project->getId() === 'console') {
- $queueForDeletes
- ->setType(DELETE_TYPE_TEAM_PROJECTS)
- ->setDocument($team)
- ->trigger();
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $project,
+ type: DELETE_TYPE_TEAM_PROJECTS,
+ document: $team,
+ ));
}
- $queueForDeletes
- ->setType(DELETE_TYPE_DOCUMENT)
- ->setDocument($team);
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $project,
+ type: DELETE_TYPE_DOCUMENT,
+ document: $team,
+ ));
$queueForEvents
->setParam('teamId', $team->getId())
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php
index 8b320535e9..993740c61a 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Authorize\External;
-use Appwrite\Event\Build;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\Platform\Modules\VCS\Http\GitHub\Deployment;
@@ -60,7 +60,7 @@ class Update extends Action
->inject('dbForPlatform')
->inject('authorization')
->inject('getProjectDB')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('platform')
->callback($this->action(...));
}
@@ -75,7 +75,7 @@ class Update extends Action
Database $dbForPlatform,
Authorization $authorization,
callable $getProjectDB,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
array $platform
) {
$installation = $dbForPlatform->getDocument('installations', $installationId);
@@ -130,7 +130,14 @@ class Update extends Action
$providerCommitAuthor = $commitDetails["commitAuthor"] ?? '';
$providerCommitAuthorUrl = $commitDetails["commitAuthorUrl"] ?? '';
- $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, true, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
+ $prFiles = $github->getPullRequestFiles($owner, $providerRepositoryName, $providerPullRequestId);
+ $providerAffectedFiles = [
+ ...array_column($prFiles, 'filename'),
+ // Only renamed files include previous_filename; skip missing values from other file changes.
+ ...array_filter(array_column($prFiles, 'previous_filename'))
+ ];
+
+ $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $providerAffectedFiles, true, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform);
$response->noContent();
}
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php
index 69da270e19..c5a8d8f43f 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php
@@ -104,7 +104,7 @@ class Get extends Action
$privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY');
$githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
- $owner = $github->getOwnerName($providerInstallationId) ?? '';
+ $owner = $github->getOwnerName($providerInstallationId);
$projectInternalId = $project->getSequence();
@@ -121,11 +121,11 @@ class Get extends Action
if (!empty($code)) {
$oauth2 = new OAuth2Github(System::getEnv('_APP_VCS_GITHUB_CLIENT_ID', ''), System::getEnv('_APP_VCS_GITHUB_CLIENT_SECRET', ''), "");
- $accessToken = $oauth2->getAccessToken($code) ?? '';
- $refreshToken = $oauth2->getRefreshToken($code) ?? '';
+ $accessToken = $oauth2->getAccessToken($code);
+ $refreshToken = $oauth2->getRefreshToken($code);
$accessTokenExpiry = DateTime::addSeconds(new \DateTime(), \intval($oauth2->getAccessTokenExpiry($code)));
- $personalSlug = $oauth2->getUserSlug($accessToken) ?? '';
+ $personalSlug = $oauth2->getUserSlug($accessToken);
$personal = $personalSlug === $owner;
}
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php
index 6e1db12c28..27c4eacba3 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\VCS\Http\GitHub;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
use Appwrite\Vcs\Comment;
@@ -20,6 +21,8 @@ use Utopia\Database\Validator\Authorization;
use Utopia\DSN\DSN;
use Utopia\Span\Span;
use Utopia\System\System;
+use Utopia\Validator\Contains;
+use Utopia\Validator\Globstar;
use Utopia\VCS\Adapter\Git\GitHub;
use Utopia\VCS\Exception\RepositoryNotFound;
@@ -40,10 +43,11 @@ trait Deployment
string $providerCommitMessage,
string $providerCommitUrl,
string $providerPullRequestId,
+ array $providerAffectedFiles,
bool $external,
Database $dbForPlatform,
Authorization $authorization,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
callable $getProjectDB,
array $platform,
) {
@@ -58,9 +62,9 @@ trait Deployment
$resourceType = $repository->getAttribute('resourceType');
$logBase = "vcs.github.event.repo.{$repositoryId}";
- Span::add("{$logBase}.projectId", $projectId);
- Span::add("{$logBase}.resourceId", $resourceId);
- Span::add("{$logBase}.resourceType", $resourceType);
+ Span::add('project.id', $projectId);
+ Span::add("{$logBase}.resource.id", $resourceId);
+ Span::add("{$logBase}.resource.type", $resourceType);
if ($resourceType !== "function" && $resourceType !== "site") {
continue;
@@ -94,6 +98,39 @@ trait Deployment
$resource = $authorization->skip(fn () => $dbForProject->getDocument($resourceCollection, $resourceId));
$resourceInternalId = $resource->getSequence();
+ $validator = new Contains(VCS_DEPLOYMENT_SKIP_PATTERNS);
+ if ($validator->isValid($providerCommitMessage)) {
+ Span::add("{$logBase}.build.skipped.reason", $validator->getDescription());
+ Span::add("{$logBase}.build.skipped", 'true');
+ continue;
+ }
+
+ // Skip deployments when the branch or affected files do not match configured build triggers.
+ $branchTrigger = new Globstar($resource->getAttribute('providerBranches', []));
+ if (!$branchTrigger->isValid($providerBranch)) {
+ Span::add("{$logBase}.build.skipped.reason", 'branch');
+ Span::add("{$logBase}.build.skipped", 'true');
+ continue;
+ }
+
+ $providerPaths = $resource->getAttribute('providerPaths', []);
+ if (!empty($providerPaths) && !empty($providerAffectedFiles)) {
+ $pathTrigger = new Globstar($providerPaths);
+ $pathMatched = false;
+ foreach ($providerAffectedFiles as $file) {
+ if ($pathTrigger->isValid($file)) {
+ $pathMatched = true;
+ break;
+ }
+ }
+
+ if (!$pathMatched) {
+ Span::add("{$logBase}.build.skipped.reason", 'path');
+ Span::add("{$logBase}.build.skipped", 'true');
+ continue;
+ }
+ }
+
$deploymentId = ID::unique();
$repositoryId = $repository->getId();
$repositoryInternalId = $repository->getSequence();
@@ -107,7 +144,7 @@ trait Deployment
$activate = true;
}
- $owner = $github->getOwnerName($providerInstallationId) ?? '';
+ $owner = $github->getOwnerName($providerInstallationId);
try {
$repositoryName = $github->getRepositoryName($providerRepositoryId);
} catch (RepositoryNotFound $e) {
@@ -528,14 +565,16 @@ trait Deployment
$queueName = $this->getBuildQueueName($project, $dbForPlatform, $authorization);
- $queueForBuilds
- ->setQueue($queueName)
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($resource)
- ->setDeployment($deployment)
- ->setProject($project); // set the project because it won't be set for git deployments
-
- $queueForBuilds->trigger(); // must trigger here so that we create a build for each function/site
+ $publisherForBuilds->enqueue(
+ new BuildMessage(
+ project: $project,
+ resource: $resource,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ),
+ new \Utopia\Queue\Queue($queueName)
+ );
Span::add("{$logBase}.build.triggered", 'true');
//TODO: Add event?
@@ -545,8 +584,6 @@ trait Deployment
}
}
- $queueForBuilds->reset(); // prevent shutdown hook from triggering again
-
if (!empty($errors)) {
throw new Exception(Exception::GENERAL_UNKNOWN, \implode("\n", $errors));
}
@@ -560,4 +597,5 @@ trait Deployment
{
return System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME);
}
+
}
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php
index e3dbcfa0e9..c79df05f8a 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Events;
-use Appwrite\Event\Build;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\Platform\Modules\VCS\Http\GitHub\Deployment;
@@ -41,7 +41,7 @@ class Create extends Action
->inject('dbForPlatform')
->inject('authorization')
->inject('getProjectDB')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('platform')
->callback($this->action(...));
}
@@ -53,7 +53,7 @@ class Create extends Action
Database $dbForPlatform,
Authorization $authorization,
callable $getProjectDB,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
array $platform
) {
$this->preprocessEvent($request);
@@ -78,8 +78,8 @@ class Create extends Action
match ($event) {
$github::EVENT_INSTALLATION => $this->handleInstallationEvent($parsedPayload, $dbForPlatform, $authorization),
- $github::EVENT_PUSH => $this->handlePushEvent($parsedPayload, $githubAppId, $privateKey, $github, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform),
- $github::EVENT_PULL_REQUEST => $this->handlePullRequestEvent($parsedPayload, $privateKey, $githubAppId, $github, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform),
+ $github::EVENT_PUSH => $this->handlePushEvent($parsedPayload, $githubAppId, $privateKey, $github, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform),
+ $github::EVENT_PULL_REQUEST => $this->handlePullRequestEvent($parsedPayload, $privateKey, $githubAppId, $github, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform),
default => null,
};
@@ -129,11 +129,10 @@ class Create extends Action
GitHub $github,
Database $dbForPlatform,
Authorization $authorization,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
callable $getProjectDB,
array $platform,
) {
- $providerBranchCreated = $parsedPayload["branchCreated"] ?? false;
$providerBranchDeleted = $parsedPayload["branchDeleted"] ?? false;
$providerBranch = $parsedPayload["branch"] ?? '';
$providerBranchUrl = $parsedPayload["branchUrl"] ?? '';
@@ -164,7 +163,8 @@ class Create extends Action
// Create new deployment only on push (not committed by us) and not when branch is deleted
if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchDeleted) {
- $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
+ $providerAffectedFiles = $parsedPayload['affectedFiles'] ?? [];
+ $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', $providerAffectedFiles, false, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform);
}
}
@@ -175,7 +175,7 @@ class Create extends Action
GitHub $github,
Database $dbForPlatform,
Authorization $authorization,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
callable $getProjectDB,
array $platform,
) {
@@ -211,12 +211,19 @@ class Create extends Action
$providerCommitAuthor = $commitDetails["commitAuthor"] ?? '';
$providerCommitMessage = $commitDetails["commitMessage"] ?? '';
+ $prFiles = $github->getPullRequestFiles($providerRepositoryOwner, $providerRepositoryName, $providerPullRequestId);
+ $providerAffectedFiles = [
+ ...array_column($prFiles, 'filename'),
+ // Only renamed files include previous_filename; skip missing values from other file changes.
+ ...array_filter(array_column($prFiles, 'previous_filename'))
+ ];
+
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::orderDesc('$createdAt')
]));
- $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
+ $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $providerAffectedFiles, $external, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform);
} elseif ($action == "closed") {
// Allowed external contributions cleanup
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Delete.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Delete.php
index 26a9476941..5d90d6d231 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Delete.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Delete.php
@@ -2,7 +2,8 @@
namespace Appwrite\Platform\Modules\VCS\Http\Installations;
-use Appwrite\Event\Delete as DeleteEvent;
+use Appwrite\Event\Message\Delete as DeleteMessage;
+use Appwrite\Event\Publisher\Delete as DeletePublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
@@ -11,6 +12,7 @@ use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
+use Utopia\Database\Document;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Text;
@@ -49,7 +51,8 @@ class Delete extends Action
->param('installationId', '', new Text(256), 'Installation Id')
->inject('response')
->inject('dbForPlatform')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
+ ->inject('project')
->callback($this->action(...));
}
@@ -57,7 +60,8 @@ class Delete extends Action
string $installationId,
Response $response,
Database $dbForPlatform,
- DeleteEvent $queueForDeletes
+ DeletePublisher $publisherForDeletes,
+ Document $project,
) {
$installation = $dbForPlatform->getDocument('installations', $installationId);
@@ -69,9 +73,11 @@ class Delete extends Action
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove installation from DB');
}
- $queueForDeletes
- ->setType(DELETE_TYPE_DOCUMENT)
- ->setDocument($installation);
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $project,
+ type: DELETE_TYPE_DOCUMENT,
+ document: $installation,
+ ));
$response->noContent();
}
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php
index 7bb2dedaf5..4e7b80f5b2 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php
@@ -59,7 +59,7 @@ class Get extends Action
) {
$installation = $dbForPlatform->getDocument('installations', $installationId);
- if ($installation === false || $installation->isEmpty()) {
+ if ($installation->isEmpty()) {
throw new Exception(Exception::INSTALLATION_NOT_FOUND);
}
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php
index 4ed4241d25..fda462159f 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php
@@ -7,9 +7,12 @@ use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
+use Appwrite\Utopia\Database\Validator\Queries\Branches;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
+use Utopia\Database\Exception\Query as QueryException;
+use Utopia\Database\Query;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\Text;
@@ -49,6 +52,8 @@ class XList extends Action
))
->param('installationId', '', new Text(256), 'Installation Id')
->param('providerRepositoryId', '', new Text(256), 'Repository Id')
+ ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
+ ->param('queries', [], new Branches(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit, offset, cursorAfter, and cursorBefore', true)
->inject('gitHub')
->inject('response')
->inject('dbForPlatform')
@@ -58,10 +63,18 @@ class XList extends Action
public function action(
string $installationId,
string $providerRepositoryId,
+ string $search,
+ array $queries,
GitHub $github,
Response $response,
Database $dbForPlatform
) {
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
$installation = $dbForPlatform->getDocument('installations', $installationId);
if ($installation->isEmpty()) {
@@ -73,9 +86,9 @@ class XList extends Action
$githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
- $owner = $github->getOwnerName($providerInstallationId) ?? '';
+ $owner = $github->getOwnerName($providerInstallationId);
try {
- $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
+ $repositoryName = $github->getRepositoryName($providerRepositoryId);
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
@@ -83,13 +96,50 @@ class XList extends Action
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
- $branches = $github->listBranches($owner, $repositoryName) ?? [];
+ $branches = $github->listBranches($owner, $repositoryName);
+
+ if (!empty($search)) {
+ $branches = \array_values(\array_filter($branches, fn (string $branch) => \stripos($branch, $search) !== false));
+ }
+
+ $total = \count($branches);
+ [
+ 'limit' => $limit,
+ 'offset' => $offset,
+ ] = Query::groupByType($queries);
+ $cursorQuery = \current(Query::getCursorQueries($queries, false));
+
+ $limit ??= APP_LIMIT_LIST_DEFAULT;
+ $offset ??= 0;
+
+ if ($cursorQuery instanceof Query) {
+ $cursor = $cursorQuery->getValue();
+ $cursorDirection = $cursorQuery->getMethod() === Query::TYPE_CURSOR_AFTER
+ ? Database::CURSOR_AFTER
+ : Database::CURSOR_BEFORE;
+
+ $cursorIndex = \array_search($cursor, $branches, true);
+ if ($cursorIndex === false) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Branch '{$cursor}' for the 'cursor' value not found.");
+ }
+
+ $offset += $cursorDirection === Database::CURSOR_AFTER ? $cursorIndex + 1 : 0;
+
+ if ($cursorDirection === Database::CURSOR_BEFORE) {
+ $start = \max(0, $cursorIndex - $limit);
+ $branches = \array_slice($branches, $start, $cursorIndex - $start);
+ } else {
+ $branches = \array_slice($branches, $offset, $limit);
+ }
+ } else {
+ $branches = \array_slice($branches, $offset, $limit);
+ }
$response->dynamic(new Document([
'branches' => \array_map(function ($branch) {
return new Document(['name' => $branch]);
}, $branches),
- 'total' => \count($branches),
+ 'total' => $total,
]), Response::MODEL_BRANCH_LIST);
}
}
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php
index a0dcec8590..89b38e7b79 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php
@@ -79,7 +79,7 @@ class Get extends Action
$owner = $github->getOwnerName($providerInstallationId);
try {
- $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
+ $repositoryName = $github->getRepositoryName($providerRepositoryId);
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php
index 04003812f8..1918e454a4 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php
@@ -152,7 +152,7 @@ class Create extends Action
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Provider Error: ' . $repository['message']);
}
- $repository['id'] = \strval($repository['id']) ?? '';
+ $repository['id'] = \strval($repository['id']);
$repository['pushedAt'] = $repository['pushed_at'] ?? '';
$repository['organization'] = $installation->getAttribute('organization', '');
$repository['provider'] = $installation->getAttribute('provider', '');
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php
index 5dd5c6dcfa..aa7d7ae95c 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php
@@ -121,7 +121,7 @@ class Create extends Action
$owner = $github->getOwnerName($providerInstallationId);
try {
- $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
+ $repositoryName = $github->getRepositoryName($providerRepositoryId);
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
@@ -307,6 +307,7 @@ class Create extends Action
];
}
+ $output->setAttribute('type', $type);
$output->setAttribute('variables', $variables);
$response->dynamic($output, $type === 'framework' ? Response::MODEL_DETECTION_FRAMEWORK : Response::MODEL_DETECTION_RUNTIME);
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php
index 52b94cd525..ec135dc96e 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php
@@ -73,9 +73,9 @@ class Get extends Action
$githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
- $owner = $github->getOwnerName($providerInstallationId) ?? '';
+ $owner = $github->getOwnerName($providerInstallationId);
try {
- $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
+ $repositoryName = $github->getRepositoryName($providerRepositoryId);
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
@@ -97,7 +97,7 @@ class Get extends Action
}
}
- $repository['id'] = \strval($repository['id']) ?? '';
+ $repository['id'] = \strval($repository['id']);
$repository['pushedAt'] = $repository['pushed_at'] ?? '';
$repository['organization'] = $installation->getAttribute('organization', '');
$repository['provider'] = $installation->getAttribute('provider', '');
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php
index d5b2b48175..b4172fabdf 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php
@@ -313,6 +313,7 @@ class XList extends Action
}, $repos);
$response->dynamic(new Document([
+ 'type' => $type,
$type === 'framework' ? 'frameworkProviderRepositories' : 'runtimeProviderRepositories' => $repos,
'total' => $total,
]), ($type === 'framework') ? Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST : Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST);
diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php
index dd7bed0137..3e11a4060c 100644
--- a/src/Appwrite/Platform/Tasks/Install.php
+++ b/src/Appwrite/Platform/Tasks/Install.php
@@ -109,7 +109,7 @@ class Install extends Action
file_put_contents($this->path . '/' . $composeFileName . '.' . $time . '.backup', $data);
$compose = new Compose($data);
$appwrite = $compose->getService('appwrite');
- $oldVersion = $appwrite?->getImageVersion();
+ $oldVersion = $appwrite->getImageVersion();
try {
$ports = $compose->getService('traefik')->getPorts();
} catch (\Throwable $th) {
@@ -122,10 +122,6 @@ class Install extends Action
if ($oldVersion) {
foreach ($compose->getServices() as $service) {
- if (!$service) {
- continue;
- }
-
$env = $service->getEnvironment()->list();
foreach ($env as $key => $value) {
@@ -177,9 +173,6 @@ class Install extends Action
// can be detected by the DB service name or _APP_DB_HOST.
$existingDatabase = null;
foreach ($compose->getServices() as $service) {
- if (!$service) {
- continue;
- }
$svcEnv = $service->getEnvironment()->list();
if (isset($svcEnv['_APP_DB_ADAPTER'])) {
$existingDatabase = $svcEnv['_APP_DB_ADAPTER'];
@@ -229,8 +222,8 @@ class Install extends Action
$assistantExistsInOldCompose = false;
if ($existingInstallation) {
try {
- $assistantService = $compose->getService('appwrite-assistant');
- $assistantExistsInOldCompose = $assistantService !== null;
+ $compose->getService('appwrite-assistant');
+ $assistantExistsInOldCompose = true;
} catch (\Throwable) {
/* ignore */
}
@@ -290,7 +283,7 @@ class Install extends Action
continue;
}
- if ($var['name'] === '_APP_DB_ADAPTER' && $data !== false) {
+ if ($var['name'] === '_APP_DB_ADAPTER' && $data !== '') {
$userInput[$var['name']] = $database;
continue;
}
@@ -334,7 +327,7 @@ class Install extends Action
@unlink(InstallerServer::INSTALLER_COMPLETE_FILE);
- $state = new State([]);
+ $state = new State();
$state->clearStaleLock();
$installerConfig = $this->readInstallerConfig();
@@ -608,7 +601,7 @@ class Install extends Action
$this->copyMongoEntrypointIfNeeded();
}
- if (!$noStart && $startIndex <= 2) {
+ if (!$noStart) {
$currentStep = InstallerServer::STEP_DOCKER_CONTAINERS;
$this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_IN_PROGRESS, $messages);
$this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI, $progress, $isUpgrade);
@@ -838,7 +831,7 @@ class Install extends Action
'email' => $email,
'domain' => $domain,
'database' => $database,
- 'ip' => ($hostIp !== false && $hostIp !== $domain) ? $hostIp : null,
+ 'ip' => ($hostIp !== $domain) ? $hostIp : null,
'os' => php_uname('s') . ' ' . php_uname('r'),
'arch' => php_uname('m'),
'cpus' => ((int) trim((string) \shell_exec('nproc'))) ?: null,
@@ -1365,9 +1358,6 @@ class Install extends Action
}
foreach ($compose->getServices() as $service) {
- if (!$service) {
- continue;
- }
$env = $service->getEnvironment()->list();
$host = $env['_APP_DB_HOST'] ?? null;
if ($host !== null && in_array($host, $dbServices, true)) {
diff --git a/src/Appwrite/Platform/Tasks/Interval.php b/src/Appwrite/Platform/Tasks/Interval.php
index a7d16e0a52..836508c73d 100644
--- a/src/Appwrite/Platform/Tasks/Interval.php
+++ b/src/Appwrite/Platform/Tasks/Interval.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Tasks;
-use Appwrite\Event\Certificate;
+use Appwrite\Event\Publisher\Certificate;
use DateTime;
use Swoole\Coroutine\Channel;
use Swoole\Process;
@@ -29,16 +29,16 @@ class Interval extends Action
->desc('Schedules tasks on regular intervals by publishing them to our queues')
->inject('dbForPlatform')
->inject('getProjectDB')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->callback($this->action(...));
}
- public function action(Database $dbForPlatform, callable $getProjectDB, Certificate $queueForCertificates): void
+ public function action(Database $dbForPlatform, callable $getProjectDB, Certificate $publisherForCertificates): void
{
Console::title('Interval V1');
Console::success(APP_NAME . ' interval process v1 has started');
- $timers = $this->runTasks($dbForPlatform, $getProjectDB, $queueForCertificates);
+ $timers = $this->runTasks($dbForPlatform, $getProjectDB, $publisherForCertificates);
$chan = new Channel(1);
Process::signal(SIGTERM, function () use ($chan) {
@@ -52,16 +52,16 @@ class Interval extends Action
}
}
- public function runTasks(Database $dbForPlatform, callable $getProjectDB, Certificate $queueForCertificates): array
+ public function runTasks(Database $dbForPlatform, callable $getProjectDB, Certificate $publisherForCertificates): array
{
$timers = [];
$tasks = $this->getTasks();
foreach ($tasks as $task) {
- $timers[] = Timer::tick($task['interval'], function () use ($task, $dbForPlatform, $getProjectDB, $queueForCertificates) {
+ $timers[] = Timer::tick($task['interval'], function () use ($task, $dbForPlatform, $getProjectDB, $publisherForCertificates) {
$taskName = $task['name'];
Span::init("interval.{$taskName}");
try {
- $task['callback']($dbForPlatform, $getProjectDB, $queueForCertificates);
+ $task['callback']($dbForPlatform, $getProjectDB, $publisherForCertificates);
} catch (\Exception $e) {
Span::error($e);
} finally {
@@ -75,20 +75,19 @@ class Interval extends Action
protected function getTasks(): array
{
$intervalDomainVerification = (int) System::getEnv('_APP_INTERVAL_DOMAIN_VERIFICATION', '120'); // 2 minutes
- $intervalCleanupStaleExecutions = (int) System::getEnv('_APP_INTERVAL_CLEANUP_STALE_EXECUTIONS', '300'); // 5 minutes
return [
[
'name' => 'domainVerification',
- "callback" => function (Database $dbForPlatform, callable $getProjectDB, Certificate $queueForCertificates) {
- $this->verifyDomain($dbForPlatform, $queueForCertificates);
+ "callback" => function (Database $dbForPlatform, callable $getProjectDB, Certificate $publisherForCertificates) {
+ $this->verifyDomain($dbForPlatform, $publisherForCertificates);
},
'interval' => $intervalDomainVerification * 1000,
]
];
}
- private function verifyDomain(Database $dbForPlatform, Certificate $queueForCertificates): void
+ private function verifyDomain(Database $dbForPlatform, Certificate $publisherForCertificates): void
{
$time = DatabaseDateTime::now();
$fromTime = new DateTime('-3 days'); // Max 3 days old
@@ -102,11 +101,11 @@ class Interval extends Action
]);
$scanned = \count($rules);
- Span::add("interval.domainVerification.scanned", $scanned);
+ Span::add("interval.domain_verification.scanned", $scanned);
if ($scanned === 0) {
- Span::add("interval.domainVerification.processed", 0);
- Span::add("interval.domainVerification.failed", 0);
+ Span::add("interval.domain_verification.processed", 0);
+ Span::add("interval.domain_verification.failed", 0);
return; // No rules to verify
}
@@ -115,66 +114,24 @@ class Interval extends Action
foreach ($rules as $rule) {
try {
- $queueForCertificates
- ->setDomain(new Document([
+ $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: new Document([
+ '$id' => $rule->getAttribute('projectId', ''),
+ '$sequence' => $rule->getAttribute('projectInternalId', 0),
+ ]),
+ domain: new Document([
'domain' => $rule->getAttribute('domain'),
'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')),
- ]))
- ->setAction(Certificate::ACTION_DOMAIN_VERIFICATION)
- ->trigger();
+ ]),
+ action: \Appwrite\Event\Certificate::ACTION_DOMAIN_VERIFICATION,
+ ));
$processed++;
} catch (\Throwable $th) {
$failed++;
}
}
- Span::add("interval.domainVerification.processed", $processed);
- Span::add("interval.domainVerification.failed", $failed);
- }
-
- private function cleanupStaleExecutions(Database $dbForPlatform, callable $getProjectDB): void
- {
- $staleThreshold = DatabaseDateTime::addSeconds(new DateTime(), -1200); // 20 minutes ago
-
- $scanned = 0;
- $processed = 0;
- $failed = 0;
-
- $dbForPlatform->foreach(
- 'projects',
- function (Document $project) use ($getProjectDB, $staleThreshold, &$scanned, &$processed, &$failed) {
- try {
- $dbForProject = $getProjectDB($project);
-
- $staleExecutions = $dbForProject->find('executions', [
- Query::equal('status', ['processing']),
- Query::lessThan('$createdAt', $staleThreshold),
- Query::limit(100),
- ]);
-
- $scanned += \count($staleExecutions);
-
- if (\count($staleExecutions) === 0) {
- return;
- }
-
- foreach ($staleExecutions as $execution) {
- $dbForProject->updateDocument('executions', $execution->getId(), new Document(['status' => 'failed', 'errors' => 'Execution timed out']));
- }
-
- $processed++;
- } catch (\Throwable $th) {
- $failed++;
- }
- },
- [
- Query::equal('region', [System::getEnv('_APP_REGION', 'default')]),
- Query::limit(100),
- ]
- );
-
- Span::add("interval.cleanupStaleExecutions.scanned", $scanned);
- Span::add("interval.cleanupStaleExecutions.processed", $processed);
- Span::add("interval.cleanupStaleExecutions.failed", $failed);
+ Span::add("interval.domain_verification.processed", $processed);
+ Span::add("interval.domain_verification.failed", $failed);
}
}
diff --git a/src/Appwrite/Platform/Tasks/Maintenance.php b/src/Appwrite/Platform/Tasks/Maintenance.php
index c821435786..e43281545a 100644
--- a/src/Appwrite/Platform/Tasks/Maintenance.php
+++ b/src/Appwrite/Platform/Tasks/Maintenance.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Tasks;
-use Appwrite\Event\Certificate;
-use Appwrite\Event\Delete;
+use Appwrite\Event\Message\Delete as DeleteMessage;
+use Appwrite\Event\Publisher\Certificate;
+use Appwrite\Event\Publisher\Delete as DeletePublisher;
use DateInterval;
use DateTime;
use Utopia\Console;
@@ -29,12 +30,12 @@ class Maintenance extends Action
->param('type', 'loop', new WhiteList(['loop', 'trigger']), 'How to run task. "loop" is meant for container entrypoint, and "trigger" for manual execution.')
->inject('dbForPlatform')
->inject('console')
- ->inject('queueForCertificates')
- ->inject('queueForDeletes')
+ ->inject('publisherForCertificates')
+ ->inject('publisherForDeletes')
->callback($this->action(...));
}
- public function action(string $type, Database $dbForPlatform, Document $console, Certificate $queueForCertificates, Delete $queueForDeletes): void
+ public function action(string $type, Database $dbForPlatform, Document $console, Certificate $publisherForCertificates, DeletePublisher $publisherForDeletes): void
{
Console::title('Maintenance V1');
Console::success(APP_NAME . ' maintenance process v1 has started');
@@ -59,7 +60,7 @@ class Maintenance extends Action
$delay = $next->getTimestamp() - $now->getTimestamp();
}
- $action = function () use ($interval, $cacheRetention, $schedulesDeletionRetention, $usageStatsRetentionHourly, $dbForPlatform, $console, $queueForDeletes, $queueForCertificates) {
+ $action = function () use ($interval, $cacheRetention, $schedulesDeletionRetention, $usageStatsRetentionHourly, $dbForPlatform, $console, $publisherForDeletes, $publisherForCertificates) {
$time = DatabaseDateTime::now();
Console::info("[{$time}] Notifying workers with maintenance tasks every {$interval} seconds");
@@ -70,12 +71,12 @@ class Maintenance extends Action
$dbForPlatform->foreach(
'projects',
- function (Document $project) use ($queueForDeletes, $usageStatsRetentionHourly) {
- $queueForDeletes
- ->setType(DELETE_TYPE_MAINTENANCE)
- ->setProject($project)
- ->setUsageRetentionHourlyDateTime(DatabaseDateTime::addSeconds(new \DateTime(), -1 * $usageStatsRetentionHourly))
- ->trigger();
+ function (Document $project) use ($publisherForDeletes, $usageStatsRetentionHourly) {
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $project,
+ type: DELETE_TYPE_MAINTENANCE,
+ hourlyUsageRetentionDatetime: DatabaseDateTime::addSeconds(new \DateTime(), -1 * $usageStatsRetentionHourly),
+ ));
},
[
Query::equal('region', [System::getEnv('_APP_REGION', 'default')]),
@@ -85,17 +86,17 @@ class Maintenance extends Action
]
);
- $queueForDeletes
- ->setType(DELETE_TYPE_MAINTENANCE)
- ->setProject($console)
- ->setUsageRetentionHourlyDateTime(DatabaseDateTime::addSeconds(new \DateTime(), -1 * $usageStatsRetentionHourly))
- ->trigger();
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $console,
+ type: DELETE_TYPE_MAINTENANCE,
+ hourlyUsageRetentionDatetime: DatabaseDateTime::addSeconds(new \DateTime(), -1 * $usageStatsRetentionHourly),
+ ));
- $this->notifyDeleteConnections($queueForDeletes);
- $this->renewCertificates($dbForPlatform, $queueForCertificates);
- $this->notifyDeleteCache($cacheRetention, $queueForDeletes);
- $this->notifyDeleteSchedules($schedulesDeletionRetention, $queueForDeletes);
- $this->notifyDeleteCSVExports($queueForDeletes);
+ $this->notifyDeleteConnections($publisherForDeletes);
+ $this->renewCertificates($dbForPlatform, $publisherForCertificates);
+ $this->notifyDeleteCache($cacheRetention, $publisherForDeletes);
+ $this->notifyDeleteSchedules($schedulesDeletionRetention, $publisherForDeletes);
+ $this->notifyDeleteCSVExports($publisherForDeletes);
};
if ($type === 'loop') {
@@ -109,22 +110,20 @@ class Maintenance extends Action
}
}
- private function notifyDeleteConnections(Delete $queueForDeletes): void
+ private function notifyDeleteConnections(DeletePublisher $publisherForDeletes): void
{
- $queueForDeletes
- ->setType(DELETE_TYPE_REALTIME)
- ->setDatetime(DatabaseDateTime::addSeconds(new \DateTime(), -60))
- ->trigger();
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ type: DELETE_TYPE_REALTIME,
+ datetime: DatabaseDateTime::addSeconds(new \DateTime(), -60),
+ ));
}
- private function notifyDeleteCSVExports(Delete $queueForDeletes): void
+ private function notifyDeleteCSVExports(DeletePublisher $publisherForDeletes): void
{
- $queueForDeletes
- ->setType(DELETE_TYPE_CSV_EXPORTS)
- ->trigger();
+ $publisherForDeletes->enqueue(new DeleteMessage(type: DELETE_TYPE_CSV_EXPORTS));
}
- private function renewCertificates(Database $dbForPlatform, Certificate $queueForCertificate): void
+ private function renewCertificates(Database $dbForPlatform, Certificate $publisherForCertificate): void
{
$time = DatabaseDateTime::now();
@@ -158,29 +157,33 @@ class Maintenance extends Action
continue;
}
- $queueForCertificate
- ->setDomain(new Document([
+ $publisherForCertificate->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: new Document([
+ '$id' => $rule->getAttribute('projectId', ''),
+ '$sequence' => $rule->getAttribute('projectInternalId', 0),
+ ]),
+ domain: new Document([
'domain' => $rule->getAttribute('domain'),
'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')),
- ]))
- ->setAction(Certificate::ACTION_GENERATION)
- ->trigger();
+ ]),
+ action: \Appwrite\Event\Certificate::ACTION_GENERATION,
+ ));
}
}
- private function notifyDeleteCache($interval, Delete $queueForDeletes): void
+ private function notifyDeleteCache($interval, DeletePublisher $publisherForDeletes): void
{
- $queueForDeletes
- ->setType(DELETE_TYPE_CACHE_BY_TIMESTAMP)
- ->setDatetime(DatabaseDateTime::addSeconds(new \DateTime(), -1 * $interval))
- ->trigger();
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ type: DELETE_TYPE_CACHE_BY_TIMESTAMP,
+ datetime: DatabaseDateTime::addSeconds(new \DateTime(), -1 * $interval),
+ ));
}
- private function notifyDeleteSchedules($interval, Delete $queueForDeletes): void
+ private function notifyDeleteSchedules($interval, DeletePublisher $publisherForDeletes): void
{
- $queueForDeletes
- ->setType(DELETE_TYPE_SCHEDULES)
- ->setDatetime(DatabaseDateTime::addSeconds(new \DateTime(), -1 * $interval))
- ->trigger();
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ type: DELETE_TYPE_SCHEDULES,
+ datetime: DatabaseDateTime::addSeconds(new \DateTime(), -1 * $interval),
+ ));
}
}
diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php
index 4725f4095f..5d52d905f6 100644
--- a/src/Appwrite/Platform/Tasks/SDKs.php
+++ b/src/Appwrite/Platform/Tasks/SDKs.php
@@ -5,7 +5,9 @@ namespace Appwrite\Platform\Tasks;
use Appwrite\SDK\Language\AgentSkills;
use Appwrite\SDK\Language\Android;
use Appwrite\SDK\Language\Apple;
+use Appwrite\SDK\Language\ClaudePlugin;
use Appwrite\SDK\Language\CLI;
+use Appwrite\SDK\Language\CodexPlugin;
use Appwrite\SDK\Language\CursorPlugin;
use Appwrite\SDK\Language\Dart;
use Appwrite\SDK\Language\Deno;
@@ -181,7 +183,7 @@ class SDKs extends Action
Console::log('');
- if ($createRelease && ! $examplesOnly) {
+ if ($createRelease) {
Console::info("━━━ {$language['name']} SDK ({$platform['name']}, {$language['version']}) ━━━");
$changelog = $language['changelog'] ?? '';
$changelog = ($changelog) ? \file_get_contents($changelog) : '# Change Log';
@@ -451,6 +453,12 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
case 'cursor-plugin':
$config = new CursorPlugin();
break;
+ case 'claude-plugin':
+ $config = new ClaudePlugin();
+ break;
+ case 'codex-plugin':
+ $config = new CodexPlugin();
+ break;
default:
throw new \Exception('Language "' . $language['key'] . '" not supported');
}
@@ -485,7 +493,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
->setGitRepo($language['gitUrl'])
->setGitRepoName($language['gitRepoName'])
->setGitUserName($language['gitUserName'])
- ->setLogo($cover)
+ ->setCoverImage($cover)
->setURL('https://appwrite.io')
->setShareText('Appwrite is a backend as a service for building web or mobile apps')
->setShareURL('http://appwrite.io')
@@ -622,29 +630,28 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
$repo->execute('config', 'advice.defaultBranchName', 'false');
$repo->addRemote('origin', $gitUrl);
- // Fetch and checkout base branch (or create if new repo)
+ // Fetch and checkout the target branch (e.g. dev) if it exists on remote,
+ // otherwise create it from the base branch (e.g. main).
+ // We build on top of the existing remote branch so a regular push
+ // works without force-pushing against protected branches.
+ $hasBranch = false;
try {
- $repo->execute('fetch', 'origin', '--quiet', '--no-tags', '--depth', '1', $repoBranch);
+ $repo->execute('fetch', 'origin', '--quiet', '--no-tags', '--depth', '1', $gitBranch);
+ $hasBranch = true;
+ } catch (\Throwable) {
+ // Branch doesn't exist on remote yet
+ }
+
+ if ($hasBranch) {
+ $repo->execute('checkout', '-f', $gitBranch);
+ } else {
+ // Fetch base branch to create the target branch from it
try {
+ $repo->execute('fetch', 'origin', '--quiet', '--no-tags', '--depth', '1', $repoBranch);
$repo->execute('checkout', '-f', $repoBranch);
} catch (\Throwable) {
$repo->execute('checkout', '-b', $repoBranch);
}
- } catch (\Throwable) {
- $repo->execute('checkout', '-b', $repoBranch);
- }
-
- try {
- $repo->execute('pull', 'origin', $repoBranch, '--quiet', '--no-tags');
- } catch (\Throwable) {
- }
-
- // Create or checkout dev branch from the base branch
- // This ensures dev always starts from the latest base branch,
- // avoiding history divergence caused by squash merges.
- try {
- $repo->execute('checkout', '-B', $gitBranch, $repoBranch);
- } catch (\Throwable) {
$repo->execute('checkout', '-b', $gitBranch);
}
@@ -685,7 +692,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
return true;
}
- $repo->execute('push', '--force-with-lease', '-u', 'origin', $gitBranch, '--quiet');
+ $repo->execute('push', '-u', 'origin', $gitBranch, '--quiet');
} catch (\Throwable $e) {
Console::warning(" Git push failed: " . $e->getMessage());
return false;
@@ -1147,7 +1154,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
if (! empty($prListOutput[0])) {
$parts = \explode(' ', trim($prListOutput[0]), 2);
- $prNumber = $parts[0] ?? '';
+ $prNumber = $parts[0];
$prUrl = $parts[1] ?? '';
}
}
diff --git a/src/Appwrite/Platform/Tasks/SSL.php b/src/Appwrite/Platform/Tasks/SSL.php
index ef8283f168..cb33836a99 100644
--- a/src/Appwrite/Platform/Tasks/SSL.php
+++ b/src/Appwrite/Platform/Tasks/SSL.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Tasks;
-use Appwrite\Event\Certificate;
+use Appwrite\Event\Publisher\Certificate;
use Utopia\Console;
use Utopia\Database\Database;
use Utopia\Database\Document;
@@ -29,11 +29,11 @@ class SSL extends Action
->param('skip-check', 'true', new Boolean(true), 'If DNS and renew check should be skipped. Defaults to true, and when true, all jobs will result in certificate generation attempt.', true)
->inject('console')
->inject('dbForPlatform')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->callback($this->action(...));
}
- public function action(string $domain, bool|string $skipCheck, Document $console, Database $dbForPlatform, Certificate $queueForCertificates): void
+ public function action(string $domain, bool|string $skipCheck, Document $console, Database $dbForPlatform, Certificate $publisherForCertificates): void
{
$domain = new Domain(!empty($domain) ? $domain : '');
if (!$domain->isKnown() || $domain->isTest()) {
@@ -98,12 +98,13 @@ class SSL extends Action
Console::info('Updated existing rule ' . $rule->getId() . ' for domain: ' . $domain->get());
}
- $queueForCertificates
- ->setDomain(new Document([
- 'domain' => $domain->get()
- ]))
- ->setSkipRenewCheck($skipCheck)
- ->trigger();
+ $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: $console,
+ domain: new Document([
+ 'domain' => $domain->get(),
+ ]),
+ skipRenewCheck: $skipCheck,
+ ));
Console::success('Scheduled a job to issue a TLS certificate for domain: ' . $domain->get());
}
diff --git a/src/Appwrite/Platform/Tasks/ScheduleBase.php b/src/Appwrite/Platform/Tasks/ScheduleBase.php
index c55e3d4a6a..1213f78924 100644
--- a/src/Appwrite/Platform/Tasks/ScheduleBase.php
+++ b/src/Appwrite/Platform/Tasks/ScheduleBase.php
@@ -73,7 +73,7 @@ abstract class ScheduleBase extends Action
* 2. Create timer that sync all changes from 'schedules' collection to local copy. Only reading changes thanks to 'resourceUpdatedAt' attribute
* 3. Create timer that prepares coroutines for soon-to-execute schedules. When it's ready, coroutine sleeps until exact time before sending request to worker.
*/
- public function action(BrokerPool $publisher, BrokerPool $publisherMigrations, BrokerPool $publisherFunctions, BrokerPool $publisherMessaging, callable $isResourceBlocked, Database $dbForPlatform, callable $getProjectDB, Telemetry $telemetry): void
+ public function action(BrokerPool $publisher, BrokerPool $publisherMigrations, BrokerPool $publisherFunctions, BrokerPool $publisherMessaging, callable $isResourceBlocked, Database $dbForPlatform, callable $getProjectDB, Telemetry $telemetry): never
{
Console::title(\ucfirst(static::getSupportedResource()) . ' scheduler V1');
Console::success(APP_NAME . ' ' . \ucfirst(static::getSupportedResource()) . ' scheduler v1 has started');
diff --git a/src/Appwrite/Platform/Tasks/ScheduleExecutions.php b/src/Appwrite/Platform/Tasks/ScheduleExecutions.php
index cd7873bab6..49dd851b6d 100644
--- a/src/Appwrite/Platform/Tasks/ScheduleExecutions.php
+++ b/src/Appwrite/Platform/Tasks/ScheduleExecutions.php
@@ -2,7 +2,8 @@
namespace Appwrite\Platform\Tasks;
-use Appwrite\Event\Func;
+use Appwrite\Event\Message\Func as FunctionMessage;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Swoole\Coroutine as Co;
use Utopia\Database\Database;
@@ -36,7 +37,10 @@ class ScheduleExecutions extends ScheduleBase
{
$intervalEnd = (new \DateTime())->modify('+' . self::ENQUEUE_TIMER . ' seconds');
- $queueForFunctions = new Func($this->publisherFunctions);
+ $publisherForFunctions = new FunctionPublisher(
+ $this->publisherFunctions,
+ new \Utopia\Queue\Queue(\Utopia\System\System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', \Appwrite\Event\Event::FUNCTIONS_QUEUE_NAME), 'utopia-queue', \Appwrite\Event\Event::FUNCTIONS_QUEUE_TTL)
+ );
foreach ($this->schedules as $schedule) {
if (!$schedule['active']) {
@@ -63,23 +67,22 @@ class ScheduleExecutions extends ScheduleBase
$this->updateProjectAccess($schedule['project'], $dbForPlatform);
- \go(function () use ($queueForFunctions, $schedule, $scheduledAt, $delay, $data, $dbForPlatform) {
+ \go(function () use ($publisherForFunctions, $schedule, $scheduledAt, $delay, $data, $dbForPlatform) {
if ($delay > 0) {
Co::sleep($delay);
}
- $queueForFunctions->setType('schedule')
- // Set functionId instead of function as we don't have $dbForProject
- // TODO: Refactor to use function instead of functionId
- ->setFunctionId($schedule['resource']['resourceId'])
- ->setExecution($schedule['resource'])
- ->setMethod($data['method'] ?? 'POST')
- ->setPath($data['path'] ?? '/')
- ->setHeaders($data['headers'] ?? [])
- ->setBody($data['body'] ?? '')
- ->setProject($schedule['project'])
- ->setUserId($data['userId'] ?? '')
- ->trigger();
+ $publisherForFunctions->enqueue(new FunctionMessage(
+ project: $schedule['project'],
+ userId: $data['userId'] ?? '',
+ functionId: $schedule['resource']['resourceId'],
+ execution: $schedule['resource'],
+ type: 'schedule',
+ body: $data['body'] ?? '',
+ path: $data['path'] ?? '/',
+ headers: $data['headers'] ?? [],
+ method: $data['method'] ?? 'POST',
+ ));
$dbForPlatform->deleteDocument(
'schedules',
diff --git a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php
index f867884801..c1a1891386 100644
--- a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php
+++ b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php
@@ -2,13 +2,13 @@
namespace Appwrite\Platform\Tasks;
-use Appwrite\Event\Func;
+use Appwrite\Event\Message\Func as FunctionMessage;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Cron\CronExpression;
use Utopia\Console;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Span\Span;
-use Utopia\System\System;
/**
* ScheduleFunctions
@@ -21,8 +21,6 @@ class ScheduleFunctions extends ScheduleBase
public const UPDATE_TIMER = 10; // seconds
public const ENQUEUE_TIMER = 60; // seconds
- private ?float $lastEnqueueUpdate = null;
-
public static function getName(): string
{
return 'schedule-functions';
@@ -43,7 +41,10 @@ class ScheduleFunctions extends ScheduleBase
$timerStart = \microtime(true);
$time = DateTime::now();
- $enqueueDiff = $this->lastEnqueueUpdate === null ? 0 : $timerStart - $this->lastEnqueueUpdate;
+ // TODO: Track the last enqueue timestamp to subtract ENQUEUE_TIMER drift from
+ // the time frame. Previously this used $this->lastEnqueueUpdate as a property
+ // but enabling the assignment broke scheduling, so the diff stays 0.
+ $enqueueDiff = 0;
$timeFrame = DateTime::addSeconds(new \DateTime(), static::ENQUEUE_TIMER - $enqueueDiff);
Console::log("Enqueue tick: started at: $time (with diff $enqueueDiff)");
@@ -97,40 +98,35 @@ class ScheduleFunctions extends ScheduleBase
$this->updateProjectAccess($schedule['project'], $dbForPlatform);
- $queueForFunctions = new Func($this->publisherFunctions);
+ $publisherForFunctions = new FunctionPublisher(
+ $this->publisherFunctions,
+ new \Utopia\Queue\Queue(\Utopia\System\System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', \Appwrite\Event\Event::FUNCTIONS_QUEUE_NAME), 'utopia-queue', \Appwrite\Event\Event::FUNCTIONS_QUEUE_TTL)
+ );
- $queueForFunctions
- ->setType('schedule')
- ->setFunction($schedule['resource'])
- ->setMethod('POST')
- ->setPath('/')
- ->setProject($schedule['project']);
+ Span::init('schedule.functions.enqueue');
+ try {
+ Span::add('project.id', $schedule['project']->getId());
+ Span::add('function.id', $schedule['resource']->getId());
+ Span::add('schedule.id', $schedule['$id'] ?? '');
- $projectDoc = $schedule['project'];
- $functionDoc = $schedule['resource'];
- $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', '');
- $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', '');
- if ($traceProjectId !== '' && $traceFunctionId !== '' && $projectDoc->getId() === $traceProjectId && $functionDoc->getId() === $traceFunctionId) {
- Span::init('execution.trace.v1_functions_enqueue');
- Span::add('datetime', gmdate('c'));
- Span::add('projectId', $projectDoc->getId());
- Span::add('functionId', $functionDoc->getId());
- Span::add('scheduleId', $schedule['$id'] ?? '');
+ $publisherForFunctions->enqueue(new FunctionMessage(
+ project: $schedule['project'],
+ function: $schedule['resource'],
+ type: 'schedule',
+ method: 'POST',
+ path: '/',
+ ));
+
+ $this->recordEnqueueDelay($delayConfig['nextDate']);
+ } finally {
Span::current()?->finish();
}
-
- $queueForFunctions->trigger();
-
- $this->recordEnqueueDelay($delayConfig['nextDate']);
}
});
}
$timerEnd = \microtime(true);
- // TODO: This was a bug before because it wasn't passed by reference, enabling it breaks scheduling
- //$this->lastEnqueueUpdate = $timerStart;
-
Console::log("Enqueue tick: {$total} executions were enqueued in " . ($timerEnd - $timerStart) . " seconds");
}
}
diff --git a/src/Appwrite/Platform/Tasks/ScheduleMessages.php b/src/Appwrite/Platform/Tasks/ScheduleMessages.php
index 57f6dd8002..634fb26dc2 100644
--- a/src/Appwrite/Platform/Tasks/ScheduleMessages.php
+++ b/src/Appwrite/Platform/Tasks/ScheduleMessages.php
@@ -2,14 +2,20 @@
namespace Appwrite\Platform\Tasks;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Event;
+use Appwrite\Event\Message\Messaging as MessagingMessage;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Utopia\Database\Database;
+use Utopia\Queue\Queue;
+use Utopia\System\System;
class ScheduleMessages extends ScheduleBase
{
public const UPDATE_TIMER = 3; // seconds
public const ENQUEUE_TIMER = 4; // seconds
+ private ?MessagingPublisher $publisherForMessaging = null;
+
public static function getName(): string
{
return 'schedule-messages';
@@ -27,6 +33,11 @@ class ScheduleMessages extends ScheduleBase
protected function enqueueResources(Database $dbForPlatform, callable $getProjectDB): void
{
+ $publisherForMessaging = $this->publisherForMessaging ??= new MessagingPublisher(
+ $this->publisherMessaging,
+ new Queue(System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME))
+ );
+
foreach ($this->schedules as $schedule) {
if (!$schedule['active']) {
continue;
@@ -39,16 +50,14 @@ class ScheduleMessages extends ScheduleBase
continue;
}
- \go(function () use ($schedule, $scheduledAt, $dbForPlatform) {
- $queueForMessaging = new Messaging($this->publisherMessaging);
-
+ \go(function () use ($schedule, $scheduledAt, $dbForPlatform, $publisherForMessaging) {
$this->updateProjectAccess($schedule['project'], $dbForPlatform);
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($schedule['resourceId'])
- ->setProject($schedule['project'])
- ->trigger();
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $schedule['project'],
+ messageId: $schedule['resourceId'],
+ ));
$dbForPlatform->deleteDocument(
'schedules',
diff --git a/src/Appwrite/Platform/Tasks/Screenshot.php b/src/Appwrite/Platform/Tasks/Screenshot.php
index 59e0b11c89..3b50ed7e00 100644
--- a/src/Appwrite/Platform/Tasks/Screenshot.php
+++ b/src/Appwrite/Platform/Tasks/Screenshot.php
@@ -40,9 +40,6 @@ class Screenshot extends Action
throw new \Exception('Invalid JSON in --variables flag');
}
}
- if ($variables === null) {
- throw new \Exception('Invalid JSON in --variables flag');
- }
$templates = Config::getParam('templates-site', []);
diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php
index 2c03ad3108..c8120bd017 100644
--- a/src/Appwrite/Platform/Tasks/Specs.php
+++ b/src/Appwrite/Platform/Tasks/Specs.php
@@ -163,6 +163,12 @@ class Specs extends Action
'description' => 'Your secret dev API key',
'in' => 'header',
],
+ 'Cookie' => [
+ 'type' => 'apiKey',
+ 'name' => 'Cookie',
+ 'description' => 'The user cookie to authenticate with. Used by SDKs that forward an incoming Cookie header in server-side runtimes.',
+ 'in' => 'header',
+ ],
'ImpersonateUserId' => [
'type' => 'apiKey',
'name' => 'X-Appwrite-Impersonate-User-Id',
@@ -219,6 +225,18 @@ class Specs extends Action
'description' => 'The user agent string of the client that made the request',
'in' => 'header',
],
+ 'DevKey' => [
+ 'type' => 'apiKey',
+ 'name' => 'X-Appwrite-Dev-Key',
+ 'description' => 'Your secret dev API key',
+ 'in' => 'header',
+ ],
+ 'Cookie' => [
+ 'type' => 'apiKey',
+ 'name' => 'Cookie',
+ 'description' => 'The user cookie to authenticate with. Used by SDKs that forward an incoming Cookie header in server-side runtimes.',
+ 'in' => 'header',
+ ],
'ImpersonateUserId' => [
'type' => 'apiKey',
'name' => 'X-Appwrite-Impersonate-User-Id',
@@ -272,7 +290,19 @@ class Specs extends Action
'Cookie' => [
'type' => 'apiKey',
'name' => 'Cookie',
- 'description' => 'The user cookie to authenticate with',
+ 'description' => 'The user cookie to authenticate with. Used by SDKs that forward an incoming Cookie header in server-side runtimes.',
+ 'in' => 'header',
+ ],
+ 'Session' => [
+ 'type' => 'apiKey',
+ 'name' => 'X-Appwrite-Session',
+ 'description' => 'The user session to authenticate with',
+ 'in' => 'header',
+ ],
+ 'DevKey' => [
+ 'type' => 'apiKey',
+ 'name' => 'X-Appwrite-Dev-Key',
+ 'description' => 'Your secret dev API key',
'in' => 'header',
],
'ImpersonateUserId' => [
@@ -297,6 +327,150 @@ class Specs extends Action
];
}
+ protected function verifyParsedSpec(array $spec): void
+ {
+ $services = [];
+ foreach ($spec['tags'] ?? [] as $tag) {
+ if (!\is_array($tag)) {
+ continue;
+ }
+
+ $service = $tag['name'] ?? null;
+ if (!\is_string($service) || $service === '') {
+ continue;
+ }
+
+ $services[$this->normalizeSdkName($service)] = $service;
+ }
+
+ if (empty($services)) {
+ return;
+ }
+
+ $enums = [];
+ $this->collectSpecEnumNames($spec, $enums);
+
+ if (empty($enums)) {
+ return;
+ }
+
+ $overlaps = [];
+ foreach ($services as $normalized => $service) {
+ if (!isset($enums[$normalized])) {
+ continue;
+ }
+
+ foreach ($enums[$normalized] as $enum) {
+ $overlaps[] = "service '{$service}' with enum '{$enum}'";
+ }
+ }
+
+ if (!empty($overlaps)) {
+ throw new \RuntimeException(
+ 'Spec service names must not overlap enum names. Overlaps: '
+ . \implode(', ', \array_unique($overlaps))
+ );
+ }
+ }
+
+ private function collectSpecEnumNames(array $node, array &$enums, ?string $fallbackName = null, bool $skipCurrentEnum = false): void
+ {
+ if (!$skipCurrentEnum && isset($node['enum']) && \is_array($node['enum'])) {
+ $enumName = $this->getExplicitSpecEnumName($node)
+ ?? $this->getFallbackSpecEnumName($node, $fallbackName);
+
+ if (!\is_null($enumName)) {
+ $this->addSpecEnumName($enums, $enumName);
+ }
+ }
+
+ $itemsEnumHandled = false;
+ if (
+ isset($node['items'])
+ && \is_array($node['items'])
+ && isset($node['items']['enum'])
+ && \is_array($node['items']['enum'])
+ ) {
+ $enumName = $this->getExplicitSpecEnumName($node['items'])
+ ?? $this->getExplicitSpecEnumName($node)
+ ?? $this->getFallbackSpecEnumName($node, $fallbackName);
+
+ if (!\is_null($enumName)) {
+ $this->addSpecEnumName($enums, $enumName);
+ }
+
+ $itemsEnumHandled = true;
+ }
+
+ $explicitEnumName = $this->getExplicitSpecEnumName($node);
+ if (!\is_null($explicitEnumName) && !isset($node['enum']) && !$itemsEnumHandled) {
+ $this->addSpecEnumName($enums, $explicitEnumName);
+ }
+
+ foreach ($node as $key => $value) {
+ if (!\is_array($value)) {
+ continue;
+ }
+
+ $this->collectSpecEnumNames(
+ $value,
+ $enums,
+ $this->getChildSpecEnumFallbackName($node, $key, $value, $fallbackName),
+ $key === 'items' && $itemsEnumHandled
+ );
+ }
+ }
+
+ private function addSpecEnumName(array &$enums, string $name): void
+ {
+ $enums[$this->normalizeSdkName($name)][] = $this->formatSdkName($name);
+ }
+
+ private function getExplicitSpecEnumName(array $node): ?string
+ {
+ $enumName = $node['x-enum-name'] ?? null;
+
+ return \is_string($enumName) && $enumName !== '' ? $enumName : null;
+ }
+
+ private function getFallbackSpecEnumName(array $node, ?string $fallbackName): ?string
+ {
+ $name = $node['name'] ?? $fallbackName;
+
+ return \is_string($name) && $name !== '' ? $name : null;
+ }
+
+ private function getChildSpecEnumFallbackName(
+ array $parent,
+ int|string $key,
+ array $child,
+ ?string $fallbackName
+ ): ?string {
+ if (isset($child['name']) && \is_string($child['name']) && $child['name'] !== '') {
+ return $child['name'];
+ }
+
+ if ($key === 'schema' || $key === 'items') {
+ return $this->getFallbackSpecEnumName($parent, $fallbackName);
+ }
+
+ if (\is_string($key) && !\in_array($key, ['components', 'content', 'definitions', 'delete', 'get', 'head', 'options', 'parameters', 'patch', 'paths', 'post', 'properties', 'put', 'responses'], true)) {
+ return $key;
+ }
+
+ return $fallbackName;
+ }
+
+ private function formatSdkName(string $name): string
+ {
+ return \str_replace(' ', '', \ucwords(\str_replace(['-', '_', '/'], ' ', $name)));
+ }
+
+ private function normalizeSdkName(string $name): string
+ {
+ return \strtolower((string) \preg_replace('/[^a-z0-9]/i', '', $name));
+ }
+
public function getSDKPlatformsForRouteSecurity(array $routeSecurity): array
{
$sdkPlatforms = [];
@@ -483,6 +657,7 @@ class Specs extends Action
try {
$parsedSpecs = $specs->parse();
+ $this->verifyParsedSpec($parsedSpecs);
} catch (\RuntimeException $e) {
throw new \RuntimeException("Spec generation failed for {$platform} ({$format}): " . $e->getMessage(), 0, $e);
}
diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php
index f49674896e..bde73fd05c 100644
--- a/src/Appwrite/Platform/Tasks/Upgrade.php
+++ b/src/Appwrite/Platform/Tasks/Upgrade.php
@@ -65,9 +65,6 @@ class Upgrade extends Install
$database = null;
$compose = new Compose($data);
foreach ($compose->getServices() as $service) {
- if (!$service) {
- continue;
- }
$env = $service->getEnvironment()->list();
if (isset($env['_APP_DB_ADAPTER'])) {
$database = $env['_APP_DB_ADAPTER'];
diff --git a/src/Appwrite/Platform/Workers/Audits.php b/src/Appwrite/Platform/Workers/Audits.php
index 6bcc85bc36..f6b0345381 100644
--- a/src/Appwrite/Platform/Workers/Audits.php
+++ b/src/Appwrite/Platform/Workers/Audits.php
@@ -2,6 +2,7 @@
namespace Appwrite\Platform\Workers;
+use Appwrite\Event\Message\Audit;
use Exception;
use Throwable;
use Utopia\Console;
@@ -40,7 +41,6 @@ class Audits extends Action
$this
->desc('Audits worker')
->inject('message')
- ->inject('project')
->inject('getAudit')
->callback($this->action(...));
@@ -50,34 +50,35 @@ class Audits extends Action
/**
* @param Message $message
- * @param Document $project
* @param callable(Document): \Utopia\Audit\Audit $getAudit
* @return Commit|NoCommit
* @throws Throwable
* @throws \Utopia\Database\Exception
* @throws Structure
*/
- public function action(Message $message, Document $project, callable $getAudit): Commit|NoCommit
+ public function action(Message $message, callable $getAudit): Commit|NoCommit
{
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new Exception('Missing payload');
}
+ $auditMessage = Audit::fromArray($payload);
+
Console::info('Aggregating audit logs');
- $event = $payload['event'] ?? '';
+ $event = $auditMessage->event;
$auditPayload = '';
- if ($project->getId() === 'console') {
- $auditPayload = $payload['payload'] ?? '';
+ if ($auditMessage->project->getId() === 'console') {
+ $auditPayload = $auditMessage->payload;
}
- $mode = $payload['mode'] ?? '';
- $resource = $payload['resource'] ?? '';
- $userAgent = $payload['userAgent'] ?? '';
- $ip = $payload['ip'] ?? '';
- $user = new Document($payload['user'] ?? []);
+ $mode = $auditMessage->mode;
+ $resource = $auditMessage->resource;
+ $userAgent = $auditMessage->userAgent;
+ $ip = $auditMessage->ip;
+ $user = $auditMessage->user;
$impersonatorUserId = $user->getAttribute('impersonatorUserId');
$actorUserId = $impersonatorUserId ?: $user->getId();
@@ -126,14 +127,14 @@ class Audits extends Action
];
}
- if (isset($this->logs[$project->getSequence()])) {
- $this->logs[$project->getSequence()]['logs'][] = $eventData;
+ if (isset($this->logs[$auditMessage->project->getSequence()])) {
+ $this->logs[$auditMessage->project->getSequence()]['logs'][] = $eventData;
} else {
- $this->logs[$project->getSequence()] = [
+ $this->logs[$auditMessage->project->getSequence()] = [
'project' => new Document([
- '$id' => $project->getId(),
- '$sequence' => $project->getSequence(),
- 'database' => $project->getAttribute('database'),
+ '$id' => $auditMessage->project->getId(),
+ '$sequence' => $auditMessage->project->getSequence(),
+ 'database' => $auditMessage->project->getAttribute('database'),
]),
'logs' => [$eventData]
];
diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php
index 73509819a9..4a31216599 100644
--- a/src/Appwrite/Platform/Workers/Certificates.php
+++ b/src/Appwrite/Platform/Workers/Certificates.php
@@ -3,10 +3,12 @@
namespace Appwrite\Platform\Workers;
use Appwrite\Certificates\Adapter as CertificatesAdapter;
-use Appwrite\Event\Certificate;
use Appwrite\Event\Event;
-use Appwrite\Event\Func;
-use Appwrite\Event\Mail;
+use Appwrite\Event\Message\Func as FunctionMessage;
+use Appwrite\Event\Message\Mail as MailMessage;
+use Appwrite\Event\Publisher\Certificate;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception as AppwriteException;
@@ -50,12 +52,12 @@ class Certificates extends Action
->desc('Certificates worker')
->inject('message')
->inject('dbForPlatform')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('queueForEvents')
->inject('queueForWebhooks')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForRealtime')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->inject('log')
->inject('certificates')
->inject('plan')
@@ -66,12 +68,12 @@ class Certificates extends Action
/**
* @param Message $message
* @param Database $dbForPlatform
- * @param Mail $queueForMails
+ * @param MailPublisher $publisherForMails
* @param Event $queueForEvents
* @param Webhook $queueForWebhooks
- * @param Func $queueForFunctions
+ * @param FunctionPublisher $publisherForFunctions
* @param Realtime $queueForRealtime
- * @param Certificate $queueForCertificates
+ * @param Certificate $publisherForCertificates
* @param Log $log
* @param CertificatesAdapter $certificates
* @param array $plan
@@ -83,39 +85,40 @@ class Certificates extends Action
public function action(
Message $message,
Database $dbForPlatform,
- Mail $queueForMails,
+ MailPublisher $publisherForMails,
Event $queueForEvents,
Webhook $queueForWebhooks,
- Func $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Realtime $queueForRealtime,
- Certificate $queueForCertificates,
+ Certificate $publisherForCertificates,
Log $log,
CertificatesAdapter $certificates,
array $plan,
ValidatorAuthorization $authorization,
): void {
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new Exception('Missing payload');
}
- $document = new Document($payload['domain'] ?? []);
+ $certificateMessage = \Appwrite\Event\Message\Certificate::fromArray($payload);
+ $document = $certificateMessage->domain;
$domain = new Domain($document->getAttribute('domain', ''));
$domainType = $document->getAttribute('domainType');
- $skipRenewCheck = $payload['skipRenewCheck'] ?? false;
- $validationDomain = $payload['validationDomain'] ?? null;
- $action = $payload['action'] ?? Certificate::ACTION_GENERATION;
+ $skipRenewCheck = $certificateMessage->skipRenewCheck;
+ $validationDomain = $certificateMessage->validationDomain;
+ $action = $certificateMessage->action;
$log->addTag('domain', $domain->get());
switch ($action) {
- case Certificate::ACTION_DOMAIN_VERIFICATION:
- $this->handleDomainVerificationAction($domain, $dbForPlatform, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime, $queueForCertificates, $log, $authorization, $validationDomain);
+ case \Appwrite\Event\Certificate::ACTION_DOMAIN_VERIFICATION:
+ $this->handleDomainVerificationAction($domain, $dbForPlatform, $queueForEvents, $queueForWebhooks, $publisherForFunctions, $queueForRealtime, $publisherForCertificates, $log, $authorization, $validationDomain);
break;
- case Certificate::ACTION_GENERATION:
- $this->handleCertificateGenerationAction($domain, $domainType, $dbForPlatform, $queueForMails, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime, $log, $certificates, $authorization, $skipRenewCheck, $plan, $validationDomain);
+ case \Appwrite\Event\Certificate::ACTION_GENERATION:
+ $this->handleCertificateGenerationAction($domain, $domainType, $dbForPlatform, $publisherForMails, $queueForEvents, $queueForWebhooks, $publisherForFunctions, $queueForRealtime, $log, $certificates, $authorization, $skipRenewCheck, $plan, $validationDomain);
break;
default:
@@ -128,9 +131,9 @@ class Certificates extends Action
* @param Database $dbForPlatform
* @param Event $queueForEvents
* @param Webhook $queueForWebhooks
- * @param Func $queueForFunctions
+ * @param FunctionPublisher $publisherForFunctions
* @param Realtime $queueForRealtime
- * @param Certificate $queueForCertificates
+ * @param Certificate $publisherForCertificates
* @param Log $log
* @param ValidatorAuthorization $authorization
* @param string|null $validationDomain
@@ -144,9 +147,9 @@ class Certificates extends Action
Database $dbForPlatform,
Event $queueForEvents,
Webhook $queueForWebhooks,
- Func $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Realtime $queueForRealtime,
- Certificate $queueForCertificates,
+ Certificate $publisherForCertificates,
Log $log,
ValidatorAuthorization $authorization,
?string $validationDomain = null
@@ -183,18 +186,22 @@ class Certificates extends Action
$rule->setAttribute('logs', $logs);
} finally {
// Update rule and emit events
- $this->updateRuleAndSendEvents($rule, $dbForPlatform, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime);
+ $this->updateRuleAndSendEvents($rule, $dbForPlatform, $queueForEvents, $queueForWebhooks, $publisherForFunctions, $queueForRealtime);
}
// Issue a TLS certificate when domain is verified
if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) {
- $queueForCertificates
- ->setDomain(new Document([
+ $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: new Document([
+ '$id' => $rule->getAttribute('projectId', ''),
+ '$sequence' => $rule->getAttribute('projectInternalId', 0),
+ ]),
+ domain: new Document([
'domain' => $rule->getAttribute('domain'),
'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')),
- ]))
- ->setAction(Certificate::ACTION_GENERATION)
- ->trigger();
+ ]),
+ action: \Appwrite\Event\Certificate::ACTION_GENERATION,
+ ));
Console::success('Certificate generation triggered successfully.');
}
@@ -204,10 +211,10 @@ class Certificates extends Action
* @param Domain $domain
* @param ?string $domainType
* @param Database $dbForPlatform
- * @param Mail $queueForMails
+ * @param MailPublisher $publisherForMails
* @param Event $queueForEvents
* @param Webhook $queueForWebhooks
- * @param Func $queueForFunctions
+ * @param FunctionPublisher $publisherForFunctions
* @param Realtime $queueForRealtime
* @param Log $log
* @param CertificatesAdapter $certificates
@@ -228,10 +235,10 @@ class Certificates extends Action
Domain $domain,
?string $domainType,
Database $dbForPlatform,
- Mail $queueForMails,
+ MailPublisher $publisherForMails,
Event $queueForEvents,
Webhook $queueForWebhooks,
- Func $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Realtime $queueForRealtime,
Log $log,
CertificatesAdapter $certificates,
@@ -353,7 +360,7 @@ class Certificates extends Action
$rule->setAttribute('status', RULE_STATUS_CERTIFICATE_GENERATION_FAILED);
// Send email to security email
- $this->notifyError($domain->get(), $e->getMessage(), $attempts, $queueForMails, $plan);
+ $this->notifyError($domain->get(), $e->getMessage(), $attempts, $publisherForMails, $plan);
throw $e;
} finally {
@@ -364,7 +371,7 @@ class Certificates extends Action
// Update rule and emit events
$rule->setAttribute('certificateId', $certificate->getId());
$rule->setAttribute('logs', $logs);
- $this->updateRuleAndSendEvents($rule, $dbForPlatform, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime);
+ $this->updateRuleAndSendEvents($rule, $dbForPlatform, $queueForEvents, $queueForWebhooks, $publisherForFunctions, $queueForRealtime);
}
}
@@ -410,7 +417,7 @@ class Certificates extends Action
* @param Database $dbForPlatform Database connection for console
* @param Event $queueForEvents Event publisher for events
* @param Webhook $queueForWebhooks Webhook publisher for webhooks
- * @param Func $queueForFunctions Function publisher for functions
+ * @param FunctionPublisher $publisherForFunctions Function publisher for functions
* @param Realtime $queueForRealtime Realtime publisher for realtime events
*
* @return void
@@ -420,7 +427,7 @@ class Certificates extends Action
Database $dbForPlatform,
Event $queueForEvents,
Webhook $queueForWebhooks,
- Func $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Realtime $queueForRealtime
): void {
$rule = $dbForPlatform->updateDocument('rules', $rule->getId(), new Document([
@@ -453,9 +460,15 @@ class Certificates extends Action
->trigger();
/** Trigger Functions */
- $queueForFunctions
- ->from($queueForEvents)
- ->trigger();
+ $publisherForFunctions->enqueue(FunctionMessage::fromEvent(
+ event: $queueForEvents->getEvent(),
+ params: $queueForEvents->getParams(),
+ project: $queueForEvents->getProject(),
+ user: $queueForEvents->getUser(),
+ userId: $queueForEvents->getUserId(),
+ payload: $queueForEvents->getPayload(),
+ platform: $queueForEvents->getPlatform(),
+ ));
/** Trigger Realtime Events */
$queueForRealtime
@@ -519,12 +532,12 @@ class Certificates extends Action
* @param string $domain Domain that caused the error
* @param string $errorMessage Verbose error message
* @param int $attempt How many times it failed already
- * @param Mail $queueForMails
+ * @param MailPublisher $publisherForMails
* @param array $plan
* @return void
* @throws Exception
*/
- private function notifyError(string $domain, string $errorMessage, int $attempt, Mail $queueForMails, array $plan): void
+ private function notifyError(string $domain, string $errorMessage, int $attempt, MailPublisher $publisherForMails, array $plan): void
{
// Log error into console
Console::warning('Cannot renew domain (' . $domain . ') on attempt no. ' . $attempt . ' certificate: ' . $errorMessage);
@@ -555,14 +568,14 @@ class Certificates extends Action
$subject = $locale->getText("emails.certificate.subject");
$preview = $locale->getText("emails.certificate.preview");
- $queueForMails
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body)
- ->setName('Appwrite Administrator')
- ->setBodyTemplate(__DIR__ . '/../../../../app/config/locale/templates/email-base-styled.tpl')
- ->setVariables($emailVariables)
- ->setRecipient(System::getEnv('_APP_EMAIL_CERTIFICATES', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS')))
- ->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ recipient: System::getEnv('_APP_EMAIL_CERTIFICATES', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS')),
+ name: 'Appwrite Administrator',
+ subject: $subject,
+ bodyTemplate: __DIR__ . '/../../../../app/config/locale/templates/email-base-styled.tpl',
+ body: $body,
+ preview: $preview,
+ variables: $emailVariables,
+ ));
}
}
diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php
index f4978780a1..8a3cc65e60 100644
--- a/src/Appwrite/Platform/Workers/Deletes.php
+++ b/src/Appwrite/Platform/Workers/Deletes.php
@@ -5,8 +5,12 @@ namespace Appwrite\Platform\Workers;
use Appwrite\Certificates\Adapter as CertificatesAdapter;
use Appwrite\Deletes\Identities;
use Appwrite\Deletes\Targets;
-use Appwrite\Event\Delete as DeleteEvent;
+use Appwrite\Event\Message\Delete as DeleteMessage;
+use Appwrite\Event\Message\Usage;
+use Appwrite\Event\Publisher\Delete as DeletePublisher;
+use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Extend\Exception;
+use Appwrite\Usage\Context as UsageContext;
use Executor\Executor;
use Throwable;
use Utopia\Abuse\Adapters\TimeLimit\Database as AbuseDatabase;
@@ -66,8 +70,9 @@ class Deletes extends Action
->inject('executionsRetentionCount')
->inject('auditRetention')
->inject('log')
- ->inject('queueForDeletes')
+ ->inject('publisherForDeletes')
->inject('getAudit')
+ ->inject('publisherForUsage')
->callback($this->action(...));
}
@@ -93,21 +98,23 @@ class Deletes extends Action
int $executionsRetentionCount,
string $auditRetention,
Log $log,
- DeleteEvent $queueForDeletes,
+ DeletePublisher $publisherForDeletes,
callable $getAudit,
+ UsagePublisher $publisherForUsage,
): void {
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new Exception('Missing payload');
}
- $type = $payload['type'] ?? '';
- $datetime = $payload['datetime'] ?? null;
- $hourlyUsageRetentionDatetime = $payload['hourlyUsageRetentionDatetime'] ?? null;
- $resource = $payload['resource'] ?? null;
- $resourceType = $payload['resourceType'] ?? null;
- $document = new Document($payload['document'] ?? []);
+ $deleteMessage = DeleteMessage::fromArray($payload);
+ $type = $deleteMessage->type;
+ $datetime = $deleteMessage->datetime;
+ $hourlyUsageRetentionDatetime = $deleteMessage->hourlyUsageRetentionDatetime;
+ $resource = $deleteMessage->resource;
+ $resourceType = $deleteMessage->resourceType;
+ $document = $deleteMessage->document ?? new Document();
$log->addTag('projectId', $project->getId());
$log->addTag('type', $type);
@@ -142,7 +149,7 @@ class Deletes extends Action
case DELETE_TYPE_RULES:
$this->deleteRule($dbForPlatform, $document, $certificates);
break;
- case DELETE_TYPE_TRANSACTION:
+ case DELETE_TYPE_TRANSACTIONS:
$this->deleteTransactionLogs($getProjectDB, $document, $project);
break;
default:
@@ -214,13 +221,28 @@ class Deletes extends Action
$this->deleteUsageStats($project, $getProjectDB, $getLogsDB, $hourlyUsageRetentionDatetime);
$this->deleteExpiredSessions($project, $getProjectDB);
$this->deleteExpiredTransactions($project, $getProjectDB);
- $this->deleteOldDeployments($queueForDeletes, $project, $getProjectDB);
+ $this->deleteExpiredPresences($project, $getProjectDB, $publisherForUsage);
+ $this->deleteOldDeployments($publisherForDeletes, $project, $getProjectDB);
+ break;
+ case DELETE_TYPE_REPORT:
+ $this->deleteReport($dbForPlatform, $project, $document);
break;
default:
throw new \Exception('No delete operation for type: ' . \strval($type));
}
}
+ private function deleteReport(Database $dbForPlatform, Document $project, Document $report): void
+ {
+ $projectInternalId = $project->getSequence();
+ $reportInternalId = $report->getSequence();
+
+ $this->deleteByGroup('insights', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::equal('reportInternalId', [$reportInternalId]),
+ ], $dbForPlatform);
+ }
+
private function cleanDatabase(
Document $databaseDoc,
callable $executionActionPerDatabase,
@@ -304,7 +326,8 @@ class Deletes extends Action
$collectionId = match ($document->getAttribute('resourceType')) {
'function' => 'functions',
'execution' => 'executions',
- 'message' => 'messages'
+ 'message' => 'messages',
+ default => throw new \Exception('Unknown resource type: ' . $document->getAttribute('resourceType')),
};
try {
@@ -375,12 +398,12 @@ class Deletes extends Action
Targets::delete($getProjectDB($project), Query::equal('sessionInternalId', [$session->getSequence()]));
}
- private function deleteOldDeployments(DeleteEvent $queueForDeletes, Document $project, callable $getProjectDB): void
+ private function deleteOldDeployments(DeletePublisher $publisherForDeletes, Document $project, callable $getProjectDB): void
{
/** @var Database $dbForProject */
$dbForProject = $getProjectDB($project);
- $removalCallback = function (Document $resource) use ($dbForProject, $queueForDeletes, $project) {
+ $removalCallback = function (Document $resource) use ($dbForProject, $publisherForDeletes, $project) {
$retention = $resource->getAttribute('deploymentRetention', 0);
// 0 means unlimited - never delete
@@ -405,12 +428,12 @@ class Deletes extends Action
'deployments',
$queries,
$dbForProject,
- function (Document $deployment) use ($queueForDeletes, $project) {
- $queueForDeletes
- ->setType(DELETE_TYPE_DOCUMENT)
- ->setDocument($deployment)
- ->setProject($project)
- ->trigger();
+ function (Document $deployment) use ($publisherForDeletes, $project) {
+ $publisherForDeletes->enqueue(new DeleteMessage(
+ project: $project,
+ type: DELETE_TYPE_DOCUMENT,
+ document: $deployment,
+ ));
}
);
};
@@ -632,6 +655,109 @@ class Deletes extends Action
$dsn = new DSN('mysql://' . $document->getAttribute('database', 'console'));
}
+ // Delete Platforms
+ try {
+ $this->deleteByGroup('platforms', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete platforms: ' . $th->getMessage());
+ }
+
+ // Delete project and function rules
+ try {
+ $this->deleteByGroup('rules', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform, function (Document $document) use ($dbForPlatform, $certificates) {
+ $this->deleteRule($dbForPlatform, $document, $certificates);
+ });
+ } catch (Throwable $th) {
+ Console::error('Failed to delete rules: ' . $th->getMessage());
+ }
+
+ // Delete Keys
+ try {
+ $this->deleteByGroup('keys', [
+ Query::equal('resourceType', ['projects']),
+ Query::equal('resourceInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete keys: ' . $th->getMessage());
+ }
+
+ // Delete Webhooks
+ try {
+ $this->deleteByGroup('webhooks', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete webhooks: ' . $th->getMessage());
+ }
+
+ // Delete VCS Installations
+ try {
+ $this->deleteByGroup('installations', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete installations: ' . $th->getMessage());
+ }
+
+ // Delete VCS Repositories
+ try {
+ $this->deleteByGroup('repositories', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete repositories: ' . $th->getMessage());
+ }
+
+ // Delete VCS comments
+ try {
+ $this->deleteByGroup('vcsComments', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete VCS comments: ' . $th->getMessage());
+ }
+
+ // Delete Schedules
+ try {
+ $this->deleteByGroup('schedules', [
+ Query::equal('projectId', [$projectId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete schedules: ' . $th->getMessage());
+ }
+
+ // Delete Advisor insights
+ try {
+ $this->deleteByGroup('insights', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete insights: ' . $th->getMessage());
+ }
+
+ // Delete Advisor reports
+ try {
+ $this->deleteByGroup('reports', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete reports: ' . $th->getMessage());
+ }
+
/**
* @var Database $dbForProject
*/
@@ -651,11 +777,8 @@ class Deletes extends Action
];
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
- $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', ''));
$projectTables = !\in_array($dsn->getHost(), $sharedTables);
- $sharedTablesV1 = \in_array($dsn->getHost(), $sharedTablesV1);
- $sharedTablesV2 = !$projectTables && !$sharedTablesV1;
$allDatabases = [
new Document([
@@ -687,81 +810,38 @@ class Deletes extends Action
};
batch(array_map(
- fn ($databaseDoc) => fn () => $this->cleanDatabase(
- $databaseDoc,
- $executionActionPerDatabase,
- $projectTables,
- $projectCollectionIds
- ),
+ fn ($databaseDoc) => function () use ($databaseDoc, $executionActionPerDatabase, $projectTables, $projectCollectionIds) {
+ try {
+ $this->cleanDatabase(
+ $databaseDoc,
+ $executionActionPerDatabase,
+ $projectTables,
+ $projectCollectionIds
+ );
+ } catch (Throwable $th) {
+ Console::error('Failed to delete database ' . $databaseDoc->getAttribute('database') . ': ' . $th->getMessage());
+ }
+ },
$databasesToClean
));
- // Delete Platforms
- $this->deleteByGroup('platforms', [
- Query::equal('projectInternalId', [$projectInternalId]),
- Query::orderAsc()
- ], $dbForPlatform);
-
- // Delete project and function rules
- $this->deleteByGroup('rules', [
- Query::equal('projectInternalId', [$projectInternalId]),
- Query::orderAsc()
- ], $dbForPlatform, function (Document $document) use ($dbForPlatform, $certificates) {
- $this->deleteRule($dbForPlatform, $document, $certificates);
- });
-
- // Delete Keys
- $this->deleteByGroup('keys', [
- Query::equal('resourceType', ['projects']),
- Query::equal('resourceInternalId', [$projectInternalId]),
- Query::orderAsc()
- ], $dbForPlatform);
-
- // Delete Webhooks
- $this->deleteByGroup('webhooks', [
- Query::equal('projectInternalId', [$projectInternalId]),
- Query::orderAsc()
- ], $dbForPlatform);
-
- // Delete VCS Installations
- $this->deleteByGroup('installations', [
- Query::equal('projectInternalId', [$projectInternalId]),
- Query::orderAsc()
- ], $dbForPlatform);
-
- // Delete VCS Repositories
- $this->deleteByGroup('repositories', [
- Query::equal('projectInternalId', [$projectInternalId]),
- Query::orderAsc()
- ], $dbForPlatform);
-
- // Delete VCS comments
- $this->deleteByGroup('vcsComments', [
- Query::equal('projectInternalId', [$projectInternalId]),
- Query::orderAsc()
- ], $dbForPlatform);
-
- // Delete Schedules
- $this->deleteByGroup('schedules', [
- Query::equal('projectId', [$projectId]),
- Query::orderAsc()
- ], $dbForPlatform);
-
// Delete metadata table
if ($projectTables) {
batch(array_map(
- fn ($databaseDoc) => fn () =>
- $executionActionPerDatabase(
- $databaseDoc,
- fn (Database $dbForDatabases) =>
- $dbForDatabases->deleteCollection(Database::METADATA)
- ),
+ fn ($databaseDoc) => function () use ($databaseDoc, $executionActionPerDatabase) {
+ try {
+ $executionActionPerDatabase(
+ $databaseDoc,
+ fn (Database $dbForDatabases) =>
+ $dbForDatabases->deleteCollection(Database::METADATA)
+ );
+ } catch (Throwable $th) {
+ Console::error('Failed to delete metadata table for database ' . $databaseDoc->getAttribute('database') . ': ' . $th->getMessage());
+ }
+ },
$databasesToClean
));
- } elseif ($sharedTablesV1) {
- /**
- * Temporary disabling deletes for internal collections
- */
+ } else {
$queries = \array_map(
fn ($id) => Query::notEqual('$id', $id),
$projectCollectionIds
@@ -769,32 +849,47 @@ class Deletes extends Action
$queries[] = Query::orderAsc();
- $this->deleteByGroup(
- Database::METADATA,
- $queries,
- $dbForProject
- );
- } elseif ($sharedTablesV2) {
- $queries = \array_map(
- fn ($id) => Query::notEqual('$id', $id),
- $projectCollectionIds
- );
-
- $queries[] = Query::orderAsc();
-
- $this->deleteByGroup(
- Database::METADATA,
- $queries,
- $dbForProject
- );
+ try {
+ $this->deleteByGroup(
+ Database::METADATA,
+ $queries,
+ $dbForProject
+ );
+ } catch (Throwable $th) {
+ Console::error('Failed to delete metadata documents: ' . $th->getMessage());
+ }
}
// Delete all storage directories
- $deviceForFiles->delete($deviceForFiles->getRoot(), true);
- $deviceForSites->delete($deviceForSites->getRoot(), true);
- $deviceForFunctions->delete($deviceForFunctions->getRoot(), true);
- $deviceForBuilds->delete($deviceForBuilds->getRoot(), true);
- $deviceForCache->delete($deviceForCache->getRoot(), true);
+ try {
+ $deviceForFiles->delete($deviceForFiles->getRoot(), true);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete files storage directory: ' . $th->getMessage());
+ }
+
+ try {
+ $deviceForSites->delete($deviceForSites->getRoot(), true);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete sites storage directory: ' . $th->getMessage());
+ }
+
+ try {
+ $deviceForFunctions->delete($deviceForFunctions->getRoot(), true);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete functions storage directory: ' . $th->getMessage());
+ }
+
+ try {
+ $deviceForBuilds->delete($deviceForBuilds->getRoot(), true);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete builds storage directory: ' . $th->getMessage());
+ }
+
+ try {
+ $deviceForCache->delete($deviceForCache->getRoot(), true);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete cache storage directory: ' . $th->getMessage());
+ }
} finally {
$dbForProject->enableValidation();
@@ -932,6 +1027,7 @@ class Deletes extends Action
Query::equal('resourceInternalId', [$resourceInternalId]),
Query::equal('resourceType', [$resourceType]),
Query::orderDesc('$createdAt'),
+ Query::orderDesc(),
Query::offset($executionsRetentionCount),
]);
@@ -1652,4 +1748,25 @@ class Deletes extends Action
// Swallow errors to avoid breaking the cleanup process
});
}
+
+ private function deleteExpiredPresences(Document $project, callable $getProjectDB, UsagePublisher $publisherForUsage): void
+ {
+ $dbForProject = $getProjectDB($project);
+
+ $now = DateTime::format(new \DateTime());
+
+ $deleted = $dbForProject->deleteDocuments('presenceLogs', [
+ Query::lessThan('expiresAt', $now),
+ ], onError: function (Throwable $th) {
+ // Swallow errors to avoid breaking the cleanup process
+ });
+
+ if ($deleted > 0) {
+ $usage = (new UsageContext())->addMetric(METRIC_USERS_PRESENCE, -$deleted);
+ $publisherForUsage->enqueue(new Usage(
+ project: $project,
+ metrics: $usage->getMetrics(),
+ ));
+ }
+ }
}
diff --git a/src/Appwrite/Platform/Workers/Executions.php b/src/Appwrite/Platform/Workers/Executions.php
index 99e20be035..8dbf10cae6 100644
--- a/src/Appwrite/Platform/Workers/Executions.php
+++ b/src/Appwrite/Platform/Workers/Executions.php
@@ -8,7 +8,6 @@ use Utopia\Database\Database;
use Utopia\Platform\Action;
use Utopia\Queue\Message;
use Utopia\Span\Span;
-use Utopia\System\System;
class Executions extends Action
{
@@ -34,26 +33,18 @@ class Executions extends Action
Message $message,
Database $dbForProject,
): void {
- $executionMessage = Execution::fromArray($message->getPayload() ?? []);
+ $executionMessage = Execution::fromArray($message->getPayload());
$execution = $executionMessage->execution;
if ($execution->isEmpty()) {
throw new Exception('Missing execution');
}
- $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', '');
- $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', '');
- $resourceId = $execution->getAttribute('resourceId', '');
- if ($traceProjectId !== '' && $traceFunctionId !== '' && $executionMessage->project->getId() === $traceProjectId && $resourceId === $traceFunctionId) {
- Span::init('execution.trace.executions_worker_upsert');
- Span::add('datetime', gmdate('c'));
- Span::add('projectId', $executionMessage->project->getId());
- Span::add('functionId', $resourceId);
- Span::add('executionId', $execution->getId());
- Span::add('deploymentId', $execution->getAttribute('deploymentId', ''));
- Span::add('resourceType', $execution->getAttribute('resourceType', ''));
- Span::current()?->finish();
- }
+ Span::add('project.id', $executionMessage->project->getId());
+ Span::add('function.id', $execution->getAttribute('resourceId', ''));
+ Span::add('execution.id', $execution->getId());
+ Span::add('deployment.id', $execution->getAttribute('deploymentId', ''));
+ Span::add('resource.type', $execution->getAttribute('resourceType', ''));
$dbForProject->upsertDocument('executions', $execution);
}
diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php
index 0899fbacb4..73c1db9444 100644
--- a/src/Appwrite/Platform/Workers/Functions.php
+++ b/src/Appwrite/Platform/Workers/Functions.php
@@ -5,11 +5,13 @@ namespace Appwrite\Platform\Workers;
use Ahc\Jwt\JWT;
use Appwrite\Bus\Events\ExecutionCompleted;
use Appwrite\Event\Event;
-use Appwrite\Event\Func;
+use Appwrite\Event\Message\Func as FunctionMessage;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Utopia\Response\Model\Execution;
+use Executor\Exception\Timeout as ExecutorTimeout;
use Executor\Executor;
use Utopia\Bus\Bus;
use Utopia\Config\Config;
@@ -45,7 +47,7 @@ class Functions extends Action
->inject('message')
->inject('dbForProject')
->inject('queueForWebhooks')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForRealtime')
->inject('queueForEvents')
->inject('bus')
@@ -60,7 +62,7 @@ class Functions extends Action
Message $message,
Database $dbForProject,
Webhook $queueForWebhooks,
- Func $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Realtime $queueForRealtime,
Event $queueForEvents,
Bus $bus,
@@ -68,7 +70,7 @@ class Functions extends Action
Executor $executor,
callable $isResourceBlocked
): void {
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new AppwriteException(
@@ -77,20 +79,27 @@ class Functions extends Action
);
}
- $type = $payload['type'] ?? '';
+ $functionMessage = FunctionMessage::fromArray($payload);
+ $type = $functionMessage->type;
- $events = $payload['events'] ?? [];
- $data = $payload['body'] ?? '';
- $eventData = $payload['payload'] ?? '';
- $platform = $payload['platform'] ?? Config::getParam('platform', []);
- $function = new Document($payload['function'] ?? []);
- $functionId = $payload['functionId'] ?? '';
- $user = new Document($payload['user'] ?? []);
- $userId = $payload['userId'] ?? '';
- $method = $payload['method'] ?? 'POST';
- $headers = $payload['headers'] ?? [];
- $path = $payload['path'] ?? '/';
- $jwt = $payload['jwt'] ?? '';
+ Span::add('project.id', $project->getId());
+ Span::add('payload.type', $type);
+ Span::add('queue.pid', $message->getPid());
+ Span::add('queue.name', $message->getQueue());
+ Span::add('message.timestamp', (string) $message->getTimestamp());
+
+ $events = $functionMessage->events;
+ $data = $functionMessage->body;
+ $eventData = $functionMessage->payload;
+ $platform = !empty($functionMessage->platform) ? $functionMessage->platform : Config::getParam('platform', []);
+ $function = $functionMessage->function ?? new Document();
+ $functionId = $functionMessage->functionId ?? '';
+ $user = $functionMessage->user ?? new Document();
+ $userId = $functionMessage->userId ?? '';
+ $method = $functionMessage->method ?: 'POST';
+ $headers = $functionMessage->headers;
+ $path = $functionMessage->path ?: '/';
+ $jwt = $functionMessage->jwt;
if ($user->isEmpty() && !empty($userId)) {
$user = $dbForProject->getDocument('users', $userId);
@@ -117,19 +126,7 @@ class Functions extends Action
$log->addTag('type', $type);
if (empty($events) && !$function->isEmpty()) {
- $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', '');
- $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', '');
- if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $function->getId() === $traceFunctionId) {
- Span::init('execution.trace.functions_worker_dequeue');
- Span::add('datetime', gmdate('c'));
- Span::add('projectId', $project->getId());
- Span::add('functionId', $function->getId());
- Span::add('payloadType', $type);
- Span::add('queuePid', $message->getPid());
- Span::add('queueName', $message->getQueue());
- Span::add('messageTimestamp', (string) $message->getTimestamp());
- Span::current()?->finish();
- }
+ Span::add('function.id', $function->getId());
}
if (!empty($events)) {
@@ -171,7 +168,7 @@ class Functions extends Action
log: $log,
dbForProject: $dbForProject,
queueForWebhooks: $queueForWebhooks,
- queueForFunctions: $queueForFunctions,
+ publisherForFunctions: $publisherForFunctions,
queueForRealtime: $queueForRealtime,
queueForEvents: $queueForEvents,
bus: $bus,
@@ -190,7 +187,7 @@ class Functions extends Action
user: $user,
jwt: null,
event: $events[0],
- eventData: \is_string($eventData) ? $eventData : \json_encode($eventData),
+ eventData: \json_encode($eventData) ?: null,
executionId: null,
);
Console::success('Triggered function: ' . $events[0]);
@@ -215,7 +212,7 @@ class Functions extends Action
log: $log,
dbForProject: $dbForProject,
queueForWebhooks: $queueForWebhooks,
- queueForFunctions: $queueForFunctions,
+ publisherForFunctions: $publisherForFunctions,
queueForRealtime: $queueForRealtime,
queueForEvents: $queueForEvents,
bus: $bus,
@@ -241,7 +238,7 @@ class Functions extends Action
log: $log,
dbForProject: $dbForProject,
queueForWebhooks: $queueForWebhooks,
- queueForFunctions: $queueForFunctions,
+ publisherForFunctions: $publisherForFunctions,
queueForRealtime: $queueForRealtime,
queueForEvents: $queueForEvents,
bus: $bus,
@@ -258,7 +255,7 @@ class Functions extends Action
jwt: $jwt,
event: null,
eventData: null,
- executionId: $execution->getId() ?? null
+ executionId: $execution->getId()
);
break;
}
@@ -321,19 +318,11 @@ class Functions extends Action
'duration' => 0.0,
]);
- $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', '');
- $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', '');
- if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $function->getId() === $traceFunctionId) {
- Span::init('execution.trace.functions_worker_before_execution_completed_bus_fail');
- Span::add('datetime', gmdate('c'));
- Span::add('projectId', $project->getId());
- Span::add('functionId', $function->getId());
- Span::add('executionId', $execution->getId());
- Span::add('deploymentId', $execution->getAttribute('deploymentId', ''));
- Span::add('trigger', $trigger);
- Span::add('status', $execution->getAttribute('status', ''));
- Span::current()?->finish();
- }
+ Span::add('function.id', $function->getId());
+ Span::add('execution.id', $execution->getId());
+ Span::add('deployment.id', $execution->getAttribute('deploymentId', ''));
+ Span::add('execution.trigger', $trigger);
+ Span::add('execution.status', $execution->getAttribute('status', ''));
$bus->dispatch(new ExecutionCompleted(
execution: $execution->getArrayCopy(),
@@ -344,7 +333,7 @@ class Functions extends Action
/**
* @param Log $log
* @param Database $dbForProject
- * @param Func $queueForFunctions
+ * @param FunctionPublisher $publisherForFunctions
* @param Realtime $queueForRealtime
* @param Event $queueForEvents
* @param Document $project
@@ -366,7 +355,7 @@ class Functions extends Action
Log $log,
Database $dbForProject,
Webhook $queueForWebhooks,
- Func $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Realtime $queueForRealtime,
Event $queueForEvents,
Bus $bus,
@@ -390,6 +379,10 @@ class Functions extends Action
$deploymentId = $function->getAttribute('deploymentId', '');
$spec = Config::getParam('specifications')[$function->getAttribute('runtimeSpecification', APP_COMPUTE_SPECIFICATION_DEFAULT)];
+ Span::add('function.id', $functionId);
+ Span::add('deployment.id', $deploymentId);
+ Span::add('execution.trigger', $trigger);
+
$log->addTag('deploymentId', $deploymentId);
/** Check if deployment exists */
@@ -434,10 +427,10 @@ 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() ?? '';
+ $headers['x-appwrite-user-id'] = $user->getId();
$headers['x-appwrite-user-jwt'] = $jwt ?? '';
$headers['x-appwrite-country-code'] = '';
$headers['x-appwrite-continent-code'] = '';
@@ -449,6 +442,8 @@ class Functions extends Action
}
$headers['x-appwrite-execution-id'] = $executionId;
+ Span::add('execution.id', $executionId);
+
$headersFiltered = [];
foreach ($headers as $key => $value) {
if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_REQUEST)) {
@@ -488,12 +483,12 @@ class Functions extends Action
// V2 vars
if ($version === 'v2') {
$vars = \array_merge($vars, [
- 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '',
+ 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'],
'APPWRITE_FUNCTION_DATA' => $body,
'APPWRITE_FUNCTION_EVENT_DATA' => $body,
- 'APPWRITE_FUNCTION_EVENT' => $headers['x-appwrite-event'] ?? '',
- 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '',
- 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? ''
+ 'APPWRITE_FUNCTION_EVENT' => $headers['x-appwrite-event'],
+ 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'],
+ 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt']
]);
}
@@ -553,36 +548,28 @@ class Functions extends Action
$source = $deployment->getAttribute('buildPath', '');
$extension = str_ends_with($source, '.tar') ? 'tar' : 'tar.gz';
$command = $version === 'v2' ? '' : "cp /tmp/code.$extension /mnt/code/code.$extension && nohup helpers/start.sh \"$command\"";
- $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', '');
- $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', '');
- if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $functionId === $traceFunctionId) {
- Span::init('execution.trace.functions_worker_before_executor');
- Span::add('datetime', gmdate('c'));
- Span::add('projectId', $project->getId());
- Span::add('functionId', $functionId);
- Span::add('executionId', $executionId);
- Span::add('deploymentId', $deployment->getId());
- Span::add('trigger', $trigger);
- Span::current()?->finish();
+ try {
+ $executionResponse = $executor->createExecution(
+ projectId: $project->getId(),
+ deploymentId: $deploymentId,
+ body: \strlen($body) > 0 ? $body : null,
+ variables: $vars,
+ timeout: $function->getAttribute('timeout', 0),
+ image: $runtime['image'],
+ source: $source,
+ entrypoint: $deployment->getAttribute('entrypoint', ''),
+ version: $version,
+ path: $path,
+ method: $method,
+ headers: $headers,
+ runtimeEntrypoint: $command,
+ cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT,
+ memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT,
+ logging: $function->getAttribute('logging', true),
+ );
+ } catch (ExecutorTimeout $th) {
+ throw new AppwriteException(AppwriteException::FUNCTION_ASYNCHRONOUS_TIMEOUT, previous: $th);
}
- $executionResponse = $executor->createExecution(
- projectId: $project->getId(),
- deploymentId: $deploymentId,
- body: \strlen($body) > 0 ? $body : null,
- variables: $vars,
- timeout: $function->getAttribute('timeout', 0),
- image: $runtime['image'],
- source: $source,
- entrypoint: $deployment->getAttribute('entrypoint', ''),
- version: $version,
- path: $path,
- method: $method,
- headers: $headers,
- runtimeEntrypoint: $command,
- cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT,
- memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT,
- logging: $function->getAttribute('logging', true),
- );
$status = $executionResponse['statusCode'] >= 500 ? 'failed' : 'completed';
@@ -637,19 +624,8 @@ class Functions extends Action
$errorCode = $th->getCode();
} finally {
/** Persist final execution status and record usage */
- $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', '');
- $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', '');
- if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $functionId === $traceFunctionId) {
- Span::init('execution.trace.functions_worker_before_execution_completed_bus');
- Span::add('datetime', gmdate('c'));
- Span::add('projectId', $project->getId());
- Span::add('functionId', $functionId);
- Span::add('executionId', $execution->getId());
- Span::add('deploymentId', $execution->getAttribute('deploymentId', ''));
- Span::add('status', $execution->getAttribute('status', ''));
- Span::add('trigger', $trigger);
- Span::current()?->finish();
- }
+ Span::add('execution.status', $execution->getAttribute('status', ''));
+
$bus->dispatch(new ExecutionCompleted(
execution: $execution->getArrayCopy(),
project: $project->getArrayCopy(),
@@ -675,9 +651,15 @@ class Functions extends Action
->trigger();
/** Trigger Functions */
- $queueForFunctions
- ->from($queueForEvents)
- ->trigger();
+ $publisherForFunctions->enqueue(FunctionMessage::fromEvent(
+ event: $queueForEvents->getEvent(),
+ params: $queueForEvents->getParams(),
+ project: $queueForEvents->getProject(),
+ user: $queueForEvents->getUser(),
+ userId: $queueForEvents->getUserId(),
+ payload: $queueForEvents->getPayload(),
+ platform: $queueForEvents->getPlatform(),
+ ));
/** Trigger Realtime Events */
$queueForRealtime
@@ -688,7 +670,7 @@ class Functions extends Action
if (!empty($error)) {
throw new AppwriteException(
AppwriteException::GENERAL_SERVER_ERROR,
- 'Function execution failed: ' . ($error ?: 'No error message provided'),
+ 'Function execution failed: ' . $error,
$errorCode
);
}
diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php
index 32de1e50d6..5cd4639988 100644
--- a/src/Appwrite/Platform/Workers/Mails.php
+++ b/src/Appwrite/Platform/Workers/Mails.php
@@ -61,7 +61,7 @@ class Mails extends Action
public function action(Message $message, Document $project, Registry $register, Log $log): void
{
Runtime::setHookFlags(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_TCP);
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new Exception('Missing payload');
@@ -173,8 +173,10 @@ class Mails extends Action
$replyTo = $customMailOptions['replyToEmail'] ?? $replyTo;
$replyToName = $customMailOptions['replyToName'] ?? $replyToName;
} elseif (!empty($smtp)) {
- $replyTo = !empty($smtp['replyTo']) ? $smtp['replyTo'] : ($smtp['senderEmail'] ?? $replyTo);
- $replyToName = $smtp['senderName'] ?? $replyToName;
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '';
+ $replyTo = !empty($smtpReplyToEmail) ? $smtpReplyToEmail : ($smtp['senderEmail'] ?? $replyTo);
+ $replyToName = !empty($smtp['replyToName']) ? $smtp['replyToName'] : ($smtp['senderName'] ?? $replyToName);
}
$attachments = null;
diff --git a/src/Appwrite/Platform/Workers/Messaging.php b/src/Appwrite/Platform/Workers/Messaging.php
index ff5eb2417a..7af079e914 100644
--- a/src/Appwrite/Platform/Workers/Messaging.php
+++ b/src/Appwrite/Platform/Workers/Messaging.php
@@ -96,7 +96,7 @@ class Messaging extends Action
UsagePublisher $publisherForUsage
): void {
Runtime::setHookFlags(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_TCP);
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new \Exception('Missing payload');
@@ -106,27 +106,20 @@ class Messaging extends Action
Span::add('message.type', $type);
- try {
- switch ($type) {
- case MESSAGE_SEND_TYPE_INTERNAL:
- $message = new Document($payload['message'] ?? []);
- $recipients = $payload['recipients'] ?? [];
+ switch ($type) {
+ case MESSAGE_SEND_TYPE_INTERNAL:
+ $message = new Document($payload['message'] ?? []);
+ $recipients = $payload['recipients'] ?? [];
- $this->sendInternalSMSMessage($message, $project, $recipients, $log);
- break;
- case MESSAGE_SEND_TYPE_EXTERNAL:
- $message = $dbForProject->getDocument('messages', $payload['messageId']);
+ $this->sendInternalSMSMessage($message, $project, $recipients, $log);
+ break;
+ case MESSAGE_SEND_TYPE_EXTERNAL:
+ $message = $dbForProject->getDocument('messages', $payload['messageId']);
- $this->sendExternalMessage($dbForProject, $message, $deviceForFiles, $project, $publisherForUsage);
- break;
- default:
- throw new \Exception('Unknown message type: ' . $type);
- }
- } catch (\Throwable $e) {
- Span::error($e);
- throw $e;
- } finally {
- Span::current()?->finish();
+ $this->sendExternalMessage($dbForProject, $message, $deviceForFiles, $project, $publisherForUsage);
+ break;
+ default:
+ throw new \Exception('Unknown message type: ' . $type);
}
}
@@ -162,21 +155,26 @@ class Messaging extends Action
}
if (\count($userIds) > 0) {
- $users = $dbForProject->find('users', [
- Query::equal('$id', $userIds),
- Query::limit(\count($userIds)),
- ]);
- foreach ($users as $user) {
- $targets = \array_filter($user->getAttribute('targets'), function (Document $target) use ($providerType) {
- return $target->getAttribute('providerType') === $providerType;
- });
+ $limit = 1000;
+ $offset = 0;
+
+ do {
+ $targets = $dbForProject->find('targets', [
+ Query::equal('userId', $userIds),
+ Query::select(['providerId', 'identifier']),
+ Query::equal('providerType', [$providerType]),
+ Query::limit($limit),
+ Query::offset($offset),
+ ]);
\array_push($allTargets, ...$targets);
- }
+ $offset += \count($targets);
+ } while (\count($targets) === $limit);
}
if (\count($targetIds) > 0) {
$targets = $dbForProject->find('targets', [
+ Query::select(['providerId', 'identifier']),
Query::equal('$id', $targetIds),
Query::equal('providerType', [$providerType]),
Query::limit(\count($targetIds)),
@@ -257,7 +255,9 @@ class Messaging extends Action
$identifiersForProvider = $identifiers[$providerId];
- $adapter = match ($provider->getAttribute('type')) {
+ $providerType = $provider->getAttribute('type');
+
+ $adapter = match ($providerType) {
MESSAGE_TYPE_SMS => $this->getSmsAdapter($provider),
MESSAGE_TYPE_PUSH => $this->getPushAdapter($provider),
MESSAGE_TYPE_EMAIL => $this->getEmailAdapter($provider),
@@ -269,18 +269,17 @@ class Messaging extends Action
$adapter->getMaxMessagesPerRequest()
);
- return batch(\array_map(function ($batch) use ($message, $provider, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) {
- return function () use ($batch, $message, $provider, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) {
+ return batch(\array_map(function ($batch) use ($message, $provider, $providerType, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) {
+ return function () use ($batch, $message, $provider, $providerType, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) {
$deliveredTotal = 0;
$deliveryErrors = [];
$messageData = clone $message;
$messageData->setAttribute('to', $batch);
- $data = match ($provider->getAttribute('type')) {
+ $data = match ($providerType) {
MESSAGE_TYPE_SMS => $this->buildSmsMessage($messageData, $provider),
MESSAGE_TYPE_PUSH => $this->buildPushMessage($messageData),
MESSAGE_TYPE_EMAIL => $this->buildEmailMessage($dbForProject, $messageData, $provider, $deviceForFiles, $project),
- default => throw new \Exception('Provider with the requested ID is of the incorrect type')
};
try {
diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php
index 118ff7acf9..2dd59c7b4c 100644
--- a/src/Appwrite/Platform/Workers/Migrations.php
+++ b/src/Appwrite/Platform/Workers/Migrations.php
@@ -3,9 +3,10 @@
namespace Appwrite\Platform\Workers;
use Ahc\Jwt\JWT;
-use Appwrite\Event\Mail;
+use Appwrite\Event\Message\Mail as MailMessage;
use Appwrite\Event\Message\Migration;
use Appwrite\Event\Message\Usage as UsageMessage;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Realtime;
use Appwrite\Extend\Exception;
@@ -30,6 +31,7 @@ use Utopia\Migration\Destination;
use Utopia\Migration\Destinations\Appwrite as DestinationAppwrite;
use Utopia\Migration\Destinations\CSV as DestinationCSV;
use Utopia\Migration\Destinations\JSON as DestinationJSON;
+use Utopia\Migration\Destinations\OnDuplicate;
use Utopia\Migration\Exception as MigrationException;
use Utopia\Migration\Resource;
use Utopia\Migration\Resources\Database\Database as ResourceDatabase;
@@ -56,7 +58,7 @@ class Migrations extends Action
protected ?Device $deviceForFiles;
protected ?Document $project;
- protected Document $sourceProject;
+ protected ?Document $sourceProject = null;
/**
* @var callable
@@ -74,7 +76,6 @@ class Migrations extends Action
*/
protected array $sourceReport = [];
- private string $source;
/**
* @var callable|null
*/
@@ -102,7 +103,7 @@ class Migrations extends Action
->inject('queueForRealtime')
->inject('deviceForMigrations')
->inject('deviceForFiles')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('usage')
->inject('publisherForUsage')
->inject('plan')
@@ -124,13 +125,13 @@ class Migrations extends Action
Realtime $queueForRealtime,
Device $deviceForMigrations,
Device $deviceForFiles,
- Mail $queueForMails,
+ MailPublisher $publisherForMails,
Context $usage,
UsagePublisher $publisherForUsage,
array $plan,
Authorization $authorization,
): void {
- $migrationMessage = Migration::fromArray($message->getPayload() ?? []);
+ $migrationMessage = Migration::fromArray($message->getPayload());
$this->getDatabasesDB = $getDatabasesDB;
$this->getProjectDB = $getProjectDB;
@@ -163,7 +164,7 @@ class Migrations extends Action
$this->processMigration(
$migration,
$queueForRealtime,
- $queueForMails,
+ $publisherForMails,
$usage,
$publisherForUsage,
$platform,
@@ -195,9 +196,25 @@ class Migrations extends Action
$migrationOptions = $migration->getAttribute('options');
/** @var Database|null $projectDB */
$projectDB = null;
- if ($credentials['projectId']) {
+ $useAppwriteApiSource = false;
+ if ($source === SourceAppwrite::getName() && empty($credentials['projectId'])) {
+ throw new Exception(Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED);
+ }
+
+ if (! empty($credentials['projectId'])) {
$this->sourceProject = $this->dbForPlatform->getDocument('projects', $credentials['projectId']);
- $projectDB = call_user_func($this->getProjectDB, $this->sourceProject);
+ if ($this->sourceProject->isEmpty()) {
+ throw new Exception(Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND);
+ }
+
+ $sourceRegion = $this->sourceProject->getAttribute('region', 'default');
+ $destinationRegion = $this->project->getAttribute('region', 'default');
+ $useAppwriteApiSource = $source === SourceAppwrite::getName()
+ && $destination === DestinationAppwrite::getName()
+ && $sourceRegion !== $destinationRegion;
+ if (! $useAppwriteApiSource) {
+ $projectDB = call_user_func($this->getProjectDB, $this->sourceProject);
+ }
}
$getDatabasesDB = fn (Document $database): Database =>
$this->getDatabasesDBForProject($database);
@@ -233,7 +250,7 @@ class Migrations extends Action
$credentials['endpoint'],
$credentials['apiKey'],
$getDatabasesDB,
- SourceAppwrite::SOURCE_DATABASE,
+ $useAppwriteApiSource ? SourceAppwrite::SOURCE_API : SourceAppwrite::SOURCE_DATABASE,
$projectDB,
$queries
),
@@ -250,7 +267,7 @@ class Migrations extends Action
$this->deviceForMigrations,
$this->dbForProject,
),
- default => throw new \Exception('Invalid source type'),
+ default => throw new Exception(Exception::MIGRATION_SOURCE_TYPE_INVALID),
};
$resources = $migration->getAttribute('resources', []);
@@ -276,6 +293,10 @@ class Migrations extends Action
$this->dbForProject,
$this->getDatabasesDB,
Config::getParam('collections', [])['databases']['collections'],
+ $this->dbForPlatform,
+ $this->project->getSequence(),
+ OnDuplicate::tryFrom($options['onDuplicate'] ?? '') ?? OnDuplicate::Fail,
+ $this->resolveDestinationDatabaseDsn(...),
),
DestinationCSV::getName() => new DestinationCSV(
$this->deviceForFiles,
@@ -295,7 +316,20 @@ class Migrations extends Action
$options['filename'],
$options['columns'] ?? [],
),
- default => throw new \Exception('Invalid destination type'),
+ default => throw new Exception(Exception::MIGRATION_DESTINATION_TYPE_INVALID),
+ };
+ }
+
+ /**
+ * Legacy / tablesdb databases route to the destination project's DSN (same as a fresh
+ * Databases create), while documentsdb / vectorsdb keep the source DSN — the dedicated-DB
+ * backfill that would re-point them is not run during migrations.
+ */
+ private function resolveDestinationDatabaseDsn(ResourceDatabase $resource): string
+ {
+ return match ($resource->getType()) {
+ DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB => (string) $resource->getDatabase(),
+ default => (string) $this->project->getAttribute('database', ''),
};
}
@@ -323,6 +357,55 @@ class Migrations extends Action
);
}
+ /**
+ * @return array
+ */
+ protected function getAPIKeyScopes(): array
+ {
+ return [
+ 'users.read',
+ 'users.write',
+ 'teams.read',
+ 'teams.write',
+ 'buckets.read',
+ 'buckets.write',
+ 'files.read',
+ 'files.write',
+ 'functions.read',
+ 'functions.write',
+ 'sites.read',
+ 'sites.write',
+ 'tokens.read',
+ 'tokens.write',
+ 'providers.read',
+ 'providers.write',
+ 'topics.read',
+ 'topics.write',
+ 'subscribers.read',
+ 'subscribers.write',
+ 'messages.read',
+ 'messages.write',
+ 'targets.read',
+ 'targets.write',
+ 'webhooks.read',
+ 'webhooks.write',
+ 'project.read',
+ 'project.write',
+ 'keys.read',
+ 'keys.write',
+ 'platforms.read',
+ 'platforms.write',
+ 'oauth2.read',
+ 'oauth2.write',
+ 'mocks.read',
+ 'mocks.write',
+ 'project.policies.read',
+ 'project.policies.write',
+ 'templates.read',
+ 'templates.write',
+ ];
+ }
+
/**
* @throws Exception
*/
@@ -343,43 +426,10 @@ class Migrations extends Action
METRIC_NETWORK_INBOUND,
METRIC_NETWORK_OUTBOUND,
],
- 'scopes' => [
- 'users.read',
- 'users.write',
- 'teams.read',
- 'teams.write',
- 'buckets.read',
- 'buckets.write',
- 'files.read',
- 'files.write',
- 'functions.read',
- 'functions.write',
- 'sites.read',
- 'sites.write',
- 'tokens.read',
- 'tokens.write',
- 'providers.read',
- 'providers.write',
- 'topics.read',
- 'topics.write',
- 'subscribers.read',
- 'subscribers.write',
- 'messages.read',
- 'messages.write',
- 'targets.read',
- 'targets.write',
- 'webhooks.read',
- 'webhooks.write',
- 'project.read',
- 'project.write',
- 'keys.read',
- 'keys.write',
- 'platforms.read',
- 'platforms.write',
- ]
+ 'scopes' => $this->getAPIKeyScopes(),
]);
- return API_KEY_DYNAMIC . '_' . $apiKey;
+ return API_KEY_EPHEMERAL . '_' . $apiKey;
}
/**
@@ -393,7 +443,7 @@ class Migrations extends Action
protected function processMigration(
Document $migration,
Realtime $queueForRealtime,
- Mail $queueForMails,
+ MailPublisher $publisherForMails,
Context $usage,
UsagePublisher $publisherForUsage,
array $platform,
@@ -405,6 +455,7 @@ class Migrations extends Action
$transfer = $source = $destination = null;
$aggregatedResources = [];
+ $caughtError = null;
$host = System::getEnv('_APP_MIGRATION_HOST');
if (empty($host)) {
@@ -498,7 +549,6 @@ class Migrations extends Action
if (!empty($sourceErrors) || ! empty($destinationErrors)) {
$migration->setAttribute('status', 'failed');
$migration->setAttribute('stage', 'finished');
- $migration->setAttribute('errors', $this->sanitizeErrors($sourceErrors, $destinationErrors));
return;
}
@@ -513,35 +563,69 @@ class Migrations extends Action
$migration->setAttribute('status', 'failed');
$migration->setAttribute('stage', 'finished');
- call_user_func($this->logError, $th, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [
- 'migrationId' => $migration->getId(),
- 'source' => $migration->getAttribute('source') ?? '',
- 'destination' => $migration->getAttribute('destination') ?? '',
- ]);
+ $caughtError = $th;
+ // Mirror general.php's HTTP-error pattern: typed AppwriteException uses its
+ // registry-driven isPublishable() flag; library-thrown Migration\Exception is
+ // always user-facing; anything else is unknown and surfaced to Sentry.
+ if ($th instanceof Exception) {
+ $publish = $th->isPublishable();
+ } elseif ($th instanceof MigrationException) {
+ $publish = false;
+ } else {
+ $publish = true;
+ }
+
+ if ($publish) {
+ call_user_func($this->logError, $th, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [
+ 'migrationId' => $migration->getId(),
+ 'source' => $migration->getAttribute('source') ?? '',
+ 'destination' => $migration->getAttribute('destination') ?? '',
+ ]);
+ }
} finally {
try {
+ $sourceErrors = $source?->getErrors() ?? [];
+ $destinationErrors = $destination?->getErrors() ?? [];
+
+ if ($caughtError !== null) {
+ if ($caughtError instanceof MigrationException) {
+ // library-thrown, message constructed by us
+ $bubbled = $caughtError;
+ } elseif ($caughtError instanceof Exception) {
+ // typed AppwriteException — message comes from the curated registry
+ $bubbled = new MigrationException(
+ resourceName: '',
+ resourceGroup: '',
+ message: $caughtError->getMessage(),
+ code: $caughtError->getCode(),
+ previous: $caughtError,
+ );
+ } else {
+ // unknown throwable — raw message may embed internal hostnames,
+ // DSNs, tokens, etc. Replace with a generic user-facing string;
+ // the original is preserved on `previous:` for Sentry.
+ $bubbled = new MigrationException(
+ resourceName: '',
+ resourceGroup: '',
+ message: 'Migration failed due to an unexpected error.',
+ code: $caughtError->getCode() ?: 500,
+ previous: $caughtError,
+ );
+ }
+ $destinationErrors[] = $bubbled;
+ }
+
+ $migration->setAttribute('errors', $this->sanitizeErrors(
+ $sourceErrors,
+ $destinationErrors,
+ ));
+
$this->updateMigrationDocument($migration, $project, $queueForRealtime);
if ($migration->getAttribute('status', '') === 'failed') {
Console::error('Migration(' . $migration->getSequence() . ':' . $migration->getId() . ') failed, Project(' . $this->project->getSequence() . ':' . $this->project->getId() . ')');
- $sourceErrors = $source?->getErrors() ?? [];
- $destinationErrors = $destination?->getErrors() ?? [];
-
- foreach ([...$sourceErrors, ...$destinationErrors] as $error) {
- /** @var MigrationException $error */
- if ($error->getCode() === 0 || $error->getCode() >= 500) {
- ($this->logError)($error, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [
- 'migrationId' => $migration->getId(),
- 'source' => $migration->getAttribute('source') ?? '',
- 'destination' => $migration->getAttribute('destination') ?? '',
- 'resourceName' => $error->getResourceName(),
- 'resourceGroup' => $error->getResourceGroup(),
- ]);
- }
- }
-
$source?->error();
$destination?->error();
}
@@ -563,7 +647,7 @@ class Migrations extends Action
}
$destination_type = $migration->getAttribute('destination');
if ($destination_type === DestinationCSV::getName() || $destination_type === DestinationJSON::getName()) {
- $this->handleDataExportComplete($project, $migration, $queueForMails, $queueForRealtime, $platform, $authorization);
+ $this->handleDataExportComplete($project, $migration, $publisherForMails, $queueForRealtime, $platform, $authorization);
}
} finally {
$source?->cleanup();
@@ -578,9 +662,10 @@ class Migrations extends Action
protected function getDatabasesDBForProject(Document $database)
{
- if ($this->sourceProject) {
+ if (isset($this->sourceProject) && ! $this->sourceProject->isEmpty()) {
return ($this->getDatabasesDB)($database, $this->sourceProject);
}
+
return ($this->getDatabasesDB)($database);
}
@@ -589,7 +674,7 @@ class Migrations extends Action
*
* @param Document $project
* @param Document $migration
- * @param Mail $queueForMails
+ * @param MailPublisher $publisherForMails
* @param Realtime $queueForRealtime
* @param array $platform
* @param Authorization $authorization
@@ -598,7 +683,7 @@ class Migrations extends Action
protected function handleDataExportComplete(
Document $project,
Document $migration,
- Mail $queueForMails,
+ MailPublisher $publisherForMails,
Realtime $queueForRealtime,
array $platform,
Authorization $authorization,
@@ -650,7 +735,7 @@ class Migrations extends Action
project: $project,
user: $user,
options: $options,
- queueForMails: $queueForMails,
+ publisherForMails: $publisherForMails,
platform: $platform,
exportType: $migration->getAttribute('destination') === DestinationJSON::getName() ? 'JSON' : 'CSV',
sizeMB: $sizeMB
@@ -713,7 +798,7 @@ class Migrations extends Action
project: $project,
user: $user,
options: $options,
- queueForMails: $queueForMails,
+ publisherForMails: $publisherForMails,
platform: $platform,
exportType: $migration->getAttribute('destination') === DestinationJSON::getName() ? 'JSON' : 'CSV',
downloadUrl: $downloadUrl
@@ -727,7 +812,7 @@ class Migrations extends Action
* @param Document $project
* @param Document $user The user who triggered the operation
* @param array $options Migration options
- * @param Mail $queueForMails
+ * @param MailPublisher $publisherForMails
* @param array $platform
* @param string $downloadUrl Download URL for successful exports
* @param float $sizeMB File size in MB for failed exports
@@ -739,7 +824,7 @@ class Migrations extends Action
Document $project,
Document $user,
array $options,
- Mail $queueForMails,
+ MailPublisher $publisherForMails,
array $platform,
string $exportType = 'CSV',
string $downloadUrl = '',
@@ -809,17 +894,18 @@ class Migrations extends Action
'type' => $exportType,
];
- $queueForMails
- ->setProject($project)
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($emailBody)
- ->setBodyTemplate(__DIR__ . '/../../../../app/config/locale/templates/email-base-styled.tpl')
- ->setVariables($emailVariables)
- ->setName($user->getAttribute('name', $user->getAttribute('email')))
- ->setRecipient($user->getAttribute('email'))
- ->setSenderName($platform['emailSenderName'])
- ->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $user->getAttribute('email'),
+ name: $user->getAttribute('name', $user->getAttribute('email')),
+ subject: $subject,
+ bodyTemplate: __DIR__ . '/../../../../app/config/locale/templates/email-base-styled.tpl',
+ body: $emailBody,
+ preview: $preview,
+ variables: $emailVariables,
+ customMailOptions: ['senderName' => $platform['emailSenderName']],
+ platform: $platform,
+ ));
Console::info("CSV export {$emailType} notification email sent to " . $user->getAttribute('email'));
}
diff --git a/src/Appwrite/Platform/Workers/StatsResources.php b/src/Appwrite/Platform/Workers/StatsResources.php
index db214f5d32..91cf3a1246 100644
--- a/src/Appwrite/Platform/Workers/StatsResources.php
+++ b/src/Appwrite/Platform/Workers/StatsResources.php
@@ -68,7 +68,7 @@ class StatsResources extends Action
{
$this->logError = $logError;
- $statsResources = StatsResourcesMessage::fromArray($message->getPayload() ?? []);
+ $statsResources = StatsResourcesMessage::fromArray($message->getPayload());
if ($statsResources->project->isEmpty()) {
throw new Exception('Missing payload');
}
@@ -80,9 +80,50 @@ class StatsResources extends Action
// Reset documents for each job
$this->documents = [];
+ if ($statsResources->gauges !== []) {
+ try {
+ $this->writeGauges($getLogsDB, $project, $statsResources->gauges);
+ } catch (Throwable $th) {
+ call_user_func_array($this->logError, [$th, "StatsResources", "write_gauges_{$project->getId()}"]);
+ }
+
+ return;
+ }
+
$this->countForProject($dbForPlatform, $getLogsDB, $getProjectDB, $getDatabasesDB, $project);
}
+ /**
+ * Write a batch of pre-computed gauge metrics to the logs database for one project.
+ *
+ * Builds (1h, 1d, inf) period documents for each metric using the existing
+ * createStatsDocuments helper, then commits via writeDocuments — same code path the
+ * standard counting flow uses, just with externally-supplied values.
+ *
+ * @param callable $getLogsDB
+ * @param Document $project
+ * @param array $gauges
+ */
+ protected function writeGauges(callable $getLogsDB, Document $project, array $gauges): void
+ {
+ $region = $project->getAttribute('region', '');
+
+ foreach ($gauges as $gauge) {
+ if ($gauge['metric'] === '') {
+ continue;
+ }
+ $this->createStatsDocuments($region, $gauge['metric'], $gauge['value']);
+ }
+
+ if ($this->documents === []) {
+ return;
+ }
+
+ /** @var \Utopia\Database\Database $dbForLogs */
+ $dbForLogs = call_user_func($getLogsDB, $project);
+ $this->writeDocuments($dbForLogs, $project);
+ }
+
protected function countForProject(Database $dbForPlatform, callable $getLogsDB, callable $getProjectDB, callable $getDatabasesDB, Document $project): void
{
/** @var \Utopia\Database\Database $dbForLogs */
diff --git a/src/Appwrite/Platform/Workers/StatsUsage.php b/src/Appwrite/Platform/Workers/StatsUsage.php
index 144c429629..dad444b381 100644
--- a/src/Appwrite/Platform/Workers/StatsUsage.php
+++ b/src/Appwrite/Platform/Workers/StatsUsage.php
@@ -151,7 +151,7 @@ class StatsUsage extends Action
{
$this->getLogsDB = $getLogsDB;
$this->register = $register;
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new Exception('Missing payload');
}
diff --git a/src/Appwrite/Platform/Workers/Webhooks.php b/src/Appwrite/Platform/Workers/Webhooks.php
index 509f0a6313..973e487de5 100644
--- a/src/Appwrite/Platform/Workers/Webhooks.php
+++ b/src/Appwrite/Platform/Workers/Webhooks.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Workers;
-use Appwrite\Event\Mail;
+use Appwrite\Event\Message\Mail as MailMessage;
use Appwrite\Event\Message\Usage as UsageMessage;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Template\Template;
use Appwrite\Usage\Context as UsageContext;
@@ -36,7 +37,7 @@ class Webhooks extends Action
->inject('message')
->inject('project')
->inject('dbForPlatform')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('publisherForUsage')
->inject('log')
->inject('plan')
@@ -47,17 +48,17 @@ class Webhooks extends Action
* @param Message $message
* @param Document $project
* @param Database $dbForPlatform
- * @param Mail $queueForMails
+ * @param MailPublisher $publisherForMails
* @param UsagePublisher $publisherForUsage
* @param Log $log
* @param array $plan
* @return void
* @throws Exception
*/
- public function action(Message $message, Document $project, Database $dbForPlatform, Mail $queueForMails, UsagePublisher $publisherForUsage, Log $log, array $plan): void
+ public function action(Message $message, Document $project, Database $dbForPlatform, MailPublisher $publisherForMails, UsagePublisher $publisherForUsage, Log $log, array $plan): void
{
$this->errors = [];
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
@@ -73,7 +74,7 @@ class Webhooks extends Action
foreach ($project->getAttribute('webhooks', []) as $webhook) {
if (array_intersect($webhook->getAttribute('events', []), $events)) {
- $this->execute($events, $webhookPayload, $webhook, $user, $project, $dbForPlatform, $queueForMails, $publisherForUsage, $plan);
+ $this->execute($events, $webhookPayload, $webhook, $user, $project, $dbForPlatform, $publisherForMails, $publisherForUsage, $plan);
}
}
@@ -89,11 +90,11 @@ class Webhooks extends Action
* @param Document $user
* @param Document $project
* @param Database $dbForPlatform
- * @param Mail $queueForMails
+ * @param MailPublisher $publisherForMails
* @param array $plan
* @return void
*/
- private function execute(array $events, string $payload, Document $webhook, Document $user, Document $project, Database $dbForPlatform, Mail $queueForMails, UsagePublisher $publisherForUsage, array $plan): void
+ private function execute(array $events, string $payload, Document $webhook, Document $user, Document $project, Database $dbForPlatform, MailPublisher $publisherForMails, UsagePublisher $publisherForUsage, array $plan): void
{
if ($webhook->getAttribute('enabled') !== true) {
return;
@@ -106,51 +107,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());
@@ -175,7 +172,7 @@ class Webhooks extends Action
if ($attempts >= \intval(System::getEnv('_APP_WEBHOOK_MAX_FAILED_ATTEMPTS', '10'))) {
$webhook->setAttribute('enabled', false);
$updatePayload['enabled'] = false;
- $this->sendEmailAlert($attempts, $statusCode, $webhook, $project, $dbForPlatform, $queueForMails, $plan);
+ $this->sendEmailAlert($attempts, $statusCode, $webhook, $project, $dbForPlatform, $publisherForMails, $plan);
}
$dbForPlatform->updateDocument('webhooks', $webhook->getId(), new Document($updatePayload));
@@ -207,11 +204,11 @@ class Webhooks extends Action
* @param Document $webhook
* @param Document $project
* @param Database $dbForPlatform
- * @param Mail $queueForMails
+ * @param MailPublisher $publisherForMails
* @param array $plan
* @return void
*/
- public function sendEmailAlert(int $attempts, mixed $statusCode, Document $webhook, Document $project, Database $dbForPlatform, Mail $queueForMails, array $plan): void
+ public function sendEmailAlert(int $attempts, mixed $statusCode, Document $webhook, Document $project, Database $dbForPlatform, MailPublisher $publisherForMails, array $plan): void
{
$memberships = $dbForPlatform->find('memberships', [
Query::equal('teamInternalId', [$project->getAttribute('teamInternalId')]),
@@ -255,18 +252,16 @@ class Webhooks extends Action
->setParam('{{message}}', $template->render())
->setParam('{{year}}', date("Y"));
- $queueForMails
- ->setProject($project)
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body->render());
-
foreach ($users as $user) {
- $queueForMails
- ->setVariables(['user' => $user->getAttribute('name', '')])
- ->setName($user->getAttribute('name', ''))
- ->setRecipient($user->getAttribute('email'))
- ->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $user->getAttribute('email'),
+ name: $user->getAttribute('name', ''),
+ subject: $subject,
+ body: $body->render(),
+ preview: $preview,
+ variables: ['user' => $user->getAttribute('name', '')],
+ ));
}
}
}
diff --git a/src/Appwrite/Presences/State.php b/src/Appwrite/Presences/State.php
new file mode 100644
index 0000000000..19e7dc98b7
--- /dev/null
+++ b/src/Appwrite/Presences/State.php
@@ -0,0 +1,274 @@
+toString();
+ }
+ } else {
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
+
+ $permissions = Permission::aggregate($permissions, $allowedPermissions);
+
+ if (\is_null($permissions)) {
+ $permissions = [];
+ if (!empty($user->getId()) && !$isPrivilegedUser) {
+ foreach ($allowedPermissions as $permission) {
+ $permissions[] = (new Permission($permission, 'user', $user->getId()))->toString();
+ }
+ }
+ }
+
+ if (!$isAPIKey && !$isPrivilegedUser) {
+ $this->checkPermissions($permissions, $authorization);
+ }
+ }
+
+ sort($permissions, SORT_STRING);
+ $document->setAttribute('$permissions', $permissions);
+ $document->setAttribute('permissionsHash', \md5(\json_encode($permissions)));
+
+ return $document;
+ }
+
+ public function upsertForUser(
+ Database $dbForProject,
+ Document $presenceDocument,
+ string $presenceId,
+ mixed $userInternalId,
+ ?callable $onPresenceCreated = null
+ ): Document {
+ if ($presenceId === 'unique()') {
+ $presenceId = ID::unique();
+ }
+ $presenceDocument->setAttribute('$id', $presenceId);
+
+ $presenceCreated = false;
+
+ try {
+ if ($dbForProject->getAdapter()->getSupportForUpsertOnUniqueIndex()) {
+ $existingPresence = $dbForProject->findOne(self::COLLECTION_ID, [Query::equal('userInternalId', [$userInternalId])]);
+ if ($existingPresence->isEmpty()) {
+ $presenceCreated = true;
+ } else {
+ $presenceDocument->setAttribute('$id', $existingPresence->getId());
+ }
+ $presence = $dbForProject->upsertDocument(self::COLLECTION_ID, $presenceDocument);
+ } else {
+ $presence = $dbForProject->withTransaction(function () use ($dbForProject, $presenceDocument, $userInternalId, &$presenceCreated) {
+ $existingPresence = $dbForProject->findOne(self::COLLECTION_ID, [Query::equal('userInternalId', [$userInternalId])]);
+
+ if ($existingPresence->isEmpty()) {
+ $presenceCreated = true;
+ return $dbForProject->createDocument(self::COLLECTION_ID, $presenceDocument);
+ }
+
+ $currentPresence = $dbForProject->getDocument(self::COLLECTION_ID, $existingPresence->getId(), forUpdate: true);
+
+ if ($currentPresence->isEmpty()) {
+ throw new Exception(Exception::DOCUMENT_NOT_FOUND, params: [$existingPresence->getId()]);
+ }
+
+ $presenceDocument->setAttribute('$id', $currentPresence->getId());
+
+ return $dbForProject->updateDocument(self::COLLECTION_ID, $currentPresence->getId(), $presenceDocument);
+ });
+ }
+
+ if ($presenceCreated && $onPresenceCreated !== null) {
+ call_user_func($onPresenceCreated);
+ }
+
+ return $presence;
+ } catch (DuplicateException $e) {
+ throw new Exception(Exception::PRESENCE_ALREADY_EXISTS, params: [$presenceId], previous: $e);
+ } catch (NotFoundException $e) {
+ throw new Exception(Exception::PRESENCE_NOT_FOUND, params: [$presenceId], previous: $e);
+ } catch (StructureException $e) {
+ throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, $e->getMessage(), previous: $e);
+ } catch (ConflictException $e) {
+ throw new Exception(Exception::DOCUMENT_UPDATE_CONFLICT, $e->getMessage(), previous: $e);
+ }
+ }
+
+ private function checkPermissions(array $permissions, Authorization $authorization): void
+ {
+ foreach (Database::PERMISSIONS as $type) {
+ foreach ($permissions as $permission) {
+ $permission = Permission::parse($permission);
+ if ($permission->getPermission() != $type) {
+ continue;
+ }
+
+ $role = (new Role(
+ $permission->getRole(),
+ $permission->getIdentifier(),
+ $permission->getDimension()
+ ))->toString();
+
+ if (!$authorization->hasRole($role)) {
+ throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', $authorization->getRoles()) . ')');
+ }
+ }
+ }
+ }
+
+ private function getListCacheFieldKey(array $roles, array $queries, string $type): string
+ {
+ $serialized = \array_map(
+ static fn ($query) => $query instanceof Query ? $query->toArray() : $query,
+ $queries,
+ );
+
+ return \sprintf(
+ '%s:%s:%s',
+ \md5(\json_encode($roles)),
+ \md5(\json_encode($serialized)),
+ $type,
+ );
+ }
+
+ public function getListCacheField(
+ Database $dbForProject,
+ array $roles,
+ array $queries,
+ string $type,
+ int $ttl
+ ): mixed {
+ $cacheField = $this->getListCacheFieldKey($roles, $queries, $type);
+ [$collectionKey] = $dbForProject->getCacheKeys(self::COLLECTION_ID);
+
+ try {
+ return $dbForProject->getCache()->load($collectionKey, $ttl, $cacheField);
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+
+ public function setListCacheField(
+ Database $dbForProject,
+ array $roles,
+ array $queries,
+ string $type,
+ mixed $value
+ ): void {
+ $cacheField = $this->getListCacheFieldKey($roles, $queries, $type);
+ [$collectionKey] = $dbForProject->getCacheKeys(self::COLLECTION_ID);
+
+ try {
+ $dbForProject->getCache()->save($collectionKey, $value, $cacheField);
+ } catch (\Throwable) {
+ }
+ }
+
+ public function purgeListCache(Database $dbForProject): bool
+ {
+ [$collectionKey] = $dbForProject->getCacheKeys(self::COLLECTION_ID);
+
+ return $dbForProject->getCache()->purge($collectionKey);
+ }
+
+ public function triggerUsage(
+ UsagePublisher $publisher,
+ Document $project,
+ int $value,
+ ): void {
+ if ($project->isEmpty()) {
+ return;
+ }
+
+ try {
+ $usage = new UsageContext();
+ $usage->addMetric(METRIC_USERS_PRESENCE, $value);
+
+ $publisher->enqueue(new UsageMessage(
+ project: $project,
+ metrics: $usage->getMetrics(),
+ ));
+ } catch (Throwable $th) {
+ if (\function_exists('logError')) {
+ \logError($th, 'realtimeStats', tags: ['projectId' => $project->getId()]);
+ }
+ }
+ }
+
+ public function triggerEvent(
+ QueueEvent $queueForEvents,
+ QueueRealtime $queueForRealtime,
+ Document $project,
+ User $user,
+ string $eventName,
+ Document $presence,
+ ): void {
+ if ($project->isEmpty() || $presence->isEmpty()) {
+ return;
+ }
+
+ try {
+ $queueForEvents
+ ->reset()
+ ->setProject($project)
+ ->setUser($user)
+ ->setEvent($eventName)
+ ->setParam('presenceId', $presence->getId())
+ ->setPayload($presence->getArrayCopy());
+
+ $queueForRealtime
+ ->reset()
+ ->setProject($project)
+ ->setUser($user)
+ ->from($queueForEvents)
+ ->trigger();
+ } catch (Throwable $th) {
+ if (\function_exists('logError')) {
+ \logError($th, 'realtimePresenceEvent', tags: [
+ 'projectId' => $project->getId(),
+ 'event' => $eventName,
+ ]);
+ }
+ }
+ }
+}
diff --git a/src/Appwrite/Promises/Promise.php b/src/Appwrite/Promises/Promise.php
index a58c7c29a8..579969cd7b 100644
--- a/src/Appwrite/Promises/Promise.php
+++ b/src/Appwrite/Promises/Promise.php
@@ -19,8 +19,7 @@ abstract class Promise
return;
}
$resolve = function ($value) {
- $this->setResult($value);
- $this->setState(self::STATE_FULFILLED);
+ $this->setState($this->setResult($value));
};
$reject = function ($value) {
$this->setResult($value);
@@ -106,6 +105,11 @@ abstract class Promise
}
$callable = $this->isFulfilled() ? $onFulfilled : $onRejected;
if (!\is_callable($callable)) {
+ if ($this->isRejected()) {
+ $reject($this->result);
+ return;
+ }
+
$resolve($this->result);
return;
}
@@ -126,30 +130,36 @@ abstract class Promise
abstract public static function all(iterable $promises): self;
/**
- * Set resolved result
+ * Set the resolved result, adopting nested promises while preserving
+ * whether the adopted promise fulfilled or rejected.
*
* @param mixed $value
- * @return void
+ * @return int
*/
- protected function setResult(mixed $value): void
+ protected function setResult(mixed $value): int
{
if (!\is_callable([$value, 'then'])) {
$this->result = $value;
- return;
+ return self::STATE_FULFILLED;
}
- $resolved = false;
+ $state = self::STATE_PENDING;
- $callable = function ($value) use (&$resolved) {
- $this->setResult($value);
- $resolved = true;
- };
+ $value->then(
+ function ($value) use (&$state) {
+ $state = $this->setResult($value);
+ },
+ function ($value) use (&$state) {
+ $this->result = $value;
+ $state = self::STATE_REJECTED;
+ }
+ );
- $value->then($callable, $callable);
-
- while (!$resolved) {
+ while ($state === self::STATE_PENDING) {
usleep(25000);
}
+
+ return $state;
}
/**
diff --git a/src/Appwrite/Promises/Swoole.php b/src/Appwrite/Promises/Swoole.php
index 9c06fbda2f..03c901ead6 100644
--- a/src/Appwrite/Promises/Swoole.php
+++ b/src/Appwrite/Promises/Swoole.php
@@ -8,7 +8,7 @@ use Utopia\DI\Container;
class Swoole extends Promise
{
- private const REQUEST_CONTAINER_CONTEXT_KEY = '__utopia_http_request_container';
+ private const REQUEST_CONTAINER_CONTEXT_KEY = '__utopia__';
public function __construct(?callable $executor = null)
{
diff --git a/src/Appwrite/Realtime/Message/Dispatcher.php b/src/Appwrite/Realtime/Message/Dispatcher.php
new file mode 100644
index 0000000000..827588f624
--- /dev/null
+++ b/src/Appwrite/Realtime/Message/Dispatcher.php
@@ -0,0 +1,142 @@
+
+ */
+ private array $handlers = [];
+
+ public function addHandler(Action $handler): self
+ {
+ $labels = $handler->getLabels();
+ $type = $labels[self::LABEL_MESSAGE_TYPE]
+ ?? throw new \LogicException('Realtime message handler is missing the messageType label.');
+
+ $this->handlers[$type] = $handler;
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function getHandlers(): array
+ {
+ return $this->handlers;
+ }
+
+ /**
+ * Routes a parsed websocket message to the handler that registered for its `type`,
+ * runs param validation + dependency injection, and returns whatever the handler returns.
+ * Errors propagate so the caller can render them as websocket error frames.
+ *
+ * @param Container $container per-message container resolving 'connection', 'project',
+ * 'projectId' and any handler-declared injections.
+ * @param array $message decoded inbound websocket frame: `['type' => ..., 'data' => ...]`.
+ * @return array|null the handler's response payload (already shaped for the
+ * wire), or null when the handler chooses not to reply.
+ */
+ public function dispatch(Container $container, array $message): ?array
+ {
+ $type = $message['type'] ?? '';
+ if (!\is_string($type) || !isset($this->handlers[$type])) {
+ throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.');
+ }
+
+ $handler = $this->handlers[$type];
+ $labels = $handler->getLabels();
+
+ $requiresProject = $labels[self::LABEL_REQUIRES_PROJECT] ?? true;
+ if ($requiresProject && empty($container->get('projectId'))) {
+ throw new Exception(
+ Exception::REALTIME_POLICY_VIOLATION,
+ 'Missing project context. Reconnect to the project first.'
+ );
+ }
+
+ $shape = $labels[self::LABEL_PAYLOAD_SHAPE] ?? self::PAYLOAD_SHAPE_OBJECT;
+ $dataPresent = \array_key_exists('data', $message);
+ $data = $dataPresent ? $message['data'] : null;
+
+ $args = $this->resolveArgs($handler, $data, $shape, $container);
+
+ return ($handler->getCallback())(...$args);
+ }
+
+ /**
+ * Resolves the ordered argument list for the handler callback by walking the action's
+ * declared option sequence. Params come from the inbound `data` (for object shape) or
+ * the entire data value (for list shape). Injections come from the per-message container.
+ *
+ * @return array
+ */
+ private function resolveArgs(
+ Action $handler,
+ mixed $data,
+ string $shape,
+ Container $container,
+ ): array {
+ $values = [];
+ $dataPresent = $data !== null;
+ foreach ($handler->getParams() as $key => $param) {
+ if ($shape === self::PAYLOAD_SHAPE_LIST) {
+ // The whole `data` field is the value of this single param. `present` reflects
+ // whether the inbound message actually contained the `data` key.
+ $present = $dataPresent;
+ $value = $dataPresent ? $data : $param['default'];
+ } else {
+ $present = \is_array($data) && \array_key_exists($key, $data);
+ $value = $present ? $data[$key] : $param['default'];
+ }
+
+ if (!$present && !$param['optional']) {
+ throw new Exception(
+ Exception::REALTIME_MESSAGE_FORMAT_INVALID,
+ \sprintf(self::REQUIRED_PARAM_ERROR_FORMAT, \ucfirst($key)),
+ );
+ }
+
+ if ($present && !($param['skipValidation'] ?? false)) {
+ $validator = $param['validator'];
+ if (\is_callable($validator) && !($validator instanceof \Utopia\Validator)) {
+ $validator = $validator();
+ }
+ if (!$validator->isValid($value)) {
+ throw new Exception(
+ Exception::REALTIME_MESSAGE_FORMAT_INVALID,
+ \sprintf('%s: %s', $key, $validator->getDescription())
+ );
+ }
+ }
+
+ $values[$key] = $value;
+ }
+
+ $ordered = [];
+ foreach ($handler->getOptions() as $optionKey => $option) {
+ if (($option['type'] ?? '') === 'param') {
+ $name = \substr($optionKey, \strlen('param:'));
+ $ordered[] = $values[$name] ?? null;
+ } else {
+ $ordered[] = $container->get($option['name']);
+ }
+ }
+
+ return $ordered;
+ }
+}
diff --git a/src/Appwrite/Realtime/Message/Handlers/Authentication.php b/src/Appwrite/Realtime/Message/Handlers/Authentication.php
new file mode 100644
index 0000000000..9065e586d3
--- /dev/null
+++ b/src/Appwrite/Realtime/Message/Handlers/Authentication.php
@@ -0,0 +1,125 @@
+desc('Authenticate the connection with a session token')
+ ->label(Dispatcher::LABEL_MESSAGE_TYPE, 'authentication')
+ ->param('session', '', new Text(2048), 'Encoded session token')
+ ->inject('connectionId')
+ ->inject('realtime')
+ ->inject('database')
+ ->inject('register')
+ ->inject('response')
+ ->callback($this->action(...));
+ }
+
+ /**
+ * @return array
+ */
+ public function action(
+ string $session,
+ int $connectionId,
+ Realtime $realtime,
+ Database $database,
+ Registry $register,
+ Response $response,
+ ): array {
+ $store = new Store();
+ $store->decode($session);
+
+ $userId = $store->getProperty('id', '');
+ if ($userId !== '') {
+ $database->purgeCachedDocument('users', $userId);
+ }
+
+ /** @var User $user */
+ $user = $database->getDocument('users', $userId);
+
+ // TODO: move proof construction to the DI container so there's one source of truth.
+ $proofForToken = new Token();
+ $proofForToken->setHash(new Sha());
+
+ if (
+ empty($user->getId())
+ || !$user->sessionVerify($store->getProperty('secret', ''), $proofForToken)
+ ) {
+ throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Session is not valid.');
+ }
+
+ $roles = $user->getRoles($database->getAuthorization());
+
+ $authorization = $realtime->connections[$connectionId]['authorization'] ?? null;
+ $projectId = $realtime->connections[$connectionId]['projectId'] ?? null;
+ // Capture the pre-auth userId before unsubscribe() clears the connection entry,
+ // so we can rebind any account channels that were stored under it.
+ $previousUserId = $realtime->connections[$connectionId]['userId'] ?? '';
+
+ $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connectionId));
+ $meta = $realtime->getSubscriptionMetadata($connectionId);
+
+ $realtime->unsubscribe($connectionId);
+
+ if (!empty($projectId)) {
+ foreach ($meta as $subscriptionId => $subscription) {
+ $queries = Query::parseQueries($subscription['queries'] ?? []);
+ $channels = Realtime::rebindAccountChannels(
+ $subscription['channels'] ?? [],
+ $previousUserId,
+ $user->getId(),
+ );
+
+ $realtime->subscribe(
+ $projectId,
+ $connectionId,
+ $subscriptionId,
+ $roles,
+ $channels,
+ $queries,
+ $user->getId(),
+ );
+ }
+ }
+
+ if ($authorization !== null) {
+ $realtime->connections[$connectionId]['authorization'] = $authorization;
+ }
+
+ $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connectionId));
+ $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
+ if ($subscriptionDelta !== 0) {
+ $register->get('telemetry.workerSubscriptionCounter')
+ ->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
+ }
+
+ Span::add('realtime.subscription_delta', $subscriptionDelta);
+
+ return [
+ 'type' => 'response',
+ 'data' => [
+ 'to' => 'authentication',
+ 'success' => true,
+ 'user' => $response->output($user, Response::MODEL_ACCOUNT),
+ ],
+ ];
+ }
+}
diff --git a/src/Appwrite/Realtime/Message/Handlers/Ping.php b/src/Appwrite/Realtime/Message/Handlers/Ping.php
new file mode 100644
index 0000000000..4bfcfa7060
--- /dev/null
+++ b/src/Appwrite/Realtime/Message/Handlers/Ping.php
@@ -0,0 +1,28 @@
+desc('Reply to client heartbeat')
+ ->label(Dispatcher::LABEL_MESSAGE_TYPE, 'ping')
+ ->label(Dispatcher::LABEL_REQUIRES_PROJECT, false)
+ ->callback($this->action(...));
+ }
+
+ /**
+ * @return array
+ */
+ public function action(): array
+ {
+ return [
+ 'type' => 'pong',
+ ];
+ }
+}
diff --git a/src/Appwrite/Realtime/Message/Handlers/Presence.php b/src/Appwrite/Realtime/Message/Handlers/Presence.php
new file mode 100644
index 0000000000..1787bc0682
--- /dev/null
+++ b/src/Appwrite/Realtime/Message/Handlers/Presence.php
@@ -0,0 +1,128 @@
+desc('Upsert a presence document for the authenticated user')
+ ->label(Dispatcher::LABEL_MESSAGE_TYPE, 'presence')
+ ->param('status', '', new Text(2048), 'Presence status')
+ ->param('presenceId', 'unique()', new Text(36), 'Presence document ID', true)
+ ->param('metadata', null, new JSON(), 'Optional metadata payload', true, [], true)
+ ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true)
+ ->inject('connectionId')
+ ->inject('realtime')
+ ->inject('database')
+ ->inject('authorization')
+ ->inject('presenceState')
+ ->inject('project')
+ ->inject('publisherForUsage')
+ ->inject('queueForEvents')
+ ->inject('queueForRealtime')
+ ->callback($this->action(...));
+ }
+
+ /**
+ * @param array|null $permissions
+ * @return array
+ */
+ public function action(
+ string $status,
+ string $presenceId,
+ mixed $metadata,
+ ?array $permissions,
+ int $connectionId,
+ Realtime $realtime,
+ Database $database,
+ Authorization $authorization,
+ PresenceState $presenceState,
+ ?Document $project,
+ UsagePublisher $publisherForUsage,
+ QueueEvent $queueForEvents,
+ QueueRealtime $queueForRealtime,
+ ): array {
+ if ($project === null || $project->isEmpty()) {
+ throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Presence requires a project context.');
+ }
+
+ $userId = $realtime->connections[$connectionId]['userId'] ?? '';
+ if (empty($userId)) {
+ throw new Exception(Exception::USER_UNAUTHORIZED, 'User must be authorized');
+ }
+
+ $user = new User($database->getDocument('users', $userId)->getArrayCopy());
+ if ($user->isEmpty()) {
+ throw new Exception(Exception::USER_NOT_FOUND, params: [$userId]);
+ }
+
+ $presenceData = [
+ 'userInternalId' => $user->getSequence(),
+ 'userId' => $user->getId(),
+ 'source' => 'realtime',
+ 'status' => $status,
+ 'expiresAt' => DateTime::format((new \DateTime())->modify('+30 days')),
+ 'hostname' => \gethostname() ?: null,
+ ];
+ if ($metadata !== null) {
+ $presenceData['metadata'] = $metadata;
+ }
+
+ $presenceDocument = new Document($presenceData);
+ $presenceState->setPermissions($presenceDocument, $permissions, $user, $authorization);
+
+ $presence = $presenceState->upsertForUser(
+ $database,
+ $presenceDocument,
+ $presenceId,
+ (string) $user->getSequence(),
+ function () use ($presenceState, $publisherForUsage, $project): void {
+ $presenceState->triggerUsage($publisherForUsage, $project, 1);
+ },
+ );
+
+ $presence->removeAttribute('$collection');
+ $presence->removeAttribute('$tenant');
+ $presence->removeAttribute('hostname');
+ $presence->removeAttribute('permissionsHash');
+ $presence->removeAttribute('userInternalId');
+
+ $realtime->connections[$connectionId]['presences'][$presence->getId()] = $presence;
+
+ $presenceState->triggerEvent(
+ $queueForEvents,
+ $queueForRealtime,
+ $project,
+ $user,
+ 'presences.[presenceId].upsert',
+ $presence,
+ );
+
+ return [
+ 'type' => 'response',
+ 'data' => [
+ 'to' => 'presence',
+ 'presence' => $presence->getArrayCopy(),
+ ],
+ ];
+ }
+}
diff --git a/src/Appwrite/Realtime/Message/Handlers/Subscribe.php b/src/Appwrite/Realtime/Message/Handlers/Subscribe.php
new file mode 100644
index 0000000000..140cd800c3
--- /dev/null
+++ b/src/Appwrite/Realtime/Message/Handlers/Subscribe.php
@@ -0,0 +1,109 @@
+desc('Bulk subscribe to realtime channels')
+ ->label(Dispatcher::LABEL_MESSAGE_TYPE, 'subscribe')
+ ->label(Dispatcher::LABEL_PAYLOAD_SHAPE, Dispatcher::PAYLOAD_SHAPE_LIST)
+ ->param('items', null, fn () => new SubscribePayloadValidator(), 'Subscriptions to add')
+ ->inject('connectionId')
+ ->inject('realtime')
+ ->inject('register')
+ ->inject('projectId')
+ ->callback($this->action(...));
+ }
+
+ /**
+ * @param array, queries?: array, subscriptionId?: string}> $items
+ * @return array
+ */
+ public function action(
+ array $items,
+ int $connectionId,
+ Realtime $realtime,
+ Registry $register,
+ ?string $projectId,
+ ): array {
+ $roles = $realtime->connections[$connectionId]['roles'] ?? [Role::guests()->toString()];
+ $userId = $realtime->connections[$connectionId]['userId'] ?? '';
+
+ $parsedPayloads = [];
+ $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connectionId));
+
+ foreach ($items as $payload) {
+ $subscriptionId = \array_key_exists('subscriptionId', $payload)
+ ? $payload['subscriptionId']
+ : ID::unique();
+
+ $queries = $payload['queries'] ?? [];
+
+ try {
+ $convertedQueries = Realtime::convertQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Invalid query: ' . $e->getMessage());
+ }
+
+ $convertedChannels = \array_keys(Realtime::convertChannels($payload['channels'], $userId));
+
+ $parsedPayloads[] = [
+ 'subscriptionId' => $subscriptionId,
+ 'channels' => $payload['channels'],
+ 'convertedChannels' => $convertedChannels,
+ 'queries' => $convertedQueries,
+ ];
+ }
+
+ foreach ($parsedPayloads as $parsedPayload) {
+ $realtime->subscribe(
+ $projectId,
+ $connectionId,
+ $parsedPayload['subscriptionId'],
+ $roles,
+ $parsedPayload['convertedChannels'],
+ $parsedPayload['queries'],
+ );
+ }
+
+ $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connectionId));
+ $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
+ $subscriptionsRequested = \count($parsedPayloads);
+
+ if ($subscriptionDelta !== 0) {
+ $register->get('telemetry.workerSubscriptionCounter')
+ ->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
+ }
+
+ Span::add('realtime.subscription_delta', $subscriptionDelta);
+ Span::add('realtime.subscriptions_requested', $subscriptionsRequested);
+ Span::add('realtime.subscribe.subscriptions_count', $subscriptionsRequested);
+
+ return [
+ 'type' => 'response',
+ 'data' => [
+ 'to' => 'subscribe',
+ 'success' => true,
+ 'subscriptions' => \array_map(static fn (array $parsed): array => [
+ 'subscriptionId' => $parsed['subscriptionId'],
+ 'channels' => $parsed['convertedChannels'],
+ 'queries' => \array_map(static fn ($q) => $q->toString(), $parsed['queries']),
+ ], $parsedPayloads),
+ ],
+ ];
+ }
+}
diff --git a/src/Appwrite/Realtime/Message/Handlers/Unsubscribe.php b/src/Appwrite/Realtime/Message/Handlers/Unsubscribe.php
new file mode 100644
index 0000000000..e1bc163f68
--- /dev/null
+++ b/src/Appwrite/Realtime/Message/Handlers/Unsubscribe.php
@@ -0,0 +1,74 @@
+desc('Bulk remove subscriptions by id')
+ ->label(Dispatcher::LABEL_MESSAGE_TYPE, 'unsubscribe')
+ ->label(Dispatcher::LABEL_PAYLOAD_SHAPE, Dispatcher::PAYLOAD_SHAPE_LIST)
+ ->param('items', null, fn () => new UnsubscribePayloadValidator(), 'Subscriptions to remove')
+ ->inject('connectionId')
+ ->inject('realtime')
+ ->inject('register')
+ ->callback($this->action(...));
+ }
+
+ /**
+ * @param array $items
+ * @return array
+ */
+ public function action(
+ array $items,
+ int $connectionId,
+ Realtime $realtime,
+ Registry $register,
+ ): array {
+ $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connectionId));
+
+ $unsubscribeResults = [];
+ foreach ($items as $payload) {
+ $subscriptionId = $payload['subscriptionId'];
+ $unsubscribeResults[] = [
+ 'subscriptionId' => $subscriptionId,
+ 'removed' => $realtime->unsubscribeSubscription($connectionId, $subscriptionId),
+ ];
+ }
+
+ $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connectionId));
+ $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
+ $subscriptionsRequested = \count($items);
+ $subscriptionsRemoved = \count(\array_filter(
+ $unsubscribeResults,
+ static fn (array $item): bool => $item['removed']
+ ));
+
+ if ($subscriptionDelta !== 0) {
+ $register->get('telemetry.workerSubscriptionCounter')
+ ->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
+ }
+
+ Span::add('realtime.subscription_delta', $subscriptionDelta);
+ Span::add('realtime.subscriptions_requested', $subscriptionsRequested);
+ Span::add('realtime.subscriptions_removed', $subscriptionsRemoved);
+
+ return [
+ 'type' => 'response',
+ 'data' => [
+ 'to' => 'unsubscribe',
+ 'success' => true,
+ 'subscriptions' => $unsubscribeResults,
+ ],
+ ];
+ }
+}
diff --git a/src/Appwrite/Realtime/Message/Validators/SubscribePayload.php b/src/Appwrite/Realtime/Message/Validators/SubscribePayload.php
new file mode 100644
index 0000000000..3e7e5cec10
--- /dev/null
+++ b/src/Appwrite/Realtime/Message/Validators/SubscribePayload.php
@@ -0,0 +1,69 @@
+description;
+ }
+
+ public function isArray(): bool
+ {
+ return true;
+ }
+
+ public function getType(): string
+ {
+ return self::TYPE_ARRAY;
+ }
+
+ public function isValid(mixed $value): bool
+ {
+ if (!\is_array($value) || !\array_is_list($value)) {
+ $this->description = 'Payload is not valid.';
+ return false;
+ }
+
+ $customId = new CustomId();
+
+ foreach ($value as $payload) {
+ if (!\is_array($payload)) {
+ $this->description = 'Each subscribe payload must be an object.';
+ return false;
+ }
+ if (\array_key_exists('subscriptionId', $payload) && !$customId->isValid($payload['subscriptionId'])) {
+ $this->description = 'subscriptionId is not a valid id.';
+ return false;
+ }
+ if (!\array_key_exists('channels', $payload)) {
+ $this->description = 'channels is not present in payload.';
+ return false;
+ }
+ if (!\is_array($payload['channels']) || !\array_is_list($payload['channels'])) {
+ $this->description = 'channels is not a valid array.';
+ return false;
+ }
+ foreach ($payload['channels'] as $channel) {
+ if (!\is_string($channel)) {
+ $this->description = 'channels must contain only strings.';
+ return false;
+ }
+ }
+ if (\array_key_exists('queries', $payload)
+ && (!\is_array($payload['queries']) || !\array_is_list($payload['queries']))
+ ) {
+ $this->description = 'queries is not a valid array.';
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/Appwrite/Realtime/Message/Validators/UnsubscribePayload.php b/src/Appwrite/Realtime/Message/Validators/UnsubscribePayload.php
new file mode 100644
index 0000000000..fca065dec8
--- /dev/null
+++ b/src/Appwrite/Realtime/Message/Validators/UnsubscribePayload.php
@@ -0,0 +1,47 @@
+description;
+ }
+
+ public function isArray(): bool
+ {
+ return true;
+ }
+
+ public function getType(): string
+ {
+ return self::TYPE_ARRAY;
+ }
+
+ public function isValid(mixed $value): bool
+ {
+ if (!\is_array($value) || !\array_is_list($value)) {
+ $this->description = 'Payload is not valid.';
+ return false;
+ }
+
+ foreach ($value as $payload) {
+ if (
+ !\is_array($payload)
+ || !\array_key_exists('subscriptionId', $payload)
+ || !\is_string($payload['subscriptionId'])
+ || $payload['subscriptionId'] === ''
+ ) {
+ $this->description = 'Each unsubscribe payload must include a non-empty subscriptionId.';
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php
index 91b090a9f6..0cbd83cf3f 100644
--- a/src/Appwrite/SDK/Specification/Format.php
+++ b/src/Appwrite/SDK/Specification/Format.php
@@ -29,6 +29,7 @@ abstract class Format
'name' => '',
'description' => '',
'endpoint' => 'https://localhost',
+ 'endpoint.docs' => 'https://.cloud.appwrite.io/v1',
'version' => '1.0.0',
'terms' => '',
'support.email' => '',
@@ -40,6 +41,9 @@ abstract class Format
'license.url' => '',
];
+ /**
+ * @var list, parameter: string, excludeKeys?: list, exclude?: bool}>
+ */
private const array OAUTH_PROVIDER_BLACKLIST = [
[
'namespace' => 'account',
@@ -65,8 +69,22 @@ abstract class Format
'mock-unverified'
],
],
+ [
+ 'namespace' => 'project',
+ 'methods' => [
+ 'getOAuth2Provider'
+ ],
+ 'parameter' => 'providerId',
+ 'excludeKeys' => [
+ 'mock',
+ 'mock-unverified'
+ ],
+ ],
];
+ /**
+ * @var list, parameter: string, excludeKeys?: list, exclude?: bool}>
+ */
private const array PROVIDER_USAGE_BLACKLIST = [
[
'namespace' => 'users',
@@ -78,6 +96,9 @@ abstract class Format
],
];
+ /**
+ * @var list, parameter: string, required?: bool, nullable?: bool}>
+ */
private const array REQUEST_PARAMETER_OVERRIDES = [
[
'namespace' => 'project',
@@ -109,24 +130,7 @@ abstract class Format
{
$blacklist = [];
- foreach (self::OAUTH_PROVIDER_BLACKLIST as $config) {
- foreach ($config['methods'] as $method) {
- $entry = [
- 'namespace' => $config['namespace'],
- 'method' => $method,
- 'parameter' => $config['parameter'],
- ];
- if (isset($config['excludeKeys'])) {
- $entry['excludeKeys'] = $config['excludeKeys'];
- }
- if (isset($config['exclude'])) {
- $entry['exclude'] = $config['exclude'];
- }
- $blacklist[] = $entry;
- }
- }
-
- foreach (self::PROVIDER_USAGE_BLACKLIST as $config) {
+ foreach ([...self::OAUTH_PROVIDER_BLACKLIST, ...self::PROVIDER_USAGE_BLACKLIST] as $config) {
foreach ($config['methods'] as $method) {
$entry = [
'namespace' => $config['namespace'],
@@ -263,6 +267,182 @@ abstract class Format
return $contents;
}
+ /**
+ * @param array $models
+ * @return array|null
+ */
+ protected function getDiscriminator(array $models, string $refPrefix): ?array
+ {
+ if (\count($models) < 2) {
+ return null;
+ }
+
+ $candidateKeys = \array_keys($models[0]->conditions);
+
+ foreach (\array_slice($models, 1) as $model) {
+ $candidateKeys = \array_values(\array_intersect($candidateKeys, \array_keys($model->conditions)));
+ }
+
+ if (empty($candidateKeys)) {
+ return null;
+ }
+
+ foreach ($candidateKeys as $key) {
+ $mapping = [];
+ $isValid = true;
+
+ foreach ($models as $model) {
+ $rules = $model->getRules();
+ $condition = $model->conditions[$key] ?? null;
+
+ if (!isset($rules[$key]) || ($rules[$key]['required'] ?? false) !== true) {
+ $isValid = false;
+ break;
+ }
+
+ if (!\is_array($condition)) {
+ if (!\is_scalar($condition)) {
+ $isValid = false;
+ break;
+ }
+
+ $values = [$condition];
+ } else {
+ if ($condition === []) {
+ $isValid = false;
+ break;
+ }
+
+ $values = $condition;
+ $hasInvalidValue = false;
+
+ foreach ($values as $value) {
+ if (!\is_scalar($value)) {
+ $hasInvalidValue = true;
+ break;
+ }
+ }
+
+ if ($hasInvalidValue) {
+ $isValid = false;
+ break;
+ }
+ }
+
+ if (isset($rules[$key]['enum']) && \is_array($rules[$key]['enum'])) {
+ $values = \array_values(\array_filter(
+ $values,
+ fn (mixed $value) => \in_array($value, $rules[$key]['enum'], true)
+ ));
+ }
+
+ if ($values === []) {
+ $isValid = false;
+ break;
+ }
+
+ $ref = $refPrefix . $model->getType();
+
+ foreach ($values as $value) {
+ $mappingKey = \is_bool($value) ? ($value ? 'true' : 'false') : (string) $value;
+
+ if (isset($mapping[$mappingKey]) && $mapping[$mappingKey] !== $ref) {
+ $isValid = false;
+ break;
+ }
+
+ $mapping[$mappingKey] = $ref;
+ }
+
+ if (!$isValid) {
+ break;
+ }
+ }
+
+ if (!$isValid || $mapping === []) {
+ continue;
+ }
+
+ return [
+ 'propertyName' => $key,
+ 'mapping' => $mapping,
+ ];
+ }
+
+ // Single-key failed — try compound discriminator
+ return $this->getCompoundDiscriminator($models, $refPrefix);
+ }
+
+ /**
+ * @param array $models
+ * @return array|null
+ */
+ private function getCompoundDiscriminator(array $models, string $refPrefix): ?array
+ {
+ $allKeys = [];
+ foreach ($models as $model) {
+ foreach (\array_keys($model->conditions) as $key) {
+ if (!\in_array($key, $allKeys, true)) {
+ $allKeys[] = $key;
+ }
+ }
+ }
+
+ if (\count($allKeys) < 2) {
+ return null;
+ }
+
+ $primaryKey = $allKeys[0];
+ $primaryMapping = [];
+ $compoundMapping = [];
+
+ foreach ($models as $model) {
+ $rules = $model->getRules();
+ $conditions = [];
+
+ foreach ($model->conditions as $key => $condition) {
+ if (!isset($rules[$key]) || ($rules[$key]['required'] ?? false) !== true) {
+ return null;
+ }
+
+ if (!\is_scalar($condition)) {
+ return null;
+ }
+
+ $conditions[$key] = \is_bool($condition) ? ($condition ? 'true' : 'false') : (string) $condition;
+ }
+
+ if (empty($conditions)) {
+ return null;
+ }
+
+ $ref = $refPrefix . $model->getType();
+ $compoundMapping[$ref] = $conditions;
+
+ // Best-effort single-key mapping — last model with this value wins (fallback)
+ if (isset($conditions[$primaryKey])) {
+ $primaryMapping[$conditions[$primaryKey]] = $ref;
+ }
+ }
+
+ // Verify compound uniqueness
+ $seen = [];
+ foreach ($compoundMapping as $conditions) {
+ $sig = \json_encode($conditions, JSON_THROW_ON_ERROR);
+ if (isset($seen[$sig])) {
+ return null;
+ }
+ $seen[$sig] = true;
+ }
+
+ return \array_filter([
+ 'propertyName' => $primaryKey,
+ 'mapping' => !empty($primaryMapping) ? $primaryMapping : null,
+ 'x-propertyNames' => $allKeys,
+ 'x-mapping' => $compoundMapping,
+ ]);
+ }
+
protected function getRequestEnumName(string $service, string $method, string $param): ?string
{
/* `$service` is `$namespace` */
@@ -287,6 +467,14 @@ abstract class Format
return 'ConsoleResourceValue';
}
break;
+ case 'getEmailTemplate':
+ switch ($param) {
+ case 'templateId':
+ return 'ProjectEmailTemplateId';
+ case 'locale':
+ return 'ProjectEmailTemplateLocale';
+ }
+ break;
}
break;
case 'account':
@@ -575,19 +763,78 @@ abstract class Format
break;
case 'project':
switch ($method) {
+ case 'updateAuthMethod':
+ switch ($param) {
+ case 'methodId':
+ return 'ProjectAuthMethodId';
+ }
+ break;
+ case 'getPolicy':
+ switch ($param) {
+ case 'policyId':
+ return 'ProjectPolicyId';
+ }
+ break;
+ case 'getOAuth2Provider':
+ switch ($param) {
+ case 'providerId':
+ return 'ProjectOAuthProviderId';
+ }
+ break;
+ case 'getEmailTemplate':
+ case 'updateEmailTemplate':
+ switch ($param) {
+ case 'templateId':
+ return 'ProjectEmailTemplateId';
+ case 'locale':
+ return 'ProjectEmailTemplateLocale';
+ }
+ break;
case 'getUsage':
switch ($param) {
case 'period':
return 'ProjectUsageRange';
}
break;
+ case 'updateProtocol':
+ switch ($param) {
+ case 'protocolId':
+ return 'ProjectProtocolId';
+ }
+ break;
+ case 'updateService':
+ switch ($param) {
+ case 'serviceId':
+ return 'ProjectServiceId';
+ }
+ break;
+ case 'updateSMTP':
+ case 'createSMTPTest':
+ switch ($param) {
+ case 'secure':
+ return 'ProjectSMTPSecure';
+ }
+ break;
+ case 'updateOAuth2Google':
+ switch ($param) {
+ case 'prompt':
+ return 'ProjectOAuth2GooglePrompt';
+ }
+ break;
+ case 'createKey':
+ case 'createEphemeralKey':
+ case 'updateKey':
+ switch ($param) {
+ case 'scopes':
+ return 'ProjectKeyScopes';
+ }
+ break;
}
break;
case 'projects':
switch ($method) {
case 'getEmailTemplate':
case 'updateEmailTemplate':
- case 'deleteEmailTemplate':
switch ($param) {
case 'type':
return 'EmailTemplateType';
@@ -595,16 +842,6 @@ abstract class Format
return 'EmailTemplateLocale';
}
break;
- case 'getSmsTemplate':
- case 'updateSmsTemplate':
- case 'deleteSmsTemplate':
- switch ($param) {
- case 'type':
- return 'SmsTemplateType';
- case 'locale':
- return 'SmsTemplateLocale';
- }
- break;
case 'createPlatform':
switch ($param) {
case 'type':
@@ -687,6 +924,16 @@ abstract class Format
break;
}
break;
+ case 'presences':
+ switch ($method) {
+ case 'getUsage':
+ switch ($param) {
+ case 'range':
+ return 'UsageRange';
+ }
+ break;
+ }
+ break;
}
return null;
}
@@ -786,6 +1033,19 @@ abstract class Format
return $values;
}
+ protected function shouldEmitDefaultForSchema(mixed $default, array $schema): bool
+ {
+ if (isset($schema['enum'])) {
+ return \in_array($default, $schema['enum'], true);
+ }
+
+ if (isset($schema['items']['enum'])) {
+ return \is_array($default) && empty(\array_diff($default, $schema['items']['enum']));
+ }
+
+ return true;
+ }
+
protected function getRequestParameterConfig(string $service, string $method, string $param, bool $optional, bool $nullable, mixed $default): array
{
$config = [
@@ -793,7 +1053,7 @@ abstract class Format
'nullable' => $nullable,
];
- foreach (self::REQUEST_PARAMETER_OVERRIDES as $override) {
+ foreach ($this->getRequestParameterOverrides() as $override) {
if (
$override['namespace'] !== $service
|| !\in_array($method, $override['methods'], true)
@@ -802,8 +1062,12 @@ abstract class Format
continue;
}
- $config['required'] = $override['required'] ?? $config['required'];
- $config['nullable'] = $override['nullable'] ?? $config['nullable'];
+ if (isset($override['required'])) {
+ $config['required'] = $override['required'];
+ }
+ if (isset($override['nullable'])) {
+ $config['nullable'] = $override['nullable'];
+ }
break;
}
@@ -812,8 +1076,20 @@ abstract class Format
return $config;
}
- public function getResponseEnumName(string $model, string $param): ?string
+ /**
+ * @return list, parameter: string, required?: bool, nullable?: bool}>
+ */
+ private function getRequestParameterOverrides(): array
{
+ return self::REQUEST_PARAMETER_OVERRIDES;
+ }
+
+ public function getResponseEnumName(string $model, string $param, ?string $enumSDKName = null): ?string
+ {
+ if ($enumSDKName) {
+ return $enumSDKName;
+ }
+
if ($param === 'type' && \str_starts_with($model, 'platform') && $model !== 'platformList') {
return 'PlatformType';
}
diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php
index b611558826..117fb5e321 100644
--- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php
+++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php
@@ -4,6 +4,7 @@ namespace Appwrite\SDK\Specification\Format;
use Appwrite\Platform\Tasks\Specs;
use Appwrite\SDK\AuthType;
+use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\MethodType;
use Appwrite\SDK\Response;
@@ -54,11 +55,22 @@ class OpenAPI3 extends Format
'servers' => [
[
'url' => $this->getParam('endpoint', ''),
+ 'description' => 'Appwrite Cloud endpoint.',
],
[
- 'url' => $this->getParam('endpoint.docs', ''),
+ 'url' => \str_replace('', '{region}', $this->getParam('endpoint.docs', '')),
+ 'description' => 'Appwrite Cloud regional endpoint. Replace `{region}` with your project region.',
+ 'variables' => [
+ 'region' => [
+ 'default' => 'fra',
+ 'description' => 'Appwrite Cloud region.',
+ ],
+ ],
],
],
+ 'x-appwrite' => [
+ 'endpointDocs' => $this->getParam('endpoint.docs', ''),
+ ],
'paths' => [],
'tags' => $this->services,
'components' => [
@@ -114,16 +126,16 @@ class OpenAPI3 extends Format
*/
$consumes = [$sdk->getRequestType()->value];
- $methodName = $sdk->getMethodName() ?? \uniqid();
+ $methodName = $sdk->getMethodName();
$desc = $sdk->getDescriptionFilePath() ?: $sdk->getDescription();
$produces = ($sdk->getContentType())->value;
- $routeSecurity = $sdk->getAuth() ?? [];
+ $routeSecurity = $sdk->getAuth();
$specs = new Specs();
$sdkPlatforms = $specs->getSDKPlatformsForRouteSecurity($routeSecurity);
- $namespace = $sdk->getNamespace() ?? 'default';
+ $namespace = $sdk->getNamespace();
$descContents = $this->getDescriptionContents($desc);
@@ -185,7 +197,7 @@ class OpenAPI3 extends Format
$additionalMethod = [
'name' => $methodObj->getMethodName(),
'namespace' => $methodObj->getNamespace(),
- 'desc' => $methodObj->getDesc() ?? '',
+ 'desc' => $methodObj->getDesc(),
'auth' => \array_slice($methodSecurities, 0, $this->authCount),
'parameters' => [],
'required' => [],
@@ -291,7 +303,22 @@ class OpenAPI3 extends Format
}
if (!(\is_array($model)) && $model->isNone()) {
- $temp['responses'][(string)$response->getCode() ?? '500'] = [
+ if ($produces === ContentType::TEXT->value && !\in_array($response->getCode(), [204, 301, 302, 308], true)) {
+ $temp['responses'][(string)$response->getCode()] = [
+ 'description' => 'Text',
+ 'content' => [
+ $produces => [
+ 'schema' => [
+ 'type' => 'string',
+ ],
+ ],
+ ],
+ ];
+
+ continue;
+ }
+
+ $temp['responses'][(string)$response->getCode()] = [
'description' => in_array($produces, [
'image/*',
'image/jpeg',
@@ -312,20 +339,21 @@ class OpenAPI3 extends Format
$usedModels[] = $m->getType();
}
- $temp['responses'][(string)$response->getCode() ?? '500'] = [
+ $temp['responses'][(string)$response->getCode()] = [
'description' => $modelDescription,
'content' => [
$produces => [
- 'schema' => [
- 'oneOf' => \array_map(fn ($m) => ['$ref' => '#/components/schemas/' . $m->getType()], $model)
- ],
+ 'schema' => \array_filter([
+ 'oneOf' => \array_map(fn ($m) => ['$ref' => '#/components/schemas/' . $m->getType()], $model),
+ 'discriminator' => $this->getDiscriminator($model, '#/components/schemas/'),
+ ]),
],
],
];
} else {
// Response definition using one type
$usedModels[] = $model->getType();
- $temp['responses'][(string)$response->getCode() ?? '500'] = [
+ $temp['responses'][(string)$response->getCode()] = [
'description' => $model->getName(),
'content' => [
$produces => [
@@ -338,9 +366,9 @@ class OpenAPI3 extends Format
}
}
- if (($response->getCode() ?? 500) === 204) {
- $temp['responses'][(string)$response->getCode() ?? '500']['description'] = 'No content';
- unset($temp['responses'][(string)$response->getCode() ?? '500']['content']);
+ if ($response->getCode() === 204) {
+ $temp['responses'][(string)$response->getCode()]['description'] = 'No content';
+ unset($temp['responses'][(string)$response->getCode()]['content']);
}
}
@@ -384,7 +412,7 @@ class OpenAPI3 extends Format
$isNullable = $validator instanceof Nullable;
$parameter = $this->getRequestParameterConfig(
- $sdk->getNamespace() ?? '',
+ $sdk->getNamespace(),
$methodName,
$name,
$param['optional'],
@@ -403,13 +431,9 @@ class OpenAPI3 extends Format
$validator = $validator->getValidator();
}
- $class = $validator instanceof Validator
- ? \get_class($validator)
- : '';
+ $class = \get_class($validator);
- $base = !empty($class)
- ? \get_parent_class($class)
- : '';
+ $base = \get_parent_class($class);
switch ($base) {
case \Appwrite\Utopia\Database\Validator\Queries\Base::class:
@@ -440,6 +464,15 @@ class OpenAPI3 extends Format
$node['schema']['type'] = $validator->getType();
$node['schema']['x-example'] = ($param['example'] ?? '') ?: '<' . \strtoupper(Template::fromCamelCaseToSnake($node['name'])) . '>';
break;
+ case \Utopia\Database\Validator\BigInt::class:
+ // BigInt validator reports Database::VAR_BIGINT, but OpenAPI expects scalar types.
+ // We expose it as int64 to keep schema consistent with Column/Attribute models.
+ $node['schema']['type'] = 'integer';
+ $node['schema']['format'] = 'int64';
+ if (!empty($param['example'])) {
+ $node['schema']['x-example'] = $param['example'];
+ }
+ break;
case \Utopia\Validator\Boolean::class:
$node['schema']['type'] = $validator->getType();
$node['schema']['x-example'] = ($param['example'] ?? '') ?: false;
@@ -468,6 +501,7 @@ class OpenAPI3 extends Format
Database::VAR_POINT => '[1, 2]',
Database::VAR_LINESTRING => '[[1, 2], [3, 4], [5, 6]]',
Database::VAR_POLYGON => '[[[1, 2], [3, 4], [5, 6], [1, 2]]]',
+ default => '',
};
break;
case \Utopia\Emails\Validator\Email::class:
@@ -517,6 +551,7 @@ class OpenAPI3 extends Format
case \Appwrite\Utopia\Database\Validator\Queries\Identities::class:
case \Appwrite\Utopia\Database\Validator\Queries\Indexes::class:
case \Appwrite\Utopia\Database\Validator\Queries\Installations::class:
+ case \Appwrite\Utopia\Database\Validator\Queries\Branches::class:
case \Appwrite\Utopia\Database\Validator\Queries\Memberships::class:
case \Appwrite\Utopia\Database\Validator\Queries\Messages::class:
case \Appwrite\Utopia\Database\Validator\Queries\Migrations::class:
@@ -618,7 +653,7 @@ class OpenAPI3 extends Format
}
if ($allowed && $validator->getType() === 'string') {
$allValues = \array_values($validator->getList());
- $allKeys = $this->getRequestEnumKeys($sdk->getNamespace() ?? '', $methodName, $name);
+ $allKeys = $this->getRequestEnumKeys($sdk->getNamespace(), $methodName, $name);
if ($excludeKeys !== null) {
$keepIndices = [];
@@ -634,7 +669,7 @@ class OpenAPI3 extends Format
$enumValues = $allValues;
}
$node['schema']['items']['enum'] = $enumValues;
- $node['schema']['items']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace() ?? '', $methodName, $name);
+ $node['schema']['items']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace(), $methodName, $name);
$node['schema']['items']['x-enum-keys'] = $enumKeys;
if (!empty($excludeKeys)) {
@@ -642,7 +677,7 @@ class OpenAPI3 extends Format
}
}
if ($validator->getType() === 'integer') {
- $node['schema']['items']['format'] = $validator->getFormat() ?? 'int32';
+ $node['schema']['items']['format'] = $validator->getFormat();
}
} else {
$node['schema']['type'] = $validator->getType();
@@ -672,7 +707,7 @@ class OpenAPI3 extends Format
}
if ($allowed && $validator->getType() === 'string') {
$allValues = \array_values($validator->getList());
- $allKeys = $this->getRequestEnumKeys($sdk->getNamespace() ?? '', $methodName, $name);
+ $allKeys = $this->getRequestEnumKeys($sdk->getNamespace(), $methodName, $name);
if ($excludeKeys !== null) {
$keepIndices = [];
@@ -688,7 +723,7 @@ class OpenAPI3 extends Format
$enumValues = $allValues;
}
$node['schema']['enum'] = $enumValues;
- $node['schema']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace() ?? '', $methodName, $name);
+ $node['schema']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace(), $methodName, $name);
$node['schema']['x-enum-keys'] = $enumKeys;
if (!empty($excludeKeys)) {
@@ -696,7 +731,7 @@ class OpenAPI3 extends Format
}
}
if ($validator->getType() === 'integer') {
- $node['schema']['format'] = $validator->getFormat() ?? 'int32';
+ $node['schema']['format'] = $validator->getFormat();
}
}
break;
@@ -744,11 +779,22 @@ class OpenAPI3 extends Format
break;
}
- if ($parameter['emitDefault']) { // Param has default value
+ if ($parameter['emitDefault'] && $this->shouldEmitDefaultForSchema($param['default'], $node['schema'])) { // Param has default value
$node['schema']['default'] = $param['default'];
}
- if (false !== \strpos($url, ':' . $name)) { // Param is in URL path
+ $pathAliases = [$name, ...($param['aliases'] ?? [])];
+ $pathAliasMap = \array_flip($pathAliases);
+ $isPathParam = false;
+
+ foreach (\explode('/', $url) as $segment) {
+ if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) {
+ $isPathParam = true;
+ break;
+ }
+ }
+
+ if ($isPathParam) { // Param is in URL path (directly or through alias)
$node['in'] = 'path';
$temp['parameters'][] = $node;
} elseif ($route->getMethod() == 'GET') { // Param is in query
@@ -773,31 +819,30 @@ class OpenAPI3 extends Format
/// If the enum flag is Set, add the enum values to the body
$body['content'][$consumes[0]]['schema']['properties'][$name]['enum'] = $node['schema']['enum'];
$body['content'][$consumes[0]]['schema']['properties'][$name]['x-enum-name'] = $node['schema']['x-enum-name'] ?? null;
- $body['content'][$consumes[0]]['schema']['properties'][$name]['x-enum-keys'] = $node['schema']['x-enum-keys'] ?? null;
+ $body['content'][$consumes[0]]['schema']['properties'][$name]['x-enum-keys'] = $node['schema']['x-enum-keys'];
}
if ($node['schema']['x-upload-id'] ?? false) {
$body['content'][$consumes[0]]['schema']['properties'][$name]['x-upload-id'] = $node['schema']['x-upload-id'];
}
- if (isset($node['default'])) {
- $body['content'][$consumes[0]]['schema']['properties'][$name]['default'] = $node['default'];
- }
-
if (\array_key_exists('items', $node['schema'])) {
$body['content'][$consumes[0]]['schema']['properties'][$name]['items'] = $node['schema']['items'];
}
- if ($node['x-global'] ?? false) {
- $body['content'][$consumes[0]]['schema']['properties'][$name]['x-global'] = true;
- }
-
if ($parameter['nullable']) {
$body['content'][$consumes[0]]['schema']['properties'][$name]['x-nullable'] = true;
}
}
- $url = \str_replace(':' . $name, '{' . $name . '}', $url);
+ $segments = \explode('/', $url);
+ foreach ($segments as &$segment) {
+ if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) {
+ $segment = '{' . $name . '}';
+ }
+ }
+ unset($segment);
+ $url = \implode('/', $segments);
}
if (!empty($bodyRequired)) {
@@ -835,6 +880,13 @@ class OpenAPI3 extends Format
if ($model->isAny()) {
$output['components']['schemas'][$model->getType()]['additionalProperties'] = true;
+
+ $additionalKey = \method_exists($model, 'getAdditionalPropertiesKey')
+ ? $model->getAdditionalPropertiesKey()
+ : null;
+ if ($additionalKey !== null) {
+ $output['components']['schemas'][$model->getType()]['x-additional-properties-key'] = $additionalKey;
+ }
}
if (!empty($required)) {
@@ -900,18 +952,30 @@ class OpenAPI3 extends Format
$rule['type'] = ($rule['type']) ? $rule['type'] : 'none';
if (\is_array($rule['type'])) {
+ $resolvedModels = \array_map(function (string $type) {
+ foreach ($this->models as $model) {
+ if ($model->getType() === $type) {
+ return $model;
+ }
+ }
+
+ throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered.");
+ }, $rule['type']);
+
if ($rule['array']) {
- $items = [
+ $items = \array_filter([
'anyOf' => \array_map(function ($type) {
return ['$ref' => '#/components/schemas/' . $type];
- }, $rule['type'])
- ];
+ }, $rule['type']),
+ 'discriminator' => $this->getDiscriminator($resolvedModels, '#/components/schemas/'),
+ ]);
} else {
- $items = [
+ $items = \array_filter([
'oneOf' => \array_map(function ($type) {
return ['$ref' => '#/components/schemas/' . $type];
- }, $rule['type'])
- ];
+ }, $rule['type']),
+ 'discriminator' => $this->getDiscriminator($resolvedModels, '#/components/schemas/'),
+ ]);
}
} else {
$items = [
@@ -958,13 +1022,13 @@ class OpenAPI3 extends Format
if ($rule['type'] === 'enum' && !empty($rule['enum'])) {
if ($rule['array']) {
$output['components']['schemas'][$model->getType()]['properties'][$name]['items']['enum'] = \array_values($rule['enum']);
- $enumName = $this->getResponseEnumName($model->getType(), $name);
+ $enumName = $this->getResponseEnumName($model->getType(), $name, $rule['enumSDKName'] ?? null);
if ($enumName) {
$output['components']['schemas'][$model->getType()]['properties'][$name]['items']['x-enum-name'] = $enumName;
}
} else {
$output['components']['schemas'][$model->getType()]['properties'][$name]['enum'] = \array_values($rule['enum']);
- $enumName = $this->getResponseEnumName($model->getType(), $name);
+ $enumName = $this->getResponseEnumName($model->getType(), $name, $rule['enumSDKName'] ?? null);
if ($enumName) {
$output['components']['schemas'][$model->getType()]['properties'][$name]['x-enum-name'] = $enumName;
}
diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php
index 413239f000..f0cd52bf99 100644
--- a/src/Appwrite/SDK/Specification/Format/Swagger2.php
+++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php
@@ -4,6 +4,7 @@ namespace Appwrite\SDK\Specification\Format;
use Appwrite\Platform\Tasks\Specs;
use Appwrite\SDK\AuthType;
+use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\MethodType;
use Appwrite\SDK\Response;
@@ -54,6 +55,9 @@ class Swagger2 extends Format
],
'host' => \parse_url($this->getParam('endpoint', ''), PHP_URL_HOST),
'x-host-docs' => \parse_url($this->getParam('endpoint.docs', ''), PHP_URL_HOST),
+ 'x-appwrite' => [
+ 'endpointDocs' => $this->getParam('endpoint.docs', ''),
+ ],
'basePath' => \parse_url($this->getParam('endpoint', ''), PHP_URL_PATH),
'schemes' => [\parse_url($this->getParam('endpoint', ''), PHP_URL_SCHEME)],
'consumes' => ['application/json', 'multipart/form-data'],
@@ -114,17 +118,17 @@ class Swagger2 extends Format
$consumes = [$sdk->getRequestType()->value];
}
- $methodName = $sdk->getMethodName() ?? \uniqid();
+ $methodName = $sdk->getMethodName();
$desc = $sdk->getDescriptionFilePath() ?: $sdk->getDescription();
$produces = ($sdk->getContentType())->value;
- $routeSecurity = $sdk->getAuth() ?? [];
+ $routeSecurity = $sdk->getAuth();
$specs = new Specs();
$sdkPlatforms = $specs->getSDKPlatformsForRouteSecurity($routeSecurity);
$sdkPlatforms = array_values(array_unique($sdkPlatforms));
- $namespace = $sdk->getNamespace() ?? 'default';
+ $namespace = $sdk->getNamespace();
$descContents = $this->getDescriptionContents($desc);
@@ -193,7 +197,7 @@ class Swagger2 extends Format
$additionalMethod = [
'name' => $methodObj->getMethodName(),
'namespace' => $methodObj->getNamespace(),
- 'desc' => $methodObj->getDesc() ?? '',
+ 'desc' => $methodObj->getDesc(),
'auth' => \array_slice($methodSecurities, 0, $this->authCount),
'parameters' => [],
'required' => [],
@@ -298,7 +302,18 @@ class Swagger2 extends Format
}
if (!(\is_array($model)) && $model->isNone()) {
- $temp['responses'][(string)$response->getCode() ?? '500'] = [
+ if ($produces === ContentType::TEXT->value && !\in_array($response->getCode(), [204, 301, 302, 308], true)) {
+ $temp['responses'][(string)$response->getCode()] = [
+ 'description' => 'Text',
+ 'schema' => [
+ 'type' => 'string',
+ ],
+ ];
+
+ continue;
+ }
+
+ $temp['responses'][(string)$response->getCode()] = [
'description' => in_array($produces, [
'image/*',
'image/jpeg',
@@ -320,18 +335,19 @@ class Swagger2 extends Format
foreach ($model as $m) {
$usedModels[] = $m->getType();
}
- $temp['responses'][(string)$response->getCode() ?? '500'] = [
+ $temp['responses'][(string)$response->getCode()] = [
'description' => $modelDescription,
- 'schema' => [
+ 'schema' => \array_filter([
'x-oneOf' => \array_map(function ($m) {
return ['$ref' => '#/definitions/' . $m->getType()];
- }, $model)
- ],
+ }, $model),
+ 'x-discriminator' => $this->getDiscriminator($model, '#/definitions/'),
+ ]),
];
} else {
// Response definition using one type
$usedModels[] = $model->getType();
- $temp['responses'][(string)$response->getCode() ?? '500'] = [
+ $temp['responses'][(string)$response->getCode()] = [
'description' => $model->getName(),
'schema' => [
'$ref' => '#/definitions/' . $model->getType(),
@@ -340,9 +356,9 @@ class Swagger2 extends Format
}
}
- if (in_array($response->getCode() ?? 500, [204, 301, 302, 308], true)) {
- $temp['responses'][(string)$response->getCode() ?? '500']['description'] = 'No content';
- unset($temp['responses'][(string)$response->getCode() ?? '500']['schema']);
+ if (in_array($response->getCode(), [204, 301, 302, 308], true)) {
+ $temp['responses'][(string)$response->getCode()]['description'] = 'No content';
+ unset($temp['responses'][(string)$response->getCode()]['schema']);
}
}
@@ -386,7 +402,7 @@ class Swagger2 extends Format
$isNullable = $validator instanceof Nullable;
$parameter = $this->getRequestParameterConfig(
- $sdk->getNamespace() ?? '',
+ $sdk->getNamespace(),
$methodName,
$name,
$param['optional'],
@@ -405,13 +421,9 @@ class Swagger2 extends Format
$validator = $validator->getValidator();
}
- $class = $validator instanceof Validator
- ? \get_class($validator)
- : '';
+ $class = \get_class($validator);
- $base = !empty($class)
- ? \get_parent_class($class)
- : '';
+ $base = \get_parent_class($class);
switch ($base) {
case \Appwrite\Utopia\Database\Validator\Queries\Base::class:
@@ -470,6 +482,7 @@ class Swagger2 extends Format
Database::VAR_POINT => '[1, 2]',
Database::VAR_LINESTRING => '[[1, 2], [3, 4], [5, 6]]',
Database::VAR_POLYGON => '[[[1, 2], [3, 4], [5, 6], [1, 2]]]',
+ default => '',
};
break;
case \Utopia\Emails\Validator\Email::class:
@@ -513,6 +526,7 @@ class Swagger2 extends Format
case \Utopia\Database\Validator\Queries::class:
case \Utopia\Database\Validator\Queries\Document::class:
case \Utopia\Database\Validator\Queries\Documents::class:
+ case \Appwrite\Utopia\Database\Validator\Queries\Branches::class:
case \Appwrite\Utopia\Database\Validator\Queries\Columns::class:
case \Appwrite\Utopia\Database\Validator\Queries\Tables::class:
$node['type'] = 'array';
@@ -623,7 +637,7 @@ class Swagger2 extends Format
}
}
if ($validator->getType() === 'integer') {
- $node['items']['format'] = $validator->getFormat() ?? 'int32';
+ $node['items']['format'] = $validator->getFormat();
}
} else {
$node['type'] = $validator->getType();
@@ -671,7 +685,7 @@ class Swagger2 extends Format
}
}
if ($validator->getType() === 'integer') {
- $node['format'] = $validator->getFormat() ?? 'int32';
+ $node['format'] = $validator->getFormat();
}
}
break;
@@ -720,11 +734,22 @@ class Swagger2 extends Format
break;
}
- if ($parameter['emitDefault']) { // Param has default value
+ if ($parameter['emitDefault'] && $this->shouldEmitDefaultForSchema($param['default'], $node)) { // Param has default value
$node['default'] = $param['default'];
}
- if (\str_contains($url, ':' . $name)) { // Param is in URL path
+ $pathAliases = [$name, ...($param['aliases'] ?? [])];
+ $pathAliasMap = \array_flip($pathAliases);
+ $isPathParam = false;
+
+ foreach (\explode('/', $url) as $segment) {
+ if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) {
+ $isPathParam = true;
+ break;
+ }
+ }
+
+ if ($isPathParam) { // Param is in URL path (directly or through alias)
$node['in'] = 'path';
$temp['parameters'][] = $node;
} elseif ($route->getMethod() == 'GET') { // Param is in query
@@ -745,10 +770,13 @@ class Swagger2 extends Format
$body['schema']['properties'][$name] = [
'type' => $node['type'],
'description' => $node['description'],
- 'default' => $node['default'] ?? null,
'x-example' => $node['x-example'] ?? null,
];
+ if (\array_key_exists('default', $node)) {
+ $body['schema']['properties'][$name]['default'] = $node['default'];
+ }
+
if (isset($node['format'])) {
$body['schema']['properties'][$name]['format'] = $node['format'];
}
@@ -757,11 +785,7 @@ class Swagger2 extends Format
/// If the enum flag is Set, add the enum values to the body
$body['schema']['properties'][$name]['enum'] = $node['enum'];
$body['schema']['properties'][$name]['x-enum-name'] = $node['x-enum-name'] ?? null;
- $body['schema']['properties'][$name]['x-enum-keys'] = $node['x-enum-keys'] ?? null;
- }
-
- if ($node['x-global'] ?? false) {
- $body['schema']['properties'][$name]['x-global'] = true;
+ $body['schema']['properties'][$name]['x-enum-keys'] = $node['x-enum-keys'];
}
if ($parameter['nullable']) {
@@ -773,7 +797,14 @@ class Swagger2 extends Format
}
}
- $url = \str_replace(':' . $name, '{' . $name . '}', $url);
+ $segments = \explode('/', $url);
+ foreach ($segments as &$segment) {
+ if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) {
+ $segment = '{' . $name . '}';
+ }
+ }
+ unset($segment);
+ $url = \implode('/', $segments);
}
if (!empty($bodyRequired)) {
@@ -813,6 +844,13 @@ class Swagger2 extends Format
if ($model->isAny()) {
$output['definitions'][$model->getType()]['additionalProperties'] = true;
+
+ $additionalKey = \method_exists($model, 'getAdditionalPropertiesKey')
+ ? $model->getAdditionalPropertiesKey()
+ : null;
+ if ($additionalKey !== null) {
+ $output['definitions'][$model->getType()]['x-additional-properties-key'] = $additionalKey;
+ }
}
if (!empty($required)) {
@@ -880,14 +918,27 @@ class Swagger2 extends Format
$rule['type'] = ($rule['type']) ?: 'none';
if (\is_array($rule['type'])) {
+ $resolvedModels = \array_map(function (string $type) {
+ foreach ($this->models as $model) {
+ if ($model->getType() === $type) {
+ return $model;
+ }
+ }
+
+ throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered.");
+ }, $rule['type']);
+ $xDiscriminator = $this->getDiscriminator($resolvedModels, '#/definitions/');
+
if ($rule['array']) {
- $items = [
- 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type'])
- ];
+ $items = \array_filter([
+ 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']),
+ 'x-discriminator' => $xDiscriminator,
+ ]);
} else {
- $items = [
- 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type'])
- ];
+ $items = \array_filter([
+ 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']),
+ 'x-discriminator' => $xDiscriminator,
+ ]);
}
} else {
$items = [
@@ -949,13 +1000,13 @@ class Swagger2 extends Format
if ($rule['type'] === 'enum' && !empty($rule['enum'])) {
if ($rule['array']) {
$output['definitions'][$model->getType()]['properties'][$name]['items']['enum'] = \array_values($rule['enum']);
- $enumName = $this->getResponseEnumName($model->getType(), $name);
+ $enumName = $this->getResponseEnumName($model->getType(), $name, $rule['enumSDKName'] ?? null);
if ($enumName) {
$output['definitions'][$model->getType()]['properties'][$name]['items']['x-enum-name'] = $enumName;
}
} else {
$output['definitions'][$model->getType()]['properties'][$name]['enum'] = \array_values($rule['enum']);
- $enumName = $this->getResponseEnumName($model->getType(), $name);
+ $enumName = $this->getResponseEnumName($model->getType(), $name, $rule['enumSDKName'] ?? null);
if ($enumName) {
$output['definitions'][$model->getType()]['properties'][$name]['x-enum-name'] = $enumName;
}
diff --git a/src/Appwrite/Utopia/Database/Validator/Attributes.php b/src/Appwrite/Utopia/Database/Validator/Attributes.php
index f8bdd01103..54aaf135f9 100644
--- a/src/Appwrite/Utopia/Database/Validator/Attributes.php
+++ b/src/Appwrite/Utopia/Database/Validator/Attributes.php
@@ -23,6 +23,7 @@ class Attributes extends Validator
protected array $supportedTypes = [
Database::VAR_STRING,
Database::VAR_INTEGER,
+ Database::VAR_BIGINT,
Database::VAR_FLOAT,
Database::VAR_BOOLEAN,
Database::VAR_DATETIME,
@@ -181,28 +182,28 @@ class Attributes extends Validator
return false;
}
- // Validate signed only for integer/float types
- if (isset($attribute['signed']) && !in_array($attribute['type'], [Database::VAR_INTEGER, Database::VAR_FLOAT])) {
- $this->message = "Attribute '" . $attribute['key'] . "': 'signed' can only be used with integer or float types";
+ // Validate signed only for integer/bigint/float types
+ if (isset($attribute['signed']) && !in_array($attribute['type'], [Database::VAR_INTEGER, Database::VAR_BIGINT, Database::VAR_FLOAT])) {
+ $this->message = "Attribute '" . $attribute['key'] . "': 'signed' can only be used with integer, bigint or float types";
return false;
}
// Validate required and default conflict
- if (isset($attribute['required']) && $attribute['required'] === true && isset($attribute['default']) && $attribute['default'] !== null) {
+ if (isset($attribute['required']) && $attribute['required'] === true && isset($attribute['default'])) {
$this->message = "Attribute '" . $attribute['key'] . "' cannot have a default value when required is true";
return false;
}
// Validate array and default conflict
- if (isset($attribute['array']) && $attribute['array'] === true && isset($attribute['default']) && $attribute['default'] !== null) {
+ if (isset($attribute['array']) && $attribute['array'] === true && isset($attribute['default'])) {
$this->message = "Attribute '" . $attribute['key'] . "' cannot have a default value when array is true";
return false;
}
- // Validate min/max range for integer/float
+ // Validate min/max range for integer/bigint/float
if (isset($attribute['min']) || isset($attribute['max'])) {
- if (!in_array($attribute['type'], [Database::VAR_INTEGER, Database::VAR_FLOAT])) {
- $this->message = "Attribute '" . $attribute['key'] . "': min/max can only be used with integer or float types";
+ if (!in_array($attribute['type'], [Database::VAR_INTEGER, Database::VAR_BIGINT, Database::VAR_FLOAT])) {
+ $this->message = "Attribute '" . $attribute['key'] . "': min/max can only be used with integer, bigint or float types";
return false;
}
@@ -264,7 +265,7 @@ class Attributes extends Validator
if (isset($attribute['min']) || isset($attribute['max'])) {
$min = $attribute['min'] ?? \PHP_INT_MIN;
$max = $attribute['max'] ?? \PHP_INT_MAX;
- $rangeValidator = new Range($min, $max, Database::VAR_INTEGER);
+ $rangeValidator = new Range($min, $max, Range::TYPE_INTEGER);
if (!$rangeValidator->isValid($attribute['default'])) {
$this->message = "Default value for integer attribute '" . $attribute['key'] . "' must be between $min and $max";
return false;
@@ -272,6 +273,23 @@ class Attributes extends Validator
}
break;
+ case Database::VAR_BIGINT:
+ if (!is_int($attribute['default'])) {
+ $this->message = "Default value for bigint attribute '" . $attribute['key'] . "' must be an integer";
+ return false;
+ }
+ // Validate within range if min/max specified
+ if (isset($attribute['min']) || isset($attribute['max'])) {
+ $min = $attribute['min'] ?? \PHP_INT_MIN;
+ $max = $attribute['max'] ?? \PHP_INT_MAX;
+ $rangeValidator = new Range($min, $max, Range::TYPE_INTEGER);
+ if (!$rangeValidator->isValid($attribute['default'])) {
+ $this->message = "Default value for bigint attribute '" . $attribute['key'] . "' must be between $min and $max";
+ return false;
+ }
+ }
+ break;
+
case Database::VAR_FLOAT:
if (!is_float($attribute['default']) && !is_int($attribute['default'])) {
$this->message = "Default value for float attribute '" . $attribute['key'] . "' must be a number";
@@ -281,7 +299,7 @@ class Attributes extends Validator
if (isset($attribute['min']) || isset($attribute['max'])) {
$min = $attribute['min'] ?? -\PHP_FLOAT_MAX;
$max = $attribute['max'] ?? \PHP_FLOAT_MAX;
- $rangeValidator = new Range($min, $max, Database::VAR_FLOAT);
+ $rangeValidator = new Range($min, $max, Range::TYPE_FLOAT);
if (!$rangeValidator->isValid((float)$attribute['default'])) {
$this->message = "Default value for float attribute '" . $attribute['key'] . "' must be between $min and $max";
return false;
@@ -331,7 +349,7 @@ class Attributes extends Validator
}
// Validate default exists in elements
- if (isset($attribute['default']) && $attribute['default'] !== null) {
+ if (isset($attribute['default'])) {
if (!in_array($attribute['default'], $attribute['elements'], true)) {
$this->message = "Default value for enum attribute '" . $attribute['key'] . "' must be one of the provided elements";
return false;
diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Branches.php b/src/Appwrite/Utopia/Database/Validator/Queries/Branches.php
new file mode 100644
index 0000000000..82ca911747
--- /dev/null
+++ b/src/Appwrite/Utopia/Database/Validator/Queries/Branches.php
@@ -0,0 +1,20 @@
+ $dbName) {
- $queryString = \str_replace('"' . $alias . '"', '"' . $dbName . '"', $queryString);
- }
- }
- unset($queryString);
+ return parent::isValid($this->normalizeAliases($value));
+ }
+
+ private function normalizeAliases(mixed $value): mixed
+ {
+ if (!\is_array($value)) {
+ return $value;
}
- return parent::isValid($value);
+ foreach ($value as &$queryString) {
+ if (!\is_string($queryString)) {
+ continue;
+ }
+ foreach (self::ATTRIBUTE_ALIASES as $alias => $dbName) {
+ $queryString = \str_replace('"' . $alias . '"', '"' . $dbName . '"', $queryString);
+ }
+ }
+ unset($queryString);
+
+ return $value;
}
}
diff --git a/src/Appwrite/Utopia/Database/Validator/Query/BranchCursor.php b/src/Appwrite/Utopia/Database/Validator/Query/BranchCursor.php
new file mode 100644
index 0000000000..86ad324200
--- /dev/null
+++ b/src/Appwrite/Utopia/Database/Validator/Query/BranchCursor.php
@@ -0,0 +1,39 @@
+getMethod();
+
+ if (!\in_array($method, [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE], true)) {
+ $this->message = 'Invalid query method: ' . $method;
+ return false;
+ }
+
+ $cursor = $value->getValue();
+
+ $validator = new Text(256);
+ if (!$validator->isValid($cursor)) {
+ $this->message = 'Invalid cursor: ' . $validator->getDescription();
+ return false;
+ }
+
+ return true;
+ }
+
+ public function getMethodType(): string
+ {
+ return self::METHOD_TYPE_CURSOR;
+ }
+}
diff --git a/src/Appwrite/Utopia/Fetch/BodyMultipart.php b/src/Appwrite/Utopia/Fetch/BodyMultipart.php
index ee482a7d9e..90732eb7a1 100644
--- a/src/Appwrite/Utopia/Fetch/BodyMultipart.php
+++ b/src/Appwrite/Utopia/Fetch/BodyMultipart.php
@@ -64,7 +64,7 @@ class BodyMultipart
$partHeaderArray = \explode(':', $partHeader, 2);
- $partHeaderName = \strtolower($partHeaderArray[0] ?? '');
+ $partHeaderName = \strtolower($partHeaderArray[0]);
$partHeaderValue = $partHeaderArray[1] ?? '';
if ($partHeaderName == "content-disposition") {
$dispositionChunks = \explode("; ", $partHeaderValue);
@@ -92,7 +92,7 @@ class BodyMultipart
*/
public function getParts(): array
{
- return $this->parts ?? [];
+ return $this->parts;
}
public function getPart(string $key, mixed $default = ''): mixed
diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php
index 3f1ea794ab..24803eeaa7 100644
--- a/src/Appwrite/Utopia/Request.php
+++ b/src/Appwrite/Utopia/Request.php
@@ -18,6 +18,7 @@ class Request extends UtopiaRequest
*/
private array $filters = [];
private ?Route $route = null;
+ private ?array $filteredParams = null;
public function __construct(SwooleRequest $request)
{
@@ -32,6 +33,10 @@ class Request extends UtopiaRequest
*/
public function getParams(): array
{
+ if ($this->filteredParams !== null) {
+ return $this->filteredParams;
+ }
+
$parameters = parent::getParams();
if (!$this->hasFilters() || !$this->hasRoute()) {
@@ -46,39 +51,52 @@ class Request extends UtopiaRequest
if (!\is_array($methods)) {
$id = $methods->getNamespace() . '.' . $methods->getMethodName();
+ } else {
+ $matched = null;
+ foreach ($methods as $method) {
+ /** @var Method|null $method */
+ if ($method === null) {
+ continue;
+ }
+
+ // Find the method that matches the parameters passed
+ $methodParamNames = \array_map(fn ($param) => $param->getName(), $method->getParameters());
+ $invalidParams = \array_diff(\array_keys($parameters), $methodParamNames);
+
+ // No params defined, or all params are valid
+ if (empty($methodParamNames) || empty($invalidParams)) {
+ $matched = $method;
+ break;
+ }
+ }
+
+ $id = $matched !== null
+ ? $matched->getNamespace() . '.' . $matched->getMethodName()
+ : 'unknown.unknown';
+ }
+
+ try {
foreach ($this->getFilters() as $filter) {
$parameters = $filter->parse($parameters, $id);
}
- return $parameters;
- }
-
- $matched = null;
- foreach ($methods as $method) {
- /** @var Method|null $method */
- if ($method === null) {
- continue;
- }
-
- // Find the method that matches the parameters passed
- $methodParamNames = \array_map(fn ($param) => $param->getName(), $method->getParameters());
- $invalidParams = \array_diff(\array_keys($parameters), $methodParamNames);
-
- // No params defined, or all params are valid
- if (empty($methodParamNames) || empty($invalidParams)) {
- $matched = $method;
- break;
+ } catch (\Throwable $e) {
+ /*
+ * 4xx filter throws are user-input errors that the action layer
+ * revalidates and reports. Cache the raw, pre-filter parameters
+ * so a subsequent getParams() — e.g. when the framework builds
+ * arguments for an error hook — returns without re-running
+ * filters. Otherwise the second throw gets wrapped as
+ * "Error handler had an error: ..." (HTTP 500), masking the
+ * intended 400.
+ */
+ $code = $e->getCode();
+ if (\is_int($code) && $code >= 400 && $code < 500) {
+ $this->filteredParams = $parameters;
}
+ throw $e;
}
- $id = $matched !== null
- ? $matched->getNamespace() . '.' . $matched->getMethodName()
- : 'unknown.unknown';
-
- // Apply filters
- foreach ($this->getFilters() as $filter) {
- $parameters = $filter->parse($parameters, $id);
- }
-
+ $this->filteredParams = $parameters;
return $parameters;
}
@@ -92,6 +110,7 @@ class Request extends UtopiaRequest
public function addFilter(Filter $filter): void
{
$this->filters[] = $filter;
+ $this->filteredParams = null;
}
/**
@@ -112,6 +131,7 @@ class Request extends UtopiaRequest
public function resetFilters(): void
{
$this->filters = [];
+ $this->filteredParams = null;
}
/**
@@ -134,6 +154,7 @@ class Request extends UtopiaRequest
public function setRoute(?Route $route): void
{
$this->route = $route;
+ $this->filteredParams = null;
}
/**
@@ -199,7 +220,11 @@ class Request extends UtopiaRequest
public function getHeader(string $key, string $default = ''): string
{
$headers = $this->getHeaders();
- return $headers[$key] ?? $default;
+ $value = $headers[$key] ?? $default;
+ if (\is_array($value)) {
+ $value = $value[0] ?? $default;
+ }
+ return \is_string($value) ? $value : $default;
}
/**
@@ -238,6 +263,9 @@ class Request extends UtopiaRequest
if ($allowedParams !== null) {
$params = array_intersect_key($params, array_flip($allowedParams));
}
+ if (!isset($params['project'])) {
+ $params['project'] = $this->getHeader('x-appwrite-project', '');
+ }
ksort($params);
return md5($this->getURI() . '*' . serialize($params) . '*' . APP_CACHE_BUSTER);
}
diff --git a/src/Appwrite/Utopia/Request/Filter.php b/src/Appwrite/Utopia/Request/Filter.php
index 4bd9b394a0..638d6f993a 100644
--- a/src/Appwrite/Utopia/Request/Filter.php
+++ b/src/Appwrite/Utopia/Request/Filter.php
@@ -45,12 +45,6 @@ abstract class Filter
*/
public function getParamValue(string $key, mixed $default = ''): mixed
{
- try {
- $value = $this->params[$key] ?? $default;
- } catch (\Exception $e) {
- $value = $default;
- }
-
- return $value;
+ return $this->params[$key] ?? $default;
}
}
diff --git a/src/Appwrite/Utopia/Request/Filters/V19.php b/src/Appwrite/Utopia/Request/Filters/V19.php
index e7789ac0f7..4f2be12367 100644
--- a/src/Appwrite/Utopia/Request/Filters/V19.php
+++ b/src/Appwrite/Utopia/Request/Filters/V19.php
@@ -35,6 +35,13 @@ class V19 extends Filter
case 'functions.updateVariable':
$content['secret'] = false;
break;
+ case 'functions.getDeploymentDownload':
+ // Pre-1.7.0 clients call the legacy alias
+ // `/v1/functions/:functionId/deployments/:deploymentId/build/download`,
+ // which always downloaded the build output. The merged 1.7.0 endpoint
+ // requires an explicit `type` param, so force it to `output` here.
+ $content['type'] = 'output';
+ break;
}
return $content;
}
diff --git a/src/Appwrite/Utopia/Request/Filters/V20.php b/src/Appwrite/Utopia/Request/Filters/V20.php
index e3d5fe2f79..6b1da2709a 100644
--- a/src/Appwrite/Utopia/Request/Filters/V20.php
+++ b/src/Appwrite/Utopia/Request/Filters/V20.php
@@ -10,6 +10,18 @@ use Utopia\Database\Query;
class V20 extends Filter
{
+ /**
+ * Per-instance (request-scoped) memo of the `attributes` array for a given
+ * `(databaseNamespace, collectionId)`. Avoids re-fetching the same collection
+ * document when multiple relationships in the same schema point at it, and
+ * when `parse()` is re-entered before `Request::getParams()` memoization warms.
+ *
+ * A `null` value means we already tried and the collection was missing or errored.
+ *
+ * @var array>|null>
+ */
+ private array $collectionAttributesCache = [];
+
// Convert 1.7 params to 1.8
public function parse(array $content, string $model): array
{
@@ -58,7 +70,7 @@ class V20 extends Filter
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
- $selections = Query::groupByType($parsed)['selections'] ?? [];
+ $selections = Query::groupByType($parsed)['selections'];
// Check if we need to add wildcard + relationships
// This happens when:
@@ -106,36 +118,21 @@ class V20 extends Filter
* Recursively includes nested relationships up to 3 levels deep.
* Prevents infinite loops by tracking all visited collections in the current path.
*/
- private function getRelatedCollectionKeys(
- ?string $databaseId = null,
- ?string $collectionId = null,
- ?string $prefix = null,
- int $depth = 1,
- array $visited = []
- ): array {
- $databaseId ??= $this->getParamValue('databaseId');
- $collectionId ??= $this->getParamValue('collectionId');
+ private function getRelatedCollectionKeys(): array
+ {
+ $databaseId = $this->getParamValue('databaseId');
+ $collectionId = $this->getParamValue('collectionId');
- if (
- empty($databaseId) ||
- empty($collectionId) ||
- $depth > Database::RELATION_MAX_DEPTH
- ) {
+ if (empty($databaseId) || empty($collectionId)) {
return [];
}
- // Check if we've already visited this collection in the current path to prevent cycles
- if (in_array($collectionId, $visited)) {
- return [];
- }
-
- $visited[] = $collectionId;
-
$dbForProject = $this->getDbForProject();
if ($dbForProject === null) {
return [];
}
+ // Resolve the database namespace once, outside the recursion.
try {
$database = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument(
'databases',
@@ -148,19 +145,42 @@ class V20 extends Filter
return [];
}
- try {
- $collection = $database = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument(
- 'database_' . $database->getSequence(),
- $collectionId
- ));
- if ($collection->isEmpty()) {
- return [];
- }
- } catch (\Throwable) {
+ $databaseNamespace = 'database_' . $database->getSequence();
+
+ return $this->walkRelatedCollectionKeys(
+ $dbForProject,
+ $databaseNamespace,
+ $collectionId,
+ null,
+ 1,
+ []
+ );
+ }
+
+ private function walkRelatedCollectionKeys(
+ Database $dbForProject,
+ string $databaseNamespace,
+ string $collectionId,
+ ?string $prefix,
+ int $depth,
+ array $visited
+ ): array {
+ if ($depth > Database::RELATION_MAX_DEPTH) {
return [];
}
- $attributes = $collection->getAttribute('attributes', []);
+ // Check if we've already visited this collection in the current path to prevent cycles
+ if (in_array($collectionId, $visited, true)) {
+ return [];
+ }
+
+ $attributes = $this->getCollectionAttributes($dbForProject, $databaseNamespace, $collectionId);
+ if ($attributes === null) {
+ return [];
+ }
+
+ $visited[] = $collectionId;
+
$relationshipKeys = [];
foreach ($attributes as $attr) {
@@ -176,27 +196,54 @@ class V20 extends Filter
$relatedCollectionId = $attr['relatedCollection'] ?? null;
// Skip this relationship entirely if it points to an already visited collection
- if ($relatedCollectionId && in_array($relatedCollectionId, $visited)) {
+ if ($relatedCollectionId && in_array($relatedCollectionId, $visited, true)) {
continue;
}
- // Add the wildcard select for this relationship
$relationshipKeys[] = $fullKey . '.*';
- // Continue recursively if we have a related collection
if ($relatedCollectionId) {
- $nestedKeys = $this->getRelatedCollectionKeys(
- $databaseId,
+ $nestedKeys = $this->walkRelatedCollectionKeys(
+ $dbForProject,
+ $databaseNamespace,
$relatedCollectionId,
$fullKey,
$depth + 1,
$visited
);
-
$relationshipKeys = \array_merge($relationshipKeys, $nestedKeys);
}
}
return \array_values(\array_unique($relationshipKeys));
}
+
+ /**
+ * @return array>|null
+ */
+ private function getCollectionAttributes(
+ Database $dbForProject,
+ string $databaseNamespace,
+ string $collectionId
+ ): ?array {
+ $cacheKey = $databaseNamespace . ':' . $collectionId;
+ if (\array_key_exists($cacheKey, $this->collectionAttributesCache)) {
+ return $this->collectionAttributesCache[$cacheKey];
+ }
+
+ try {
+ $collection = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument(
+ $databaseNamespace,
+ $collectionId
+ ));
+ } catch (\Throwable) {
+ return $this->collectionAttributesCache[$cacheKey] = null;
+ }
+
+ if ($collection->isEmpty()) {
+ return $this->collectionAttributesCache[$cacheKey] = null;
+ }
+
+ return $this->collectionAttributesCache[$cacheKey] = $collection->getAttribute('attributes', []);
+ }
}
diff --git a/src/Appwrite/Utopia/Request/Filters/V22.php b/src/Appwrite/Utopia/Request/Filters/V22.php
index 4f1e746775..7e4c5b8e41 100644
--- a/src/Appwrite/Utopia/Request/Filters/V22.php
+++ b/src/Appwrite/Utopia/Request/Filters/V22.php
@@ -73,10 +73,10 @@ class V22 extends Filter
public function parse(array $content, string $model): array
{
switch ($model) {
- case 'project.updateServiceStatus':
+ case 'project.updateService':
$content = $this->parseUpdateServiceStatus($content);
break;
- case 'project.updateProtocolStatus':
+ case 'project.updateProtocol':
$content = $this->parseUpdateProtocolStatus($content);
break;
case 'project.createKey':
diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php
new file mode 100644
index 0000000000..e509900417
--- /dev/null
+++ b/src/Appwrite/Utopia/Request/Filters/V23.php
@@ -0,0 +1,110 @@
+parseEmailTemplate($content);
+ break;
+ case 'project.updateEmailTemplate':
+ $content = $this->parseEmailTemplate($content);
+ $content = $this->parseReplyTo($content);
+ break;
+ case 'project.updateSMTP':
+ $content = $this->parseReplyTo($content);
+ break;
+ case 'project.updateMembershipPrivacyPolicy':
+ $content = $this->parseUpdateMembershipPrivacyPolicy($content);
+ break;
+ case 'project.updateSessionAlertPolicy':
+ $content = $this->parseUpdateSessionAlertPolicy($content);
+ break;
+ case 'project.updateUserLimitPolicy':
+ case 'project.updatePasswordHistoryPolicy':
+ case 'project.updateSessionLimitPolicy':
+ $content = $this->parseLimitToTotal($content);
+ break;
+ case 'project.updateAuthMethod':
+ $content = $this->parseUpdateAuthMethod($content);
+ break;
+ }
+
+ return $content;
+ }
+
+ protected function parseUpdateMembershipPrivacyPolicy(array $content): array
+ {
+ $content['userId'] = false;
+ $content['userPhone'] = false;
+
+ if (isset($content['mfa'])) {
+ $content['userMFA'] = $content['mfa'];
+ unset($content['mfa']);
+ }
+
+ return $content;
+ }
+
+ protected function parseUpdateSessionAlertPolicy(array $content): array
+ {
+ if (isset($content['alerts'])) {
+ $content['enabled'] = $content['alerts'];
+ unset($content['alerts']);
+ }
+
+ return $content;
+ }
+
+ protected function parseUpdateAuthMethod(array $content): array
+ {
+ if (isset($content['status'])) {
+ $content['enabled'] = $content['status'];
+ unset($content['status']);
+ }
+
+ if (isset($content['method'])) {
+ $content['methodId'] = $content['method'];
+ unset($content['method']);
+ }
+
+ return $content;
+ }
+
+ protected function parseLimitToTotal(array $content): array
+ {
+ if (isset($content['limit'])) {
+ $content['total'] = $content['limit'] === 0 ? null : $content['limit'];
+ unset($content['limit']);
+ }
+
+ return $content;
+ }
+
+ protected function parseEmailTemplate(array $content): array
+ {
+ if (isset($content['type'])) {
+ $content['templateId'] = $content['type'];
+ unset($content['type']);
+ }
+
+ return $content;
+ }
+
+ protected function parseReplyTo(array $content): array
+ {
+ if (isset($content['replyTo'])) {
+ $content['replyToEmail'] = $content['replyTo'];
+ unset($content['replyTo']);
+ }
+
+ return $content;
+ }
+}
diff --git a/src/Appwrite/Utopia/Request/Filters/V24.php b/src/Appwrite/Utopia/Request/Filters/V24.php
new file mode 100644
index 0000000000..f62c1f8c0b
--- /dev/null
+++ b/src/Appwrite/Utopia/Request/Filters/V24.php
@@ -0,0 +1,36 @@
+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;
+ }
+}
diff --git a/src/Appwrite/Utopia/Request/Filters/V25.php b/src/Appwrite/Utopia/Request/Filters/V25.php
new file mode 100644
index 0000000000..cba70a5f7b
--- /dev/null
+++ b/src/Appwrite/Utopia/Request/Filters/V25.php
@@ -0,0 +1,27 @@
+fillVariableId($content);
+ break;
+ }
+
+ return $content;
+ }
+
+ protected function fillVariableId(array $content): array
+ {
+ $content['variableId'] = $content['variableId'] ?? 'unique()';
+ return $content;
+ }
+}
diff --git a/src/Appwrite/Utopia/Request/Filters/V26.php b/src/Appwrite/Utopia/Request/Filters/V26.php
new file mode 100644
index 0000000000..00310ddff9
--- /dev/null
+++ b/src/Appwrite/Utopia/Request/Filters/V26.php
@@ -0,0 +1,37 @@
+stripProjectMetadata($content);
+ break;
+ }
+
+ return $content;
+ }
+
+ protected function stripProjectMetadata(array $content): array
+ {
+ unset(
+ $content['description'],
+ $content['logo'],
+ $content['url'],
+ $content['legalName'],
+ $content['legalCountry'],
+ $content['legalState'],
+ $content['legalCity'],
+ $content['legalAddress'],
+ $content['legalTaxId'],
+ );
+
+ return $content;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php
index 5cd0e8366a..66e4a5f4ae 100644
--- a/src/Appwrite/Utopia/Response.php
+++ b/src/Appwrite/Utopia/Response.php
@@ -39,6 +39,7 @@ class Response extends SwooleResponse
public const MODEL_USAGE_TABLE = 'usageTable';
public const MODEL_USAGE_COLLECTION = 'usageCollection';
public const MODEL_USAGE_USERS = 'usageUsers';
+ public const MODEL_USAGE_PRESENCE = 'usagePresence';
public const MODEL_USAGE_BUCKETS = 'usageBuckets';
public const MODEL_USAGE_STORAGE = 'usageStorage';
public const MODEL_USAGE_FUNCTIONS = 'usageFunctions';
@@ -64,6 +65,8 @@ class Response extends SwooleResponse
public const MODEL_COLUMN_INDEX_LIST = 'columnIndexList';
public const MODEL_DOCUMENT = 'document';
public const MODEL_DOCUMENT_LIST = 'documentList';
+ public const MODEL_PRESENCE = 'presence';
+ public const MODEL_PRESENCE_LIST = 'presenceList';
public const MODEL_ROW = 'row';
public const MODEL_ROW_LIST = 'rowList';
@@ -72,6 +75,7 @@ class Response extends SwooleResponse
public const MODEL_ATTRIBUTE_LIST = 'attributeList';
public const MODEL_ATTRIBUTE_STRING = 'attributeString';
public const MODEL_ATTRIBUTE_INTEGER = 'attributeInteger';
+ public const MODEL_ATTRIBUTE_BIGINT = 'attributeBigint';
public const MODEL_ATTRIBUTE_FLOAT = 'attributeFloat';
public const MODEL_ATTRIBUTE_BOOLEAN = 'attributeBoolean';
public const MODEL_ATTRIBUTE_EMAIL = 'attributeEmail';
@@ -95,6 +99,7 @@ class Response extends SwooleResponse
public const MODEL_COLUMN_LIST = 'columnList';
public const MODEL_COLUMN_STRING = 'columnString';
public const MODEL_COLUMN_INTEGER = 'columnInteger';
+ public const MODEL_COLUMN_BIGINT = 'columnBigint';
public const MODEL_COLUMN_FLOAT = 'columnFloat';
public const MODEL_COLUMN_BOOLEAN = 'columnBoolean';
public const MODEL_COLUMN_EMAIL = 'columnEmail';
@@ -247,13 +252,28 @@ class Response extends SwooleResponse
// Project
public const MODEL_PROJECT = 'project';
public const MODEL_PROJECT_LIST = 'projectList';
+ public const MODEL_PROJECT_AUTH_METHOD = 'projectAuthMethod';
+ public const MODEL_PROJECT_SERVICE = 'projectService';
+ public const MODEL_PROJECT_PROTOCOL = 'projectProtocol';
public const MODEL_WEBHOOK = 'webhook';
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';
+ public const MODEL_MOCK_NUMBER_LIST = 'mockNumberList';
+ public const MODEL_POLICY_LIST = 'policyList';
+ public const MODEL_POLICY_PASSWORD_DICTIONARY = 'policyPasswordDictionary';
+ public const MODEL_POLICY_PASSWORD_HISTORY = 'policyPasswordHistory';
+ public const MODEL_POLICY_PASSWORD_PERSONAL_DATA = 'policyPasswordPersonalData';
+ public const MODEL_POLICY_SESSION_ALERT = 'policySessionAlert';
+ public const MODEL_POLICY_SESSION_DURATION = 'policySessionDuration';
+ public const MODEL_POLICY_SESSION_INVALIDATION = 'policySessionInvalidation';
+ public const MODEL_POLICY_SESSION_LIMIT = 'policySessionLimit';
+ public const MODEL_POLICY_USER_LIMIT = 'policyUserLimit';
+ public const MODEL_POLICY_MEMBERSHIP_PRIVACY = 'policyMembershipPrivacy';
public const MODEL_AUTH_PROVIDER = 'authProvider';
public const MODEL_AUTH_PROVIDER_LIST = 'authProviderList';
public const MODEL_PLATFORM_APPLE = 'platformApple';
@@ -265,8 +285,49 @@ class Response extends SwooleResponse
public const MODEL_VARIABLE = 'variable';
public const MODEL_VARIABLE_LIST = 'variableList';
public const MODEL_VCS = 'vcs';
- public const MODEL_SMS_TEMPLATE = 'smsTemplate';
public const MODEL_EMAIL_TEMPLATE = 'emailTemplate';
+ public const MODEL_EMAIL_TEMPLATE_LIST = 'emailTemplateList';
+ public const MODEL_OAUTH2_GITHUB = 'oAuth2Github';
+ public const MODEL_OAUTH2_DISCORD = 'oAuth2Discord';
+ public const MODEL_OAUTH2_FIGMA = 'oAuth2Figma';
+ public const MODEL_OAUTH2_DROPBOX = 'oAuth2Dropbox';
+ public const MODEL_OAUTH2_DAILYMOTION = 'oAuth2Dailymotion';
+ public const MODEL_OAUTH2_BITBUCKET = 'oAuth2Bitbucket';
+ public const MODEL_OAUTH2_BITLY = 'oAuth2Bitly';
+ public const MODEL_OAUTH2_BOX = 'oAuth2Box';
+ public const MODEL_OAUTH2_AUTODESK = 'oAuth2Autodesk';
+ public const MODEL_OAUTH2_GOOGLE = 'oAuth2Google';
+ public const MODEL_OAUTH2_ZOOM = 'oAuth2Zoom';
+ public const MODEL_OAUTH2_ZOHO = 'oAuth2Zoho';
+ public const MODEL_OAUTH2_YANDEX = 'oAuth2Yandex';
+ public const MODEL_OAUTH2_X = 'oAuth2X';
+ public const MODEL_OAUTH2_WORDPRESS = 'oAuth2WordPress';
+ public const MODEL_OAUTH2_TWITCH = 'oAuth2Twitch';
+ public const MODEL_OAUTH2_STRIPE = 'oAuth2Stripe';
+ public const MODEL_OAUTH2_SPOTIFY = 'oAuth2Spotify';
+ public const MODEL_OAUTH2_SLACK = 'oAuth2Slack';
+ public const MODEL_OAUTH2_PODIO = 'oAuth2Podio';
+ public const MODEL_OAUTH2_NOTION = 'oAuth2Notion';
+ public const MODEL_OAUTH2_SALESFORCE = 'oAuth2Salesforce';
+ public const MODEL_OAUTH2_YAHOO = 'oAuth2Yahoo';
+ public const MODEL_OAUTH2_LINKEDIN = 'oAuth2Linkedin';
+ public const MODEL_OAUTH2_DISQUS = 'oAuth2Disqus';
+ public const MODEL_OAUTH2_AMAZON = 'oAuth2Amazon';
+ public const MODEL_OAUTH2_ETSY = 'oAuth2Etsy';
+ public const MODEL_OAUTH2_FACEBOOK = 'oAuth2Facebook';
+ public const MODEL_OAUTH2_TRADESHIFT = 'oAuth2Tradeshift';
+ public const MODEL_OAUTH2_PAYPAL = 'oAuth2Paypal';
+ public const MODEL_OAUTH2_GITLAB = 'oAuth2Gitlab';
+ public const MODEL_OAUTH2_AUTHENTIK = 'oAuth2Authentik';
+ public const MODEL_OAUTH2_AUTH0 = 'oAuth2Auth0';
+ public const MODEL_OAUTH2_FUSIONAUTH = 'oAuth2FusionAuth';
+ public const MODEL_OAUTH2_KEYCLOAK = 'oAuth2Keycloak';
+ public const MODEL_OAUTH2_OIDC = 'oAuth2Oidc';
+ public const MODEL_OAUTH2_APPLE = 'oAuth2Apple';
+ public const MODEL_OAUTH2_OKTA = 'oAuth2Okta';
+ public const MODEL_OAUTH2_KICK = 'oAuth2Kick';
+ public const MODEL_OAUTH2_MICROSOFT = 'oAuth2Microsoft';
+ public const MODEL_OAUTH2_PROVIDER_LIST = 'oAuth2ProviderList';
// Health
public const MODEL_HEALTH_STATUS = 'healthStatus';
@@ -277,8 +338,20 @@ class Response extends SwooleResponse
public const MODEL_HEALTH_CERTIFICATE = 'healthCertificate';
public const MODEL_HEALTH_STATUS_LIST = 'healthStatusList';
+ // Advisor
+ public const MODEL_INSIGHT = 'insight';
+ public const MODEL_INSIGHT_LIST = 'insightList';
+ public const MODEL_INSIGHT_CTA = 'insightCTA';
+ public const MODEL_REPORT = 'report';
+ public const MODEL_REPORT_LIST = 'reportList';
+
// Console
public const MODEL_CONSOLE_VARIABLES = 'consoleVariables';
+ 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';
@@ -375,9 +448,10 @@ class Response extends SwooleResponse
return isset(self::$models[$key]);
}
- public function applyFilters(array $data, string $model): array
+ public function applyFilters(array $data, string $model, Document $raw): array
{
foreach ($this->filters as $filter) {
+ $filter->setRawContent($raw);
$data = $filter->parse($data, $model);
}
@@ -397,7 +471,7 @@ class Response extends SwooleResponse
public function dynamic(Document $document, string $model): void
{
$output = $this->output(clone $document, $model);
- $output = $this->applyFilters($output, $model);
+ $output = $this->applyFilters($output, $model, raw: clone $document);
switch ($this->getContentType()) {
case self::CONTENT_TYPE_JSON:
@@ -613,6 +687,8 @@ class Response extends SwooleResponse
throw new \Exception('Response body is not a valid JSON object.');
}
+ $this->payload = \is_array($data) ? $data : (array) $data;
+
$this
->setContentType(Response::CONTENT_TYPE_JSON, self::CHARSET_UTF8)
->send(\json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
@@ -627,13 +703,12 @@ class Response extends SwooleResponse
}
/**
- * Reset the sent flag so the response can be reused for another
- * action execution (e.g. batched GraphQL queries that share one
- * Response instance).
+ * Set the sent flag on the response. Pass false to allow reuse
+ * (e.g. batched GraphQL queries), true to prevent further writes.
*/
- public function clearSent(): static
+ public function setSent(bool $sent): static
{
- $this->sent = false;
+ $this->sent = $sent;
return $this;
}
diff --git a/src/Appwrite/Utopia/Response/Filter.php b/src/Appwrite/Utopia/Response/Filter.php
index bd82467f81..13833be328 100644
--- a/src/Appwrite/Utopia/Response/Filter.php
+++ b/src/Appwrite/Utopia/Response/Filter.php
@@ -2,8 +2,15 @@
namespace Appwrite\Utopia\Response;
+use Utopia\Database\Document;
+
abstract class Filter
{
+ /**
+ * @var ?Document $rawContent
+ */
+ protected ?Document $rawContent = null;
+
/**
* Parse the content to another format.
*
@@ -14,6 +21,10 @@ abstract class Filter
*/
abstract public function parse(array $content, string $model): array;
+ public function setRawContent(Document $rawContent): void
+ {
+ $this->rawContent = $rawContent;
+ }
/**
* Handle list
diff --git a/src/Appwrite/Utopia/Response/Filters/V16.php b/src/Appwrite/Utopia/Response/Filters/V16.php
index 7eb3ec6eb3..74bae97abb 100644
--- a/src/Appwrite/Utopia/Response/Filters/V16.php
+++ b/src/Appwrite/Utopia/Response/Filters/V16.php
@@ -40,7 +40,7 @@ class V16 extends Filter
}
if (isset($content['buildSize'])) {
- $content['size'] += + $content['buildSize'] ?? 0;
+ $content['size'] += +$content['buildSize'];
unset($content['buildSize']);
}
diff --git a/src/Appwrite/Utopia/Response/Filters/V23.php b/src/Appwrite/Utopia/Response/Filters/V23.php
new file mode 100644
index 0000000000..cd8ce44c0a
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Filters/V23.php
@@ -0,0 +1,76 @@
+ $this->parseMembership($content),
+ Response::MODEL_MEMBERSHIP_LIST => $this->handleList($content, 'memberships', fn ($item) => $this->parseMembership($item)),
+ Response::MODEL_PROJECT => $this->parseProject($content),
+ Response::MODEL_PROJECT_LIST => $this->handleList($content, 'projects', fn ($item) => $this->parseProject($item)),
+ Response::MODEL_EMAIL_TEMPLATE => $this->parseEmailTemplate($content),
+ Response::MODEL_MOCK_NUMBER => $this->parseMockNumber($content),
+ default => $content,
+ };
+ }
+
+ private function parseMockNumber(array $content): array
+ {
+ unset($content['$createdAt']);
+ unset($content['$updatedAt']);
+
+ if (isset($content['number'])) {
+ $content['phone'] = $content['number'];
+ unset($content['number']);
+ }
+
+ return $content;
+ }
+
+ private function parseMembership(array $content): array
+ {
+ unset($content['userPhone']);
+
+ return $content;
+ }
+
+ private function parseEmailTemplate(array $content): array
+ {
+ if (isset($content['templateId'])) {
+ $content['type'] = $content['templateId'];
+ unset($content['templateId']);
+ }
+
+ if (isset($content['replyToEmail'])) {
+ $content['replyTo'] = $content['replyToEmail'];
+ unset($content['replyToEmail']);
+ }
+
+ unset($content['replyToName']);
+ unset($content['custom']);
+
+ return $content;
+ }
+
+ private function parseProject(array $content): array
+ {
+ unset($content['authMembershipsUserId']);
+ unset($content['authMembershipsUserPhone']);
+
+ if (isset($content['smtpReplyToEmail'])) {
+ $content['smtpReplyTo'] = $content['smtpReplyToEmail'];
+ unset($content['smtpReplyToEmail']);
+ }
+
+ unset($content['smtpReplyToName']);
+
+ return $content;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Filters/V24.php b/src/Appwrite/Utopia/Response/Filters/V24.php
new file mode 100644
index 0000000000..46db062863
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Filters/V24.php
@@ -0,0 +1,56 @@
+ $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 '';
+ }
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Filters/V25.php b/src/Appwrite/Utopia/Response/Filters/V25.php
new file mode 100644
index 0000000000..bda98ed0d8
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Filters/V25.php
@@ -0,0 +1,34 @@
+ $this->parseOAuth2Oidc($content),
+ Response::MODEL_OAUTH2_PROVIDER_LIST => $this->handleList($content, 'providers', fn ($item) => ($item['$id'] ?? null) === 'oidc' ? $this->parseOAuth2Oidc($item) : $item),
+ default => $content,
+ };
+ }
+
+ private function parseOAuth2Oidc(array $content): array
+ {
+ if (isset($content['tokenURL'])) {
+ $content['tokenUrl'] = $content['tokenURL'];
+ unset($content['tokenURL']);
+ }
+
+ if (isset($content['userInfoURL'])) {
+ $content['userInfoUrl'] = $content['userInfoURL'];
+ unset($content['userInfoURL']);
+ }
+
+ return $content;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Filters/V26.php b/src/Appwrite/Utopia/Response/Filters/V26.php
new file mode 100644
index 0000000000..3867ba907f
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Filters/V26.php
@@ -0,0 +1,227 @@
+ $this->parseProject($content, $this->rawContent),
+ Response::MODEL_PROJECT_LIST => $this->handleList($content, 'projects', function ($item) {
+ $projectId = $item['$id'] ?? '';
+
+ $rawProjects = $this->rawContent->getAttribute('projects', []);
+ $rawProject = new Document();
+ foreach ($rawProjects as $rawItem) {
+ if ($rawItem->getId() === $projectId) {
+ $rawProject = $rawItem;
+ break;
+ }
+ }
+
+ return $this->parseProject($item, $rawProject);
+ }),
+ default => $content,
+ };
+ }
+
+ private function parseProject(array $content, Document $raw): array
+ {
+ $this->expandAuthMethods($content);
+ $this->expandServices($content);
+ $this->expandProtocols($content);
+
+ unset($content['authMethods'], $content['services'], $content['protocols']);
+
+ $auths = new Document($raw->getAttribute('auths', []));
+ $content['authLimit'] = $auths->getAttribute('limit', 0);
+ $content['authDuration'] = $auths->getAttribute('duration', TOKEN_EXPIRATION_LOGIN_LONG);
+ $content['authSessionsLimit'] = $auths->getAttribute('maxSessions', 0);
+ $content['authPasswordHistory'] = $auths->getAttribute('passwordHistory', 0);
+ $content['authPasswordDictionary'] = $auths->getAttribute('passwordDictionary', false);
+ $content['authPersonalDataCheck'] = $auths->getAttribute('personalDataCheck', false);
+ $content['authDisposableEmails'] = $auths->getAttribute('disposableEmails', false);
+ $content['authCanonicalEmails'] = $auths->getAttribute('canonicalEmails', false);
+ $content['authFreeEmails'] = $auths->getAttribute('freeEmails', false);
+ $content['authMockNumbers'] = $auths->getAttribute('mockNumbers', []);
+ $content['authSessionAlerts'] = $auths->getAttribute('sessionAlerts', false);
+ $content['authMembershipsUserName'] = $auths->getAttribute('membershipsUserName', false);
+ $content['authMembershipsUserEmail'] = $auths->getAttribute('membershipsUserEmail', false);
+ $content['authMembershipsMfa'] = $auths->getAttribute('membershipsMfa', false);
+ $content['authMembershipsUserId'] = $auths->getAttribute('membershipsUserId', false);
+ $content['authMembershipsUserPhone'] = $auths->getAttribute('membershipsUserPhone', false);
+ $content['authInvalidateSessions'] = $auths->getAttribute('invalidateSessions', false);
+
+ $content['description'] = $raw->getAttribute('description', '');
+ $content['logo'] = $raw->getAttribute('logo', '');
+ $content['url'] = $raw->getAttribute('url', '');
+ $content['legalName'] = $raw->getAttribute('legalName', '');
+ $content['legalCountry'] = $raw->getAttribute('legalCountry', '');
+ $content['legalState'] = $raw->getAttribute('legalState', '');
+ $content['legalCity'] = $raw->getAttribute('legalCity', '');
+ $content['legalAddress'] = $raw->getAttribute('legalAddress', '');
+ $content['legalTaxId'] = $raw->getAttribute('legalTaxId', '');
+
+ $content['oAuthProviders'] = $this->expandOAuthProviders($raw);
+
+ $content['platforms'] = [];
+ foreach ($raw->getAttribute('platforms', []) as $platform) {
+ $content['platforms'][] = $this->parsePlatform($platform);
+ }
+
+ $content['webhooks'] = [];
+ foreach ($raw->getAttribute('webhooks', []) as $webhook) {
+ $content['webhooks'][] = $this->parseWebhook($webhook);
+ }
+
+ $content['keys'] = [];
+ foreach ($raw->getAttribute('keys', []) as $key) {
+ $content['keys'][] = $this->parseKey($key);
+ }
+
+ return $content;
+ }
+
+ private function parsePlatform(Document $platform): array
+ {
+ $type = $platform->getAttribute('type', '');
+ $key = $platform->getAttribute('key', '');
+
+ $result = [
+ '$id' => $platform->getAttribute('$id', ''),
+ '$createdAt' => $platform->getAttribute('$createdAt', ''),
+ '$updatedAt' => $platform->getAttribute('$updatedAt', ''),
+ 'name' => $platform->getAttribute('name', ''),
+ 'type' => $type,
+ ];
+
+ switch ($type) {
+ case Platform::TYPE_ANDROID:
+ $result['applicationId'] = $key;
+ break;
+ case Platform::TYPE_APPLE:
+ $result['bundleIdentifier'] = $key;
+ break;
+ case Platform::TYPE_LINUX:
+ $result['packageName'] = $key;
+ break;
+ case Platform::TYPE_WINDOWS:
+ $result['packageIdentifierName'] = $key;
+ break;
+ default:
+ // Web and backwards-compatibility types are mapped to web
+ $result['hostname'] = $platform->getAttribute('hostname', '');
+ $result['key'] = $key;
+ break;
+ }
+
+ return $result;
+ }
+
+ private function parseWebhook(Document $webhook): array
+ {
+ return [
+ '$id' => $webhook->getAttribute('$id', ''),
+ '$createdAt' => $webhook->getAttribute('$createdAt', ''),
+ '$updatedAt' => $webhook->getAttribute('$updatedAt', ''),
+ 'name' => $webhook->getAttribute('name', ''),
+ 'url' => $webhook->getAttribute('url', ''),
+ 'events' => $webhook->getAttribute('events', []),
+ 'tls' => $webhook->getAttribute('security', true),
+ 'authUsername' => $webhook->getAttribute('httpUser', ''),
+ 'authPassword' => $webhook->getAttribute('httpPass', ''),
+ 'secret' => $webhook->getAttribute('signatureKey', ''),
+ 'enabled' => $webhook->getAttribute('enabled', true),
+ 'logs' => $webhook->getAttribute('logs', ''),
+ 'attempts' => $webhook->getAttribute('attempts', 0),
+ ];
+ }
+
+ private function parseKey(Document $key): array
+ {
+ return [
+ '$id' => $key->getAttribute('$id', ''),
+ '$createdAt' => $key->getAttribute('$createdAt', ''),
+ '$updatedAt' => $key->getAttribute('$updatedAt', ''),
+ 'name' => $key->getAttribute('name', ''),
+ 'expire' => $key->getAttribute('expire', ''),
+ 'scopes' => $key->getAttribute('scopes', []),
+ 'secret' => $key->getAttribute('secret', ''),
+ 'accessedAt' => $key->getAttribute('accessedAt', ''),
+ 'sdks' => $key->getAttribute('sdks', []),
+ ];
+ }
+
+ private function expandAuthMethods(array &$content): void
+ {
+ $authMethods = [];
+ foreach ($content['authMethods'] ?? [] as $method) {
+ $authMethods[$method['$id'] ?? ''] = $method['enabled'] ?? true;
+ }
+
+ foreach (Config::getParam('auth', []) as $id => $method) {
+ $key = $method['key'] ?? '';
+ $content['auth' . ucfirst($key)] = $authMethods[$id] ?? true;
+ }
+ }
+
+ private function expandServices(array &$content): void
+ {
+ $services = [];
+ foreach ($content['services'] ?? [] as $service) {
+ $services[$service['$id'] ?? ''] = $service['enabled'] ?? true;
+ }
+
+ foreach (Config::getParam('services', []) as $id => $service) {
+ if (!($service['optional'] ?? false)) {
+ continue;
+ }
+ $key = $service['key'] ?? '';
+ $content['serviceStatusFor' . ucfirst($key)] = $services[$id] ?? true;
+ }
+ }
+
+ private function expandProtocols(array &$content): void
+ {
+ $protocols = [];
+ foreach ($content['protocols'] ?? [] as $protocol) {
+ $protocols[$protocol['$id'] ?? ''] = $protocol['enabled'] ?? true;
+ }
+
+ foreach (Config::getParam('protocols', []) as $id => $api) {
+ $key = $api['key'] ?? '';
+ $content['protocolStatusFor' . ucfirst($key)] = $protocols[$id] ?? true;
+ }
+ }
+
+ private function expandOAuthProviders(Document $raw): array
+ {
+ $providers = Config::getParam('oAuthProviders', []);
+ $providerValues = $raw->getAttribute('oAuthProviders', []);
+ $projectProviders = [];
+
+ foreach ($providers as $key => $provider) {
+ if (!($provider['enabled'] ?? false)) {
+ continue;
+ }
+
+ $projectProviders[] = [
+ 'key' => $key,
+ 'name' => $provider['name'] ?? '',
+ 'appId' => $providerValues[$key . 'Appid'] ?? '',
+ 'secret' => '',
+ 'enabled' => $providerValues[$key . 'Enabled'] ?? false,
+ ];
+ }
+
+ return $projectProviders;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php b/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php
index 3e162bb905..a721235f94 100644
--- a/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php
+++ b/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php
@@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model;
class AlgoArgon2 extends Model
{
+ public array $conditions = [
+ 'type' => 'argon2',
+ ];
+
public function __construct()
{
// No options if imported. If hashed by Appwrite, following configuration is available:
diff --git a/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php b/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php
index 709dea1a41..ef15e5d50a 100644
--- a/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php
+++ b/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php
@@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model;
class AlgoBcrypt extends Model
{
+ public array $conditions = [
+ 'type' => 'bcrypt',
+ ];
+
public function __construct()
{
// No options, because this can only be imported, and verifying doesnt require any configuration
diff --git a/src/Appwrite/Utopia/Response/Model/AlgoMd5.php b/src/Appwrite/Utopia/Response/Model/AlgoMd5.php
index 509ee70c31..26b2886330 100644
--- a/src/Appwrite/Utopia/Response/Model/AlgoMd5.php
+++ b/src/Appwrite/Utopia/Response/Model/AlgoMd5.php
@@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model;
class AlgoMd5 extends Model
{
+ public array $conditions = [
+ 'type' => 'md5',
+ ];
+
public function __construct()
{
// No options, because this can only be imported, and verifying doesnt require any configuration
diff --git a/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php b/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php
index f16792086e..7d8400edec 100644
--- a/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php
+++ b/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php
@@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model;
class AlgoPhpass extends Model
{
+ public array $conditions = [
+ 'type' => 'phpass',
+ ];
+
public function __construct()
{
// No options, because this can only be imported, and verifying doesnt require any configuration
diff --git a/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php b/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php
index 4dda297d71..043a27166d 100644
--- a/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php
+++ b/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php
@@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model;
class AlgoScrypt extends Model
{
+ public array $conditions = [
+ 'type' => 'scrypt',
+ ];
+
public function __construct()
{
$this
diff --git a/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php b/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php
index 40b9df1dad..24dd41bb77 100644
--- a/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php
+++ b/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php
@@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model;
class AlgoScryptModified extends Model
{
+ public array $conditions = [
+ 'type' => 'scryptMod',
+ ];
+
public function __construct()
{
$this
diff --git a/src/Appwrite/Utopia/Response/Model/AlgoSha.php b/src/Appwrite/Utopia/Response/Model/AlgoSha.php
index 2a0893adc4..52743ec26a 100644
--- a/src/Appwrite/Utopia/Response/Model/AlgoSha.php
+++ b/src/Appwrite/Utopia/Response/Model/AlgoSha.php
@@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model;
class AlgoSha extends Model
{
+ public array $conditions = [
+ 'type' => 'sha',
+ ];
+
public function __construct()
{
// No options, because this can only be imported, and verifying doesnt require any configuration
diff --git a/src/Appwrite/Utopia/Response/Model/Any.php b/src/Appwrite/Utopia/Response/Model/Any.php
index 6863748ac8..0ae3040a1d 100644
--- a/src/Appwrite/Utopia/Response/Model/Any.php
+++ b/src/Appwrite/Utopia/Response/Model/Any.php
@@ -12,6 +12,26 @@ class Any extends Model
*/
protected bool $any = true;
+ /**
+ * JSON wire-format key under which extra/dynamic attributes are exposed in
+ * generated SDK models (e.g. Document's `data` slot). Default null means
+ * SDK templates fall back to their hardcoded "data" key. Set this on
+ * subclasses (via setAdditionalPropertiesKey) to use a custom key like
+ * "metadata" while still benefiting from the generic `Model` mapping.
+ */
+ protected ?string $additionalPropertiesKey = null;
+
+ public function setAdditionalPropertiesKey(string $key): self
+ {
+ $this->additionalPropertiesKey = $key;
+ return $this;
+ }
+
+ public function getAdditionalPropertiesKey(): ?string
+ {
+ return $this->additionalPropertiesKey;
+ }
+
/**
* Get Name
*
diff --git a/src/Appwrite/Utopia/Response/Model/AttributeBigInt.php b/src/Appwrite/Utopia/Response/Model/AttributeBigInt.php
new file mode 100644
index 0000000000..baa93ff103
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/AttributeBigInt.php
@@ -0,0 +1,66 @@
+addRule('key', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Attribute Key.',
+ 'default' => '',
+ 'example' => 'count',
+ ])
+ ->addRule('type', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Attribute type.',
+ 'default' => '',
+ 'example' => 'bigint',
+ ])
+ ->addRule('min', [
+ 'type' => self::TYPE_INTEGER,
+ 'format' => 'int64',
+ 'description' => 'Minimum value to enforce for new documents.',
+ 'default' => null,
+ 'required' => false,
+ 'example' => 1,
+ ])
+ ->addRule('max', [
+ 'type' => self::TYPE_INTEGER,
+ 'format' => 'int64',
+ 'description' => 'Maximum value to enforce for new documents.',
+ 'default' => null,
+ 'required' => false,
+ 'example' => 10,
+ ])
+ ->addRule('default', [
+ 'type' => self::TYPE_INTEGER,
+ 'format' => 'int64',
+ 'description' => 'Default value for attribute when not provided. Cannot be set when attribute is required.',
+ 'default' => null,
+ 'required' => false,
+ 'example' => 10,
+ ])
+ ;
+ }
+
+ public array $conditions = [
+ 'type' => 'bigint'
+ ];
+
+ public function getName(): string
+ {
+ return 'AttributeBigInt';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_ATTRIBUTE_BIGINT;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/AttributeList.php b/src/Appwrite/Utopia/Response/Model/AttributeList.php
index 50189a80c3..87d1dc8b9f 100644
--- a/src/Appwrite/Utopia/Response/Model/AttributeList.php
+++ b/src/Appwrite/Utopia/Response/Model/AttributeList.php
@@ -19,6 +19,9 @@ class AttributeList extends Model
->addRule('attributes', [
'type' => [
Response::MODEL_ATTRIBUTE_BOOLEAN,
+ // BigInt must come before Integer: response model dispatch is "first match wins",
+ // and Integer matches all int types (including bigint), while BigInt is more specific (size=8).
+ Response::MODEL_ATTRIBUTE_BIGINT,
Response::MODEL_ATTRIBUTE_INTEGER,
Response::MODEL_ATTRIBUTE_FLOAT,
Response::MODEL_ATTRIBUTE_EMAIL,
diff --git a/src/Appwrite/Utopia/Response/Model/AuthProvider.php b/src/Appwrite/Utopia/Response/Model/AuthProvider.php
index 2b8f962cd0..034be623e8 100644
--- a/src/Appwrite/Utopia/Response/Model/AuthProvider.php
+++ b/src/Appwrite/Utopia/Response/Model/AuthProvider.php
@@ -30,9 +30,9 @@ class AuthProvider extends Model
])
->addRule('secret', [
'type' => self::TYPE_STRING,
- 'description' => 'OAuth 2.0 application secret. Might be JSON string if provider requires extra configuration.',
+ 'description' => 'OAuth 2.0 application secret. Might be JSON string if provider requires extra configuration. This property is write-only and always returned empty.',
'default' => '',
- 'example' => 'Bpw_g9c2TGXxfgLshDbSaL8tsCcqgczQ',
+ 'example' => '',
])
->addRule('enabled', [
'type' => self::TYPE_BOOLEAN,
diff --git a/src/Appwrite/Utopia/Response/Model/Collection.php b/src/Appwrite/Utopia/Response/Model/Collection.php
index 4ab7de8e4d..bc4de22858 100644
--- a/src/Appwrite/Utopia/Response/Model/Collection.php
+++ b/src/Appwrite/Utopia/Response/Model/Collection.php
@@ -62,6 +62,9 @@ class Collection extends Model
->addRule('attributes', [
'type' => [
Response::MODEL_ATTRIBUTE_BOOLEAN,
+ // BigInt must come before Integer: response model dispatch is "first match wins",
+ // and Integer matches all int types (including bigint), while BigInt is more specific (size=8).
+ Response::MODEL_ATTRIBUTE_BIGINT,
Response::MODEL_ATTRIBUTE_INTEGER,
Response::MODEL_ATTRIBUTE_FLOAT,
Response::MODEL_ATTRIBUTE_EMAIL,
diff --git a/src/Appwrite/Utopia/Response/Model/ColumnBigInt.php b/src/Appwrite/Utopia/Response/Model/ColumnBigInt.php
new file mode 100644
index 0000000000..895356dbf2
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/ColumnBigInt.php
@@ -0,0 +1,66 @@
+addRule('key', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Column Key.',
+ 'default' => '',
+ 'example' => 'count',
+ ])
+ ->addRule('type', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Column type.',
+ 'default' => '',
+ 'example' => 'bigint',
+ ])
+ ->addRule('min', [
+ 'type' => self::TYPE_INTEGER,
+ 'format' => 'int64',
+ 'description' => 'Minimum value to enforce for new documents.',
+ 'default' => null,
+ 'required' => false,
+ 'example' => 1,
+ ])
+ ->addRule('max', [
+ 'type' => self::TYPE_INTEGER,
+ 'format' => 'int64',
+ 'description' => 'Maximum value to enforce for new documents.',
+ 'default' => null,
+ 'required' => false,
+ 'example' => 10,
+ ])
+ ->addRule('default', [
+ 'type' => self::TYPE_INTEGER,
+ 'format' => 'int64',
+ 'description' => 'Default value for column when not provided. Cannot be set when column is required.',
+ 'default' => null,
+ 'required' => false,
+ 'example' => 10,
+ ])
+ ;
+ }
+
+ public array $conditions = [
+ 'type' => 'bigint'
+ ];
+
+ public function getName(): string
+ {
+ return 'ColumnBigInt';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_COLUMN_BIGINT;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/ColumnList.php b/src/Appwrite/Utopia/Response/Model/ColumnList.php
index e99223cd17..0586015e4d 100644
--- a/src/Appwrite/Utopia/Response/Model/ColumnList.php
+++ b/src/Appwrite/Utopia/Response/Model/ColumnList.php
@@ -19,6 +19,9 @@ class ColumnList extends Model
->addRule('columns', [
'type' => [
Response::MODEL_COLUMN_BOOLEAN,
+ // BigInt must come before Integer: response model dispatch is "first match wins",
+ // and Integer matches all int types (including bigint), while BigInt is more specific (size=8).
+ Response::MODEL_COLUMN_BIGINT,
Response::MODEL_COLUMN_INTEGER,
Response::MODEL_COLUMN_FLOAT,
Response::MODEL_COLUMN_EMAIL,
diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleKeyScope.php b/src/Appwrite/Utopia/Response/Model/ConsoleKeyScope.php
new file mode 100644
index 0000000000..224d114271
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/ConsoleKeyScope.php
@@ -0,0 +1,49 @@
+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',
+ ])
+ ->addRule('category', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Scope category.',
+ 'default' => '',
+ 'example' => 'Auth',
+ ])
+ ->addRule('deprecated', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'Scope is deprecated.',
+ 'default' => false,
+ 'example' => true,
+ ])
+ ;
+ }
+
+ public function getName(): string
+ {
+ return 'Console Key Scope';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_CONSOLE_KEY_SCOPE;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleKeyScopeList.php b/src/Appwrite/Utopia/Response/Model/ConsoleKeyScopeList.php
new file mode 100644
index 0000000000..aadf3afa63
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/ConsoleKeyScopeList.php
@@ -0,0 +1,37 @@
+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;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2Provider.php b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2Provider.php
new file mode 100644
index 0000000000..05969a5e8c
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2Provider.php
@@ -0,0 +1,37 @@
+addRule('$id', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'OAuth2 provider ID.',
+ 'default' => '',
+ 'example' => 'github',
+ ])
+ ->addRule('parameters', [
+ 'type' => Response::MODEL_CONSOLE_OAUTH2_PROVIDER_PARAMETER,
+ 'description' => 'List of parameters required to configure this OAuth2 provider.',
+ 'default' => [],
+ 'array' => true,
+ ])
+ ;
+ }
+
+ public function getName(): string
+ {
+ return 'Console OAuth2 Provider';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_CONSOLE_OAUTH2_PROVIDER;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderList.php b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderList.php
new file mode 100644
index 0000000000..42d6936d42
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderList.php
@@ -0,0 +1,37 @@
+addRule('total', [
+ 'type' => self::TYPE_INTEGER,
+ 'description' => 'Total number of OAuth2 providers exposed by the server.',
+ 'default' => 0,
+ 'example' => 5,
+ ])
+ ->addRule('oAuth2Providers', [
+ 'type' => Response::MODEL_CONSOLE_OAUTH2_PROVIDER,
+ 'description' => 'List of OAuth2 providers, each with the parameters required to configure it.',
+ 'default' => [],
+ 'array' => true,
+ ])
+ ;
+ }
+
+ public function getName(): string
+ {
+ return 'Console OAuth2 Providers List';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_CONSOLE_OAUTH2_PROVIDER_LIST;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderParameter.php b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderParameter.php
new file mode 100644
index 0000000000..a097718492
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderParameter.php
@@ -0,0 +1,49 @@
+addRule('$id', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Parameter ID. Maps to the request body field used by the project OAuth2 update endpoint (e.g. `clientId`, `appKey`, `tenant`).',
+ 'default' => '',
+ 'example' => 'clientId',
+ ])
+ ->addRule('name', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Verbose, user-facing parameter name as shown in the provider\'s own dashboard. Includes alternate names when the provider exposes more than one.',
+ 'default' => '',
+ 'example' => 'Client ID or App ID',
+ ])
+ ->addRule('example', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Example value for this parameter.',
+ 'default' => '',
+ 'example' => 'e4d87900000000540733',
+ ])
+ ->addRule('hint', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Optional hint for this parameter, typically calling out a common wrong value. Empty string when no hint is set.',
+ 'default' => '',
+ 'example' => 'Example of wrong value: 370006',
+ ])
+ ;
+ }
+
+ public function getName(): string
+ {
+ return 'Console OAuth2 Provider Parameter';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_CONSOLE_OAUTH2_PROVIDER_PARAMETER;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/Detection.php b/src/Appwrite/Utopia/Response/Model/Detection.php
index 007182d1e9..9dfcc795d6 100644
--- a/src/Appwrite/Utopia/Response/Model/Detection.php
+++ b/src/Appwrite/Utopia/Response/Model/Detection.php
@@ -7,9 +7,16 @@ use Appwrite\Utopia\Response\Model;
abstract class Detection extends Model
{
- public function __construct()
+ public function __construct(string $type)
{
$this
+ ->addRule('type', [
+ 'type' => self::TYPE_ENUM,
+ 'description' => 'Repository detection type.',
+ 'default' => $type,
+ 'example' => $type,
+ 'enum' => [$type],
+ ])
->addRule('variables', [
'type' => Response::MODEL_DETECTION_VARIABLE,
'description' => 'Environment variables found in .env files',
diff --git a/src/Appwrite/Utopia/Response/Model/DetectionFramework.php b/src/Appwrite/Utopia/Response/Model/DetectionFramework.php
index 4cdf37bbcf..00f318ba4a 100644
--- a/src/Appwrite/Utopia/Response/Model/DetectionFramework.php
+++ b/src/Appwrite/Utopia/Response/Model/DetectionFramework.php
@@ -8,7 +8,11 @@ class DetectionFramework extends Detection
{
public function __construct()
{
- parent::__construct();
+ $this->conditions = [
+ 'type' => 'framework',
+ ];
+
+ parent::__construct('framework');
$this
->addRule('framework', [
diff --git a/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php b/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php
index 1e63929092..94368f890c 100644
--- a/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php
+++ b/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php
@@ -8,7 +8,11 @@ class DetectionRuntime extends Detection
{
public function __construct()
{
- parent::__construct();
+ $this->conditions = [
+ 'type' => 'runtime',
+ ];
+
+ parent::__construct('runtime');
$this
->addRule('runtime', [
diff --git a/src/Appwrite/Utopia/Response/Model/TemplateSMS.php b/src/Appwrite/Utopia/Response/Model/EphemeralKey.php
similarity index 77%
rename from src/Appwrite/Utopia/Response/Model/TemplateSMS.php
rename to src/Appwrite/Utopia/Response/Model/EphemeralKey.php
index 2b19ef4878..f6b7fdd7f3 100644
--- a/src/Appwrite/Utopia/Response/Model/TemplateSMS.php
+++ b/src/Appwrite/Utopia/Response/Model/EphemeralKey.php
@@ -4,12 +4,13 @@ namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
-class TemplateSMS extends Template
+class EphemeralKey extends Key
{
public function __construct()
{
parent::__construct();
}
+
/**
* Get Name
*
@@ -17,7 +18,7 @@ class TemplateSMS extends Template
*/
public function getName(): string
{
- return 'SmsTemplate';
+ return 'Ephemeral Key';
}
/**
@@ -27,6 +28,6 @@ class TemplateSMS extends Template
*/
public function getType(): string
{
- return Response::MODEL_SMS_TEMPLATE;
+ return Response::MODEL_EPHEMERAL_KEY;
}
}
diff --git a/src/Appwrite/Utopia/Response/Model/File.php b/src/Appwrite/Utopia/Response/Model/File.php
index 9b3e6ff618..61dd496f52 100644
--- a/src/Appwrite/Utopia/Response/Model/File.php
+++ b/src/Appwrite/Utopia/Response/Model/File.php
@@ -67,6 +67,12 @@ class File extends Model
'default' => 0,
'example' => 17890,
])
+ ->addRule('sizeActual', [
+ 'type' => self::TYPE_INTEGER,
+ 'description' => 'File actual stored size in bytes after compression and/or encryption.',
+ 'default' => 0,
+ 'example' => 12345,
+ ])
->addRule('chunksTotal', [
'type' => self::TYPE_INTEGER,
'description' => 'Total number of chunks available',
diff --git a/src/Appwrite/Utopia/Response/Model/Insight.php b/src/Appwrite/Utopia/Response/Model/Insight.php
new file mode 100644
index 0000000000..39a12a9832
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/Insight.php
@@ -0,0 +1,130 @@
+addRule('$id', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Insight ID.',
+ 'default' => '',
+ 'example' => '5e5ea5c16897e',
+ ])
+ ->addRule('$createdAt', [
+ 'type' => self::TYPE_DATETIME,
+ 'description' => 'Insight creation date in ISO 8601 format.',
+ 'default' => '',
+ 'example' => self::TYPE_DATETIME_EXAMPLE,
+ ])
+ ->addRule('$updatedAt', [
+ 'type' => self::TYPE_DATETIME,
+ 'description' => 'Insight update date in ISO 8601 format.',
+ 'default' => '',
+ 'example' => self::TYPE_DATETIME_EXAMPLE,
+ ])
+ ->addRule('reportId', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Parent report ID. Insights always belong to a report.',
+ 'default' => '',
+ 'example' => '5e5ea5c16897e',
+ ])
+ ->addRule('type', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Insight type. One of databaseIndex (legacy), tablesDBIndex, documentsDBIndex, vectorsDBIndex, databasePerformance, sitePerformance, siteAccessibility, siteSeo, functionPerformance. The index types are engine-specific so each CTA can pair the right service+method (databases.createIndex, tablesDB.createIndex, documentsDB.createIndex, or vectorsDB.createIndex).',
+ 'default' => '',
+ 'example' => 'tablesDBIndex',
+ ])
+ ->addRule('severity', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Insight severity. One of info, warning, critical.',
+ 'default' => 'info',
+ 'example' => 'warning',
+ ])
+ ->addRule('status', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Insight status. One of active, dismissed.',
+ 'default' => 'active',
+ 'example' => 'active',
+ ])
+ ->addRule('resourceType', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Type of the resource the insight is about. Plural noun, e.g. databases, sites, functions.',
+ 'default' => '',
+ 'example' => 'databases',
+ ])
+ ->addRule('resourceId', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'ID of the resource the insight is about.',
+ 'default' => '',
+ 'example' => 'main',
+ ])
+ ->addRule('parentResourceType', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Plural noun for the parent resource that contains the insight\'s resource, e.g. an insight about a column index on a table → resourceType=indexes, parentResourceType=tables. Empty when the resource has no parent.',
+ 'default' => '',
+ 'example' => 'tables',
+ ])
+ ->addRule('parentResourceId', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'ID of the parent resource. Empty when the resource has no parent.',
+ 'default' => '',
+ 'example' => 'orders',
+ ])
+ ->addRule('title', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Insight title.',
+ 'default' => '',
+ 'example' => 'Missing index on collection orders',
+ ])
+ ->addRule('summary', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Short markdown summary describing the insight.',
+ 'default' => '',
+ 'example' => 'Queries against `orders.status` are scanning the full collection.',
+ ])
+ ->addRule('ctas', [
+ 'type' => Response::MODEL_INSIGHT_CTA,
+ 'description' => 'List of call-to-action buttons attached to this insight.',
+ 'default' => [],
+ 'example' => [],
+ 'array' => true,
+ ])
+ ->addRule('analyzedAt', [
+ 'type' => self::TYPE_DATETIME,
+ 'description' => 'Time the insight was analyzed in ISO 8601 format.',
+ 'default' => null,
+ 'example' => self::TYPE_DATETIME_EXAMPLE,
+ 'required' => false,
+ ])
+ ->addRule('dismissedAt', [
+ 'type' => self::TYPE_DATETIME,
+ 'description' => 'Time the insight was dismissed in ISO 8601 format. Empty when not dismissed.',
+ 'default' => null,
+ 'example' => self::TYPE_DATETIME_EXAMPLE,
+ 'required' => false,
+ ])
+ ->addRule('dismissedBy', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'User ID that dismissed the insight. Empty when not dismissed.',
+ 'default' => '',
+ 'example' => '5e5ea5c16897e',
+ 'required' => false,
+ ]);
+ }
+
+ public function getName(): string
+ {
+ return 'Insight';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_INSIGHT;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/InsightCTA.php b/src/Appwrite/Utopia/Response/Model/InsightCTA.php
new file mode 100644
index 0000000000..3ebd8b5796
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/InsightCTA.php
@@ -0,0 +1,48 @@
+addRule('label', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Human-readable label for the CTA, used in UI.',
+ 'default' => '',
+ 'example' => 'Create missing index',
+ ])
+ ->addRule('service', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Public API service (SDK namespace) the client should invoke. Must match the engine that owns the resource — for index suggestions: databases (legacy), tablesDB, documentsDB, or vectorsDB.',
+ 'default' => '',
+ 'example' => 'tablesDB',
+ ])
+ ->addRule('method', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Public API method on the chosen service the client should invoke when this CTA is triggered.',
+ 'default' => '',
+ 'example' => 'createIndex',
+ ])
+ ->addRule('params', [
+ 'type' => self::TYPE_JSON,
+ 'description' => 'Parameter map the client should pass to the service method when this CTA is triggered. Keys match the target API\'s parameter names (e.g. databaseId/tableId/columns for tablesDB, databaseId/collectionId/attributes for the legacy Databases API).',
+ 'default' => new \stdClass(),
+ 'example' => ['databaseId' => 'main', 'tableId' => 'orders', 'key' => '_idx_status', 'type' => 'key', 'columns' => ['status']],
+ ]);
+ }
+
+ public function getName(): string
+ {
+ return 'InsightCTA';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_INSIGHT_CTA;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/Key.php b/src/Appwrite/Utopia/Response/Model/Key.php
index a13c9146cd..e41ddab667 100644
--- a/src/Appwrite/Utopia/Response/Model/Key.php
+++ b/src/Appwrite/Utopia/Response/Model/Key.php
@@ -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
diff --git a/src/Appwrite/Utopia/Response/Model/Membership.php b/src/Appwrite/Utopia/Response/Model/Membership.php
index 46153842bc..9be7102145 100644
--- a/src/Appwrite/Utopia/Response/Model/Membership.php
+++ b/src/Appwrite/Utopia/Response/Model/Membership.php
@@ -46,6 +46,12 @@ class Membership extends Model
'default' => '',
'example' => 'john@appwrite.io',
])
+ ->addRule('userPhone', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'User phone number. Hide this attribute by toggling membership privacy in the Console.',
+ 'default' => '',
+ 'example' => '+1 555 555 5555',
+ ])
->addRule('teamId', [
'type' => self::TYPE_STRING,
'description' => 'Team ID.',
diff --git a/src/Appwrite/Utopia/Response/Model/MigrationReport.php b/src/Appwrite/Utopia/Response/Model/MigrationReport.php
index 850e4b5ae9..388630af3f 100644
--- a/src/Appwrite/Utopia/Response/Model/MigrationReport.php
+++ b/src/Appwrite/Utopia/Response/Model/MigrationReport.php
@@ -53,6 +53,12 @@ class MigrationReport extends Model
'default' => 0,
'example' => 20,
])
+ ->addRule(Resource::TYPE_PLATFORM, [
+ 'type' => self::TYPE_INTEGER,
+ 'description' => 'Number of platforms to be migrated.',
+ 'default' => 0,
+ 'example' => 5,
+ ])
->addRule(Resource::TYPE_SITE, [
'type' => self::TYPE_INTEGER,
'description' => 'Number of sites to be migrated.',
diff --git a/src/Appwrite/Utopia/Response/Model/MockNumber.php b/src/Appwrite/Utopia/Response/Model/MockNumber.php
index 14ce747da6..507700bc5b 100644
--- a/src/Appwrite/Utopia/Response/Model/MockNumber.php
+++ b/src/Appwrite/Utopia/Response/Model/MockNumber.php
@@ -4,13 +4,14 @@ namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
+use Utopia\Database\Document;
class MockNumber extends Model
{
public function __construct()
{
$this
- ->addRule('phone', [
+ ->addRule('number', [
'type' => self::TYPE_STRING,
'description' => 'Mock phone number for testing phone authentication. Useful for testing phone authentication without sending an SMS.',
'default' => '',
@@ -22,9 +23,31 @@ class MockNumber extends Model
'default' => '',
'example' => '123456',
])
+ ->addRule('$createdAt', [
+ 'type' => self::TYPE_DATETIME,
+ 'description' => 'Attribute creation date in ISO 8601 format.',
+ 'default' => '',
+ 'example' => self::TYPE_DATETIME_EXAMPLE,
+ ])
+ ->addRule('$updatedAt', [
+ 'type' => self::TYPE_DATETIME,
+ 'description' => 'Attribute update date in ISO 8601 format.',
+ 'default' => '',
+ 'example' => self::TYPE_DATETIME_EXAMPLE,
+ ]);
;
}
+ public function filter(Document $document): Document
+ {
+ if ($document->isSet('phone')) {
+ $document->setAttribute('number', $document->getAttribute('phone'));
+ $document->removeAttribute('phone');
+ }
+
+ return $document;
+ }
+
/**
* Get Name
*
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Amazon.php b/src/Appwrite/Utopia/Response/Model/OAuth2Amazon.php
new file mode 100644
index 0000000000..f6c935648d
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Amazon.php
@@ -0,0 +1,47 @@
+ 'amazon',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Amazon';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'amzn1.application-oa2-client.87400c00000000000000000000063d5b2';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return '79ffe4000000000000000000000000000000000000000000000000000002de55';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Amazon';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_AMAZON;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php b/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php
new file mode 100644
index 0000000000..075494b8ef
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php
@@ -0,0 +1,103 @@
+ 'apple',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Apple';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'ip.appwrite.app.web';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ // Unused: this model overrides __construct() to expose keyId, teamId
+ // and p8File instead of a single clientSecret field.
+ return '';
+ }
+
+ public function getClientIdFieldName(): string
+ {
+ return 'serviceId';
+ }
+
+ public function getClientIdLabel(): string
+ {
+ return 'service ID';
+ }
+
+ public function __construct()
+ {
+ // Apple's OAuth2 app credential is split into three fields (.p8 file
+ // contents, Key ID, Team ID) instead of a single clientSecret, so the
+ // rules are defined manually rather than delegating to OAuth2Base.
+ $this
+ ->addRule('$id', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'OAuth2 provider ID.',
+ 'default' => '',
+ 'example' => 'apple',
+ ])
+ ->addRule('enabled', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'OAuth2 provider is active and can be used to create sessions.',
+ 'default' => false,
+ 'example' => false,
+ ])
+ ->addRule($this->getClientIdFieldName(), [
+ 'type' => self::TYPE_STRING,
+ 'description' => $this->getClientIdDescription(),
+ 'default' => '',
+ 'example' => $this->getClientIdExample(),
+ ])
+ ->addRule('keyId', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Apple OAuth2 key ID.',
+ 'default' => '',
+ 'example' => 'P4000000N8',
+ ])
+ ->addRule('teamId', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Apple OAuth2 team ID.',
+ 'default' => '',
+ 'example' => 'D4000000R6',
+ ])
+ ->addRule('p8File', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Apple OAuth2 .p8 private key file contents. The secret key wrapped by the PEM markers is 200 characters long.',
+ 'default' => '',
+ 'example' => '-----BEGIN PRIVATE KEY-----MIGTAg...jy2Xbna-----END PRIVATE KEY-----',
+ ]);
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Apple';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_APPLE;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php b/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php
new file mode 100644
index 0000000000..6e83b1b05b
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php
@@ -0,0 +1,59 @@
+ 'auth0',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Auth0';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'OaOkIA000000000000000000005KLSYq';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'zXz0000-00000000000000000000000000000-00000000000000000000PJafnF';
+ }
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->addRule('endpoint', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Auth0 OAuth2 endpoint domain.',
+ 'default' => '',
+ 'example' => 'example.us.auth0.com',
+ ]);
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Auth0';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_AUTH0;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php b/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php
new file mode 100644
index 0000000000..db192ea24b
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php
@@ -0,0 +1,59 @@
+ 'authentik',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Authentik';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'dTKOPa0000000000000000000000000000e7G8hv';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'ntQadq000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Hp5WK';
+ }
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->addRule('endpoint', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Authentik OAuth2 endpoint domain.',
+ 'default' => '',
+ 'example' => 'example.authentik.com',
+ ]);
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Authentik';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_AUTHENTIK;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Autodesk.php b/src/Appwrite/Utopia/Response/Model/OAuth2Autodesk.php
new file mode 100644
index 0000000000..3317f15bec
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Autodesk.php
@@ -0,0 +1,47 @@
+ 'autodesk',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Autodesk';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return '5zw90v00000000000000000000kVYXN7';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return '7I000000000000MW';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Autodesk';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_AUTODESK;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Base.php b/src/Appwrite/Utopia/Response/Model/OAuth2Base.php
new file mode 100644
index 0000000000..058afc0fa1
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Base.php
@@ -0,0 +1,125 @@
+ 'appKey').
+ *
+ * @return string
+ */
+ public function getClientIdFieldName(): string
+ {
+ return 'clientId';
+ }
+
+ /**
+ * Public-facing field name of the client secret. Providers may override
+ * when they use different terminology (e.g. Dropbox -> 'appSecret').
+ *
+ * @return string
+ */
+ public function getClientSecretFieldName(): string
+ {
+ return 'clientSecret';
+ }
+
+ /**
+ * Human-readable label for the client ID, used in the generated rule
+ * description. Providers may override (e.g. Dropbox -> 'app key').
+ *
+ * @return string
+ */
+ public function getClientIdLabel(): string
+ {
+ return 'client ID';
+ }
+
+ /**
+ * Human-readable label for the client secret, used in the generated rule
+ * description. Providers may override (e.g. Dropbox -> 'app secret').
+ *
+ * @return string
+ */
+ public function getClientSecretLabel(): string
+ {
+ return 'client secret';
+ }
+
+ /**
+ * Rule description for the client ID. Auto-generated from the provider
+ * label and client ID label. Providers may override to add extra context.
+ *
+ * @return string
+ */
+ public function getClientIdDescription(): string
+ {
+ return $this->getProviderLabel() . ' OAuth2 ' . $this->getClientIdLabel() . '.';
+ }
+
+ /**
+ * Rule description for the client secret. Auto-generated from the provider
+ * label and client secret label. Providers may override to add extra
+ * context.
+ *
+ * @return string
+ */
+ public function getClientSecretDescription(): string
+ {
+ return $this->getProviderLabel() . ' OAuth2 ' . $this->getClientSecretLabel() . '.';
+ }
+
+ public function __construct()
+ {
+ $this
+ ->addRule('$id', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'OAuth2 provider ID.',
+ 'default' => '',
+ 'example' => 'github',
+ ])
+ ->addRule('enabled', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'OAuth2 provider is active and can be used to create sessions.',
+ 'default' => false,
+ 'example' => false,
+ ])
+ ->addRule($this->getClientIdFieldName(), [
+ 'type' => self::TYPE_STRING,
+ 'description' => $this->getClientIdDescription(),
+ 'default' => '',
+ 'example' => $this->getClientIdExample(),
+ ])
+ ->addRule($this->getClientSecretFieldName(), [
+ 'type' => self::TYPE_STRING,
+ 'description' => $this->getClientSecretDescription(),
+ 'default' => '',
+ 'example' => $this->getClientSecretExample(),
+ ]);
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Bitbucket.php b/src/Appwrite/Utopia/Response/Model/OAuth2Bitbucket.php
new file mode 100644
index 0000000000..870cd0bda3
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Bitbucket.php
@@ -0,0 +1,67 @@
+ 'bitbucket',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Bitbucket';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'Knt70000000000ByRc';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'NMfLZJ00000000000000000000TLQdDx';
+ }
+
+ public function getClientIdFieldName(): string
+ {
+ return 'key';
+ }
+
+ public function getClientSecretFieldName(): string
+ {
+ return 'secret';
+ }
+
+ public function getClientIdLabel(): string
+ {
+ return 'key';
+ }
+
+ public function getClientSecretLabel(): string
+ {
+ return 'secret';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Bitbucket';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_BITBUCKET;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Bitly.php b/src/Appwrite/Utopia/Response/Model/OAuth2Bitly.php
new file mode 100644
index 0000000000..6a27176d3d
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Bitly.php
@@ -0,0 +1,47 @@
+ 'bitly',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Bitly';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'd95151000000000000000000000000000067af9b';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'a13e250000000000000000000000000000d73095';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Bitly';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_BITLY;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Box.php b/src/Appwrite/Utopia/Response/Model/OAuth2Box.php
new file mode 100644
index 0000000000..9bbfd6021f
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Box.php
@@ -0,0 +1,47 @@
+ 'box',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Box';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'deglcs00000000000000000000x2og6y';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'OKM1f100000000000000000000eshEif';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Box';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_BOX;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Dailymotion.php b/src/Appwrite/Utopia/Response/Model/OAuth2Dailymotion.php
new file mode 100644
index 0000000000..6c3d0eba95
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Dailymotion.php
@@ -0,0 +1,67 @@
+ 'dailymotion',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Dailymotion';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return '07a9000000000000067f';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'a399a90000000000000000000000000000d90639';
+ }
+
+ public function getClientIdFieldName(): string
+ {
+ return 'apiKey';
+ }
+
+ public function getClientSecretFieldName(): string
+ {
+ return 'apiSecret';
+ }
+
+ public function getClientIdLabel(): string
+ {
+ return 'API key';
+ }
+
+ public function getClientSecretLabel(): string
+ {
+ return 'API secret';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Dailymotion';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_DAILYMOTION;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php b/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php
new file mode 100644
index 0000000000..6ac72ad8e4
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php
@@ -0,0 +1,47 @@
+ 'discord',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Discord';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return '950722000000343754';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'YmPXnM000000000000000000002zFg5D';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Discord';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_DISCORD;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Disqus.php b/src/Appwrite/Utopia/Response/Model/OAuth2Disqus.php
new file mode 100644
index 0000000000..bec78ed189
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Disqus.php
@@ -0,0 +1,67 @@
+ 'disqus',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Disqus';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'cgegH70000000000000000000000000000000000000000000000000000Hr1nYX';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'W7Bykj00000000000000000000000000000000000000000000000000003o43w9';
+ }
+
+ public function getClientIdFieldName(): string
+ {
+ return 'publicKey';
+ }
+
+ public function getClientSecretFieldName(): string
+ {
+ return 'secretKey';
+ }
+
+ public function getClientIdLabel(): string
+ {
+ return 'public key';
+ }
+
+ public function getClientSecretLabel(): string
+ {
+ return 'secret key';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Disqus';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_DISQUS;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php b/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php
new file mode 100644
index 0000000000..db7285fd47
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php
@@ -0,0 +1,67 @@
+ 'dropbox',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Dropbox';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'jl000000000009t';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'g200000000000vw';
+ }
+
+ public function getClientIdFieldName(): string
+ {
+ return 'appKey';
+ }
+
+ public function getClientSecretFieldName(): string
+ {
+ return 'appSecret';
+ }
+
+ public function getClientIdLabel(): string
+ {
+ return 'app key';
+ }
+
+ public function getClientSecretLabel(): string
+ {
+ return 'app secret';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Dropbox';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_DROPBOX;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Etsy.php b/src/Appwrite/Utopia/Response/Model/OAuth2Etsy.php
new file mode 100644
index 0000000000..be12e4c51c
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Etsy.php
@@ -0,0 +1,67 @@
+ 'etsy',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Etsy';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'nsgzxh0000000000008j85a2';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'tp000000ru';
+ }
+
+ public function getClientIdFieldName(): string
+ {
+ return 'keyString';
+ }
+
+ public function getClientSecretFieldName(): string
+ {
+ return 'sharedSecret';
+ }
+
+ public function getClientIdLabel(): string
+ {
+ return 'keystring';
+ }
+
+ public function getClientSecretLabel(): string
+ {
+ return 'shared secret';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Etsy';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_ETSY;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Facebook.php b/src/Appwrite/Utopia/Response/Model/OAuth2Facebook.php
new file mode 100644
index 0000000000..9ad14bdb2a
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Facebook.php
@@ -0,0 +1,67 @@
+ 'facebook',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Facebook';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return '260600000007694';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return '2d0b2800000000000000000000d38af4';
+ }
+
+ public function getClientIdFieldName(): string
+ {
+ return 'appId';
+ }
+
+ public function getClientSecretFieldName(): string
+ {
+ return 'appSecret';
+ }
+
+ public function getClientIdLabel(): string
+ {
+ return 'app ID';
+ }
+
+ public function getClientSecretLabel(): string
+ {
+ return 'app secret';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Facebook';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_FACEBOOK;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php b/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php
new file mode 100644
index 0000000000..9339257e5b
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php
@@ -0,0 +1,47 @@
+ 'figma',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Figma';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'byay5H0000000000VtiI40';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'yEpOYn0000000000000000004iIsU5';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Figma';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_FIGMA;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2FusionAuth.php b/src/Appwrite/Utopia/Response/Model/OAuth2FusionAuth.php
new file mode 100644
index 0000000000..8dbe3c76f0
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2FusionAuth.php
@@ -0,0 +1,59 @@
+ 'fusionauth',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'FusionAuth';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'b2222c00-0000-0000-0000-000000862097';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'Jx4s0C0000000000000000000000000000000wGqLsc';
+ }
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->addRule('endpoint', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'FusionAuth OAuth2 endpoint domain.',
+ 'default' => '',
+ 'example' => 'example.fusionauth.io',
+ ]);
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2FusionAuth';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_FUSIONAUTH;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php b/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php
new file mode 100644
index 0000000000..2f975f16e4
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php
@@ -0,0 +1,52 @@
+ 'github',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'GitHub';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'e4d87900000000540733';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return '5e07c00000000000000000000000000000198bcc';
+ }
+
+ public function getClientIdDescription(): string
+ {
+ return parent::getClientIdDescription() . ' For GitHub Apps, use the "App ID" when both an App ID and client ID are available.';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2GitHub';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_GITHUB;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php b/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php
new file mode 100644
index 0000000000..39c148caec
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php
@@ -0,0 +1,79 @@
+ 'gitlab',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'GitLab';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'd41ffe0000000000000000000000000000000000000000000000000000d5e252';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'gloas-838cfa0000000000000000000000000000000000000000000000000000ecbb38';
+ }
+
+ public function getClientIdFieldName(): string
+ {
+ return 'applicationId';
+ }
+
+ public function getClientSecretFieldName(): string
+ {
+ return 'secret';
+ }
+
+ public function getClientIdLabel(): string
+ {
+ return 'application ID';
+ }
+
+ public function getClientSecretLabel(): string
+ {
+ return 'secret';
+ }
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->addRule('endpoint', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'GitLab OAuth2 endpoint URL. Defaults to https://gitlab.com for self-hosted instances.',
+ 'default' => '',
+ 'example' => 'https://gitlab.com',
+ ]);
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Gitlab';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_GITLAB;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Google.php b/src/Appwrite/Utopia/Response/Model/OAuth2Google.php
new file mode 100644
index 0000000000..ebef9aecf7
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Google.php
@@ -0,0 +1,61 @@
+ 'google',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Google';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return '120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'GOCSPX-2k8gsR0000000000000000VNahJj';
+ }
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->addRule('prompt', [
+ 'type' => self::TYPE_ENUM,
+ 'description' => 'Google OAuth2 prompt values.',
+ 'default' => ['consent'],
+ 'example' => ['consent'],
+ 'array' => true,
+ 'enum' => ['none', 'consent', 'select_account'],
+ ]);
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Google';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_GOOGLE;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Keycloak.php b/src/Appwrite/Utopia/Response/Model/OAuth2Keycloak.php
new file mode 100644
index 0000000000..063f7d2a5c
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Keycloak.php
@@ -0,0 +1,66 @@
+ 'keycloak',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Keycloak';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'appwrite-o0000000st-app';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'jdjrJd00000000000000000000HUsaZO';
+ }
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->addRule('endpoint', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Keycloak OAuth2 endpoint domain.',
+ 'default' => '',
+ 'example' => 'keycloak.example.com',
+ ]);
+
+ $this->addRule('realmName', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Keycloak OAuth2 realm name.',
+ 'default' => '',
+ 'example' => 'appwrite-realm',
+ ]);
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Keycloak';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_KEYCLOAK;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Kick.php b/src/Appwrite/Utopia/Response/Model/OAuth2Kick.php
new file mode 100644
index 0000000000..2f5814f1d3
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Kick.php
@@ -0,0 +1,47 @@
+ 'kick',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Kick';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return '01KQ7C00000000000001MFHS32';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return '34ac5600000000000000000000000000000000000000000000000000e830c8b';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Kick';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_KICK;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Linkedin.php b/src/Appwrite/Utopia/Response/Model/OAuth2Linkedin.php
new file mode 100644
index 0000000000..012aa85735
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Linkedin.php
@@ -0,0 +1,57 @@
+ 'linkedin',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'LinkedIn';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return '770000000000dv';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'WPL_AP1.2Bf0000000000000./HtlYw==';
+ }
+
+ public function getClientSecretFieldName(): string
+ {
+ return 'primaryClientSecret';
+ }
+
+ public function getClientSecretLabel(): string
+ {
+ return 'primary client secret';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Linkedin';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_LINKEDIN;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Microsoft.php b/src/Appwrite/Utopia/Response/Model/OAuth2Microsoft.php
new file mode 100644
index 0000000000..b7004fdb85
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Microsoft.php
@@ -0,0 +1,79 @@
+ 'microsoft',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Microsoft';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return '00001111-aaaa-2222-bbbb-3333cccc4444';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'A1bC2dE3fH4iJ5kL6mN7oP8qR9sT0u';
+ }
+
+ public function getClientIdFieldName(): string
+ {
+ return 'applicationId';
+ }
+
+ public function getClientSecretFieldName(): string
+ {
+ return 'applicationSecret';
+ }
+
+ public function getClientIdLabel(): string
+ {
+ return 'application ID';
+ }
+
+ public function getClientSecretLabel(): string
+ {
+ return 'application secret';
+ }
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->addRule('tenant', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Microsoft Entra ID tenant identifier. Use \'common\', \'organizations\', \'consumers\' or a specific tenant ID.',
+ 'default' => '',
+ 'example' => 'common',
+ ]);
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Microsoft';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_MICROSOFT;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Notion.php b/src/Appwrite/Utopia/Response/Model/OAuth2Notion.php
new file mode 100644
index 0000000000..8796ce603e
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Notion.php
@@ -0,0 +1,57 @@
+ 'notion',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Notion';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return '341d8700-0000-0000-0000-000000446ee3';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'secret_dLUr4b000000000000000000000000000000lFHAa9';
+ }
+
+ public function getClientIdFieldName(): string
+ {
+ return 'oauthClientId';
+ }
+
+ public function getClientSecretFieldName(): string
+ {
+ return 'oauthClientSecret';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Notion';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_NOTION;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php b/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php
new file mode 100644
index 0000000000..0b18539423
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php
@@ -0,0 +1,78 @@
+ 'oidc',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'OpenID Connect';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'qibI2x0000000000000000000000000006L2YFoG';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'Ah68ed000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003qpcHV';
+ }
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this
+ ->addRule('wellKnownURL', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'OpenID Connect well-known configuration URL. When set, authorization, token, and user info endpoints can be discovered automatically.',
+ 'default' => '',
+ 'example' => 'https://myoauth.com/.well-known/openid-configuration',
+ ])
+ ->addRule('authorizationURL', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'OpenID Connect authorization endpoint URL.',
+ 'default' => '',
+ 'example' => 'https://myoauth.com/oauth2/authorize',
+ ])
+ ->addRule('tokenURL', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'OpenID Connect token endpoint URL.',
+ 'default' => '',
+ 'example' => 'https://myoauth.com/oauth2/token',
+ ])
+ ->addRule('userInfoURL', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'OpenID Connect user info endpoint URL.',
+ 'default' => '',
+ 'example' => 'https://myoauth.com/oauth2/userinfo',
+ ]);
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Oidc';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_OIDC;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php b/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php
new file mode 100644
index 0000000000..0804adfa1b
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php
@@ -0,0 +1,66 @@
+ 'okta',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Okta';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return '0oa00000000000000698';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'Kiq0000000000000000000000000000000000000-00000000000H2L5-3SJ-vRV';
+ }
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->addRule('domain', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Okta OAuth2 domain.',
+ 'default' => '',
+ 'example' => 'trial-6400025.okta.com',
+ ]);
+
+ $this->addRule('authorizationServerId', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Okta OAuth2 authorization server ID.',
+ 'default' => '',
+ 'example' => 'aus000000000000000h7z',
+ ]);
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Okta';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_OKTA;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Paypal.php b/src/Appwrite/Utopia/Response/Model/OAuth2Paypal.php
new file mode 100644
index 0000000000..20ff9f9ba5
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Paypal.php
@@ -0,0 +1,57 @@
+ ['paypal', 'paypalSandbox'],
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'PayPal';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'AdhIEG7-000000000000-0000000000000000000000000000000-0000000000000000000000-2pyB';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'EH8KCXtew--000000000000000000000000000000000000000_C-1_5UP_000000000000000CB7KDp';
+ }
+
+ public function getClientSecretFieldName(): string
+ {
+ return 'secretKey';
+ }
+
+ public function getClientSecretLabel(): string
+ {
+ return 'secret key';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Paypal';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_PAYPAL;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Podio.php b/src/Appwrite/Utopia/Response/Model/OAuth2Podio.php
new file mode 100644
index 0000000000..f588136a62
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Podio.php
@@ -0,0 +1,47 @@
+ 'podio',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Podio';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'appwrite-oauth-test-app';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'Rn247T0000000000000000000000000000000000000000000000000000W2zWTN';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Podio';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_PODIO;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php b/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php
new file mode 100644
index 0000000000..81c23c803c
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php
@@ -0,0 +1,78 @@
+addRule('total', [
+ 'type' => self::TYPE_INTEGER,
+ 'description' => 'Total number of OAuth2 providers in the given project.',
+ 'default' => 0,
+ 'example' => 5,
+ ])
+ ->addRule('providers', [
+ 'type' => [
+ Response::MODEL_OAUTH2_GITHUB,
+ Response::MODEL_OAUTH2_DISCORD,
+ Response::MODEL_OAUTH2_FIGMA,
+ Response::MODEL_OAUTH2_DROPBOX,
+ Response::MODEL_OAUTH2_DAILYMOTION,
+ Response::MODEL_OAUTH2_BITBUCKET,
+ Response::MODEL_OAUTH2_BITLY,
+ Response::MODEL_OAUTH2_BOX,
+ Response::MODEL_OAUTH2_AUTODESK,
+ Response::MODEL_OAUTH2_GOOGLE,
+ Response::MODEL_OAUTH2_ZOOM,
+ Response::MODEL_OAUTH2_ZOHO,
+ Response::MODEL_OAUTH2_YANDEX,
+ Response::MODEL_OAUTH2_X,
+ Response::MODEL_OAUTH2_WORDPRESS,
+ Response::MODEL_OAUTH2_TWITCH,
+ Response::MODEL_OAUTH2_STRIPE,
+ Response::MODEL_OAUTH2_SPOTIFY,
+ Response::MODEL_OAUTH2_SLACK,
+ Response::MODEL_OAUTH2_PODIO,
+ Response::MODEL_OAUTH2_NOTION,
+ Response::MODEL_OAUTH2_SALESFORCE,
+ Response::MODEL_OAUTH2_YAHOO,
+ Response::MODEL_OAUTH2_LINKEDIN,
+ Response::MODEL_OAUTH2_DISQUS,
+ Response::MODEL_OAUTH2_AMAZON,
+ Response::MODEL_OAUTH2_ETSY,
+ Response::MODEL_OAUTH2_FACEBOOK,
+ Response::MODEL_OAUTH2_TRADESHIFT,
+ Response::MODEL_OAUTH2_PAYPAL,
+ Response::MODEL_OAUTH2_GITLAB,
+ Response::MODEL_OAUTH2_AUTHENTIK,
+ Response::MODEL_OAUTH2_AUTH0,
+ Response::MODEL_OAUTH2_FUSIONAUTH,
+ Response::MODEL_OAUTH2_KEYCLOAK,
+ Response::MODEL_OAUTH2_OIDC,
+ Response::MODEL_OAUTH2_APPLE,
+ Response::MODEL_OAUTH2_OKTA,
+ Response::MODEL_OAUTH2_KICK,
+ Response::MODEL_OAUTH2_MICROSOFT,
+ ],
+ 'description' => 'List of OAuth2 providers.',
+ 'default' => [],
+ 'array' => true,
+ ])
+ ;
+ }
+
+ public function getName(): string
+ {
+ return 'OAuth2 Providers List';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_PROVIDER_LIST;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Salesforce.php b/src/Appwrite/Utopia/Response/Model/OAuth2Salesforce.php
new file mode 100644
index 0000000000..c76ddce854
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Salesforce.php
@@ -0,0 +1,67 @@
+ 'salesforce',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Salesforce';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return '3MVG9I0000000000000000000000000000000000000000000000000000000000000000000000000C5Aejq';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return '3w000000000000e2';
+ }
+
+ public function getClientIdFieldName(): string
+ {
+ return 'customerKey';
+ }
+
+ public function getClientSecretFieldName(): string
+ {
+ return 'customerSecret';
+ }
+
+ public function getClientIdLabel(): string
+ {
+ return 'consumer key';
+ }
+
+ public function getClientSecretLabel(): string
+ {
+ return 'consumer secret';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Salesforce';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_SALESFORCE;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Slack.php b/src/Appwrite/Utopia/Response/Model/OAuth2Slack.php
new file mode 100644
index 0000000000..47eb058816
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Slack.php
@@ -0,0 +1,47 @@
+ 'slack',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Slack';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return '23000000089.15000000000023';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return '81656000000000000000000000f3d2fd';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Slack';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_SLACK;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Spotify.php b/src/Appwrite/Utopia/Response/Model/OAuth2Spotify.php
new file mode 100644
index 0000000000..3fdf9da659
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Spotify.php
@@ -0,0 +1,47 @@
+ 'spotify',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Spotify';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return '6ec271000000000000000000009beace';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'db068a000000000000000000008b5b9f';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Spotify';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_SPOTIFY;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Stripe.php b/src/Appwrite/Utopia/Response/Model/OAuth2Stripe.php
new file mode 100644
index 0000000000..98c7a88af7
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Stripe.php
@@ -0,0 +1,57 @@
+ 'stripe',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Stripe';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'ca_UKibXX0000000000000000000006byvR';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'sk_51SfOd000000000000000000000000000000000000000000000000000000000000000000000000000000000000000QGWYfp';
+ }
+
+ public function getClientSecretFieldName(): string
+ {
+ return 'apiSecretKey';
+ }
+
+ public function getClientSecretLabel(): string
+ {
+ return 'API secret key';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Stripe';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_STRIPE;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Tradeshift.php b/src/Appwrite/Utopia/Response/Model/OAuth2Tradeshift.php
new file mode 100644
index 0000000000..4d2c37a951
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Tradeshift.php
@@ -0,0 +1,57 @@
+ ['tradeshift', 'tradeshiftBox'],
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Tradeshift';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'appwrite-test-org.appwrite-test-app';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return '7cb52700-0000-0000-0000-000000ca5b83';
+ }
+
+ public function getClientIdFieldName(): string
+ {
+ return 'oauth2ClientId';
+ }
+
+ public function getClientSecretFieldName(): string
+ {
+ return 'oauth2ClientSecret';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Tradeshift';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_TRADESHIFT;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Twitch.php b/src/Appwrite/Utopia/Response/Model/OAuth2Twitch.php
new file mode 100644
index 0000000000..4b03b3d6cc
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Twitch.php
@@ -0,0 +1,47 @@
+ 'twitch',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Twitch';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'vvi0in000000000000000000ikmt9p';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'pmapue000000000000000000zylw3v';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Twitch';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_TWITCH;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2WordPress.php b/src/Appwrite/Utopia/Response/Model/OAuth2WordPress.php
new file mode 100644
index 0000000000..89df7a081e
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2WordPress.php
@@ -0,0 +1,47 @@
+ 'wordpress',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'WordPress';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return '130005';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'PlBfJS0000000000000000000000000000000000000000000000000000EdUZJk';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2WordPress';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_WORDPRESS;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2X.php b/src/Appwrite/Utopia/Response/Model/OAuth2X.php
new file mode 100644
index 0000000000..2f36166c19
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2X.php
@@ -0,0 +1,67 @@
+ 'x',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'X';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'slzZV0000000000000NFLaWT';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'tkEPkp00000000000000000000000000000000000000FTxbI9';
+ }
+
+ public function getClientIdFieldName(): string
+ {
+ return 'customerKey';
+ }
+
+ public function getClientSecretFieldName(): string
+ {
+ return 'secretKey';
+ }
+
+ public function getClientIdLabel(): string
+ {
+ return 'customer key';
+ }
+
+ public function getClientSecretLabel(): string
+ {
+ return 'secret key';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2X';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_X;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Yahoo.php b/src/Appwrite/Utopia/Response/Model/OAuth2Yahoo.php
new file mode 100644
index 0000000000..0e3bc7b8a6
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Yahoo.php
@@ -0,0 +1,47 @@
+ 'yahoo',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Yahoo';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'dj0yJm000000000000000000000000000000000000000000000000000000000000000000000000000000000000Z4PWRm';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'cf978f0000000000000000000000000000c5e2e9';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Yahoo';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_YAHOO;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Yandex.php b/src/Appwrite/Utopia/Response/Model/OAuth2Yandex.php
new file mode 100644
index 0000000000..dd6b8a4486
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Yandex.php
@@ -0,0 +1,47 @@
+ 'yandex',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Yandex';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return '6a8a6a0000000000000000000091483c';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'bbf98500000000000000000000c75a63';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Yandex';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_YANDEX;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Zoho.php b/src/Appwrite/Utopia/Response/Model/OAuth2Zoho.php
new file mode 100644
index 0000000000..abf9e98d9a
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Zoho.php
@@ -0,0 +1,47 @@
+ 'zoho',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Zoho';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return '1000.83C178000000000000000000RPNX0B';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'fb5cac000000000000000000000000000000a68f6e';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Zoho';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_ZOHO;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Zoom.php b/src/Appwrite/Utopia/Response/Model/OAuth2Zoom.php
new file mode 100644
index 0000000000..d14fe6d0cf
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Zoom.php
@@ -0,0 +1,47 @@
+ 'zoom',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Zoom';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return 'QMAC00000000000000w0AQ';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'GAWsG4000000000000000000007U01ON';
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'OAuth2Zoom';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_OAUTH2_ZOOM;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/PolicyBase.php b/src/Appwrite/Utopia/Response/Model/PolicyBase.php
new file mode 100644
index 0000000000..04a44d9ffd
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/PolicyBase.php
@@ -0,0 +1,19 @@
+addRule('$id', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Policy ID.',
+ 'default' => '',
+ 'example' => 'password-dictionary',
+ ]);
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/PolicyList.php b/src/Appwrite/Utopia/Response/Model/PolicyList.php
new file mode 100644
index 0000000000..09548fedcf
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/PolicyList.php
@@ -0,0 +1,46 @@
+addRule('total', [
+ 'type' => self::TYPE_INTEGER,
+ 'description' => 'Total number of policies in the given project.',
+ 'default' => 0,
+ 'example' => 9,
+ ])
+ ->addRule('policies', [
+ 'type' => [
+ Response::MODEL_POLICY_PASSWORD_DICTIONARY,
+ Response::MODEL_POLICY_PASSWORD_HISTORY,
+ Response::MODEL_POLICY_PASSWORD_PERSONAL_DATA,
+ Response::MODEL_POLICY_SESSION_ALERT,
+ Response::MODEL_POLICY_SESSION_DURATION,
+ Response::MODEL_POLICY_SESSION_INVALIDATION,
+ Response::MODEL_POLICY_SESSION_LIMIT,
+ Response::MODEL_POLICY_USER_LIMIT,
+ Response::MODEL_POLICY_MEMBERSHIP_PRIVACY,
+ ],
+ 'description' => 'List of policies.',
+ 'default' => [],
+ 'array' => true,
+ ]);
+ }
+
+ public function getName(): string
+ {
+ return 'Policies List';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_POLICY_LIST;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/PolicyMembershipPrivacy.php b/src/Appwrite/Utopia/Response/Model/PolicyMembershipPrivacy.php
new file mode 100644
index 0000000000..fe2851d35b
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/PolicyMembershipPrivacy.php
@@ -0,0 +1,59 @@
+ 'membership-privacy',
+ ];
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this
+ ->addRule('userId', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'Whether user ID is visible in memberships.',
+ 'default' => false,
+ 'example' => true,
+ ])
+ ->addRule('userEmail', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'Whether user email is visible in memberships.',
+ 'default' => false,
+ 'example' => true,
+ ])
+ ->addRule('userPhone', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'Whether user phone is visible in memberships.',
+ 'default' => false,
+ 'example' => true,
+ ])
+ ->addRule('userName', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'Whether user name is visible in memberships.',
+ 'default' => false,
+ 'example' => true,
+ ])
+ ->addRule('userMFA', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'Whether user MFA status is visible in memberships.',
+ 'default' => false,
+ 'example' => true,
+ ]);
+ }
+
+ public function getName(): string
+ {
+ return 'Policy Membership Privacy';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_POLICY_MEMBERSHIP_PRIVACY;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/PolicyPasswordDictionary.php b/src/Appwrite/Utopia/Response/Model/PolicyPasswordDictionary.php
new file mode 100644
index 0000000000..78cd284332
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/PolicyPasswordDictionary.php
@@ -0,0 +1,34 @@
+ 'password-dictionary',
+ ];
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->addRule('enabled', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'Whether password dictionary policy is enabled.',
+ 'default' => false,
+ 'example' => true,
+ ]);
+ }
+
+ public function getName(): string
+ {
+ return 'Policy Password Dictionary';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_POLICY_PASSWORD_DICTIONARY;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/PolicyPasswordHistory.php b/src/Appwrite/Utopia/Response/Model/PolicyPasswordHistory.php
new file mode 100644
index 0000000000..a9b5951db6
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/PolicyPasswordHistory.php
@@ -0,0 +1,34 @@
+ 'password-history',
+ ];
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->addRule('total', [
+ 'type' => self::TYPE_INTEGER,
+ 'description' => 'Password history length. A value of 0 means the policy is disabled.',
+ 'default' => 0,
+ 'example' => 5,
+ ]);
+ }
+
+ public function getName(): string
+ {
+ return 'Policy Password History';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_POLICY_PASSWORD_HISTORY;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/PolicyPasswordPersonalData.php b/src/Appwrite/Utopia/Response/Model/PolicyPasswordPersonalData.php
new file mode 100644
index 0000000000..feffd95f1b
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/PolicyPasswordPersonalData.php
@@ -0,0 +1,34 @@
+ 'password-personal-data',
+ ];
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->addRule('enabled', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'Whether password personal data policy is enabled.',
+ 'default' => false,
+ 'example' => true,
+ ]);
+ }
+
+ public function getName(): string
+ {
+ return 'Policy Password Personal Data';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_POLICY_PASSWORD_PERSONAL_DATA;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/PolicySessionAlert.php b/src/Appwrite/Utopia/Response/Model/PolicySessionAlert.php
new file mode 100644
index 0000000000..4f1a66c65c
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/PolicySessionAlert.php
@@ -0,0 +1,34 @@
+ 'session-alert',
+ ];
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->addRule('enabled', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'Whether session alert policy is enabled.',
+ 'default' => false,
+ 'example' => true,
+ ]);
+ }
+
+ public function getName(): string
+ {
+ return 'Policy Session Alert';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_POLICY_SESSION_ALERT;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/PolicySessionDuration.php b/src/Appwrite/Utopia/Response/Model/PolicySessionDuration.php
new file mode 100644
index 0000000000..1242802c42
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/PolicySessionDuration.php
@@ -0,0 +1,34 @@
+ 'session-duration',
+ ];
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->addRule('duration', [
+ 'type' => self::TYPE_INTEGER,
+ 'description' => 'Session duration in seconds.',
+ 'default' => TOKEN_EXPIRATION_LOGIN_LONG,
+ 'example' => 3600,
+ ]);
+ }
+
+ public function getName(): string
+ {
+ return 'Policy Session Duration';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_POLICY_SESSION_DURATION;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/PolicySessionInvalidation.php b/src/Appwrite/Utopia/Response/Model/PolicySessionInvalidation.php
new file mode 100644
index 0000000000..12cbe10851
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/PolicySessionInvalidation.php
@@ -0,0 +1,34 @@
+ 'session-invalidation',
+ ];
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->addRule('enabled', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'Whether session invalidation policy is enabled.',
+ 'default' => true,
+ 'example' => true,
+ ]);
+ }
+
+ public function getName(): string
+ {
+ return 'Policy Session Invalidation';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_POLICY_SESSION_INVALIDATION;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/PolicySessionLimit.php b/src/Appwrite/Utopia/Response/Model/PolicySessionLimit.php
new file mode 100644
index 0000000000..2f187ef1f9
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/PolicySessionLimit.php
@@ -0,0 +1,34 @@
+ 'session-limit',
+ ];
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->addRule('total', [
+ 'type' => self::TYPE_INTEGER,
+ 'description' => 'Maximum number of sessions allowed per user. A value of 0 means the policy is disabled.',
+ 'default' => 0,
+ 'example' => 10,
+ ]);
+ }
+
+ public function getName(): string
+ {
+ return 'Policy Session Limit';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_POLICY_SESSION_LIMIT;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/PolicyUserLimit.php b/src/Appwrite/Utopia/Response/Model/PolicyUserLimit.php
new file mode 100644
index 0000000000..0ae80445ea
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/PolicyUserLimit.php
@@ -0,0 +1,34 @@
+ 'user-limit',
+ ];
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->addRule('total', [
+ 'type' => self::TYPE_INTEGER,
+ 'description' => 'Maximum number of users allowed in the project. A value of 0 means the policy is disabled.',
+ 'default' => 0,
+ 'example' => 100,
+ ]);
+ }
+
+ public function getName(): string
+ {
+ return 'Policy User Limit';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_POLICY_USER_LIMIT;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/Presence.php b/src/Appwrite/Utopia/Response/Model/Presence.php
new file mode 100644
index 0000000000..ecc3755bae
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/Presence.php
@@ -0,0 +1,103 @@
+setAdditionalPropertiesKey('metadata');
+
+ $this
+ ->addRule('$id', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Presence ID.',
+ 'default' => '',
+ 'example' => '5e5ea5c16897e',
+ ])
+ ->addRule('$createdAt', [
+ 'type' => self::TYPE_DATETIME,
+ 'description' => 'Presence creation date in ISO 8601 format.',
+ 'default' => '',
+ 'example' => self::TYPE_DATETIME_EXAMPLE,
+ ])
+ ->addRule('$updatedAt', [
+ 'type' => self::TYPE_DATETIME,
+ 'description' => 'Presence update date in ISO 8601 format.',
+ 'default' => '',
+ 'example' => self::TYPE_DATETIME_EXAMPLE,
+ ])
+ ->addRule('$permissions', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Presence permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).',
+ 'default' => '',
+ 'example' => ['read("any")'],
+ 'array' => true,
+ ])
+ ->addRule('userId', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'User ID.',
+ 'default' => '',
+ 'example' => '674af8f3e12a5f9ac0be',
+ ])
+ ->addRule('status', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Presence status.',
+ 'required' => false,
+ 'default' => null,
+ 'example' => 'online',
+ ])
+ ->addRule('source', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Presence source.',
+ 'default' => '',
+ 'example' => 'HTTP',
+ ])
+ ->addRule('expiresAt', [
+ 'type' => self::TYPE_DATETIME,
+ 'description' => 'Presence expiry date in ISO 8601 format.',
+ 'required' => false,
+ 'default' => null,
+ 'example' => self::TYPE_DATETIME_EXAMPLE,
+ ]);
+ // User-defined extras flow through Any's generic mapping, surfaced under
+ // the "metadata" key declared via setAdditionalPropertiesKey() above.
+ }
+
+ public function filter(DatabaseDocument $document): DatabaseDocument
+ {
+ $document->removeAttribute('$collection');
+ $document->removeAttribute('$tenant');
+ $document->removeAttribute('hostname');
+ $document->removeAttribute('permissionsHash');
+ $document->removeAttribute('userInternalId');
+
+ foreach ($document->getAttributes() as $attribute) {
+ if (\is_array($attribute)) {
+ foreach ($attribute as $subAttribute) {
+ if ($subAttribute instanceof DatabaseDocument) {
+ $this->filter($subAttribute);
+ }
+ }
+ } elseif ($attribute instanceof DatabaseDocument) {
+ $this->filter($attribute);
+ }
+ }
+
+ return $document;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php
index 8bf2637d7e..286edc935e 100644
--- a/src/Appwrite/Utopia/Response/Model/Project.php
+++ b/src/Appwrite/Utopia/Response/Model/Project.php
@@ -12,6 +12,7 @@ class Project extends Model
public function __construct()
{
$this
+ // Basic project information
->addRule('$id', [
'type' => self::TYPE_STRING,
'description' => 'Project ID.',
@@ -36,12 +37,6 @@ class Project extends Model
'default' => '',
'example' => 'New Project',
])
- ->addRule('description', [
- 'type' => self::TYPE_STRING,
- 'description' => 'Project description.',
- 'default' => '',
- 'example' => 'This is a new project.',
- ])
->addRule('teamId', [
'type' => self::TYPE_STRING,
'description' => 'Project team ID.',
@@ -251,13 +246,17 @@ class Project extends Model
'example' => new \stdClass(),
'array' => true,
])
+
+ // Resource: Dev Keys
->addRule('devKeys', [
'type' => Response::MODEL_DEV_KEY,
- 'description' => 'List of dev keys.',
+ 'description' => 'Deprecated since 1.9.5: List of dev keys.',
'default' => [],
'example' => new \stdClass(),
'array' => true,
])
+
+ // Resource: SMTP
->addRule('smtpEnabled', [
'type' => self::TYPE_BOOLEAN,
'description' => 'Status for custom SMTP',
@@ -277,7 +276,13 @@ class Project extends Model
'default' => '',
'example' => 'john@appwrite.io',
])
- ->addRule('smtpReplyTo', [
+ ->addRule('smtpReplyToName', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'SMTP reply to name',
+ 'default' => '',
+ 'example' => 'Support Team',
+ ])
+ ->addRule('smtpReplyToEmail', [
'type' => self::TYPE_STRING,
'description' => 'SMTP reply to email',
'default' => '',
@@ -303,9 +308,9 @@ class Project extends Model
])
->addRule('smtpPassword', [
'type' => self::TYPE_STRING,
- 'description' => 'SMTP server password',
+ 'description' => 'SMTP server password. This property is write-only and always returned empty.',
'default' => '',
- 'example' => 'securepassword',
+ 'example' => '',
])
->addRule('smtpSecure', [
'type' => self::TYPE_STRING,
@@ -313,6 +318,8 @@ class Project extends Model
'default' => '',
'example' => 'tls',
])
+
+ // Resource: Ping
->addRule('pingCount', [
'type' => self::TYPE_INTEGER,
'description' => 'Number of times the ping was received for this project.',
@@ -325,6 +332,8 @@ class Project extends Model
'default' => '',
'example' => self::TYPE_DATETIME_EXAMPLE,
])
+
+ // Resource: Labels
->addRule('labels', [
'type' => self::TYPE_STRING,
'description' => 'Labels for the project.',
@@ -332,32 +341,56 @@ class Project extends Model
'example' => ['vip'],
'array' => true,
])
+
+ // Resource: Billing
->addRule('status', [
'type' => self::TYPE_STRING,
'description' => 'Project status.',
'default' => 'active',
'example' => 'active',
])
+
+ // Resource: Auth methods
+ ->addRule('authMethods', [
+ 'type' => Response::MODEL_PROJECT_AUTH_METHOD,
+ 'description' => 'List of auth methods.',
+ 'default' => [],
+ 'example' => new \stdClass(),
+ 'array' => true,
+ ])
+
+ // Resource: Services
+ ->addRule('services', [
+ 'type' => Response::MODEL_PROJECT_SERVICE,
+ 'description' => 'List of services.',
+ 'default' => [],
+ 'example' => new \stdClass(),
+ 'array' => true,
+ ])
+
+ // Resource: Protocols
+ ->addRule('protocols', [
+ 'type' => Response::MODEL_PROJECT_PROTOCOL,
+ 'description' => 'List of protocols.',
+ 'default' => [],
+ 'example' => new \stdClass(),
+ 'array' => true,
+ ])
;
- $services = Config::getParam('services', []);
- $auth = Config::getParam('auth', []);
-
- foreach ($auth as $index => $method) {
+ foreach (Config::getParam('auth', []) as $method) {
$name = $method['name'] ?? '';
$key = $method['key'] ?? '';
- $this
- ->addRule('auth' . ucfirst($key), [
- 'type' => self::TYPE_BOOLEAN,
- 'description' => $name . ' auth method status',
- 'example' => true,
- 'default' => true,
- ])
- ;
+ $this->addRule('auth' . ucfirst($key), [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => $name . ' auth method status',
+ 'example' => true,
+ 'default' => true,
+ ]);
}
- foreach ($services as $service) {
+ foreach (Config::getParam('services', []) as $service) {
if (!$service['optional']) {
continue;
}
@@ -365,30 +398,24 @@ class Project extends Model
$name = $service['name'] ?? '';
$key = $service['key'] ?? '';
- $this
- ->addRule('serviceStatusFor' . ucfirst($key), [
- 'type' => self::TYPE_BOOLEAN,
- 'description' => $name . ' service status',
- 'example' => true,
- 'default' => true,
- ])
- ;
+ $this->addRule('serviceStatusFor' . ucfirst($key), [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => $name . ' service status',
+ 'example' => true,
+ 'default' => true,
+ ]);
}
- $apis = Config::getParam('protocols', []);
-
- foreach ($apis as $api) {
+ foreach (Config::getParam('protocols', []) as $api) {
$name = $api['name'] ?? '';
$key = $api['key'] ?? '';
- $this
- ->addRule('protocolStatusFor' . ucfirst($key), [
- 'type' => self::TYPE_BOOLEAN,
- 'description' => $name . ' protocol status',
- 'example' => true,
- 'default' => true,
- ])
- ;
+ $this->addRule('protocolStatusFor' . ucfirst($key), [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => $name . ' protocol status',
+ 'example' => true,
+ 'default' => true,
+ ]);
}
}
@@ -424,6 +451,9 @@ class Project extends Model
$this->expandApiFields($document);
$this->expandAuthFields($document);
$this->expandOAuthProviders($document);
+ $this->expandServices($document);
+ $this->expandProtocols($document);
+ $this->expandAuthMethods($document);
return $document;
}
@@ -434,16 +464,17 @@ class Project extends Model
return;
}
- // SMTP
$smtp = $document->getAttribute('smtp', []);
+
$document->setAttribute('smtpEnabled', $smtp['enabled'] ?? false);
$document->setAttribute('smtpSenderEmail', $smtp['senderEmail'] ?? '');
$document->setAttribute('smtpSenderName', $smtp['senderName'] ?? '');
- $document->setAttribute('smtpReplyTo', $smtp['replyTo'] ?? '');
+ $document->setAttribute('smtpReplyToEmail', $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''); // Includes backwards compatibility
+ $document->setAttribute('smtpReplyToName', $smtp['replyToName'] ?? '');
$document->setAttribute('smtpHost', $smtp['host'] ?? '');
$document->setAttribute('smtpPort', $smtp['port'] ?? '');
$document->setAttribute('smtpUsername', $smtp['username'] ?? '');
- $document->setAttribute('smtpPassword', $smtp['password'] ?? '');
+ $document->setAttribute('smtpPassword', ''); // Write-only: never expose the stored value
$document->setAttribute('smtpSecure', $smtp['secure'] ?? '');
}
@@ -454,12 +485,12 @@ class Project extends Model
}
$values = $document->getAttribute('services', []);
- $services = Config::getParam('services', []);
- foreach ($services as $service) {
+ foreach (Config::getParam('services', []) as $service) {
if (!$service['optional']) {
continue;
}
+
$key = $service['key'] ?? '';
$value = $values[$key] ?? true;
$document->setAttribute('serviceStatusFor' . ucfirst($key), $value);
@@ -473,9 +504,8 @@ class Project extends Model
}
$values = $document->getAttribute('apis', []);
- $apis = Config::getParam('protocols', []);
- foreach ($apis as $api) {
+ foreach (Config::getParam('protocols', []) as $api) {
$key = $api['key'] ?? '';
$value = $values[$key] ?? true;
$document->setAttribute('protocolStatusFor' . ucfirst($key), $value);
@@ -490,7 +520,6 @@ class Project extends Model
$authValues = $document->getAttribute('auths', []);
$passwordPolicy = $authValues['passwordPolicy'] ?? [];
- $auth = Config::getParam('auth', []);
$document->setAttribute('authLimit', $authValues['limit'] ?? 0);
$document->setAttribute('authDuration', $authValues['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG);
@@ -513,7 +542,7 @@ class Project extends Model
$document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? true);
$document->setAttribute('authInvalidateSessions', $authValues['invalidateSessions'] ?? false);
- foreach ($auth as $method) {
+ foreach (Config::getParam('auth', []) as $method) {
$key = $method['key'];
$value = $authValues[$key] ?? true;
$document->setAttribute('auth' . ucfirst($key), $value);
@@ -532,7 +561,6 @@ class Project extends Model
foreach ($providers as $key => $provider) {
if (!$provider['enabled']) {
- // Disabled by Appwrite configuration, exclude from response
continue;
}
@@ -547,4 +575,53 @@ class Project extends Model
$document->setAttribute('oAuthProviders', $projectProviders);
}
+
+ private function expandServices(Document $document): void
+ {
+ $values = $document->getAttribute('services', []);
+ $services = [];
+
+ foreach (Config::getParam('services', []) as $id => $service) {
+ if (!$service['optional']) {
+ continue;
+ }
+
+ $services[] = new Document([
+ '$id' => $id,
+ 'enabled' => $values[$service['key']] ?? true,
+ ]);
+ }
+
+ $document->setAttribute('services', $services);
+ }
+
+ private function expandProtocols(Document $document): void
+ {
+ $values = $document->getAttribute('apis', []);
+ $protocols = [];
+
+ foreach (Config::getParam('protocols', []) as $id => $api) {
+ $protocols[] = new Document([
+ '$id' => $id,
+ 'enabled' => $values[$api['key']] ?? true,
+ ]);
+ }
+
+ $document->setAttribute('protocols', $protocols);
+ }
+
+ private function expandAuthMethods(Document $document): void
+ {
+ $values = $document->getAttribute('auths', []);
+ $authMethods = [];
+
+ foreach (Config::getParam('auth', []) as $id => $method) {
+ $authMethods[] = new Document([
+ '$id' => $id,
+ 'enabled' => $values[$method['key']] ?? true
+ ]);
+ }
+
+ $document->setAttribute('authMethods', $authMethods);
+ }
}
diff --git a/src/Appwrite/Utopia/Response/Model/ProjectAuthMethod.php b/src/Appwrite/Utopia/Response/Model/ProjectAuthMethod.php
new file mode 100644
index 0000000000..cb4a7dc93f
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/ProjectAuthMethod.php
@@ -0,0 +1,50 @@
+addRule('$id', [
+ 'type' => self::TYPE_ENUM,
+ 'description' => 'Auth method ID.',
+ 'default' => '',
+ 'example' => 'email-password',
+ 'enum' => \array_keys(Config::getParam('auth', [])),
+ 'enumSDKName' => 'ProjectAuthMethodId',
+ ])
+ ->addRule('enabled', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'Auth method status.',
+ 'example' => false,
+ 'default' => true,
+ ])
+ ;
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'ProjectAuthMethod';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_PROJECT_AUTH_METHOD;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/ProjectProtocol.php b/src/Appwrite/Utopia/Response/Model/ProjectProtocol.php
new file mode 100644
index 0000000000..918b1bc630
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/ProjectProtocol.php
@@ -0,0 +1,50 @@
+addRule('$id', [
+ 'type' => self::TYPE_ENUM,
+ 'description' => 'Protocol ID.',
+ 'default' => '',
+ 'example' => 'graphql',
+ 'enum' => \array_keys(Config::getParam('protocols', [])),
+ 'enumSDKName' => 'ProjectProtocolId',
+ ])
+ ->addRule('enabled', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'Protocol status.',
+ 'example' => false,
+ 'default' => true,
+ ])
+ ;
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'ProjectProtocol';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_PROJECT_PROTOCOL;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/ProjectService.php b/src/Appwrite/Utopia/Response/Model/ProjectService.php
new file mode 100644
index 0000000000..2e76dcfbe7
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/ProjectService.php
@@ -0,0 +1,50 @@
+addRule('$id', [
+ 'type' => self::TYPE_ENUM,
+ 'description' => 'Service ID.',
+ 'default' => '',
+ 'example' => 'sites',
+ 'enum' => \array_keys(\array_filter(Config::getParam('services', []), fn ($element) => $element['optional'])),
+ 'enumSDKName' => 'ProjectServiceId',
+ ])
+ ->addRule('enabled', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'Service status.',
+ 'example' => false,
+ 'default' => true,
+ ])
+ ;
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'ProjectService';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_PROJECT_SERVICE;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php
new file mode 100644
index 0000000000..d1982e2f84
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php
@@ -0,0 +1,29 @@
+ 'framework',
+ ];
+
+ public function __construct()
+ {
+ parent::__construct(
+ 'Framework Provider Repositories List',
+ Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST,
+ 'frameworkProviderRepositories',
+ Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK
+ );
+
+ $this->addRule('type', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Provider repository list type.',
+ 'default' => 'framework',
+ 'example' => 'framework',
+ ]);
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php
new file mode 100644
index 0000000000..f7ef1d7b5f
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php
@@ -0,0 +1,29 @@
+ 'runtime',
+ ];
+
+ public function __construct()
+ {
+ parent::__construct(
+ 'Runtime Provider Repositories List',
+ Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST,
+ 'runtimeProviderRepositories',
+ Response::MODEL_PROVIDER_REPOSITORY_RUNTIME
+ );
+
+ $this->addRule('type', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Provider repository list type.',
+ 'default' => 'runtime',
+ 'example' => 'runtime',
+ ]);
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/Report.php b/src/Appwrite/Utopia/Response/Model/Report.php
new file mode 100644
index 0000000000..0c5baf9cdd
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/Report.php
@@ -0,0 +1,99 @@
+addRule('$id', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Report ID.',
+ 'default' => '',
+ 'example' => '5e5ea5c16897e',
+ ])
+ ->addRule('$createdAt', [
+ 'type' => self::TYPE_DATETIME,
+ 'description' => 'Report creation date in ISO 8601 format.',
+ 'default' => '',
+ 'example' => self::TYPE_DATETIME_EXAMPLE,
+ ])
+ ->addRule('$updatedAt', [
+ 'type' => self::TYPE_DATETIME,
+ 'description' => 'Report update date in ISO 8601 format.',
+ 'default' => '',
+ 'example' => self::TYPE_DATETIME_EXAMPLE,
+ ])
+ ->addRule('appId', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'ID of the third-party app that submitted the report.',
+ 'default' => '',
+ 'example' => '5e5ea5c16897e',
+ ])
+ ->addRule('type', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Analyzer that produced this report. e.g. lighthouse, audit, databaseAnalyzer.',
+ 'default' => '',
+ 'example' => 'lighthouse',
+ ])
+ ->addRule('title', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Short, human-readable title for the report.',
+ 'default' => '',
+ 'example' => 'Lighthouse audit for https://appwrite.io/',
+ ])
+ ->addRule('summary', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Markdown summary describing the report.',
+ 'default' => '',
+ 'example' => 'Performance score 78. 4 opportunities found.',
+ ])
+ ->addRule('targetType', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Plural noun describing what the report analyzes, e.g. databases, sites, urls.',
+ 'default' => '',
+ 'example' => 'urls',
+ ])
+ ->addRule('target', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Free-form target identifier (URL for lighthouse, resource ID for db).',
+ 'default' => '',
+ 'example' => 'https://appwrite.io/',
+ ])
+ ->addRule('categories', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Categories covered by the report, e.g. performance, accessibility.',
+ 'default' => [],
+ 'example' => ['performance', 'accessibility'],
+ 'array' => true,
+ ])
+ ->addRule('insights', [
+ 'type' => Response::MODEL_INSIGHT,
+ 'description' => 'Insights nested under this report.',
+ 'default' => [],
+ 'example' => [],
+ 'array' => true,
+ ])
+ ->addRule('analyzedAt', [
+ 'type' => self::TYPE_DATETIME,
+ 'description' => 'Time the report was analyzed in ISO 8601 format.',
+ 'default' => null,
+ 'example' => self::TYPE_DATETIME_EXAMPLE,
+ 'required' => false,
+ ]);
+ }
+
+ public function getName(): string
+ {
+ return 'Report';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_REPORT;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/Rule.php b/src/Appwrite/Utopia/Response/Model/Rule.php
index 1ff854e7ce..d5ea9ee0b7 100644
--- a/src/Appwrite/Utopia/Response/Model/Rule.php
+++ b/src/Appwrite/Utopia/Response/Model/Rule.php
@@ -74,7 +74,7 @@ class Rule extends Model
])
->addRule('deploymentResourceId', [
'type' => self::TYPE_STRING,
- 'description' => 'ID deployment\'s resource. Used if type is "deployment"',
+ 'description' => 'ID of deployment\'s resource (site or function ID). Used if type is "deployment"',
'default' => '',
'example' => 'n3u9feiwmf',
])
@@ -86,10 +86,10 @@ class Rule extends Model
])
->addRule('status', [
'type' => self::TYPE_ENUM,
- 'description' => 'Domain verification status. Possible values are "created", "verifying", "verified" and "unverified"',
- 'default' => 'created',
+ 'description' => 'Domain verification status. Possible values are "unverified", "verifying", "verified"',
+ 'default' => 'unverified',
'example' => 'verified',
- 'enum' => ['created', 'verifying', 'verified', 'unverified'],
+ 'enum' => ['unverified', 'verifying', 'verified'],
])
->addRule('logs', [
'type' => self::TYPE_STRING,
diff --git a/src/Appwrite/Utopia/Response/Model/Table.php b/src/Appwrite/Utopia/Response/Model/Table.php
index 20cd3ccca2..f9f2804fe5 100644
--- a/src/Appwrite/Utopia/Response/Model/Table.php
+++ b/src/Appwrite/Utopia/Response/Model/Table.php
@@ -63,6 +63,9 @@ class Table extends Model
->addRule('columns', [
'type' => [
Response::MODEL_COLUMN_BOOLEAN,
+ // BigInt must come before Integer: response model dispatch is "first match wins",
+ // and Integer matches all int types (including bigint), while BigInt is more specific (size=8).
+ Response::MODEL_COLUMN_BIGINT,
Response::MODEL_COLUMN_INTEGER,
Response::MODEL_COLUMN_FLOAT,
Response::MODEL_COLUMN_EMAIL,
diff --git a/src/Appwrite/Utopia/Response/Model/Template.php b/src/Appwrite/Utopia/Response/Model/Template.php
deleted file mode 100644
index 3ce9cacdb3..0000000000
--- a/src/Appwrite/Utopia/Response/Model/Template.php
+++ /dev/null
@@ -1,32 +0,0 @@
-addRule('type', [
- 'type' => self::TYPE_STRING,
- 'description' => 'Template type',
- 'default' => '',
- 'example' => 'verification',
- ])
- ->addRule('locale', [
- 'type' => self::TYPE_STRING,
- 'description' => 'Template locale',
- 'default' => '',
- 'example' => 'en_us',
- ])
- ->addRule('message', [
- 'type' => self::TYPE_STRING,
- 'description' => 'Template message',
- 'default' => '',
- 'example' => 'Click on the link to verify your account.',
- ])
- ;
- }
-}
diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php
index ecdf89e774..833de90065 100644
--- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php
+++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php
@@ -3,13 +3,31 @@
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
+use Appwrite\Utopia\Response\Model;
-class TemplateEmail extends Template
+class TemplateEmail extends Model
{
public function __construct()
{
- parent::__construct();
$this
+ ->addRule('templateId', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Template type',
+ 'default' => '',
+ 'example' => 'verification',
+ ])
+ ->addRule('locale', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Template locale',
+ 'default' => '',
+ 'example' => 'en_us',
+ ])
+ ->addRule('message', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Template message',
+ 'default' => '',
+ 'example' => 'Click on the link to verify your account.',
+ ])
->addRule('senderName', [
'type' => self::TYPE_STRING,
'description' => 'Name of the sender',
@@ -22,12 +40,18 @@ class TemplateEmail extends Template
'default' => '',
'example' => 'mail@appwrite.io',
])
- ->addRule('replyTo', [
+ ->addRule('replyToEmail', [
'type' => self::TYPE_STRING,
'description' => 'Reply to email address',
'default' => '',
'example' => 'emails@appwrite.io',
])
+ ->addRule('replyToName', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Reply to name',
+ 'default' => '',
+ 'example' => 'Support Team',
+ ])
->addRule('subject', [
'type' => self::TYPE_STRING,
'description' => 'Email subject',
diff --git a/src/Appwrite/Utopia/Response/Model/UsagePresence.php b/src/Appwrite/Utopia/Response/Model/UsagePresence.php
new file mode 100644
index 0000000000..f679d4c00c
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/UsagePresence.php
@@ -0,0 +1,43 @@
+addRule('range', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Time range of the usage stats.',
+ 'default' => '',
+ 'example' => '30d',
+ ])
+ ->addRule('usersOnlineTotal', [
+ 'type' => self::TYPE_INTEGER,
+ 'description' => 'Current total number of online users.',
+ 'default' => 0,
+ 'example' => 0,
+ ])
+ ->addRule('presences', [
+ 'type' => Response::MODEL_METRIC,
+ 'description' => 'Aggregated number of online users per period.',
+ 'default' => [],
+ 'example' => [],
+ 'array' => true,
+ ]);
+ }
+
+ public function getName(): string
+ {
+ return 'UsagePresence';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_USAGE_PRESENCE;
+ }
+}
diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php
index 148b29c1d1..8741ecff6c 100644
--- a/src/Appwrite/Vcs/Comment.php
+++ b/src/Appwrite/Vcs/Comment.php
@@ -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',
@@ -50,6 +50,8 @@ class Comment
protected string $statePrefix = '[appwrite]: #';
+ protected ?string $tip = null;
+
/**
* @var mixed[] $builds
*/
@@ -81,7 +83,14 @@ class Comment
public function generateComment(): string
{
- $json = \json_encode($this->builds);
+ if ($this->tip === null) {
+ $this->tip = $this->tips[\array_rand($this->tips)];
+ }
+
+ $json = \json_encode([
+ 'builds' => $this->builds,
+ 'tip' => $this->tip,
+ ]);
$text = $this->statePrefix . \base64_encode($json) . "\n\n";
@@ -148,6 +157,7 @@ class Comment
'building' => $this->generatImage($pathLight, $pathDark, 'Building', 85) . ' _Building_',
'ready' => $this->generatImage($pathLight, $pathDark, 'Ready', 85) . ' _Ready_',
'failed' => $this->generatImage($pathLight, $pathDark, 'Failed', 85) . ' _Failed_',
+ default => '',
};
if ($site['action']['type'] === 'logs') {
@@ -195,6 +205,7 @@ class Comment
'building' => $this->generatImage($pathLight, $pathDark, 'Building', 85) . ' _Building_',
'ready' => $this->generatImage($pathLight, $pathDark, 'Ready', 85) . ' _Ready_',
'failed' => $this->generatImage($pathLight, $pathDark, 'Failed', 85) . ' _Failed_',
+ default => '',
};
if ($function['action']['type'] === 'logs') {
@@ -224,8 +235,7 @@ class Comment
$i++;
}
- $tip = $this->tips[array_rand($this->tips)];
- $text .= "\n
\n\n> [!TIP]\n> $tip\n\n";
+ $text .= "\n
\n\n> [!TIP]\n> {$this->tip}\n\n";
return $text;
}
@@ -245,13 +255,20 @@ class Comment
public function parseComment(string $comment): self
{
- $state = \explode("\n", $comment)[0] ?? '';
+ $state = \explode("\n", $comment)[0];
$state = substr($state, strlen($this->statePrefix));
$json = \base64_decode($state);
- $builds = \json_decode($json, true);
- $this->builds = \is_array($builds) ? $builds : [];
+ $data = \json_decode($json, true);
+
+ if (\is_array($data) && \array_key_exists('builds', $data)) {
+ $this->builds = \is_array($data['builds']) ? $data['builds'] : [];
+ $this->tip = $data['tip'] ?? null;
+ } else {
+ // Backward compatibility with old state format (builds array only)
+ $this->builds = \is_array($data) ? $data : [];
+ }
return $this;
}
diff --git a/src/Executor/Exception.php b/src/Executor/Exception.php
new file mode 100644
index 0000000000..b799d22567
--- /dev/null
+++ b/src/Executor/Exception.php
@@ -0,0 +1,7 @@
+timeoutSeconds;
+ }
+}
diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php
index f899f06bad..c570970732 100644
--- a/src/Executor/Executor.php
+++ b/src/Executor/Executor.php
@@ -2,9 +2,9 @@
namespace Executor;
-use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Utopia\Fetch\BodyMultipart;
-use Exception;
+use Executor\Exception as ExecutorException;
+use Executor\Exception\Timeout as ExecutorTimeout;
use Utopia\System\System;
class Executor
@@ -104,7 +104,7 @@ class Executor
$status = $response['headers']['status-code'];
if ($status >= 400) {
$message = \is_string($response['body']) ? $response['body'] : $response['body']['message'];
- throw new \Exception($message, $status);
+ throw new ExecutorException($message, $status);
}
return $response['body'];
@@ -163,7 +163,7 @@ class Executor
}
if ($status >= 400) {
- throw new \Exception($message, $status);
+ throw new ExecutorException($message, $status);
}
return $response['body'];
@@ -247,7 +247,7 @@ class Executor
$status = $response['headers']['status-code'];
if ($status >= 400) {
$message = \is_string($response['body']) ? $response['body'] : $response['body']['message'];
- throw new \Exception($message, $status);
+ throw new ExecutorException($message, $status);
}
$headers = $response['body']['headers'] ?? [];
@@ -281,7 +281,7 @@ class Executor
$status = $response['headers']['status-code'];
if ($status >= 400) {
$message = \is_string($response['body']) ? $response['body'] : $response['body']['message'];
- throw new \Exception($message, $status);
+ throw new ExecutorException($message, $status);
}
return $response['body'];
@@ -297,10 +297,10 @@ class Executor
* @param array $params
* @param array $headers
* @param bool $decode
- * @return array|string
+ * @return array
* @throws Exception
*/
- private function call(string $endpoint, string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true, int $timeout = 15, ?callable $callback = null)
+ private function call(string $endpoint, string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true, int $timeout = 15, ?callable $callback = null): array
{
$headers = array_merge($this->headers, $headers);
$ch = curl_init($endpoint . $path . (($method == self::METHOD_GET && !empty($params)) ? '?' . http_build_query($params) : ''));
@@ -378,7 +378,6 @@ class Executor
$responseBody = curl_exec($ch);
if (isset($callback)) {
- curl_close($ch);
return [];
}
@@ -392,7 +391,7 @@ class Executor
$strpos = \is_bool($strpos) ? \strlen($responseType) : $strpos;
switch (substr($responseType, 0, $strpos)) {
case 'multipart/form-data':
- $boundary = \explode('boundary=', $responseHeaders['content-type'] ?? '')[1] ?? '';
+ $boundary = \explode('boundary=', $responseHeaders['content-type'])[1] ?? '';
$multipartResponse = new BodyMultipart($boundary);
$multipartResponse->load(\is_bool($responseBody) ? '' : $responseBody);
@@ -402,7 +401,7 @@ class Executor
$json = json_decode($responseBody, true);
if ($json === null) {
- throw new Exception('Failed to parse response: ' . $responseBody);
+ throw new ExecutorException('Failed to parse response: ' . $responseBody);
}
$responseBody = $json;
@@ -413,13 +412,11 @@ class Executor
if ($curlError) {
if ($curlError == CURLE_OPERATION_TIMEDOUT) {
- throw new AppwriteException(AppwriteException::FUNCTION_SYNCHRONOUS_TIMEOUT);
+ throw new ExecutorTimeout('Executor request timed out', $timeout);
}
- throw new Exception($curlErrorMessage . ' with status code ' . $responseStatus, $responseStatus);
+ throw new ExecutorException($curlErrorMessage . ' with status code ' . $responseStatus, $responseStatus);
}
- curl_close($ch);
-
$responseHeaders['status-code'] = $responseStatus;
return [
diff --git a/src/Utopia/Bus/Bus.php b/src/Utopia/Bus/Bus.php
index bef39f0481..2debb22f98 100644
--- a/src/Utopia/Bus/Bus.php
+++ b/src/Utopia/Bus/Bus.php
@@ -37,14 +37,19 @@ class Bus
foreach ($listeners as $listener) {
$deps = array_map($resolver, $listener->getInjections());
- Span::init('listener.' . $listener::getName());
- Span::add('bus.event', $event::class);
+
+ Span::current()?->add('listener.' . $listener::getName() . '.event', $event::class);
+
try {
($listener->getCallback())($event, ...$deps);
+ Span::current()?->add('listener.' . $listener::getName() . '.success', true);
} catch (\Throwable $e) {
- Span::error($e);
- } finally {
- Span::current()?->finish();
+ Span::current()?->add('listener.' . $listener::getName() . '.success', false);
+ Span::current()?->add('listener.' . $listener::getName() . '.error.code', $e->getCode());
+ Span::current()?->add('listener.' . $listener::getName() . '.error.message', $e->getMessage());
+ Span::current()?->add('listener.' . $listener::getName() . '.error.line', $e->getLine());
+ Span::current()?->add('listener.' . $listener::getName() . '.error.file', $e->getFile());
+ Span::current()?->add('listener.' . $listener::getName() . '.error.trace', $e->getTraceAsString());
}
}
}
diff --git a/tests/benchmarks/bulk-operations/utils.js b/tests/benchmarks/bulk-operations/utils.js
index dc8dcac569..5b8bbc6c67 100644
--- a/tests/benchmarks/bulk-operations/utils.js
+++ b/tests/benchmarks/bulk-operations/utils.js
@@ -197,8 +197,8 @@ const SCOPES = [
"buckets.write",
"functions.read",
"functions.write",
- "execution.read",
- "execution.write",
+ "executions.read",
+ "executions.write",
"targets.read",
"targets.write",
"providers.read",
diff --git a/tests/benchmarks/http-local.sh b/tests/benchmarks/http-local.sh
new file mode 100755
index 0000000000..734c825fda
--- /dev/null
+++ b/tests/benchmarks/http-local.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+export K6_WEB_DASHBOARD="${K6_WEB_DASHBOARD:-true}"
+export K6_WEB_DASHBOARD_HOST="${K6_WEB_DASHBOARD_HOST:-127.0.0.1}"
+export K6_WEB_DASHBOARD_PORT="${K6_WEB_DASHBOARD_PORT:-5665}"
+export K6_WEB_DASHBOARD_EXPORT="${K6_WEB_DASHBOARD_EXPORT:-/tmp/appwrite-k6-report.html}"
+export APPWRITE_ENDPOINT="${APPWRITE_ENDPOINT:-http://localhost/v1}"
+export APPWRITE_WORKER_TIMEOUT_MS="${APPWRITE_WORKER_TIMEOUT_MS:-120000}"
+export APPWRITE_BENCHMARK_SUMMARY_PATH="${APPWRITE_BENCHMARK_SUMMARY_PATH:-/tmp/appwrite-k6-summary.json}"
+
+samples_path="${APPWRITE_BENCHMARK_SAMPLES_PATH:-/tmp/appwrite-k6-samples.json}"
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd "${script_dir}/../.." && pwd)"
+
+exec k6 run --out "json=${samples_path}" "$@" "${repo_root}/tests/benchmarks/http.js"
diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js
index 799c8fb23c..ef42e99663 100644
--- a/tests/benchmarks/http.js
+++ b/tests/benchmarks/http.js
@@ -1,34 +1,628 @@
+/*
+ * Run locally:
+ * Requires k6 and a running Appwrite instance.
+ *
+ * tests/benchmarks/http-local.sh
+ *
+ * Open http://127.0.0.1:5665 while the benchmark is running.
+ */
import http from 'k6/http';
-import { check } from 'k6';
-import { Counter } from 'k6/metrics';
+import { check, group, sleep } from 'k6';
+import encoding from 'k6/encoding';
+import { Counter, Trend } from 'k6/metrics';
-// A simple counter for http requests
-export const requests = new Counter('http_reqs');
+const ENDPOINT = (__ENV.APPWRITE_ENDPOINT || 'http://localhost/v1').replace(/\/+$/, '');
+const CONSOLE_PROJECT = __ENV.APPWRITE_CONSOLE_PROJECT || 'console';
+const REGION = __ENV.APPWRITE_REGION || 'default';
+const REDIRECT_URL = __ENV.APPWRITE_BENCHMARK_REDIRECT_URL || 'http://localhost';
+const PASSWORD = __ENV.APPWRITE_BENCHMARK_PASSWORD || 'Password123!';
+const WORKER_TIMEOUT_MS = Number(__ENV.APPWRITE_WORKER_TIMEOUT_MS || 120000);
+const ITERATIONS = Number(__ENV.APPWRITE_BENCHMARK_ITERATIONS || 1);
+const VUS = Number(__ENV.APPWRITE_BENCHMARK_VUS || 1);
+const SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_SUMMARY_PATH || '/tmp/appwrite-k6-summary.json';
+const PREVIOUS_SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH || '';
+const PREVIOUS_SUMMARY = PREVIOUS_SUMMARY_PATH ? loadPreviousSummary(PREVIOUS_SUMMARY_PATH) : null;
-// you can specify stages of your test (ramp up/down patterns) through the options object
-// target is the number of VUs you are aiming for
+export const httpWaiting = new Trend('appwrite_http_waiting', true);
+export const apiDuration = new Trend('appwrite_api_duration', true);
+export const apiWaiting = new Trend('appwrite_api_waiting', true);
+export const flowFailures = new Counter('appwrite_benchmark_flow_failures');
export const options = {
- stages: [
- { target: 50, duration: '1m' },
- // { target: 15, duration: '1m' },
- // { target: 0, duration: '1m' },
- ],
+ scenarios: {
+ curated_flows: {
+ executor: 'shared-iterations',
+ exec: 'curatedFlows',
+ vus: VUS,
+ iterations: ITERATIONS,
+ maxDuration: __ENV.APPWRITE_BENCHMARK_MAX_DURATION || '30m',
+ },
+ },
thresholds: {
- requests: ['count < 100'],
+ http_req_failed: ['rate<0.05'],
+ appwrite_api_duration: ['p(95)<2000'],
+ appwrite_benchmark_flow_failures: ['count<1'],
},
};
-export default function () {
- const config = {
- headers: {
- 'X-Appwrite-Key': '24356eb021863f81eb7dd77c7750304d0464e141cad6e9a8befa1f7d2b066fde190df3dab1e8d2639dbb82ee848da30501424923f4cd80d887ee40ad77ded62763ee489448523f6e39667f290f9a54b2ab8fad131a0bc985e6c0f760015f7f3411e40626c75646bb19d2bb2f7bf2f63130918220a206758cbc48845fd725a695',
- 'X-Appwrite-Project': '60479fe35d95d'
- }}
+const API_SCOPES = [
+ 'sessions.write',
+ 'users.read',
+ 'users.write',
+ 'teams.read',
+ 'teams.write',
+ 'databases.read',
+ 'databases.write',
+ 'collections.read',
+ 'collections.write',
+ 'tables.read',
+ 'tables.write',
+ 'attributes.read',
+ 'attributes.write',
+ 'columns.read',
+ 'columns.write',
+ 'indexes.read',
+ 'indexes.write',
+ 'documents.read',
+ 'documents.write',
+ 'rows.read',
+ 'rows.write',
+ 'files.read',
+ 'files.write',
+ 'buckets.read',
+ 'buckets.write',
+ 'functions.read',
+ 'functions.write',
+ 'log.read',
+ 'log.write',
+ 'executions.read',
+ 'executions.write',
+ 'locale.read',
+ 'avatars.read',
+ 'rules.read',
+ 'rules.write',
+ 'migrations.read',
+ 'migrations.write',
+ 'vcs.read',
+ 'vcs.write',
+ 'assistant.read',
+ 'tokens.read',
+ 'tokens.write',
+ 'platforms.read',
+ 'platforms.write',
+ 'oauth2.read',
+ 'oauth2.write',
+];
- const resDb = http.get('http://localhost:9501/', config);
+const BASE_PERMISSIONS = [
+ 'read("any")',
+ 'create("any")',
+ 'update("any")',
+ 'delete("any")',
+];
- check(resDb, {
- 'status is 200': (r) => r.status === 200,
+const ITEM_PERMISSIONS = [
+ 'read("any")',
+ 'update("any")',
+ 'delete("any")',
+];
+
+export function setup() {
+ const runId = unique('run');
+ const consoleEmail = __ENV.APPWRITE_ADMIN_EMAIL || `bench-admin-${runId}@example.com`;
+ const consolePassword = __ENV.APPWRITE_ADMIN_PASSWORD || PASSWORD;
+
+ const consoleHeaders = {
+ 'Content-Type': 'application/json',
+ 'X-Appwrite-Project': CONSOLE_PROJECT,
+ };
+
+ const account = rawRequest('POST', '/account', {
+ userId: unique('admin'),
+ email: consoleEmail,
+ password: consolePassword,
+ name: 'Benchmark Admin',
+ }, consoleHeaders, 'setup.account.create');
+
+ if (![201, 409].includes(account.status)) {
+ failResponse(account, 'Unable to create or reuse the benchmark console account');
+ }
+
+ const session = rawRequest('POST', '/account/sessions/email', {
+ email: consoleEmail,
+ password: consolePassword,
+ }, consoleHeaders, 'setup.account.session');
+
+ assertStatus(session, [201], 'console session created');
+
+ const consoleSessionHeaders = {
+ ...consoleHeaders,
+ Cookie: cookieHeader(session),
+ };
+
+ const team = setupApi('POST', '/teams', {
+ teamId: unique('team'),
+ name: `Benchmark Team ${runId}`,
+ }, consoleSessionHeaders, [201], 'setup.teams.create');
+
+ const teamId = team.json('$id');
+ const project = setupApi('POST', '/projects', {
+ projectId: unique('project'),
+ name: `Benchmark Project ${runId}`,
+ teamId,
+ region: REGION,
+ }, consoleSessionHeaders, [201], 'setup.projects.create');
+
+ const projectId = project.json('$id');
+ const key = setupApi('POST', `/projects/${projectId}/keys`, {
+ keyId: unique('key'),
+ name: 'Benchmark API key',
+ scopes: API_SCOPES,
+ }, consoleSessionHeaders, [201], 'setup.projects.keys.create');
+
+ const apiHeaders = {
+ 'Content-Type': 'application/json',
+ 'X-Appwrite-Project': projectId,
+ 'X-Appwrite-Key': key.json('secret'),
+ };
+
+ const platform = setupApi('POST', '/project/platforms/web', {
+ platformId: unique('web'),
+ name: 'Benchmark web',
+ hostname: hostnameFromUrl(REDIRECT_URL),
+ }, apiHeaders, [201, 409], 'setup.project.platforms.web.create');
+
+ const tablesDb = setupTablesDb(apiHeaders);
+
+ return {
+ runId,
+ teamId,
+ projectId,
+ databaseId: tablesDb.databaseId,
+ tableId: tablesDb.tableId,
+ consoleSessionHeaders,
+ apiHeaders,
+ platformStatus: platform.status,
+ };
+}
+
+function setupTablesDb(apiHeaders) {
+ const databaseId = unique('tdb');
+ const tableId = unique('tbl');
+
+ setupApi('POST', '/tablesdb', { databaseId, name: 'Benchmark TablesDB' }, apiHeaders, [201], 'setup.tablesdb.create');
+ setupApi('POST', `/tablesdb/${databaseId}/tables`, {
+ tableId,
+ name: 'Benchmark Table',
+ permissions: BASE_PERMISSIONS,
+ rowSecurity: false,
+ }, apiHeaders, [201], 'setup.tablesdb.tables.create');
+
+ const columns = [
+ ['string', 'title', { size: 128 }],
+ ['integer', 'quantity', { min: 0, max: 100000 }],
+ ['email', 'email', {}],
+ ['boolean', 'active', {}],
+ ];
+
+ for (const [type, key, extra] of columns) {
+ setupApi('POST', `/tablesdb/${databaseId}/tables/${tableId}/columns/${type}`, {
+ key,
+ required: false,
+ array: false,
+ ...extra,
+ }, apiHeaders, [202], `setup.tablesdb.columns.${type}.create`);
+ waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, apiHeaders, 'available', WORKER_TIMEOUT_MS, `setup.tablesdb.columns.${type}.wait`);
+ }
+
+ return { databaseId, tableId };
+}
+
+export function curatedFlows(data) {
+ const ctx = { ...data };
+
+ try {
+ group('account flow', () => accountFlow(ctx));
+ group('tablesdb rows flow', () => tablesDbFlow(ctx));
+ group('storage files and tokens flow', () => storageFlow(ctx));
+ group('functions control-plane flow', () => computeFlow(ctx));
+ } catch (error) {
+ flowFailures.add(1);
+ throw error;
+ }
+}
+
+export function teardown(data) {
+ if (data && data.projectId && data.consoleSessionHeaders) {
+ rawRequest('DELETE', `/projects/${data.projectId}`, null, data.consoleSessionHeaders, 'teardown.projects.delete');
+ }
+
+ if (data && data.teamId && data.consoleSessionHeaders) {
+ rawRequest('DELETE', `/teams/${data.teamId}`, null, data.consoleSessionHeaders, 'teardown.teams.delete');
+ }
+}
+
+function accountFlow(ctx) {
+ const userId = unique('user');
+ const email = `bench-user-${unique('mail')}@example.com`;
+ const headers = projectHeaders(ctx.projectId);
+
+ api('POST', '/account', {
+ userId,
+ email,
+ password: PASSWORD,
+ name: 'Benchmark User',
+ }, headers, [201], 'account.create');
+
+ const session = api('POST', '/account/sessions/email', {
+ email,
+ password: PASSWORD,
+ }, headers, [201], 'account.sessions.email.create');
+
+ const sessionHeaders = {
+ ...headers,
+ Cookie: cookieHeader(session),
+ };
+
+ ctx.userId = userId;
+ ctx.userEmail = email;
+ ctx.sessionHeaders = sessionHeaders;
+
+ api('GET', '/account', null, sessionHeaders, [200], 'account.get');
+ api('GET', '/account/logs', null, sessionHeaders, [200], 'account.logs.list');
+ api('PATCH', '/account/prefs', { prefs: { benchmark: true, runId: ctx.runId } }, sessionHeaders, [200], 'account.prefs.update');
+ api('PATCH', '/account/name', { name: 'Benchmark User Updated' }, sessionHeaders, [200], 'account.name.update');
+ api('PATCH', '/account/password', { password: `${PASSWORD}2`, oldPassword: PASSWORD }, sessionHeaders, [200], 'account.password.update');
+}
+
+function tablesDbFlow(ctx) {
+ requireSession(ctx, 'tablesDbFlow');
+
+ const databaseId = ctx.databaseId;
+ const tableId = ctx.tableId;
+ const rowId = unique('row');
+
+ api('POST', `/tablesdb/${databaseId}/tables/${tableId}/rows`, {
+ rowId,
+ data: tablePayload(),
+ permissions: ITEM_PERMISSIONS,
+ }, ctx.sessionHeaders, [201], 'tablesdb.rows.create');
+ api('GET', `/tablesdb/${databaseId}/tables/${tableId}/rows`, null, ctx.sessionHeaders, [200], 'tablesdb.rows.list');
+ api('GET', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, null, ctx.sessionHeaders, [200], 'tablesdb.rows.get');
+ api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, {
+ data: { title: 'Benchmark Row Updated' },
+ }, ctx.sessionHeaders, [200], 'tablesdb.rows.update');
+ api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}/quantity/increment`, {
+ value: 1,
+ }, ctx.sessionHeaders, [200], 'tablesdb.rows.increment');
+ api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}/quantity/decrement`, {
+ value: 1,
+ }, ctx.sessionHeaders, [200], 'tablesdb.rows.decrement');
+ api('DELETE', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, null, ctx.sessionHeaders, [204], 'tablesdb.rows.delete');
+}
+
+function storageFlow(ctx) {
+ requireSession(ctx, 'storageFlow');
+
+ const bucketId = unique('bucket');
+ const fileId = unique('file');
+
+ api('POST', '/storage/buckets', {
+ bucketId,
+ name: 'Benchmark Bucket',
+ permissions: BASE_PERMISSIONS,
+ fileSecurity: false,
+ enabled: true,
+ maximumFileSize: 30000000,
+ allowedFileExtensions: [],
+ compression: 'none',
+ encryption: false,
+ antivirus: false,
+ }, ctx.apiHeaders, [201], 'storage.buckets.create');
+
+ const multipartHeaders = { ...ctx.sessionHeaders };
+ delete multipartHeaders['Content-Type'];
+
+ const upload = http.post(`${ENDPOINT}/storage/buckets/${bucketId}/files`, {
+ fileId,
+ file: http.file(onePixelPng(), 'benchmark.png', 'image/png'),
+ ...flattenMultipartArray('permissions', ITEM_PERMISSIONS),
+ }, {
+ headers: multipartHeaders,
+ tags: { name: 'storage.files.create' },
});
-}
\ No newline at end of file
+
+ httpWaiting.add(upload.timings.waiting, { name: 'storage.files.create' });
+ apiDuration.add(upload.timings.duration, { name: 'storage.files.create' });
+ apiWaiting.add(upload.timings.waiting, { name: 'storage.files.create' });
+ assertStatus(upload, [201], 'storage file created');
+
+ api('GET', `/storage/buckets/${bucketId}/files`, null, ctx.sessionHeaders, [200], 'storage.files.list');
+ api('GET', `/storage/buckets/${bucketId}/files/${fileId}`, null, ctx.sessionHeaders, [200], 'storage.files.get');
+ api('GET', `/storage/buckets/${bucketId}/files/${fileId}/view`, null, ctx.sessionHeaders, [200], 'storage.files.view');
+ api('GET', `/storage/buckets/${bucketId}/files/${fileId}/download`, null, ctx.sessionHeaders, [200], 'storage.files.download');
+ api('GET', `/storage/buckets/${bucketId}/files/${fileId}/preview`, null, ctx.sessionHeaders, [200], 'storage.files.preview');
+ api('PUT', `/storage/buckets/${bucketId}/files/${fileId}`, {
+ name: 'benchmark-renamed.png',
+ permissions: ITEM_PERMISSIONS,
+ }, ctx.sessionHeaders, [200], 'storage.files.update');
+
+ const token = api('POST', `/tokens/buckets/${bucketId}/files/${fileId}`, {}, ctx.apiHeaders, [201], 'tokens.files.create');
+ api('GET', `/tokens/buckets/${bucketId}/files/${fileId}`, null, ctx.apiHeaders, [200], 'tokens.files.list');
+ api('GET', `/tokens/${token.json('$id')}`, null, ctx.apiHeaders, [200], 'tokens.get');
+ api('PATCH', `/tokens/${token.json('$id')}`, { expire: null }, ctx.apiHeaders, [200], 'tokens.update');
+ api('DELETE', `/tokens/${token.json('$id')}`, null, ctx.apiHeaders, [204], 'tokens.delete');
+
+ api('DELETE', `/storage/buckets/${bucketId}/files/${fileId}`, null, ctx.sessionHeaders, [204], 'storage.files.delete');
+ api('DELETE', `/storage/buckets/${bucketId}`, null, ctx.apiHeaders, [204], 'storage.buckets.delete');
+}
+
+function computeFlow(ctx) {
+ requireSession(ctx, 'computeFlow');
+
+ const functionId = unique('fn');
+ let functionVariableId;
+
+ api('POST', '/functions', {
+ functionId,
+ name: 'Benchmark Function',
+ runtime: __ENV.APPWRITE_BENCHMARK_RUNTIME || 'node-22',
+ execute: ['any'],
+ events: [],
+ schedule: '',
+ timeout: 15,
+ enabled: true,
+ logging: true,
+ entrypoint: 'index.js',
+ commands: 'npm install',
+ scopes: ['users.read'],
+ }, ctx.apiHeaders, [201], 'functions.create');
+ api('GET', '/functions/runtimes', null, ctx.sessionHeaders, [200], 'functions.runtimes.list');
+ api('GET', '/functions/specifications', null, ctx.apiHeaders, [200], 'functions.specifications.list');
+ const functionVariable = api('POST', `/functions/${functionId}/variables`, {
+ variableId: 'unique()',
+ key: 'BENCHMARK',
+ value: 'true',
+ secret: false,
+ }, ctx.apiHeaders, [201], 'functions.variables.create');
+ functionVariableId = functionVariable.json('$id');
+
+ api('PUT', `/functions/${functionId}/variables/${functionVariableId}`, {
+ key: 'BENCHMARK',
+ value: 'updated',
+ secret: false,
+ }, ctx.apiHeaders, [200], 'functions.variables.update');
+ api('GET', `/functions/${functionId}/variables/${functionVariableId}`, null, ctx.apiHeaders, [200], 'functions.variables.get');
+ api('DELETE', `/functions/${functionId}/variables/${functionVariableId}`, null, ctx.apiHeaders, [204], 'functions.variables.delete');
+ api('DELETE', `/functions/${functionId}`, null, ctx.apiHeaders, [204], 'functions.delete');
+}
+
+function api(method, path, body, headers, expected, name) {
+ const response = rawRequest(method, path, body, headers, name);
+ apiDuration.add(response.timings.duration, { name });
+ apiWaiting.add(response.timings.waiting, { name });
+ assertStatus(response, expected, name);
+ return response;
+}
+
+function setupApi(method, path, body, headers, expected, name) {
+ const response = rawRequest(method, path, body, headers, name);
+ assertStatus(response, expected, name);
+ return response;
+}
+
+function rawRequest(method, path, body, headers, name) {
+ const params = {
+ headers,
+ tags: { name },
+ };
+ const payload = body === null || body === undefined ? null : JSON.stringify(body);
+ const response = http.request(method, `${ENDPOINT}${path}`, payload, params);
+ httpWaiting.add(response.timings.waiting, { name });
+
+ return response;
+}
+
+function waitForStatus(path, headers, wantedStatus, timeoutMs, name) {
+ const started = Date.now();
+
+ while (Date.now() - started < timeoutMs) {
+ const response = rawRequest('GET', path, null, headers, name);
+ if (response.status === 200) {
+ const status = response.json('status');
+ if (status === wantedStatus) {
+ return response;
+ }
+ if (status === 'failed') {
+ throw new Error(`${path} failed while waiting for ${wantedStatus}`);
+ }
+ }
+ sleep(0.5);
+ }
+
+ throw new Error(`Timed out waiting for ${path} to become ${wantedStatus}`);
+}
+
+function assertStatus(response, expected, name) {
+ const ok = check(response, {
+ [`${name} status ${expected.join('|')}`]: (r) => expected.includes(r.status),
+ });
+
+ if (!ok) {
+ failResponse(response, `${name} returned an unexpected status`);
+ }
+}
+
+function failResponse(response, message) {
+ throw new Error(`${message}. Status: ${response.status}. Body: ${response.body}`);
+}
+
+function cookieHeader(response) {
+ return response.headers['Set-Cookie'] || response.headers['set-cookie'] || '';
+}
+
+function projectHeaders(projectId) {
+ return {
+ 'Content-Type': 'application/json',
+ 'X-Appwrite-Project': projectId,
+ };
+}
+
+function requireSession(ctx, flow) {
+ if (!ctx.sessionHeaders || typeof ctx.sessionHeaders !== 'object') {
+ throw new Error(`accountFlow must run before ${flow}`);
+ }
+}
+
+function tablePayload() {
+ return {
+ title: 'Benchmark Row',
+ quantity: 1,
+ email: 'row@example.com',
+ active: true,
+ };
+}
+
+function onePixelPng() {
+ return encoding.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=', 'std', 'b');
+}
+
+function flattenMultipartArray(key, values) {
+ const output = {};
+ values.forEach((value, index) => {
+ output[`${key}[${index}]`] = value;
+ });
+ return output;
+}
+
+function unique(prefix) {
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
+ .toLowerCase()
+ .replace(/[^a-z0-9-]/g, '-')
+ .slice(0, 36);
+}
+
+function hostnameFromUrl(value) {
+ return value.replace(/^https?:\/\//, '').split('/')[0].split(':')[0];
+}
+
+export function handleSummary(data) {
+ const lines = [
+ 'Appwrite curated benchmark review',
+ '',
+ 'Before',
+ '',
+ summaryTable(PREVIOUS_SUMMARY),
+ '',
+ 'After',
+ '',
+ summaryTable(data),
+ '',
+ 'Delta',
+ '',
+ deltaTable(PREVIOUS_SUMMARY, data),
+ '',
+ ];
+
+ return {
+ stdout: `${lines.join('\n')}\n`,
+ [SUMMARY_PATH]: JSON.stringify(data, null, 2),
+ };
+}
+
+function summaryTable(data) {
+ return [
+ '| Scenario | P50 (ms) | P95 (ms) | Requests | RPS |',
+ '| --- | ---: | ---: | ---: | ---: |',
+ summaryRow(data, 'API total', 'appwrite_api_duration'),
+ ].join('\n');
+}
+
+function summaryRow(data, label, metric, iterationsMetric = null, rpsMetric = null) {
+ const values = data && data.metrics[metric] && data.metrics[metric].values;
+ if (!values || values.count === 0) {
+ return `| ${label} | n/a | n/a | n/a | n/a |`;
+ }
+
+ const iterations = iterationsMetric
+ ? trendMetric(data, iterationsMetric, 'count')
+ : values.count;
+ const rps = rpsMetric ? trendMetric(data, rpsMetric, 'rate') : null;
+
+ return `| ${label} | ${formatDetailValue(values.med)} | ${formatDetailValue(values['p(95)'])} | ${formatCount(iterations)} | ${formatRate(rps)} |`;
+}
+
+function loadPreviousSummary(path) {
+ let contents;
+ try {
+ contents = open(path);
+ } catch (error) {
+ console.warn(`Missing benchmark summary at ${path}: ${error.message}`);
+ return null;
+ }
+
+ try {
+ return JSON.parse(contents);
+ } catch (error) {
+ console.warn(`Invalid benchmark summary at ${path}: ${error.message}`);
+ return null;
+ }
+}
+
+function deltaTable(before, after) {
+ return [
+ '| Scenario | P95 delta (ms) |',
+ '| --- | ---: |',
+ ...[
+ ['API total', 'appwrite_api_duration'],
+ ].map(([label, metric]) => {
+ const beforeP95 = trendMetric(before, metric, 'p(95)');
+ const afterP95 = trendMetric(after, metric, 'p(95)');
+ return `| ${label} | ${formatDelta(beforeP95, afterP95)} |`;
+ }),
+ ].join('\n');
+}
+
+function trendMetric(data, metric, stat) {
+ return data && data.metrics[metric] && data.metrics[metric].values
+ ? data.metrics[metric].values[stat]
+ : null;
+}
+
+function formatDetailValue(value) {
+ if (value === null || value === undefined || Number.isNaN(value)) {
+ return 'n/a';
+ }
+
+ return `${Number(value).toFixed(2)}`;
+}
+
+function formatDelta(before, after) {
+ if (before === null || before === undefined || after === null || after === undefined || Number.isNaN(before) || Number.isNaN(after)) {
+ return 'n/a';
+ }
+
+ const delta = round(after - before);
+ const sign = delta > 0 ? '+' : '';
+ return `${sign}${delta}`;
+}
+
+function formatCount(value) {
+ if (value === null || value === undefined || Number.isNaN(value)) {
+ return 'n/a';
+ }
+
+ return `${Math.round(value)}`;
+}
+
+function formatRate(value) {
+ if (value === null || value === undefined || Number.isNaN(value)) {
+ return 'n/a';
+ }
+
+ return `${Number(value).toFixed(2)}`;
+}
+
+function round(value) {
+ return Math.round((value || 0) * 100) / 100;
+}
diff --git a/tests/e2e/Client.php b/tests/e2e/Client.php
index d170d56fe4..6965a87d73 100644
--- a/tests/e2e/Client.php
+++ b/tests/e2e/Client.php
@@ -264,7 +264,7 @@ class Client
$strpos = \is_bool($strpos) ? \strlen($responseType) : $strpos;
switch (substr($responseType, 0, $strpos)) {
case 'multipart/form-data':
- $boundary = \explode('boundary=', $responseHeaders['content-type'] ?? '')[1] ?? '';
+ $boundary = \explode('boundary=', $responseHeaders['content-type'])[1] ?? '';
$multipartResponse = new BodyMultipart($boundary);
$multipartResponse->load(\is_bool($responseBody) ? '' : $responseBody);
@@ -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) {
diff --git a/tests/e2e/General/HTTPTest.php b/tests/e2e/General/HTTPTest.php
index 450e4f2378..0358281eb7 100644
--- a/tests/e2e/General/HTTPTest.php
+++ b/tests/e2e/General/HTTPTest.php
@@ -101,7 +101,7 @@ class HTTPTest extends Scope
$body = $response['body'];
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertIsString($body['server']);
- $this->assertIsString($body['client-web']);
+ $this->assertIsString($body['server-web']);
$this->assertIsString($body['client-flutter']);
$this->assertIsString($body['console-web']);
$this->assertIsString($body['server-nodejs']);
diff --git a/tests/e2e/General/UsageTest.php b/tests/e2e/General/UsageTest.php
index f6eb963967..7d0e858bbb 100644
--- a/tests/e2e/General/UsageTest.php
+++ b/tests/e2e/General/UsageTest.php
@@ -18,6 +18,7 @@ use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Validator\Datetime as DatetimeValidator;
use Utopia\System\System;
+use WebSocket\Client as WebSocketClient;
class UsageTest extends Scope
{
@@ -227,6 +228,110 @@ class UsageTest extends Scope
}
#[Depends('testUsersStats')]
+ public function testPreparePresenceStats(array $data): array
+ {
+ $presenceKey = $this->getNewKey([
+ 'presences.read',
+ 'presences.write',
+ ]);
+ $projectId = $this->getProject()['$id'];
+
+ $apiUser = $this->getUser(true);
+ $apiPresence = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . ID::unique(),
+ [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $presenceKey,
+ ],
+ [
+ 'userId' => $apiUser['$id'],
+ 'status' => 'online',
+ 'metadata' => [
+ 'source' => 'api',
+ 'testRunId' => ID::unique(),
+ ],
+ 'permissions' => [
+ Permission::read(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ ]
+ );
+ $this->assertEquals(200, $apiPresence['headers']['status-code']);
+
+ return $data;
+ }
+
+ #[Depends('testPreparePresenceStats')]
+ #[Retry(count: 1)]
+ public function testPresenceStats(array $data): array
+ {
+ $projectId = $this->getProject()['$id'];
+ $realtimeUser = $this->getUser(true);
+ $realtime = new WebSocketClient(
+ 'ws://appwrite.test/v1/realtime?' . \http_build_query([
+ 'project' => $projectId,
+ ]),
+ [
+ 'headers' => [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_' . $projectId . '=' . $realtimeUser['session'],
+ ],
+ 'timeout' => 2,
+ ]
+ );
+
+ try {
+ $connected = \json_decode($realtime->receive(), true);
+ $this->assertSame('connected', $connected['type'] ?? null);
+
+ $presenceId = ID::unique();
+ $realtime->send(\json_encode([
+ 'type' => 'presence',
+ 'data' => [
+ 'presenceId' => $presenceId,
+ 'status' => 'online',
+ 'metadata' => [
+ 'source' => 'realtime',
+ 'testRunId' => ID::unique(),
+ ],
+ 'permissions' => [
+ Permission::read(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ ],
+ ]));
+
+ $response = \json_decode($realtime->receive(), true);
+ $this->assertSame('response', $response['type'] ?? null);
+ $this->assertSame('presence', $response['data']['to'] ?? null);
+ $this->assertSame($presenceId, $response['data']['presence']['$id'] ?? null);
+
+ $this->assertEventually(function () {
+ $response = $this->client->call(
+ Client::METHOD_GET,
+ '/presences/usage?range=90d',
+ $this->getConsoleHeaders()
+ );
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals('90d', $response['body']['range']);
+ $this->assertEquals(90, count($response['body']['presences']));
+ $this->assertEquals(2, $response['body']['usersOnlineTotal']);
+ $this->assertEquals(2, $response['body']['presences'][array_key_last($response['body']['presences'])]['value']);
+ $this->validateDates($response['body']['presences']);
+ });
+ } finally {
+ $realtime->close();
+ }
+
+ return $data;
+ }
+
+ #[Depends('testPresenceStats')]
public function testPrepareStorageStats(array $data): array
{
$requestsTotal = $data['requestsTotal'];
@@ -1605,8 +1710,6 @@ class UsageTest extends Scope
'siteId' => ID::unique()
]);
- $this->assertNotNull($siteId);
-
$deployment = $this->createDeploymentSite($siteId, [
'siteId' => $siteId,
'code' => $this->packageSite('static'),
diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php
index a62a1e8ba3..c34a6527f9 100644
--- a/tests/e2e/Scopes/ProjectCustom.php
+++ b/tests/e2e/Scopes/ProjectCustom.php
@@ -137,8 +137,8 @@ trait ProjectCustom
'functions.write',
'sites.read',
'sites.write',
- 'execution.read',
- 'execution.write',
+ 'executions.read',
+ 'executions.write',
'log.read',
'log.write',
'locale.read',
@@ -169,6 +169,18 @@ trait ProjectCustom
'keys.write',
'platforms.read',
'platforms.write',
+ 'oauth2.read',
+ 'oauth2.write',
+ 'mocks.read',
+ 'mocks.write',
+ 'project.policies.read',
+ 'project.policies.write',
+ 'templates.read',
+ 'templates.write',
+ 'insights.read',
+ 'insights.write',
+ 'reports.read',
+ 'reports.write',
],
]);
diff --git a/tests/e2e/Services/Account/AccountBase.php b/tests/e2e/Services/Account/AccountBase.php
index a81da60968..8b4dfd4e3e 100644
--- a/tests/e2e/Services/Account/AccountBase.php
+++ b/tests/e2e/Services/Account/AccountBase.php
@@ -175,7 +175,7 @@ trait AccountBase
// FInd 6 concurrent digits in email text - OTP
preg_match_all("/\b\d{6}\b/", $lastEmail['text'], $matches);
- $code = ($matches[0] ?? [])[0] ?? '';
+ $code = $matches[0][0] ?? '';
$this->assertNotEmpty($code);
$this->assertStringContainsStringIgnoringCase('Use OTP ' . $code . ' to sign in to '. $this->getProject()['name'] . '. Expires in 15 minutes.', $lastEmail['text']);
diff --git a/tests/e2e/Services/Account/AccountConsoleClientTest.php b/tests/e2e/Services/Account/AccountConsoleClientTest.php
index 78f7798193..cd2c43381c 100644
--- a/tests/e2e/Services/Account/AccountConsoleClientTest.php
+++ b/tests/e2e/Services/Account/AccountConsoleClientTest.php
@@ -14,7 +14,12 @@ class AccountConsoleClientTest extends Scope
use ProjectConsole;
use SideClient;
- public function testDeleteAccount(): void
+ /**
+ * Test that account deletion succeeds even with active team memberships.
+ * When the user is the sole owner and only member of a team, the team
+ * should be cleaned up automatically.
+ */
+ public function testDeleteAccountWithMembership(): void
{
$email = uniqid() . 'user@localhost.test';
$password = 'password';
@@ -46,7 +51,7 @@ class AccountConsoleClientTest extends Scope
$session = $response['cookies']['a_session_' . $this->getProject()['$id']];
- // create team
+ // Create team — user becomes sole owner and only member
$team = $this->client->call(Client::METHOD_POST, '/teams', [
'origin' => 'http://localhost',
'content-type' => 'application/json',
@@ -58,7 +63,51 @@ class AccountConsoleClientTest extends Scope
]);
$this->assertEquals($team['headers']['status-code'], 201);
- $teamId = $team['body']['$id'];
+ // Account deletion should succeed even with active membership
+ $response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
+ ]));
+
+ $this->assertEquals(204, $response['headers']['status-code']);
+ }
+
+ /**
+ * Test that account deletion works when the user has no team memberships.
+ */
+ public function testDeleteAccountWithoutMembership(): void
+ {
+ $email = uniqid() . 'user@localhost.test';
+ $password = 'password';
+ $name = 'User Name';
+
+ $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ]), [
+ 'userId' => ID::unique(),
+ 'email' => $email,
+ 'password' => $password,
+ 'name' => $name,
+ ]);
+
+ $this->assertEquals($response['headers']['status-code'], 201);
+
+ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ]), [
+ 'email' => $email,
+ 'password' => $password,
+ ]);
+
+ $this->assertEquals($response['headers']['status-code'], 201);
+
+ $session = $response['cookies']['a_session_' . $this->getProject()['$id']];
$response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([
'origin' => 'http://localhost',
@@ -67,27 +116,7 @@ class AccountConsoleClientTest extends Scope
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
]));
- $this->assertEquals($response['headers']['status-code'], 400);
-
- // DELETE TEAM
- $response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId, array_merge([
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
- ]));
- $this->assertEquals($response['headers']['status-code'], 204);
-
- $this->assertEventually(function () use ($session) {
- $response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
- ]));
-
- $this->assertEquals(204, $response['headers']['status-code']);
- }, 10_000, 500);
+ $this->assertEquals(204, $response['headers']['status-code']);
}
public function testSessionAlert(): void
@@ -174,7 +203,7 @@ class AccountConsoleClientTest extends Scope
// Find 6 concurrent digits in email text - OTP
preg_match_all("/\b\d{6}\b/", $lastEmail['text'], $matches);
- $code = ($matches[0] ?? [])[0] ?? '';
+ $code = $matches[0][0] ?? '';
$this->assertNotEmpty($code);
diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php
index 951ab179b3..5b0d947198 100644
--- a/tests/e2e/Services/Account/AccountCustomClientTest.php
+++ b/tests/e2e/Services/Account/AccountCustomClientTest.php
@@ -772,6 +772,7 @@ class AccountCustomClientTest extends Scope
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
+ 'x-appwrite-response-format' => '1.9.1',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]), [
'status' => true,
@@ -1025,123 +1026,101 @@ class AccountCustomClientTest extends Scope
// Use fresh account for predictable log count
$data = $this->createFreshAccountWithSession();
$session = $data['session'];
+ $headers = array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
+ ]);
/**
* Test for SUCCESS
*/
- $response = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
- ]));
+ $this->assertEventually(function () use ($headers) {
+ $response = $this->client->call(Client::METHOD_GET, '/account/logs', $headers);
- $this->assertEquals(200, $response['headers']['status-code']);
- $this->assertIsArray($response['body']['logs']);
- $this->assertNotEmpty($response['body']['logs']);
- // Fresh account: session.create is always logged. user.create audit may or may not
- // be present depending on async audit processing timing.
- $logCount = count($response['body']['logs']);
- $this->assertContains($logCount, [1, 2]);
- $this->assertIsNumeric($response['body']['total']);
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertIsArray($response['body']['logs']);
+ $this->assertNotEmpty($response['body']['logs']);
+ $logCount = count($response['body']['logs']);
+ $this->assertContains($logCount, [1, 2]);
+ $this->assertIsNumeric($response['body']['total']);
- // Check session.create log (logs[0] - most recent)
- $this->assertEquals('Windows', $response['body']['logs'][0]['osName']);
- $this->assertEquals('WIN', $response['body']['logs'][0]['osCode']);
- $this->assertEquals('10', $response['body']['logs'][0]['osVersion']);
+ $this->assertEquals('session.create', $response['body']['logs'][0]['event']);
+ $this->assertEquals('Windows', $response['body']['logs'][0]['osName']);
+ $this->assertEquals('WIN', $response['body']['logs'][0]['osCode']);
+ $this->assertEquals('10', $response['body']['logs'][0]['osVersion']);
- $this->assertEquals('browser', $response['body']['logs'][0]['clientType']);
- $this->assertEquals('Chrome', $response['body']['logs'][0]['clientName']);
- $this->assertEquals('CH', $response['body']['logs'][0]['clientCode']);
- $this->assertEquals('70.0', $response['body']['logs'][0]['clientVersion']);
- $this->assertEquals('Blink', $response['body']['logs'][0]['clientEngine']);
+ $this->assertEquals('browser', $response['body']['logs'][0]['clientType']);
+ $this->assertEquals('Chrome', $response['body']['logs'][0]['clientName']);
+ $this->assertEquals('CH', $response['body']['logs'][0]['clientCode']);
+ $this->assertEquals('70.0', $response['body']['logs'][0]['clientVersion']);
+ $this->assertEquals('Blink', $response['body']['logs'][0]['clientEngine']);
- $this->assertEquals('desktop', $response['body']['logs'][0]['deviceName']);
- $this->assertEquals('', $response['body']['logs'][0]['deviceBrand']);
- $this->assertEquals('', $response['body']['logs'][0]['deviceModel']);
- $this->assertEquals(filter_var($response['body']['logs'][0]['ip'], FILTER_VALIDATE_IP), $response['body']['logs'][0]['ip']);
+ $this->assertEquals('desktop', $response['body']['logs'][0]['deviceName']);
+ $this->assertEquals('', $response['body']['logs'][0]['deviceBrand']);
+ $this->assertEquals('', $response['body']['logs'][0]['deviceModel']);
+ $this->assertEquals(filter_var($response['body']['logs'][0]['ip'], FILTER_VALIDATE_IP), $response['body']['logs'][0]['ip']);
- $this->assertEquals('--', $response['body']['logs'][0]['countryCode']);
- $this->assertEquals('Unknown', $response['body']['logs'][0]['countryName']);
+ $this->assertEquals('--', $response['body']['logs'][0]['countryCode']);
+ $this->assertEquals('Unknown', $response['body']['logs'][0]['countryName']);
- if ($logCount === 2) {
- // Check user.create log (logs[1] - oldest)
- $this->assertEquals('user.create', $response['body']['logs'][1]['event']);
- $this->assertEquals(filter_var($response['body']['logs'][1]['ip'], FILTER_VALIDATE_IP), $response['body']['logs'][1]['ip']);
- $this->assertTrue((new DatetimeValidator())->isValid($response['body']['logs'][1]['time']));
- }
+ if ($logCount === 2) {
+ $this->assertEquals('user.create', $response['body']['logs'][1]['event']);
+ $this->assertEquals(filter_var($response['body']['logs'][1]['ip'], FILTER_VALIDATE_IP), $response['body']['logs'][1]['ip']);
+ $this->assertTrue((new DatetimeValidator())->isValid($response['body']['logs'][1]['time']));
+ }
- $responseLimit = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
- ]), [
- 'queries' => [
- Query::limit(1)->toString()
- ]
- ]);
+ $responseLimit = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [
+ 'queries' => [
+ Query::limit(1)->toString()
+ ]
+ ]);
- $this->assertEquals(200, $responseLimit['headers']['status-code']);
- $this->assertIsArray($responseLimit['body']['logs']);
- $this->assertNotEmpty($responseLimit['body']['logs']);
- $this->assertCount(1, $responseLimit['body']['logs']);
- $this->assertIsNumeric($responseLimit['body']['total']);
+ $this->assertEquals(200, $responseLimit['headers']['status-code']);
+ $this->assertIsArray($responseLimit['body']['logs']);
+ $this->assertNotEmpty($responseLimit['body']['logs']);
+ $this->assertCount(1, $responseLimit['body']['logs']);
+ $this->assertIsNumeric($responseLimit['body']['total']);
- $this->assertEquals($response['body']['logs'][0], $responseLimit['body']['logs'][0]);
+ $this->assertEquals($response['body']['logs'][0], $responseLimit['body']['logs'][0]);
- $responseOffset = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
- ]), [
- 'queries' => [
- Query::offset(1)->toString()
- ]
- ]);
+ $responseOffset = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [
+ 'queries' => [
+ Query::offset(1)->toString()
+ ]
+ ]);
- $this->assertEquals($responseOffset['headers']['status-code'], 200);
- $this->assertIsArray($responseOffset['body']['logs']);
- // With offset(1), remaining logs = logCount - 1
- $this->assertCount($logCount - 1, $responseOffset['body']['logs']);
- $this->assertIsNumeric($responseOffset['body']['total']);
+ $this->assertEquals(200, $responseOffset['headers']['status-code']);
+ $this->assertIsArray($responseOffset['body']['logs']);
+ $this->assertCount($logCount - 1, $responseOffset['body']['logs']);
+ $this->assertIsNumeric($responseOffset['body']['total']);
- if ($logCount === 2) {
- $this->assertEquals($response['body']['logs'][1], $responseOffset['body']['logs'][0]);
- }
+ if ($logCount === 2) {
+ $this->assertEquals($response['body']['logs'][1], $responseOffset['body']['logs'][0]);
+ }
- $responseLimitOffset = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
- ]), [
- 'queries' => [
- Query::offset(1)->toString(),
- Query::limit(1)->toString()
- ]
- ]);
+ $responseLimitOffset = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [
+ 'queries' => [
+ Query::offset(1)->toString(),
+ Query::limit(1)->toString()
+ ]
+ ]);
- $this->assertEquals(200, $responseLimitOffset['headers']['status-code']);
- $this->assertIsArray($responseLimitOffset['body']['logs']);
- // With offset(1)+limit(1), remaining logs = min(1, logCount - 1)
- $this->assertCount(min(1, $logCount - 1), $responseLimitOffset['body']['logs']);
- $this->assertIsNumeric($responseLimitOffset['body']['total']);
+ $this->assertEquals(200, $responseLimitOffset['headers']['status-code']);
+ $this->assertIsArray($responseLimitOffset['body']['logs']);
+ $this->assertCount(min(1, $logCount - 1), $responseLimitOffset['body']['logs']);
+ $this->assertIsNumeric($responseLimitOffset['body']['total']);
- if ($logCount === 2) {
- $this->assertEquals($response['body']['logs'][1], $responseLimitOffset['body']['logs'][0]);
- }
+ if ($logCount === 2) {
+ $this->assertEquals($response['body']['logs'][1], $responseLimitOffset['body']['logs'][0]);
+ }
+ });
/**
* Test for total=false
*/
- $logsWithIncludeTotalFalse = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
- ]), [
+ $logsWithIncludeTotalFalse = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [
'total' => false
]);
@@ -2050,6 +2029,7 @@ class AccountCustomClientTest extends Scope
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
+ 'x-appwrite-response-format' => '1.9.1',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]), [
'alerts' => true,
@@ -2135,7 +2115,7 @@ class AccountCustomClientTest extends Scope
// Find 6 concurrent digits in email text - OTP
preg_match_all("/\b\d{6}\b/", $lastEmail['text'], $matches);
- $code = ($matches[0] ?? [])[0] ?? '';
+ $code = $matches[0][0] ?? '';
$this->assertNotEmpty($code);
@@ -3363,7 +3343,7 @@ class AccountCustomClientTest extends Scope
{
$data = $this->setupPhoneAccount();
$id = $data['id'];
- $token = explode(" ", $data['token'])[0] ?? '';
+ $token = explode(" ", $data['token'])[0];
$number = $data['number'];
/**
@@ -3694,6 +3674,7 @@ class AccountCustomClientTest extends Scope
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
+ 'x-appwrite-response-format' => '1.9.1',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]), [
'status' => false,
@@ -4161,177 +4142,71 @@ class AccountCustomClientTest extends Scope
$this->assertEquals(401, $verification3['headers']['status-code']);
}
- /**
- * Test that a new email/password session is immediately usable even when
- * a concurrent request re-populates the user cache between the cache purge
- * and session creation.
- *
- * Regression test for: purging the user cache BEFORE persisting the session
- * allows a concurrent request (from a different Swoole worker) to re-cache
- * a stale user document that lacks the new session, causing sessionVerify
- * to fail with 401 on subsequent requests using the new session.
- */
- public function testEmailPasswordSessionNotCorruptedByConcurrentRequests(): void
+ public function testRefreshEmailPasswordSession(): void
{
- $projectId = $this->getProject()['$id'];
- $endpoint = $this->client->getEndpoint();
+ $email = uniqid() . 'user@localhost.test';
- $email = uniqid('race_', true) . getmypid() . '@localhost.test';
- $password = 'password123!';
-
- // Create user
- $response = $this->client->call(Client::METHOD_POST, '/account', [
+ $account = $this->client->call(Client::METHOD_POST, '/account', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
- 'x-appwrite-project' => $projectId,
- ], [
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ]), [
'userId' => ID::unique(),
'email' => $email,
- 'password' => $password,
- 'name' => 'Race Test User',
+ 'password' => 'password',
]);
- $this->assertEquals(201, $response['headers']['status-code']);
- // Login to get session A
- $responseA = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [
+ $this->assertEquals(201, $account['headers']['status-code']);
+
+ $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
- 'x-appwrite-project' => $projectId,
- ], [
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ]), [
'email' => $email,
- 'password' => $password,
+ 'password' => 'password',
]);
- $this->assertEquals(201, $responseA['headers']['status-code']);
- $sessionA = $responseA['cookies']['a_session_' . $projectId];
- // Verify session A works
- $verifyA = $this->client->call(Client::METHOD_GET, '/account', [
+ $this->assertEquals(201, $session['headers']['status-code']);
+ $this->assertNotEmpty($session['body']['$id']);
+
+ $sessionId = $session['body']['$id'];
+ $cookie = 'a_session_' . $this->getProject()['$id'] . '=' .$session['cookies']['a_session_' . $this->getProject()['$id']];
+
+ $session = $this->client->call(Client::METHOD_GET, '/account/sessions/current', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
- 'x-appwrite-project' => $projectId,
- 'cookie' => 'a_session_' . $projectId . '=' . $sessionA,
- ]);
- $this->assertEquals(200, $verifyA['headers']['status-code']);
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'cookie' => $cookie,
+ ]));
- /**
- * Race condition scenario:
- * 1. Start login B via curl_multi (non-blocking)
- * 2. Drive the transfer for ~150ms so login B reaches purgeCachedDocument
- * (findOne ~15ms + Argon2 hash verify ~60ms + middleware overhead)
- * 3. THEN add GET requests to curl_multi - these hit different workers and
- * re-cache a stale user document (without session B) during the window
- * between purgeCachedDocument and createDocument
- * 4. After all complete, verify session B is usable
- */
- for ($attempt = 0; $attempt < 5; $attempt++) {
- $loginCookies = [];
+ $this->assertEquals(200, $session['headers']['status-code']);
+ $this->assertNotEmpty($session['body']['expire']);
+ $expiryBefore = $session['body']['expire'];
- $multi = curl_multi_init();
+ \sleep(3); // Small delay to ensure expiry an expand
- // Start login B first (alone)
- $loginHandle = curl_init("{$endpoint}/account/sessions/email");
- curl_setopt_array($loginHandle, [
- CURLOPT_POST => true,
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_HTTPHEADER => [
- 'origin: http://localhost',
- 'content-type: application/json',
- "x-appwrite-project: {$projectId}",
- ],
- CURLOPT_POSTFIELDS => \json_encode([
- 'email' => $email,
- 'password' => $password,
- ]),
- CURLOPT_HEADERFUNCTION => function ($curl, $header) use (&$loginCookies) {
- if (\stripos($header, 'set-cookie:') === 0) {
- $cookiePart = \trim(\substr($header, 11));
- $eqPos = \strpos($cookiePart, '=');
- if ($eqPos !== false) {
- $name = \substr($cookiePart, 0, $eqPos);
- $rest = \substr($cookiePart, $eqPos + 1);
- $semiPos = \strpos($rest, ';');
- $loginCookies[$name] = $semiPos !== false
- ? \substr($rest, 0, $semiPos)
- : $rest;
- }
- }
- return \strlen($header);
- },
- ]);
- curl_multi_add_handle($multi, $loginHandle);
+ $session = $this->client->call(Client::METHOD_PATCH, '/account/sessions/' . $sessionId, array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'cookie' => $cookie,
+ ]));
- // Drive the login transfer forward and wait for the server to start
- // processing the login (past hash verification + cache purge).
- $deadline = \microtime(true) + 0.15; // 150ms
- do {
- curl_multi_exec($multi, $active);
- curl_multi_select($multi, 0.005);
- } while (\microtime(true) < $deadline && $active);
+ $this->assertEquals(200, $session['headers']['status-code']);
+ $this->assertNotEmpty($session['body']['expire']);
+ $expiryAfter = $session['body']['expire'];
- // NOW add GET requests - they arrive after the cache purge
- // but before session creation (which is delayed by the usleep or I/O).
- $getHandles = [];
- for ($i = 0; $i < 10; $i++) {
- $gh = curl_init("{$endpoint}/account");
- curl_setopt_array($gh, [
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_HTTPHEADER => [
- 'origin: http://localhost',
- 'content-type: application/json',
- "x-appwrite-project: {$projectId}",
- "cookie: a_session_{$projectId}={$sessionA}",
- ],
- ]);
- curl_multi_add_handle($multi, $gh);
- $getHandles[] = $gh;
- }
+ $this->assertGreaterThan(\strtotime($expiryBefore), \strtotime($expiryAfter));
- // Drive all to completion
- do {
- $status = curl_multi_exec($multi, $active);
- if ($active) {
- curl_multi_select($multi, 0.05);
- }
- } while ($active && $status === CURLM_OK);
+ $session = $this->client->call(Client::METHOD_GET, '/account/sessions/current', array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'cookie' => $cookie,
+ ]));
- $loginStatus = curl_getinfo($loginHandle, CURLINFO_HTTP_CODE);
-
- curl_multi_remove_handle($multi, $loginHandle);
- curl_close($loginHandle);
- foreach ($getHandles as $gh) {
- curl_multi_remove_handle($multi, $gh);
- curl_close($gh);
- }
- curl_multi_close($multi);
-
- $this->assertEquals(201, $loginStatus, 'Login for session B should succeed');
-
- $sessionBCookie = $loginCookies["a_session_{$projectId}"] ?? null;
- $this->assertNotNull($sessionBCookie, 'Session B cookie should be set');
-
- // THE CRITICAL CHECK: verify session B is usable immediately
- $verifyB = $this->client->call(Client::METHOD_GET, '/account', [
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $projectId,
- 'cookie' => "a_session_{$projectId}={$sessionBCookie}",
- ]);
-
- $this->assertEquals(
- 200,
- $verifyB['headers']['status-code'],
- 'Session B must be immediately usable after login. '
- . 'A 401 here means a stale user cache (without the new session) was served. '
- . 'The fix is to create the session document BEFORE purging the user cache.'
- );
-
- // Clean up session B for next iteration
- $this->client->call(Client::METHOD_DELETE, '/account/sessions/current', [
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $projectId,
- 'cookie' => "a_session_{$projectId}={$sessionBCookie}",
- ]);
- }
+ $this->assertEquals(200, $session['headers']['status-code']);
+ $this->assertEquals(\strtotime($expiryAfter), \strtotime($session['body']['expire']));
}
}
diff --git a/tests/e2e/Services/Advisor/AdvisorBase.php b/tests/e2e/Services/Advisor/AdvisorBase.php
new file mode 100644
index 0000000000..f228cf5591
--- /dev/null
+++ b/tests/e2e/Services/Advisor/AdvisorBase.php
@@ -0,0 +1,122 @@
+ 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ }
+
+ protected function getReport(string $reportId, ?array $headers = null): array
+ {
+ return $this->client->call(Client::METHOD_GET, '/reports/' . $reportId, $headers ?? $this->serverHeaders());
+ }
+
+ protected function listReports(array $params = [], ?array $headers = null): array
+ {
+ return $this->client->call(Client::METHOD_GET, '/reports', $headers ?? $this->serverHeaders(), $params);
+ }
+
+ protected function getInsight(string $reportId, string $insightId, ?array $headers = null): array
+ {
+ return $this->client->call(Client::METHOD_GET, '/reports/' . $reportId . '/insights/' . $insightId, $headers ?? $this->serverHeaders());
+ }
+
+ protected function listInsights(string $reportId, array $params = [], ?array $headers = null): array
+ {
+ return $this->client->call(Client::METHOD_GET, '/reports/' . $reportId . '/insights', $headers ?? $this->serverHeaders(), $params);
+ }
+
+ public function testListReports(): void
+ {
+ $list = $this->listReports();
+
+ $this->assertSame(200, $list['headers']['status-code']);
+ $this->assertArrayHasKey('reports', $list['body']);
+ $this->assertArrayHasKey('total', $list['body']);
+ $this->assertIsArray($list['body']['reports']);
+ }
+
+ public function testGetReportMissing(): void
+ {
+ $missing = $this->getReport(ID::unique());
+
+ $this->assertSame(404, $missing['headers']['status-code']);
+ $this->assertSame('report_not_found', $missing['body']['type']);
+ }
+
+ public function testListInsightsMissingReport(): void
+ {
+ $missing = $this->listInsights(ID::unique());
+
+ $this->assertSame(404, $missing['headers']['status-code']);
+ $this->assertSame('report_not_found', $missing['body']['type']);
+ }
+
+ public function testGetInsightMissingReport(): void
+ {
+ $missing = $this->getInsight(ID::unique(), ID::unique());
+
+ $this->assertSame(404, $missing['headers']['status-code']);
+ $this->assertSame('report_not_found', $missing['body']['type']);
+ }
+
+ public function testReportsCreateAndUpdateNotExposed(): void
+ {
+ $create = $this->client->call(Client::METHOD_POST, '/reports', $this->serverHeaders(), [
+ 'reportId' => ID::unique(),
+ 'type' => 'audit',
+ 'title' => 'Read-only check',
+ 'targetType' => 'sites',
+ 'target' => 'home',
+ ]);
+ $this->assertSame(404, $create['headers']['status-code']);
+
+ $update = $this->client->call(Client::METHOD_PATCH, '/reports/' . ID::unique(), $this->serverHeaders(), [
+ 'title' => 'Read-only check',
+ ]);
+ $this->assertSame(404, $update['headers']['status-code']);
+ }
+
+ public function testDeleteReportMissing(): void
+ {
+ $delete = $this->client->call(Client::METHOD_DELETE, '/reports/' . ID::unique(), $this->serverHeaders());
+ $this->assertSame(404, $delete['headers']['status-code']);
+ $this->assertSame('report_not_found', $delete['body']['type']);
+ }
+
+ public function testInsightsCreateUpdateDeleteNotExposed(): void
+ {
+ $create = $this->client->call(
+ Client::METHOD_POST,
+ '/reports/' . ID::unique() . '/insights',
+ $this->serverHeaders(),
+ []
+ );
+ $this->assertSame(404, $create['headers']['status-code']);
+
+ $update = $this->client->call(
+ Client::METHOD_PATCH,
+ '/reports/' . ID::unique() . '/insights/' . ID::unique(),
+ $this->serverHeaders(),
+ ['status' => 'dismissed']
+ );
+ $this->assertSame(404, $update['headers']['status-code']);
+
+ $delete = $this->client->call(
+ Client::METHOD_DELETE,
+ '/reports/' . ID::unique() . '/insights/' . ID::unique(),
+ $this->serverHeaders()
+ );
+ $this->assertSame(404, $delete['headers']['status-code']);
+ }
+}
diff --git a/tests/e2e/Services/Advisor/AdvisorCustomServerTest.php b/tests/e2e/Services/Advisor/AdvisorCustomServerTest.php
new file mode 100644
index 0000000000..d91f95035e
--- /dev/null
+++ b/tests/e2e/Services/Advisor/AdvisorCustomServerTest.php
@@ -0,0 +1,58 @@
+getProject()['$id'];
+
+ $userKey = $this->getNewKey([
+ // Advisor read APIs are protected by the underlying report/insight resource scopes.
+ 'insights.read',
+ 'reports.read',
+ ]);
+
+ $listed = $this->client->call(
+ Client::METHOD_GET,
+ '/reports',
+ [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $userKey,
+ ]
+ );
+
+ $this->assertSame(200, $listed['headers']['status-code']);
+
+ $create = $this->client->call(
+ Client::METHOD_POST,
+ '/reports',
+ [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $userKey,
+ ],
+ [
+ 'reportId' => ID::unique(),
+ 'type' => 'audit',
+ 'title' => 'Read-only check',
+ 'targetType' => 'sites',
+ 'target' => 'home',
+ ]
+ );
+
+ $this->assertSame(404, $create['headers']['status-code']);
+ }
+}
diff --git a/tests/e2e/Services/Avatars/AvatarsBase.php b/tests/e2e/Services/Avatars/AvatarsBase.php
index 14442abb60..614349ae74 100644
--- a/tests/e2e/Services/Avatars/AvatarsBase.php
+++ b/tests/e2e/Services/Avatars/AvatarsBase.php
@@ -204,7 +204,7 @@ trait AvatarsBase
$response = $this->client->call(Client::METHOD_GET, '/avatars/image', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
- 'url' => 'https://appwrite.io/images/open-graph/website.png',
+ 'url' => 'https://appwrite.io/images/open-graph/website.avif',
]);
$this->assertEquals(200, $response['headers']['status-code']);
@@ -216,7 +216,7 @@ trait AvatarsBase
$response = $this->client->call(Client::METHOD_GET, '/avatars/image', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
- 'url' => 'https://appwrite.io/images/open-graph/website.png',
+ 'url' => 'https://appwrite.io/images/open-graph/website.avif',
'width' => 200,
'height' => 200,
]);
@@ -230,7 +230,7 @@ trait AvatarsBase
$response = $this->client->call(Client::METHOD_GET, '/avatars/image', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
- 'url' => 'https://appwrite.io/images/open-graph/website.png',
+ 'url' => 'https://appwrite.io/images/open-graph/website.avif',
'width' => 300,
'height' => 300,
'quality' => 30,
@@ -258,7 +258,7 @@ trait AvatarsBase
$response = $this->client->call(Client::METHOD_GET, '/avatars/image', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
- 'url' => 'https://appwrite.io/images/open-graph/website.png',
+ 'url' => 'https://appwrite.io/images/open-graph/website.avif',
'width' => 2001,
'height' => 300,
'quality' => 30,
diff --git a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php
index 373383e3ec..43daba470b 100644
--- a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php
+++ b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php
@@ -41,4 +41,183 @@ class ConsoleConsoleClientTest extends Scope
$this->assertIsString($response['body']['_APP_DB_ADAPTER']);
// When adding new keys, dont forget to update count a few lines above
}
+
+ public function testListOAuth2Providers(): void
+ {
+ $response = $this->client->call(Client::METHOD_GET, '/console/oauth2-providers', 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']['oAuth2Providers']);
+ $this->assertGreaterThan(0, $response['body']['total']);
+ $this->assertEquals($response['body']['total'], \count($response['body']['oAuth2Providers']));
+
+ $providerIds = \array_column($response['body']['oAuth2Providers'], '$id');
+ $this->assertEquals('amazon', $providerIds[0]);
+ $this->assertEquals('zoom', $providerIds[\count($providerIds) - 1]);
+
+ // Well-known providers must be present
+ $this->assertContains('github', $providerIds);
+ $this->assertContains('google', $providerIds);
+
+ // Mock providers must be excluded
+ $this->assertNotContains('mock', $providerIds);
+ $this->assertNotContains('mock-unverified', $providerIds);
+
+ // Every provider has the expected shape
+ foreach ($response['body']['oAuth2Providers'] as $provider) {
+ $this->assertArrayHasKey('$id', $provider);
+ $this->assertIsString($provider['$id']);
+ $this->assertArrayHasKey('parameters', $provider);
+ $this->assertIsArray($provider['parameters']);
+ $this->assertGreaterThan(0, \count($provider['parameters']));
+
+ foreach ($provider['parameters'] as $parameter) {
+ $this->assertArrayHasKey('$id', $parameter);
+ $this->assertIsString($parameter['$id']);
+ $this->assertNotEmpty($parameter['$id']);
+ $this->assertArrayHasKey('name', $parameter);
+ $this->assertIsString($parameter['name']);
+ $this->assertNotEmpty($parameter['name']);
+ $this->assertArrayHasKey('example', $parameter);
+ $this->assertIsString($parameter['example']);
+ $this->assertArrayHasKey('hint', $parameter);
+ $this->assertIsString($parameter['hint']);
+ }
+ }
+
+ // GitHub provider has the expected metadata for clientId, including the hint
+ $github = null;
+ foreach ($response['body']['oAuth2Providers'] as $provider) {
+ if ($provider['$id'] === 'github') {
+ $github = $provider;
+ break;
+ }
+ }
+ $this->assertNotNull($github);
+ $this->assertCount(2, $github['parameters']);
+ $clientId = $github['parameters'][0];
+ $this->assertEquals('clientId', $clientId['$id']);
+ $this->assertEquals('OAuth2 app Client ID, or App ID', $clientId['name']);
+ $this->assertEquals('e4d87900000000540733', $clientId['example']);
+ $this->assertEquals('Example of wrong value: 370006', $clientId['hint']);
+ $clientSecret = $github['parameters'][1];
+ $this->assertEquals('clientSecret', $clientSecret['$id']);
+ $this->assertEquals('Client Secret', $clientSecret['name']);
+ $this->assertNotEmpty($clientSecret['example']);
+ $this->assertEquals('', $clientSecret['hint']);
+
+ // Multi-parameter provider (Apple) exposes its non-clientSecret fields
+ $apple = null;
+ foreach ($response['body']['oAuth2Providers'] as $provider) {
+ if ($provider['$id'] === 'apple') {
+ $apple = $provider;
+ break;
+ }
+ }
+ $this->assertNotNull($apple);
+ $appleParamIds = \array_column($apple['parameters'], '$id');
+ $this->assertContains('serviceId', $appleParamIds);
+ $this->assertContains('keyId', $appleParamIds);
+ $this->assertContains('teamId', $appleParamIds);
+ $this->assertContains('p8File', $appleParamIds);
+ // Apple does not expose a single clientSecret param
+ $this->assertNotContains('clientSecret', $appleParamIds);
+
+ // Sandbox providers (e.g. paypalSandbox) are included
+ $this->assertContains('paypalSandbox', $providerIds);
+ }
+
+ public function testListKeyScopes(): void
+ {
+ $response = $this->client->call(Client::METHOD_GET, '/console/scopes/project', 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']);
+ $this->assertArrayHasKey('deprecated', $scope);
+ $this->assertIsBool($scope['deprecated']);
+ }
+
+ // 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 users', $usersRead['description']);
+ }
+
+ public function testListOrganizationScopes(): void
+ {
+ $response = $this->client->call(Client::METHOD_GET, '/console/scopes/organization', 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('projects.read', $scopeIds);
+ $this->assertContains('projects.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']);
+ $this->assertArrayHasKey('deprecated', $scope);
+ $this->assertIsBool($scope['deprecated']);
+ $this->assertArrayHasKey('category', $scope);
+ $this->assertIsString($scope['category']);
+ }
+
+ // A specific scope has the expected description
+ $projectsRead = null;
+ foreach ($response['body']['scopes'] as $scope) {
+ if ($scope['$id'] === 'projects.read') {
+ $projectsRead = $scope;
+ break;
+ }
+ }
+ $this->assertNotNull($projectsRead);
+ $this->assertEquals('Access to read organization projects', $projectsRead['description']);
+ }
}
diff --git a/tests/e2e/Services/Console/ConsoleCustomServerTest.php b/tests/e2e/Services/Console/ConsoleCustomServerTest.php
index 3748bbe546..e7a95fd357 100644
--- a/tests/e2e/Services/Console/ConsoleCustomServerTest.php
+++ b/tests/e2e/Services/Console/ConsoleCustomServerTest.php
@@ -24,4 +24,85 @@ class ConsoleCustomServerTest extends Scope
$this->assertEquals(401, $response['headers']['status-code']);
}
+
+ public function testListOAuth2Providers(): 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/oauth2-providers', [
+ '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']['oAuth2Providers']);
+ $this->assertGreaterThan(0, $response['body']['total']);
+
+ $providerIds = \array_column($response['body']['oAuth2Providers'], '$id');
+ $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/project', [
+ '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);
+
+ $usersRead = null;
+ foreach ($response['body']['scopes'] as $scope) {
+ if ($scope['$id'] === 'users.read') {
+ $usersRead = $scope;
+ break;
+ }
+ }
+ $this->assertNotNull($usersRead);
+ $this->assertIsString($usersRead['description']);
+ $this->assertNotEmpty($usersRead['description']);
+ $this->assertArrayHasKey('deprecated', $usersRead);
+ $this->assertIsBool($usersRead['deprecated']);
+ }
+
+ public function testListOrganizationScopes(): 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/organization', [
+ '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('projects.read', $scopeIds);
+
+ $projectsRead = null;
+ foreach ($response['body']['scopes'] as $scope) {
+ if ($scope['$id'] === 'projects.read') {
+ $projectsRead = $scope;
+ break;
+ }
+ }
+ $this->assertNotNull($projectsRead);
+ $this->assertIsString($projectsRead['description']);
+ $this->assertNotEmpty($projectsRead['description']);
+ $this->assertArrayHasKey('deprecated', $projectsRead);
+ $this->assertIsBool($projectsRead['deprecated']);
+ }
}
diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php
index 2c5e587fc2..e3efe3bbd9 100644
--- a/tests/e2e/Services/Databases/DatabasesBase.php
+++ b/tests/e2e/Services/Databases/DatabasesBase.php
@@ -936,7 +936,6 @@ trait DatabasesBase
{
if (!$this->getSupportForAttributes()) {
$this->markTestSkipped('Attributes are not supported by this database adapter');
- return;
}
// Use dedicated collections for this test to avoid conflicts with setupAttributes()
$data = $this->setupDatabase();
@@ -1189,7 +1188,6 @@ trait DatabasesBase
{
if (!$this->getSupportForAttributes()) {
$this->markTestSkipped('Attributes are not supported by this database adapter');
- return;
}
$data = $this->setupAttributes();
$databaseId = $data['databaseId'];
@@ -1221,7 +1219,6 @@ trait DatabasesBase
{
if (!$this->getSupportForAttributes()) {
$this->markTestSkipped('Attributes are not supported by this database adapter');
- return;
}
$data = $this->setupDatabase();
$databaseId = $data['databaseId'];
@@ -1290,7 +1287,6 @@ trait DatabasesBase
{
if (!$this->getSupportForAttributes()) {
$this->markTestSkipped('Attributes are not supported by this database adapter');
- return;
}
$database = $this->client->call(Client::METHOD_POST, $this->getApiBasePath(), [
'content-type' => 'application/json',
@@ -1351,7 +1347,6 @@ trait DatabasesBase
{
if (!$this->getSupportForAttributes()) {
$this->markTestSkipped('Attributes are not supported by this database adapter');
- return;
}
$data = $this->setupAttributes();
$databaseId = $data['databaseId'];
@@ -3324,7 +3319,6 @@ trait DatabasesBase
{
if (!$this->getSupportForAttributes()) {
$this->markTestSkipped('Attributes are not supported by this database adapter');
- return;
}
$data = $this->setupDocuments();
$databaseId = $data['databaseId'];
@@ -3368,7 +3362,7 @@ trait DatabasesBase
]);
$this->assertEquals(200, $documents2['headers']['status-code']);
- $this->assertEquals(3, $documents2['body']['total']);
+ $this->assertSame(3, $documents2['body']['total']);
$this->assertCount(3, $documents2['body'][$this->getRecordResource()]);
$this->assertEquals($documents1['body'][$this->getRecordResource()][0]['$id'], $documents2['body'][$this->getRecordResource()][0]['$id']);
$this->assertEquals($documents1['body'][$this->getRecordResource()][0]['title'], $documents2['body'][$this->getRecordResource()][0]['title']);
@@ -3458,7 +3452,6 @@ trait DatabasesBase
{
if (!$this->getSupportForAttributes()) {
$this->markTestSkipped('Attributes are not supported by this database adapter');
- return;
}
$data = $this->setupDocuments();
$databaseId = $data['databaseId'];
@@ -3531,7 +3524,6 @@ trait DatabasesBase
{
if (!$this->getSupportForAttributes()) {
$this->markTestSkipped('Attributes are not supported by this database adapter');
- return;
}
$data = $this->setupDocuments();
$databaseId = $data['databaseId'];
@@ -3578,7 +3570,6 @@ trait DatabasesBase
{
if (!$this->getSupportForAttributes()) {
$this->markTestSkipped('Attributes are not supported by this database adapter');
- return;
}
$data = $this->setupDocuments();
$databaseId = $data['databaseId'];
@@ -4929,7 +4920,6 @@ trait DatabasesBase
{
if (!$this->getSupportForAttributes()) {
$this->markTestSkipped('Attributes are not supported by this database adapter');
- return;
}
$database = $this->client->call(Client::METHOD_POST, $this->getApiBasePath(), array_merge([
'content-type' => 'application/json',
@@ -11539,4 +11529,162 @@ trait DatabasesBase
$this->assertEquals('Product B', $rows['body'][$this->getRecordResource()][0]['name']);
$this->assertEquals(139.99, $rows['body'][$this->getRecordResource()][0]['price']);
}
+ public function testDocumentWithEmptyPayload(): void
+ {
+ $data = $this->setupCollection();
+ $databaseId = $data['databaseId'];
+ $document = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ $this->getRecordIdParam() => ID::unique(),
+ 'data' => [],
+ 'permissions' => [
+ Permission::read(Role::user($this->getUser()['$id'])),
+ Permission::update(Role::user($this->getUser()['$id'])),
+ Permission::delete(Role::user($this->getUser()['$id'])),
+ ]
+ ]);
+ if ($this->getSupportForAttributes()) {
+ $this->assertEquals(400, $document['headers']['status-code']);
+ } else {
+ $this->assertEquals(201, $document['headers']['status-code']);
+ $this->assertEquals($data['moviesId'], $document['body'][$this->getContainerIdResponseKey()]);
+ $this->assertArrayNotHasKey('$collection', $document['body']);
+ $this->assertEquals($databaseId, $document['body']['$databaseId']);
+ $this->assertTrue(array_key_exists('$sequence', $document['body']));
+ $this->assertIsString($document['body']['$sequence']);
+
+ $documentId = $document['body']['$id'];
+
+ $fetched = $this->client->call(
+ Client::METHOD_GET,
+ $this->getRecordUrl($databaseId, $data['moviesId'], $documentId),
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders())
+ );
+
+ $this->assertEquals(200, $fetched['headers']['status-code']);
+ $this->assertEqualsCanonicalizing([
+ '$id',
+ '$databaseId',
+ '$createdAt',
+ '$updatedAt',
+ '$permissions',
+ '$sequence',
+ $this->getContainerIdResponseKey(),
+ ], \array_keys($fetched['body']));
+ $this->assertFalse(array_key_exists('$tenant', $fetched['body']));
+
+ $updated = $this->client->call(
+ Client::METHOD_PATCH,
+ $this->getRecordUrl($databaseId, $data['moviesId'], $documentId),
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()),
+ [
+ 'data' => [
+ 'status' => 'draft',
+ ],
+ ]
+ );
+
+ $this->assertEquals(200, $updated['headers']['status-code']);
+ $this->assertEquals('draft', $updated['body']['status']);
+
+ $refetched = $this->client->call(
+ Client::METHOD_GET,
+ $this->getRecordUrl($databaseId, $data['moviesId'], $documentId),
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders())
+ );
+
+ $this->assertEquals(200, $refetched['headers']['status-code']);
+ $this->assertEquals('draft', $refetched['body']['status']);
+ }
+ }
+
+ /**
+ * API keys may set $createdAt / $updatedAt; invalid strings must return 400, not 500.
+ * Assertions are HTTP status codes only (no error body matching).
+ */
+ public function testInvalidDate(): void
+ {
+ $data = $this->setupAttributes();
+ $databaseId = $data['databaseId'];
+ $invalidDatetime = '1dfs:12:55+sdf:00';
+ $validUpdatedAt = '2024-01-01T00:00:00Z';
+
+ $apiKeyHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+
+ $documentPayload = [
+ 'title' => 'Captain America',
+ 'releaseYear' => 1944,
+ 'actors' => [
+ 'Chris Evans',
+ 'Samuel Jackson',
+ ],
+ ];
+ $permissions = [
+ Permission::read(Role::user($this->getUser()['$id'])),
+ Permission::update(Role::user($this->getUser()['$id'])),
+ Permission::delete(Role::user($this->getUser()['$id'])),
+ ];
+
+ $invalidCreate = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $data['moviesId']), $apiKeyHeaders, [
+ $this->getRecordIdParam() => ID::unique(),
+ 'data' => \array_merge($documentPayload, ['$updatedAt' => $invalidDatetime]),
+ 'permissions' => $permissions,
+ ]);
+ $this->assertEquals(400, $invalidCreate['headers']['status-code']);
+
+ $document = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $data['moviesId']), $apiKeyHeaders, [
+ $this->getRecordIdParam() => ID::unique(),
+ 'data' => $documentPayload,
+ 'permissions' => $permissions,
+ ]);
+ $this->assertEquals(201, $document['headers']['status-code']);
+ $documentId = $document['body']['$id'];
+ $this->assertNotEmpty($documentId);
+
+ $invalidPatch = $this->client->call(
+ Client::METHOD_PATCH,
+ $this->getRecordUrl($databaseId, $data['moviesId'], $documentId),
+ $apiKeyHeaders,
+ [
+ 'data' => [
+ '$updatedAt' => $invalidDatetime,
+ ],
+ ]
+ );
+ $this->assertEquals(400, $invalidPatch['headers']['status-code']);
+
+ $updated = $this->client->call(
+ Client::METHOD_PATCH,
+ $this->getRecordUrl($databaseId, $data['moviesId'], $documentId),
+ $apiKeyHeaders,
+ [
+ 'data' => [
+ '$updatedAt' => $validUpdatedAt,
+ ],
+ ]
+ );
+ $this->assertEquals(200, $updated['headers']['status-code']);
+
+ $refetched = $this->client->call(
+ Client::METHOD_GET,
+ $this->getRecordUrl($databaseId, $data['moviesId'], $documentId),
+ $apiKeyHeaders
+ );
+ $this->assertEquals(200, $refetched['headers']['status-code']);
+ }
}
diff --git a/tests/e2e/Services/Databases/Transactions/ACIDBase.php b/tests/e2e/Services/Databases/Transactions/ACIDBase.php
index 1a6ee83b33..11b6de3b70 100644
--- a/tests/e2e/Services/Databases/Transactions/ACIDBase.php
+++ b/tests/e2e/Services/Databases/Transactions/ACIDBase.php
@@ -178,7 +178,6 @@ trait ACIDBase
{
if (!$this->getSupportForAttributes()) {
$this->markTestSkipped('This adapter does not support attributes; schema constraint consistency cannot be tested.');
- return;
}
// Create database
diff --git a/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php b/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php
index abe4d4968b..a7cad1c0f7 100644
--- a/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php
+++ b/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php
@@ -10,7 +10,6 @@ use Tests\E2E\Scopes\SideConsole;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
-use Utopia\Database\Query;
class DatabasesConsoleClientTest extends Scope
{
@@ -258,55 +257,4 @@ class DatabasesConsoleClientTest extends Scope
$this->assertIsArray($response['body']['documents']);
}
- #[Depends('testCreateCollection')]
- public function testGetCollectionLogs(array $data)
- {
- $databaseId = $data['databaseId'];
- /**
- * Test for SUCCESS
- */
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()));
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertIsNumeric($logs['body']['total']);
-
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'queries' => [Query::limit(1)->toString()]
- ]);
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertLessThanOrEqual(1, count($logs['body']['logs']));
- $this->assertIsNumeric($logs['body']['total']);
-
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'queries' => [Query::offset(1)->toString()]
- ]);
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertIsNumeric($logs['body']['total']);
-
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'queries' => [Query::offset(1)->toString(), Query::limit(1)->toString()]
- ]);
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertLessThanOrEqual(1, count($logs['body']['logs']));
- $this->assertIsNumeric($logs['body']['total']);
- }
}
diff --git a/tests/e2e/Services/Databases/VectorsDBConsoleClientTest.php b/tests/e2e/Services/Databases/VectorsDBConsoleClientTest.php
index 80c2bc9d5c..238d197158 100644
--- a/tests/e2e/Services/Databases/VectorsDBConsoleClientTest.php
+++ b/tests/e2e/Services/Databases/VectorsDBConsoleClientTest.php
@@ -10,7 +10,6 @@ use Tests\E2E\Scopes\SideConsole;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
-use Utopia\Database\Query;
class VectorsDBConsoleClientTest extends Scope
{
@@ -258,55 +257,4 @@ class VectorsDBConsoleClientTest extends Scope
$this->assertIsArray($response['body']['documents']);
}
- #[Depends('testCreateCollection')]
- public function testGetCollectionLogs(array $data)
- {
- $databaseId = $data['databaseId'];
- /**
- * Test for SUCCESS
- */
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()));
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertIsNumeric($logs['body']['total']);
-
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'queries' => [Query::limit(1)->toString()]
- ]);
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertLessThanOrEqual(1, count($logs['body']['logs']));
- $this->assertIsNumeric($logs['body']['total']);
-
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'queries' => [Query::offset(1)->toString()]
- ]);
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertIsNumeric($logs['body']['total']);
-
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'queries' => [Query::offset(1)->toString(), Query::limit(1)->toString()]
- ]);
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertLessThanOrEqual(1, count($logs['body']['logs']));
- $this->assertIsNumeric($logs['body']['total']);
- }
}
diff --git a/tests/e2e/Services/Databases/VectorsDBCustomClientTest.php b/tests/e2e/Services/Databases/VectorsDBCustomClientTest.php
index 7add5c7f71..632b1a62de 100644
--- a/tests/e2e/Services/Databases/VectorsDBCustomClientTest.php
+++ b/tests/e2e/Services/Databases/VectorsDBCustomClientTest.php
@@ -3,6 +3,7 @@
namespace Tests\E2E\Services\Databases;
use Tests\E2E\Client;
+use Tests\E2E\Scopes\ApiVectorsDB;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideClient;
@@ -16,6 +17,7 @@ class VectorsDBCustomClientTest extends Scope
use DatabasesBase;
use ProjectCustom;
use SideClient;
+ use ApiVectorsDB;
public function testAllowedPermissions(): void
{
diff --git a/tests/e2e/Services/Functions/FunctionsBase.php b/tests/e2e/Services/Functions/FunctionsBase.php
index 42976cda84..458359bbe9 100644
--- a/tests/e2e/Services/Functions/FunctionsBase.php
+++ b/tests/e2e/Services/Functions/FunctionsBase.php
@@ -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);
diff --git a/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php b/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php
index 06044d9984..5d501486fd 100644
--- a/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php
+++ b/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php
@@ -7,8 +7,10 @@ use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideConsole;
use Utopia\Console;
+use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Role;
+use Utopia\Database\Query;
class FunctionsConsoleClientTest extends Scope
{
@@ -70,6 +72,7 @@ class FunctionsConsoleClientTest extends Scope
$variable = $this->createVariable(
$functionId,
[
+ 'variableId' => ID::unique(),
'key' => 'APP_TEST',
'value' => 'TESTINGVALUE',
'secret' => false
@@ -82,6 +85,7 @@ class FunctionsConsoleClientTest extends Scope
$secretVariable = $this->createVariable(
$functionId,
[
+ 'variableId' => ID::unique(),
'key' => 'APP_TEST_1',
'value' => 'TESTINGVALUE_1',
'secret' => true
@@ -196,6 +200,7 @@ class FunctionsConsoleClientTest extends Scope
$variable = $this->createVariable(
$functionId,
[
+ 'variableId' => ID::unique(),
'key' => 'APP_TEST',
'value' => 'TESTINGVALUE',
'secret' => false
@@ -208,6 +213,7 @@ class FunctionsConsoleClientTest extends Scope
$variable = $this->createVariable(
$functionId,
[
+ 'variableId' => ID::unique(),
'key' => 'APP_TEST_1',
'value' => 'TESTINGVALUE_1',
'secret' => true
@@ -226,6 +232,7 @@ class FunctionsConsoleClientTest extends Scope
$variable = $this->createVariable(
$functionId,
[
+ 'variableId' => ID::unique(),
'key' => 'APP_TEST',
'value' => 'ANOTHERTESTINGVALUE',
'secret' => false
@@ -234,10 +241,47 @@ class FunctionsConsoleClientTest extends Scope
$this->assertEquals(409, $variable['headers']['status-code']);
+ // Test for invalid variableId
+ $variable = $this->createVariable(
+ $functionId,
+ [
+ 'variableId' => '!invalid-id!',
+ 'key' => 'INVALID_ID_KEY',
+ 'value' => 'value',
+ ]
+ );
+
+ $this->assertEquals(400, $variable['headers']['status-code']);
+
+ // Test for duplicate variableId
+ $duplicateVariableId = ID::unique();
+ $variable = $this->createVariable(
+ $functionId,
+ [
+ 'variableId' => $duplicateVariableId,
+ 'key' => 'DUP_ID_KEY_1',
+ 'value' => 'value1',
+ ]
+ );
+
+ $this->assertEquals(201, $variable['headers']['status-code']);
+
+ $duplicate = $this->createVariable(
+ $functionId,
+ [
+ 'variableId' => $duplicateVariableId,
+ 'key' => 'DUP_ID_KEY_2',
+ 'value' => 'value2',
+ ]
+ );
+
+ $this->assertEquals(409, $duplicate['headers']['status-code']);
+
// Test for invalid key
$variable = $this->createVariable(
$functionId,
[
+ 'variableId' => ID::unique(),
'key' => str_repeat("A", 256),
'value' => 'TESTINGVALUE'
]
@@ -249,6 +293,7 @@ class FunctionsConsoleClientTest extends Scope
$variable = $this->createVariable(
$functionId,
[
+ 'variableId' => ID::unique(),
'key' => 'LONGKEY',
'value' => str_repeat("#", 8193),
]
@@ -283,6 +328,150 @@ class FunctionsConsoleClientTest extends Scope
*/
}
+ public function testListVariablesWithLimit(): void
+ {
+ // Create a fresh function for this test
+ $function = $this->createFunction([
+ 'functionId' => ID::unique(),
+ 'name' => 'Test List Variables With Limit',
+ 'execute' => [Role::user($this->getUser()['$id'])->toString()],
+ 'runtime' => 'node-22',
+ 'entrypoint' => 'index.js',
+ 'timeout' => 10,
+ ]);
+ $this->assertEquals(201, $function['headers']['status-code']);
+ $functionId = $function['body']['$id'];
+
+ $variable1 = $this->createVariable($functionId, [
+ 'variableId' => ID::unique(),
+ 'key' => 'LIMIT_KEY_1',
+ 'value' => 'limit-value-1',
+ ]);
+ $this->assertEquals(201, $variable1['headers']['status-code']);
+
+ $variable2 = $this->createVariable($functionId, [
+ 'variableId' => ID::unique(),
+ 'key' => 'LIMIT_KEY_2',
+ 'value' => 'limit-value-2',
+ ]);
+ $this->assertEquals(201, $variable2['headers']['status-code']);
+
+ // List with limit of 1
+ $response = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/variables', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [
+ Query::limit(1)->toString(),
+ ],
+ 'total' => true,
+ ]);
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertCount(1, $response['body']['variables']);
+ $this->assertGreaterThanOrEqual(2, $response['body']['total']);
+
+ $this->cleanupFunction($functionId);
+ }
+
+ public function testListVariablesWithoutTotal(): void
+ {
+ // Create a fresh function for this test
+ $function = $this->createFunction([
+ 'functionId' => ID::unique(),
+ 'name' => 'Test List Variables Without Total',
+ 'execute' => [Role::user($this->getUser()['$id'])->toString()],
+ 'runtime' => 'node-22',
+ 'entrypoint' => 'index.js',
+ 'timeout' => 10,
+ ]);
+ $this->assertEquals(201, $function['headers']['status-code']);
+ $functionId = $function['body']['$id'];
+
+ $variable = $this->createVariable($functionId, [
+ 'variableId' => ID::unique(),
+ 'key' => 'NO_TOTAL_KEY',
+ 'value' => 'no-total-value',
+ ]);
+ $this->assertEquals(201, $variable['headers']['status-code']);
+
+ // List with total=false
+ $response = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/variables', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'total' => false,
+ ]);
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals(0, $response['body']['total']);
+ $this->assertGreaterThanOrEqual(1, \count($response['body']['variables']));
+
+ $this->cleanupFunction($functionId);
+ }
+
+ public function testListVariablesCursorPagination(): void
+ {
+ // Create a fresh function for this test
+ $function = $this->createFunction([
+ 'functionId' => ID::unique(),
+ 'name' => 'Test List Variables Cursor Pagination',
+ 'execute' => [Role::user($this->getUser()['$id'])->toString()],
+ 'runtime' => 'node-22',
+ 'entrypoint' => 'index.js',
+ 'timeout' => 10,
+ ]);
+ $this->assertEquals(201, $function['headers']['status-code']);
+ $functionId = $function['body']['$id'];
+
+ $variable1 = $this->createVariable($functionId, [
+ 'variableId' => ID::unique(),
+ 'key' => 'CURSOR_KEY_1',
+ 'value' => 'cursor-value-1',
+ ]);
+ $this->assertEquals(201, $variable1['headers']['status-code']);
+
+ $variable2 = $this->createVariable($functionId, [
+ 'variableId' => ID::unique(),
+ 'key' => 'CURSOR_KEY_2',
+ 'value' => 'cursor-value-2',
+ ]);
+ $this->assertEquals(201, $variable2['headers']['status-code']);
+
+ // Get first page with limit 1
+ $page1 = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/variables', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [
+ Query::limit(1)->toString(),
+ ],
+ 'total' => true,
+ ]);
+
+ $this->assertEquals(200, $page1['headers']['status-code']);
+ $this->assertCount(1, $page1['body']['variables']);
+ $cursorId = $page1['body']['variables'][0]['$id'];
+
+ // Get next page using cursor
+ $page2 = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/variables', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [
+ Query::limit(1)->toString(),
+ Query::cursorAfter(new Document(['$id' => $cursorId]))->toString(),
+ ],
+ 'total' => true,
+ ]);
+
+ $this->assertEquals(200, $page2['headers']['status-code']);
+ $this->assertCount(1, $page2['body']['variables']);
+ $this->assertNotEquals($cursorId, $page2['body']['variables'][0]['$id']);
+
+ $this->cleanupFunction($functionId);
+ }
+
public function testGetVariable(): void
{
$data = $this->setupTestVariables();
@@ -337,6 +526,7 @@ class FunctionsConsoleClientTest extends Scope
$functionId = $function['body']['$id'];
$variable = $this->createVariable($functionId, [
+ 'variableId' => ID::unique(),
'key' => 'APP_TEST',
'value' => 'TESTINGVALUE',
'secret' => false
@@ -345,6 +535,7 @@ class FunctionsConsoleClientTest extends Scope
$variableId = $variable['body']['$id'];
$secretVariable = $this->createVariable($functionId, [
+ 'variableId' => ID::unique(),
'key' => 'APP_TEST_1',
'value' => 'TESTINGVALUE_1',
'secret' => true
@@ -457,6 +648,7 @@ class FunctionsConsoleClientTest extends Scope
* Test for FAILURE
*/
+ // Update with no parameters should fail with 400
$response = $this->client->call(Client::METHOD_PUT, '/functions/' . $functionId . '/variables/' . $variableId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
@@ -464,6 +656,7 @@ class FunctionsConsoleClientTest extends Scope
$this->assertEquals(400, $response['headers']['status-code']);
+ // Update with only value should succeed
$response = $this->client->call(Client::METHOD_PUT, '/functions/' . $functionId . '/variables/' . $variableId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
@@ -471,7 +664,7 @@ class FunctionsConsoleClientTest extends Scope
'value' => 'TESTINGVALUEUPDATED_2'
]);
- $this->assertEquals(400, $response['headers']['status-code']);
+ $this->assertEquals(200, $response['headers']['status-code']);
$longKey = str_repeat("A", 256);
$response = $this->client->call(Client::METHOD_PUT, '/functions/' . $functionId . '/variables/' . $variableId, array_merge([
@@ -496,6 +689,110 @@ class FunctionsConsoleClientTest extends Scope
$this->assertEquals(400, $response['headers']['status-code']);
}
+ public function testUpdateVariableKey(): void
+ {
+ // Create a fresh function and variable for this test
+ $function = $this->createFunction([
+ 'functionId' => ID::unique(),
+ 'name' => 'Test Update Variable Key',
+ 'execute' => [Role::user($this->getUser()['$id'])->toString()],
+ 'runtime' => 'node-22',
+ 'entrypoint' => 'index.js',
+ 'timeout' => 10,
+ ]);
+ $this->assertEquals(201, $function['headers']['status-code']);
+ $functionId = $function['body']['$id'];
+
+ $variable = $this->createVariable($functionId, [
+ 'variableId' => ID::unique(),
+ 'key' => 'KEY_BEFORE',
+ 'value' => 'unchanged-value',
+ 'secret' => false
+ ]);
+ $this->assertEquals(201, $variable['headers']['status-code']);
+ $variableId = $variable['body']['$id'];
+
+ // Update only key (key is nullable, but we provide a new key)
+ $response = $this->client->call(Client::METHOD_PUT, '/functions/' . $functionId . '/variables/' . $variableId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'key' => 'KEY_AFTER',
+ ]);
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals('KEY_AFTER', $response['body']['key']);
+ $this->assertEquals('unchanged-value', $response['body']['value']);
+
+ $this->cleanupFunction($functionId);
+ }
+
+ public function testUpdateVariableValueOnly(): void
+ {
+ // Create a fresh function and variable for this test
+ $function = $this->createFunction([
+ 'functionId' => ID::unique(),
+ 'name' => 'Test Update Variable Value',
+ 'execute' => [Role::user($this->getUser()['$id'])->toString()],
+ 'runtime' => 'node-22',
+ 'entrypoint' => 'index.js',
+ 'timeout' => 10,
+ ]);
+ $this->assertEquals(201, $function['headers']['status-code']);
+ $functionId = $function['body']['$id'];
+
+ $variable = $this->createVariable($functionId, [
+ 'variableId' => ID::unique(),
+ 'key' => 'UNCHANGED_KEY',
+ 'value' => 'value-before',
+ 'secret' => false
+ ]);
+ $this->assertEquals(201, $variable['headers']['status-code']);
+ $variableId = $variable['body']['$id'];
+
+ // Update only value
+ $response = $this->client->call(Client::METHOD_PUT, '/functions/' . $functionId . '/variables/' . $variableId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'value' => 'value-after',
+ ]);
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals('UNCHANGED_KEY', $response['body']['key']);
+ $this->assertEquals('value-after', $response['body']['value']);
+
+ $this->cleanupFunction($functionId);
+ }
+
+ public function testUpdateVariableNotFound(): void
+ {
+ // Create a fresh function for this test
+ $function = $this->createFunction([
+ 'functionId' => ID::unique(),
+ 'name' => 'Test Update Variable Not Found',
+ 'execute' => [Role::user($this->getUser()['$id'])->toString()],
+ 'runtime' => 'node-22',
+ 'entrypoint' => 'index.js',
+ 'timeout' => 10,
+ ]);
+ $this->assertEquals(201, $function['headers']['status-code']);
+ $functionId = $function['body']['$id'];
+
+ $response = $this->client->call(Client::METHOD_PUT, '/functions/' . $functionId . '/variables/non-existent-id', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'key' => 'NEW_KEY',
+ 'value' => 'new-value',
+ ]);
+
+ $this->assertEquals(404, $response['headers']['status-code']);
+ $this->assertEquals('variable_not_found', $response['body']['type']);
+
+ $this->cleanupFunction($functionId);
+ }
+
public function testDeleteVariable(): void
{
// Create a fresh function and variables for this test since it deletes them
@@ -512,6 +809,7 @@ class FunctionsConsoleClientTest extends Scope
$functionId = $function['body']['$id'];
$variable = $this->createVariable($functionId, [
+ 'variableId' => ID::unique(),
'key' => 'APP_TEST',
'value' => 'TESTINGVALUE',
'secret' => false
@@ -520,6 +818,7 @@ class FunctionsConsoleClientTest extends Scope
$variableId = $variable['body']['$id'];
$secretVariable = $this->createVariable($functionId, [
+ 'variableId' => ID::unique(),
'key' => 'APP_TEST_1',
'value' => 'TESTINGVALUE_1',
'secret' => true
@@ -585,6 +884,7 @@ class FunctionsConsoleClientTest extends Scope
// create variable
$variable = $this->createVariable($functionId, [
+ 'variableId' => ID::unique(),
'key' => 'CUSTOM_VARIABLE',
'value' => 'a_secret_value',
'secret' => true,
diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php
index ba518ee0b6..44d5d274da 100644
--- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php
+++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php
@@ -53,14 +53,17 @@ class FunctionsCustomServerTest extends Scope
$functionId = $function['body']['$id'] ?? '';
$variable = $this->createVariable($functionId, [
+ 'variableId' => 'unique()',
'key' => 'funcKey1',
'value' => 'funcValue1',
]);
$variable2 = $this->createVariable($functionId, [
+ 'variableId' => 'unique()',
'key' => 'funcKey2',
'value' => 'funcValue2',
]);
$variable3 = $this->createVariable($functionId, [
+ 'variableId' => 'unique()',
'key' => 'funcKey3',
'value' => 'funcValue3',
]);
@@ -109,6 +112,7 @@ class FunctionsCustomServerTest extends Scope
// Create a variable for later tests
$variable = $this->createVariable($functionId, [
+ 'variableId' => 'unique()',
'key' => 'GLOBAL_VARIABLE',
'value' => 'Global Variable Value',
]);
@@ -278,14 +282,17 @@ class FunctionsCustomServerTest extends Scope
$this->assertEquals(10, $function['body']['timeout']);
$variable = $this->createVariable($functionId, [
+ 'variableId' => 'unique()',
'key' => 'funcKey1',
'value' => 'funcValue1',
]);
$variable2 = $this->createVariable($functionId, [
+ 'variableId' => 'unique()',
'key' => 'funcKey2',
'value' => 'funcValue2',
]);
$variable3 = $this->createVariable($functionId, [
+ 'variableId' => 'unique()',
'key' => 'funcKey3',
'value' => 'funcValue3',
]);
@@ -521,6 +528,7 @@ class FunctionsCustomServerTest extends Scope
// Create a variable for later tests
$variable = $this->createVariable($functionId, [
+ 'variableId' => 'unique()',
'key' => 'GLOBAL_VARIABLE',
'value' => 'Global Variable Value',
]);
@@ -567,6 +575,44 @@ class FunctionsCustomServerTest extends Scope
}, 120000, 500);
}
+ public function testCreateDeploymentWithSingleContentRangeChunk(): void
+ {
+ $functionId = $this->setupFunction([
+ 'functionId' => ID::unique(),
+ 'name' => 'Test Single Chunk Range',
+ 'execute' => [Role::user($this->getUser()['$id'])->toString()],
+ 'runtime' => 'node-22',
+ 'entrypoint' => 'index.js',
+ 'timeout' => 10,
+ ]);
+
+ $code = $this->packageFunction('basic');
+ $size = \filesize($code->getFilename());
+
+ $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'content-range' => 'bytes 0-' . ($size - 1) . '/' . $size,
+ ], $this->getHeaders()), [
+ 'code' => $code,
+ 'activate' => true,
+ ]);
+
+ $this->assertEquals(202, $deployment['headers']['status-code']);
+ $this->assertNotEmpty($deployment['body']['$id']);
+
+ $deploymentId = $deployment['body']['$id'];
+
+ $this->assertEventually(function () use ($functionId, $deploymentId) {
+ $deployment = $this->getDeployment($functionId, $deploymentId);
+
+ $this->assertEquals(200, $deployment['headers']['status-code']);
+ $this->assertEquals('ready', $deployment['body']['status']);
+ }, 120000, 500);
+
+ $this->cleanupFunction($functionId);
+ }
+
public function testCreateFunctionAndDeploymentFromTemplate()
{
@@ -662,7 +708,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',
]);
@@ -1041,6 +1087,256 @@ class FunctionsCustomServerTest extends Scope
}, 120000, 500);
}
+ public function testCreateDeploymentOutOfOrder(): void
+ {
+ $data = $this->setupTestFunction();
+ $functionId = $data['functionId'];
+
+ // Prepare a code file that spans at least 3 chunks
+ $folder = 'large';
+ $folderPath = realpath(__DIR__ . '/../../../resources/functions') . "/$folder";
+ $code = "$folderPath/code.tar.gz";
+
+
+
+ $totalSize = filesize($code);
+ $chunkSize = 5 * 1024 * 1024; // 5MB chunks
+ $mimeType = 'application/x-gzip';
+ $chunksTotal = (int) ceil($totalSize / $chunkSize);
+
+ // Read all chunks into memory
+ $handle = fopen($code, "rb");
+ $this->assertNotFalse($handle, "Could not open test resource: $code");
+ $chunks = [];
+ for ($i = 0; $i < $chunksTotal; $i++) {
+ $start = $i * $chunkSize;
+ $end = min($start + $chunkSize, $totalSize);
+ $length = $end - $start;
+ $chunkData = fread($handle, $length);
+ $chunks[] = [
+ 'data' => $chunkData,
+ 'start' => $start,
+ 'end' => $end - 1,
+ 'index' => $i,
+ ];
+ }
+ fclose($handle);
+
+ // We need at least 2 chunks for a meaningful out-of-order test
+ $this->assertGreaterThanOrEqual(2, count($chunks), 'Test file must span at least 2 chunks');
+
+ // Upload chunks in out-of-order sequence: last chunk first, then first, then second
+ $uploadOrder = [count($chunks) - 1, 0, 1];
+ $deploymentId = '';
+ $deployment = null;
+
+ foreach ($uploadOrder as $chunkIndex) {
+ $chunk = $chunks[$chunkIndex];
+ $curlFile = new \CURLFile(
+ 'data://' . $mimeType . ';base64,' . base64_encode($chunk['data']),
+ $mimeType,
+ 'large-fx.tar.gz'
+ );
+
+ $headers = [
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'content-range' => 'bytes ' . $chunk['start'] . '-' . $chunk['end'] . '/' . $totalSize,
+ ];
+
+ if (!empty($deploymentId)) {
+ $headers['x-appwrite-id'] = $deploymentId;
+ }
+
+ $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge($headers, $this->getHeaders()), [
+ 'entrypoint' => 'index.js',
+ 'code' => $curlFile,
+ 'activate' => true,
+ ]);
+
+ $this->assertEquals(202, $deployment['headers']['status-code']);
+ $deploymentId = $deployment['body']['$id'];
+ }
+
+ // Upload remaining chunks in any order to complete the file
+ $remainingChunks = [];
+ for ($i = 2; $i < count($chunks) - 1; $i++) {
+ $remainingChunks[] = $i;
+ }
+ shuffle($remainingChunks);
+
+ foreach ($remainingChunks as $chunkIndex) {
+ $chunk = $chunks[$chunkIndex];
+ $curlFile = new \CURLFile(
+ 'data://' . $mimeType . ';base64,' . base64_encode($chunk['data']),
+ $mimeType,
+ 'large-fx.tar.gz'
+ );
+
+ $headers = [
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'content-range' => 'bytes ' . $chunk['start'] . '-' . $chunk['end'] . '/' . $totalSize,
+ 'x-appwrite-id' => $deploymentId,
+ ];
+
+ $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge($headers, $this->getHeaders()), [
+ 'entrypoint' => 'index.js',
+ 'code' => $curlFile,
+ 'activate' => true,
+ ]);
+
+ $this->assertEquals(202, $deployment['headers']['status-code']);
+ }
+
+
+
+ // Wait for build to complete
+ $this->assertEventually(function () use ($functionId, $deploymentId) {
+ $deployment = $this->getDeployment($functionId, $deploymentId);
+ $this->assertEquals(200, $deployment['headers']['status-code']);
+ $this->assertEquals('ready', $deployment['body']['status']);
+ }, 120000, 500);
+ }
+
+ public function testCreateDeploymentParallelChunksLargeFile(): void
+ {
+ $functionId = $this->setupFunction([
+ 'functionId' => ID::unique(),
+ 'name' => 'Test Parallel Chunk Deployment',
+ 'execute' => [Role::user($this->getUser()['$id'])->toString()],
+ 'runtime' => 'node-22',
+ 'entrypoint' => 'index.js',
+ 'timeout' => 10,
+ ]);
+
+ $deploymentId = ID::unique();
+ $tmpDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'appwrite-parallel-function-deployment-' . $deploymentId;
+
+ mkdir($tmpDirectory);
+
+ try {
+ copy(__DIR__ . '/../../../resources/functions/basic/index.js', $tmpDirectory . DIRECTORY_SEPARATOR . 'index.js');
+ file_put_contents($tmpDirectory . DIRECTORY_SEPARATOR . 'large.bin', random_bytes(20 * 1024 * 1024));
+
+ $source = $tmpDirectory . DIRECTORY_SEPARATOR . 'code.tar.gz';
+ Console::execute('cd ' . $tmpDirectory . ' && tar --exclude code.tar.gz -czf code.tar.gz .', '', $this->stdout, $this->stderr);
+
+ $totalSize = filesize($source);
+ $chunkSize = 5 * 1024 * 1024;
+ $chunksTotal = (int) ceil($totalSize / $chunkSize);
+
+ $this->assertGreaterThanOrEqual(4, $chunksTotal, 'Test deployment must span at least 4 chunks');
+
+ $requests = [];
+ $sourceHandle = fopen($source, 'rb');
+ $this->assertNotFalse($sourceHandle, 'Could not open deployment package');
+
+ try {
+ for ($i = 0; $i < $chunksTotal; $i++) {
+ $start = $i * $chunkSize;
+ $end = min($start + $chunkSize, $totalSize) - 1;
+ $length = $end - $start + 1;
+ $chunkPath = $tmpDirectory . DIRECTORY_SEPARATOR . 'chunk-' . $i . '.part';
+
+ fseek($sourceHandle, $start);
+ file_put_contents($chunkPath, fread($sourceHandle, $length));
+
+ $requests[] = [
+ 'headers' => [
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ 'x-appwrite-id' => $deploymentId,
+ 'content-range' => 'bytes ' . $start . '-' . $end . '/' . $totalSize,
+ ],
+ 'chunkPath' => $chunkPath,
+ ];
+ }
+ } finally {
+ fclose($sourceHandle);
+ }
+
+ $responses = [];
+ $endpoint = parse_url($this->client->getEndpoint());
+ $scheme = $endpoint['scheme'] ?? 'http';
+ $host = $endpoint['host'] ?? 'appwrite';
+ $port = $endpoint['port'] ?? ($scheme === 'https' ? 443 : 80);
+ $basePath = rtrim($endpoint['path'] ?? '', '/');
+
+ \Swoole\Coroutine\run(function () use ($basePath, $functionId, $host, $port, $requests, $scheme, &$responses): void {
+ $wg = new \Swoole\Coroutine\WaitGroup();
+
+ foreach ($requests as $index => $request) {
+ $wg->add();
+ \Swoole\Coroutine::create(function () use ($basePath, $functionId, $host, $index, $port, $request, &$responses, $scheme, $wg): void {
+ try {
+ for ($attempt = 0; $attempt < 3; $attempt++) {
+ $client = new \Swoole\Coroutine\Http\Client($host, (int) $port, $scheme === 'https');
+ $client->set([
+ 'timeout' => 300,
+ 'ssl_verify_peer' => false,
+ 'ssl_verify_host' => false,
+ ]);
+ $client->setHeaders($request['headers']);
+ $client->setMethod(Client::METHOD_POST);
+ $client->setData([
+ 'entrypoint' => 'index.js',
+ 'activate' => true,
+ ]);
+ $client->addFile($request['chunkPath'], 'code', 'application/x-gzip', 'code.tar.gz');
+ $client->execute($basePath . '/functions/' . $functionId . '/deployments');
+
+ $responses[$index] = [
+ 'body' => $client->body,
+ 'error' => $client->errMsg,
+ 'headers' => $client->headers ?? [],
+ 'statusCode' => $client->statusCode,
+ ];
+
+ $client->close();
+
+ if ($responses[$index]['statusCode'] !== 429) {
+ break;
+ }
+
+ $retryAfter = (float) ($responses[$index]['headers']['retry-after'] ?? 0.1);
+ \Swoole\Coroutine::sleep(max($retryAfter, 0.1));
+ }
+ } finally {
+ $wg->done();
+ }
+ });
+ }
+
+ $wg->wait();
+ });
+
+ ksort($responses);
+
+ foreach ($responses as $response) {
+ $this->assertSame('', $response['error']);
+ $this->assertContains($response['statusCode'], [202], (string) $response['body']);
+ }
+
+ $this->assertEventually(function () use ($functionId, $deploymentId) {
+ $deployment = $this->getDeployment($functionId, $deploymentId);
+
+ $this->assertEquals(200, $deployment['headers']['status-code']);
+ $this->assertEquals('ready', $deployment['body']['status']);
+ $this->assertEquals($deploymentId, $deployment['body']['$id']);
+ }, 120000, 500);
+ } finally {
+ $this->cleanupFunction($functionId);
+
+ if (is_dir($tmpDirectory)) {
+ foreach (glob($tmpDirectory . DIRECTORY_SEPARATOR . '*') ?: [] as $file) {
+ unlink($file);
+ }
+ rmdir($tmpDirectory);
+ }
+ }
+ }
+
public function testUpdateDeployment(): void
{
$data = $this->setupTestDeployment();
@@ -1861,6 +2157,7 @@ class FunctionsCustomServerTest extends Scope
]);
$variable = $this->createVariable($functionId, [
+ 'variableId' => 'unique()',
'key' => 'CUSTOM_VARIABLE',
'value' => 'variable'
]);
@@ -2091,7 +2388,7 @@ class FunctionsCustomServerTest extends Scope
]);
$deploymentId = $this->setupDeployment($functionId, [
- 'code' => $this->packageFunction('dynamic-api-key'),
+ 'code' => $this->packageFunction('ephemeral-api-key'),
'activate' => true,
]);
@@ -2481,17 +2778,17 @@ class FunctionsCustomServerTest extends Scope
$this->assertEmpty($executions['body']['executions'][0]['logs']);
$this->assertEmpty($executions['body']['executions'][0]['errors']);
- // Ensure executions count
- $executions = $this->listExecutions($functionId);
+ $this->assertEventually(function () use ($functionId) {
+ $executions = $this->listExecutions($functionId);
- $this->assertEquals(200, $executions['headers']['status-code']);
- $this->assertCount(3, $executions['body']['executions']);
+ $this->assertEquals(200, $executions['headers']['status-code']);
+ $this->assertCount(3, $executions['body']['executions']);
- // Double check logs and errors are empty
- foreach ($executions['body']['executions'] as $execution) {
- $this->assertEmpty($execution['logs']);
- $this->assertEmpty($execution['errors']);
- }
+ foreach ($executions['body']['executions'] as $execution) {
+ $this->assertEmpty($execution['logs']);
+ $this->assertEmpty($execution['errors']);
+ }
+ }, 10000, 500);
$this->cleanupFunction($functionId);
}
diff --git a/tests/e2e/Services/GraphQL/Base.php b/tests/e2e/Services/GraphQL/Base.php
index c42679018e..9010490cc9 100644
--- a/tests/e2e/Services/GraphQL/Base.php
+++ b/tests/e2e/Services/GraphQL/Base.php
@@ -4,6 +4,7 @@ namespace Tests\E2E\Services\GraphQL;
use CURLFile;
use Utopia\Console;
+use Utopia\Image\Image;
trait Base
{
@@ -516,6 +517,21 @@ trait Base
}
';
+ protected function assertFilePreviewResponse(array $file): void
+ {
+ $this->assertEquals(200, $file['headers']['status-code']);
+ $this->assertEquals('image/png', $file['headers']['content-type']);
+ $this->assertNotEmpty($file['body']);
+
+ $image = new Image($file['body']);
+ $dimensions = \getimagesizefromstring($file['body']);
+
+ $this->assertNotEmpty($image->output('png'));
+ $this->assertIsArray($dimensions);
+ $this->assertEquals(100, $dimensions[0]);
+ $this->assertEquals(100, $dimensions[1]);
+ }
+
public function getQuery(string $name): string
{
switch ($name) {
@@ -2388,8 +2404,8 @@ trait Base
}
}';
case self::GET_FILE_PREVIEW:
- return 'query getFilePreview($bucketId: String!, $fileId: String!) {
- storageGetFilePreview(bucketId: $bucketId, fileId: $fileId) {
+ return 'query getFilePreview($bucketId: String!, $fileId: String!, $width: Int, $height: Int) {
+ storageGetFilePreview(bucketId: $bucketId, fileId: $fileId, width: $width, height: $height) {
status
}
}';
diff --git a/tests/e2e/Services/GraphQL/FunctionsClientTest.php b/tests/e2e/Services/GraphQL/FunctionsClientTest.php
index 8dc2fe337f..e8e033f353 100644
--- a/tests/e2e/Services/GraphQL/FunctionsClientTest.php
+++ b/tests/e2e/Services/GraphQL/FunctionsClientTest.php
@@ -55,10 +55,10 @@ class FunctionsClientTest extends Scope
$query = '
mutation createVariables($functionId: String!) {
- var1: functionsCreateVariable(functionId: $functionId, key: "name", value: "John Doe") {
+ var1: functionsCreateVariable(functionId: $functionId, variableId: "unique()", key: "name", value: "John Doe") {
_id
}
- var2: functionsCreateVariable(functionId: $functionId, key: "age", value: "42") {
+ var2: functionsCreateVariable(functionId: $functionId, variableId: "unique()", key: "age", value: "42") {
_id
}
}
@@ -184,7 +184,7 @@ class FunctionsClientTest extends Scope
public function testCreateFunction(): void
{
$function = $this->setupFunction();
- $this->assertIsArray($function);
+ $this->assertNotEmpty($function);
}
/**
@@ -194,7 +194,7 @@ class FunctionsClientTest extends Scope
public function testCreateDeployment(): void
{
$deployment = $this->setupDeployment();
- $this->assertIsArray($deployment);
+ $this->assertNotEmpty($deployment);
}
/**
@@ -204,7 +204,7 @@ class FunctionsClientTest extends Scope
public function testCreateExecution(): void
{
$execution = $this->setupExecution();
- $this->assertIsArray($execution);
+ $this->assertNotEmpty($execution);
}
/**
diff --git a/tests/e2e/Services/GraphQL/FunctionsServerTest.php b/tests/e2e/Services/GraphQL/FunctionsServerTest.php
index 8e1c7ac7e7..95b52bcbe3 100644
--- a/tests/e2e/Services/GraphQL/FunctionsServerTest.php
+++ b/tests/e2e/Services/GraphQL/FunctionsServerTest.php
@@ -55,10 +55,10 @@ class FunctionsServerTest extends Scope
$query = '
mutation createVariables($functionId: String!) {
- var1: functionsCreateVariable(functionId: $functionId, key: "name", value: "John Doe") {
+ var1: functionsCreateVariable(functionId: $functionId, variableId: "unique()", key: "name", value: "John Doe") {
_id
}
- var2: functionsCreateVariable(functionId: $functionId, key: "age", value: "42") {
+ var2: functionsCreateVariable(functionId: $functionId, variableId: "unique()", key: "age", value: "42") {
_id
}
}
@@ -186,7 +186,7 @@ class FunctionsServerTest extends Scope
public function testCreateFunction(): void
{
$function = $this->setupFunction();
- $this->assertIsArray($function);
+ $this->assertNotEmpty($function);
}
/**
@@ -196,7 +196,7 @@ class FunctionsServerTest extends Scope
public function testCreateDeployment(): void
{
$deployment = $this->setupDeployment();
- $this->assertIsArray($deployment);
+ $this->assertNotEmpty($deployment);
}
/**
@@ -206,7 +206,7 @@ class FunctionsServerTest extends Scope
public function testCreateExecution(): void
{
$execution = $this->setupExecution();
- $this->assertIsArray($execution);
+ $this->assertNotEmpty($execution);
}
/**
diff --git a/tests/e2e/Services/GraphQL/Legacy/AuthTest.php b/tests/e2e/Services/GraphQL/Legacy/AuthTest.php
index 4a3e49cc60..d3c6d01ffa 100644
--- a/tests/e2e/Services/GraphQL/Legacy/AuthTest.php
+++ b/tests/e2e/Services/GraphQL/Legacy/AuthTest.php
@@ -18,7 +18,6 @@ class AuthTest extends Scope
use Base;
private array $account1;
- private array $account2;
private string $token1;
private string $token2;
diff --git a/tests/e2e/Services/GraphQL/PresenceTest.php b/tests/e2e/Services/GraphQL/PresenceTest.php
new file mode 100644
index 0000000000..b0329e1c97
--- /dev/null
+++ b/tests/e2e/Services/GraphQL/PresenceTest.php
@@ -0,0 +1,52 @@
+getProject()['$id'];
+ $apiKey = $this->getNewKey(['presences.write']);
+ $user = $this->getUser(true);
+
+ $payload = [
+ 'query' => <<<'GQL'
+ mutation upsert($presenceId: String!, $userId: String!, $status: String!, $metadata: Json) {
+ presencesUpsert(presenceId: $presenceId, userId: $userId, status: $status, metadata: $metadata) {
+ _id
+ userId
+ status
+ source
+ }
+ }
+ GQL,
+ 'variables' => [
+ 'presenceId' => ID::unique(),
+ 'userId' => $user['$id'],
+ 'status' => 'online',
+ 'metadata' => [
+ 'testRunId' => ID::unique(),
+ ],
+ ],
+ ];
+
+ $response = $this->client->call(Client::METHOD_POST, '/graphql', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $apiKey,
+ ], $payload);
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals('online', $response['body']['data']['presencesUpsert']['status']);
+ $this->assertEquals('graphql', $response['body']['data']['presencesUpsert']['source']);
+ }
+}
diff --git a/tests/e2e/Services/GraphQL/StorageClientTest.php b/tests/e2e/Services/GraphQL/StorageClientTest.php
index 25041e843b..9cdf523a0a 100644
--- a/tests/e2e/Services/GraphQL/StorageClientTest.php
+++ b/tests/e2e/Services/GraphQL/StorageClientTest.php
@@ -112,7 +112,7 @@ class StorageClientTest extends Scope
public function testCreateFile(): void
{
$file = $this->setupFile();
- $this->assertIsArray($file);
+ $this->assertNotEmpty($file);
}
/**
@@ -200,7 +200,7 @@ class StorageClientTest extends Scope
'x-appwrite-project' => $projectId,
], $this->getHeaders()), $gqlPayload);
- $this->assertEquals(46719, \strlen($file['body']));
+ $this->assertFilePreviewResponse($file);
return $file;
}
diff --git a/tests/e2e/Services/GraphQL/StorageServerTest.php b/tests/e2e/Services/GraphQL/StorageServerTest.php
index cc4c8ecec3..7808c50be6 100644
--- a/tests/e2e/Services/GraphQL/StorageServerTest.php
+++ b/tests/e2e/Services/GraphQL/StorageServerTest.php
@@ -111,7 +111,7 @@ class StorageServerTest extends Scope
public function testCreateFile(): void
{
$file = $this->setupFile();
- $this->assertIsArray($file);
+ $this->assertNotEmpty($file);
}
public function testGetBuckets(): array
@@ -262,7 +262,7 @@ class StorageServerTest extends Scope
'x-appwrite-project' => $projectId,
], $this->getHeaders()), $gqlPayload);
- $this->assertEquals(46719, \strlen($file['body']));
+ $this->assertFilePreviewResponse($file);
return $file;
}
diff --git a/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php b/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php
index 9c6910fb30..13f083f0eb 100644
--- a/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php
+++ b/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php
@@ -18,7 +18,6 @@ class AuthTest extends Scope
use Base;
private array $account1;
- private array $account2;
private string $token1;
private string $token2;
diff --git a/tests/e2e/Services/GraphQL/TeamsServerTest.php b/tests/e2e/Services/GraphQL/TeamsServerTest.php
index ff6e8e3c6f..dd546119e2 100644
--- a/tests/e2e/Services/GraphQL/TeamsServerTest.php
+++ b/tests/e2e/Services/GraphQL/TeamsServerTest.php
@@ -199,7 +199,7 @@ class TeamsServerTest extends Scope
public function testUpdateTeamPrefs()
{
$team = $this->setupTeamWithPrefs();
- $this->assertIsArray($team);
+ $this->assertNotEmpty($team);
}
public function testGetTeamPreferences()
diff --git a/tests/e2e/Services/Messaging/MessagingBase.php b/tests/e2e/Services/Messaging/MessagingBase.php
index d83b450739..43dc58d44c 100644
--- a/tests/e2e/Services/Messaging/MessagingBase.php
+++ b/tests/e2e/Services/Messaging/MessagingBase.php
@@ -2525,6 +2525,119 @@ trait MessagingBase
$this->assertEquals(0, \count($message['body']['deliveryErrors']));
}
+ public function testCreatePushNotificationWithUsersRecipients(): void
+ {
+ if (empty(System::getEnv('_APP_MESSAGE_PUSH_TEST_DSN'))) {
+ $this->markTestSkipped('Push DSN empty');
+ }
+
+ $dsn = new DSN(System::getEnv('_APP_MESSAGE_PUSH_TEST_DSN'));
+ $to = $dsn->getParam('to');
+ $serviceAccountJSON = $dsn->getParam('serviceAccountJSON');
+
+ if (empty($to) || empty($serviceAccountJSON)) {
+ $this->markTestSkipped('Push provider not configured');
+ }
+
+ $provider1 = $this->client->call(Client::METHOD_POST, '/messaging/providers/fcm', \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]), [
+ 'providerId' => ID::unique(),
+ 'name' => 'FCM-Users-1',
+ 'serviceAccountJSON' => $serviceAccountJSON,
+ 'enabled' => true,
+ ]);
+
+ $this->assertEquals(201, $provider1['headers']['status-code']);
+
+ $provider2 = $this->client->call(Client::METHOD_POST, '/messaging/providers/fcm', \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]), [
+ 'providerId' => ID::unique(),
+ 'name' => 'FCM-Users-2',
+ 'serviceAccountJSON' => $serviceAccountJSON,
+ 'enabled' => true,
+ ]);
+
+ $this->assertEquals(201, $provider2['headers']['status-code']);
+
+ $user = $this->client->call(Client::METHOD_POST, '/users', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'userId' => ID::unique(),
+ 'email' => uniqid() . "@mail.org",
+ 'password' => 'password',
+ 'name' => 'Messaging User Recipients',
+ ]);
+
+ $this->assertEquals(201, $user['headers']['status-code']);
+
+ $target1 = $this->client->call(Client::METHOD_POST, '/users/' . $user['body']['$id'] . '/targets', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'targetId' => ID::unique(),
+ 'providerType' => 'push',
+ 'providerId' => $provider1['body']['$id'],
+ 'identifier' => $to,
+ ]);
+
+ $this->assertEquals(201, $target1['headers']['status-code']);
+
+ $target2 = $this->client->call(Client::METHOD_POST, '/users/' . $user['body']['$id'] . '/targets', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'targetId' => ID::unique(),
+ 'providerType' => 'push',
+ 'providerId' => $provider2['body']['$id'],
+ 'identifier' => $to,
+ ]);
+
+ $this->assertEquals(201, $target2['headers']['status-code']);
+
+ $push = $this->client->call(Client::METHOD_POST, '/messaging/messages/push', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'messageId' => ID::unique(),
+ 'users' => [$user['body']['$id']],
+ 'title' => 'Test-Notification-Users',
+ 'body' => 'Test-Notification-Body-Users',
+ ]);
+
+ $this->assertEquals(201, $push['headers']['status-code']);
+
+ $pushMessageId = $push['body']['$id'];
+ $this->assertEventually(function () use ($pushMessageId) {
+ $response = $this->client->call(Client::METHOD_GET, '/messaging/messages/' . $pushMessageId, [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertContains($response['body']['status'], ['sent', 'failed']);
+ }, 30000, 500);
+
+ $message = $this->client->call(Client::METHOD_GET, '/messaging/messages/' . $pushMessageId, [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]);
+
+ $this->assertEquals(200, $message['headers']['status-code']);
+ $this->assertEquals(2, $message['body']['deliveredTotal'] + \count($message['body']['deliveryErrors']));
+ }
+
public function testUpdatePushNotification(): void
{
$push = $this->setupSentPushData();
diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php
index 9e9ce2fbcd..82fcee4838 100644
--- a/tests/e2e/Services/Migrations/MigrationsBase.php
+++ b/tests/e2e/Services/Migrations/MigrationsBase.php
@@ -761,6 +761,1275 @@ trait MigrationsBase
self::$cachedTableData = [];
}
+ /** Rows under all three modes; schema tolerance lets every run hit 'completed'. */
+ public function testAppwriteMigrationRowsOnDuplicate(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => ID::unique(),
+ 'data' => ['name' => 'Original'],
+ ]);
+ $this->assertEquals(201, $row['headers']['status-code']);
+ $rowId = $row['body']['$id'];
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ // First migration: destination is empty, strict completion expected.
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ // Mutate destination row to prove onDuplicate=skip preserves it.
+ $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders, [
+ 'data' => ['name' => 'Mutated'],
+ ]);
+ $this->assertEquals(200, $mutate['headers']['status-code']);
+ $this->assertEquals('Mutated', $mutate['body']['name']);
+
+ // Re-migration with onDuplicate=skip — completion is strict because
+ // DestinationAppwrite tolerates existing schema resources.
+ $skipResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEquals('completed', $skipResult['status']);
+
+ $rowAfterSkip = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $rowAfterSkip['headers']['status-code']);
+ $this->assertEquals('Mutated', $rowAfterSkip['body']['name'], 'onDuplicate=skip must not overwrite destination row');
+
+ // Re-migration with onDuplicate=overwrite — strict completion; destination
+ // row restored to source value.
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ $rowAfterOverwrite = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $rowAfterOverwrite['headers']['status-code']);
+ $this->assertEquals('Original', $rowAfterOverwrite['body']['name'], 'onDuplicate=overwrite must restore source value');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Unchanged source under Skip/Overwrite is a no-op — every resource Tolerated. */
+ public function testAppwriteMigrationReRunIsIdempotent(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ // Seed two rows on source so the row-level tolerance is exercised too.
+ foreach (['row-a', 'row-b'] as $rowId) {
+ $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => $rowId,
+ 'data' => ['name' => 'Seeded ' . $rowId],
+ ]);
+ $this->assertEquals(201, $row['headers']['status-code']);
+ }
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ // First migration: fresh destination.
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ // Re-run under Skip: nothing on source has changed. Destination
+ // schema + rows are already correct — expect clean completion.
+ $reRunSkip = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEquals('completed', $reRunSkip['status']);
+
+ // Re-run under Overwrite: same unchanged source. Schema tolerance path
+ // fires for each resource; rows go through DB-native upsert.
+ $reRunOverwrite = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $reRunOverwrite['status']);
+
+ foreach (['row-a', 'row-b'] as $rowId) {
+ $check = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $check['headers']['status-code']);
+ $this->assertEquals('Seeded ' . $rowId, $check['body']['name']);
+ }
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Overwrite reconciles container drift via UpdateInPlace; children (rows) preserved. */
+ public function testAppwriteMigrationOverwriteUpdatesContainerMetadata(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+ $rowId = 'persist-me';
+
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => $rowId,
+ 'data' => ['name' => 'SeedRow'],
+ ]);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ // First migration — dest empty, strict completion.
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ // `_updatedAt` is stored at second granularity (strtotime) — ensure
+ // the source edits below produce a strictly-newer timestamp than
+ // dest's first-migration timestamp.
+ sleep(1);
+
+ // Mutate source: rename database + toggle table enabled.
+ $this->client->call(Client::METHOD_PUT, '/databases/' . $databaseId, $sourceHeaders, [
+ 'name' => 'Renamed Source DB',
+ ]);
+ $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $sourceHeaders, [
+ 'name' => 'Renamed Source Table',
+ 'permissions' => [Permission::read(Role::any())],
+ 'rowSecurity' => true,
+ 'enabled' => false,
+ ]);
+
+ // Overwrite re-migration: UpdateInPlace path fires for database + table.
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ // Assert dest database metadata reflects source's new values.
+ $destDb = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId, $destHeaders);
+ $this->assertEquals(200, $destDb['headers']['status-code']);
+ $this->assertEquals('Renamed Source DB', $destDb['body']['name']);
+
+ // Assert dest table metadata reflects source's new values.
+ $destTable = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $destHeaders);
+ $this->assertEquals(200, $destTable['headers']['status-code']);
+ $this->assertEquals('Renamed Source Table', $destTable['body']['name']);
+ $this->assertFalse($destTable['body']['enabled'], 'Overwrite must propagate source enabled=false');
+ $this->assertTrue($destTable['body']['documentSecurity'] ?? $destTable['body']['rowSecurity'], 'Overwrite must propagate source rowSecurity=true');
+
+ // Child row untouched — UpdateInPlace only rewrites container metadata.
+ $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $row['headers']['status-code']);
+ $this->assertEquals('SeedRow', $row['body']['name'], 'Overwrite must not touch child rows when updating container metadata');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Skip preserves dest container drift even when source has diverged. */
+ public function testAppwriteMigrationSkipPreservesContainerDrift(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ ];
+
+ // First migration: dest gets whatever source had.
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ sleep(1);
+
+ // Mutate dest: ops tightens permissions and renames the table for
+ // its production-specific branding.
+ $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $destHeaders, [
+ 'name' => 'Dest-Managed Table',
+ 'permissions' => [Permission::read(Role::users())],
+ 'rowSecurity' => false,
+ 'enabled' => true,
+ ]);
+
+ // Also mutate source so the second run has a real divergence.
+ $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $sourceHeaders, [
+ 'name' => 'Source Renamed',
+ 'permissions' => [Permission::read(Role::any())],
+ 'rowSecurity' => true,
+ 'enabled' => false,
+ ]);
+
+ // Skip re-migration: must tolerate existing destination — no update.
+ $skipResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEquals('completed', $skipResult['status']);
+
+ // Dest kept its tightened values.
+ $destTable = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $destHeaders);
+ $this->assertEquals(200, $destTable['headers']['status-code']);
+ $this->assertEquals('Dest-Managed Table', $destTable['body']['name'], 'Skip must not propagate source name over dest drift');
+ $this->assertTrue($destTable['body']['enabled'], 'Skip must preserve dest enabled flag');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Overwrite drops dest columns source no longer declares; cleanup runs before rows land. */
+ public function testAppwriteMigrationOverwriteDropsOrphanColumn(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ // First migration: dest mirrors source (one column 'name').
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ // Add an orphan column directly on destination (not on source).
+ // Simulates the post-rename state: source dropped a column, dest
+ // still has it — or a dest-only column added by a separate app.
+ $orphanResp = $this->client->call(
+ Client::METHOD_POST,
+ '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string',
+ $destHeaders,
+ [
+ 'key' => 'orphan_col',
+ 'size' => 50,
+ 'required' => false,
+ ]
+ );
+ $this->assertEquals(202, $orphanResp['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/orphan_col', $destHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 5000, 500);
+
+ // Seed a row on source so per-table orphan cleanup fires inside
+ // createRecord (before rows land), not just at end of run.
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => ID::unique(),
+ 'data' => ['name' => 'seed'],
+ ]);
+
+ // Overwrite re-migration: orphan_col must be dropped from dest.
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ // Orphan column dropped.
+ $orphanCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/orphan_col', $destHeaders);
+ $this->assertEquals(404, $orphanCheck['headers']['status-code'], 'Overwrite must drop destination column source no longer declares');
+
+ // Source's column preserved.
+ $nameCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $nameCheck['headers']['status-code'], 'Overwrite must preserve columns source declared');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Skip preserves orphan columns; cleanup is Overwrite-only. */
+ public function testAppwriteMigrationSkipKeepsOrphanColumn(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ $orphanResp = $this->client->call(
+ Client::METHOD_POST,
+ '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string',
+ $destHeaders,
+ [
+ 'key' => 'dest_only_col',
+ 'size' => 50,
+ 'required' => false,
+ ]
+ );
+ $this->assertEquals(202, $orphanResp['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/dest_only_col', $destHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 5000, 500);
+
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => ID::unique(),
+ 'data' => ['name' => 'seed'],
+ ]);
+
+ // Skip re-migration: orphan column must NOT be dropped.
+ $skipResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEquals('completed', $skipResult['status']);
+
+ $orphanCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/dest_only_col', $destHeaders);
+ $this->assertEquals(200, $orphanCheck['headers']['status-code'], 'Skip must preserve destination columns, including orphans');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** SDK-reachable attribute change propagates via updateAttributeInPlace; row data preserved. */
+ public function testAppwriteMigrationOverwriteUpdatesAttributeInPlace(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+ $rowId = 'persist-on-inplace';
+
+ // Seed a row that proves drop+recreate didn't happen — recreate would
+ // have wiped this column's data on the destination.
+ $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => $rowId,
+ 'data' => ['name' => 'SeedRow'],
+ ]);
+ $this->assertEquals(201, $row['headers']['status-code']);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ // First migration — dest gets the column as required:true.
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ $beforeUpdate = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $beforeUpdate['headers']['status-code']);
+ $this->assertTrue($beforeUpdate['body']['required']);
+
+ // _updatedAt has second granularity; ensure source's PATCH produces a
+ // strictly-newer timestamp than the dest's first-migration value.
+ sleep(1);
+
+ // SDK-reachable change set: required true→false, default null→'unknown'.
+ // Both fields are supported by PATCH /columns/string/:key — must route
+ // through updateAttributeInPlace, not DropAndRecreate.
+ $patch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string/name', $sourceHeaders, [
+ 'required' => false,
+ 'default' => 'unknown',
+ ]);
+ $this->assertEquals(200, $patch['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertFalse($r['body']['required']);
+ $this->assertEquals('unknown', $r['body']['default']);
+ }, 5000, 500);
+
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertFalse($r['body']['required'], 'updateAttributeInPlace must propagate source required=false');
+ $this->assertEquals('unknown', $r['body']['default'], 'updateAttributeInPlace must propagate source default');
+ }, 10000, 500);
+
+ // Pre-existing row preserved — proof that the path was UpdateInPlace
+ // and not DropAndRecreate (which would have nulled this column).
+ $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $rowAfter['headers']['status-code']);
+ $this->assertEquals('SeedRow', $rowAfter['body']['name'], 'updateAttributeInPlace must not touch row data');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Skip preserves dest attribute drift; leaf-level analog of the container drift test. */
+ public function testAppwriteMigrationSkipPreservesAttributeDrift(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ sleep(1);
+
+ // Dest divergence: ops loosens the column for a production-only need.
+ $destPatch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string/name', $destHeaders, [
+ 'required' => false,
+ 'default' => 'dest-default',
+ ]);
+ $this->assertEquals(200, $destPatch['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertFalse($r['body']['required']);
+ }, 5000, 500);
+
+ sleep(1);
+
+ // Source advances strictly later (and to a different value). Under
+ // Overwrite this would propagate to dest; under Skip it must not.
+ $sourcePatch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string/name', $sourceHeaders, [
+ 'required' => true,
+ 'default' => null,
+ ]);
+ $this->assertEquals(200, $sourcePatch['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertTrue($r['body']['required']);
+ }, 5000, 500);
+
+ $skipResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEquals('completed', $skipResult['status']);
+
+ $destAttr = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $destAttr['headers']['status-code']);
+ $this->assertFalse($destAttr['body']['required'], 'Skip must not propagate source required over dest drift');
+ $this->assertEquals('dest-default', $destAttr['body']['default'], 'Skip must preserve dest default');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Two-way onDelete change updates in place on both sides; partner meta refreshed by hand. */
+ public function testAppwriteMigrationOverwriteUpdatesRelationshipOnDeleteInPlace(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $databaseId = ID::unique();
+ $createDb = $this->client->call(Client::METHOD_POST, '/databases', $sourceHeaders, [
+ 'databaseId' => $databaseId,
+ 'name' => 'Rel In-Place DB',
+ ]);
+ $this->assertEquals(201, $createDb['headers']['status-code']);
+
+ foreach (['parents', 'children'] as $tbl) {
+ $createTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $sourceHeaders, [
+ 'tableId' => $tbl,
+ 'name' => $tbl,
+ ]);
+ $this->assertEquals(201, $createTable['headers']['status-code']);
+ }
+
+ // Two-way: parents.kids ↔ children.parent. Required to hit the in-place path.
+ $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [
+ 'relatedTableId' => 'children',
+ 'type' => Database::RELATION_ONE_TO_MANY,
+ 'twoWay' => true,
+ 'key' => 'kids',
+ 'twoWayKey' => 'parent',
+ 'onDelete' => Database::RELATION_MUTATE_CASCADE,
+ ]);
+ $this->assertEquals(202, $createRel['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $r['body']['onDelete']);
+ }, 10000, 500);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ // Both sides land on dest with onDelete=cascade.
+ $this->assertEventually(function () use ($databaseId, $destHeaders) {
+ $parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders);
+ $this->assertEquals(200, $parent['headers']['status-code']);
+ $this->assertEquals('available', $parent['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $parent['body']['onDelete']);
+
+ $child = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/columns/parent', $destHeaders);
+ $this->assertEquals(200, $child['headers']['status-code']);
+ $this->assertEquals('available', $child['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $child['body']['onDelete']);
+ }, 10000, 500);
+
+ sleep(1);
+
+ // SDK-reachable: PATCH /columns/:key/relationship accepts onDelete.
+ $patch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids/relationship', $sourceHeaders, [
+ 'onDelete' => Database::RELATION_MUTATE_RESTRICT,
+ ]);
+ $this->assertEquals(200, $patch['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete']);
+ }, 5000, 500);
+
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ // Both sides on dest must reflect onDelete=restrict. Asserting the
+ // partner side is the regression guard for the previously-missed
+ // partner meta refresh in updateRelationshipInPlace.
+ $this->assertEventually(function () use ($databaseId, $destHeaders) {
+ $parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders);
+ $this->assertEquals(200, $parent['headers']['status-code']);
+ $this->assertEquals('available', $parent['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $parent['body']['onDelete'], 'parent-side onDelete must reflect source');
+ $this->assertEquals(Database::RELATION_ONE_TO_MANY, $parent['body']['relationType'], 'In-place update must not change relationType');
+ $this->assertTrue($parent['body']['twoWay'], 'In-place update must not change twoWay');
+
+ $child = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/columns/parent', $destHeaders);
+ $this->assertEquals(200, $child['headers']['status-code']);
+ $this->assertEquals('available', $child['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $child['body']['onDelete'], 'partner-side onDelete must reflect source after in-place update');
+ }, 10000, 500);
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Two-way recreate with same spec: spec-match guard tolerates parent; pair-key dedup tolerates partner. Both sides + child rows preserved. */
+ public function testAppwriteMigrationOverwriteTwoWayRecreateSkipsPartnerSide(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $databaseId = ID::unique();
+ $createDb = $this->client->call(Client::METHOD_POST, '/databases', $sourceHeaders, [
+ 'databaseId' => $databaseId,
+ 'name' => 'Two-Way Recreate DB',
+ ]);
+ $this->assertEquals(201, $createDb['headers']['status-code']);
+
+ foreach (['parents', 'children'] as $tbl) {
+ $createTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $sourceHeaders, [
+ 'tableId' => $tbl,
+ 'name' => $tbl,
+ 'permissions' => [
+ Permission::create(Role::any()),
+ Permission::read(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ ]);
+ $this->assertEquals(201, $createTable['headers']['status-code']);
+ }
+
+ // Add a non-relationship column on parents so we can POST a row with
+ // non-empty data. tablesdb POST /rows rejects empty data arrays in
+ // 1.9.x (Create.php:161 — getSupportForEmptyDocument() defaults false).
+ $createLabel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/string', $sourceHeaders, [
+ 'key' => 'label',
+ 'size' => 32,
+ 'required' => false,
+ ]);
+ $this->assertEquals(202, $createLabel['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/label', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 10000, 500);
+
+ $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [
+ 'relatedTableId' => 'children',
+ 'type' => Database::RELATION_ONE_TO_MANY,
+ 'twoWay' => true,
+ 'key' => 'kids',
+ 'twoWayKey' => 'parent',
+ 'onDelete' => Database::RELATION_MUTATE_CASCADE,
+ ]);
+ $this->assertEquals(202, $createRel['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 10000, 500);
+
+ $parentRow = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/rows', $sourceHeaders, [
+ 'rowId' => 'parent-1',
+ 'data' => ['label' => 'p1'],
+ ]);
+ $this->assertEquals(201, $parentRow['headers']['status-code']);
+ $childRow = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/children/rows', $sourceHeaders, [
+ 'rowId' => 'child-1',
+ 'data' => ['parent' => 'parent-1'],
+ ]);
+ $this->assertEquals(201, $childRow['headers']['status-code']);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ // Recreate the relationship on source so its createdAt advances past
+ // dest's stored value — forces SchemaAction::DropAndRecreate on the
+ // parent side, which is the path the partner-side dedup guards.
+ sleep(1);
+ $deleteRel = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(204, $deleteRel['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(404, $r['headers']['status-code']);
+ }, 10000, 500);
+
+ sleep(1);
+ $recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [
+ 'relatedTableId' => 'children',
+ 'type' => Database::RELATION_ONE_TO_MANY,
+ 'twoWay' => true,
+ 'key' => 'kids',
+ 'twoWayKey' => 'parent',
+ 'onDelete' => Database::RELATION_MUTATE_CASCADE,
+ ]);
+ $this->assertEquals(202, $recreate['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 10000, 500);
+
+ // Child-row's relationship was wiped by the source-side delete. Re-link.
+ $relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/children/rows/child-1', $sourceHeaders, [
+ 'data' => ['parent' => 'parent-1'],
+ ]);
+ $this->assertEquals(200, $relink['headers']['status-code']);
+
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ $this->assertEventually(function () use ($databaseId, $destHeaders) {
+ $parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders);
+ $this->assertEquals(200, $parent['headers']['status-code']);
+ $this->assertEquals('available', $parent['body']['status']);
+
+ $child = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/columns/parent', $destHeaders);
+ $this->assertEquals(200, $child['headers']['status-code']);
+ $this->assertEquals('available', $child['body']['status']);
+ }, 10000, 500);
+
+ // Both rows survive the re-migration. If the partner-side dedup were
+ // missing and the partner pass re-fired DropAndRecreate, the partner
+ // (children) table's row would have been wiped before the row pass.
+ $destChild = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/rows/child-1', $destHeaders);
+ $this->assertEquals(200, $destChild['headers']['status-code'], 'partner-table row must survive two-way recreate re-migration');
+ $this->assertEquals('parent-1', $destChild['body']['parent']['$id'] ?? $destChild['body']['parent'], 'partner-table row relationship must point to the migrated parent');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** One-way + onDelete change falls through to DropAndRecreate (in-place gated off for one-way). */
+ public function testAppwriteMigrationOverwriteOneWayRelationshipDropAndRecreate(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $databaseId = ID::unique();
+ $createDb = $this->client->call(Client::METHOD_POST, '/databases', $sourceHeaders, [
+ 'databaseId' => $databaseId,
+ 'name' => 'One-Way DropAndRecreate DB',
+ ]);
+ $this->assertEquals(201, $createDb['headers']['status-code']);
+
+ foreach (['parents', 'children'] as $tbl) {
+ $createTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $sourceHeaders, [
+ 'tableId' => $tbl,
+ 'name' => $tbl,
+ ]);
+ $this->assertEquals(201, $createTable['headers']['status-code']);
+ }
+
+ $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [
+ 'relatedTableId' => 'children',
+ 'type' => Database::RELATION_ONE_TO_MANY,
+ 'twoWay' => false,
+ 'key' => 'kids',
+ 'onDelete' => Database::RELATION_MUTATE_CASCADE,
+ ]);
+ $this->assertEquals(202, $createRel['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 10000, 500);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ $this->assertEventually(function () use ($databaseId, $destHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $r['body']['onDelete']);
+ }, 10000, 500);
+
+ sleep(1);
+
+ $patch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids/relationship', $sourceHeaders, [
+ 'onDelete' => Database::RELATION_MUTATE_RESTRICT,
+ ]);
+ $this->assertEquals(200, $patch['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete']);
+ }, 5000, 500);
+
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ $this->assertEventually(function () use ($databaseId, $destHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete'], 'one-way DropAndRecreate must propagate source onDelete');
+ $this->assertEquals(Database::RELATION_ONE_TO_MANY, $r['body']['relationType'], 'DropAndRecreate must preserve relationType');
+ $this->assertFalse($r['body']['twoWay'], 'DropAndRecreate must preserve twoWay=false');
+ }, 10000, 500);
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Recreate with non-SDK spec change (array toggle): updateAttributeInPlace bails → drop+recreate; row pass refills. */
+ public function testAppwriteMigrationOverwriteAttributeRecreateDropsAndRecreates(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+ $rowId = 'row-after-recreate';
+
+ $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => $rowId,
+ 'data' => ['name' => 'before-recreate'],
+ ]);
+ $this->assertEquals(201, $row['headers']['status-code']);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ sleep(1);
+
+ // Drop + recreate the column on source. createdAt advances → re-migration
+ // must take the createdAt-diff DropAndRecreate path on dest.
+ $delete = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(204, $delete['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(404, $r['headers']['status-code']);
+ }, 10000, 500);
+
+ // Recreate with `array: true` — a non-SDK change (`array` is in
+ // ATTRIBUTE_NON_SDK_FIELDS). Forces updateAttributeInPlace to bail
+ // and the caller to fall through to drop+recreate, which is what
+ // this test pins.
+ $recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $sourceHeaders, [
+ 'key' => 'name',
+ 'size' => 100,
+ 'required' => false,
+ 'array' => true,
+ ]);
+ $this->assertEquals(202, $recreate['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 10000, 500);
+
+ // Source row's data was nulled by the source-side delete. Set a list value (column is array=true now).
+ $relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $sourceHeaders, [
+ 'data' => ['name' => ['after-recreate']],
+ ]);
+ $this->assertEquals(200, $relink['headers']['status-code']);
+
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) {
+ $col = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $col['headers']['status-code']);
+ $this->assertEquals('available', $col['body']['status']);
+ $this->assertTrue($col['body']['array'], 'recreated column must reflect the new spec (array=true)');
+ $this->assertFalse($col['body']['required']);
+ }, 10000, 500);
+
+ $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $rowAfter['headers']['status-code']);
+ $this->assertEquals(['after-recreate'], $rowAfter['body']['name'], 'row pass must repopulate the recreated column with source value');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Source drops+recreates with SAME spec: spec-match guard forces Tolerate; dest meta untouched. */
+ public function testAppwriteMigrationOverwriteSameSpecRecreateTolerates(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+ $rowId = 'row-spec-match';
+
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => $rowId,
+ 'data' => ['name' => 'before-recreate'],
+ ]);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ $destBefore = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $destBefore['headers']['status-code']);
+ $destCreatedAtBefore = $destBefore['body']['$createdAt'];
+
+ sleep(1);
+
+ // Drop + recreate with the EXACT same spec as setupMigrationTable
+ // (size=100, required=true). Source's $createdAt advances but the
+ // spec is identical → spec-match guard must force Tolerate.
+ $delete = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(204, $delete['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(404, $r['headers']['status-code']);
+ }, 10000, 500);
+
+ $recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $sourceHeaders, [
+ 'key' => 'name',
+ 'size' => 100,
+ 'required' => true,
+ ]);
+ $this->assertEquals(202, $recreate['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 10000, 500);
+
+ $relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $sourceHeaders, [
+ 'data' => ['name' => 'after-recreate'],
+ ]);
+ $this->assertEquals(200, $relink['headers']['status-code']);
+
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ // Spec-match guard fired → dest column's $createdAt stayed at the
+ // first-migration value. If DropAndRecreate had run, $createdAt
+ // would have been bumped to source's NEW createdAt.
+ $destAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $destAfter['headers']['status-code']);
+ $this->assertEquals($destCreatedAtBefore, $destAfter['body']['$createdAt'], 'spec-match guard must keep dest column meta untouched');
+ $this->assertEquals(100, $destAfter['body']['size']);
+ $this->assertTrue($destAfter['body']['required']);
+
+ // Row pass under Overwrite still propagated source's new row value.
+ $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $rowAfter['headers']['status-code']);
+ $this->assertEquals('after-recreate', $rowAfter['body']['name']);
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
/**
* Storage
*/
@@ -1096,6 +2365,7 @@ trait MigrationsBase
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
+ 'x-appwrite-response-format' => '1.9.3'
], [
'key' => 'TEST_VAR',
'value' => 'test_value',
@@ -1193,6 +2463,83 @@ trait MigrationsBase
return new CURLFile($tarPath, 'application/x-gzip', \basename($tarPath));
}
+ /**
+ * Integrations
+ */
+ public function testAppwriteMigrationPlatform(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+
+ $destinationHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ // Create platform on source project
+ $response = $this->client->call(Client::METHOD_POST, '/project/platforms/web', $sourceHeaders, [
+ 'platformId' => ID::unique(),
+ 'name' => 'Test Platform',
+ 'hostname' => 'localhost',
+ ]);
+
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']);
+ $this->assertNotEmpty($response['body']['$id']);
+
+ $platform = $response['body'];
+
+ $result = $this->performMigrationSync([
+ 'resources' => [
+ Resource::TYPE_PLATFORM,
+ ],
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+
+ $this->assertEquals('completed', $result['status']);
+ $this->assertEquals([Resource::TYPE_PLATFORM], $result['resources']);
+ $this->assertArrayHasKey(Resource::TYPE_PLATFORM, $result['statusCounters']);
+ $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PLATFORM]['error']);
+ $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PLATFORM]['pending']);
+ $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_PLATFORM]['success']);
+ $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PLATFORM]['processing']);
+ $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PLATFORM]['warning']);
+
+ // Verify platform on destination project using the project's API key
+ $response = $this->client->call(Client::METHOD_GET, '/project/platforms', $destinationHeaders);
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']);
+ $this->assertGreaterThan(0, $response['body']['total']);
+
+ $foundPlatform = null;
+
+ foreach ($response['body']['platforms'] as $p) {
+ if ($p['name'] === 'Test Platform' && $p['type'] === 'web') {
+ $foundPlatform = $p;
+
+ break;
+ }
+ }
+
+ $this->assertNotNull($foundPlatform);
+ $this->assertEquals('web', $foundPlatform['type']);
+ $this->assertEquals('Test Platform', $foundPlatform['name']);
+ $this->assertEquals('localhost', $foundPlatform['hostname']);
+
+ // Cleanup on destination
+ $this->client->call(Client::METHOD_DELETE, '/project/platforms/' . $foundPlatform['$id'], $destinationHeaders);
+
+ // Cleanup on source
+ $this->client->call(Client::METHOD_DELETE, '/project/platforms/' . $platform['$id'], $sourceHeaders);
+ }
+
/**
* Import documents from a CSV file.
*/
@@ -1256,7 +2603,6 @@ trait MigrationsBase
'max' => 65,
'required' => true,
]);
-
$this->assertEquals(202, $response['headers']['status-code']);
$this->assertEquals($response['body']['key'], 'age');
$this->assertEquals($response['body']['type'], 'integer');
@@ -1303,6 +2649,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!
};
@@ -1482,6 +2829,260 @@ trait MigrationsBase
}, 10_000, 500);
}
+ /**
+ * Set up a database + table + bucket + uploaded CSV for the skip/overwrite tests.
+ * Returns [$databaseId, $tableId, $bucketId, $fileId, $firstRowId, $firstRowName, $firstRowAge].
+ *
+ * @return array{string,string,string,string,string,string,int}
+ */
+ private function prepareCsvImportFixture(string $testLabel): array
+ {
+ $headers = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey']
+ ];
+
+ // database
+ $response = $this->client->call(Client::METHOD_POST, '/databases', $headers, [
+ 'databaseId' => ID::unique(),
+ 'name' => 'Test DB ' . $testLabel,
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $databaseId = $response['body']['$id'];
+
+ // table
+ $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $headers, [
+ 'name' => 'Test table ' . $testLabel,
+ 'tableId' => ID::unique(),
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $tableId = $response['body']['$id'];
+
+ // columns: name, age (match documents.csv fixture)
+ $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $headers, [
+ 'key' => 'name',
+ 'size' => 256,
+ 'required' => true,
+ ]);
+ $this->assertEquals(202, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', $headers, [
+ 'key' => 'age',
+ 'min' => 18,
+ 'max' => 65,
+ 'required' => true,
+ ]);
+ $this->assertEquals(202, $response['headers']['status-code']);
+
+ // Columns are created async (202). Wait for both to be `available`
+ // before proceeding so the migration worker doesn't race the schema.
+ foreach (['name', 'age'] as $column) {
+ $this->assertEventually(function () use ($databaseId, $tableId, $column, $headers) {
+ $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/' . $column, $headers);
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals('available', $response['body']['status']);
+ }, 5000, 500);
+ }
+
+ // bucket
+ $response = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [
+ 'bucketId' => ID::unique(),
+ 'name' => 'Bucket ' . $testLabel,
+ 'maximumFileSize' => 2000000,
+ 'allowedFileExtensions' => ['csv'],
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $bucketId = $response['body']['$id'];
+
+ // upload documents.csv (100 rows with $id, name, age columns)
+ $response = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'fileId' => ID::unique(),
+ 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/csv/documents.csv'), 'text/csv', 'documents.csv'),
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $fileId = $response['body']['$id'];
+
+ // first row in documents.csv: hxfcwpcas5xokpwe,Diamond Mendez,56
+ return [$databaseId, $tableId, $bucketId, $fileId, 'hxfcwpcas5xokpwe', 'Diamond Mendez', 56];
+ }
+
+ /**
+ * onDuplicate=skip on re-import: duplicates are silently no-op'd, existing rows preserved unchanged.
+ */
+ public function testCreateCSVImportSkipDuplicates(): void
+ {
+ [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareCsvImportFixture('skip');
+
+ // First import: 100 rows created
+ $first = $this->performCsvMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($first) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']);
+ }, 10_000, 500);
+
+ // Mutate one row so we can prove skip does NOT overwrite it
+ $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'data' => ['age' => 22],
+ ]);
+ $this->assertEquals(200, $mutate['headers']['status-code']);
+ $this->assertEquals(22, $mutate['body']['age']);
+
+ // Second import with onDuplicate=skip: no errors, mutated row preserved
+ $second = $this->performCsvMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEventually(function () use ($second) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ }, 10_000, 500);
+
+ // Mutated row kept its mutated value (not overwritten by CSV's original age)
+ $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals(200, $row['headers']['status-code']);
+ $this->assertEquals($originalName, $row['body']['name']);
+ $this->assertEquals(22, $row['body']['age'], 'onDuplicate=skip must not overwrite mutated row');
+
+ // Row count still 100 (no duplicates created)
+ $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [Query::limit(150)->toString()],
+ ]);
+ $this->assertEquals(100, $rows['body']['total']);
+ }
+
+ /**
+ * onDuplicate=overwrite on re-import: existing rows are replaced with imported values.
+ */
+ public function testCreateCSVImportOverwrite(): void
+ {
+ [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareCsvImportFixture('overwrite');
+
+ // First import: 100 rows created
+ $first = $this->performCsvMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($first) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']);
+ }, 10_000, 500);
+
+ // Mutate one row so we can prove overwrite restores it to the CSV's original value
+ $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'data' => ['age' => 22],
+ ]);
+ $this->assertEquals(200, $mutate['headers']['status-code']);
+ $this->assertEquals(22, $mutate['body']['age']);
+
+ // Second import with onDuplicate=overwrite: mutated row restored to CSV value
+ $second = $this->performCsvMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEventually(function () use ($second) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ }, 10_000, 500);
+
+ // Mutated row is back to CSV's original age (proving overwrite actually replaced the row)
+ $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals(200, $row['headers']['status-code']);
+ $this->assertEquals($originalName, $row['body']['name']);
+ $this->assertEquals($originalAge, $row['body']['age'], 'onDuplicate=overwrite must restore row to imported value');
+
+ // Row count still 100 (no duplicates created)
+ $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [Query::limit(150)->toString()],
+ ]);
+ $this->assertEquals(100, $rows['body']['total']);
+ }
+
+ /**
+ * Default behavior (neither flag): re-import of duplicate ids fails with DuplicateException.
+ * Regression guard so the skip/overwrite additions don't silently change the default.
+ */
+ public function testCreateCSVImportDefaultFailsOnDuplicate(): void
+ {
+ [$databaseId, $tableId, $bucketId, $fileId] = $this->prepareCsvImportFixture('default');
+
+ // First import: succeeds
+ $first = $this->performCsvMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($first) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ }, 10_000, 500);
+
+ // Second import with no flags: should fail on duplicate ids
+ $second = $this->performCsvMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($second) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('finished', $migration['body']['stage']);
+ $this->assertEquals('failed', $migration['body']['status']);
+ $this->assertNotEmpty($migration['body']['errors']);
+ }, 60_000, 500);
+ }
+
private function performCsvMigration(array $body): array
{
return $this->client->call(Client::METHOD_POST, '/migrations/csv', [
@@ -1491,6 +3092,246 @@ trait MigrationsBase
], $body);
}
+ /**
+ * Set up a database + table + bucket + uploaded JSON for the skip/overwrite tests.
+ * Mirrors prepareCsvImportFixture but uploads documents.json instead.
+ *
+ * @return array{string,string,string,string,string,string,int}
+ */
+ private function prepareJsonImportFixture(string $testLabel): array
+ {
+ $headers = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey']
+ ];
+
+ // database
+ $response = $this->client->call(Client::METHOD_POST, '/databases', $headers, [
+ 'databaseId' => ID::unique(),
+ 'name' => 'Test JSON DB ' . $testLabel,
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $databaseId = $response['body']['$id'];
+
+ // table
+ $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $headers, [
+ 'name' => 'Test JSON table ' . $testLabel,
+ 'tableId' => ID::unique(),
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $tableId = $response['body']['$id'];
+
+ // columns: name, age (match documents.json fixture)
+ $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $headers, [
+ 'key' => 'name',
+ 'size' => 256,
+ 'required' => true,
+ ]);
+ $this->assertEquals(202, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', $headers, [
+ 'key' => 'age',
+ 'min' => 18,
+ 'max' => 65,
+ 'required' => true,
+ ]);
+ $this->assertEquals(202, $response['headers']['status-code']);
+
+ foreach (['name', 'age'] as $column) {
+ $this->assertEventually(function () use ($databaseId, $tableId, $column, $headers) {
+ $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/' . $column, $headers);
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals('available', $response['body']['status']);
+ }, 5000, 500);
+ }
+
+ // bucket
+ $response = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [
+ 'bucketId' => ID::unique(),
+ 'name' => 'JSON Bucket ' . $testLabel,
+ 'maximumFileSize' => 2000000,
+ 'allowedFileExtensions' => ['json'],
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $bucketId = $response['body']['$id'];
+
+ // upload documents.json (same row shape as documents.csv)
+ $response = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'fileId' => ID::unique(),
+ 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/json/documents.json'), 'application/json', 'documents.json'),
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $fileId = $response['body']['$id'];
+
+ // first row in documents.json: hxfcwpcas5xokpwe, Diamond Mendez, 56
+ return [$databaseId, $tableId, $bucketId, $fileId, 'hxfcwpcas5xokpwe', 'Diamond Mendez', 56];
+ }
+
+ /**
+ * onDuplicate=skip on JSON re-import: duplicates silently no-op, existing rows preserved unchanged.
+ */
+ public function testCreateJSONImportSkipDuplicates(): void
+ {
+ [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareJsonImportFixture('skip');
+
+ $first = $this->performJsonMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($first) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']);
+ }, 10_000, 500);
+
+ // Mutate one row so we can prove skip does NOT overwrite it
+ $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'data' => ['age' => 22],
+ ]);
+ $this->assertEquals(200, $mutate['headers']['status-code']);
+ $this->assertEquals(22, $mutate['body']['age']);
+
+ $second = $this->performJsonMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEventually(function () use ($second) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ }, 10_000, 500);
+
+ $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals(200, $row['headers']['status-code']);
+ $this->assertEquals($originalName, $row['body']['name']);
+ $this->assertEquals(22, $row['body']['age'], 'onDuplicate=skip must not overwrite mutated row');
+
+ $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [Query::limit(150)->toString()],
+ ]);
+ $this->assertEquals(100, $rows['body']['total']);
+ }
+
+ /**
+ * onDuplicate=overwrite on JSON re-import: existing rows replaced with imported values.
+ */
+ public function testCreateJSONImportOverwrite(): void
+ {
+ [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareJsonImportFixture('overwrite');
+
+ $first = $this->performJsonMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($first) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']);
+ }, 10_000, 500);
+
+ $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'data' => ['age' => 22],
+ ]);
+ $this->assertEquals(200, $mutate['headers']['status-code']);
+ $this->assertEquals(22, $mutate['body']['age']);
+
+ $second = $this->performJsonMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEventually(function () use ($second) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ }, 10_000, 500);
+
+ $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals(200, $row['headers']['status-code']);
+ $this->assertEquals($originalName, $row['body']['name']);
+ $this->assertEquals($originalAge, $row['body']['age'], 'onDuplicate=overwrite must restore row to imported value');
+
+ $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [Query::limit(150)->toString()],
+ ]);
+ $this->assertEquals(100, $rows['body']['total']);
+ }
+
+ /**
+ * Default (no onDuplicate) on JSON re-import: regression guard, must fail on duplicate ids.
+ */
+ public function testCreateJSONImportDefaultFailsOnDuplicate(): void
+ {
+ [$databaseId, $tableId, $bucketId, $fileId] = $this->prepareJsonImportFixture('default');
+
+ $first = $this->performJsonMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($first) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ }, 10_000, 500);
+
+ $second = $this->performJsonMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($second) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('finished', $migration['body']['stage']);
+ $this->assertEquals('failed', $migration['body']['status']);
+ $this->assertNotEmpty($migration['body']['errors']);
+ }, 60_000, 500);
+ }
+
/**
* Test CSV export with email notification
*/
@@ -1572,6 +3413,19 @@ trait MigrationsBase
$this->assertEquals(202, $varchar['headers']['status-code']);
+ $bigint = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/bigint', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey']
+ ], [
+ 'key' => 'bigint',
+ 'min' => 2147483648,
+ 'max' => 9223372036854775807,
+ 'required' => false,
+ ]);
+
+ $this->assertEquals(202, $bigint['headers']['status-code']);
+
$mediumtext = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/mediumtext', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
@@ -1622,6 +3476,7 @@ trait MigrationsBase
'mediumtext' => 'mediumText',
'longtext' => 'longText',
'varchar' => 'varchar',
+ 'bigint' => 2147483648 + $i,
]
]);
@@ -1710,6 +3565,8 @@ trait MigrationsBase
$this->assertStringContainsString('mediumText', $csvData, 'CSV should contain the medium column header');
$this->assertStringContainsString('longText', $csvData, 'CSV should contain the long text column header');
$this->assertStringContainsString('varchar', $csvData, 'CSV should contain the varchar column header');
+ $this->assertStringContainsString('bigint', $csvData, 'CSV should contain the bigint column header');
+ $this->assertStringContainsString('2147483649', $csvData, 'CSV should contain bigint test data');
// Cleanup
$this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, [
@@ -4207,7 +6064,9 @@ trait MigrationsBase
}, 30_000, 500);
// Check that email was sent with download link
- $lastEmail = $this->getLastEmail();
+ $lastEmail = $this->getLastEmail(probe: function ($email) {
+ $this->assertEquals('Your JSON export is ready', $email['subject']);
+ });
$this->assertNotEmpty($lastEmail);
$this->assertEquals('Your JSON export is ready', $lastEmail['subject']);
$this->assertStringContainsStringIgnoringCase('Your data export has been completed successfully', $lastEmail['text']);
diff --git a/tests/e2e/Services/Organization/ProjectsBase.php b/tests/e2e/Services/Organization/ProjectsBase.php
new file mode 100644
index 0000000000..4e18050670
--- /dev/null
+++ b/tests/e2e/Services/Organization/ProjectsBase.php
@@ -0,0 +1,484 @@
+client->call(Client::METHOD_POST, '/teams', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'teamId' => $teamId,
+ 'name' => 'Organization Test',
+ ]);
+ if (\in_array($team['headers']['status-code'], [201, 409])) {
+ break;
+ }
+ \usleep(500000);
+ }
+ $this->assertContains($team['headers']['status-code'], [201, 409], 'Setup organization (team) failed');
+
+ self::$cachedOrganization = [
+ 'teamId' => $team['body']['$id'] ?? $teamId,
+ ];
+
+ return self::$cachedOrganization;
+ }
+
+ protected function getOrganizationHeaders(): array
+ {
+ $organization = $this->setupOrganization();
+
+ return array_merge($this->getHeaders(), [
+ 'x-appwrite-organization' => $organization['teamId'],
+ ]);
+ }
+
+ /**
+ * Setup and cache a project created via organization endpoints.
+ */
+ protected function setupOrganizationProject(): array
+ {
+ if (!empty(self::$cachedProjectData)) {
+ return self::$cachedProjectData;
+ }
+
+ $project = null;
+ for ($i = 0; $i < 3; $i++) {
+ $project = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()), [
+ 'projectId' => ID::unique(),
+ 'name' => 'Organization Project Test',
+ 'region' => System::getEnv('_APP_REGION', 'default'),
+ ]);
+ if ($project['headers']['status-code'] === 201) {
+ break;
+ }
+ \usleep(500000);
+ }
+ $this->assertEquals(201, $project['headers']['status-code'], 'Setup organization project failed');
+
+ self::$cachedProjectData = [
+ 'projectId' => $project['body']['$id'],
+ 'teamId' => $this->setupOrganization()['teamId'],
+ ];
+
+ return self::$cachedProjectData;
+ }
+
+ public function testCreateProject(): void
+ {
+ $organization = $this->setupOrganization();
+ $teamId = $organization['teamId'];
+
+ /**
+ * Test for SUCCESS
+ */
+ $response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()), [
+ 'projectId' => ID::unique(),
+ 'name' => 'Organization Project Test',
+ 'region' => System::getEnv('_APP_REGION', 'default'),
+ ]);
+
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertEquals('Organization Project Test', $response['body']['name']);
+ $this->assertEquals($teamId, $response['body']['teamId']);
+ $this->assertEquals(PROJECT_STATUS_ACTIVE, $response['body']['status']);
+ $this->assertArrayHasKey('platforms', $response['body']);
+ $this->assertArrayHasKey('webhooks', $response['body']);
+ $this->assertArrayHasKey('keys', $response['body']);
+
+ /**
+ * Test for FAILURE - missing organization header
+ */
+ $response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'projectId' => ID::unique(),
+ 'name' => 'Organization Project Test',
+ 'region' => System::getEnv('_APP_REGION', 'default'),
+ ]);
+
+ $this->assertEquals(404, $response['headers']['status-code']);
+
+ /**
+ * Test for FAILURE - empty name
+ */
+ $response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()), [
+ 'projectId' => ID::unique(),
+ 'name' => '',
+ 'region' => System::getEnv('_APP_REGION', 'default'),
+ ]);
+
+ $this->assertEquals(400, $response['headers']['status-code']);
+ }
+
+ public function testCreateDuplicateProject(): void
+ {
+ $organization = $this->setupOrganization();
+ $projectId = ID::unique();
+
+ $response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()), [
+ 'projectId' => $projectId,
+ 'name' => 'Original Organization Project',
+ 'region' => System::getEnv('_APP_REGION', 'default'),
+ ]);
+
+ $this->assertEquals(201, $response['headers']['status-code']);
+
+ /**
+ * Test for FAILURE - duplicate project ID
+ */
+ $response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()), [
+ 'projectId' => $projectId,
+ 'name' => 'Duplicate Organization Project',
+ 'region' => System::getEnv('_APP_REGION', 'default'),
+ ]);
+
+ $this->assertEquals(409, $response['headers']['status-code']);
+ $this->assertEquals(409, $response['body']['code']);
+ $this->assertEquals(Exception::PROJECT_ALREADY_EXISTS, $response['body']['type']);
+ }
+
+ public function testGetProject(): void
+ {
+ $data = $this->setupOrganizationProject();
+ $projectId = $data['projectId'];
+
+ /**
+ * Test for SUCCESS
+ */
+ $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects/' . $projectId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()));
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertEquals($projectId, $response['body']['$id']);
+ $this->assertEquals('Organization Project Test', $response['body']['name']);
+ $this->assertEquals(PROJECT_STATUS_ACTIVE, $response['body']['status']);
+ $this->assertArrayHasKey('platforms', $response['body']);
+ $this->assertArrayHasKey('webhooks', $response['body']);
+ $this->assertArrayHasKey('keys', $response['body']);
+
+ /**
+ * Test for FAILURE - project not found
+ */
+ $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects/' . ID::unique(), array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()));
+
+ $this->assertEquals(404, $response['headers']['status-code']);
+
+ /**
+ * Test for FAILURE - project from different organization
+ */
+ $otherTeam = $this->client->call(Client::METHOD_POST, '/teams', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'teamId' => ID::unique(),
+ 'name' => 'Other Organization',
+ ]);
+ $this->assertContains($otherTeam['headers']['status-code'], [201, 409]);
+ $otherTeamId = $otherTeam['body']['$id'] ?? $otherTeam['body']['teamId'];
+
+ $otherProject = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], array_merge($this->getHeaders(), [
+ 'x-appwrite-organization' => $otherTeamId,
+ ])), [
+ 'projectId' => ID::unique(),
+ 'name' => 'Other Organization Project',
+ 'region' => System::getEnv('_APP_REGION', 'default'),
+ ]);
+ $this->assertEquals(201, $otherProject['headers']['status-code']);
+ $otherProjectId = $otherProject['body']['$id'];
+
+ $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects/' . $otherProjectId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()));
+
+ $this->assertEquals(404, $response['headers']['status-code']);
+ }
+
+ public function testUpdateProject(): void
+ {
+ $data = $this->setupOrganizationProject();
+ $projectId = $data['projectId'];
+
+ /**
+ * Test for SUCCESS
+ */
+ $response = $this->client->call(Client::METHOD_PATCH, '/v1/organization/projects/' . $projectId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()), [
+ 'name' => 'Updated Organization Project',
+ ]);
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals($projectId, $response['body']['$id']);
+ $this->assertEquals('Updated Organization Project', $response['body']['name']);
+
+ /**
+ * Test for FAILURE - project not found
+ */
+ $response = $this->client->call(Client::METHOD_PATCH, '/v1/organization/projects/' . ID::unique(), array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()), [
+ 'name' => 'Should Fail',
+ ]);
+
+ $this->assertEquals(404, $response['headers']['status-code']);
+
+ /**
+ * Test for FAILURE - empty name
+ */
+ $response = $this->client->call(Client::METHOD_PATCH, '/v1/organization/projects/' . $projectId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()), [
+ 'name' => '',
+ ]);
+
+ $this->assertEquals(400, $response['headers']['status-code']);
+ }
+
+ public function testDeleteProject(): void
+ {
+ $organization = $this->setupOrganization();
+
+ $project = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()), [
+ 'projectId' => ID::unique(),
+ 'name' => 'Project To Delete',
+ 'region' => System::getEnv('_APP_REGION', 'default'),
+ ]);
+
+ $this->assertEquals(201, $project['headers']['status-code']);
+ $projectId = $project['body']['$id'];
+
+ /**
+ * Test for SUCCESS
+ */
+ $response = $this->client->call(Client::METHOD_DELETE, '/v1/organization/projects/' . $projectId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()));
+
+ $this->assertEquals(204, $response['headers']['status-code']);
+
+ // Verify project is actually deleted
+ $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects/' . $projectId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()));
+
+ $this->assertEquals(404, $response['headers']['status-code']);
+
+ /**
+ * Test for FAILURE - project not found (already deleted)
+ */
+ $response = $this->client->call(Client::METHOD_DELETE, '/v1/organization/projects/' . $projectId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()));
+
+ $this->assertEquals(404, $response['headers']['status-code']);
+ }
+
+ public function testListProjects(): void
+ {
+ $organization = $this->setupOrganization();
+ $teamId = $organization['teamId'];
+
+ // Create a second project in the same organization
+ $project2 = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()), [
+ 'projectId' => ID::unique(),
+ 'name' => 'Second Organization Project',
+ 'region' => System::getEnv('_APP_REGION', 'default'),
+ ]);
+
+ $this->assertEquals(201, $project2['headers']['status-code']);
+ $project2Id = $project2['body']['$id'];
+
+ /**
+ * Test for SUCCESS - basic list
+ */
+ $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()));
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']);
+ $this->assertGreaterThan(0, count($response['body']['projects']));
+ $this->assertGreaterThan(0, $response['body']['total']);
+
+ /**
+ * Test search queries
+ */
+ $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders(), [
+ 'search' => 'Second Organization Project',
+ ]));
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertGreaterThan(0, $response['body']['total']);
+ $this->assertIsArray($response['body']['projects']);
+ $this->assertEquals('Second Organization Project', $response['body']['projects'][0]['name']);
+
+ /**
+ * Test pagination with limit
+ */
+ $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()), [
+ 'queries' => [
+ Query::limit(1)->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertCount(1, $response['body']['projects']);
+
+ /**
+ * Test pagination with offset
+ */
+ $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()), [
+ 'queries' => [
+ Query::offset(1)->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']);
+
+ /**
+ * Test query by name
+ */
+ $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()), [
+ 'queries' => [
+ Query::equal('name', ['Second Organization Project'])->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertGreaterThanOrEqual(1, count($response['body']['projects']));
+ $this->assertEquals('Second Organization Project', $response['body']['projects'][0]['name']);
+
+ /**
+ * Test cursor pagination
+ */
+ $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()));
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['projects']);
+
+ $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()), [
+ 'queries' => [
+ Query::cursorAfter(new Document(['$id' => $response['body']['projects'][0]['$id']]))->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']);
+
+ /**
+ * Test for FAILURE - invalid cursor
+ */
+ $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()), [
+ 'queries' => [
+ Query::cursorAfter(new Document(['$id' => 'unknown']))->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(400, $response['headers']['status-code']);
+ }
+
+ public function testListProjectsQuerySelect(): void
+ {
+ $data = $this->setupOrganizationProject();
+ $projectId = $data['projectId'];
+
+ $response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getOrganizationHeaders()), [
+ 'queries' => [
+ Query::select(['name'])->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['projects']);
+ $this->assertEquals('Organization Project Test', $response['body']['projects'][0]['name']);
+ }
+}
diff --git a/tests/e2e/Services/Organization/ProjectsConsoleClientTest.php b/tests/e2e/Services/Organization/ProjectsConsoleClientTest.php
new file mode 100644
index 0000000000..5d016eff01
--- /dev/null
+++ b/tests/e2e/Services/Organization/ProjectsConsoleClientTest.php
@@ -0,0 +1,14 @@
+getProject()['$id'];
+
+ if (!empty(self::$presenceApiKeyCache[$projectId])) {
+ return self::$presenceApiKeyCache[$projectId];
+ }
+
+ self::$presenceApiKeyCache[$projectId] = $this->getNewKey([
+ 'presences.read',
+ 'presences.write',
+ ]);
+
+ return self::$presenceApiKeyCache[$projectId];
+ }
+
+ /**
+ * Server-side helper: ensure presences requests use a presence-scoped API key.
+ */
+ protected function getPresenceServerHeaders(): array
+ {
+ $headers = $this->getHeaders(false);
+
+ // Override the project API key added by `SideServer` with a presence-scoped key.
+ $headers['x-appwrite-key'] = $this->getPresenceApiKey();
+
+ return $headers;
+ }
+
+ protected function setupPresence(array $overrides = []): array
+ {
+ $projectId = $this->getProject()['$id'];
+ $cacheKey = $projectId;
+
+ if (empty($overrides) && !empty(self::$presenceCache[$cacheKey])) {
+ return self::$presenceCache[$cacheKey];
+ }
+
+ $payload = \array_merge([
+ 'userId' => $this->getUser()['$id'],
+ 'status' => 'online',
+ 'metadata' => [
+ 'device' => 'web',
+ 'setup' => true,
+ ],
+ ], $overrides);
+
+ $response = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . ID::unique(),
+ [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $this->getPresenceApiKey(),
+ ],
+ $payload
+ );
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertArrayHasKey('userId', $response['body']);
+ $this->assertArrayHasKey('status', $response['body']);
+ $this->assertArrayHasKey('metadata', $response['body']);
+
+ $this->assertEquals($payload['userId'], $response['body']['userId']);
+
+ $canonicalPresence = $this->client->call(
+ Client::METHOD_GET,
+ '/presences',
+ [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $this->getPresenceApiKey(),
+ ],
+ [
+ 'queries' => [
+ Query::equal('userId', [$payload['userId']])->toString(),
+ ],
+ ]
+ );
+ $this->assertEquals(200, $canonicalPresence['headers']['status-code']);
+ $this->assertGreaterThanOrEqual(1, $canonicalPresence['body']['total'] ?? 0);
+ $this->assertNotEmpty($canonicalPresence['body']['presences'][0] ?? []);
+
+ $presence = $canonicalPresence['body']['presences'][0];
+
+ if (empty($overrides)) {
+ self::$presenceCache[$cacheKey] = $presence;
+ }
+
+ return $presence;
+ }
+
+ protected function resolvePresenceForUser(string $userId, array $headers): array
+ {
+ $presence = $this->client->call(
+ Client::METHOD_GET,
+ '/presences',
+ $headers,
+ [
+ 'queries' => [
+ Query::equal('userId', [$userId])->toString(),
+ ],
+ ]
+ );
+
+ $this->assertEquals(200, $presence['headers']['status-code']);
+ $this->assertGreaterThanOrEqual(1, $presence['body']['total'] ?? 0);
+ $this->assertNotEmpty($presence['body']['presences'][0] ?? []);
+
+ return $presence['body']['presences'][0];
+ }
+
+ public function testUpsertAndGetPresence(): void
+ {
+ if ($this->getSide() === 'client') {
+ $userId = $this->getUser()['$id'];
+
+ $upsert = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . ID::unique(),
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders(false)),
+ [
+ 'status' => 'online',
+ 'metadata' => ['device' => 'web'],
+ ]
+ );
+
+ $this->assertEquals(200, $upsert['headers']['status-code']);
+ $this->assertNotEmpty($upsert['body']['$id']);
+ $this->assertEquals($userId, $upsert['body']['userId']);
+
+ $get = $this->client->call(
+ Client::METHOD_GET,
+ '/presences/' . $upsert['body']['$id'],
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders(false))
+ );
+
+ $this->assertEquals(200, $get['headers']['status-code']);
+ $this->assertEquals($upsert['body']['$id'], $get['body']['$id']);
+ $this->assertEquals($userId, $get['body']['userId']);
+ $this->assertArrayHasKey('expiresAt', $get['body']);
+
+ return;
+ }
+
+ $presence = $this->setupPresence();
+
+ $get = $this->client->call(
+ Client::METHOD_GET,
+ '/presences/' . $presence['$id'],
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getPresenceServerHeaders())
+ );
+
+ $this->assertEquals(200, $get['headers']['status-code']);
+ $this->assertEquals($presence['$id'], $get['body']['$id']);
+ $this->assertEquals($presence['userId'], $get['body']['userId']);
+ $this->assertArrayHasKey('expiresAt', $get['body']);
+ }
+
+ public function testListPresences(): void
+ {
+ if ($this->getSide() === 'client') {
+ $upsert = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . ID::unique(),
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders(false)),
+ [
+ 'status' => 'online',
+ 'metadata' => ['device' => 'web'],
+ ]
+ );
+
+ $this->assertEquals(200, $upsert['headers']['status-code']);
+ $this->assertNotEmpty($upsert['body']['$id']);
+ $this->assertArrayHasKey('userId', $upsert['body']);
+
+ $list = $this->client->call(
+ Client::METHOD_GET,
+ '/presences',
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders(false)),
+ [
+ 'queries' => [
+ Query::equal('userId', [$upsert['body']['userId']])->toString(),
+ ],
+ ]
+ );
+
+ $this->assertEquals(200, $list['headers']['status-code']);
+ $this->assertArrayHasKey('total', $list['body']);
+ $this->assertArrayHasKey('presences', $list['body']);
+ $this->assertIsArray($list['body']['presences']);
+ $this->assertGreaterThanOrEqual(1, $list['body']['total']);
+
+ // Client sessions must not be able to list presences belonging to a different user.
+ $projectId = $this->getProject()['$id'];
+ $originalUser = $this->getUser();
+ $otherUserId = $this->getUser(true)['$id'];
+
+ // Important: don't let `getUser(true)` overwrite the cached user/session for the rest
+ // of this test run. We only need the other user's ID.
+ self::$user[$projectId] = $originalUser;
+
+ // Seed another presence for the other user (setup via API key, not the client session).
+ $this->setupPresence([
+ 'userId' => $otherUserId,
+ 'status' => 'online',
+ 'metadata' => ['device' => 'other-user'],
+ ]);
+
+ $otherList = $this->client->call(
+ Client::METHOD_GET,
+ '/presences',
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders(false)),
+ [
+ 'queries' => [
+ Query::equal('userId', [$otherUserId])->toString(),
+ ],
+ ]
+ );
+
+ $this->assertEquals(200, $otherList['headers']['status-code']);
+ $this->assertArrayHasKey('total', $otherList['body']);
+ $this->assertArrayHasKey('presences', $otherList['body']);
+ $this->assertSame([], $otherList['body']['presences']);
+ $this->assertEquals(0, $otherList['body']['total']);
+ return;
+ }
+
+ $presence = $this->setupPresence();
+
+ $list = $this->client->call(
+ Client::METHOD_GET,
+ '/presences',
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getPresenceServerHeaders()),
+ [
+ 'queries' => [
+ Query::equal('userId', [$presence['userId']])->toString(),
+ ],
+ ]
+ );
+
+ $this->assertEquals(200, $list['headers']['status-code']);
+ $this->assertArrayHasKey('total', $list['body']);
+ $this->assertArrayHasKey('presences', $list['body']);
+ $this->assertIsArray($list['body']['presences']);
+ $this->assertGreaterThanOrEqual(1, $list['body']['total']);
+ }
+
+ public function testClientPresenceCustomPermissionsForOtherUser(): void
+ {
+ if ($this->getSide() !== 'client') {
+ $this->expectNotToPerformAssertions();
+ return;
+ }
+
+ $projectId = $this->getProject()['$id'];
+ $user1 = $this->getUser(true);
+ $user2 = $this->getUser(true);
+ $headersUser2 = [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_' . $projectId . '=' . $user2['session'],
+ ];
+
+ $permissionsForUser2 = [
+ Permission::read(Role::user($user2['$id'])),
+ Permission::update(Role::user($user2['$id'])),
+ Permission::delete(Role::user($user2['$id'])),
+ Permission::write(Role::user($user2['$id'])),
+ ];
+
+ $permissionsForUser1 = [
+ Permission::read(Role::user($user1['$id'])),
+ Permission::update(Role::user($user1['$id'])),
+ Permission::delete(Role::user($user1['$id'])),
+ Permission::write(Role::user($user1['$id'])),
+ ];
+
+ // Create a presence for user1 using a presence-scoped API key so we can set ACLs.
+ $presenceAllow = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . ID::unique(),
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $this->getPresenceApiKey(),
+ ]),
+ [
+ 'userId' => $user1['$id'],
+ 'status' => 'online',
+ 'metadata' => ['case' => 'allow'],
+ // Owner always retains full permissions; user2 additionally gets access.
+ 'permissions' => \array_merge($permissionsForUser1, $permissionsForUser2),
+ ]
+ );
+
+ $this->assertEquals(200, $presenceAllow['headers']['status-code']);
+ $presenceIdAllow = $presenceAllow['body']['$id'];
+
+ // user2 can read
+ $get = $this->client->call(
+ Client::METHOD_GET,
+ '/presences/' . $presenceIdAllow,
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], $headersUser2)
+ );
+ $this->assertEquals(200, $get['headers']['status-code']);
+
+ // user2 can update
+ $patch = $this->client->call(
+ Client::METHOD_PATCH,
+ '/presences/' . $presenceIdAllow,
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], $headersUser2),
+ [
+ 'status' => 'busy',
+ 'metadata' => ['case' => 'allow-update'],
+ ]
+ );
+ $this->assertEquals(200, $patch['headers']['status-code']);
+ $this->assertEquals('busy', $patch['body']['status']);
+
+ // user2 can delete
+ $delete = $this->client->call(
+ Client::METHOD_DELETE,
+ '/presences/' . $presenceIdAllow,
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], $headersUser2)
+ );
+ $this->assertEquals(204, $delete['headers']['status-code']);
+
+ // Create another presence for user1 without granting any special permissions to user2.
+ $presenceDeny = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . ID::unique(),
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $this->getPresenceApiKey(),
+ ]),
+ [
+ 'userId' => $user1['$id'],
+ 'status' => 'online',
+ 'metadata' => ['case' => 'deny'],
+ // Only the owner has permissions; user2 should not be able to access this document.
+ 'permissions' => $permissionsForUser1,
+ ]
+ );
+
+ $this->assertEquals(200, $presenceDeny['headers']['status-code']);
+ $presenceIdDeny = $presenceDeny['body']['$id'];
+
+ // user2 cannot read
+ $getDeny = $this->client->call(
+ Client::METHOD_GET,
+ '/presences/' . $presenceIdDeny,
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], $headersUser2)
+ );
+ // When read permission is missing, the document should be treated as not found.
+ $this->assertEquals(404, $getDeny['headers']['status-code']);
+
+ // user2 cannot update
+ $patchDeny = $this->client->call(
+ Client::METHOD_PATCH,
+ '/presences/' . $presenceIdDeny,
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], $headersUser2),
+ [
+ 'status' => 'busy',
+ ]
+ );
+ $this->assertEquals(404, $patchDeny['headers']['status-code']);
+
+ // user2 cannot delete
+ $deleteDeny = $this->client->call(
+ Client::METHOD_DELETE,
+ '/presences/' . $presenceIdDeny,
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], $headersUser2)
+ );
+ $this->assertEquals(404, $deleteDeny['headers']['status-code']);
+ }
+
+ public function testUpdatePresenceSparseFields(): void
+ {
+ if ($this->getSide() === 'client') {
+ $upsert = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . ID::unique(),
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders(false)),
+ [
+ 'status' => 'away',
+ 'metadata' => ['source' => 'setup'],
+ ]
+ );
+
+ $this->assertEquals(200, $upsert['headers']['status-code']);
+ $presence = $this->resolvePresenceForUser(
+ $upsert['body']['userId'],
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders(false))
+ );
+ $presenceId = $presence['$id'];
+
+ $update = $this->client->call(
+ Client::METHOD_PATCH,
+ '/presences/' . $presenceId,
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders(false)),
+ [
+ 'status' => 'busy',
+ 'metadata' => ['source' => 'update'],
+ ]
+ );
+
+ $this->assertEquals(200, $update['headers']['status-code']);
+ $this->assertEquals('busy', $update['body']['status']);
+ $this->assertEquals(['source' => 'update'], $update['body']['metadata']);
+
+ return;
+ }
+
+ $presence = $this->setupPresence([
+ 'status' => 'away',
+ 'metadata' => ['source' => 'setup'],
+ ]);
+
+ $payload = [
+ 'status' => 'busy',
+ 'metadata' => ['source' => 'update'],
+ ];
+
+ if ($this->getSide() === 'server') {
+ $payload['userId'] = $presence['userId'];
+ }
+
+ $update = $this->client->call(
+ Client::METHOD_PATCH,
+ '/presences/' . $presence['$id'],
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getPresenceServerHeaders()),
+ $payload
+ );
+
+ $this->assertEquals(200, $update['headers']['status-code']);
+ $this->assertEquals('busy', $update['body']['status']);
+ $this->assertEquals(['source' => 'update'], $update['body']['metadata']);
+ }
+
+ public function testUpdatePresenceUserIdReassignsDefaultPermissions(): void
+ {
+ if ($this->getSide() !== 'server') {
+ $this->expectNotToPerformAssertions();
+ return;
+ }
+
+ $projectId = $this->getProject()['$id'];
+ $user1 = $this->getUser(true);
+ $user2 = $this->getUser(true);
+
+ $headersUser1 = [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_' . $projectId . '=' . $user1['session'],
+ ];
+
+ $headersUser2 = [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_' . $projectId . '=' . $user2['session'],
+ ];
+
+ $create = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . ID::unique(),
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], $headersUser1),
+ [
+ 'status' => 'online',
+ 'metadata' => ['owner' => 'user1'],
+ ]
+ );
+
+ $this->assertEquals(200, $create['headers']['status-code']);
+ $presence = $this->resolvePresenceForUser(
+ $user1['$id'],
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], $headersUser1)
+ );
+
+ $reassign = $this->client->call(
+ Client::METHOD_PATCH,
+ '/presences/' . $presence['$id'],
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], $this->getPresenceServerHeaders()),
+ [
+ 'userId' => $user2['$id'],
+ 'status' => 'busy',
+ ]
+ );
+
+ $this->assertEquals(200, $reassign['headers']['status-code']);
+ $this->assertSame($user2['$id'], $reassign['body']['userId']);
+
+ $getOldOwner = $this->client->call(
+ Client::METHOD_GET,
+ '/presences/' . $presence['$id'],
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], $headersUser1)
+ );
+ $this->assertEquals(404, $getOldOwner['headers']['status-code']);
+
+ $getNewOwner = $this->client->call(
+ Client::METHOD_GET,
+ '/presences/' . $presence['$id'],
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], $headersUser2)
+ );
+ $this->assertEquals(200, $getNewOwner['headers']['status-code']);
+ $this->assertSame($user2['$id'], $getNewOwner['body']['userId']);
+
+ $patchOldOwner = $this->client->call(
+ Client::METHOD_PATCH,
+ '/presences/' . $presence['$id'],
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], $headersUser1),
+ [
+ 'status' => 'offline',
+ ]
+ );
+ $this->assertEquals(404, $patchOldOwner['headers']['status-code']);
+
+ $patchNewOwner = $this->client->call(
+ Client::METHOD_PATCH,
+ '/presences/' . $presence['$id'],
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], $headersUser2),
+ [
+ 'status' => 'away',
+ ]
+ );
+ $this->assertEquals(200, $patchNewOwner['headers']['status-code']);
+ $this->assertSame('away', $patchNewOwner['body']['status']);
+ }
+
+ public function testDeletePresence(): void
+ {
+ if ($this->getSide() === 'client') {
+ $upsert = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . ID::unique(),
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders(false)),
+ [
+ 'status' => 'temp-delete',
+ 'metadata' => ['cleanup' => true],
+ ]
+ );
+
+ $this->assertEquals(200, $upsert['headers']['status-code']);
+ $presence = $this->resolvePresenceForUser(
+ $upsert['body']['userId'],
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders(false))
+ );
+ $presenceId = $presence['$id'];
+
+ $delete = $this->client->call(
+ Client::METHOD_DELETE,
+ '/presences/' . $presenceId,
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders(false))
+ );
+
+ $this->assertEquals(204, $delete['headers']['status-code']);
+
+ return;
+ }
+
+ $presence = $this->setupPresence([
+ 'status' => 'temp-delete',
+ 'metadata' => ['cleanup' => true],
+ ]);
+
+ $delete = $this->client->call(
+ Client::METHOD_DELETE,
+ '/presences/' . $presence['$id'],
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getPresenceServerHeaders())
+ );
+
+ $this->assertEquals(204, $delete['headers']['status-code']);
+ }
+
+ public function testUpdatePresencePurgeListCache(): void
+ {
+ if ($this->getSide() === 'client') {
+ $upsert = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . ID::unique(),
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders(false)),
+ [
+ 'status' => 'cache-update-setup',
+ 'metadata' => ['cache' => 'update-setup'],
+ ]
+ );
+ $this->assertEquals(200, $upsert['headers']['status-code']);
+ $headers = \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders(false));
+ $presence = $this->resolvePresenceForUser($upsert['body']['userId'], $headers);
+ } else {
+ $presence = $this->setupPresence([
+ 'status' => 'cache-update-setup',
+ 'metadata' => ['cache' => 'update-setup'],
+ ]);
+ $headers = \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getPresenceServerHeaders());
+ }
+
+ $listPayload = [
+ 'queries' => [
+ Query::equal('userId', [$presence['userId']])->toString(),
+ ],
+ 'ttl' => 60,
+ ];
+
+ $list1 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload);
+ $this->assertEquals(200, $list1['headers']['status-code']);
+ $this->assertArrayHasKey('x-appwrite-cache', $list1['headers']);
+
+ $list2 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload);
+ $this->assertEquals(200, $list2['headers']['status-code']);
+ $this->assertArrayHasKey('x-appwrite-cache', $list2['headers']);
+ $this->assertEquals('hit', $list2['headers']['x-appwrite-cache']);
+
+ $updatePayload = [
+ 'status' => 'cache-update-applied',
+ 'purge' => true,
+ ];
+
+ if ($this->getSide() !== 'client') {
+ $updatePayload['userId'] = $presence['userId'];
+ }
+
+ $update = $this->client->call(
+ Client::METHOD_PATCH,
+ '/presences/' . $presence['$id'],
+ $headers,
+ $updatePayload
+ );
+ $this->assertEquals(200, $update['headers']['status-code']);
+ $this->assertEquals('cache-update-applied', $update['body']['status']);
+
+ $list3 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload);
+ $this->assertEquals(200, $list3['headers']['status-code']);
+ $this->assertArrayHasKey('x-appwrite-cache', $list3['headers']);
+ $this->assertEquals('miss', $list3['headers']['x-appwrite-cache']);
+ }
+
+ public function testUpdatePresencePurgeOnlyListCache(): void
+ {
+ if ($this->getSide() === 'client') {
+ $upsert = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . ID::unique(),
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders(false)),
+ [
+ 'status' => 'cache-purge-only-setup',
+ 'metadata' => ['cache' => 'purge-only-setup'],
+ ]
+ );
+ $this->assertEquals(200, $upsert['headers']['status-code']);
+ $headers = \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders(false));
+ $presence = $this->resolvePresenceForUser($upsert['body']['userId'], $headers);
+ } else {
+ $presence = $this->setupPresence([
+ 'status' => 'cache-purge-only-setup',
+ 'metadata' => ['cache' => 'purge-only-setup'],
+ ]);
+ $headers = \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getPresenceServerHeaders());
+ }
+
+ $listPayload = [
+ 'queries' => [
+ Query::equal('userId', [$presence['userId']])->toString(),
+ ],
+ 'ttl' => 60,
+ ];
+
+ $list1 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload);
+ $this->assertEquals(200, $list1['headers']['status-code']);
+ $this->assertArrayHasKey('x-appwrite-cache', $list1['headers']);
+
+ $list2 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload);
+ $this->assertEquals(200, $list2['headers']['status-code']);
+ $this->assertArrayHasKey('x-appwrite-cache', $list2['headers']);
+ $this->assertEquals('hit', $list2['headers']['x-appwrite-cache']);
+
+ $updatePayload = [
+ 'purge' => true,
+ ];
+
+ if ($this->getSide() !== 'client') {
+ $updatePayload['userId'] = $presence['userId'];
+ }
+
+ $update = $this->client->call(
+ Client::METHOD_PATCH,
+ '/presences/' . $presence['$id'],
+ $headers,
+ $updatePayload
+ );
+ $this->assertEquals(200, $update['headers']['status-code']);
+ $this->assertEquals($presence['$id'], $update['body']['$id']);
+
+ $list3 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload);
+ $this->assertEquals(200, $list3['headers']['status-code']);
+ $this->assertArrayHasKey('x-appwrite-cache', $list3['headers']);
+ $this->assertEquals('miss', $list3['headers']['x-appwrite-cache']);
+ }
+
+ public function testDeletePresencePurgesListCache(): void
+ {
+ if ($this->getSide() === 'client') {
+ $upsert = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . ID::unique(),
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders(false)),
+ [
+ 'status' => 'cache-delete-setup',
+ 'metadata' => ['cache' => 'delete-setup'],
+ ]
+ );
+ $this->assertEquals(200, $upsert['headers']['status-code']);
+ $headers = \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders(false));
+ $presence = $this->resolvePresenceForUser($upsert['body']['userId'], $headers);
+ } else {
+ $presence = $this->setupPresence([
+ 'status' => 'cache-delete-setup',
+ 'metadata' => ['cache' => 'delete-setup'],
+ ]);
+ $headers = \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getPresenceServerHeaders());
+ }
+
+ $listPayload = [
+ 'queries' => [
+ Query::equal('userId', [$presence['userId']])->toString(),
+ ],
+ 'ttl' => 60,
+ ];
+
+ $list1 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload);
+ $this->assertEquals(200, $list1['headers']['status-code']);
+ $this->assertArrayHasKey('x-appwrite-cache', $list1['headers']);
+
+ $list2 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload);
+ $this->assertEquals(200, $list2['headers']['status-code']);
+ $this->assertArrayHasKey('x-appwrite-cache', $list2['headers']);
+ $this->assertEquals('hit', $list2['headers']['x-appwrite-cache']);
+
+ $delete = $this->client->call(
+ Client::METHOD_DELETE,
+ '/presences/' . $presence['$id'],
+ $headers
+ );
+ $this->assertEquals(204, $delete['headers']['status-code']);
+
+ $list3 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload);
+ $this->assertEquals(200, $list3['headers']['status-code']);
+ $this->assertArrayHasKey('x-appwrite-cache', $list3['headers']);
+ $this->assertEquals('miss', $list3['headers']['x-appwrite-cache']);
+ }
+
+ public function testUpdateNotFound(): void
+ {
+ if ($this->getSide() === 'client') {
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/presences/' . ID::unique(),
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders(false)),
+ [
+ 'status' => 'ghost',
+ ]
+ );
+
+ $this->assertEquals(404, $response['headers']['status-code']);
+ return;
+ }
+
+ $payload = [
+ 'status' => 'ghost',
+ ];
+
+ if ($this->getSide() === 'server') {
+ $payload['userId'] = $this->getUser()['$id'];
+ }
+
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/presences/' . ID::unique(),
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getPresenceServerHeaders()),
+ $payload
+ );
+
+ $this->assertEquals(404, $response['headers']['status-code']);
+ }
+
+ public function testClientCannotPassUserId(): void
+ {
+ if ($this->getSide() === 'server') {
+ $this->expectNotToPerformAssertions();
+ return;
+ }
+
+ $response = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . ID::unique(),
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders(false)),
+ [
+ 'userId' => ID::unique(),
+ 'status' => 'online',
+ ]
+ );
+
+ $this->assertEquals(401, $response['headers']['status-code']);
+ }
+
+ public function testServerRequiresUserId(): void
+ {
+ if ($this->getSide() === 'client') {
+ $this->expectNotToPerformAssertions();
+ return;
+ }
+
+ $response = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . ID::unique(),
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getPresenceServerHeaders()),
+ [
+ 'status' => 'online',
+ ]
+ );
+
+ $this->assertEquals(400, $response['headers']['status-code']);
+ }
+
+ public function testUpsertSameUserMaintainsSinglePresence(): void
+ {
+ if ($this->getSide() === 'client') {
+ $this->expectNotToPerformAssertions();
+ return;
+ }
+
+ $projectId = $this->getProject()['$id'];
+ $userId = $this->getUser()['$id'];
+ $headers = \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], $this->getPresenceServerHeaders());
+
+ $firstUpsert = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . ID::unique(),
+ $headers,
+ [
+ 'userId' => $userId,
+ 'status' => 'online',
+ 'metadata' => ['source' => 'first-upsert'],
+ ]
+ );
+ $this->assertEquals(200, $firstUpsert['headers']['status-code']);
+
+ $secondUpsert = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . ID::unique(),
+ $headers,
+ [
+ 'userId' => $userId,
+ 'status' => 'away',
+ 'metadata' => ['source' => 'second-upsert'],
+ ]
+ );
+ $this->assertEquals(200, $secondUpsert['headers']['status-code']);
+
+ $this->assertEquals('away', $secondUpsert['body']['status']);
+ $this->assertEquals(['source' => 'second-upsert'], $secondUpsert['body']['metadata']);
+
+ $list = $this->client->call(
+ Client::METHOD_GET,
+ '/presences',
+ $headers,
+ [
+ 'queries' => [
+ Query::equal('userId', [$userId])->toString(),
+ ],
+ ]
+ );
+
+ $this->assertEquals(200, $list['headers']['status-code']);
+ $this->assertEquals(1, $list['body']['total']);
+ $this->assertCount(1, $list['body']['presences']);
+ $this->assertEquals($userId, $list['body']['presences'][0]['userId']);
+ $this->assertEquals('away', $list['body']['presences'][0]['status']);
+ }
+
+ /**
+ * Regression test for cross-user overwrite on the native-upsert path.
+ *
+ * Scenario:
+ * - User A has a presence row with $id = $sharedPresenceId.
+ * - User B (different userInternalId, no existing presence) issues an upsert that
+ * re-uses $sharedPresenceId.
+ *
+ * Without the ownership guard in State::upsertForUser, the second call would silently
+ * UPDATE A's row (because upsertDocument matches on the primary key) leaving B's data
+ * under A's $id. With the guard, the second call must fail with PRESENCE_ALREADY_EXISTS
+ * and A's row must be untouched.
+ */
+ public function testCrossUserUpsertDoesNotOverwriteForeignPresence(): void
+ {
+ if ($this->getSide() !== 'client') {
+ $this->expectNotToPerformAssertions();
+ return;
+ }
+
+ $projectId = $this->getProject()['$id'];
+ $originalUser = $this->getUser();
+
+ $user1 = $this->getUser(true);
+ $user2 = $this->getUser(true);
+
+ // Preserve the cached session for the rest of the test run.
+ self::$user[$projectId] = $originalUser;
+
+ $headersUser1 = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_' . $projectId . '=' . $user1['session'],
+ ];
+ $headersUser2 = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_' . $projectId . '=' . $user2['session'],
+ ];
+
+ $sharedPresenceId = ID::unique();
+
+ $victim = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . $sharedPresenceId,
+ $headersUser1,
+ [
+ 'status' => 'online',
+ 'metadata' => ['owner' => 'user1'],
+ ]
+ );
+ $this->assertEquals(200, $victim['headers']['status-code']);
+ $this->assertEquals($sharedPresenceId, $victim['body']['$id']);
+ $this->assertEquals($user1['$id'], $victim['body']['userId']);
+
+ $attack = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . $sharedPresenceId,
+ $headersUser2,
+ [
+ 'status' => 'online',
+ 'metadata' => ['owner' => 'user2'],
+ ]
+ );
+ $this->assertNotEquals(
+ 200,
+ $attack['headers']['status-code'],
+ 'Cross-user upsert must not succeed silently. Got body: ' . \json_encode($attack['body'] ?? [])
+ );
+
+ // Verify User1's row is intact. Read via a presence-scoped API key to bypass
+ // any read-permission ambiguity and inspect the persisted state directly.
+ $check = $this->client->call(
+ Client::METHOD_GET,
+ '/presences/' . $sharedPresenceId,
+ [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $this->getPresenceApiKey(),
+ ]
+ );
+ $this->assertEquals(200, $check['headers']['status-code']);
+ $this->assertEquals($user1['$id'], $check['body']['userId']);
+ $this->assertEquals(['owner' => 'user1'], $check['body']['metadata']);
+ }
+}
diff --git a/tests/e2e/Services/Presences/PresenceConsoleClientTest.php b/tests/e2e/Services/Presences/PresenceConsoleClientTest.php
new file mode 100644
index 0000000000..c3c2233256
--- /dev/null
+++ b/tests/e2e/Services/Presences/PresenceConsoleClientTest.php
@@ -0,0 +1,39 @@
+client->call(Client::METHOD_GET, '/presences/usage', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'range' => '32h',
+ ]);
+
+ $this->assertEquals(400, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_GET, '/presences/usage', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'range' => '24h',
+ ]);
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals('24h', $response['body']['range']);
+ $this->assertCount(3, $response['body']);
+ $this->assertIsNumeric($response['body']['usersOnlineTotal']);
+ $this->assertIsArray($response['body']['presences']);
+ }
+}
diff --git a/tests/e2e/Services/Presences/PresenceCustomClientTest.php b/tests/e2e/Services/Presences/PresenceCustomClientTest.php
new file mode 100644
index 0000000000..0679fefb00
--- /dev/null
+++ b/tests/e2e/Services/Presences/PresenceCustomClientTest.php
@@ -0,0 +1,14 @@
+getProject()['$id'];
+
+ if (!empty(self::$presenceApiKeyCache[$projectId])) {
+ return self::$presenceApiKeyCache[$projectId];
+ }
+
+ self::$presenceApiKeyCache[$projectId] = $this->getNewKey([
+ 'presences.read',
+ 'presences.write',
+ ]);
+
+ return self::$presenceApiKeyCache[$projectId];
+ }
+
+ public function testExpiredPresenceDeletedByMaintenance(): void
+ {
+ $projectId = $this->getProject()['$id'];
+ $userId = $this->getUser()['$id'];
+ // Set a near-future expiry to satisfy validation, then wait until it is in the past.
+ $expiresAt = DateTime::format((new \DateTime())->modify('+2 seconds'));
+
+ $createServer = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . ID::unique(),
+ [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $this->getPresenceApiKey(),
+ ],
+ [
+ 'userId' => $userId,
+ 'status' => 'online',
+ 'metadata' => ['test' => 'presence-expiry'],
+ ]
+ );
+
+ $this->assertEquals(200, $createServer['headers']['status-code']);
+ $presenceIdServer = $createServer['body']['$id'];
+
+ $expireServer = $this->client->call(
+ Client::METHOD_PATCH,
+ '/presences/' . $presenceIdServer,
+ [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $this->getPresenceApiKey(),
+ ],
+ [
+ 'userId' => $userId,
+ 'expiresAt' => $expiresAt,
+ ]
+ );
+
+ $this->assertEquals(200, $expireServer['headers']['status-code']);
+ $this->assertEquals(
+ (new \DateTime($expiresAt))->getTimestamp(),
+ (new \DateTime($expireServer['body']['expiresAt']))->getTimestamp()
+ );
+
+ \sleep(3);
+
+ $stdout = '';
+ $stderr = '';
+ $code = Console::execute('docker exec appwrite maintenance --type=trigger', '', $stdout, $stderr);
+ $this->assertSame(0, $code, "Maintenance command failed with code $code: $stderr ($stdout)");
+
+ // Maintenance + delete workers are asynchronous; give extra time to observe cleanup.
+ $this->assertEventually(function () use ($presenceIdServer, $projectId) {
+ $getServer = $this->client->call(
+ Client::METHOD_GET,
+ '/presences/' . $presenceIdServer,
+ [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $this->getPresenceApiKey(),
+ ]
+ );
+
+ $this->assertEquals(404, $getServer['headers']['status-code']);
+ }, 30000, 1000);
+ }
+}
diff --git a/tests/e2e/Services/Presences/PresenceRealtimeClientTest.php b/tests/e2e/Services/Presences/PresenceRealtimeClientTest.php
new file mode 100644
index 0000000000..d824412a51
--- /dev/null
+++ b/tests/e2e/Services/Presences/PresenceRealtimeClientTest.php
@@ -0,0 +1,683 @@
+getProject(true);
+ self::$project = $project;
+
+ $user = $this->getUser(true);
+ $headers = [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_' . $project['$id'] . '=' . $user['session'],
+ ];
+
+ return [$project, $user, $headers];
+ }
+
+ private function getServerHeaders(array $project): array
+ {
+ return [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $project['$id'],
+ 'x-appwrite-key' => $this->getPresenceApiKey($project),
+ ];
+ }
+
+ private function getPresenceApiKey(array $project): string
+ {
+ $projectId = $project['$id'];
+
+ if (!empty(self::$presenceApiKeyCache[$projectId])) {
+ return self::$presenceApiKeyCache[$projectId];
+ }
+
+ // Realtime tests validate HTTP reads of presences; those endpoints require `presences.read`.
+ self::$presenceApiKeyCache[$projectId] = $this->getNewKey([
+ 'presences.read',
+ 'presences.write',
+ ]);
+
+ return self::$presenceApiKeyCache[$projectId];
+ }
+
+ private function connectRealtimeAndSubscribe(
+ array $project,
+ array $headers,
+ array $channels = [],
+ int $timeout = 1
+ ): WebSocketClient {
+ $queryString = \http_build_query([
+ 'project' => $project['$id'],
+ ]);
+
+ $client = new WebSocketClient(
+ 'ws://appwrite.test/v1/realtime?' . $queryString,
+ [
+ 'headers' => $headers,
+ 'timeout' => $timeout,
+ ]
+ );
+
+ $connected = \json_decode($client->receive(), true);
+ $this->assertSame('connected', $connected['type'] ?? null);
+
+ if (empty($channels)) {
+ return $client;
+ }
+
+ $client->send(\json_encode([
+ 'type' => 'subscribe',
+ 'data' => [[
+ 'channels' => $channels,
+ ]],
+ ]));
+
+ $subscribeResponse = \json_decode($client->receive(), true);
+ $this->assertSame('response', $subscribeResponse['type'] ?? null);
+ $this->assertSame('subscribe', $subscribeResponse['data']['to'] ?? null);
+ $this->assertTrue($subscribeResponse['data']['success'] ?? false);
+ $this->assertNotEmpty($subscribeResponse['data']['subscriptions'] ?? []);
+
+ return $client;
+ }
+
+ private function receiveUntil(
+ WebSocketClient $client,
+ callable $match,
+ int $timeoutMs = 800,
+ int $pollMs = 50
+ ): array {
+ $deadline = \microtime(true) + ($timeoutMs / 1000);
+ $lastMessage = [];
+
+ while (\microtime(true) < $deadline) {
+ try {
+ $message = \json_decode($client->receive(), true);
+ } catch (TimeoutException) {
+ \usleep($pollMs * 1000);
+ continue;
+ }
+
+ if (!\is_array($message)) {
+ continue;
+ }
+
+ $lastMessage = $message;
+ if ($match($message)) {
+ return $message;
+ }
+ }
+
+ $this->fail('Timed out waiting for expected websocket frame. Last frame: ' . \json_encode($lastMessage));
+ }
+
+ private function assertQuietFor(WebSocketClient $client, callable $forbidden, int $timeoutMs = 150): void
+ {
+ $deadline = \microtime(true) + ($timeoutMs / 1000);
+ while (\microtime(true) < $deadline) {
+ try {
+ $message = \json_decode($client->receive(), true);
+ } catch (TimeoutException) {
+ continue;
+ }
+
+ if (!\is_array($message)) {
+ continue;
+ }
+
+ if ($forbidden($message)) {
+ $this->fail('Received forbidden websocket frame: ' . \json_encode($message));
+ }
+ }
+ }
+
+ private function assertPresenceRealtimeEvent(
+ array $event,
+ string $presenceId,
+ string $action,
+ string $status,
+ array $metadata,
+ string $expectedUserId
+ ): void {
+ $this->assertSame('event', $event['type'] ?? null);
+ $this->assertContains('presences', $event['data']['channels'] ?? []);
+ $this->assertContains('presences.' . $presenceId, $event['data']['channels'] ?? []);
+ $this->assertContains('presences.' . $presenceId . '.' . $action, $event['data']['events'] ?? []);
+ $this->assertSame($presenceId, $event['data']['payload']['$id'] ?? null);
+ $this->assertSame($status, $event['data']['payload']['status'] ?? null);
+ $this->assertSame($metadata, $event['data']['payload']['metadata'] ?? []);
+ $this->assertSame($expectedUserId, $event['data']['payload']['userId'] ?? null);
+ }
+
+ private function receivePresenceEvent(
+ WebSocketClient $client,
+ string $presenceId,
+ string $action,
+ string $status,
+ array $metadata,
+ string $expectedUserId,
+ int $timeoutMs = 2500
+ ): array {
+ $event = $this->receiveUntil(
+ $client,
+ fn (array $message): bool => ($message['type'] ?? null) === 'event'
+ && ($message['data']['payload']['$id'] ?? null) === $presenceId
+ && \in_array('presences.' . $presenceId . '.' . $action, $message['data']['events'] ?? [], true),
+ $timeoutMs
+ );
+
+ $this->assertPresenceRealtimeEvent($event, $presenceId, $action, $status, $metadata, $expectedUserId);
+ return $event;
+ }
+
+ private function collectPresenceOutcome(
+ WebSocketClient $client,
+ string $presenceId,
+ string $expectedStatus,
+ array $expectedMetadata,
+ string $expectedUserId
+ ): void {
+ $response = null;
+ $event = null;
+
+ $this->receiveUntil($client, function (array $message) use (
+ &$response,
+ &$event,
+ $presenceId,
+ $expectedStatus,
+ $expectedMetadata,
+ $expectedUserId
+ ): bool {
+ $type = $message['type'] ?? null;
+ if ($type === 'response' && ($message['data']['to'] ?? null) === 'presence') {
+ if (($message['data']['presence']['$id'] ?? null) !== $presenceId) {
+ return false;
+ }
+ $this->assertSame($expectedStatus, $message['data']['presence']['status'] ?? null);
+ $this->assertSame($expectedMetadata, $message['data']['presence']['metadata'] ?? null);
+ $response = $message;
+ }
+
+ if ($type === 'event' && ($message['data']['payload']['$id'] ?? null) === $presenceId) {
+ if (!\in_array('presences.' . $presenceId . '.upsert', $message['data']['events'] ?? [], true)) {
+ return false;
+ }
+ $this->assertPresenceRealtimeEvent($message, $presenceId, 'upsert', $expectedStatus, $expectedMetadata, $expectedUserId);
+ $event = $message;
+ }
+
+ return $response !== null && $event !== null;
+ }, 2500);
+ }
+
+ private function receiveErrorMessage(WebSocketClient $client): array
+ {
+ $error = $this->receiveUntil(
+ $client,
+ fn (array $message): bool => ($message['type'] ?? null) === 'error',
+ 3000
+ );
+ $this->assertSame('error', $error['type'] ?? null);
+ return $error;
+ }
+
+ private function sendPresenceMessage(
+ WebSocketClient $client,
+ string $presenceId,
+ string $status,
+ array $metadata,
+ array $permissions
+ ): void {
+ $client->send(\json_encode([
+ 'type' => 'presence',
+ 'data' => [
+ 'presenceId' => $presenceId,
+ 'status' => $status,
+ 'metadata' => $metadata,
+ 'permissions' => $permissions,
+ ],
+ ]));
+ }
+
+ private function getPresencePermissions(string|Role $readRole): array
+ {
+ return [
+ Permission::read($readRole),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ];
+ }
+
+ public function testPresenceUpsertSenderGetsResponseAndEvent(): void
+ {
+ [$project, $user, $headers] = $this->bootstrapIsolatedProject();
+ $presenceId = ID::unique();
+ $metadata = ['testRunId' => ID::unique(), 'case' => 'upsert-basic'];
+
+ $publisher = $this->connectRealtimeAndSubscribe(
+ $project,
+ $headers,
+ ['presences', 'presences.' . $presenceId],
+ timeout: 2
+ );
+
+ try {
+ $this->sendPresenceMessage(
+ $publisher,
+ $presenceId,
+ 'online',
+ $metadata,
+ $this->getPresencePermissions(Role::any())
+ );
+
+ $this->collectPresenceOutcome($publisher, $presenceId, 'online', $metadata, $user['$id']);
+
+ $read = $this->client->call(
+ Client::METHOD_GET,
+ '/presences/' . $presenceId,
+ $this->getServerHeaders($project)
+ );
+
+ $this->assertSame(200, $read['headers']['status-code']);
+ $this->assertSame($presenceId, $read['body']['$id']);
+ $this->assertSame($user['$id'], $read['body']['userId']);
+ $this->assertSame('online', $read['body']['status']);
+ $this->assertSame($metadata, $read['body']['metadata']);
+ } finally {
+ $publisher->close();
+ }
+ }
+
+ public function testPresenceUpsertSameUserUpdatesSingleRecord(): void
+ {
+ [$project, $user, $headers] = $this->bootstrapIsolatedProject();
+ $firstPresenceId = ID::unique();
+ $secondPresenceId = ID::unique();
+ $marker = ID::unique();
+
+ $publisher = $this->connectRealtimeAndSubscribe(
+ $project,
+ $headers,
+ ['presences', 'presences.' . $firstPresenceId, 'presences.' . $secondPresenceId],
+ timeout: 2
+ );
+
+ try {
+ $firstMetadata = ['testRunId' => $marker, 'step' => 'first'];
+ $secondMetadata = ['testRunId' => $marker, 'step' => 'second'];
+
+ $this->sendPresenceMessage(
+ $publisher,
+ $firstPresenceId,
+ 'away',
+ $firstMetadata,
+ $this->getPresencePermissions(Role::any())
+ );
+ $this->collectPresenceOutcome($publisher, $firstPresenceId, 'away', $firstMetadata, $user['$id']);
+
+ $this->sendPresenceMessage(
+ $publisher,
+ $secondPresenceId,
+ 'busy',
+ $secondMetadata,
+ $this->getPresencePermissions(Role::any())
+ );
+ // The server keeps one row per user keyed by userInternalId and anchors $id to the
+ // first claim, so the second upsert's response/event come back under $firstPresenceId.
+ $this->collectPresenceOutcome($publisher, $firstPresenceId, 'busy', $secondMetadata, $user['$id']);
+
+ $list = $this->client->call(
+ Client::METHOD_GET,
+ '/presences',
+ $this->getServerHeaders($project),
+ [
+ 'queries' => [
+ Query::equal('userId', [$user['$id']])->toString(),
+ ],
+ ]
+ );
+
+ $this->assertSame(200, $list['headers']['status-code']);
+ $this->assertSame(1, $list['body']['total']);
+ $this->assertSame($user['$id'], $list['body']['presences'][0]['userId']);
+ $this->assertSame('busy', $list['body']['presences'][0]['status']);
+ $this->assertSame($secondMetadata, $list['body']['presences'][0]['metadata']);
+ } finally {
+ $publisher->close();
+ }
+ }
+
+ public function testPresenceValidationErrorsReturnErrorOnly(): void
+ {
+ [$project, , $headers] = $this->bootstrapIsolatedProject();
+ $presenceId = ID::unique();
+ $client = $this->connectRealtimeAndSubscribe($project, $headers, ['presences', 'presences.' . $presenceId], timeout: 2);
+
+ try {
+ $client->send(\json_encode([
+ 'type' => 'presence',
+ 'data' => [
+ 'presenceId' => $presenceId,
+ 'metadata' => [
+ 'testRunId' => ID::unique(),
+ ],
+ ],
+ ]));
+ $missingStatus = $this->receiveErrorMessage($client);
+ $this->assertStringContainsString('Payload is not valid. Status is required', $missingStatus['data']['message'] ?? '');
+ $this->assertQuietFor(
+ $client,
+ fn (array $frame): bool => ($frame['type'] ?? null) === 'event'
+ && ($frame['data']['payload']['$id'] ?? null) === $presenceId
+ );
+
+ $client->send(\json_encode([
+ 'type' => 'presence',
+ 'data' => [
+ 'presenceId' => $presenceId,
+ 'status' => 'online',
+ 'permissions' => 'invalid',
+ ],
+ ]));
+ $invalidPermissions = $this->receiveErrorMessage($client);
+ $this->assertStringContainsString('permissions: Permissions must be an array of strings', $invalidPermissions['data']['message'] ?? '');
+ $this->assertQuietFor(
+ $client,
+ fn (array $frame): bool => ($frame['type'] ?? null) === 'event'
+ && ($frame['data']['payload']['$id'] ?? null) === $presenceId
+ );
+ } finally {
+ $client->close();
+ }
+ }
+
+ public function testPresenceUnauthenticatedUserGetsAuthorizationError(): void
+ {
+ $project = $this->getProject(true);
+ self::$project = $project;
+
+ $presenceId = ID::unique();
+ $client = $this->connectRealtimeAndSubscribe(
+ $project,
+ ['origin' => 'http://localhost'],
+ ['presences', 'presences.' . $presenceId],
+ timeout: 2
+ );
+
+ try {
+ $client->send(\json_encode([
+ 'type' => 'presence',
+ 'data' => [
+ 'presenceId' => $presenceId,
+ 'status' => 'online',
+ 'metadata' => ['testRunId' => ID::unique()],
+ ],
+ ]));
+
+ $error = $this->receiveErrorMessage($client);
+ $this->assertSame(401, $error['data']['code'] ?? null);
+ $this->assertSame('User must be authorized', $error['data']['message'] ?? null);
+
+ $this->assertQuietFor(
+ $client,
+ fn (array $frame): bool => ($frame['type'] ?? null) === 'event'
+ && ($frame['data']['payload']['$id'] ?? null) === $presenceId
+ );
+ } finally {
+ $client->close();
+ }
+ }
+
+ public function testChannelParsingChannelsAndEvents(): void
+ {
+ [$project, $user, $headers] = $this->bootstrapIsolatedProject();
+ $presenceId = ID::unique();
+ $listener = $this->connectRealtimeAndSubscribe(
+ $project,
+ $headers,
+ ['presences', 'presences.' . $presenceId],
+ timeout: 2
+ );
+
+ try {
+ $createMetadata = ['testRunId' => ID::unique(), 'source' => 'channel-create'];
+ $updateMetadata = ['testRunId' => $createMetadata['testRunId'], 'source' => 'channel-update'];
+
+ $create = $this->client->call(
+ Client::METHOD_PUT,
+ '/presences/' . $presenceId,
+ $this->getServerHeaders($project),
+ [
+ 'userId' => $user['$id'],
+ 'status' => 'online',
+ 'metadata' => $createMetadata,
+ 'permissions' => $this->getPresencePermissions(Role::any()),
+ ]
+ );
+ $this->assertSame(200, $create['headers']['status-code']);
+ $this->receivePresenceEvent($listener, $presenceId, 'upsert', 'online', $createMetadata, $user['$id']);
+
+ $update = $this->client->call(
+ Client::METHOD_PATCH,
+ '/presences/' . $presenceId,
+ $this->getServerHeaders($project),
+ [
+ 'status' => 'away',
+ 'metadata' => $updateMetadata,
+ ]
+ );
+ $this->assertSame(200, $update['headers']['status-code']);
+ $this->receivePresenceEvent($listener, $presenceId, 'update', 'away', $updateMetadata, $user['$id']);
+
+ $delete = $this->client->call(
+ Client::METHOD_DELETE,
+ '/presences/' . $presenceId,
+ $this->getServerHeaders($project)
+ );
+ $this->assertSame(204, $delete['headers']['status-code']);
+ $this->receivePresenceEvent($listener, $presenceId, 'delete', 'away', $updateMetadata, $user['$id']);
+ } finally {
+ $listener->close();
+ }
+ }
+
+ public function testPresencePermissionsReceiverRouting(): void
+ {
+ [$project, $user1, $user1Headers] = $this->bootstrapIsolatedProject();
+ $user2 = $this->getUser(true);
+
+ $user2Headers = [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_' . $project['$id'] . '=' . $user2['session'],
+ ];
+
+ $presenceIdAny = ID::unique();
+ $presenceIdOwner = ID::unique();
+
+ $channels = [
+ 'presences',
+ 'presences.' . $presenceIdAny,
+ 'presences.' . $presenceIdOwner,
+ ];
+
+ $publisher = $this->connectRealtimeAndSubscribe($project, $user1Headers, ['presences'], timeout: 1);
+ $listener1 = $this->connectRealtimeAndSubscribe($project, $user1Headers, $channels, timeout: 1);
+ $listener2 = $this->connectRealtimeAndSubscribe($project, $user2Headers, $channels, timeout: 1);
+
+ try {
+ $metadataAny = ['testRunId' => ID::unique(), 'visibility' => 'any'];
+ $this->sendPresenceMessage(
+ $publisher,
+ $presenceIdAny,
+ 'online',
+ $metadataAny,
+ $this->getPresencePermissions(Role::any())
+ );
+ $this->collectPresenceOutcome($publisher, $presenceIdAny, 'online', $metadataAny, $user1['$id']);
+ $this->receivePresenceEvent($listener1, $presenceIdAny, 'upsert', 'online', $metadataAny, $user1['$id']);
+ $this->receivePresenceEvent($listener2, $presenceIdAny, 'upsert', 'online', $metadataAny, $user1['$id']);
+
+ $metadataOwner = ['testRunId' => ID::unique(), 'visibility' => 'owner'];
+ $this->sendPresenceMessage(
+ $publisher,
+ $presenceIdOwner,
+ 'busy',
+ $metadataOwner,
+ $this->getPresencePermissions(Role::user($user1['$id']))
+ );
+ // Same user, so the server reuses the original record's $id ($presenceIdAny);
+ // only permissions/status/metadata change — which is what permission routing should filter on.
+ $this->collectPresenceOutcome($publisher, $presenceIdAny, 'busy', $metadataOwner, $user1['$id']);
+ $this->receivePresenceEvent($listener1, $presenceIdAny, 'upsert', 'busy', $metadataOwner, $user1['$id']);
+ $this->assertQuietFor(
+ $listener2,
+ fn (array $frame): bool => ($frame['type'] ?? null) === 'event'
+ && ($frame['data']['payload']['$id'] ?? null) === $presenceIdAny
+ && ($frame['data']['payload']['metadata']['visibility'] ?? null) === 'owner'
+ );
+ } finally {
+ $publisher->close();
+ $listener1->close();
+ $listener2->close();
+ }
+ }
+
+ public function testPresenceCloseEmitsDeleteEvent(): void
+ {
+ [$project, $user, $headers] = $this->bootstrapIsolatedProject();
+ $presenceId = ID::unique();
+ $metadata = ['testRunId' => ID::unique(), 'source' => 'close-delete'];
+
+ $publisher = $this->connectRealtimeAndSubscribe($project, $headers, ['presences', 'presences.' . $presenceId], timeout: 1);
+ $listener = $this->connectRealtimeAndSubscribe($project, $headers, ['presences', 'presences.' . $presenceId], timeout: 1);
+
+ try {
+ $this->sendPresenceMessage(
+ $publisher,
+ $presenceId,
+ 'online',
+ $metadata,
+ $this->getPresencePermissions(Role::any())
+ );
+ $this->collectPresenceOutcome($publisher, $presenceId, 'online', $metadata, $user['$id']);
+ $this->receivePresenceEvent($listener, $presenceId, 'upsert', 'online', $metadata, $user['$id']);
+
+ $publisher->close();
+
+ $this->receivePresenceEvent($listener, $presenceId, 'delete', 'online', $metadata, $user['$id'], timeoutMs: 3000);
+ } finally {
+ $listener->close();
+ }
+ }
+
+ public function testHttpDeleteThenCloseDoesNotDuplicateDeleteEvent(): void
+ {
+ [$project, $user, $headers] = $this->bootstrapIsolatedProject();
+ $presenceId = ID::unique();
+ $metadata = ['testRunId' => ID::unique(), 'source' => 'http-delete-then-close'];
+
+ $publisher = $this->connectRealtimeAndSubscribe($project, $headers, ['presences', 'presences.' . $presenceId], timeout: 1);
+ $listener = $this->connectRealtimeAndSubscribe($project, $headers, ['presences', 'presences.' . $presenceId], timeout: 1);
+
+ try {
+ // Publish a presence over WebSocket so the realtime worker tracks it in
+ // its in-memory connection map under the publisher connection.
+ $this->sendPresenceMessage(
+ $publisher,
+ $presenceId,
+ 'online',
+ $metadata,
+ $this->getPresencePermissions(Role::any())
+ );
+ $this->collectPresenceOutcome($publisher, $presenceId, 'online', $metadata, $user['$id']);
+ $this->receivePresenceEvent($listener, $presenceId, 'upsert', 'online', $metadata, $user['$id']);
+
+ // HTTP DELETE removes the row from the DB and emits the delete event via pubsub.
+ // The realtime worker is expected to strip the presence from the publisher's
+ // in-memory connection state when it processes the pubsub message.
+ $delete = $this->client->call(
+ Client::METHOD_DELETE,
+ '/presences/' . $presenceId,
+ $this->getServerHeaders($project)
+ );
+ $this->assertSame(204, $delete['headers']['status-code']);
+
+ // Synchronization point: wait for the listener to receive the legitimate
+ // delete event before closing the publisher. Redis pubsub broadcasts to
+ // every realtime worker simultaneously, so the listener's worker observing
+ // the event implies the publisher's worker has also processed it (and run
+ // the in-memory cleanup) by the time onClose fires.
+ $deleteEvents = [];
+ $deleteEvents[] = $this->receivePresenceEvent($listener, $presenceId, 'delete', 'online', $metadata, $user['$id']);
+
+ $publisher->close();
+
+ // Watch for any additional presences.{id}.delete frame. A second one would
+ // be the regression: onClose re-firing the event for a presence already
+ // removed via HTTP DELETE.
+ $deadline = \microtime(true) + 2.0;
+
+ $this->assertEventually(
+ function () use ($listener, $presenceId, $deadline, &$deleteEvents): void {
+ try {
+ $raw = $listener->receive();
+ $frame = \json_decode($raw, true);
+ if (
+ \is_array($frame)
+ && ($frame['type'] ?? null) === 'event'
+ && ($frame['data']['payload']['$id'] ?? null) === $presenceId
+ && \in_array('presences.' . $presenceId . '.delete', $frame['data']['events'] ?? [], true)
+ ) {
+ $deleteEvents[] = $frame;
+ if (\count($deleteEvents) > 1) {
+ throw new Critical(
+ 'Duplicate presence delete event after HTTP DELETE + WebSocket close: '
+ . \json_encode($frame)
+ );
+ }
+ }
+ } catch (TimeoutException) {
+ // No frame this poll; fall through to deadline check.
+ }
+
+ if (\microtime(true) < $deadline) {
+ // Throw a non-Critical exception so assertEventually retries.
+ throw new \RuntimeException('still watching for duplicate delete event');
+ }
+ },
+ timeoutMs: 3000,
+ waitMs: 0
+ );
+
+ $this->assertCount(
+ 1,
+ $deleteEvents,
+ 'Expected exactly one presences.' . $presenceId . '.delete event; got ' . \count($deleteEvents)
+ );
+ $this->assertPresenceRealtimeEvent($deleteEvents[0], $presenceId, 'delete', 'online', $metadata, $user['$id']);
+ } finally {
+ $listener->close();
+ }
+ }
+}
diff --git a/tests/e2e/Services/Project/AuthMethodsBase.php b/tests/e2e/Services/Project/AuthMethodsBase.php
new file mode 100644
index 0000000000..cccdca9ea1
--- /dev/null
+++ b/tests/e2e/Services/Project/AuthMethodsBase.php
@@ -0,0 +1,340 @@
+ response field name exposed by the Project model.
+ */
+ protected static array $authMethods = [
+ 'email-password' => 'authEmailPassword',
+ 'magic-url' => 'authUsersAuthMagicURL',
+ 'email-otp' => 'authEmailOtp',
+ 'anonymous' => 'authAnonymous',
+ 'invites' => 'authInvites',
+ 'jwt' => 'authJWT',
+ 'phone' => 'authPhone',
+ ];
+
+ // Success flow
+
+ public function testDisableAuthMethod(): void
+ {
+ foreach (self::$authMethods as $methodId => $responseKey) {
+ $response = $this->updateAuthMethod($methodId, false);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertSame(false, $response['body'][$responseKey]);
+ }
+
+ // Cleanup
+ foreach (self::$authMethods as $methodId => $responseKey) {
+ $this->updateAuthMethod($methodId, true);
+ }
+ }
+
+ public function testEnableAuthMethod(): void
+ {
+ // Disable first
+ foreach (self::$authMethods as $methodId => $responseKey) {
+ $this->updateAuthMethod($methodId, false);
+ }
+
+ // Re-enable
+ foreach (self::$authMethods as $methodId => $responseKey) {
+ $response = $this->updateAuthMethod($methodId, true);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertSame(true, $response['body'][$responseKey]);
+ }
+ }
+
+ public function testDisableAuthMethodIdempotent(): void
+ {
+ $first = $this->updateAuthMethod('email-password', false);
+ $this->assertSame(200, $first['headers']['status-code']);
+ $this->assertSame(false, $first['body']['authEmailPassword']);
+
+ $second = $this->updateAuthMethod('email-password', false);
+ $this->assertSame(200, $second['headers']['status-code']);
+ $this->assertSame(false, $second['body']['authEmailPassword']);
+
+ // Cleanup
+ $this->updateAuthMethod('email-password', true);
+ }
+
+ public function testEnableAuthMethodIdempotent(): void
+ {
+ $first = $this->updateAuthMethod('email-password', true);
+ $this->assertSame(200, $first['headers']['status-code']);
+ $this->assertSame(true, $first['body']['authEmailPassword']);
+
+ $second = $this->updateAuthMethod('email-password', true);
+ $this->assertSame(200, $second['headers']['status-code']);
+ $this->assertSame(true, $second['body']['authEmailPassword']);
+ }
+
+ public function testDisableOneMethodDoesNotAffectOther(): void
+ {
+ // Ensure both start enabled
+ $this->updateAuthMethod('email-password', true);
+ $this->updateAuthMethod('magic-url', true);
+
+ $response = $this->updateAuthMethod('email-password', false);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(false, $response['body']['authEmailPassword']);
+ $this->assertSame(true, $response['body']['authUsersAuthMagicURL']);
+
+ // Cleanup
+ $this->updateAuthMethod('email-password', true);
+ }
+
+ public function testDisabledEmailPasswordBlocksSessionCreation(): void
+ {
+ $this->updateAuthMethod('email-password', false);
+
+ // Unauthenticated account creation would normally be permitted; with the
+ // method disabled we expect the shared auth filter to reject it.
+ $response = $this->client->call(Client::METHOD_POST, '/account', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], [
+ 'userId' => 'unique()',
+ 'email' => 'disabled-method-' . \uniqid() . '@appwrite.io',
+ 'password' => 'password123',
+ ]);
+
+ $this->assertSame(501, $response['headers']['status-code']);
+ $this->assertSame('user_auth_method_unsupported', $response['body']['type']);
+
+ // Cleanup
+ $this->updateAuthMethod('email-password', true);
+ }
+
+ public function testEnabledEmailPasswordAllowsSessionCreation(): void
+ {
+ $this->updateAuthMethod('email-password', false);
+ $this->updateAuthMethod('email-password', true);
+
+ $response = $this->client->call(Client::METHOD_POST, '/account', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], [
+ 'userId' => 'unique()',
+ 'email' => 'enabled-method-' . \uniqid() . '@appwrite.io',
+ 'password' => 'password123',
+ ]);
+
+ $this->assertNotSame(501, $response['headers']['status-code']);
+ $this->assertNotSame('user_auth_method_unsupported', $response['body']['type'] ?? '');
+ }
+
+ public function testDisabledAnonymousBlocksSessionCreation(): void
+ {
+ $this->updateAuthMethod('anonymous', false);
+
+ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/anonymous', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ]);
+
+ $this->assertSame(501, $response['headers']['status-code']);
+ $this->assertSame('user_auth_method_unsupported', $response['body']['type']);
+
+ // Cleanup
+ $this->updateAuthMethod('anonymous', true);
+ }
+
+ public function testResponseModel(): void
+ {
+ $response = $this->updateAuthMethod('email-password', false);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertArrayHasKey('$id', $response['body']);
+ $this->assertArrayHasKey('name', $response['body']);
+ foreach (self::$authMethods as $methodId => $responseKey) {
+ $this->assertArrayHasKey($responseKey, $response['body']);
+ }
+
+ // Cleanup
+ $this->updateAuthMethod('email-password', true);
+ }
+
+ // Failure flow
+
+ public function testUpdateAuthMethodWithoutAuthentication(): void
+ {
+ $response = $this->updateAuthMethod('email-password', false, false);
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ public function testUpdateAuthMethodInvalidMethodId(): void
+ {
+ $response = $this->updateAuthMethod('invalid-method', false);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateAuthMethodEmptyMethodId(): void
+ {
+ $response = $this->updateAuthMethod('', false);
+
+ $this->assertSame(404, $response['headers']['status-code']);
+ }
+
+ public function testUpdateAuthMethodMissingEnabled(): void
+ {
+ $headers = \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders());
+
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/auth-methods/email-password',
+ $headers,
+ []
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ // Backwards compatibility
+
+ public function testUpdateAuthMethodLegacyAliasPath(): void
+ {
+ $headers = \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
+ ], $this->getHeaders());
+
+ $projectId = $this->getProject()['$id'];
+
+ // Disable via the legacy `/v1/projects/:projectId/auth/:methodId` alias
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/projects/' . $projectId . '/auth/email-password',
+ $headers,
+ [
+ 'enabled' => false,
+ ]
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertSame(false, $response['body']['authEmailPassword']);
+
+ // Re-enable via the legacy alias
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/projects/' . $projectId . '/auth/email-password',
+ $headers,
+ [
+ 'enabled' => true,
+ ]
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(true, $response['body']['authEmailPassword']);
+ }
+
+ public function testUpdateAuthMethodLegacyStatusParam(): void
+ {
+ // Old SDK passed `status` in the body. The V23 request filter (triggered
+ // via `x-appwrite-response-format: 1.9.1`) must rename it to `enabled`.
+ $headers = \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
+ ], $this->getHeaders());
+
+ $projectId = $this->getProject()['$id'];
+
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/projects/' . $projectId . '/auth/email-password',
+ $headers,
+ [
+ 'status' => false,
+ ]
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(false, $response['body']['authEmailPassword']);
+
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/projects/' . $projectId . '/auth/email-password',
+ $headers,
+ [
+ 'status' => true,
+ ]
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(true, $response['body']['authEmailPassword']);
+ }
+
+ public function testUpdateAuthMethodLegacyMethodParam(): void
+ {
+ // Old SDK also had `method` as a path identifier; the V23 filter renames
+ // a stray `method` body field to `methodId`. The URL path parameter of
+ // the alias already binds to `:methodId`, so supplying `method` in the
+ // body is tolerated.
+ $headers = \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
+ ], $this->getHeaders());
+
+ $projectId = $this->getProject()['$id'];
+
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/projects/' . $projectId . '/auth/email-password',
+ $headers,
+ [
+ 'method' => 'email-password',
+ 'status' => false,
+ ]
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(false, $response['body']['authEmailPassword']);
+
+ // Cleanup
+ $this->updateAuthMethod('email-password', true);
+ }
+
+ // Helpers
+
+ protected function updateAuthMethod(string $methodId, bool $enabled, bool $authenticated = true): mixed
+ {
+ $headers = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ];
+
+ if ($authenticated) {
+ $headers = \array_merge($headers, $this->getHeaders(), [
+ 'x-appwrite-response-format' => '1.9.4',
+ ]);
+ }
+
+ return $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/auth-methods/' . $methodId,
+ $headers,
+ [
+ 'enabled' => $enabled,
+ ]
+ );
+ }
+}
diff --git a/tests/e2e/Services/Project/AuthMethodsConsoleClientTest.php b/tests/e2e/Services/Project/AuthMethodsConsoleClientTest.php
new file mode 100644
index 0000000000..e1ae5de357
--- /dev/null
+++ b/tests/e2e/Services/Project/AuthMethodsConsoleClientTest.php
@@ -0,0 +1,14 @@
+getProject()['$id'];
+ $apiKey = $this->getProject()['apiKey'];
+
+ $serverHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $apiKey,
+ ];
+
+ // Public headers carry no session / api key — this forces the shared
+ // auth init to actually evaluate the auth-method gate (it is bypassed
+ // for privileged / app users).
+ $publicHeaders = [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ];
+
+ $setAuthMethod = function (string $methodId, bool $enabled) use ($serverHeaders): void {
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/auth-methods/' . $methodId,
+ $serverHeaders,
+ ['enabled' => $enabled]
+ );
+ $this->assertSame(200, $response['headers']['status-code'], 'Failed to toggle ' . $methodId);
+ };
+
+ $methods = ['email-password', 'magic-url', 'email-otp', 'anonymous', 'invites', 'jwt', 'phone'];
+
+ // Step 1 — Disable every auth method up front.
+ foreach ($methods as $methodId) {
+ $setAuthMethod($methodId, false);
+ }
+
+ $assertBlocked = function (array $response, string $context): void {
+ $this->assertSame(501, $response['headers']['status-code'], $context . ' should be blocked with 501');
+ $this->assertSame('user_auth_method_unsupported', $response['body']['type'] ?? '', $context . ' should return user_auth_method_unsupported');
+ };
+
+ $assertNotBlocked = function (array $response, string $context): void {
+ $this->assertNotSame(501, $response['headers']['status-code'], $context . ' should not be blocked after enabling');
+ $this->assertNotSame('user_auth_method_unsupported', $response['body']['type'] ?? '', $context . ' should not return user_auth_method_unsupported after enabling');
+ };
+
+ $email = 'auth_methods_' . \uniqid() . '@localhost.test';
+ $password = 'password1234';
+
+ // Step 2 — anonymous session creation.
+ $anonymousAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/sessions/anonymous', $publicHeaders);
+
+ $assertBlocked($anonymousAttempt(), 'Anonymous session (disabled)');
+ $setAuthMethod('anonymous', true);
+ $response = $anonymousAttempt();
+ $assertNotBlocked($response, 'Anonymous session (enabled)');
+ $this->assertSame(201, $response['headers']['status-code']);
+
+ // Step 3 — email/password account creation.
+ $createAccount = fn () => $this->client->call(Client::METHOD_POST, '/account', $publicHeaders, [
+ 'userId' => ID::unique(),
+ 'email' => $email,
+ 'password' => $password,
+ 'name' => 'Auth Methods User',
+ ]);
+
+ $assertBlocked($createAccount(), 'Account creation (email-password disabled)');
+ $setAuthMethod('email-password', true);
+ $response = $createAccount();
+ $assertNotBlocked($response, 'Account creation (email-password enabled)');
+ $this->assertSame(201, $response['headers']['status-code']);
+ $userId = $response['body']['$id'];
+
+ // Step 4 — email/password session creation (still gated by email-password).
+ // Disable momentarily to prove the session endpoint is gated too.
+ $setAuthMethod('email-password', false);
+ $emailSessionAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [
+ 'email' => $email,
+ 'password' => $password,
+ ]);
+
+ $assertBlocked($emailSessionAttempt(), 'Email/password session (disabled)');
+ $setAuthMethod('email-password', true);
+ $response = $emailSessionAttempt();
+ $assertNotBlocked($response, 'Email/password session (enabled)');
+ $this->assertSame(201, $response['headers']['status-code']);
+ $sessionSecret = $response['cookies']['a_session_' . $projectId] ?? '';
+ $this->assertNotEmpty($sessionSecret, 'Expected a session cookie after email/password login');
+
+ // Step 5 — email OTP token.
+ $emailOtpAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/tokens/email', $publicHeaders, [
+ 'userId' => $userId,
+ 'email' => $email,
+ ]);
+
+ $assertBlocked($emailOtpAttempt(), 'Email OTP (disabled)');
+ $setAuthMethod('email-otp', true);
+ $response = $emailOtpAttempt();
+ $assertNotBlocked($response, 'Email OTP (enabled)');
+ $this->assertSame(201, $response['headers']['status-code']);
+
+ // Step 6 — magic URL token.
+ $magicUrlAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', $publicHeaders, [
+ 'userId' => ID::unique(),
+ 'email' => 'magic_' . \uniqid() . '@localhost.test',
+ ]);
+
+ $assertBlocked($magicUrlAttempt(), 'Magic URL (disabled)');
+ $setAuthMethod('magic-url', true);
+ $response = $magicUrlAttempt();
+ $assertNotBlocked($response, 'Magic URL (enabled)');
+ $this->assertSame(201, $response['headers']['status-code']);
+
+ // Step 7 — phone token. After enabling the auth method the endpoint may
+ // still fail for provider reasons — we only assert that the auth-method
+ // gate stops fighting us.
+ $phoneAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/tokens/phone', $publicHeaders, [
+ 'userId' => ID::unique(),
+ 'phone' => '+14155550199',
+ ]);
+
+ $assertBlocked($phoneAttempt(), 'Phone token (disabled)');
+ $setAuthMethod('phone', true);
+ $assertNotBlocked($phoneAttempt(), 'Phone token (enabled)');
+
+ // Step 8 — team invites. Needs an existing team; the session user
+ // isn't a team owner, so we don't assert on 201 here — the gate itself
+ // is what's under test and any non-501 proves it was lifted.
+ $teamResponse = $this->client->call(Client::METHOD_POST, '/teams', $serverHeaders, [
+ 'teamId' => ID::unique(),
+ 'name' => 'Auth Methods Team',
+ ]);
+ $this->assertSame(201, $teamResponse['headers']['status-code']);
+ $teamId = $teamResponse['body']['$id'];
+
+ $inviteHeaders = \array_merge($publicHeaders, [
+ 'cookie' => 'a_session_' . $projectId . '=' . $sessionSecret,
+ ]);
+ $inviteAttempt = fn () => $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', $inviteHeaders, [
+ 'email' => 'invitee_' . \uniqid() . '@localhost.test',
+ 'roles' => ['developer'],
+ 'url' => 'http://localhost/join',
+ ]);
+
+ $assertBlocked($inviteAttempt(), 'Team invite (disabled)');
+ $setAuthMethod('invites', true);
+ $assertNotBlocked($inviteAttempt(), 'Team invite (enabled)');
+
+ // Step 9 — JWT creation. Requires an active session.
+ $sessionHeaders = \array_merge($publicHeaders, [
+ 'cookie' => 'a_session_' . $projectId . '=' . $sessionSecret,
+ ]);
+ $jwtAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/jwts', $sessionHeaders);
+
+ $assertBlocked($jwtAttempt(), 'JWT (disabled)');
+ $setAuthMethod('jwt', true);
+ $response = $jwtAttempt();
+ $assertNotBlocked($response, 'JWT (enabled)');
+ $this->assertSame(201, $response['headers']['status-code']);
+
+ // Step 10 — End goal: GET /v1/account returns 200 using the session we
+ // built via the (now enabled) email-password flow.
+ $response = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame($userId, $response['body']['$id']);
+ $this->assertSame($email, $response['body']['email']);
+ }
+}
diff --git a/tests/e2e/Services/Project/KeysBase.php b/tests/e2e/Services/Project/KeysBase.php
index 505c7f6539..c8687d9964 100644
--- a/tests/e2e/Services/Project/KeysBase.php
+++ b/tests/e2e/Services/Project/KeysBase.php
@@ -239,6 +239,125 @@ trait KeysBase
$this->deleteKey($customId);
}
+ // =========================================================================
+ // Create ephemeral key tests
+ // =========================================================================
+
+ public function testCreateEphemeralKey(): void
+ {
+ $duration = 900;
+
+ $key = $this->createEphemeralKey(
+ ['users.read', 'users.write'],
+ $duration,
+ );
+
+ $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']);
+
+ $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 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(
+ [],
+ 900,
+ );
+
+ $this->assertSame(201, $key['headers']['status-code']);
+ $this->assertSame([], $key['body']['scopes']);
+ }
+
+ public function testCreateEphemeralKeyWithoutAuthentication(): void
+ {
+ $response = $this->createEphemeralKey(
+ ['users.read'],
+ 900,
+ false
+ );
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ public function testCreateEphemeralKeyMissingDuration(): void
+ {
+ $response = $this->createEphemeralKey(
+ ['users.read'],
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testCreateEphemeralKeyInvalidScope(): void
+ {
+ $response = $this->createEphemeralKey(
+ ['invalid.scope'],
+ 900,
+ );
+
+ $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 +974,29 @@ trait KeysBase
return $this->client->call(Client::METHOD_DELETE, '/project/keys/' . $keyId, $headers);
}
+
+ /**
+ * @param array $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);
+ }
}
diff --git a/tests/e2e/Services/Project/KeysIntegrationTest.php b/tests/e2e/Services/Project/KeysIntegrationTest.php
new file mode 100644
index 0000000000..4dc5838e72
--- /dev/null
+++ b/tests/e2e/Services/Project/KeysIntegrationTest.php
@@ -0,0 +1,103 @@
+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']);
+ }
+}
diff --git a/tests/e2e/Services/Project/MockPhonesBase.php b/tests/e2e/Services/Project/MockPhonesBase.php
new file mode 100644
index 0000000000..e41a8901bf
--- /dev/null
+++ b/tests/e2e/Services/Project/MockPhonesBase.php
@@ -0,0 +1,550 @@
+uniquePhoneNumber();
+
+ $response = $this->createMockPhone($number, '123456');
+
+ $this->assertSame(201, $response['headers']['status-code']);
+ $this->assertSame($number, $response['body']['number']);
+ $this->assertSame('123456', $response['body']['otp']);
+
+ $dateValidator = new DatetimeValidator();
+ $this->assertTrue($dateValidator->isValid($response['body']['$createdAt']));
+ $this->assertTrue($dateValidator->isValid($response['body']['$updatedAt']));
+
+ // Verify via GET
+ $get = $this->getMockPhone($number);
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertSame($number, $get['body']['number']);
+ $this->assertSame('123456', $get['body']['otp']);
+
+ // Verify via LIST
+ $list = $this->listMockPhones();
+ $this->assertSame(200, $list['headers']['status-code']);
+ $numbers = \array_column($list['body']['mockNumbers'], 'number');
+ $this->assertContains($number, $numbers);
+
+ // Cleanup
+ $this->deleteMockPhone($number);
+ }
+
+ public function testCreateMockPhoneAlreadyExists(): void
+ {
+ $number = $this->uniquePhoneNumber();
+
+ $first = $this->createMockPhone($number, '123456');
+ $this->assertSame(201, $first['headers']['status-code']);
+
+ $duplicate = $this->createMockPhone($number, '654321');
+ $this->assertSame(409, $duplicate['headers']['status-code']);
+ $this->assertSame('mock_number_already_exists', $duplicate['body']['type']);
+
+ // Original OTP must remain unchanged
+ $get = $this->getMockPhone($number);
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertSame('123456', $get['body']['otp']);
+
+ // Cleanup
+ $this->deleteMockPhone($number);
+ }
+
+ public function testCreateMockPhoneInvalidNumber(): void
+ {
+ // Missing `+` prefix — Phone validator rejects.
+ $response = $this->createMockPhone('16555551234', '123456');
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testCreateMockPhoneNumberTooLong(): void
+ {
+ // 16 digits exceeds the E.164 15-digit maximum.
+ $response = $this->createMockPhone('+1234567890987654', '123456');
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testCreateMockPhoneInvalidOtpTooShort(): void
+ {
+ $response = $this->createMockPhone($this->uniquePhoneNumber(), '123');
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testCreateMockPhoneInvalidOtpTooLong(): void
+ {
+ $response = $this->createMockPhone($this->uniquePhoneNumber(), '1234567');
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testCreateMockPhoneInvalidOtpNonNumeric(): void
+ {
+ $response = $this->createMockPhone($this->uniquePhoneNumber(), 'abc123');
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testCreateMockPhoneMissingNumber(): void
+ {
+ $response = $this->createMockPhone(null, '123456');
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testCreateMockPhoneMissingOtp(): void
+ {
+ $response = $this->createMockPhone($this->uniquePhoneNumber(), null);
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testCreateMockPhoneWithoutAuthentication(): void
+ {
+ $response = $this->createMockPhone($this->uniquePhoneNumber(), '123456', authenticated: false);
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ // Get mock phone tests
+
+ public function testGetMockPhone(): void
+ {
+ $number = $this->uniquePhoneNumber();
+ $create = $this->createMockPhone($number, '987654');
+ $this->assertSame(201, $create['headers']['status-code']);
+
+ $response = $this->getMockPhone($number);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame($number, $response['body']['number']);
+ $this->assertSame('987654', $response['body']['otp']);
+
+ $dateValidator = new DatetimeValidator();
+ $this->assertTrue($dateValidator->isValid($response['body']['$createdAt']));
+ $this->assertTrue($dateValidator->isValid($response['body']['$updatedAt']));
+
+ // Cleanup
+ $this->deleteMockPhone($number);
+ }
+
+ public function testGetMockPhoneNotFound(): void
+ {
+ $response = $this->getMockPhone($this->uniquePhoneNumber());
+
+ $this->assertSame(404, $response['headers']['status-code']);
+ $this->assertSame('mock_number_not_found', $response['body']['type']);
+ }
+
+ public function testGetMockPhoneInvalidNumber(): void
+ {
+ // Path param is still validated with the Phone validator.
+ $response = $this->getMockPhone('not-a-phone');
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testGetMockPhoneWithoutAuthentication(): void
+ {
+ $number = $this->uniquePhoneNumber();
+ $create = $this->createMockPhone($number, '123456');
+ $this->assertSame(201, $create['headers']['status-code']);
+
+ $response = $this->getMockPhone($number, authenticated: false);
+ $this->assertSame(401, $response['headers']['status-code']);
+
+ // Cleanup
+ $this->deleteMockPhone($number);
+ }
+
+ // Update mock phone tests
+
+ public function testUpdateMockPhone(): void
+ {
+ $number = $this->uniquePhoneNumber();
+ $create = $this->createMockPhone($number, '111111');
+ $this->assertSame(201, $create['headers']['status-code']);
+
+ $createdAt = $create['body']['$createdAt'];
+
+ // Sleep a bit so $updatedAt shifts noticeably — makes the assertion below meaningful.
+ \sleep(1);
+
+ $update = $this->updateMockPhone($number, '222222');
+
+ $this->assertSame(200, $update['headers']['status-code']);
+ $this->assertSame($number, $update['body']['number']);
+ $this->assertSame('222222', $update['body']['otp']);
+ $this->assertSame($createdAt, $update['body']['$createdAt']);
+ $this->assertNotSame($createdAt, $update['body']['$updatedAt']);
+
+ // Verify persistence via GET
+ $get = $this->getMockPhone($number);
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertSame('222222', $get['body']['otp']);
+
+ // Cleanup
+ $this->deleteMockPhone($number);
+ }
+
+ public function testUpdateMockPhoneNotFound(): void
+ {
+ $response = $this->updateMockPhone($this->uniquePhoneNumber(), '123456');
+
+ $this->assertSame(404, $response['headers']['status-code']);
+ $this->assertSame('mock_number_not_found', $response['body']['type']);
+ }
+
+ public function testUpdateMockPhoneInvalidOtp(): void
+ {
+ $number = $this->uniquePhoneNumber();
+ $create = $this->createMockPhone($number, '123456');
+ $this->assertSame(201, $create['headers']['status-code']);
+
+ $response = $this->updateMockPhone($number, 'abc123');
+ $this->assertSame(400, $response['headers']['status-code']);
+
+ // Original OTP must remain unchanged
+ $get = $this->getMockPhone($number);
+ $this->assertSame('123456', $get['body']['otp']);
+
+ // Cleanup
+ $this->deleteMockPhone($number);
+ }
+
+ public function testUpdateMockPhoneMissingOtp(): void
+ {
+ $number = $this->uniquePhoneNumber();
+ $create = $this->createMockPhone($number, '123456');
+ $this->assertSame(201, $create['headers']['status-code']);
+
+ $response = $this->updateMockPhone($number, null);
+ $this->assertSame(400, $response['headers']['status-code']);
+
+ // Cleanup
+ $this->deleteMockPhone($number);
+ }
+
+ public function testUpdateMockPhoneWithoutAuthentication(): void
+ {
+ $number = $this->uniquePhoneNumber();
+ $create = $this->createMockPhone($number, '123456');
+ $this->assertSame(201, $create['headers']['status-code']);
+
+ $response = $this->updateMockPhone($number, '654321', authenticated: false);
+ $this->assertSame(401, $response['headers']['status-code']);
+
+ // Verify it's unchanged
+ $get = $this->getMockPhone($number);
+ $this->assertSame('123456', $get['body']['otp']);
+
+ // Cleanup
+ $this->deleteMockPhone($number);
+ }
+
+ // List mock phones tests
+
+ public function testListMockPhones(): void
+ {
+ $number1 = $this->uniquePhoneNumber();
+ $number2 = $this->uniquePhoneNumber();
+ $number3 = $this->uniquePhoneNumber();
+
+ $this->assertSame(201, $this->createMockPhone($number1, '111111')['headers']['status-code']);
+ $this->assertSame(201, $this->createMockPhone($number2, '222222')['headers']['status-code']);
+ $this->assertSame(201, $this->createMockPhone($number3, '333333')['headers']['status-code']);
+
+ $response = $this->listMockPhones();
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertArrayHasKey('mockNumbers', $response['body']);
+ $this->assertArrayHasKey('total', $response['body']);
+ $this->assertIsArray($response['body']['mockNumbers']);
+ $this->assertIsInt($response['body']['total']);
+ $this->assertGreaterThanOrEqual(3, $response['body']['total']);
+ $this->assertGreaterThanOrEqual(3, \count($response['body']['mockNumbers']));
+
+ // Verify shape of each entry
+ foreach ($response['body']['mockNumbers'] as $entry) {
+ $this->assertArrayHasKey('number', $entry);
+ $this->assertArrayHasKey('otp', $entry);
+ $this->assertArrayHasKey('$createdAt', $entry);
+ $this->assertArrayHasKey('$updatedAt', $entry);
+ }
+
+ // All three seeded phones must be in the list
+ $numbers = \array_column($response['body']['mockNumbers'], 'number');
+ $this->assertContains($number1, $numbers);
+ $this->assertContains($number2, $numbers);
+ $this->assertContains($number3, $numbers);
+
+ // Cleanup
+ $this->deleteMockPhone($number1);
+ $this->deleteMockPhone($number2);
+ $this->deleteMockPhone($number3);
+ }
+
+ public function testListMockPhonesTotalFalse(): void
+ {
+ $number = $this->uniquePhoneNumber();
+ $create = $this->createMockPhone($number, '123456');
+ $this->assertSame(201, $create['headers']['status-code']);
+
+ $response = $this->listMockPhones(total: false);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(0, $response['body']['total']);
+ $this->assertGreaterThanOrEqual(1, \count($response['body']['mockNumbers']));
+
+ // Cleanup
+ $this->deleteMockPhone($number);
+ }
+
+ public function testListMockPhonesTotalMatchesCount(): void
+ {
+ $number = $this->uniquePhoneNumber();
+ $create = $this->createMockPhone($number, '123456');
+ $this->assertSame(201, $create['headers']['status-code']);
+
+ $response = $this->listMockPhones();
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(\count($response['body']['mockNumbers']), $response['body']['total']);
+
+ // Cleanup
+ $this->deleteMockPhone($number);
+ }
+
+ public function testListMockPhonesWithLimit(): void
+ {
+ $number1 = $this->uniquePhoneNumber();
+ $number2 = $this->uniquePhoneNumber();
+
+ $this->assertSame(201, $this->createMockPhone($number1, '111111')['headers']['status-code']);
+ $this->assertSame(201, $this->createMockPhone($number2, '222222')['headers']['status-code']);
+
+ $response = $this->listMockPhones([
+ Query::limit(1)->toString(),
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertCount(1, $response['body']['mockNumbers']);
+ $this->assertGreaterThanOrEqual(2, $response['body']['total']);
+
+ // Cleanup
+ $this->deleteMockPhone($number1);
+ $this->deleteMockPhone($number2);
+ }
+
+ public function testListMockPhonesWithOffset(): void
+ {
+ $number1 = $this->uniquePhoneNumber();
+ $number2 = $this->uniquePhoneNumber();
+
+ $this->assertSame(201, $this->createMockPhone($number1, '111111')['headers']['status-code']);
+ $this->assertSame(201, $this->createMockPhone($number2, '222222')['headers']['status-code']);
+
+ $listAll = $this->listMockPhones();
+ $this->assertSame(200, $listAll['headers']['status-code']);
+ $totalAll = \count($listAll['body']['mockNumbers']);
+
+ $listOffset = $this->listMockPhones([
+ Query::offset(1)->toString(),
+ ]);
+
+ $this->assertSame(200, $listOffset['headers']['status-code']);
+ $this->assertCount($totalAll - 1, $listOffset['body']['mockNumbers']);
+ $this->assertSame($listAll['body']['total'], $listOffset['body']['total']);
+
+ // Cleanup
+ $this->deleteMockPhone($number1);
+ $this->deleteMockPhone($number2);
+ }
+
+ public function testListMockPhonesWithoutAuthentication(): void
+ {
+ $response = $this->listMockPhones(authenticated: false);
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ // Delete mock phone tests
+
+ public function testDeleteMockPhone(): void
+ {
+ $number = $this->uniquePhoneNumber();
+ $create = $this->createMockPhone($number, '123456');
+ $this->assertSame(201, $create['headers']['status-code']);
+
+ // Confirm it exists
+ $this->assertSame(200, $this->getMockPhone($number)['headers']['status-code']);
+
+ $response = $this->deleteMockPhone($number);
+ $this->assertSame(204, $response['headers']['status-code']);
+ $this->assertEmpty($response['body']);
+
+ // Confirm it is gone
+ $get = $this->getMockPhone($number);
+ $this->assertSame(404, $get['headers']['status-code']);
+ $this->assertSame('mock_number_not_found', $get['body']['type']);
+ }
+
+ public function testDeleteMockPhoneNotFound(): void
+ {
+ $response = $this->deleteMockPhone($this->uniquePhoneNumber());
+
+ $this->assertSame(404, $response['headers']['status-code']);
+ $this->assertSame('mock_number_not_found', $response['body']['type']);
+ }
+
+ public function testDeleteMockPhoneDoubleDelete(): void
+ {
+ $number = $this->uniquePhoneNumber();
+ $this->assertSame(201, $this->createMockPhone($number, '123456')['headers']['status-code']);
+
+ $first = $this->deleteMockPhone($number);
+ $this->assertSame(204, $first['headers']['status-code']);
+
+ $second = $this->deleteMockPhone($number);
+ $this->assertSame(404, $second['headers']['status-code']);
+ $this->assertSame('mock_number_not_found', $second['body']['type']);
+ }
+
+ public function testDeleteMockPhoneRemovedFromList(): void
+ {
+ $number = $this->uniquePhoneNumber();
+ $create = $this->createMockPhone($number, '123456');
+ $this->assertSame(201, $create['headers']['status-code']);
+
+ $before = $this->listMockPhones();
+ $this->assertSame(200, $before['headers']['status-code']);
+ $this->assertContains($number, \array_column($before['body']['mockNumbers'], 'number'));
+ $countBefore = $before['body']['total'];
+
+ $delete = $this->deleteMockPhone($number);
+ $this->assertSame(204, $delete['headers']['status-code']);
+
+ $after = $this->listMockPhones();
+ $this->assertSame(200, $after['headers']['status-code']);
+ $this->assertSame($countBefore - 1, $after['body']['total']);
+ $this->assertNotContains($number, \array_column($after['body']['mockNumbers'], 'number'));
+ }
+
+ public function testDeleteMockPhoneWithoutAuthentication(): void
+ {
+ $number = $this->uniquePhoneNumber();
+ $create = $this->createMockPhone($number, '123456');
+ $this->assertSame(201, $create['headers']['status-code']);
+
+ $response = $this->deleteMockPhone($number, authenticated: false);
+ $this->assertSame(401, $response['headers']['status-code']);
+
+ // Still present
+ $this->assertSame(200, $this->getMockPhone($number)['headers']['status-code']);
+
+ // Cleanup
+ $this->deleteMockPhone($number);
+ }
+
+ // Helpers
+
+ protected function createMockPhone(?string $number, ?string $otp, bool $authenticated = true): mixed
+ {
+ $headers = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ];
+
+ if ($authenticated) {
+ $headers = \array_merge($headers, $this->getHeaders());
+ }
+
+ $params = [];
+ if ($number !== null) {
+ $params['number'] = $number;
+ }
+ if ($otp !== null) {
+ $params['otp'] = $otp;
+ }
+
+ return $this->client->call(Client::METHOD_POST, '/project/mock-phones', $headers, $params);
+ }
+
+ protected function getMockPhone(string $number, bool $authenticated = true): mixed
+ {
+ $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_GET, '/project/mock-phones/' . $number, $headers);
+ }
+
+ protected function updateMockPhone(string $number, ?string $otp, bool $authenticated = true): mixed
+ {
+ $headers = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ];
+
+ if ($authenticated) {
+ $headers = \array_merge($headers, $this->getHeaders());
+ }
+
+ $params = [];
+ if ($otp !== null) {
+ $params['otp'] = $otp;
+ }
+
+ return $this->client->call(Client::METHOD_PUT, '/project/mock-phones/' . $number, $headers, $params);
+ }
+
+ protected function listMockPhones(?array $queries = null, ?bool $total = null, bool $authenticated = true): mixed
+ {
+ $headers = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ];
+
+ if ($authenticated) {
+ $headers = \array_merge($headers, $this->getHeaders());
+ }
+
+ $params = [];
+ if ($queries !== null) {
+ $params['queries'] = $queries;
+ }
+ if ($total !== null) {
+ $params['total'] = $total;
+ }
+
+ return $this->client->call(Client::METHOD_GET, '/project/mock-phones', $headers, $params);
+ }
+
+ protected function deleteMockPhone(string $number, bool $authenticated = true): mixed
+ {
+ $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_DELETE, '/project/mock-phones/' . $number, $headers);
+ }
+
+ protected function uniquePhoneNumber(): string
+ {
+ // E.164: leading '+', first digit 1-9, 10 more digits. Randomised to avoid
+ // collisions between interleaved tests that all live in the same project.
+ return '+1' . \random_int(2000000000, 9999999999);
+ }
+}
diff --git a/tests/e2e/Services/Project/MockPhonesConsoleClientTest.php b/tests/e2e/Services/Project/MockPhonesConsoleClientTest.php
new file mode 100644
index 0000000000..c4819774bf
--- /dev/null
+++ b/tests/e2e/Services/Project/MockPhonesConsoleClientTest.php
@@ -0,0 +1,14 @@
+getProject()['$id'];
+ $apiKey = $this->getProject()['apiKey'];
+
+ $serverHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $apiKey,
+ ];
+
+ $clientHeaders = [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ];
+
+ // Step 1: Configure two mock phones with distinct OTPs.
+ $phoneA = '+1' . \random_int(2000000000, 9999999999);
+ $phoneB = '+1' . \random_int(2000000000, 9999999999);
+ $otpA = '111111';
+ $otpB = '222222';
+
+ $mockA = $this->client->call(Client::METHOD_POST, '/project/mock-phones', $serverHeaders, [
+ 'number' => $phoneA,
+ 'otp' => $otpA,
+ ]);
+ $this->assertSame(201, $mockA['headers']['status-code']);
+ $this->assertSame($phoneA, $mockA['body']['number']);
+ $this->assertSame($otpA, $mockA['body']['otp']);
+
+ $mockB = $this->client->call(Client::METHOD_POST, '/project/mock-phones', $serverHeaders, [
+ 'number' => $phoneB,
+ 'otp' => $otpB,
+ ]);
+ $this->assertSame(201, $mockB['headers']['status-code']);
+ $this->assertSame($phoneB, $mockB['body']['number']);
+ $this->assertSame($otpB, $mockB['body']['otp']);
+
+ // Step 2 (Phone A): sign-in flow that also creates the user (userId = unique()).
+ $tokenA = $this->client->call(Client::METHOD_POST, '/account/tokens/phone', $clientHeaders, [
+ 'userId' => ID::unique(),
+ 'phone' => $phoneA,
+ ]);
+ $this->assertSame(201, $tokenA['headers']['status-code']);
+ $userIdA = $tokenA['body']['userId'];
+ $this->assertNotEmpty($userIdA);
+
+ // Arbitrary wrong OTP must be rejected.
+ $wrongA = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [
+ 'userId' => $userIdA,
+ 'secret' => '999999',
+ ]);
+ $this->assertSame(401, $wrongA['headers']['status-code']);
+
+ // Phone B's OTP must not unlock Phone A's user — proves OTPs are scoped to the mock record.
+ $crossA = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [
+ 'userId' => $userIdA,
+ 'secret' => $otpB,
+ ]);
+ $this->assertSame(401, $crossA['headers']['status-code']);
+
+ // Correct mock OTP establishes the session.
+ $sessionA = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [
+ 'userId' => $userIdA,
+ 'secret' => $otpA,
+ ]);
+ $this->assertSame(201, $sessionA['headers']['status-code']);
+ $this->assertNotEmpty($sessionA['cookies']['a_session_' . $projectId] ?? null);
+ $cookieA = $sessionA['cookies']['a_session_' . $projectId];
+
+ // GET /account using the session confirms identity.
+ $accountA = $this->client->call(Client::METHOD_GET, '/account', \array_merge($clientHeaders, [
+ 'cookie' => 'a_session_' . $projectId . '=' . $cookieA,
+ ]));
+ $this->assertSame(200, $accountA['headers']['status-code']);
+ $this->assertSame($userIdA, $accountA['body']['$id']);
+ $this->assertSame($phoneA, $accountA['body']['phone']);
+ $this->assertTrue($accountA['body']['phoneVerification']);
+
+ // Step 3 (Phone B): pre-create the user server-side, then sign in with the mock OTP.
+ $precreated = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [
+ 'userId' => ID::unique(),
+ 'phone' => $phoneB,
+ ]);
+ $this->assertSame(201, $precreated['headers']['status-code']);
+ $userIdB = $precreated['body']['$id'];
+ $this->assertSame($phoneB, $precreated['body']['phone']);
+
+ $tokenB = $this->client->call(Client::METHOD_POST, '/account/tokens/phone', $clientHeaders, [
+ 'userId' => $userIdB,
+ 'phone' => $phoneB,
+ ]);
+ $this->assertSame(201, $tokenB['headers']['status-code']);
+ $this->assertSame($userIdB, $tokenB['body']['userId']);
+
+ // Arbitrary wrong OTP must be rejected.
+ $wrongB = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [
+ 'userId' => $userIdB,
+ 'secret' => '000000',
+ ]);
+ $this->assertSame(401, $wrongB['headers']['status-code']);
+
+ // Phone A's OTP must not unlock Phone B's user.
+ $crossB = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [
+ 'userId' => $userIdB,
+ 'secret' => $otpA,
+ ]);
+ $this->assertSame(401, $crossB['headers']['status-code']);
+
+ // Correct mock OTP establishes the session.
+ $sessionB = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [
+ 'userId' => $userIdB,
+ 'secret' => $otpB,
+ ]);
+ $this->assertSame(201, $sessionB['headers']['status-code']);
+ $this->assertNotEmpty($sessionB['cookies']['a_session_' . $projectId] ?? null);
+ $cookieB = $sessionB['cookies']['a_session_' . $projectId];
+
+ // GET /account using the session confirms identity.
+ $accountB = $this->client->call(Client::METHOD_GET, '/account', \array_merge($clientHeaders, [
+ 'cookie' => 'a_session_' . $projectId . '=' . $cookieB,
+ ]));
+ $this->assertSame(200, $accountB['headers']['status-code']);
+ $this->assertSame($userIdB, $accountB['body']['$id']);
+ $this->assertSame($phoneB, $accountB['body']['phone']);
+ $this->assertTrue($accountB['body']['phoneVerification']);
+
+ // Cross-check: the two flows produced distinct users.
+ $this->assertNotSame($userIdA, $userIdB);
+ $this->assertNotSame($accountA['body']['phone'], $accountB['body']['phone']);
+
+ // Cleanup mock phone config to avoid polluting project state for later tests.
+ $this->client->call(Client::METHOD_DELETE, '/project/mock-phones/' . \urlencode($phoneA), $serverHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/project/mock-phones/' . \urlencode($phoneB), $serverHeaders);
+ }
+}
diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php
new file mode 100644
index 0000000000..5959a584ea
--- /dev/null
+++ b/tests/e2e/Services/Project/OAuth2Base.php
@@ -0,0 +1,2926 @@
+updateOAuth2('amazon', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(
+ 200,
+ $response['headers']['status-code'],
+ 'OAuth2 reset failed — downstream tests will be unreliable. Body: ' . \json_encode($response['body'] ?? null),
+ );
+ }
+
+ // =========================================================================
+ // List OAuth2 providers
+ // =========================================================================
+
+ public function testListOAuth2Providers(): void
+ {
+ $response = $this->listOAuth2Providers();
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertArrayHasKey('total', $response['body']);
+ $this->assertArrayHasKey('providers', $response['body']);
+ $this->assertGreaterThan(0, $response['body']['total']);
+ $this->assertSame($response['body']['total'], \count($response['body']['providers']));
+ }
+
+ public function testListOAuth2ProvidersIncludesKnownProviders(): void
+ {
+ $response = $this->listOAuth2Providers();
+
+ $this->assertSame(200, $response['headers']['status-code']);
+
+ $ids = \array_column($response['body']['providers'], '$id');
+
+ // Spot-check a representative cross-section of providers across all
+ // provider shapes (plain, multi-field, sandboxed, custom param names).
+ $expected = [
+ 'github',
+ 'amazon',
+ 'apple',
+ 'auth0',
+ 'authentik',
+ 'fusionauth',
+ 'gitlab',
+ 'keycloak',
+ 'oidc',
+ 'okta',
+ 'microsoft',
+ 'dropbox',
+ 'paypalSandbox',
+ 'kick',
+ ];
+
+ foreach ($expected as $providerId) {
+ $this->assertContains($providerId, $ids, "Missing provider {$providerId} in listOAuth2Providers response");
+ }
+ }
+
+ /**
+ * Pin the exact set of registered providers — adding or removing a
+ * provider must be a deliberate change to this assertion. Catches
+ * registration drift (e.g. forgetting to wire a new provider into
+ * `Base::getProviderActions()`).
+ */
+ public function testListOAuth2ProvidersExposesEntireRegistry(): void
+ {
+ $response = $this->listOAuth2Providers();
+ $this->assertSame(200, $response['headers']['status-code']);
+
+ $ids = \array_column($response['body']['providers'], '$id');
+ \sort($ids);
+
+ $expected = [
+ 'amazon', 'apple', 'auth0', 'authentik', 'autodesk', 'bitbucket',
+ 'bitly', 'box', 'dailymotion', 'discord', 'disqus', 'dropbox',
+ 'etsy', 'facebook', 'figma', 'fusionauth', 'github', 'gitlab',
+ 'google', 'keycloak', 'kick', 'linkedin', 'microsoft', 'notion',
+ 'oidc', 'okta', 'paypal', 'paypalSandbox', 'podio', 'salesforce',
+ 'slack', 'spotify', 'stripe', 'tradeshift', 'tradeshiftBox',
+ 'twitch', 'wordpress', 'x', 'yahoo', 'yandex', 'zoho', 'zoom',
+ ];
+ \sort($expected);
+
+ $this->assertSame($expected, $ids, 'Registry drift — listed providers do not match the expected set.');
+ }
+
+ public function testListOAuth2ProvidersResponseShape(): void
+ {
+ $response = $this->listOAuth2Providers();
+
+ $this->assertSame(200, $response['headers']['status-code']);
+
+ foreach ($response['body']['providers'] as $provider) {
+ $this->assertArrayHasKey('$id', $provider);
+ $this->assertArrayHasKey('enabled', $provider);
+ $this->assertIsString($provider['$id']);
+ $this->assertIsBool($provider['enabled']);
+ }
+ }
+
+ public function testListOAuth2ProvidersClientSecretsNotExposed(): void
+ {
+ // Seed credentials so the list cannot trivially return empty values.
+ $this->updateOAuth2('amazon', [
+ 'clientId' => 'amzn1.application-oa2-client.testListSeed',
+ 'clientSecret' => 'super-secret-must-not-leak',
+ 'enabled' => false,
+ ]);
+
+ $response = $this->listOAuth2Providers();
+
+ $this->assertSame(200, $response['headers']['status-code']);
+
+ $matched = false;
+ foreach ($response['body']['providers'] as $provider) {
+ if ($provider['$id'] !== 'amazon') {
+ continue;
+ }
+
+ $matched = true;
+ $this->assertSame('amzn1.application-oa2-client.testListSeed', $provider['clientId']);
+ $this->assertSame('', $provider['clientSecret']);
+ }
+
+ $this->assertTrue($matched, 'List did not include the seeded provider.');
+ }
+
+ public function testListOAuth2ProvidersWithoutAuthentication(): void
+ {
+ $response = $this->listOAuth2Providers(authenticated: false);
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ public function testListOAuth2ProvidersExcludesUnregisteredConfigEntries(): void
+ {
+ // `mock` and `mock-unverified` exist in oAuthProviders config (enabled: true)
+ // but are intentionally absent from Base::getProviderActions() — they're
+ // internal Mock OAuth2 adapters used by other test suites, not public
+ // providers. XList iterates the action registry, so they must never be
+ // included even though config marks them enabled.
+ $response = $this->listOAuth2Providers();
+
+ $this->assertSame(200, $response['headers']['status-code']);
+
+ $ids = \array_column($response['body']['providers'], '$id');
+ $this->assertNotContains('mock', $ids);
+ $this->assertNotContains('mock-unverified', $ids);
+ }
+
+ public function testListOAuth2ProvidersTotalFalse(): void
+ {
+ $response = $this->listOAuth2Providers(total: false);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(0, $response['body']['total']);
+ $this->assertGreaterThan(0, \count($response['body']['providers']));
+ }
+
+ public function testListOAuth2ProvidersWithLimit(): void
+ {
+ $response = $this->listOAuth2Providers([
+ Query::limit(1)->toString(),
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertCount(1, $response['body']['providers']);
+ $this->assertGreaterThan(1, $response['body']['total']);
+ }
+
+ public function testListOAuth2ProvidersWithOffset(): void
+ {
+ $listAll = $this->listOAuth2Providers();
+ $this->assertSame(200, $listAll['headers']['status-code']);
+
+ $listOffset = $this->listOAuth2Providers([
+ Query::offset(1)->toString(),
+ ]);
+
+ $this->assertSame(200, $listOffset['headers']['status-code']);
+ $this->assertCount(\count($listAll['body']['providers']) - 1, $listOffset['body']['providers']);
+ $this->assertSame($listAll['body']['total'], $listOffset['body']['total']);
+ }
+
+ // =========================================================================
+ // Get OAuth2 provider
+ // =========================================================================
+
+ public function testGetOAuth2Provider(): void
+ {
+ $response = $this->getOAuth2Provider('github');
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('github', $response['body']['$id']);
+ $this->assertArrayHasKey('enabled', $response['body']);
+ $this->assertArrayHasKey('clientId', $response['body']);
+ $this->assertArrayHasKey('clientSecret', $response['body']);
+ $this->assertSame('', $response['body']['clientSecret']);
+ }
+
+ public function testGetOAuth2ProviderWithAlias(): void
+ {
+ // The action declares the canonical param name as `providerId` and
+ // registers `provider` as an alias so that older SDK versions that
+ // send the provider in the query string continue to work.
+ $headers = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ];
+ $headers = \array_merge($headers, $this->getHeaders());
+
+ // Call with `provider` in query string (legacy behaviour)
+ $response = $this->client->call(
+ Client::METHOD_GET,
+ '/project/oauth2/github?provider=github',
+ $headers,
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('github', $response['body']['$id']);
+ }
+
+ public function testGetOAuth2ProviderClientSecretWriteOnly(): void
+ {
+ $this->updateOAuth2('amazon', [
+ 'clientId' => 'amzn1.application-oa2-client.getSecretCheck',
+ 'clientSecret' => 'must-never-be-returned',
+ 'enabled' => false,
+ ]);
+
+ $response = $this->getOAuth2Provider('amazon');
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('amzn1.application-oa2-client.getSecretCheck', $response['body']['clientId']);
+ $this->assertSame('', $response['body']['clientSecret']);
+ }
+
+ public function testGetOAuth2ProviderMatchesListEntry(): void
+ {
+ $list = $this->listOAuth2Providers();
+ $this->assertSame(200, $list['headers']['status-code']);
+
+ // Drive the loop directly off the LIST result so any provider added
+ // to the registry is automatically checked for List/Get parity.
+ foreach ($list['body']['providers'] as $listEntry) {
+ $providerId = $listEntry['$id'];
+ $get = $this->getOAuth2Provider($providerId);
+
+ $this->assertSame(200, $get['headers']['status-code'], "GET failed for {$providerId}");
+ $this->assertSame($listEntry, $get['body'], "List/Get drift on {$providerId}");
+ }
+ }
+
+ public function testGetOAuth2ProviderUnsupported(): void
+ {
+ // The `providerId` param is validated by a WhiteList of registered
+ // OAuth2 provider keys, so an unknown value is rejected at validation
+ // time — before the action runs — and surfaces as a generic argument
+ // error rather than `project_provider_unsupported`.
+ $response = $this->getOAuth2Provider('not-a-real-provider');
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+ }
+
+ public function testGetOAuth2ProviderRegisteredInConfigButNoUpdateClass(): void
+ {
+ // `mock` is present in oAuthProviders config (enabled: true) but is
+ // NOT registered in Base::getProviderActions(). It passes the
+ // WhiteList validator (which only checks config membership) and
+ // reaches the action body, where the action-registry check throws
+ // `project_provider_unsupported`.
+ $response = $this->getOAuth2Provider('mock');
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('project_provider_unsupported', $response['body']['type']);
+ }
+
+ public function testGetOAuth2ProviderWithoutAuthentication(): void
+ {
+ $response = $this->getOAuth2Provider('github', authenticated: false);
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ // =========================================================================
+ // Update plain provider (Amazon — clientId + clientSecret, no extra fields)
+ // =========================================================================
+
+ public function testUpdateOAuth2Plain(): void
+ {
+ $response = $this->updateOAuth2('amazon', [
+ 'clientId' => 'amzn1.application-oa2-client.test01',
+ 'clientSecret' => 'test-secret-01',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('amazon', $response['body']['$id']);
+ $this->assertSame('amzn1.application-oa2-client.test01', $response['body']['clientId']);
+ $this->assertSame(false, $response['body']['enabled']);
+ }
+
+ public function testUpdateOAuth2PlainEnable(): void
+ {
+ // Amazon has no verifyCredentials() hook, so enabling with arbitrary
+ // credentials succeeds without making a real network call.
+ $response = $this->updateOAuth2('amazon', [
+ 'clientId' => 'amzn1.application-oa2-client.test02',
+ 'clientSecret' => 'test-secret-02',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(true, $response['body']['enabled']);
+ }
+
+ public function testUpdateOAuth2PlainDisable(): void
+ {
+ $this->updateOAuth2('amazon', [
+ 'clientId' => 'amzn1.application-oa2-client.test03',
+ 'clientSecret' => 'test-secret-03',
+ 'enabled' => true,
+ ]);
+
+ $response = $this->updateOAuth2('amazon', [
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(false, $response['body']['enabled']);
+ // Credentials persist across an enabled toggle.
+ $this->assertSame('amzn1.application-oa2-client.test03', $response['body']['clientId']);
+ }
+
+ public function testUpdateOAuth2PlainPartial(): void
+ {
+ // Seed both credentials.
+ $this->updateOAuth2('amazon', [
+ 'clientId' => 'seed-client-id',
+ 'clientSecret' => 'seed-secret',
+ 'enabled' => false,
+ ]);
+
+ // Patch only clientId.
+ $response = $this->updateOAuth2('amazon', [
+ 'clientId' => 'updated-client-id',
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('updated-client-id', $response['body']['clientId']);
+
+ // Read back through GET to confirm the secret is still set internally
+ // (write-only, so we cannot inspect the value, but enabling should still
+ // succeed because the secret remains non-empty).
+ $enable = $this->updateOAuth2('amazon', [
+ 'enabled' => true,
+ ]);
+ $this->assertSame(200, $enable['headers']['status-code']);
+ $this->assertSame(true, $enable['body']['enabled']);
+ }
+
+ public function testUpdateOAuth2PlainEnableRequiresCredentials(): void
+ {
+ // Start from a clean state with no credentials.
+ $this->updateOAuth2('amazon', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+
+ $response = $this->updateOAuth2('amazon', [
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+ }
+
+ public function testUpdateOAuth2PlainEnabledOmittedDoesNotThrow(): void
+ {
+ // With enabled omitted (null) and no credentials, the silent-validation
+ // branch must not surface as an error.
+ $this->updateOAuth2('amazon', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+
+ $response = $this->updateOAuth2('amazon', [
+ 'clientId' => 'partial-only',
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(false, $response['body']['enabled']);
+ $this->assertSame('partial-only', $response['body']['clientId']);
+ }
+
+ public function testUpdateOAuth2PlainResponseModel(): void
+ {
+ $response = $this->updateOAuth2('amazon', [
+ 'clientId' => 'amzn1.application-oa2-client.modelCheck',
+ 'clientSecret' => 'model-check-secret',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertArrayHasKey('$id', $response['body']);
+ $this->assertArrayHasKey('enabled', $response['body']);
+ $this->assertArrayHasKey('clientId', $response['body']);
+ $this->assertArrayHasKey('clientSecret', $response['body']);
+ }
+
+ public function testUpdateOAuth2WithoutAuthentication(): void
+ {
+ $response = $this->updateOAuth2('amazon', [
+ 'clientId' => 'no-auth',
+ 'clientSecret' => 'no-auth',
+ 'enabled' => false,
+ ], authenticated: false);
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ public function testUpdateOAuth2UnknownProvider(): void
+ {
+ // Each Update endpoint is registered at a fixed `/oauth2/{providerId}`
+ // path, so an unknown provider does not match any route → 404.
+ $response = $this->updateOAuth2('not-a-real-provider', [
+ 'clientId' => 'whatever',
+ 'clientSecret' => 'whatever',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(404, $response['headers']['status-code']);
+ }
+
+ public function testUpdateOAuth2InvalidEnabled(): void
+ {
+ $response = $this->updateOAuth2('amazon', [
+ 'enabled' => 'not-a-boolean',
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+ }
+
+ // =========================================================================
+ // Update GitHub (verifyCredentials makes a real call to GitHub on enable)
+ //
+ // Only failure paths and the silent-on-disable branch are tested here.
+ // Happy-path enable would require real GitHub OAuth2 credentials, which
+ // CI doesn't have. Wiring, validation, and the non-enabling branch are
+ // sufficient to surface most regressions; success-path issues are caught
+ // by integration / staging environments instead.
+ // =========================================================================
+
+ public function testUpdateOAuth2GitHubInvalidCredentialsRejected(): void
+ {
+ // GitHub is the only provider with a real verifyCredentials() hook.
+ // Enabling with bogus credentials must surface a 400 from the wrapping
+ // exception, not silently succeed.
+ $response = $this->updateOAuth2('github', [
+ 'clientId' => 'fake-client-id-' . \uniqid(),
+ 'clientSecret' => 'fake-client-secret',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+
+ // Cleanup: ensure it's left disabled.
+ $this->updateOAuth2('github', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2GitHubInvalidCredentialsSilentWhenNotEnabling(): void
+ {
+ // When `enabled` is omitted, verifyCredentials() failure is swallowed.
+ // The provider remains disabled but the request succeeds.
+ $response = $this->updateOAuth2('github', [
+ 'clientId' => 'still-fake-' . \uniqid(),
+ 'clientSecret' => 'still-fake-secret',
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(false, $response['body']['enabled']);
+
+ // Cleanup
+ $this->updateOAuth2('github', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ // =========================================================================
+ // Update Apple (serviceId + keyId + teamId + p8File)
+ // =========================================================================
+
+ public function testUpdateOAuth2Apple(): void
+ {
+ $response = $this->updateOAuth2('apple', [
+ 'serviceId' => 'ip.appwrite.app.web',
+ 'keyId' => 'P4000000N8',
+ 'teamId' => 'D4000000R6',
+ 'p8File' => '-----BEGIN PRIVATE KEY-----TEST-----END PRIVATE KEY-----',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('apple', $response['body']['$id']);
+ $this->assertSame('ip.appwrite.app.web', $response['body']['serviceId']);
+ $this->assertSame('P4000000N8', $response['body']['keyId']);
+ $this->assertSame('D4000000R6', $response['body']['teamId']);
+ $this->assertSame('', $response['body']['p8File']);
+ $this->assertSame(false, $response['body']['enabled']);
+
+ // Cleanup
+ $this->updateOAuth2('apple', [
+ 'serviceId' => '',
+ 'keyId' => '',
+ 'teamId' => '',
+ 'p8File' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2ApplePartial(): void
+ {
+ // Seed all four fields.
+ $this->updateOAuth2('apple', [
+ 'serviceId' => 'ip.appwrite.app.seed',
+ 'keyId' => 'KEYSEED01',
+ 'teamId' => 'TEAMSEED01',
+ 'p8File' => '-----BEGIN PRIVATE KEY-----SEED-----END PRIVATE KEY-----',
+ 'enabled' => false,
+ ]);
+
+ // Patch only `keyId` — others must be preserved.
+ $response = $this->updateOAuth2('apple', [
+ 'keyId' => 'KEYUPDATED',
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('ip.appwrite.app.seed', $response['body']['serviceId']);
+ $this->assertSame('KEYUPDATED', $response['body']['keyId']);
+ $this->assertSame('TEAMSEED01', $response['body']['teamId']);
+ $this->assertSame('', $response['body']['p8File']);
+
+ // Cleanup
+ $this->updateOAuth2('apple', [
+ 'serviceId' => '',
+ 'keyId' => '',
+ 'teamId' => '',
+ 'p8File' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2ApplePartialPreservesEachField(): void
+ {
+ // Seed all four fields, then patch each one individually and confirm
+ // the others survive across the chain. testUpdateOAuth2ApplePartial
+ // only covers `keyId`; this exercises serviceId/teamId/p8File too.
+ $this->updateOAuth2('apple', [
+ 'serviceId' => 'ip.appwrite.app.merge',
+ 'keyId' => 'KEYMERGE01',
+ 'teamId' => 'TEAMMERGE',
+ 'p8File' => '-----BEGIN PRIVATE KEY-----MERGE-----END PRIVATE KEY-----',
+ 'enabled' => false,
+ ]);
+
+ // Patch only `teamId`.
+ $teamOnly = $this->updateOAuth2('apple', [
+ 'teamId' => 'TEAMROTATED',
+ ]);
+ $this->assertSame(200, $teamOnly['headers']['status-code']);
+ $this->assertSame('TEAMROTATED', $teamOnly['body']['teamId']);
+ $this->assertSame('KEYMERGE01', $teamOnly['body']['keyId']);
+ $this->assertSame('', $teamOnly['body']['p8File']);
+ $this->assertSame('ip.appwrite.app.merge', $teamOnly['body']['serviceId']);
+
+ // Patch only `serviceId` — keyId/teamId/p8File live in the JSON blob
+ // and must survive a top-level (non-blob) field update.
+ $serviceOnly = $this->updateOAuth2('apple', [
+ 'serviceId' => 'ip.appwrite.app.rotated',
+ ]);
+ $this->assertSame(200, $serviceOnly['headers']['status-code']);
+ $this->assertSame('ip.appwrite.app.rotated', $serviceOnly['body']['serviceId']);
+
+ // Patch only `p8File`. keyId/teamId/serviceId must still be set
+ // internally — confirm by enabling. Apple has no verifyCredentials()
+ // hook, so persistCredentials only checks for non-empty serviceId and
+ // non-empty stored secret blob.
+ $p8Only = $this->updateOAuth2('apple', [
+ 'p8File' => '-----BEGIN PRIVATE KEY-----ROTATED-----END PRIVATE KEY-----',
+ ]);
+ $this->assertSame(200, $p8Only['headers']['status-code']);
+
+ $enable = $this->updateOAuth2('apple', ['enabled' => true]);
+ $this->assertSame(200, $enable['headers']['status-code']);
+ $this->assertTrue($enable['body']['enabled']);
+
+ // Cleanup
+ $this->updateOAuth2('apple', [
+ 'serviceId' => '',
+ 'keyId' => '',
+ 'teamId' => '',
+ 'p8File' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2AppleClearAllFieldsBlocksEnable(): void
+ {
+ // Seed all four Apple fields.
+ $this->updateOAuth2('apple', [
+ 'serviceId' => 'ip.appwrite.app.clearAll',
+ 'keyId' => 'KEYCLEARALL',
+ 'teamId' => 'TEAMCLEARALL',
+ 'p8File' => '-----BEGIN PRIVATE KEY-----CLEARALL-----END PRIVATE KEY-----',
+ 'enabled' => false,
+ ]);
+
+ // Clear all credentials with empty strings. With `enabled` omitted, the
+ // silent-validation branch swallows the empty-credentials throw, so the
+ // call still succeeds — see testUpdateOAuth2PlainEnabledOmittedDoesNotThrow.
+ $clear = $this->updateOAuth2('apple', [
+ 'serviceId' => '',
+ 'keyId' => '',
+ 'teamId' => '',
+ 'p8File' => '',
+ ]);
+ $this->assertSame(200, $clear['headers']['status-code']);
+ $this->assertSame('', $clear['body']['serviceId']);
+
+ // A subsequent `enabled => true` must now 400. Empty serviceId trips
+ // persistCredentials' empty(appId) guard before any provider hook runs,
+ // proving that the clear actually took effect on stored state.
+ $enable = $this->updateOAuth2('apple', [
+ 'enabled' => true,
+ ]);
+ $this->assertSame(400, $enable['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $enable['body']['type']);
+
+ // Cleanup (already cleared; included for reset symmetry).
+ $this->updateOAuth2('apple', [
+ 'serviceId' => '',
+ 'keyId' => '',
+ 'teamId' => '',
+ 'p8File' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2AppleResponseModel(): void
+ {
+ $response = $this->updateOAuth2('apple', [
+ 'serviceId' => 'ip.appwrite.app.shape',
+ 'keyId' => 'SHAPEKEY01',
+ 'teamId' => 'SHAPETEAM',
+ 'p8File' => '-----BEGIN PRIVATE KEY-----SHAPE-----END PRIVATE KEY-----',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertArrayHasKey('$id', $response['body']);
+ $this->assertArrayHasKey('enabled', $response['body']);
+ $this->assertArrayHasKey('serviceId', $response['body']);
+ $this->assertArrayHasKey('keyId', $response['body']);
+ $this->assertArrayHasKey('teamId', $response['body']);
+ $this->assertArrayHasKey('p8File', $response['body']);
+ // Apple has no clientId/clientSecret in the response model.
+ $this->assertArrayNotHasKey('clientId', $response['body']);
+ $this->assertArrayNotHasKey('clientSecret', $response['body']);
+
+ // Cleanup
+ $this->updateOAuth2('apple', [
+ 'serviceId' => '',
+ 'keyId' => '',
+ 'teamId' => '',
+ 'p8File' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testGetOAuth2AppleSecretsWriteOnly(): void
+ {
+ $this->updateOAuth2('apple', [
+ 'serviceId' => 'ip.appwrite.app.read',
+ 'keyId' => 'KEYREAD',
+ 'teamId' => 'TEAMREAD',
+ 'p8File' => '-----BEGIN PRIVATE KEY-----READ-----END PRIVATE KEY-----',
+ 'enabled' => false,
+ ]);
+
+ $response = $this->getOAuth2Provider('apple');
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('ip.appwrite.app.read', $response['body']['serviceId']);
+ $this->assertSame('KEYREAD', $response['body']['keyId']);
+ $this->assertSame('TEAMREAD', $response['body']['teamId']);
+ $this->assertSame('', $response['body']['p8File']);
+
+ // Cleanup
+ $this->updateOAuth2('apple', [
+ 'serviceId' => '',
+ 'keyId' => '',
+ 'teamId' => '',
+ 'p8File' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2AppleEnableAndReadBack(): void
+ {
+ // Apple has no verifyCredentials() hook, so enabling with arbitrary
+ // (well-formed) values succeeds without any real Apple network call.
+ $update = $this->updateOAuth2('apple', [
+ 'serviceId' => 'ip.appwrite.app.enable',
+ 'keyId' => 'ENABLEKEY',
+ 'teamId' => 'ENABLETEAM',
+ 'p8File' => '-----BEGIN PRIVATE KEY-----ENABLE-----END PRIVATE KEY-----',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(200, $update['headers']['status-code']);
+ $this->assertTrue($update['body']['enabled']);
+
+ // GET must hide p8File while keeping the non-secret fields.
+ $get = $this->getOAuth2Provider('apple');
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertTrue($get['body']['enabled']);
+ $this->assertSame('ip.appwrite.app.enable', $get['body']['serviceId']);
+ $this->assertSame('ENABLEKEY', $get['body']['keyId']);
+ $this->assertSame('ENABLETEAM', $get['body']['teamId']);
+ $this->assertSame('', $get['body']['p8File']);
+
+ // Cleanup
+ $this->updateOAuth2('apple', [
+ 'serviceId' => '',
+ 'keyId' => '',
+ 'teamId' => '',
+ 'p8File' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ // =========================================================================
+ // Update Auth0 (clientId + clientSecret + optional endpoint)
+ // =========================================================================
+
+ public function testUpdateOAuth2Auth0(): void
+ {
+ $response = $this->updateOAuth2('auth0', [
+ 'clientId' => 'OaOkIA000000000000000000005KLSYq',
+ 'clientSecret' => 'auth0-test-secret',
+ 'endpoint' => 'example.us.auth0.com',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('auth0', $response['body']['$id']);
+ $this->assertSame('OaOkIA000000000000000000005KLSYq', $response['body']['clientId']);
+ $this->assertSame('example.us.auth0.com', $response['body']['endpoint']);
+
+ // Cleanup
+ $this->updateOAuth2('auth0', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2Auth0PartialEndpoint(): void
+ {
+ // Seed clientSecret + endpoint.
+ $this->updateOAuth2('auth0', [
+ 'clientId' => 'auth0-seed-client',
+ 'clientSecret' => 'auth0-seed-secret',
+ 'endpoint' => 'seed.us.auth0.com',
+ 'enabled' => false,
+ ]);
+
+ // Update only endpoint.
+ $response = $this->updateOAuth2('auth0', [
+ 'endpoint' => 'updated.us.auth0.com',
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('updated.us.auth0.com', $response['body']['endpoint']);
+ // clientId is unchanged on top-level provider state.
+ $this->assertSame('auth0-seed-client', $response['body']['clientId']);
+
+ // Cleanup
+ $this->updateOAuth2('auth0', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2Auth0PartialPreservesEachField(): void
+ {
+ // testUpdateOAuth2Auth0PartialEndpoint only patches `endpoint`. Cover
+ // patching `clientSecret` alone (must not wipe endpoint) and `clientId`
+ // alone (must not wipe the JSON-blob fields).
+ $this->updateOAuth2('auth0', [
+ 'clientId' => 'auth0-merge-client',
+ 'clientSecret' => 'auth0-merge-secret',
+ 'endpoint' => 'merge.us.auth0.com',
+ 'enabled' => false,
+ ]);
+
+ // Patch only clientSecret — clientId and endpoint must survive.
+ $secretOnly = $this->updateOAuth2('auth0', [
+ 'clientSecret' => 'auth0-rotated-secret',
+ ]);
+ $this->assertSame(200, $secretOnly['headers']['status-code']);
+ $this->assertSame('auth0-merge-client', $secretOnly['body']['clientId']);
+ $this->assertSame('merge.us.auth0.com', $secretOnly['body']['endpoint']);
+
+ // Patch only clientId — endpoint must survive.
+ $idOnly = $this->updateOAuth2('auth0', [
+ 'clientId' => 'auth0-rotated-client',
+ ]);
+ $this->assertSame(200, $idOnly['headers']['status-code']);
+ $this->assertSame('auth0-rotated-client', $idOnly['body']['clientId']);
+ $this->assertSame('merge.us.auth0.com', $idOnly['body']['endpoint']);
+
+ // Confirm the rotated clientSecret survived the chain by enabling.
+ // Auth0 has no verifyCredentials() hook; non-empty secret is enough.
+ $enable = $this->updateOAuth2('auth0', ['enabled' => true]);
+ $this->assertSame(200, $enable['headers']['status-code']);
+ $this->assertTrue($enable['body']['enabled']);
+
+ // Cleanup
+ $this->updateOAuth2('auth0', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2Auth0EndpointAcceptsEmpty(): void
+ {
+ // Auth0's `endpoint` validator is `Nullable(Text(256, 0))`. Passing
+ // `''` must clear the stored value rather than leave it untouched
+ // (would happen if the merge fell back to existing on empty-string).
+ $this->updateOAuth2('auth0', [
+ 'clientId' => 'auth0-clear-client',
+ 'clientSecret' => 'auth0-clear-secret',
+ 'endpoint' => 'before.us.auth0.com',
+ 'enabled' => false,
+ ]);
+
+ $response = $this->updateOAuth2('auth0', [
+ 'endpoint' => '',
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('', $response['body']['endpoint']);
+ $this->assertSame('auth0-clear-client', $response['body']['clientId']);
+
+ // Cleanup
+ $this->updateOAuth2('auth0', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2Auth0EnableAndReadBack(): void
+ {
+ $update = $this->updateOAuth2('auth0', [
+ 'clientId' => 'auth0-enable-client',
+ 'clientSecret' => 'auth0-enable-secret',
+ 'endpoint' => 'enable.us.auth0.com',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(200, $update['headers']['status-code']);
+ $this->assertTrue($update['body']['enabled']);
+
+ // GET must hide clientSecret while keeping clientId and endpoint.
+ $get = $this->getOAuth2Provider('auth0');
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertTrue($get['body']['enabled']);
+ $this->assertSame('auth0-enable-client', $get['body']['clientId']);
+ $this->assertSame('enable.us.auth0.com', $get['body']['endpoint']);
+ $this->assertSame('', $get['body']['clientSecret']);
+
+ // Cleanup
+ $this->updateOAuth2('auth0', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ // =========================================================================
+ // Update Authentik (clientId + clientSecret + optional endpoint)
+ // =========================================================================
+
+ public function testUpdateOAuth2AuthentikAllowsOmittedEndpointWhenDisabled(): void
+ {
+ $response = $this->updateOAuth2('authentik', [
+ 'clientId' => 'whatever',
+ 'clientSecret' => 'whatever',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('authentik', $response['body']['$id']);
+
+ // Cleanup
+ $this->updateOAuth2('authentik', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2AuthentikEmptyEndpointRejectedWhenEnabling(): void
+ {
+ $response = $this->updateOAuth2('authentik', [
+ 'clientId' => 'whatever',
+ 'clientSecret' => 'whatever',
+ 'endpoint' => '',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+ }
+
+ public function testUpdateOAuth2Authentik(): void
+ {
+ $response = $this->updateOAuth2('authentik', [
+ 'clientId' => 'dTKOPa0000000000000000000000000000e7G8hv',
+ 'clientSecret' => 'authentik-secret',
+ 'endpoint' => 'example.authentik.com',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('authentik', $response['body']['$id']);
+ $this->assertSame('dTKOPa0000000000000000000000000000e7G8hv', $response['body']['clientId']);
+ $this->assertSame('example.authentik.com', $response['body']['endpoint']);
+
+ // Cleanup
+ $this->updateOAuth2('authentik', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2AuthentikPartialPreservesSecret(): void
+ {
+ // The `clientSecret` and `endpoint` live in the JSON blob and must
+ // survive when omitted on a subsequent call that only changes clientId.
+ $this->updateOAuth2('authentik', [
+ 'clientId' => 'authentik-merge-client',
+ 'clientSecret' => 'authentik-merge-secret',
+ 'endpoint' => 'merge.authentik.com',
+ 'enabled' => false,
+ ]);
+
+ $response = $this->updateOAuth2('authentik', [
+ 'clientId' => 'authentik-rotated-client',
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('authentik-rotated-client', $response['body']['clientId']);
+ $this->assertSame('merge.authentik.com', $response['body']['endpoint']);
+
+ // Confirm clientSecret survived the omitted-field merge by enabling
+ // without re-sending endpoint.
+ $enable = $this->updateOAuth2('authentik', [
+ 'enabled' => true,
+ ]);
+ $this->assertSame(200, $enable['headers']['status-code']);
+ $this->assertTrue($enable['body']['enabled']);
+
+ // Cleanup
+ $this->updateOAuth2('authentik', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2AuthentikEnableAndReadBack(): void
+ {
+ $update = $this->updateOAuth2('authentik', [
+ 'clientId' => 'authentik-enable-client',
+ 'clientSecret' => 'authentik-enable-secret',
+ 'endpoint' => 'enable.authentik.com',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(200, $update['headers']['status-code']);
+ $this->assertTrue($update['body']['enabled']);
+
+ // GET must hide clientSecret while keeping clientId and endpoint.
+ $get = $this->getOAuth2Provider('authentik');
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertTrue($get['body']['enabled']);
+ $this->assertSame('authentik-enable-client', $get['body']['clientId']);
+ $this->assertSame('enable.authentik.com', $get['body']['endpoint']);
+ $this->assertSame('', $get['body']['clientSecret']);
+
+ // Cleanup
+ $this->updateOAuth2('authentik', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ // =========================================================================
+ // Update FusionAuth (clientId + clientSecret + optional endpoint)
+ // =========================================================================
+
+ public function testUpdateOAuth2FusionAuthAllowsOmittedEndpointWhenDisabled(): void
+ {
+ $response = $this->updateOAuth2('fusionauth', [
+ 'clientId' => 'whatever',
+ 'clientSecret' => 'whatever',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('fusionauth', $response['body']['$id']);
+
+ // Cleanup
+ $this->updateOAuth2('fusionauth', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2FusionAuthEmptyEndpointRejectedWhenEnabling(): void
+ {
+ $response = $this->updateOAuth2('fusionauth', [
+ 'clientId' => 'whatever',
+ 'clientSecret' => 'whatever',
+ 'endpoint' => '',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+ }
+
+ public function testUpdateOAuth2FusionAuth(): void
+ {
+ $response = $this->updateOAuth2('fusionauth', [
+ 'clientId' => 'b2222c00-0000-0000-0000-000000862097',
+ 'clientSecret' => 'fusionauth-secret',
+ 'endpoint' => 'example.fusionauth.io',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('fusionauth', $response['body']['$id']);
+ $this->assertSame('b2222c00-0000-0000-0000-000000862097', $response['body']['clientId']);
+ $this->assertSame('example.fusionauth.io', $response['body']['endpoint']);
+
+ // Cleanup
+ $this->updateOAuth2('fusionauth', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2FusionAuthPartialPreservesSecret(): void
+ {
+ // The `clientSecret` and `endpoint` live in the JSON blob and must
+ // survive when omitted on a subsequent call that only changes clientId.
+ $this->updateOAuth2('fusionauth', [
+ 'clientId' => 'fusionauth-merge-client',
+ 'clientSecret' => 'fusionauth-merge-secret',
+ 'endpoint' => 'merge.fusionauth.io',
+ 'enabled' => false,
+ ]);
+
+ $response = $this->updateOAuth2('fusionauth', [
+ 'clientId' => 'fusionauth-rotated-client',
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('fusionauth-rotated-client', $response['body']['clientId']);
+ $this->assertSame('merge.fusionauth.io', $response['body']['endpoint']);
+
+ // Confirm clientSecret survived the omitted-field merge by enabling
+ // without re-sending endpoint.
+ $enable = $this->updateOAuth2('fusionauth', [
+ 'enabled' => true,
+ ]);
+ $this->assertSame(200, $enable['headers']['status-code']);
+ $this->assertTrue($enable['body']['enabled']);
+
+ // Cleanup
+ $this->updateOAuth2('fusionauth', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2FusionAuthEnableAndReadBack(): void
+ {
+ $update = $this->updateOAuth2('fusionauth', [
+ 'clientId' => 'fusionauth-enable-client',
+ 'clientSecret' => 'fusionauth-enable-secret',
+ 'endpoint' => 'enable.fusionauth.io',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(200, $update['headers']['status-code']);
+ $this->assertTrue($update['body']['enabled']);
+
+ // GET must hide clientSecret while keeping clientId and endpoint.
+ $get = $this->getOAuth2Provider('fusionauth');
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertTrue($get['body']['enabled']);
+ $this->assertSame('fusionauth-enable-client', $get['body']['clientId']);
+ $this->assertSame('enable.fusionauth.io', $get['body']['endpoint']);
+ $this->assertSame('', $get['body']['clientSecret']);
+
+ // Cleanup
+ $this->updateOAuth2('fusionauth', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ // =========================================================================
+ // Update Keycloak (clientId + clientSecret + optional endpoint + optional realmName)
+ // =========================================================================
+
+ public function testUpdateOAuth2KeycloakAllowsOmittedEndpointWhenDisabled(): void
+ {
+ $response = $this->updateOAuth2('keycloak', [
+ 'clientId' => 'whatever',
+ 'clientSecret' => 'whatever',
+ 'realmName' => 'appwrite-realm',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('keycloak', $response['body']['$id']);
+
+ // Cleanup
+ $this->updateOAuth2('keycloak', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'realmName' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2KeycloakEmptyEndpointRejectedWhenEnabling(): void
+ {
+ $response = $this->updateOAuth2('keycloak', [
+ 'clientId' => 'whatever',
+ 'clientSecret' => 'whatever',
+ 'endpoint' => '',
+ 'realmName' => 'appwrite-realm',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+ }
+
+ public function testUpdateOAuth2KeycloakAllowsOmittedRealmNameWhenDisabled(): void
+ {
+ $response = $this->updateOAuth2('keycloak', [
+ 'clientId' => 'whatever',
+ 'clientSecret' => 'whatever',
+ 'endpoint' => 'keycloak.example.com',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('keycloak', $response['body']['$id']);
+
+ // Cleanup
+ $this->updateOAuth2('keycloak', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'realmName' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2KeycloakEmptyRealmNameRejectedWhenEnabling(): void
+ {
+ $response = $this->updateOAuth2('keycloak', [
+ 'clientId' => 'whatever',
+ 'clientSecret' => 'whatever',
+ 'endpoint' => 'keycloak.example.com',
+ 'realmName' => '',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+ }
+
+ public function testUpdateOAuth2Keycloak(): void
+ {
+ $response = $this->updateOAuth2('keycloak', [
+ 'clientId' => 'appwrite-o0000000st-app',
+ 'clientSecret' => 'keycloak-secret',
+ 'endpoint' => 'keycloak.example.com',
+ 'realmName' => 'appwrite-realm',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('keycloak', $response['body']['$id']);
+ $this->assertSame('appwrite-o0000000st-app', $response['body']['clientId']);
+ $this->assertSame('keycloak.example.com', $response['body']['endpoint']);
+ $this->assertSame('appwrite-realm', $response['body']['realmName']);
+
+ // Cleanup
+ $this->updateOAuth2('keycloak', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'realmName' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2KeycloakPartialPreservesSecret(): void
+ {
+ // The `clientSecret`, `endpoint`, and `realmName` live in the JSON
+ // blob and must survive when omitted on a subsequent call that only
+ // changes clientId.
+ $this->updateOAuth2('keycloak', [
+ 'clientId' => 'keycloak-merge-client',
+ 'clientSecret' => 'keycloak-merge-secret',
+ 'endpoint' => 'merge.keycloak.com',
+ 'realmName' => 'merge-realm',
+ 'enabled' => false,
+ ]);
+
+ $response = $this->updateOAuth2('keycloak', [
+ 'clientId' => 'keycloak-rotated-client',
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('keycloak-rotated-client', $response['body']['clientId']);
+ $this->assertSame('merge.keycloak.com', $response['body']['endpoint']);
+ $this->assertSame('merge-realm', $response['body']['realmName']);
+
+ // Confirm clientSecret survived the omitted-field merge by enabling
+ // without re-sending endpoint or realmName.
+ $enable = $this->updateOAuth2('keycloak', [
+ 'enabled' => true,
+ ]);
+ $this->assertSame(200, $enable['headers']['status-code']);
+ $this->assertTrue($enable['body']['enabled']);
+
+ // Cleanup
+ $this->updateOAuth2('keycloak', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'realmName' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2KeycloakEnableAndReadBack(): void
+ {
+ $update = $this->updateOAuth2('keycloak', [
+ 'clientId' => 'keycloak-enable-client',
+ 'clientSecret' => 'keycloak-enable-secret',
+ 'endpoint' => 'enable.keycloak.com',
+ 'realmName' => 'enable-realm',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(200, $update['headers']['status-code']);
+ $this->assertTrue($update['body']['enabled']);
+
+ // GET must hide clientSecret while keeping clientId, endpoint, realmName.
+ $get = $this->getOAuth2Provider('keycloak');
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertTrue($get['body']['enabled']);
+ $this->assertSame('keycloak-enable-client', $get['body']['clientId']);
+ $this->assertSame('enable.keycloak.com', $get['body']['endpoint']);
+ $this->assertSame('enable-realm', $get['body']['realmName']);
+ $this->assertSame('', $get['body']['clientSecret']);
+
+ // Cleanup
+ $this->updateOAuth2('keycloak', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'realmName' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ // =========================================================================
+ // Update Microsoft (applicationId + applicationSecret + optional tenant)
+ // =========================================================================
+
+ public function testUpdateOAuth2MicrosoftAllowsOmittedTenantWhenDisabled(): void
+ {
+ $response = $this->updateOAuth2('microsoft', [
+ 'applicationId' => 'whatever',
+ 'applicationSecret' => 'whatever',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('microsoft', $response['body']['$id']);
+
+ // Cleanup
+ $this->updateOAuth2('microsoft', [
+ 'applicationId' => '',
+ 'applicationSecret' => '',
+ 'tenant' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2MicrosoftEmptyTenantRejectedWhenEnabling(): void
+ {
+ $response = $this->updateOAuth2('microsoft', [
+ 'applicationId' => 'whatever',
+ 'applicationSecret' => 'whatever',
+ 'tenant' => '',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+ }
+
+ public function testUpdateOAuth2Microsoft(): void
+ {
+ $response = $this->updateOAuth2('microsoft', [
+ 'applicationId' => '00001111-aaaa-2222-bbbb-3333cccc4444',
+ 'applicationSecret' => 'A1bC2dE3fH4iJ5kL6mN7oP8qR9sT0u',
+ 'tenant' => 'common',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('microsoft', $response['body']['$id']);
+ $this->assertSame('00001111-aaaa-2222-bbbb-3333cccc4444', $response['body']['applicationId']);
+ $this->assertSame('common', $response['body']['tenant']);
+ // Custom param names: applicationId/applicationSecret, not clientId/clientSecret.
+ $this->assertArrayNotHasKey('clientId', $response['body']);
+ $this->assertArrayNotHasKey('clientSecret', $response['body']);
+
+ // Cleanup
+ $this->updateOAuth2('microsoft', [
+ 'applicationId' => '',
+ 'applicationSecret' => '',
+ 'tenant' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2MicrosoftPartialPreservesSecret(): void
+ {
+ // Seed full credentials.
+ $this->updateOAuth2('microsoft', [
+ 'applicationId' => 'seed-app-id',
+ 'applicationSecret' => 'seed-app-secret',
+ 'tenant' => 'common',
+ 'enabled' => false,
+ ]);
+
+ // Patch with only a new applicationId, leaving applicationSecret and
+ // tenant omitted. The stored JSON values must not be wiped.
+ $response = $this->updateOAuth2('microsoft', [
+ 'applicationId' => 'updated-app-id',
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('updated-app-id', $response['body']['applicationId']);
+ $this->assertSame('common', $response['body']['tenant']);
+
+ // Cleanup
+ $this->updateOAuth2('microsoft', [
+ 'applicationId' => '',
+ 'applicationSecret' => '',
+ 'tenant' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2MicrosoftEnableAndReadBack(): void
+ {
+ $update = $this->updateOAuth2('microsoft', [
+ 'applicationId' => 'microsoft-enable-app',
+ 'applicationSecret' => 'microsoft-enable-secret',
+ 'tenant' => 'common',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(200, $update['headers']['status-code']);
+ $this->assertTrue($update['body']['enabled']);
+
+ // GET must hide applicationSecret while keeping applicationId/tenant.
+ $get = $this->getOAuth2Provider('microsoft');
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertTrue($get['body']['enabled']);
+ $this->assertSame('microsoft-enable-app', $get['body']['applicationId']);
+ $this->assertSame('common', $get['body']['tenant']);
+ $this->assertSame('', $get['body']['applicationSecret']);
+
+ // Cleanup
+ $this->updateOAuth2('microsoft', [
+ 'applicationId' => '',
+ 'applicationSecret' => '',
+ 'tenant' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ // =========================================================================
+ // Update Gitlab (applicationId + secret + optional endpoint, custom names)
+ // =========================================================================
+
+ public function testUpdateOAuth2Gitlab(): void
+ {
+ $response = $this->updateOAuth2('gitlab', [
+ 'applicationId' => 'd41ffe0000000000000000000000000000000000000000000000000000d5e252',
+ 'secret' => 'gloas-838cfa00',
+ 'endpoint' => 'https://gitlab.example.com',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('gitlab', $response['body']['$id']);
+ $this->assertSame('d41ffe0000000000000000000000000000000000000000000000000000d5e252', $response['body']['applicationId']);
+ $this->assertSame('https://gitlab.example.com', $response['body']['endpoint']);
+ // Custom names — the response model exposes `applicationId`/`secret`.
+ $this->assertArrayNotHasKey('clientId', $response['body']);
+ $this->assertArrayNotHasKey('clientSecret', $response['body']);
+
+ // Cleanup
+ $this->updateOAuth2('gitlab', [
+ 'applicationId' => '',
+ 'secret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2GitlabInvalidEndpoint(): void
+ {
+ $response = $this->updateOAuth2('gitlab', [
+ 'applicationId' => 'whatever',
+ 'secret' => 'whatever',
+ 'endpoint' => 'not a url',
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+ }
+
+ public function testUpdateOAuth2GitlabPartialEndpoint(): void
+ {
+ $this->updateOAuth2('gitlab', [
+ 'applicationId' => 'gitlab-seed-app',
+ 'secret' => 'gitlab-seed-secret',
+ 'endpoint' => 'https://seed.gitlab.com',
+ 'enabled' => false,
+ ]);
+
+ $response = $this->updateOAuth2('gitlab', [
+ 'endpoint' => 'https://updated.gitlab.com',
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('https://updated.gitlab.com', $response['body']['endpoint']);
+ $this->assertSame('gitlab-seed-app', $response['body']['applicationId']);
+
+ // Cleanup
+ $this->updateOAuth2('gitlab', [
+ 'applicationId' => '',
+ 'secret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2GitlabPartialPreservesEachField(): void
+ {
+ // testUpdateOAuth2GitlabPartialEndpoint covers patching only `endpoint`.
+ // Cover patching `secret` alone (must not wipe applicationId/endpoint)
+ // and `applicationId` alone (must not wipe the JSON-blob endpoint).
+ $this->updateOAuth2('gitlab', [
+ 'applicationId' => 'gitlab-merge-app',
+ 'secret' => 'gitlab-merge-secret',
+ 'endpoint' => 'https://merge.gitlab.com',
+ 'enabled' => false,
+ ]);
+
+ // Patch only `secret`.
+ $secretOnly = $this->updateOAuth2('gitlab', [
+ 'secret' => 'gitlab-rotated-secret',
+ ]);
+ $this->assertSame(200, $secretOnly['headers']['status-code']);
+ $this->assertSame('gitlab-merge-app', $secretOnly['body']['applicationId']);
+ $this->assertSame('https://merge.gitlab.com', $secretOnly['body']['endpoint']);
+
+ // Patch only `applicationId`.
+ $idOnly = $this->updateOAuth2('gitlab', [
+ 'applicationId' => 'gitlab-rotated-app',
+ ]);
+ $this->assertSame(200, $idOnly['headers']['status-code']);
+ $this->assertSame('gitlab-rotated-app', $idOnly['body']['applicationId']);
+ $this->assertSame('https://merge.gitlab.com', $idOnly['body']['endpoint']);
+
+ // Cleanup
+ $this->updateOAuth2('gitlab', [
+ 'applicationId' => '',
+ 'secret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2GitlabEnableAndReadBack(): void
+ {
+ $update = $this->updateOAuth2('gitlab', [
+ 'applicationId' => 'gitlab-enable-app',
+ 'secret' => 'gitlab-enable-secret',
+ 'endpoint' => 'https://enable.gitlab.com',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(200, $update['headers']['status-code']);
+ $this->assertTrue($update['body']['enabled']);
+
+ // GET must hide `secret` while keeping applicationId and endpoint.
+ $get = $this->getOAuth2Provider('gitlab');
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertTrue($get['body']['enabled']);
+ $this->assertSame('gitlab-enable-app', $get['body']['applicationId']);
+ $this->assertSame('https://enable.gitlab.com', $get['body']['endpoint']);
+ $this->assertSame('', $get['body']['secret']);
+
+ // Cleanup
+ $this->updateOAuth2('gitlab', [
+ 'applicationId' => '',
+ 'secret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2GitlabEndpointAcceptsEmpty(): void
+ {
+ // The `endpoint` validator is `Nullable(URL(allowEmpty: true))`. Passing
+ // `''` must clear the stored value rather than 400 on URL validation.
+ $this->updateOAuth2('gitlab', [
+ 'applicationId' => 'gitlab-clear-app',
+ 'secret' => 'gitlab-clear-secret',
+ 'endpoint' => 'https://before.gitlab.com',
+ 'enabled' => false,
+ ]);
+
+ $response = $this->updateOAuth2('gitlab', [
+ 'endpoint' => '',
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('', $response['body']['endpoint']);
+
+ // Cleanup
+ $this->updateOAuth2('gitlab', [
+ 'applicationId' => '',
+ 'secret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ // =========================================================================
+ // Update OIDC (clientId + secret + wellKnownURL or 3 discovery URLs)
+ // =========================================================================
+
+ public function testUpdateOAuth2OidcWithWellKnown(): void
+ {
+ $response = $this->updateOAuth2('oidc', [
+ 'clientId' => 'oidc-client',
+ 'clientSecret' => 'oidc-secret',
+ 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('https://idp.example.com/.well-known/openid-configuration', $response['body']['wellKnownURL']);
+ $this->assertArrayHasKey('authorizationURL', $response['body']);
+ $this->assertArrayHasKey('tokenURL', $response['body']);
+ $this->assertArrayHasKey('userInfoURL', $response['body']);
+
+ // Cleanup
+ $this->updateOAuth2('oidc', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'wellKnownURL' => '',
+ 'authorizationURL' => '',
+ 'tokenURL' => '',
+ 'userInfoURL' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2OidcWithDiscoveryURLs(): void
+ {
+ $response = $this->updateOAuth2('oidc', [
+ 'clientId' => 'oidc-discovery',
+ 'clientSecret' => 'oidc-discovery-secret',
+ 'authorizationURL' => 'https://idp.example.com/oauth2/authorize',
+ 'tokenURL' => 'https://idp.example.com/oauth2/token',
+ 'userInfoURL' => 'https://idp.example.com/oauth2/userinfo',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('https://idp.example.com/oauth2/authorize', $response['body']['authorizationURL']);
+ $this->assertSame('https://idp.example.com/oauth2/token', $response['body']['tokenURL']);
+ $this->assertSame('https://idp.example.com/oauth2/userinfo', $response['body']['userInfoURL']);
+
+ // Cleanup
+ $this->updateOAuth2('oidc', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'wellKnownURL' => '',
+ 'authorizationURL' => '',
+ 'tokenURL' => '',
+ 'userInfoURL' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2OidcEnableMissingURLs(): void
+ {
+ $this->updateOAuth2('oidc', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'wellKnownURL' => '',
+ 'authorizationURL' => '',
+ 'tokenURL' => '',
+ 'userInfoURL' => '',
+ 'enabled' => false,
+ ]);
+
+ $response = $this->updateOAuth2('oidc', [
+ 'clientId' => 'oidc-no-urls',
+ 'clientSecret' => 'oidc-no-urls',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+
+ // Cleanup
+ $this->updateOAuth2('oidc', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2OidcEnablePartialDiscoveryFails(): void
+ {
+ // Only authorization+token, missing userInfo — must fail to enable.
+ $this->updateOAuth2('oidc', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'wellKnownURL' => '',
+ 'authorizationURL' => '',
+ 'tokenURL' => '',
+ 'userInfoURL' => '',
+ 'enabled' => false,
+ ]);
+
+ $response = $this->updateOAuth2('oidc', [
+ 'clientId' => 'oidc-partial',
+ 'clientSecret' => 'oidc-partial-secret',
+ 'authorizationURL' => 'https://idp.example.com/oauth2/authorize',
+ 'tokenURL' => 'https://idp.example.com/oauth2/token',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+
+ // Cleanup
+ $this->updateOAuth2('oidc', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'wellKnownURL' => '',
+ 'authorizationURL' => '',
+ 'tokenURL' => '',
+ 'userInfoURL' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2OidcEnableSucceedsWithWellKnown(): void
+ {
+ $update = $this->updateOAuth2('oidc', [
+ 'clientId' => 'oidc-enable-client',
+ 'clientSecret' => 'oidc-enable-secret',
+ 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(200, $update['headers']['status-code']);
+ $this->assertTrue($update['body']['enabled']);
+
+ // GET must hide clientSecret while keeping clientId and the URL.
+ $get = $this->getOAuth2Provider('oidc');
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertTrue($get['body']['enabled']);
+ $this->assertSame('oidc-enable-client', $get['body']['clientId']);
+ $this->assertSame('https://idp.example.com/.well-known/openid-configuration', $get['body']['wellKnownURL']);
+ $this->assertSame('', $get['body']['clientSecret']);
+
+ // Cleanup
+ $this->updateOAuth2('oidc', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'wellKnownURL' => '',
+ 'authorizationURL' => '',
+ 'tokenURL' => '',
+ 'userInfoURL' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2OidcEnableInSeparateRequestWithWellKnown(): void
+ {
+ // Configure URLs first with `enabled: false`. Then enable in a SECOND
+ // request that omits all URL fields. The merge-on-enable logic in
+ // Oidc::handle() must see the previously-stored wellKnownEndpoint and
+ // allow the toggle. This is the headline feature of the merge logic.
+ $this->updateOAuth2('oidc', [
+ 'clientId' => 'oidc-split-wk-client',
+ 'clientSecret' => 'oidc-split-wk-secret',
+ 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration',
+ 'enabled' => false,
+ ]);
+
+ $enable = $this->updateOAuth2('oidc', [
+ 'enabled' => true,
+ ]);
+ $this->assertSame(200, $enable['headers']['status-code']);
+ $this->assertTrue($enable['body']['enabled']);
+
+ // Cleanup
+ $this->updateOAuth2('oidc', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'wellKnownURL' => '',
+ 'authorizationURL' => '',
+ 'tokenURL' => '',
+ 'userInfoURL' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2OidcEnableAcrossRequestsWithDiscoveryURLs(): void
+ {
+ // Reset to clean state — earlier tests in this section may have left
+ // partial URL state when running in any order.
+ $this->updateOAuth2('oidc', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'wellKnownURL' => '',
+ 'authorizationURL' => '',
+ 'tokenURL' => '',
+ 'userInfoURL' => '',
+ 'enabled' => false,
+ ]);
+
+ // Request 1: configure two of the three discovery URLs.
+ $this->updateOAuth2('oidc', [
+ 'clientId' => 'oidc-split-discovery',
+ 'clientSecret' => 'oidc-split-discovery-secret',
+ 'authorizationURL' => 'https://idp.example.com/oauth2/authorize',
+ 'tokenURL' => 'https://idp.example.com/oauth2/token',
+ 'enabled' => false,
+ ]);
+
+ // Request 2: send only the third URL plus enable=true. The merged
+ // state must include the two stored URLs + the new one to satisfy
+ // the all-three-discovery-URLs branch of the enable check.
+ $enable = $this->updateOAuth2('oidc', [
+ 'userInfoURL' => 'https://idp.example.com/oauth2/userinfo',
+ 'enabled' => true,
+ ]);
+ $this->assertSame(200, $enable['headers']['status-code']);
+ $this->assertTrue($enable['body']['enabled']);
+
+ // Confirm all three URLs ended up persisted (merge wrote the new
+ // userInfoURL while preserving the previously stored two).
+ $get = $this->getOAuth2Provider('oidc');
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertSame('https://idp.example.com/oauth2/authorize', $get['body']['authorizationURL']);
+ $this->assertSame('https://idp.example.com/oauth2/token', $get['body']['tokenURL']);
+ $this->assertSame('https://idp.example.com/oauth2/userinfo', $get['body']['userInfoURL']);
+
+ // Cleanup
+ $this->updateOAuth2('oidc', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'wellKnownURL' => '',
+ 'authorizationURL' => '',
+ 'tokenURL' => '',
+ 'userInfoURL' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2OidcEnableFailsAfterClearingWellKnown(): void
+ {
+ // Seed wellKnownURL only (no discovery URLs).
+ $this->updateOAuth2('oidc', [
+ 'clientId' => 'oidc-clear-then-enable',
+ 'clientSecret' => 'oidc-clear-then-enable-secret',
+ 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration',
+ 'authorizationURL' => '',
+ 'tokenURL' => '',
+ 'userInfoURL' => '',
+ 'enabled' => false,
+ ]);
+
+ // Clear wellKnownURL and try to enable in the same request. Merge
+ // sees `wellKnown=''` (the cleared empty wins over the stored value
+ // because the new value is non-null) and no discovery URLs → 400.
+ // This is the inverse of testUpdateOAuth2OidcEnableInSeparateRequestWithWellKnown:
+ // confirms the merge correctly *replaces* with empty rather than
+ // falling back to the stored non-empty value.
+ $response = $this->updateOAuth2('oidc', [
+ 'wellKnownURL' => '',
+ 'enabled' => true,
+ ]);
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+
+ // Cleanup
+ $this->updateOAuth2('oidc', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'wellKnownURL' => '',
+ 'authorizationURL' => '',
+ 'tokenURL' => '',
+ 'userInfoURL' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2OidcSwitchModesWellKnownToDiscovery(): void
+ {
+ // Configure with wellKnownURL, then switch to the three-discovery-URL
+ // mode in a single request: clear wellKnown, set the three URLs,
+ // enable. Merge sees wellKnown='' AND all three discovery URLs set →
+ // hasAllDiscovery branch passes.
+ $this->updateOAuth2('oidc', [
+ 'clientId' => 'oidc-switch-client',
+ 'clientSecret' => 'oidc-switch-secret',
+ 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration',
+ 'enabled' => false,
+ ]);
+
+ $switch = $this->updateOAuth2('oidc', [
+ 'wellKnownURL' => '',
+ 'authorizationURL' => 'https://idp.example.com/oauth2/authorize',
+ 'tokenURL' => 'https://idp.example.com/oauth2/token',
+ 'userInfoURL' => 'https://idp.example.com/oauth2/userinfo',
+ 'enabled' => true,
+ ]);
+ $this->assertSame(200, $switch['headers']['status-code']);
+ $this->assertTrue($switch['body']['enabled']);
+ $this->assertSame('', $switch['body']['wellKnownURL']);
+ $this->assertSame('https://idp.example.com/oauth2/authorize', $switch['body']['authorizationURL']);
+ $this->assertSame('https://idp.example.com/oauth2/token', $switch['body']['tokenURL']);
+ $this->assertSame('https://idp.example.com/oauth2/userinfo', $switch['body']['userInfoURL']);
+
+ // Cleanup
+ $this->updateOAuth2('oidc', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'wellKnownURL' => '',
+ 'authorizationURL' => '',
+ 'tokenURL' => '',
+ 'userInfoURL' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2OidcURLsAcceptEmpty(): void
+ {
+ // All four URL fields use `Nullable(URL(allowEmpty: true))`. Passing `''`
+ // for each must clear them rather than 400 on URL validation.
+ $this->updateOAuth2('oidc', [
+ 'clientId' => 'oidc-clear-client',
+ 'clientSecret' => 'oidc-clear-secret',
+ 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration',
+ 'authorizationURL' => 'https://idp.example.com/oauth2/authorize',
+ 'tokenURL' => 'https://idp.example.com/oauth2/token',
+ 'userInfoURL' => 'https://idp.example.com/oauth2/userinfo',
+ 'enabled' => false,
+ ]);
+
+ $response = $this->updateOAuth2('oidc', [
+ 'wellKnownURL' => '',
+ 'authorizationURL' => '',
+ 'tokenURL' => '',
+ 'userInfoURL' => '',
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('', $response['body']['wellKnownURL']);
+ $this->assertSame('', $response['body']['authorizationURL']);
+ $this->assertSame('', $response['body']['tokenURL']);
+ $this->assertSame('', $response['body']['userInfoURL']);
+
+ // Cleanup
+ $this->updateOAuth2('oidc', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2OidcBackwardCompatibleResponseFormat(): void
+ {
+ // Reset to clean state
+ $this->updateOAuth2('oidc', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'wellKnownURL' => '',
+ 'authorizationURL' => '',
+ 'tokenURL' => '',
+ 'userInfoURL' => '',
+ 'enabled' => false,
+ ]);
+
+ $headers = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.3',
+ ];
+ $headers = \array_merge($headers, $this->getHeaders());
+
+ // Update using OLD param names (aliases must still work)
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/oauth2/oidc',
+ $headers,
+ [
+ 'clientId' => 'oidc-compat-client',
+ 'clientSecret' => 'oidc-compat-secret',
+ 'tokenUrl' => 'https://idp.example.com/oauth2/token',
+ 'userInfoUrl' => 'https://idp.example.com/oauth2/userinfo',
+ 'enabled' => false,
+ ],
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertArrayHasKey('tokenUrl', $response['body']);
+ $this->assertArrayHasKey('userInfoUrl', $response['body']);
+ $this->assertArrayNotHasKey('tokenURL', $response['body']);
+ $this->assertArrayNotHasKey('userInfoURL', $response['body']);
+ $this->assertSame('https://idp.example.com/oauth2/token', $response['body']['tokenUrl']);
+ $this->assertSame('https://idp.example.com/oauth2/userinfo', $response['body']['userInfoUrl']);
+
+ // GET with 1.9.3 format must also return old param names
+ $get = $this->client->call(
+ Client::METHOD_GET,
+ '/project/oauth2/oidc',
+ $headers,
+ );
+
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertArrayHasKey('tokenUrl', $get['body']);
+ $this->assertArrayHasKey('userInfoUrl', $get['body']);
+ $this->assertArrayNotHasKey('tokenURL', $get['body']);
+ $this->assertArrayNotHasKey('userInfoURL', $get['body']);
+ $this->assertSame('https://idp.example.com/oauth2/token', $get['body']['tokenUrl']);
+ $this->assertSame('https://idp.example.com/oauth2/userinfo', $get['body']['userInfoUrl']);
+
+ // LIST with 1.9.3 format must also return old param names for OIDC
+ $list = $this->client->call(
+ Client::METHOD_GET,
+ '/project/oauth2',
+ $headers,
+ );
+
+ $this->assertSame(200, $list['headers']['status-code']);
+ $oidcEntry = null;
+ foreach ($list['body']['providers'] as $provider) {
+ if ($provider['$id'] === 'oidc') {
+ $oidcEntry = $provider;
+ break;
+ }
+ }
+ $this->assertNotNull($oidcEntry, 'OIDC provider missing from listOAuth2Providers response');
+ $this->assertArrayHasKey('tokenUrl', $oidcEntry);
+ $this->assertArrayHasKey('userInfoUrl', $oidcEntry);
+ $this->assertArrayNotHasKey('tokenURL', $oidcEntry);
+ $this->assertArrayNotHasKey('userInfoURL', $oidcEntry);
+ $this->assertSame('https://idp.example.com/oauth2/token', $oidcEntry['tokenUrl']);
+ $this->assertSame('https://idp.example.com/oauth2/userinfo', $oidcEntry['userInfoUrl']);
+
+ // Cleanup
+ $this->updateOAuth2('oidc', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'tokenURL' => '',
+ 'userInfoURL' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ // =========================================================================
+ // Update Okta (clientId + clientSecret + optional domain/authServer)
+ // =========================================================================
+
+ public function testUpdateOAuth2Okta(): void
+ {
+ $response = $this->updateOAuth2('okta', [
+ 'clientId' => '0oa00000000000000698',
+ 'clientSecret' => 'okta-secret',
+ 'domain' => 'trial-6400025.okta.com',
+ 'authorizationServerId' => 'aus000000000000000h7z',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('okta', $response['body']['$id']);
+ $this->assertSame('0oa00000000000000698', $response['body']['clientId']);
+ $this->assertSame('trial-6400025.okta.com', $response['body']['domain']);
+ $this->assertSame('aus000000000000000h7z', $response['body']['authorizationServerId']);
+
+ // Cleanup
+ $this->updateOAuth2('okta', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'domain' => '',
+ 'authorizationServerId' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2OktaInvalidDomain(): void
+ {
+ $response = $this->updateOAuth2('okta', [
+ 'clientId' => 'whatever',
+ 'clientSecret' => 'whatever',
+ 'domain' => 'https://trial-6400025.okta.com/',
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+ }
+
+ public function testUpdateOAuth2OktaEnableRequiresDomain(): void
+ {
+ $this->updateOAuth2('okta', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'domain' => '',
+ 'authorizationServerId' => '',
+ 'enabled' => false,
+ ]);
+
+ $response = $this->updateOAuth2('okta', [
+ 'clientId' => 'okta-no-domain',
+ 'clientSecret' => 'okta-no-domain-secret',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+
+ // Cleanup
+ $this->updateOAuth2('okta', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2OktaEnableSucceedsWithDomain(): void
+ {
+ $update = $this->updateOAuth2('okta', [
+ 'clientId' => 'okta-enable-client',
+ 'clientSecret' => 'okta-enable-secret',
+ 'domain' => 'enable.okta.com',
+ 'authorizationServerId' => 'aus000000000000000h7z',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(200, $update['headers']['status-code']);
+ $this->assertTrue($update['body']['enabled']);
+
+ // GET must hide clientSecret while keeping clientId, domain and authServerId.
+ $get = $this->getOAuth2Provider('okta');
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertTrue($get['body']['enabled']);
+ $this->assertSame('okta-enable-client', $get['body']['clientId']);
+ $this->assertSame('enable.okta.com', $get['body']['domain']);
+ $this->assertSame('aus000000000000000h7z', $get['body']['authorizationServerId']);
+ $this->assertSame('', $get['body']['clientSecret']);
+
+ // Cleanup
+ $this->updateOAuth2('okta', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'domain' => '',
+ 'authorizationServerId' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2OktaPartialPreservesEachField(): void
+ {
+ // Okta has no field-by-field partial test in the existing suite. Cover
+ // each of `domain`, `authorizationServerId`, and `clientSecret` being
+ // patched alone — all three live in the same JSON blob.
+ $this->updateOAuth2('okta', [
+ 'clientId' => 'okta-merge-client',
+ 'clientSecret' => 'okta-merge-secret',
+ 'domain' => 'merge.okta.com',
+ 'authorizationServerId' => 'aus000000000000merge',
+ 'enabled' => false,
+ ]);
+
+ // Patch only `domain` — others must survive.
+ $domainOnly = $this->updateOAuth2('okta', [
+ 'domain' => 'rotated.okta.com',
+ ]);
+ $this->assertSame(200, $domainOnly['headers']['status-code']);
+ $this->assertSame('rotated.okta.com', $domainOnly['body']['domain']);
+ $this->assertSame('okta-merge-client', $domainOnly['body']['clientId']);
+ $this->assertSame('aus000000000000merge', $domainOnly['body']['authorizationServerId']);
+
+ // Patch only `authorizationServerId`.
+ $authServerOnly = $this->updateOAuth2('okta', [
+ 'authorizationServerId' => 'aus000000000rotated00',
+ ]);
+ $this->assertSame(200, $authServerOnly['headers']['status-code']);
+ $this->assertSame('rotated.okta.com', $authServerOnly['body']['domain']);
+ $this->assertSame('aus000000000rotated00', $authServerOnly['body']['authorizationServerId']);
+
+ // Patch only `clientSecret` — domain and authServerId in the JSON blob
+ // must survive. Confirm the rotated secret persisted by enabling.
+ $secretOnly = $this->updateOAuth2('okta', [
+ 'clientSecret' => 'okta-rotated-secret',
+ ]);
+ $this->assertSame(200, $secretOnly['headers']['status-code']);
+ $this->assertSame('rotated.okta.com', $secretOnly['body']['domain']);
+ $this->assertSame('aus000000000rotated00', $secretOnly['body']['authorizationServerId']);
+
+ $enable = $this->updateOAuth2('okta', ['enabled' => true]);
+ $this->assertSame(200, $enable['headers']['status-code']);
+ $this->assertTrue($enable['body']['enabled']);
+
+ // Cleanup
+ $this->updateOAuth2('okta', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'domain' => '',
+ 'authorizationServerId' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2OktaAuthServerIdAcceptsEmpty(): void
+ {
+ // `authorizationServerId` is `Nullable(Text(256, 0))`. Passing `''`
+ // must clear the stored value while leaving the rest of the JSON blob
+ // (clientSecret, oktaDomain) untouched.
+ $this->updateOAuth2('okta', [
+ 'clientId' => 'okta-clear-auth-server',
+ 'clientSecret' => 'okta-clear-auth-server-secret',
+ 'domain' => 'authserver.okta.com',
+ 'authorizationServerId' => 'aus0000000000beforeauth',
+ 'enabled' => false,
+ ]);
+
+ $response = $this->updateOAuth2('okta', [
+ 'authorizationServerId' => '',
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('', $response['body']['authorizationServerId']);
+ // domain (also stored in the JSON blob) must NOT have been wiped.
+ $this->assertSame('authserver.okta.com', $response['body']['domain']);
+
+ // Cleanup
+ $this->updateOAuth2('okta', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'domain' => '',
+ 'authorizationServerId' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2OktaDomainAcceptsEmpty(): void
+ {
+ // The `domain` validator is `Nullable(Domain(allowEmpty: true))`. Passing
+ // `''` must clear the stored value rather than 400 on Domain validation.
+ $this->updateOAuth2('okta', [
+ 'clientId' => 'okta-clear-client',
+ 'clientSecret' => 'okta-clear-secret',
+ 'domain' => 'before.okta.com',
+ 'enabled' => false,
+ ]);
+
+ $response = $this->updateOAuth2('okta', [
+ 'domain' => '',
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('', $response['body']['domain']);
+
+ // Cleanup
+ $this->updateOAuth2('okta', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'domain' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ // =========================================================================
+ // Update Dropbox (custom param names: appKey + appSecret)
+ // =========================================================================
+
+ public function testUpdateOAuth2DropboxFieldNames(): void
+ {
+ $response = $this->updateOAuth2('dropbox', [
+ 'appKey' => 'jl000000000009t',
+ 'appSecret' => 'g200000000000vw',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('dropbox', $response['body']['$id']);
+ $this->assertSame('jl000000000009t', $response['body']['appKey']);
+ $this->assertArrayHasKey('appSecret', $response['body']);
+ $this->assertArrayNotHasKey('clientId', $response['body']);
+ $this->assertArrayNotHasKey('clientSecret', $response['body']);
+
+ // GET enforces write-only on the secret regardless of the custom name.
+ $get = $this->getOAuth2Provider('dropbox');
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertSame('jl000000000009t', $get['body']['appKey']);
+ $this->assertSame('', $get['body']['appSecret']);
+
+ // Cleanup
+ $this->updateOAuth2('dropbox', [
+ 'appKey' => '',
+ 'appSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2DropboxPartial(): void
+ {
+ // Seed both fields, then patch only `appKey` and verify `appSecret`
+ // persists by enabling — Dropbox has no verifyCredentials() hook, so
+ // enabling succeeds purely from local state.
+ $this->updateOAuth2('dropbox', [
+ 'appKey' => 'dropbox-seed-key',
+ 'appSecret' => 'dropbox-seed-secret',
+ 'enabled' => false,
+ ]);
+
+ $response = $this->updateOAuth2('dropbox', [
+ 'appKey' => 'dropbox-updated-key',
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('dropbox-updated-key', $response['body']['appKey']);
+
+ $enable = $this->updateOAuth2('dropbox', [
+ 'enabled' => true,
+ ]);
+ $this->assertSame(200, $enable['headers']['status-code']);
+ $this->assertSame(true, $enable['body']['enabled']);
+
+ // Cleanup
+ $this->updateOAuth2('dropbox', [
+ 'appKey' => '',
+ 'appSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2DropboxEnableAndReadBack(): void
+ {
+ $update = $this->updateOAuth2('dropbox', [
+ 'appKey' => 'dropbox-enable-key',
+ 'appSecret' => 'dropbox-enable-secret',
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(200, $update['headers']['status-code']);
+ $this->assertTrue($update['body']['enabled']);
+
+ // GET must hide `appSecret` while keeping `appKey`.
+ $get = $this->getOAuth2Provider('dropbox');
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertTrue($get['body']['enabled']);
+ $this->assertSame('dropbox-enable-key', $get['body']['appKey']);
+ $this->assertSame('', $get['body']['appSecret']);
+
+ // Cleanup
+ $this->updateOAuth2('dropbox', [
+ 'appKey' => '',
+ 'appSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ // =========================================================================
+ // Update Paypal Sandbox (inherits from Paypal — independent provider ID)
+ // =========================================================================
+
+ public function testUpdateOAuth2PaypalSandbox(): void
+ {
+ $response = $this->updateOAuth2('paypalSandbox', [
+ 'clientId' => 'paypal-sandbox-client',
+ 'clientSecret' => 'paypal-sandbox-secret',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('paypalSandbox', $response['body']['$id']);
+ $this->assertSame('paypal-sandbox-client', $response['body']['clientId']);
+
+ // Sandbox is independent of the regular paypal entry.
+ $regular = $this->getOAuth2Provider('paypal');
+ $this->assertSame(200, $regular['headers']['status-code']);
+ $this->assertSame('paypal', $regular['body']['$id']);
+ $this->assertNotSame('paypal-sandbox-client', $regular['body']['clientId']);
+
+ // Cleanup
+ $this->updateOAuth2('paypalSandbox', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2PaypalSandboxResponseModel(): void
+ {
+ // PaypalSandbox inherits from Paypal: param/response field is
+ // `secretKey` instead of `clientSecret`. A regression that adds the
+ // default `clientSecret` to the response model would leak the
+ // unwritten field; pin its absence on both PATCH and GET.
+ $update = $this->updateOAuth2('paypalSandbox', [
+ 'clientId' => 'paypal-sandbox-shape',
+ 'secretKey' => 'paypal-sandbox-shape-secret',
+ 'enabled' => false,
+ ]);
+ $this->assertSame(200, $update['headers']['status-code']);
+ $this->assertArrayHasKey('secretKey', $update['body']);
+ $this->assertArrayNotHasKey('clientSecret', $update['body']);
+
+ $get = $this->getOAuth2Provider('paypalSandbox');
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertArrayHasKey('secretKey', $get['body']);
+ $this->assertArrayNotHasKey('clientSecret', $get['body']);
+
+ // Cleanup
+ $this->updateOAuth2('paypalSandbox', [
+ 'clientId' => '',
+ 'secretKey' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2PaypalDoesNotAffectSandbox(): void
+ {
+ // Reverse direction: writing to regular paypal must leave sandbox state intact.
+ $this->updateOAuth2('paypalSandbox', [
+ 'clientId' => 'sandbox-untouched',
+ 'clientSecret' => 'sandbox-secret',
+ 'enabled' => false,
+ ]);
+
+ $this->updateOAuth2('paypal', [
+ 'clientId' => 'paypal-prod',
+ 'secretKey' => 'paypal-prod-secret',
+ 'enabled' => false,
+ ]);
+
+ $sandbox = $this->getOAuth2Provider('paypalSandbox');
+ $this->assertSame(200, $sandbox['headers']['status-code']);
+ $this->assertSame('sandbox-untouched', $sandbox['body']['clientId']);
+
+ // Cleanup both
+ $this->updateOAuth2('paypal', [
+ 'clientId' => '',
+ 'secretKey' => '',
+ 'enabled' => false,
+ ]);
+ $this->updateOAuth2('paypalSandbox', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ // =========================================================================
+ // Update Tradeshift Sandbox (inherits from Tradeshift — independent provider ID)
+ // =========================================================================
+
+ public function testUpdateOAuth2TradeshiftBox(): void
+ {
+ $response = $this->updateOAuth2('tradeshiftBox', [
+ 'oauth2ClientId' => 'tradeshift-sandbox-client',
+ 'oauth2ClientSecret' => 'tradeshift-sandbox-secret',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('tradeshiftBox', $response['body']['$id']);
+ $this->assertSame('tradeshift-sandbox-client', $response['body']['oauth2ClientId']);
+
+ // Sandbox is independent of the regular tradeshift entry.
+ $regular = $this->getOAuth2Provider('tradeshift');
+ $this->assertSame(200, $regular['headers']['status-code']);
+ $this->assertSame('tradeshift', $regular['body']['$id']);
+ $this->assertNotSame('tradeshift-sandbox-client', $regular['body']['oauth2ClientId']);
+
+ // Cleanup
+ $this->updateOAuth2('tradeshiftBox', [
+ 'oauth2ClientId' => '',
+ 'oauth2ClientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2TradeshiftBoxResponseModel(): void
+ {
+ // TradeshiftSandbox inherits from Tradeshift: both clientId AND
+ // clientSecret are renamed (oauth2ClientId / oauth2ClientSecret).
+ // Pin that the default field names are absent from PATCH and GET
+ // responses so a stray addition to the response model is caught.
+ $update = $this->updateOAuth2('tradeshiftBox', [
+ 'oauth2ClientId' => 'tradeshift-box-shape',
+ 'oauth2ClientSecret' => 'tradeshift-box-shape-secret',
+ 'enabled' => false,
+ ]);
+ $this->assertSame(200, $update['headers']['status-code']);
+ $this->assertArrayHasKey('oauth2ClientId', $update['body']);
+ $this->assertArrayHasKey('oauth2ClientSecret', $update['body']);
+ $this->assertArrayNotHasKey('clientId', $update['body']);
+ $this->assertArrayNotHasKey('clientSecret', $update['body']);
+
+ $get = $this->getOAuth2Provider('tradeshiftBox');
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertArrayHasKey('oauth2ClientId', $get['body']);
+ $this->assertArrayHasKey('oauth2ClientSecret', $get['body']);
+ $this->assertArrayNotHasKey('clientId', $get['body']);
+ $this->assertArrayNotHasKey('clientSecret', $get['body']);
+
+ // Cleanup
+ $this->updateOAuth2('tradeshiftBox', [
+ 'oauth2ClientId' => '',
+ 'oauth2ClientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2TradeshiftDoesNotAffectSandbox(): void
+ {
+ // Reverse direction: writing to regular tradeshift must not touch sandbox state.
+ $this->updateOAuth2('tradeshiftBox', [
+ 'oauth2ClientId' => 'tradeshift-sandbox-untouched',
+ 'oauth2ClientSecret' => 'tradeshift-sandbox-secret',
+ 'enabled' => false,
+ ]);
+
+ $this->updateOAuth2('tradeshift', [
+ 'oauth2ClientId' => 'tradeshift-prod',
+ 'oauth2ClientSecret' => 'tradeshift-prod-secret',
+ 'enabled' => false,
+ ]);
+
+ $sandbox = $this->getOAuth2Provider('tradeshiftBox');
+ $this->assertSame(200, $sandbox['headers']['status-code']);
+ $this->assertSame('tradeshift-sandbox-untouched', $sandbox['body']['oauth2ClientId']);
+
+ // Cleanup both
+ $this->updateOAuth2('tradeshift', [
+ 'oauth2ClientId' => '',
+ 'oauth2ClientSecret' => '',
+ 'enabled' => false,
+ ]);
+ $this->updateOAuth2('tradeshiftBox', [
+ 'oauth2ClientId' => '',
+ 'oauth2ClientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ // =========================================================================
+ // Update Google (clientId + clientSecret + optional prompt)
+ // =========================================================================
+
+ /**
+ * Default prompt MUST run before any other Google test that sets a custom
+ * prompt value. The global resetProjectOAuth2() only clears Amazon state,
+ * so Google state leaks across tests in the same class. Running this first
+ * guarantees the stored JSON blob has no pre-existing "prompt" key.
+ */
+ public function testUpdateOAuth2GoogleDefaultPrompt(): void
+ {
+ // When prompt is omitted and nothing is stored, the default is ['consent'].
+ $response = $this->updateOAuth2('google', [
+ 'clientId' => 'google-default-client',
+ 'clientSecret' => 'google-default-secret',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(['consent'], $response['body']['prompt']);
+
+ // Cleanup
+ $this->updateOAuth2('google', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2Google(): void
+ {
+ $response = $this->updateOAuth2('google', [
+ 'clientId' => '120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com',
+ 'clientSecret' => 'GOCSPX-2k8gsR0000000000000000VNahJj',
+ 'prompt' => ['select_account'],
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('google', $response['body']['$id']);
+ $this->assertSame('120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com', $response['body']['clientId']);
+ $this->assertSame(['select_account'], $response['body']['prompt']);
+
+ // Cleanup
+ $this->updateOAuth2('google', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2GooglePartialPreservesPrompt(): void
+ {
+ // Seed clientSecret + prompt.
+ $this->updateOAuth2('google', [
+ 'clientId' => 'google-seed-client',
+ 'clientSecret' => 'google-seed-secret',
+ 'prompt' => ['consent', 'select_account'],
+ 'enabled' => false,
+ ]);
+
+ // Update only clientId.
+ $response = $this->updateOAuth2('google', [
+ 'clientId' => 'google-rotated-client',
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('google-rotated-client', $response['body']['clientId']);
+ $this->assertSame(['consent', 'select_account'], $response['body']['prompt']);
+
+ // Cleanup
+ $this->updateOAuth2('google', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2GooglePromptNoneAloneRejected(): void
+ {
+ $response = $this->updateOAuth2('google', [
+ 'clientId' => 'whatever',
+ 'clientSecret' => 'whatever',
+ 'prompt' => ['none', 'consent'],
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+ }
+
+ public function testUpdateOAuth2GooglePromptEmptyArrayRejected(): void
+ {
+ $response = $this->updateOAuth2('google', [
+ 'clientId' => 'whatever',
+ 'clientSecret' => 'whatever',
+ 'prompt' => [],
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+ }
+
+ public function testUpdateOAuth2GooglePromptNoneAloneAccepted(): void
+ {
+ $response = $this->updateOAuth2('google', [
+ 'clientId' => '120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com',
+ 'clientSecret' => 'GOCSPX-2k8gsR0000000000000000VNahJj',
+ 'prompt' => ['none'],
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(['none'], $response['body']['prompt']);
+
+ // Cleanup
+ $this->updateOAuth2('google', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2GoogleEnableAndReadBack(): void
+ {
+ $update = $this->updateOAuth2('google', [
+ 'clientId' => 'google-enable-client',
+ 'clientSecret' => 'google-enable-secret',
+ 'prompt' => ['select_account'],
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(200, $update['headers']['status-code']);
+ $this->assertTrue($update['body']['enabled']);
+
+ // GET must hide clientSecret while keeping clientId and prompt.
+ $get = $this->getOAuth2Provider('google');
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertTrue($get['body']['enabled']);
+ $this->assertSame('google-enable-client', $get['body']['clientId']);
+ $this->assertSame(['select_account'], $get['body']['prompt']);
+ $this->assertSame('', $get['body']['clientSecret']);
+
+ // Cleanup
+ $this->updateOAuth2('google', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ // =========================================================================
+ // Smoke test: every plain (clientId + clientSecret) provider
+ //
+ // Ensures each provider's Update endpoint is wired up correctly: routing,
+ // provider class, response model and `$id`. Custom-shaped providers
+ // (apple, auth0, authentik, fusionauth, gitlab, keycloak, microsoft, oidc,
+ // okta, dropbox) and sandboxes (paypalSandbox, tradeshiftSandbox) have
+ // dedicated tests above.
+ // Github is excluded because its `verifyCredentials()` hook is exercised
+ // separately.
+ // =========================================================================
+
+ /**
+ * Provider, ID-field, secret-field. Many providers rename one or both of
+ * the two credential params (`clientId`/`clientSecret`) to match the
+ * upstream provider's terminology, so the smoke test parameterises both.
+ *
+ * @return array>
+ */
+ public static function plainProviders(): array
+ {
+ return [
+ 'discord' => ['discord', 'clientId', 'clientSecret'],
+ 'figma' => ['figma', 'clientId', 'clientSecret'],
+ 'dailymotion' => ['dailymotion', 'apiKey', 'apiSecret'],
+ 'bitbucket' => ['bitbucket', 'key', 'secret'],
+ 'bitly' => ['bitly', 'clientId', 'clientSecret'],
+ 'box' => ['box', 'clientId', 'clientSecret'],
+ 'autodesk' => ['autodesk', 'clientId', 'clientSecret'],
+ 'google' => ['google', 'clientId', 'clientSecret'],
+ 'zoom' => ['zoom', 'clientId', 'clientSecret'],
+ 'zoho' => ['zoho', 'clientId', 'clientSecret'],
+ 'yandex' => ['yandex', 'clientId', 'clientSecret'],
+ 'x' => ['x', 'customerKey', 'secretKey'],
+ 'wordpress' => ['wordpress', 'clientId', 'clientSecret'],
+ 'twitch' => ['twitch', 'clientId', 'clientSecret'],
+ 'stripe' => ['stripe', 'clientId', 'apiSecretKey'],
+ 'spotify' => ['spotify', 'clientId', 'clientSecret'],
+ 'slack' => ['slack', 'clientId', 'clientSecret'],
+ 'podio' => ['podio', 'clientId', 'clientSecret'],
+ 'notion' => ['notion', 'oauthClientId', 'oauthClientSecret'],
+ 'salesforce' => ['salesforce', 'customerKey', 'customerSecret'],
+ 'yahoo' => ['yahoo', 'clientId', 'clientSecret'],
+ 'linkedin' => ['linkedin', 'clientId', 'primaryClientSecret'],
+ 'disqus' => ['disqus', 'publicKey', 'secretKey'],
+ 'etsy' => ['etsy', 'keyString', 'sharedSecret'],
+ 'facebook' => ['facebook', 'appId', 'appSecret'],
+ 'tradeshift' => ['tradeshift', 'oauth2ClientId', 'oauth2ClientSecret'],
+ 'paypal' => ['paypal', 'clientId', 'secretKey'],
+ 'kick' => ['kick', 'clientId', 'clientSecret'],
+ ];
+ }
+
+ #[DataProvider('plainProviders')]
+ public function testUpdateOAuth2PlainProvider(string $providerId, string $idField, string $secretField): void
+ {
+ $clientId = $providerId . '-smoke-client';
+ $clientSecret = $providerId . '-smoke-secret';
+
+ $update = $this->updateOAuth2($providerId, [
+ $idField => $clientId,
+ $secretField => $clientSecret,
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $update['headers']['status-code']);
+ $this->assertSame($providerId, $update['body']['$id']);
+ $this->assertSame($clientId, $update['body'][$idField]);
+ $this->assertFalse($update['body']['enabled']);
+
+ // GET round-trip — confirms the value actually persisted (catches a
+ // PATCH that only echoes input without writing) and that the secret
+ // is hidden on read.
+ $get = $this->getOAuth2Provider($providerId);
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertSame($providerId, $get['body']['$id']);
+ $this->assertSame($clientId, $get['body'][$idField]);
+ $this->assertSame('', $get['body'][$secretField]);
+ $this->assertFalse($get['body']['enabled']);
+
+ // Cleanup
+ $this->updateOAuth2($providerId, [
+ $idField => '',
+ $secretField => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ /**
+ * For providers that rename `clientId` / `clientSecret` to a custom field
+ * (e.g. `apiKey`/`apiSecret`, `customerKey`/`secretKey`, `oauthClientId`),
+ * the renamed field replaces the default — the response model must NOT
+ * also expose the default name. Catches a regression where adding a
+ * custom param name forgets to remove the default from the response.
+ */
+ #[DataProvider('plainProviders')]
+ public function testUpdateOAuth2PlainProviderResponseDoesNotLeakDefaultNames(string $providerId, string $idField, string $secretField): void
+ {
+ if ($idField === 'clientId' && $secretField === 'clientSecret') {
+ // Default-named provider — nothing to leak. Avoids a no-op assertion.
+ $this->markTestSkipped("{$providerId} uses default field names.");
+ }
+
+ $update = $this->updateOAuth2($providerId, [
+ $idField => $providerId . '-leak-check-id',
+ $secretField => $providerId . '-leak-check-secret',
+ 'enabled' => false,
+ ]);
+ $this->assertSame(200, $update['headers']['status-code']);
+
+ if ($idField !== 'clientId') {
+ $this->assertArrayNotHasKey('clientId', $update['body'], "PATCH response for {$providerId} leaks default `clientId` despite using `{$idField}`.");
+ }
+ if ($secretField !== 'clientSecret') {
+ $this->assertArrayNotHasKey('clientSecret', $update['body'], "PATCH response for {$providerId} leaks default `clientSecret` despite using `{$secretField}`.");
+ }
+
+ $get = $this->getOAuth2Provider($providerId);
+ $this->assertSame(200, $get['headers']['status-code']);
+ if ($idField !== 'clientId') {
+ $this->assertArrayNotHasKey('clientId', $get['body'], "GET response for {$providerId} leaks default `clientId` despite using `{$idField}`.");
+ }
+ if ($secretField !== 'clientSecret') {
+ $this->assertArrayNotHasKey('clientSecret', $get['body'], "GET response for {$providerId} leaks default `clientSecret` despite using `{$secretField}`.");
+ }
+
+ // Cleanup
+ $this->updateOAuth2($providerId, [
+ $idField => '',
+ $secretField => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ // =========================================================================
+ // Helpers
+ // =========================================================================
+
+ /**
+ * @param array $params
+ */
+ protected function updateOAuth2(string $provider, array $params, bool $authenticated = true): mixed
+ {
+ $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_PATCH,
+ '/project/oauth2/' . $provider,
+ $headers,
+ $params,
+ );
+ }
+
+ protected function getOAuth2Provider(string $providerId, bool $authenticated = true): mixed
+ {
+ $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_GET,
+ '/project/oauth2/' . $providerId,
+ $headers,
+ );
+ }
+
+ protected function listOAuth2Providers(?array $queries = null, ?bool $total = null, bool $authenticated = true): mixed
+ {
+ $params = [];
+
+ if ($queries !== null) {
+ $params['queries'] = $queries;
+ }
+
+ if ($total !== null) {
+ $params['total'] = $total;
+ }
+
+ $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_GET,
+ '/project/oauth2',
+ $headers,
+ $params,
+ );
+ }
+}
diff --git a/tests/e2e/Services/Project/OAuth2ConsoleClientTest.php b/tests/e2e/Services/Project/OAuth2ConsoleClientTest.php
new file mode 100644
index 0000000000..b5654ffe4b
--- /dev/null
+++ b/tests/e2e/Services/Project/OAuth2ConsoleClientTest.php
@@ -0,0 +1,14 @@
+markTestSkipped('GitHub OAuth2 credentials not configured (_TESTS_OAUTH2_GITHUB_CLIENT_ID, _TESTS_OAUTH2_GITHUB_CLIENT_SECRET)');
+ }
+
+ $consoleHeaders = [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'cookie' => 'a_session_console=' . $this->getRoot()['session'],
+ 'x-appwrite-project' => 'console',
+ 'x-appwrite-response-format' => '1.9.4',
+ ];
+
+ // Step 1: Create new organization (team)
+ $team = $this->client->call(Client::METHOD_POST, '/teams', $consoleHeaders, [
+ 'teamId' => ID::unique(),
+ 'name' => 'GitHub OAuth Org ' . uniqid(),
+ ]);
+ $this->assertSame(201, $team['headers']['status-code']);
+ $teamId = $team['body']['$id'];
+
+ // Step 2: Create new project
+ $project = $this->client->call(Client::METHOD_POST, '/projects', $consoleHeaders, [
+ 'projectId' => 'githuboauthapp', // Must be this ID, its used in redirect URL set in GitHub app configuration
+ 'name' => 'GitHub OAuth Project',
+ 'teamId' => $teamId,
+ 'region' => System::getEnv('_APP_REGION', 'default'),
+ ]);
+ $this->assertSame(201, $project['headers']['status-code']);
+ $newProjectId = $project['body']['$id'];
+
+ // Step 3: Configure GitHub provider on the new project via PATCH /v1/project/oauth2/github
+ $newProjectAdminHeaders = [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'cookie' => 'a_session_console=' . $this->getRoot()['session'],
+ 'x-appwrite-project' => $newProjectId,
+ 'x-appwrite-mode' => 'admin',
+ ];
+
+ $configResponse = $this->client->call(Client::METHOD_PATCH, '/project/oauth2/github', $newProjectAdminHeaders, [
+ 'clientId' => $clientId,
+ 'clientSecret' => $clientSecret,
+ 'enabled' => true,
+ ]);
+ $this->assertSame(200, $configResponse['headers']['status-code']);
+ $this->assertTrue($configResponse['body']['enabled']);
+ $this->assertSame($clientId, $configResponse['body']['clientId']);
+
+ // Step 4: Verify OAuth provider is enabled via GET /v1/projects/:projectId
+ $projectDetails = $this->client->call(Client::METHOD_GET, '/projects/' . $newProjectId, $consoleHeaders);
+ $this->assertSame(200, $projectDetails['headers']['status-code']);
+
+ $githubProvider = null;
+ foreach ($projectDetails['body']['oAuthProviders'] as $provider) {
+ if ($provider['key'] === 'github') {
+ $githubProvider = $provider;
+ break;
+ }
+ }
+ $this->assertNotNull($githubProvider, 'GitHub OAuth provider not found in project details');
+ $this->assertTrue($githubProvider['enabled']);
+ $this->assertSame($clientId, $githubProvider['appId']);
+ $this->assertSame('', $githubProvider['secret']); // Write only
+
+ // Step 5: Without client headers (no API key), go through the OAuth flow
+ $clientHeaders = [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $newProjectId,
+ ];
+
+ $oauthInit = $this->client->call(
+ Client::METHOD_GET,
+ '/account/sessions/oauth2/github',
+ $clientHeaders,
+ [
+ 'success' => 'http://localhost:4000/success',
+ 'failure' => 'http://localhost:4000/failure',
+ ],
+ followRedirects: false
+ );
+
+ $this->assertSame(301, $oauthInit['headers']['status-code']);
+ $this->assertArrayHasKey('location', $oauthInit['headers']);
+ $this->assertStringStartsWith('https://github.com/login/oauth/authorize', $oauthInit['headers']['location']);
+ $this->assertStringContainsString('client_id=' . \urlencode($clientId), $oauthInit['headers']['location']);
+ $this->assertStringContainsString('redirect_uri=', $oauthInit['headers']['location']);
+
+ // Follow the redirect to GitHub's authorization endpoint. With a real user agent, GitHub
+ // would prompt for login + app approval, then redirect back to Appwrite's callback with a
+ // valid `code`. Appwrite would then exchange the code, create the session and redirect to
+ // the success URL with the session cookie set.
+ $oauthClient = new Client();
+ $oauthClient->setEndpoint('');
+
+ $githubResponse = $oauthClient->call(
+ Client::METHOD_GET,
+ $oauthInit['headers']['location'],
+ [],
+ [],
+ decode: false,
+ followRedirects: false
+ );
+
+ // GitHub returns 200 (login HTML) or 302 (redirect to login) — both indicate the flow
+ // reached GitHub. Anything else means our redirect is malformed.
+ $this->assertContains($githubResponse['headers']['status-code'], [200, 302]);
+
+ // Cleanup: delete the project
+ $deleteProject = $this->client->call(Client::METHOD_DELETE, '/projects/' . $newProjectId, $consoleHeaders);
+ $this->assertSame(204, $deleteProject['headers']['status-code']);
+
+ // Cleanup: delete the organization (team)
+ $deleteTeam = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId, $consoleHeaders);
+ $this->assertSame(204, $deleteTeam['headers']['status-code']);
+ }
+}
diff --git a/tests/e2e/Services/Project/PoliciesBase.php b/tests/e2e/Services/Project/PoliciesBase.php
new file mode 100644
index 0000000000..43c09b55c3
--- /dev/null
+++ b/tests/e2e/Services/Project/PoliciesBase.php
@@ -0,0 +1,1186 @@
+ ['enabled'],
+ 'password-history' => ['total'],
+ 'password-personal-data' => ['enabled'],
+ 'session-alert' => ['enabled'],
+ 'session-duration' => ['duration'],
+ 'session-invalidation' => ['enabled'],
+ 'session-limit' => ['total'],
+ 'user-limit' => ['total'],
+ 'membership-privacy' => ['userId', 'userEmail', 'userPhone', 'userName', 'userMFA'],
+ ];
+
+ foreach ($expectedFields as $policyId => $fields) {
+ $response = $this->getPolicy($policyId);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame($policyId, $response['body']['$id']);
+
+ foreach ($fields as $field) {
+ $this->assertArrayHasKey($field, $response['body']);
+ }
+ }
+ }
+
+ public function testGetPolicyMatchesListPolicies(): void
+ {
+ $list = $this->listPolicies();
+
+ $this->assertSame(200, $list['headers']['status-code']);
+
+ $byId = [];
+ foreach ($list['body']['policies'] as $policy) {
+ $byId[$policy['$id']] = $policy;
+ }
+
+ foreach (\array_keys($byId) as $policyId) {
+ $response = $this->getPolicy($policyId);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame($byId[$policyId], $response['body']);
+ }
+ }
+
+ public function testGetPolicyReflectsUpdates(): void
+ {
+ $this->updatePasswordDictionaryPolicy(true);
+ $this->updatePasswordHistoryPolicy(5);
+ $this->updateSessionDurationPolicy(3600);
+ $this->updateMembershipPrivacyPolicy([
+ 'userId' => true,
+ 'userEmail' => true,
+ 'userPhone' => false,
+ 'userName' => true,
+ 'userMFA' => true,
+ ]);
+
+ $passwordDictionary = $this->getPolicy('password-dictionary');
+ $passwordHistory = $this->getPolicy('password-history');
+ $sessionDuration = $this->getPolicy('session-duration');
+ $membershipPrivacy = $this->getPolicy('membership-privacy');
+
+ $this->assertSame(200, $passwordDictionary['headers']['status-code']);
+ $this->assertSame(true, $passwordDictionary['body']['enabled']);
+
+ $this->assertSame(200, $passwordHistory['headers']['status-code']);
+ $this->assertSame(5, $passwordHistory['body']['total']);
+
+ $this->assertSame(200, $sessionDuration['headers']['status-code']);
+ $this->assertSame(3600, $sessionDuration['body']['duration']);
+
+ $this->assertSame(200, $membershipPrivacy['headers']['status-code']);
+ $this->assertSame(true, $membershipPrivacy['body']['userId']);
+ $this->assertSame(true, $membershipPrivacy['body']['userEmail']);
+ $this->assertSame(false, $membershipPrivacy['body']['userPhone']);
+ $this->assertSame(true, $membershipPrivacy['body']['userName']);
+ $this->assertSame(true, $membershipPrivacy['body']['userMFA']);
+
+ // Cleanup
+ $this->updatePasswordDictionaryPolicy(false);
+ $this->updatePasswordHistoryPolicy(null);
+ $this->updateSessionDurationPolicy(31536000);
+ $this->updateMembershipPrivacyPolicy([
+ 'userId' => false,
+ 'userEmail' => false,
+ 'userPhone' => false,
+ 'userName' => false,
+ 'userMFA' => false,
+ ]);
+ }
+
+ public function testGetPolicyWithoutAuthentication(): void
+ {
+ $response = $this->getPolicy('password-dictionary', authenticated: false);
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ public function testGetPolicyInvalidPolicyId(): void
+ {
+ $response = $this->getPolicy('invalid-policy');
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ // =========================================================================
+ // List Policies
+ // =========================================================================
+
+ public function testListPolicies(): void
+ {
+ $response = $this->listPolicies();
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertArrayHasKey('policies', $response['body']);
+ $this->assertArrayHasKey('total', $response['body']);
+ $this->assertIsArray($response['body']['policies']);
+ $this->assertIsInt($response['body']['total']);
+ $this->assertSame(9, $response['body']['total']);
+ $this->assertCount(9, $response['body']['policies']);
+
+ $policyIds = \array_column($response['body']['policies'], '$id');
+
+ $this->assertContains('password-dictionary', $policyIds);
+ $this->assertContains('password-history', $policyIds);
+ $this->assertContains('password-personal-data', $policyIds);
+ $this->assertContains('session-alert', $policyIds);
+ $this->assertContains('session-duration', $policyIds);
+ $this->assertContains('session-invalidation', $policyIds);
+ $this->assertContains('session-limit', $policyIds);
+ $this->assertContains('user-limit', $policyIds);
+ $this->assertContains('membership-privacy', $policyIds);
+ }
+
+ public function testListPoliciesResponseModel(): void
+ {
+ $response = $this->listPolicies();
+
+ $this->assertSame(200, $response['headers']['status-code']);
+
+ foreach ($response['body']['policies'] as $policy) {
+ $this->assertArrayHasKey('$id', $policy);
+ }
+
+ $byId = [];
+ foreach ($response['body']['policies'] as $policy) {
+ $byId[$policy['$id']] = $policy;
+ }
+
+ $this->assertArrayHasKey('enabled', $byId['password-dictionary']);
+ $this->assertArrayHasKey('total', $byId['password-history']);
+ $this->assertArrayHasKey('enabled', $byId['password-personal-data']);
+ $this->assertArrayHasKey('enabled', $byId['session-alert']);
+ $this->assertArrayHasKey('duration', $byId['session-duration']);
+ $this->assertArrayHasKey('enabled', $byId['session-invalidation']);
+ $this->assertArrayHasKey('total', $byId['session-limit']);
+ $this->assertArrayHasKey('total', $byId['user-limit']);
+ $this->assertArrayHasKey('userId', $byId['membership-privacy']);
+ $this->assertArrayHasKey('userEmail', $byId['membership-privacy']);
+ $this->assertArrayHasKey('userPhone', $byId['membership-privacy']);
+ $this->assertArrayHasKey('userName', $byId['membership-privacy']);
+ $this->assertArrayHasKey('userMFA', $byId['membership-privacy']);
+ }
+
+ public function testListPoliciesReflectsUpdates(): void
+ {
+ $this->updatePasswordDictionaryPolicy(true);
+ $this->updatePasswordHistoryPolicy(5);
+ $this->updateSessionDurationPolicy(3600);
+ $this->updateMembershipPrivacyPolicy([
+ 'userId' => true,
+ 'userEmail' => true,
+ 'userPhone' => false,
+ 'userName' => true,
+ 'userMFA' => true,
+ ]);
+
+ $response = $this->listPolicies();
+
+ $this->assertSame(200, $response['headers']['status-code']);
+
+ $byId = [];
+ foreach ($response['body']['policies'] as $policy) {
+ $byId[$policy['$id']] = $policy;
+ }
+
+ $this->assertSame(true, $byId['password-dictionary']['enabled']);
+ $this->assertSame(5, $byId['password-history']['total']);
+ $this->assertSame(3600, $byId['session-duration']['duration']);
+ $this->assertSame(true, $byId['membership-privacy']['userId']);
+ $this->assertSame(true, $byId['membership-privacy']['userEmail']);
+ $this->assertSame(false, $byId['membership-privacy']['userPhone']);
+ $this->assertSame(true, $byId['membership-privacy']['userName']);
+ $this->assertSame(true, $byId['membership-privacy']['userMFA']);
+
+ // Cleanup
+ $this->updatePasswordDictionaryPolicy(false);
+ $this->updatePasswordHistoryPolicy(null);
+ $this->updateSessionDurationPolicy(31536000);
+ $this->updateMembershipPrivacyPolicy([
+ 'userId' => false,
+ 'userEmail' => false,
+ 'userPhone' => false,
+ 'userName' => false,
+ 'userMFA' => false,
+ ]);
+ }
+
+ public function testListPoliciesTotalFalse(): void
+ {
+ $response = $this->listPolicies(total: false);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(0, $response['body']['total']);
+ $this->assertCount(9, $response['body']['policies']);
+ }
+
+ public function testListPoliciesWithLimit(): void
+ {
+ $response = $this->listPolicies([
+ Query::limit(1)->toString(),
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertCount(1, $response['body']['policies']);
+ $this->assertSame(9, $response['body']['total']);
+ }
+
+ public function testListPoliciesWithOffset(): void
+ {
+ $listAll = $this->listPolicies();
+ $this->assertSame(200, $listAll['headers']['status-code']);
+
+ $listOffset = $this->listPolicies([
+ Query::offset(1)->toString(),
+ ]);
+
+ $this->assertSame(200, $listOffset['headers']['status-code']);
+ $this->assertCount(\count($listAll['body']['policies']) - 1, $listOffset['body']['policies']);
+ $this->assertSame($listAll['body']['total'], $listOffset['body']['total']);
+ }
+
+ public function testListPoliciesWithoutAuthentication(): void
+ {
+ $response = $this->listPolicies(authenticated: false);
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ // =========================================================================
+ // Password Dictionary Policy
+ // =========================================================================
+
+ public function testUpdatePasswordDictionaryPolicyEnable(): void
+ {
+ $response = $this->updatePasswordDictionaryPolicy(true);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertSame(true, $response['body']['authPasswordDictionary']);
+
+ $project = $this->getProjectDocument();
+ $this->assertSame(200, $project['headers']['status-code']);
+ $this->assertSame(true, $project['body']['authPasswordDictionary']);
+
+ // Cleanup
+ $this->updatePasswordDictionaryPolicy(false);
+ }
+
+ public function testUpdatePasswordDictionaryPolicyDisable(): void
+ {
+ $this->updatePasswordDictionaryPolicy(true);
+
+ $response = $this->updatePasswordDictionaryPolicy(false);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(false, $response['body']['authPasswordDictionary']);
+
+ $project = $this->getProjectDocument();
+ $this->assertSame(200, $project['headers']['status-code']);
+ $this->assertSame(false, $project['body']['authPasswordDictionary']);
+ }
+
+ public function testUpdatePasswordDictionaryPolicyIdempotent(): void
+ {
+ $first = $this->updatePasswordDictionaryPolicy(true);
+ $this->assertSame(200, $first['headers']['status-code']);
+ $this->assertSame(true, $first['body']['authPasswordDictionary']);
+
+ $second = $this->updatePasswordDictionaryPolicy(true);
+ $this->assertSame(200, $second['headers']['status-code']);
+ $this->assertSame(true, $second['body']['authPasswordDictionary']);
+
+ // Cleanup
+ $this->updatePasswordDictionaryPolicy(false);
+ }
+
+ public function testUpdatePasswordDictionaryPolicyWithoutAuth(): void
+ {
+ $response = $this->updatePasswordDictionaryPolicy(true, false);
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ public function testUpdatePasswordDictionaryPolicyInvalidType(): void
+ {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders(), [
+ 'enabled' => 'not-a-boolean',
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdatePasswordDictionaryPolicyMissingParam(): void
+ {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders(), []);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ // =========================================================================
+ // Password History Policy
+ // =========================================================================
+
+ public function testUpdatePasswordHistoryPolicyEnable(): void
+ {
+ $response = $this->updatePasswordHistoryPolicy(5);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertSame(5, $response['body']['authPasswordHistory']);
+
+ $project = $this->getProjectDocument();
+ $this->assertSame(200, $project['headers']['status-code']);
+ $this->assertSame(5, $project['body']['authPasswordHistory']);
+
+ // Cleanup (disable by setting total to null which maps to 0)
+ $this->updatePasswordHistoryPolicy(null);
+ }
+
+ public function testUpdatePasswordHistoryPolicyMin(): void
+ {
+ $response = $this->updatePasswordHistoryPolicy(1);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(1, $response['body']['authPasswordHistory']);
+
+ // Cleanup
+ $this->updatePasswordHistoryPolicy(null);
+ }
+
+ public function testUpdatePasswordHistoryPolicyMax(): void
+ {
+ $response = $this->updatePasswordHistoryPolicy(5000);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(5000, $response['body']['authPasswordHistory']);
+
+ // Cleanup
+ $this->updatePasswordHistoryPolicy(null);
+ }
+
+ public function testUpdatePasswordHistoryPolicyDisable(): void
+ {
+ $this->updatePasswordHistoryPolicy(5);
+
+ $response = $this->updatePasswordHistoryPolicy(null);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(0, $response['body']['authPasswordHistory']);
+
+ $project = $this->getProjectDocument();
+ $this->assertSame(200, $project['headers']['status-code']);
+ $this->assertSame(0, $project['body']['authPasswordHistory']);
+ }
+
+ public function testUpdatePasswordHistoryPolicyBelowMin(): void
+ {
+ $response = $this->updatePasswordHistoryPolicy(0);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdatePasswordHistoryPolicyAboveMax(): void
+ {
+ $response = $this->updatePasswordHistoryPolicy(5001);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdatePasswordHistoryPolicyInvalidType(): void
+ {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $this->buildHeaders(), [
+ 'total' => 'not-a-number',
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdatePasswordHistoryPolicyMissingParam(): void
+ {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $this->buildHeaders(), []);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdatePasswordHistoryPolicyWithoutAuth(): void
+ {
+ $response = $this->updatePasswordHistoryPolicy(5, false);
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ // =========================================================================
+ // Password Personal Data Policy
+ // =========================================================================
+
+ public function testUpdatePasswordPersonalDataPolicyEnable(): void
+ {
+ $response = $this->updatePasswordPersonalDataPolicy(true);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertSame(true, $response['body']['authPersonalDataCheck']);
+
+ $project = $this->getProjectDocument();
+ $this->assertSame(200, $project['headers']['status-code']);
+ $this->assertSame(true, $project['body']['authPersonalDataCheck']);
+
+ // Cleanup
+ $this->updatePasswordPersonalDataPolicy(false);
+ }
+
+ public function testUpdatePasswordPersonalDataPolicyDisable(): void
+ {
+ $this->updatePasswordPersonalDataPolicy(true);
+
+ $response = $this->updatePasswordPersonalDataPolicy(false);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(false, $response['body']['authPersonalDataCheck']);
+
+ $project = $this->getProjectDocument();
+ $this->assertSame(200, $project['headers']['status-code']);
+ $this->assertSame(false, $project['body']['authPersonalDataCheck']);
+ }
+
+ public function testUpdatePasswordPersonalDataPolicyInvalidType(): void
+ {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $this->buildHeaders(), [
+ 'enabled' => 'not-a-boolean',
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdatePasswordPersonalDataPolicyMissingParam(): void
+ {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $this->buildHeaders(), []);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdatePasswordPersonalDataPolicyWithoutAuth(): void
+ {
+ $response = $this->updatePasswordPersonalDataPolicy(true, false);
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ // =========================================================================
+ // Session Alert Policy
+ // =========================================================================
+
+ public function testUpdateSessionAlertPolicyEnable(): void
+ {
+ $response = $this->updateSessionAlertPolicy(true);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertSame(true, $response['body']['authSessionAlerts']);
+
+ $project = $this->getProjectDocument();
+ $this->assertSame(200, $project['headers']['status-code']);
+ $this->assertSame(true, $project['body']['authSessionAlerts']);
+
+ // Cleanup
+ $this->updateSessionAlertPolicy(false);
+ }
+
+ public function testUpdateSessionAlertPolicyDisable(): void
+ {
+ $this->updateSessionAlertPolicy(true);
+
+ $response = $this->updateSessionAlertPolicy(false);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(false, $response['body']['authSessionAlerts']);
+
+ $project = $this->getProjectDocument();
+ $this->assertSame(200, $project['headers']['status-code']);
+ $this->assertSame(false, $project['body']['authSessionAlerts']);
+ }
+
+ public function testUpdateSessionAlertPolicyInvalidType(): void
+ {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $this->buildHeaders(), [
+ 'enabled' => 'not-a-boolean',
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSessionAlertPolicyMissingParam(): void
+ {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $this->buildHeaders(), []);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSessionAlertPolicyWithoutAuth(): void
+ {
+ $response = $this->updateSessionAlertPolicy(true, false);
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ // =========================================================================
+ // Session Duration Policy
+ // =========================================================================
+
+ public function testUpdateSessionDurationPolicy(): void
+ {
+ $response = $this->updateSessionDurationPolicy(3600);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertSame(3600, $response['body']['authDuration']);
+
+ $project = $this->getProjectDocument();
+ $this->assertSame(200, $project['headers']['status-code']);
+ $this->assertSame(3600, $project['body']['authDuration']);
+
+ // Cleanup (reset to default 1 year)
+ $this->updateSessionDurationPolicy(31536000);
+ }
+
+ public function testUpdateSessionDurationPolicyMin(): void
+ {
+ $response = $this->updateSessionDurationPolicy(5);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(5, $response['body']['authDuration']);
+
+ // Cleanup
+ $this->updateSessionDurationPolicy(31536000);
+ }
+
+ public function testUpdateSessionDurationPolicyMax(): void
+ {
+ $response = $this->updateSessionDurationPolicy(31536000);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(31536000, $response['body']['authDuration']);
+ }
+
+ public function testUpdateSessionDurationPolicyBelowMin(): void
+ {
+ $response = $this->updateSessionDurationPolicy(4);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSessionDurationPolicyAboveMax(): void
+ {
+ $response = $this->updateSessionDurationPolicy(31536001);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSessionDurationPolicyInvalidType(): void
+ {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $this->buildHeaders(), [
+ 'duration' => 'not-a-number',
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSessionDurationPolicyMissingParam(): void
+ {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $this->buildHeaders(), []);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSessionDurationPolicyWithoutAuth(): void
+ {
+ $response = $this->updateSessionDurationPolicy(3600, false);
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ // =========================================================================
+ // Session Invalidation Policy
+ // =========================================================================
+
+ public function testUpdateSessionInvalidationPolicyEnable(): void
+ {
+ $response = $this->updateSessionInvalidationPolicy(true);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertSame(true, $response['body']['authInvalidateSessions']);
+
+ $project = $this->getProjectDocument();
+ $this->assertSame(200, $project['headers']['status-code']);
+ $this->assertSame(true, $project['body']['authInvalidateSessions']);
+
+ // Cleanup
+ $this->updateSessionInvalidationPolicy(false);
+ }
+
+ public function testUpdateSessionInvalidationPolicyDisable(): void
+ {
+ $this->updateSessionInvalidationPolicy(true);
+
+ $response = $this->updateSessionInvalidationPolicy(false);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(false, $response['body']['authInvalidateSessions']);
+
+ $project = $this->getProjectDocument();
+ $this->assertSame(200, $project['headers']['status-code']);
+ $this->assertSame(false, $project['body']['authInvalidateSessions']);
+ }
+
+ public function testUpdateSessionInvalidationPolicyInvalidType(): void
+ {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $this->buildHeaders(), [
+ 'enabled' => 'not-a-boolean',
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSessionInvalidationPolicyMissingParam(): void
+ {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $this->buildHeaders(), []);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSessionInvalidationPolicyWithoutAuth(): void
+ {
+ $response = $this->updateSessionInvalidationPolicy(true, false);
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ // =========================================================================
+ // Session Limit Policy
+ // =========================================================================
+
+ public function testUpdateSessionLimitPolicy(): void
+ {
+ $response = $this->updateSessionLimitPolicy(5);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertSame(5, $response['body']['authSessionsLimit']);
+
+ $project = $this->getProjectDocument();
+ $this->assertSame(200, $project['headers']['status-code']);
+ $this->assertSame(5, $project['body']['authSessionsLimit']);
+
+ // Cleanup (reset to default)
+ $this->updateSessionLimitPolicy(10);
+ }
+
+ public function testUpdateSessionLimitPolicyMin(): void
+ {
+ $response = $this->updateSessionLimitPolicy(1);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(1, $response['body']['authSessionsLimit']);
+
+ // Cleanup
+ $this->updateSessionLimitPolicy(10);
+ }
+
+ public function testUpdateSessionLimitPolicyMax(): void
+ {
+ $response = $this->updateSessionLimitPolicy(5000);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(5000, $response['body']['authSessionsLimit']);
+
+ // Cleanup
+ $this->updateSessionLimitPolicy(10);
+ }
+
+ public function testUpdateSessionLimitPolicyDisable(): void
+ {
+ $response = $this->updateSessionLimitPolicy(null);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(0, $response['body']['authSessionsLimit']);
+
+ // Cleanup
+ $this->updateSessionLimitPolicy(10);
+ }
+
+ public function testUpdateSessionLimitPolicyBelowMin(): void
+ {
+ $response = $this->updateSessionLimitPolicy(0);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSessionLimitPolicyAboveMax(): void
+ {
+ $response = $this->updateSessionLimitPolicy(5001);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSessionLimitPolicyInvalidType(): void
+ {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $this->buildHeaders(), [
+ 'total' => 'not-a-number',
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSessionLimitPolicyMissingParam(): void
+ {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $this->buildHeaders(), []);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSessionLimitPolicyWithoutAuth(): void
+ {
+ $response = $this->updateSessionLimitPolicy(5, false);
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ // =========================================================================
+ // User Limit Policy
+ // =========================================================================
+
+ public function testUpdateUserLimitPolicy(): void
+ {
+ $response = $this->updateUserLimitPolicy(100);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertSame(100, $response['body']['authLimit']);
+
+ $project = $this->getProjectDocument();
+ $this->assertSame(200, $project['headers']['status-code']);
+ $this->assertSame(100, $project['body']['authLimit']);
+
+ // Cleanup
+ $this->updateUserLimitPolicy(null);
+ }
+
+ public function testUpdateUserLimitPolicyMin(): void
+ {
+ $response = $this->updateUserLimitPolicy(1);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(1, $response['body']['authLimit']);
+
+ // Cleanup
+ $this->updateUserLimitPolicy(null);
+ }
+
+ public function testUpdateUserLimitPolicyMax(): void
+ {
+ $response = $this->updateUserLimitPolicy(5000);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(5000, $response['body']['authLimit']);
+
+ // Cleanup
+ $this->updateUserLimitPolicy(null);
+ }
+
+ public function testUpdateUserLimitPolicyDisable(): void
+ {
+ $this->updateUserLimitPolicy(100);
+
+ $response = $this->updateUserLimitPolicy(null);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(0, $response['body']['authLimit']);
+
+ $project = $this->getProjectDocument();
+ $this->assertSame(200, $project['headers']['status-code']);
+ $this->assertSame(0, $project['body']['authLimit']);
+ }
+
+ public function testUpdateUserLimitPolicyBelowMin(): void
+ {
+ $response = $this->updateUserLimitPolicy(0);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateUserLimitPolicyAboveMax(): void
+ {
+ $response = $this->updateUserLimitPolicy(5001);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateUserLimitPolicyInvalidType(): void
+ {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $this->buildHeaders(), [
+ 'total' => 'not-a-number',
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateUserLimitPolicyMissingParam(): void
+ {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $this->buildHeaders(), []);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateUserLimitPolicyWithoutAuth(): void
+ {
+ $response = $this->updateUserLimitPolicy(100, false);
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ // =========================================================================
+ // Membership Privacy Policy
+ // =========================================================================
+
+ public function testUpdateMembershipPrivacyPolicyAllEnabled(): void
+ {
+ $response = $this->updateMembershipPrivacyPolicy([
+ 'userId' => true,
+ 'userEmail' => true,
+ 'userPhone' => true,
+ 'userName' => true,
+ 'userMFA' => true,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertSame(true, $response['body']['authMembershipsUserId']);
+ $this->assertSame(true, $response['body']['authMembershipsUserEmail']);
+ $this->assertSame(true, $response['body']['authMembershipsUserPhone']);
+ $this->assertSame(true, $response['body']['authMembershipsUserName']);
+ $this->assertSame(true, $response['body']['authMembershipsMfa']);
+
+ $project = $this->getProjectDocument();
+ $this->assertSame(200, $project['headers']['status-code']);
+ $this->assertSame(true, $project['body']['authMembershipsUserId']);
+ $this->assertSame(true, $project['body']['authMembershipsUserEmail']);
+ $this->assertSame(true, $project['body']['authMembershipsUserPhone']);
+ $this->assertSame(true, $project['body']['authMembershipsUserName']);
+ $this->assertSame(true, $project['body']['authMembershipsMfa']);
+ }
+
+ public function testUpdateMembershipPrivacyPolicyAllDisabled(): void
+ {
+ $response = $this->updateMembershipPrivacyPolicy([
+ 'userId' => false,
+ 'userEmail' => false,
+ 'userPhone' => false,
+ 'userName' => false,
+ 'userMFA' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(false, $response['body']['authMembershipsUserId']);
+ $this->assertSame(false, $response['body']['authMembershipsUserEmail']);
+ $this->assertSame(false, $response['body']['authMembershipsUserPhone']);
+ $this->assertSame(false, $response['body']['authMembershipsUserName']);
+ $this->assertSame(false, $response['body']['authMembershipsMfa']);
+
+ $project = $this->getProjectDocument();
+ $this->assertSame(200, $project['headers']['status-code']);
+ $this->assertSame(false, $project['body']['authMembershipsUserId']);
+ $this->assertSame(false, $project['body']['authMembershipsUserEmail']);
+ $this->assertSame(false, $project['body']['authMembershipsUserPhone']);
+ $this->assertSame(false, $project['body']['authMembershipsUserName']);
+ $this->assertSame(false, $project['body']['authMembershipsMfa']);
+
+ // Cleanup (restore defaults)
+ $this->updateMembershipPrivacyPolicy([
+ 'userId' => true,
+ 'userEmail' => true,
+ 'userPhone' => true,
+ 'userName' => true,
+ 'userMFA' => true,
+ ]);
+ }
+
+ public function testUpdateMembershipPrivacyPolicyMixed(): void
+ {
+ $response = $this->updateMembershipPrivacyPolicy([
+ 'userId' => true,
+ 'userEmail' => false,
+ 'userPhone' => true,
+ 'userName' => false,
+ 'userMFA' => true,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(true, $response['body']['authMembershipsUserId']);
+ $this->assertSame(false, $response['body']['authMembershipsUserEmail']);
+ $this->assertSame(true, $response['body']['authMembershipsUserPhone']);
+ $this->assertSame(false, $response['body']['authMembershipsUserName']);
+ $this->assertSame(true, $response['body']['authMembershipsMfa']);
+
+ // Cleanup
+ $this->updateMembershipPrivacyPolicy([
+ 'userId' => true,
+ 'userEmail' => true,
+ 'userPhone' => true,
+ 'userName' => true,
+ 'userMFA' => true,
+ ]);
+ }
+
+ public function testUpdateMembershipPrivacyPolicyIndividualFields(): void
+ {
+ // Start from a known baseline where every field is enabled
+ $this->updateMembershipPrivacyPolicy([
+ 'userId' => true,
+ 'userEmail' => true,
+ 'userPhone' => true,
+ 'userName' => true,
+ 'userMFA' => true,
+ ]);
+
+ $fields = [
+ 'userId' => 'authMembershipsUserId',
+ 'userEmail' => 'authMembershipsUserEmail',
+ 'userPhone' => 'authMembershipsUserPhone',
+ 'userName' => 'authMembershipsUserName',
+ 'userMFA' => 'authMembershipsMfa',
+ ];
+
+ // Each field can be toggled individually without clobbering the others
+ foreach ($fields as $param => $attribute) {
+ $response = $this->updateMembershipPrivacyPolicy([$param => false]);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(false, $response['body'][$attribute]);
+
+ foreach ($fields as $otherParam => $otherAttribute) {
+ if ($otherParam === $param) {
+ continue;
+ }
+ $this->assertSame(true, $response['body'][$otherAttribute], $otherAttribute . ' should be untouched while only ' . $param . ' was updated');
+ }
+
+ // Restore the field before the next iteration
+ $restore = $this->updateMembershipPrivacyPolicy([$param => true]);
+ $this->assertSame(200, $restore['headers']['status-code']);
+ $this->assertSame(true, $restore['body'][$attribute]);
+ }
+ }
+
+ public function testUpdateMembershipPrivacyPolicyMultipleFields(): void
+ {
+ $this->updateMembershipPrivacyPolicy([
+ 'userId' => true,
+ 'userEmail' => true,
+ 'userPhone' => true,
+ 'userName' => true,
+ 'userMFA' => true,
+ ]);
+
+ $response = $this->updateMembershipPrivacyPolicy([
+ 'userId' => false,
+ 'userPhone' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(false, $response['body']['authMembershipsUserId']);
+ $this->assertSame(false, $response['body']['authMembershipsUserPhone']);
+ $this->assertSame(true, $response['body']['authMembershipsUserEmail']);
+ $this->assertSame(true, $response['body']['authMembershipsUserName']);
+ $this->assertSame(true, $response['body']['authMembershipsMfa']);
+
+ // Cleanup
+ $this->updateMembershipPrivacyPolicy([
+ 'userId' => true,
+ 'userEmail' => true,
+ 'userPhone' => true,
+ 'userName' => true,
+ 'userMFA' => true,
+ ]);
+ }
+
+ public function testUpdateMembershipPrivacyPolicyEmptyBody(): void
+ {
+ // PATCH with no fields should be a no-op, leaving state unchanged
+ $this->updateMembershipPrivacyPolicy([
+ 'userId' => false,
+ 'userEmail' => false,
+ 'userPhone' => false,
+ 'userName' => false,
+ 'userMFA' => false,
+ ]);
+
+ $response = $this->updateMembershipPrivacyPolicy([]);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(false, $response['body']['authMembershipsUserId']);
+ $this->assertSame(false, $response['body']['authMembershipsUserEmail']);
+ $this->assertSame(false, $response['body']['authMembershipsUserPhone']);
+ $this->assertSame(false, $response['body']['authMembershipsUserName']);
+ $this->assertSame(false, $response['body']['authMembershipsMfa']);
+
+ // Cleanup
+ $this->updateMembershipPrivacyPolicy([
+ 'userId' => true,
+ 'userEmail' => true,
+ 'userPhone' => true,
+ 'userName' => true,
+ 'userMFA' => true,
+ ]);
+ }
+
+ public function testUpdateMembershipPrivacyPolicyInvalidType(): void
+ {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders(), [
+ 'userId' => 'not-a-boolean',
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateMembershipPrivacyPolicyWithoutAuth(): void
+ {
+ $response = $this->updateMembershipPrivacyPolicy([
+ 'userId' => true,
+ 'userEmail' => true,
+ 'userPhone' => true,
+ 'userName' => true,
+ 'userMFA' => true,
+ ], false);
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ // =========================================================================
+ // Helpers
+ // =========================================================================
+
+ protected function buildHeaders(bool $authenticated = true): array
+ {
+ $headers = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ];
+
+ if ($authenticated) {
+ $headers = array_merge($headers, $this->getHeaders(), [
+ 'x-appwrite-response-format' => '1.9.4',
+ ]);
+ }
+
+ return $headers;
+ }
+
+ protected function getProjectDocument(): array
+ {
+ return $this->client->call(Client::METHOD_GET, '/projects/' . $this->getProject()['$id'], [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => 'console',
+ 'cookie' => 'a_session_console=' . $this->getRoot()['session'],
+ 'x-appwrite-response-format' => '1.9.4',
+ ]);
+ }
+
+ protected function listPolicies(?array $queries = null, ?bool $total = null, bool $authenticated = true): mixed
+ {
+ $params = [];
+
+ if ($queries !== null) {
+ $params['queries'] = $queries;
+ }
+
+ if ($total !== null) {
+ $params['total'] = $total;
+ }
+
+ return $this->client->call(Client::METHOD_GET, '/project/policies', $this->buildHeaders($authenticated), $params);
+ }
+
+ protected function getPolicy(string $policyId, bool $authenticated = true): mixed
+ {
+ return $this->client->call(Client::METHOD_GET, '/project/policies/' . $policyId, $this->buildHeaders($authenticated));
+ }
+
+ protected function updatePasswordDictionaryPolicy(bool $enabled, bool $authenticated = true): mixed
+ {
+ return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders($authenticated), [
+ 'enabled' => $enabled,
+ ]);
+ }
+
+ protected function updatePasswordHistoryPolicy(?int $total, bool $authenticated = true): mixed
+ {
+ return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $this->buildHeaders($authenticated), [
+ 'total' => $total,
+ ]);
+ }
+
+ protected function updatePasswordPersonalDataPolicy(bool $enabled, bool $authenticated = true): mixed
+ {
+ return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $this->buildHeaders($authenticated), [
+ 'enabled' => $enabled,
+ ]);
+ }
+
+ protected function updateSessionAlertPolicy(bool $enabled, bool $authenticated = true): mixed
+ {
+ return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $this->buildHeaders($authenticated), [
+ 'enabled' => $enabled,
+ ]);
+ }
+
+ protected function updateSessionDurationPolicy(int $duration, bool $authenticated = true): mixed
+ {
+ return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $this->buildHeaders($authenticated), [
+ 'duration' => $duration,
+ ]);
+ }
+
+ protected function updateSessionInvalidationPolicy(bool $enabled, bool $authenticated = true): mixed
+ {
+ return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $this->buildHeaders($authenticated), [
+ 'enabled' => $enabled,
+ ]);
+ }
+
+ protected function updateSessionLimitPolicy(?int $total, bool $authenticated = true): mixed
+ {
+ return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $this->buildHeaders($authenticated), [
+ 'total' => $total,
+ ]);
+ }
+
+ protected function updateUserLimitPolicy(?int $total, bool $authenticated = true): mixed
+ {
+ return $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $this->buildHeaders($authenticated), [
+ 'total' => $total,
+ ]);
+ }
+
+ /**
+ * @param array $params
+ */
+ protected function updateMembershipPrivacyPolicy(array $params, bool $authenticated = true): mixed
+ {
+ return $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders($authenticated), $params);
+ }
+}
diff --git a/tests/e2e/Services/Project/PoliciesConsoleClientTest.php b/tests/e2e/Services/Project/PoliciesConsoleClientTest.php
new file mode 100644
index 0000000000..2db8e57a35
--- /dev/null
+++ b/tests/e2e/Services/Project/PoliciesConsoleClientTest.php
@@ -0,0 +1,14 @@
+getProject()['$id'];
+ $apiKey = $this->getProject()['apiKey'];
+
+ $serverHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $apiKey,
+ 'x-appwrite-response-format' => '1.9.4',
+ ];
+
+ // Step 1: Configure privacy to false
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $serverHeaders, [
+ 'userId' => false,
+ 'userEmail' => false,
+ 'userPhone' => false,
+ 'userName' => false,
+ 'userMFA' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertFalse($response['body']['authMembershipsUserId']);
+ $this->assertFalse($response['body']['authMembershipsUserEmail']);
+ $this->assertFalse($response['body']['authMembershipsUserPhone']);
+ $this->assertFalse($response['body']['authMembershipsUserName']);
+ $this->assertFalse($response['body']['authMembershipsMfa']);
+
+ // Step 2: Setup two users
+ $user1Email = 'user1_' . uniqid() . '@localhost.test';
+ $user1Name = 'Alice Anderson';
+ $user1Phone = '+12025550101';
+ $password = 'password1234';
+
+ $user1 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [
+ 'userId' => ID::unique(),
+ 'email' => $user1Email,
+ 'password' => $password,
+ 'name' => $user1Name,
+ ]);
+ $this->assertSame(201, $user1['headers']['status-code']);
+ $user1Id = $user1['body']['$id'];
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $user1Id . '/phone', $serverHeaders, [
+ 'number' => $user1Phone,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+
+ $user2Email = 'user2_' . uniqid() . '@localhost.test';
+ $user2Name = 'Bob Baker';
+ $user2Phone = '+12025550102';
+
+ $user2 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [
+ 'userId' => ID::unique(),
+ 'email' => $user2Email,
+ 'password' => $password,
+ 'name' => $user2Name,
+ ]);
+ $this->assertSame(201, $user2['headers']['status-code']);
+ $user2Id = $user2['body']['$id'];
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $user2Id . '/phone', $serverHeaders, [
+ 'number' => $user2Phone,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+
+ // Step 3: Create team and add both users as members
+ $team = $this->client->call(Client::METHOD_POST, '/teams', $serverHeaders, [
+ 'teamId' => ID::unique(),
+ 'name' => 'Privacy Team',
+ 'roles' => ['member'],
+ ]);
+ $this->assertSame(201, $team['headers']['status-code']);
+ $teamId = $team['body']['$id'];
+
+ $membership1 = $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', $serverHeaders, [
+ 'userId' => $user1Id,
+ 'roles' => ['member'],
+ ]);
+ $this->assertSame(201, $membership1['headers']['status-code']);
+ $this->assertTrue($membership1['body']['confirm']);
+
+ $membership2 = $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', $serverHeaders, [
+ 'userId' => $user2Id,
+ 'roles' => ['member'],
+ ]);
+ $this->assertSame(201, $membership2['headers']['status-code']);
+ $this->assertTrue($membership2['body']['confirm']);
+
+ // Step 4: Sign in as user1 and list memberships with privacy disabled
+ $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], [
+ 'email' => $user1Email,
+ 'password' => $password,
+ ]);
+ $this->assertSame(201, $session['headers']['status-code']);
+ $user1Session = $session['cookies']['a_session_' . $projectId];
+
+ $clientHeaders = [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'cookie' => 'a_session_' . $projectId . '=' . $user1Session,
+ ];
+
+ $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamId . '/memberships', $clientHeaders);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(2, $response['body']['total']);
+ $this->assertCount(2, $response['body']['memberships']);
+
+ foreach ($response['body']['memberships'] as $membership) {
+ $this->assertSame('', $membership['userName']);
+ $this->assertSame('', $membership['userEmail']);
+ $this->assertSame('', $membership['userPhone']);
+ $this->assertSame('', $membership['userId']);
+ $this->assertFalse($membership['mfa']);
+ }
+
+ // Step 5: Update privacy to true
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $serverHeaders, [
+ 'userId' => true,
+ 'userEmail' => true,
+ 'userPhone' => true,
+ 'userName' => true,
+ 'userMFA' => true,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertTrue($response['body']['authMembershipsUserId']);
+ $this->assertTrue($response['body']['authMembershipsUserEmail']);
+ $this->assertTrue($response['body']['authMembershipsUserPhone']);
+ $this->assertTrue($response['body']['authMembershipsUserName']);
+ $this->assertTrue($response['body']['authMembershipsMfa']);
+
+ // Step 6: List memberships with privacy enabled - user details exposed
+ $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamId . '/memberships', $clientHeaders);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(2, $response['body']['total']);
+ $this->assertCount(2, $response['body']['memberships']);
+
+ $membershipsByUser = [];
+ foreach ($response['body']['memberships'] as $membership) {
+ $membershipsByUser[$membership['userId']] = $membership;
+ }
+
+ $this->assertArrayHasKey($user1Id, $membershipsByUser);
+ $this->assertSame($user1Id, $membershipsByUser[$user1Id]['userId']);
+ $this->assertSame($user1Name, $membershipsByUser[$user1Id]['userName']);
+ $this->assertSame($user1Email, $membershipsByUser[$user1Id]['userEmail']);
+ $this->assertSame($user1Phone, $membershipsByUser[$user1Id]['userPhone']);
+ $this->assertFalse($membershipsByUser[$user1Id]['mfa']);
+
+ $this->assertArrayHasKey($user2Id, $membershipsByUser);
+ $this->assertSame($user2Id, $membershipsByUser[$user2Id]['userId']);
+ $this->assertSame($user2Name, $membershipsByUser[$user2Id]['userName']);
+ $this->assertSame($user2Email, $membershipsByUser[$user2Id]['userEmail']);
+ $this->assertSame($user2Phone, $membershipsByUser[$user2Id]['userPhone']);
+ $this->assertFalse($membershipsByUser[$user2Id]['mfa']);
+ }
+}
diff --git a/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php
new file mode 100644
index 0000000000..0da64eb50b
--- /dev/null
+++ b/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php
@@ -0,0 +1,69 @@
+getProject()['$id'];
+ $apiKey = $this->getProject()['apiKey'];
+
+ $serverHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $apiKey,
+ 'x-appwrite-response-format' => '1.9.4',
+ ];
+
+ // "password" is the top entry in the common-passwords dictionary and is 8 chars (min length).
+ $commonPassword = 'football';
+
+ // Step 1: Disable password dictionary policy
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $serverHeaders, [
+ 'enabled' => false,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertFalse($response['body']['authPasswordDictionary']);
+
+ // Step 2: Create user with common password - should succeed
+ $user1 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [
+ 'userId' => ID::unique(),
+ 'email' => 'dict_off_' . uniqid() . '@localhost.test',
+ 'password' => $commonPassword,
+ 'name' => 'Dictionary Off User',
+ ]);
+ $this->assertSame(201, $user1['headers']['status-code']);
+ $this->assertNotEmpty($user1['body']['$id']);
+
+ // Step 3: Enable password dictionary policy
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $serverHeaders, [
+ 'enabled' => true,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertTrue($response['body']['authPasswordDictionary']);
+
+ // Step 4: Creating another user with the common password must fail
+ $user2 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [
+ 'userId' => ID::unique(),
+ 'email' => 'dict_on_' . uniqid() . '@localhost.test',
+ 'password' => $commonPassword,
+ 'name' => 'Dictionary On User',
+ ]);
+ $this->assertSame(400, $user2['headers']['status-code']);
+
+ // Cleanup: disable policy
+ $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $serverHeaders, [
+ 'enabled' => false,
+ ]);
+ }
+}
diff --git a/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php
new file mode 100644
index 0000000000..1027d88222
--- /dev/null
+++ b/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php
@@ -0,0 +1,153 @@
+getProject()['$id'];
+ $apiKey = $this->getProject()['apiKey'];
+
+ $serverHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $apiKey,
+ 'x-appwrite-response-format' => '1.9.4',
+ ];
+
+ // Step 1: Enable password history policy with limit 3
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $serverHeaders, [
+ 'total' => 3,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(3, $response['body']['authPasswordHistory']);
+
+ $firstPassword = 'firstpassword';
+ $secondPassword = 'secondpassword';
+ $thirdPassword = 'thirdpassword';
+ $fourthPassword = 'fourthpassword';
+
+ // Step 2: Sign up user with firstpassword (policy on, so signup populates history)
+ $email = 'history_' . uniqid() . '@localhost.test';
+ $userId = ID::unique();
+
+ $account = $this->client->call(Client::METHOD_POST, '/account', [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], [
+ 'userId' => $userId,
+ 'email' => $email,
+ 'password' => $firstPassword,
+ 'name' => 'History User',
+ ]);
+ $this->assertSame(201, $account['headers']['status-code']);
+
+ $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], [
+ 'email' => $email,
+ 'password' => $firstPassword,
+ ]);
+ $this->assertSame(201, $session['headers']['status-code']);
+ $sessionCookie = $session['cookies']['a_session_' . $projectId];
+
+ $clientHeaders = [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie,
+ ];
+
+ // Change password: first -> second
+ $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [
+ 'password' => $secondPassword,
+ 'oldPassword' => $firstPassword,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+
+ // Change password: second -> third
+ $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [
+ 'password' => $thirdPassword,
+ 'oldPassword' => $secondPassword,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+
+ // Step 3: Attempt to reuse each of the 3 previous passwords - all should fail
+ foreach ([$firstPassword, $secondPassword, $thirdPassword] as $reused) {
+ $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [
+ 'password' => $reused,
+ 'oldPassword' => $thirdPassword,
+ ]);
+ $this->assertSame(400, $response['headers']['status-code'], 'Reusing password "' . $reused . '" should be blocked by history policy');
+ $this->assertSame('password_recently_used', $response['body']['type']);
+ }
+
+ // Step 4: Setting fourthpassword succeeds
+ $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [
+ 'password' => $fourthPassword,
+ 'oldPassword' => $thirdPassword,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+
+ // Verify the new password works by signing in again
+ $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], [
+ 'email' => $email,
+ 'password' => $fourthPassword,
+ ]);
+ $this->assertSame(201, $session['headers']['status-code']);
+
+ // Step 5: Disable password history policy
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $serverHeaders, [
+ 'total' => null,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(0, $response['body']['authPasswordHistory']);
+
+ // Step 6: With policy off, reusing any previous password should succeed, as should setting a brand new one.
+ // oldPassword must match current password, so walk through each previous password sequentially.
+ $fifthPassword = 'fifthpassword';
+ $chain = [
+ [$fourthPassword, $firstPassword],
+ [$firstPassword, $secondPassword],
+ [$secondPassword, $thirdPassword],
+ [$thirdPassword, $fourthPassword],
+ [$fourthPassword, $fifthPassword],
+ ];
+
+ foreach ($chain as [$current, $next]) {
+ $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [
+ 'password' => $next,
+ 'oldPassword' => $current,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code'], 'Changing password from "' . $current . '" to "' . $next . '" should succeed with history policy disabled');
+ }
+
+ // Verify the final password works by signing in
+ $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], [
+ 'email' => $email,
+ 'password' => $fifthPassword,
+ ]);
+ $this->assertSame(201, $session['headers']['status-code']);
+ }
+}
diff --git a/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php
new file mode 100644
index 0000000000..ebb94a2631
--- /dev/null
+++ b/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php
@@ -0,0 +1,105 @@
+getProject()['$id'];
+ $apiKey = $this->getProject()['apiKey'];
+
+ $serverHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $apiKey,
+ 'x-appwrite-response-format' => '1.9.4',
+ ];
+
+ $setPersonalData = function (bool $enabled) use ($serverHeaders): void {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $serverHeaders, [
+ 'enabled' => $enabled,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame($enabled, $response['body']['authPersonalDataCheck']);
+ };
+
+ $buildCases = function (): array {
+ $suffix = \uniqid();
+ $userId = 'personaluser' . $suffix;
+ $emailLocal = 'personalmail' . $suffix;
+ $email = $emailLocal . '@localhost.test';
+ $name = 'Personalname' . $suffix;
+ $phone = '+12025550' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT);
+
+ return [
+ 'userId' => [
+ 'userId' => $userId,
+ 'email' => 'safe_' . $suffix . '@localhost.test',
+ 'phone' => '+12025559' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT),
+ 'name' => 'Safe Name',
+ 'password' => $userId . 'extra',
+ ],
+ 'email' => [
+ 'userId' => 'safeid' . $suffix,
+ 'email' => $email,
+ 'phone' => '+12025558' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT),
+ 'name' => 'Safe Name',
+ 'password' => 'prefix_' . $emailLocal . '_suffix',
+ ],
+ 'name' => [
+ 'userId' => 'safeid2' . $suffix,
+ 'email' => 'safename_' . $suffix . '@localhost.test',
+ 'phone' => '+12025557' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT),
+ 'name' => $name,
+ 'password' => 'prefix' . $name . 'xyz',
+ ],
+ 'phone' => [
+ 'userId' => 'safeid3' . $suffix,
+ 'email' => 'safephone_' . $suffix . '@localhost.test',
+ 'phone' => $phone,
+ 'name' => 'Safe Name',
+ 'password' => 'prefix' . \str_replace('+', '', $phone) . 'xyz',
+ ],
+ ];
+ };
+
+ $createUser = function (array $params) use ($serverHeaders): array {
+ return $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [
+ 'userId' => $params['userId'],
+ 'email' => $params['email'],
+ 'phone' => $params['phone'],
+ 'password' => $params['password'],
+ 'name' => $params['name'],
+ ]);
+ };
+
+ // Step 1: Enable password personal data policy
+ $setPersonalData(true);
+
+ // Step 2: Each of the four personal-data fields in the password must block user creation
+ foreach ($buildCases() as $field => $params) {
+ $response = $createUser($params);
+ $this->assertSame(400, $response['headers']['status-code'], 'Password containing ' . $field . ' should be rejected');
+ $this->assertSame('password_personal_data', $response['body']['type']);
+ }
+
+ // Step 3: Disable password personal data policy
+ $setPersonalData(false);
+
+ // Step 4: The same categories of passwords should now be accepted (fresh data to avoid uniqueness conflicts)
+ foreach ($buildCases() as $field => $params) {
+ $response = $createUser($params);
+ $this->assertSame(201, $response['headers']['status-code'], 'Password containing ' . $field . ' should be accepted with policy disabled');
+ $this->assertSame($params['userId'], $response['body']['$id']);
+ }
+ }
+}
diff --git a/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php
new file mode 100644
index 0000000000..3e847b9ddb
--- /dev/null
+++ b/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php
@@ -0,0 +1,122 @@
+getProject()['$id'];
+ $apiKey = $this->getProject()['apiKey'];
+ $password = 'password1234';
+
+ $serverHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $apiKey,
+ 'x-appwrite-response-format' => '1.9.4',
+ ];
+
+ $publicHeaders = [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ];
+
+ $setSessionAlert = function (bool $enabled) use ($serverHeaders): void {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $serverHeaders, [
+ 'enabled' => $enabled,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame($enabled, $response['body']['authSessionAlerts']);
+ };
+
+ $createUser = function (string $email) use ($serverHeaders, $password): void {
+ $response = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [
+ 'userId' => ID::unique(),
+ 'email' => $email,
+ 'password' => $password,
+ 'name' => 'Alert User',
+ ]);
+ $this->assertSame(201, $response['headers']['status-code']);
+ };
+
+ $createSession = function (string $email) use ($publicHeaders, $password): void {
+ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [
+ 'email' => $email,
+ 'password' => $password,
+ ]);
+ $this->assertSame(201, $response['headers']['status-code']);
+ };
+
+ $countEmailsTo = function (string $address): int {
+ $emails = \json_decode(\file_get_contents('http://maildev:1080/email'), true) ?? [];
+ $count = 0;
+ foreach ($emails as $email) {
+ foreach ($email['to'] ?? [] as $recipient) {
+ if (($recipient['address'] ?? '') === $address) {
+ $count++;
+ }
+ }
+ }
+ return $count;
+ };
+
+ $assertEmailCountStays = function (string $address, int $expected, int $seconds) use ($countEmailsTo): void {
+ $deadline = \microtime(true) + $seconds;
+ while (\microtime(true) < $deadline) {
+ $this->assertSame($expected, $countEmailsTo($address), 'Unexpected email count for ' . $address);
+ \usleep(500_000);
+ }
+ };
+
+ // Step 1: Disable session alerts
+ $setSessionAlert(false);
+
+ // Step 2: Create user1 and two sessions
+ $user1Email = 'alert1_' . uniqid() . '@localhost.test';
+ $createUser($user1Email);
+ $createSession($user1Email);
+ $createSession($user1Email);
+
+ // Step 3: No alert should arrive in the next 10 seconds
+ $assertEmailCountStays($user1Email, 0, 10);
+
+ // Step 4: Enable session alerts
+ $setSessionAlert(true);
+
+ // Step 5: Create user2 and one session
+ $user2Email = 'alert2_' . uniqid() . '@localhost.test';
+ $createUser($user2Email);
+ $createSession($user2Email);
+
+ // Step 6: First session never alerts, so nothing arrives in 10 seconds
+ $assertEmailCountStays($user2Email, 0, 10);
+
+ // Step 7: Create the second session for user2
+ $createSession($user2Email);
+
+ // Step 8: Session alert email should eventually arrive
+ $this->assertEventually(function () use ($countEmailsTo, $user2Email) {
+ $this->assertSame(1, $countEmailsTo($user2Email));
+ }, 15_000, 500);
+
+ // Step 9: Disable session alerts
+ $setSessionAlert(false);
+
+ // Step 10: Create the third session for user2
+ $createSession($user2Email);
+
+ // Step 11: No additional alert email should arrive in 10 seconds
+ $assertEmailCountStays($user2Email, 1, 10);
+ }
+}
diff --git a/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php
new file mode 100644
index 0000000000..582da65373
--- /dev/null
+++ b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php
@@ -0,0 +1,93 @@
+getProject()['$id'];
+ $apiKey = $this->getProject()['apiKey'];
+
+ $serverHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $apiKey,
+ 'x-appwrite-response-format' => '1.9.4',
+ ];
+
+ $publicHeaders = [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ];
+
+ $setDuration = function (int $seconds) use ($serverHeaders): void {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $serverHeaders, [
+ 'duration' => $seconds,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame($seconds, $response['body']['authDuration']);
+ };
+
+ // Step 1: Set session duration to 5 seconds
+ $setDuration(5);
+
+ // Step 2: Create user and a session
+ $email = 'duration_' . uniqid() . '@localhost.test';
+ $password = 'password1234';
+
+ $user = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [
+ 'userId' => ID::unique(),
+ 'email' => $email,
+ 'password' => $password,
+ 'name' => 'Duration User',
+ ]);
+ $this->assertSame(201, $user['headers']['status-code']);
+
+ $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [
+ 'email' => $email,
+ 'password' => $password,
+ ]);
+ $this->assertSame(201, $session['headers']['status-code']);
+ $sessionCookie = $session['cookies']['a_session_' . $projectId];
+
+ $accountHeaders = [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie,
+ ];
+
+ $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders);
+ $this->assertSame(200, $response['headers']['status-code']);
+
+ // Step 3: Poll until the 5s TTL elapses - session should expire
+ $this->assertEventually(function () use ($accountHeaders) {
+ $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders);
+ $this->assertSame(401, $response['headers']['status-code']);
+ }, 15_000, 500);
+
+ // Step 4: Raise duration to 10s - same session should still not be usable
+ $setDuration(10);
+
+ $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders);
+ $this->assertSame(401, $response['headers']['status-code']);
+
+ // Step 5: Set duration to 1 year
+ $setDuration(31536000);
+
+ // Step 6: Same session should still not be usable
+ $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders);
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+}
diff --git a/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php
new file mode 100644
index 0000000000..b6f972ca3e
--- /dev/null
+++ b/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php
@@ -0,0 +1,120 @@
+getProject()['$id'];
+ $apiKey = $this->getProject()['apiKey'];
+
+ $serverHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $apiKey,
+ 'x-appwrite-response-format' => '1.9.4',
+ ];
+
+ $publicHeaders = [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ];
+
+ $setInvalidation = function (bool $enabled) use ($serverHeaders): void {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $serverHeaders, [
+ 'enabled' => $enabled,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame($enabled, $response['body']['authInvalidateSessions']);
+ };
+
+ $accountHeaders = function (string $sessionCookie) use ($projectId): array {
+ return [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie,
+ ];
+ };
+
+ $getAccount = function (string $sessionCookie) use ($accountHeaders): array {
+ return $this->client->call(Client::METHOD_GET, '/account', $accountHeaders($sessionCookie));
+ };
+
+ // Step 1: Disable session invalidation
+ $setInvalidation(false);
+
+ // Step 2: Create user and two sessions
+ $email = 'invalidation_' . uniqid() . '@localhost.test';
+ $firstPassword = 'firstpassword';
+
+ $user = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [
+ 'userId' => ID::unique(),
+ 'email' => $email,
+ 'password' => $firstPassword,
+ 'name' => 'Invalidation User',
+ ]);
+ $this->assertSame(201, $user['headers']['status-code']);
+ $userId = $user['body']['$id'];
+
+ $login = function (string $password) use ($publicHeaders, $email, $projectId): string {
+ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [
+ 'email' => $email,
+ 'password' => $password,
+ ]);
+ $this->assertSame(201, $response['headers']['status-code']);
+ return $response['cookies']['a_session_' . $projectId];
+ };
+
+ $session1 = $login($firstPassword);
+ $session2 = $login($firstPassword);
+
+ $this->assertSame(200, $getAccount($session1)['headers']['status-code']);
+ $this->assertSame(200, $getAccount($session2)['headers']['status-code']);
+
+ // Step 3: Change password while invalidation is disabled - both sessions survive
+ $secondPassword = 'secondpassword';
+ $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $userId . '/password', $serverHeaders, [
+ 'password' => $secondPassword,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+
+ $this->assertEventually(function () use ($getAccount, $session1, $session2) {
+ $this->assertSame(200, $getAccount($session1)['headers']['status-code']);
+ $this->assertSame(200, $getAccount($session2)['headers']['status-code']);
+ }, 15_000, 500);
+
+ // Step 4: Enable session invalidation
+ $setInvalidation(true);
+
+ // Step 5: Change password - both sessions should be invalidated
+ $thirdPassword = 'thirdpassword';
+ $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $userId . '/password', $serverHeaders, [
+ 'password' => $thirdPassword,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+
+ $this->assertEventually(function () use ($getAccount, $session1, $session2) {
+ $this->assertSame(401, $getAccount($session1)['headers']['status-code']);
+ $this->assertSame(401, $getAccount($session2)['headers']['status-code']);
+ }, 15_000, 500);
+
+ // Step 6: Disable session invalidation again
+ $setInvalidation(false);
+
+ // Step 7: Previously-invalidated sessions stay dead
+ $this->assertSame(401, $getAccount($session1)['headers']['status-code']);
+ $this->assertSame(401, $getAccount($session2)['headers']['status-code']);
+ }
+}
diff --git a/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php
new file mode 100644
index 0000000000..295418a974
--- /dev/null
+++ b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php
@@ -0,0 +1,122 @@
+getProject()['$id'];
+ $apiKey = $this->getProject()['apiKey'];
+
+ $serverHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $apiKey,
+ ];
+
+ $publicHeaders = [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ];
+
+ $email = 'session_' . uniqid() . '@localhost.test';
+ $password = 'password1234';
+
+ // Create user (via API key so signup rules don't interfere)
+ $response = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [
+ 'userId' => ID::unique(),
+ 'email' => $email,
+ 'password' => $password,
+ 'name' => 'Session User',
+ ]);
+ $this->assertSame(201, $response['headers']['status-code']);
+
+ $login = function () use ($publicHeaders, $email, $password): string {
+ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [
+ 'email' => $email,
+ 'password' => $password,
+ ]);
+ $this->assertSame(201, $response['headers']['status-code']);
+ return $response['cookies']['a_session_' . $this->getProject()['$id']];
+ };
+
+ $accountHeaders = function (string $sessionCookie) use ($projectId): array {
+ return [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie,
+ ];
+ };
+
+ $getAccount = function (string $sessionCookie) use ($accountHeaders): array {
+ return $this->client->call(Client::METHOD_GET, '/account', $accountHeaders($sessionCookie));
+ };
+
+ $setSessionLimit = function (?int $total) use ($serverHeaders): void {
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $serverHeaders, [
+ 'total' => $total,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+ };
+
+ // Step 1: Session limit = 1
+ $setSessionLimit(1);
+
+ $session1 = $login();
+ $this->assertEventually(function () use ($getAccount, $session1) {
+ $response = $getAccount($session1);
+ $this->assertSame(200, $response['headers']['status-code']);
+ }, 15_000, 500);
+
+ // New session pushes old one out
+ $session2 = $login();
+
+ \sleep(3); // Giving ::shutdown() hooks some time
+
+ $this->assertSame(200, $getAccount($session2)['headers']['status-code']);
+ $this->assertSame(401, $getAccount($session1)['headers']['status-code']);
+
+ // Step 2: Session limit = 2
+ $setSessionLimit(2);
+
+ $session3 = $login();
+
+ \sleep(3); // Giving ::shutdown() hooks some time
+
+ $this->assertSame(200, $getAccount($session2)['headers']['status-code']);
+ $this->assertSame(200, $getAccount($session3)['headers']['status-code']);
+
+ // Step 3: 4th session evicts session2 (oldest), session3 and session4 remain
+ $session4 = $login();
+
+ \sleep(3); // Giving ::shutdown() hooks some time
+
+ $this->assertSame(200, $getAccount($session4)['headers']['status-code']);
+ $this->assertSame(200, $getAccount($session3)['headers']['status-code']);
+ $this->assertSame(401, $getAccount($session2)['headers']['status-code']);
+
+ // Step 4: Disable session limit, create 5 new sessions, all should remain usable
+ $setSessionLimit(null);
+
+ $newSessions = [];
+ for ($i = 0; $i < 5; $i++) {
+ $newSessions[] = $login();
+ }
+
+ foreach ($newSessions as $index => $sessionCookie) {
+ $this->assertSame(200, $getAccount($sessionCookie)['headers']['status-code'], 'Session #' . ($index + 1) . ' should remain valid when limit is disabled');
+ }
+ }
+}
diff --git a/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php
new file mode 100644
index 0000000000..6025d80536
--- /dev/null
+++ b/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php
@@ -0,0 +1,88 @@
+getProject()['$id'];
+ $apiKey = $this->getProject()['apiKey'];
+
+ $serverHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $apiKey,
+ 'x-appwrite-response-format' => '1.9.4',
+ ];
+
+ $signupHeaders = [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ];
+
+ $signup = function () use ($signupHeaders): array {
+ return $this->client->call(Client::METHOD_POST, '/account', $signupHeaders, [
+ 'userId' => ID::unique(),
+ 'email' => 'limit_' . uniqid() . '@localhost.test',
+ 'password' => 'password1234',
+ 'name' => 'Limit User',
+ ]);
+ };
+
+ // Step 1: Set user limit to 3
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $serverHeaders, [
+ 'total' => 3,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(3, $response['body']['authLimit']);
+
+ // Create 3 users - all should succeed
+ for ($i = 1; $i <= 3; $i++) {
+ $response = $signup();
+ $this->assertSame(201, $response['headers']['status-code'], 'User ' . $i . ' should be created under limit of 3');
+ }
+
+ // User 4 should be blocked
+ $response = $signup();
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('user_count_exceeded', $response['body']['type']);
+
+ // Step 2: Raise user limit to 4
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $serverHeaders, [
+ 'total' => 4,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(4, $response['body']['authLimit']);
+
+ // User 4 now succeeds
+ $response = $signup();
+ $this->assertSame(201, $response['headers']['status-code']);
+
+ // User 5 should be blocked
+ $response = $signup();
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('user_count_exceeded', $response['body']['type']);
+
+ // Step 3: Remove user limit (null -> stored as 0 -> unlimited)
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $serverHeaders, [
+ 'total' => null,
+ ]);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(0, $response['body']['authLimit']);
+
+ // User 5 now succeeds
+ $response = $signup();
+ $this->assertSame(201, $response['headers']['status-code']);
+ }
+}
diff --git a/tests/e2e/Services/Project/ProjectBase.php b/tests/e2e/Services/Project/ProjectBase.php
new file mode 100644
index 0000000000..fa4d2ca7fa
--- /dev/null
+++ b/tests/e2e/Services/Project/ProjectBase.php
@@ -0,0 +1,7 @@
+createTeam('Delete Project Team');
+ $project = $this->createProject($team['body']['$id'], 'Delete Project');
+
+ $response = $this->client->call(Client::METHOD_DELETE, '/project', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $project['body']['$id'],
+ ], $this->getHeaders()));
+
+ $this->assertSame(204, $response['headers']['status-code']);
+
+ $getProject = $this->getConsoleProject($project['body']['$id']);
+
+ $this->assertSame(404, $getProject['headers']['status-code']);
+ }
+
+ public function testDeleteProjectUsingKey(): void
+ {
+ $team = $this->createTeam('Delete Project Key Team');
+ $project = $this->createProject($team['body']['$id'], 'Delete Project Using Key');
+ $apiKey = $this->createProjectKey($project['body']['$id'], ['project.write']);
+
+ $response = $this->client->call(Client::METHOD_DELETE, '/project', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $project['body']['$id'],
+ 'x-appwrite-key' => $apiKey,
+ ]);
+
+ $this->assertSame(204, $response['headers']['status-code']);
+
+ $getProject = $this->getConsoleProject($project['body']['$id']);
+
+ $this->assertSame(404, $getProject['headers']['status-code']);
+ }
+
+ public function testGetProject(): void
+ {
+ $team = $this->createTeam('Get Project Team');
+ $project = $this->createProject($team['body']['$id'], 'Get Project');
+
+ $response = $this->client->call(Client::METHOD_GET, '/project', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $project['body']['$id'],
+ ], $this->getHeaders()));
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame($project['body']['$id'], $response['body']['$id']);
+ $this->assertNotEmpty($response['body']['$createdAt']);
+ $this->assertNotEmpty($response['body']['$updatedAt']);
+ $this->assertSame('Get Project', $response['body']['name']);
+ $this->assertSame($team['body']['$id'], $response['body']['teamId']);
+ $this->assertSame('active', $response['body']['status']);
+
+ // Auth methods
+ $this->assertIsArray($response['body']['authMethods']);
+ $this->assertNotEmpty($response['body']['authMethods']);
+ foreach ($response['body']['authMethods'] as $authMethod) {
+ $this->assertArrayHasKey('$id', $authMethod);
+ $this->assertArrayHasKey('enabled', $authMethod);
+ $this->assertIsBool($authMethod['enabled']);
+ }
+
+ // Services
+ $this->assertIsArray($response['body']['services']);
+ $this->assertNotEmpty($response['body']['services']);
+ foreach ($response['body']['services'] as $service) {
+ $this->assertArrayHasKey('$id', $service);
+ $this->assertArrayHasKey('enabled', $service);
+ $this->assertIsBool($service['enabled']);
+ }
+
+ // Protocols
+ $this->assertIsArray($response['body']['protocols']);
+ $this->assertNotEmpty($response['body']['protocols']);
+ foreach ($response['body']['protocols'] as $protocol) {
+ $this->assertArrayHasKey('$id', $protocol);
+ $this->assertArrayHasKey('enabled', $protocol);
+ $this->assertIsBool($protocol['enabled']);
+ }
+
+ // SMTP defaults
+ $this->assertFalse($response['body']['smtpEnabled']);
+ $this->assertSame('', $response['body']['smtpSenderEmail']);
+ $this->assertSame('', $response['body']['smtpSenderName']);
+ $this->assertSame('', $response['body']['smtpReplyToEmail']);
+ $this->assertSame('', $response['body']['smtpReplyToName']);
+ $this->assertSame('', $response['body']['smtpHost']);
+ $this->assertSame('', $response['body']['smtpPort']);
+ $this->assertSame('', $response['body']['smtpUsername']);
+ $this->assertSame('', $response['body']['smtpPassword']);
+ $this->assertSame('', $response['body']['smtpSecure']);
+
+ // Other fields
+ $this->assertIsArray($response['body']['labels']);
+ $this->assertIsArray($response['body']['devKeys']);
+ $this->assertSame(0, $response['body']['pingCount']);
+ $this->assertSame('', $response['body']['pingedAt']);
+
+ // Ensure old flattened fields are not present
+ $this->assertArrayNotHasKey('description', $response['body']);
+ $this->assertArrayNotHasKey('logo', $response['body']);
+ $this->assertArrayNotHasKey('url', $response['body']);
+ $this->assertArrayNotHasKey('authDuration', $response['body']);
+ $this->assertArrayNotHasKey('authLimit', $response['body']);
+ $this->assertArrayNotHasKey('authSessionsLimit', $response['body']);
+ $this->assertArrayNotHasKey('authPasswordHistory', $response['body']);
+ $this->assertArrayNotHasKey('authPasswordDictionary', $response['body']);
+ $this->assertArrayNotHasKey('authPersonalDataCheck', $response['body']);
+ $this->assertArrayNotHasKey('authDisposableEmails', $response['body']);
+ $this->assertArrayNotHasKey('authCanonicalEmails', $response['body']);
+ $this->assertArrayNotHasKey('authFreeEmails', $response['body']);
+ $this->assertArrayNotHasKey('oAuthProviders', $response['body']);
+ $this->assertArrayNotHasKey('platforms', $response['body']);
+ $this->assertArrayNotHasKey('webhooks', $response['body']);
+ $this->assertArrayNotHasKey('keys', $response['body']);
+ }
+
+ protected function createTeam(string $name): array
+ {
+ $response = $this->client->call(Client::METHOD_POST, '/teams', $this->getConsoleSessionHeaders(), [
+ 'teamId' => ID::unique(),
+ 'name' => $name,
+ ]);
+
+ $this->assertSame(201, $response['headers']['status-code']);
+ $this->assertSame($name, $response['body']['name']);
+ $this->assertNotEmpty($response['body']['$id']);
+
+ return $response;
+ }
+
+ protected function createProject(string $teamId, string $name): array
+ {
+ $response = $this->client->call(Client::METHOD_POST, '/projects', $this->getConsoleSessionHeaders(), [
+ 'projectId' => ID::unique(),
+ 'region' => System::getEnv('_APP_REGION', 'default'),
+ 'name' => $name,
+ 'teamId' => $teamId,
+ ]);
+
+ $this->assertSame(201, $response['headers']['status-code']);
+ $this->assertSame($name, $response['body']['name']);
+ $this->assertNotEmpty($response['body']['$id']);
+
+ return $response;
+ }
+
+ protected function createProjectKey(string $projectId, array $scopes): string
+ {
+ $response = $this->client->call(Client::METHOD_POST, '/projects/' . $projectId . '/keys', $this->getConsoleSessionHeaders(), [
+ 'keyId' => ID::unique(),
+ 'name' => 'Delete Project Key',
+ 'scopes' => $scopes,
+ ]);
+
+ $this->assertSame(201, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['secret']);
+
+ return $response['body']['secret'];
+ }
+
+ protected function getConsoleProject(string $projectId): array
+ {
+ return $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, $this->getConsoleSessionHeaders());
+ }
+
+ protected function getConsoleSessionHeaders(): array
+ {
+ return [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'cookie' => 'a_session_console=' . $this->getRoot()['session'],
+ 'x-appwrite-project' => 'console',
+ ];
+ }
+}
diff --git a/tests/e2e/Services/Project/ProjectCustomServerTest.php b/tests/e2e/Services/Project/ProjectCustomServerTest.php
new file mode 100644
index 0000000000..a719d4b372
--- /dev/null
+++ b/tests/e2e/Services/Project/ProjectCustomServerTest.php
@@ -0,0 +1,21 @@
+expectNotToPerformAssertions();
+ }
+}
diff --git a/tests/e2e/Services/Project/ProtocolsBase.php b/tests/e2e/Services/Project/ProtocolsBase.php
index 0187fc8463..092ed97b6a 100644
--- a/tests/e2e/Services/Project/ProtocolsBase.php
+++ b/tests/e2e/Services/Project/ProtocolsBase.php
@@ -241,6 +241,34 @@ trait ProtocolsBase
$this->assertSame(404, $response['headers']['status-code']);
}
+ // Backwards compatibility
+
+ public function testUpdateProtocolLegacyStatusPath(): void
+ {
+ $headers = array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
+ ], $this->getHeaders());
+
+ // Disable via the legacy `/status` alias
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/protocols/rest/status', $headers, [
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertSame(false, $response['body']['protocolStatusForRest']);
+
+ // Re-enable via the legacy `/status` alias
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/protocols/rest/status', $headers, [
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(true, $response['body']['protocolStatusForRest']);
+ }
+
// Helpers
protected function updateProtocolStatus(string $protocolId, bool $enabled, bool $authenticated = true): mixed
@@ -251,10 +279,12 @@ trait ProtocolsBase
];
if ($authenticated) {
- $headers = array_merge($headers, $this->getHeaders());
+ $headers = array_merge($headers, $this->getHeaders(), [
+ 'x-appwrite-response-format' => '1.9.4',
+ ]);
}
- return $this->client->call(Client::METHOD_PATCH, '/project/protocols/' . $protocolId . '/status', $headers, [
+ return $this->client->call(Client::METHOD_PATCH, '/project/protocols/' . $protocolId, $headers, [
'enabled' => $enabled,
]);
}
diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php
new file mode 100644
index 0000000000..19355bdce0
--- /dev/null
+++ b/tests/e2e/Services/Project/SMTPBase.php
@@ -0,0 +1,1261 @@
+updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ username: 'user',
+ password: 'password',
+ enabled: false,
+ );
+ }
+
+ // Update SMTP status tests
+
+ public function testUpdateSMTPStatusEnable(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ enabled: true,
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertSame(true, $response['body']['smtpEnabled']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPStatusDisable(): void
+ {
+ $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ enabled: true,
+ );
+
+ $response = $this->updateSMTP(enabled: false);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertSame(false, $response['body']['smtpEnabled']);
+ }
+
+ public function testUpdateSMTPStatusEnableIdempotent(): void
+ {
+ $first = $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ enabled: true,
+ );
+ $this->assertSame(200, $first['headers']['status-code']);
+ $this->assertSame(true, $first['body']['smtpEnabled']);
+
+ $second = $this->updateSMTP(enabled: true);
+ $this->assertSame(200, $second['headers']['status-code']);
+ $this->assertSame(true, $second['body']['smtpEnabled']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPStatusDisableIdempotent(): void
+ {
+ $first = $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ enabled: false,
+ );
+ $this->assertSame(200, $first['headers']['status-code']);
+ $this->assertSame(false, $first['body']['smtpEnabled']);
+
+ $second = $this->updateSMTP(enabled: false);
+ $this->assertSame(200, $second['headers']['status-code']);
+ $this->assertSame(false, $second['body']['smtpEnabled']);
+ }
+
+ public function testUpdateSMTPStatusResponseModel(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ username: 'user',
+ password: 'password',
+ enabled: true,
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertArrayHasKey('$id', $response['body']);
+ $this->assertArrayHasKey('name', $response['body']);
+ $this->assertArrayHasKey('smtpEnabled', $response['body']);
+ $this->assertArrayHasKey('smtpSenderName', $response['body']);
+ $this->assertArrayHasKey('smtpSenderEmail', $response['body']);
+ $this->assertArrayHasKey('smtpReplyToEmail', $response['body']);
+ $this->assertArrayHasKey('smtpReplyToName', $response['body']);
+ $this->assertArrayHasKey('smtpHost', $response['body']);
+ $this->assertArrayHasKey('smtpPort', $response['body']);
+ $this->assertArrayHasKey('smtpUsername', $response['body']);
+ $this->assertArrayHasKey('smtpPassword', $response['body']);
+ // smtpPassword is write-only: the stored password must never leak in responses
+ $this->assertSame('', $response['body']['smtpPassword']);
+ $this->assertArrayHasKey('smtpSecure', $response['body']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPStatusWithoutAuthentication(): void
+ {
+ $response = $this->updateSMTP(enabled: true, authenticated: false);
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ // Update SMTP tests
+
+ public function testUpdateSMTPCredentials(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertSame(true, $response['body']['smtpEnabled']);
+ $this->assertSame('Test Sender', $response['body']['smtpSenderName']);
+ $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']);
+ $this->assertSame('maildev', $response['body']['smtpHost']);
+ $this->assertSame(1025, $response['body']['smtpPort']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPWithOptionalReplyTo(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Full Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ replyToEmail: 'reply@example.com',
+ replyToName: 'Full Reply',
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(true, $response['body']['smtpEnabled']);
+ $this->assertSame('Full Sender', $response['body']['smtpSenderName']);
+ $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']);
+ $this->assertSame('reply@example.com', $response['body']['smtpReplyToEmail']);
+ $this->assertSame('Full Reply', $response['body']['smtpReplyToName']);
+ $this->assertSame('maildev', $response['body']['smtpHost']);
+ $this->assertSame(1025, $response['body']['smtpPort']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPOverwritesPreviousSettings(): void
+ {
+ $this->updateSMTP(
+ senderName: 'First Sender',
+ senderEmail: 'first@example.com',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $response = $this->updateSMTP(
+ senderName: 'Second Sender',
+ senderEmail: 'second@example.com',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('Second Sender', $response['body']['smtpSenderName']);
+ $this->assertSame('second@example.com', $response['body']['smtpSenderEmail']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPEnablesSMTP(): void
+ {
+ // Ensure SMTP is disabled
+ $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ enabled: false,
+ );
+
+ $response = $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(true, $response['body']['smtpEnabled']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPResponseModel(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ username: 'user',
+ password: 'password',
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertArrayHasKey('$id', $response['body']);
+ $this->assertArrayHasKey('name', $response['body']);
+ $this->assertArrayHasKey('smtpEnabled', $response['body']);
+ $this->assertArrayHasKey('smtpSenderName', $response['body']);
+ $this->assertArrayHasKey('smtpSenderEmail', $response['body']);
+ $this->assertArrayHasKey('smtpReplyToEmail', $response['body']);
+ $this->assertArrayHasKey('smtpReplyToName', $response['body']);
+ $this->assertArrayHasKey('smtpHost', $response['body']);
+ $this->assertArrayHasKey('smtpPort', $response['body']);
+ $this->assertArrayHasKey('smtpUsername', $response['body']);
+ $this->assertArrayHasKey('smtpPassword', $response['body']);
+ // smtpPassword is write-only: the stored password must never leak in responses
+ $this->assertSame('', $response['body']['smtpPassword']);
+ $this->assertArrayHasKey('smtpSecure', $response['body']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPWithoutAuthentication(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ authenticated: false,
+ );
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSMTPInvalidSenderEmail(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'not-an-email',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSMTPEmptySenderName(): void
+ {
+ // Empty sender name is valid — PHPMailer accepts '' as display name.
+ $response = $this->updateSMTP(
+ senderName: '',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('', $response['body']['smtpSenderName']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPEmptySenderEmail(): void
+ {
+ // Empty senderEmail clears the stored value; connection test is skipped when
+ // there is no valid From address, so this is accepted even without enabled=false.
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: '',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('', $response['body']['smtpSenderEmail']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPEmptyHost(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: '',
+ port: 1025,
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSMTPInvalidHost(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'https://myhost.com/v1',
+ port: 1025,
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSMTPInvalidReplyToEmail(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ replyToEmail: 'not-an-email',
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSMTPReplyToEmailCanBeCleared(): void
+ {
+ // Step 1: Set a custom replyToEmail.
+ $set = $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ replyToEmail: 'reply@example.com',
+ );
+ $this->assertSame(200, $set['headers']['status-code']);
+ $this->assertSame('reply@example.com', $set['body']['smtpReplyToEmail']);
+
+ // Step 2: Clear it with an empty string.
+ $clear = $this->updateSMTP(replyToEmail: '');
+ $this->assertSame(200, $clear['headers']['status-code']);
+ $this->assertSame('', $clear['body']['smtpReplyToEmail']);
+
+ // Step 3: Verify the cleared value persists.
+ $verify = $this->updateSMTP();
+ $this->assertSame(200, $verify['headers']['status-code']);
+ $this->assertSame('', $verify['body']['smtpReplyToEmail']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPSenderEmailCanBeClearedWhenDisabled(): void
+ {
+ // Step 1: Configure SMTP with a sender email, then disable it.
+ $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ enabled: false,
+ );
+
+ // Step 2: Clear senderEmail while keeping SMTP disabled.
+ // enabled=false skips the PHPMailer connection check so empty senderEmail is valid.
+ $clear = $this->updateSMTP(
+ senderEmail: '',
+ enabled: false,
+ );
+ $this->assertSame(200, $clear['headers']['status-code']);
+ $this->assertSame('', $clear['body']['smtpSenderEmail']);
+
+ // Step 3: Verify the cleared value persists.
+ $verify = $this->updateSMTP(enabled: false);
+ $this->assertSame(200, $verify['headers']['status-code']);
+ $this->assertSame('', $verify['body']['smtpSenderEmail']);
+ }
+
+ public function testUpdateSMTPInvalidSecure(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ secure: 'invalid',
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSMTPSenderNameMinLength(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'A',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('A', $response['body']['smtpSenderName']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPSenderNameMaxLength(): void
+ {
+ $name = str_repeat('a', 256);
+ $response = $this->updateSMTP(
+ senderName: $name,
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame($name, $response['body']['smtpSenderName']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPSenderNameTooLong(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: str_repeat('a', 257),
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSMTPUsernameMinLength(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ username: 'u',
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('u', $response['body']['smtpUsername']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPUsernameMaxLength(): void
+ {
+ $username = str_repeat('a', 256);
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ username: $username,
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame($username, $response['body']['smtpUsername']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPUsernameTooLong(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ username: str_repeat('a', 257),
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSMTPUsernameEmpty(): void
+ {
+ // Empty string clears a previously-set username (no-auth SMTP).
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ username: '',
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('', $response['body']['smtpUsername']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPPasswordMinLength(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ password: 'p',
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ // smtpPassword is write-only: the accepted password must not be echoed back
+ $this->assertSame('', $response['body']['smtpPassword']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPPasswordMaxLength(): void
+ {
+ $password = str_repeat('a', 256);
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ password: $password,
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ // smtpPassword is write-only: the accepted password must not be echoed back
+ $this->assertSame('', $response['body']['smtpPassword']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPPasswordTooLong(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ password: str_repeat('a', 257),
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSMTPPasswordEmpty(): void
+ {
+ // Empty string clears a previously-set password (no-auth SMTP).
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ password: '',
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ // smtpPassword is write-only and never echoed back.
+ $this->assertSame('', $response['body']['smtpPassword']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPCredentialsCanBeCleared(): void
+ {
+ // Step 1: Set username and password.
+ $set = $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ username: 'myuser',
+ password: 'mypassword',
+ );
+ $this->assertSame(200, $set['headers']['status-code']);
+ $this->assertSame('myuser', $set['body']['smtpUsername']);
+
+ // Step 2: Clear both credentials by passing empty strings.
+ $clear = $this->updateSMTP(
+ username: '',
+ password: '',
+ );
+ $this->assertSame(200, $clear['headers']['status-code']);
+ $this->assertSame('', $clear['body']['smtpUsername']);
+ // smtpPassword is write-only and never echoed back regardless.
+ $this->assertSame('', $clear['body']['smtpPassword']);
+
+ // Step 3: Verify the cleared username persists (a no-params PATCH must not restore it).
+ $verify = $this->updateSMTP();
+ $this->assertSame(200, $verify['headers']['status-code']);
+ $this->assertSame('', $verify['body']['smtpUsername']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPWithoutSecure(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('', $response['body']['smtpSecure']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPInvalidConnectionEnabled(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'localhost',
+ port: 12345,
+ enabled: true,
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('project_smtp_config_invalid', $response['body']['type']);
+ }
+
+ public function testUpdateSMTPInvalidConnectionDisabled(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'localhost',
+ port: 12345,
+ enabled: false,
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(false, $response['body']['smtpEnabled']);
+ $this->assertSame('Test', $response['body']['smtpSenderName']);
+ $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']);
+ $this->assertSame('localhost', $response['body']['smtpHost']);
+ $this->assertSame(12345, $response['body']['smtpPort']);
+ }
+
+ public function testUpdateSMTPLegacyReplyToAndResponseFormat(): void
+ {
+ $headers = \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
+ ], $this->getHeaders());
+
+ // Legacy client sends `replyTo` (not `replyToEmail`). Request filter maps it.
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/smtp',
+ $headers,
+ [
+ 'enabled' => true,
+ 'senderName' => 'Legacy Sender',
+ 'senderEmail' => 'legacy-sender@example.com',
+ 'host' => 'maildev',
+ 'port' => 1025,
+ 'replyTo' => 'legacy-reply@example.com',
+ ],
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(true, $response['body']['smtpEnabled']);
+ $this->assertSame('Legacy Sender', $response['body']['smtpSenderName']);
+ $this->assertSame('legacy-sender@example.com', $response['body']['smtpSenderEmail']);
+
+ // Response filter must expose smtpReplyTo and strip smtpReplyToEmail / smtpReplyToName.
+ $this->assertArrayHasKey('smtpReplyTo', $response['body']);
+ $this->assertArrayNotHasKey('smtpReplyToEmail', $response['body']);
+ $this->assertArrayNotHasKey('smtpReplyToName', $response['body']);
+ $this->assertSame('legacy-reply@example.com', $response['body']['smtpReplyTo']);
+
+ // Sanity-check: a modern (non-legacy) read sees the new field names.
+ $modern = $this->updateSMTP(enabled: true);
+ $this->assertArrayHasKey('smtpReplyToEmail', $modern['body']);
+ $this->assertSame('legacy-reply@example.com', $modern['body']['smtpReplyToEmail']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testCreateSMTPTestLegacyInlineParams(): void
+ {
+ // Seed the project with a distinct SMTP config so we can prove the
+ // inline (1.9.1-style) params take precedence over project config.
+ $this->updateSMTP(
+ senderName: 'Project Sender',
+ senderEmail: 'project-sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ replyToEmail: 'project-reply@example.com',
+ replyToName: 'Project Reply',
+ enabled: false,
+ );
+
+ $recipient = 'legacy-smtp-' . \uniqid() . '@appwrite.io';
+
+ $headers = \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
+ ], $this->getHeaders());
+
+ $response = $this->client->call(
+ Client::METHOD_POST,
+ '/project/smtp/tests',
+ $headers,
+ [
+ 'emails' => [$recipient],
+ 'senderName' => 'Inline Legacy Sender',
+ 'senderEmail' => 'inline-legacy@appwrite.io',
+ 'replyTo' => 'inline-legacy-reply@appwrite.io',
+ 'host' => 'maildev',
+ 'port' => 1025,
+ 'username' => 'user',
+ 'password' => 'password',
+ ],
+ );
+
+ $this->assertSame(204, $response['headers']['status-code']);
+ $this->assertEmpty($response['body']);
+
+ // Verify the email was sent using the inline params (not project SMTP).
+ $email = $this->getLastEmailByAddress($recipient, function ($email) {
+ $this->assertSame('Custom SMTP email sample', $email['subject']);
+ });
+
+ $this->assertSame('inline-legacy@appwrite.io', $email['from'][0]['address']);
+ $this->assertSame('Inline Legacy Sender', $email['from'][0]['name']);
+ $this->assertSame('inline-legacy-reply@appwrite.io', $email['replyTo'][0]['address']);
+ $this->assertSame('Inline Legacy Sender', $email['replyTo'][0]['name']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPBackwardsCompatibilityDisable(): void
+ {
+ // First enable SMTP
+ $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ enabled: true,
+ );
+
+ // Use the deprecated enabled=false parameter to disable
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ enabled: false,
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(false, $response['body']['smtpEnabled']);
+ }
+
+ public function testUpdateSMTPRequiredFieldsOptionalAfterConfigured(): void
+ {
+ // Seed with a known configuration so required fields (host, port, senderEmail) are stored.
+ $this->updateSMTP(
+ senderName: 'Initial Sender',
+ senderEmail: 'initial@example.com',
+ host: 'maildev',
+ port: 1025,
+ enabled: true,
+ );
+
+ // Partial update: only update senderName, omitting host/port/senderEmail.
+ // Required fields should not be re-required because they are already stored.
+ $response = $this->updateSMTP(senderName: 'Updated Sender');
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('Updated Sender', $response['body']['smtpSenderName']);
+ $this->assertSame('initial@example.com', $response['body']['smtpSenderEmail']);
+ $this->assertSame('maildev', $response['body']['smtpHost']);
+ $this->assertSame(1025, $response['body']['smtpPort']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPAllParamsOptionalAfterConfigured(): void
+ {
+ // Seed a configuration so all fields are stored.
+ $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ enabled: true,
+ );
+
+ // Issue a PATCH with no params at all. Once previously configured, this must succeed.
+ $response = $this->updateSMTP();
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ // Previously-set values are preserved
+ $this->assertSame('Test Sender', $response['body']['smtpSenderName']);
+ $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']);
+ $this->assertSame('maildev', $response['body']['smtpHost']);
+ $this->assertSame(1025, $response['body']['smtpPort']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testUpdateSMTPEnabledTrueWithInvalidCredentials(): void
+ {
+ // Explicitly enabling SMTP with unreachable host/port must throw.
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'localhost',
+ port: 12345,
+ enabled: true,
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('project_smtp_config_invalid', $response['body']['type']);
+ }
+
+ public function testUpdateSMTPEnabledFalseWithInvalidCredentials(): void
+ {
+ // enabled=false means SMTP is not in use, so invalid credentials must be accepted.
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'localhost',
+ port: 12345,
+ enabled: false,
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(false, $response['body']['smtpEnabled']);
+ $this->assertSame('localhost', $response['body']['smtpHost']);
+ $this->assertSame(12345, $response['body']['smtpPort']);
+
+ // Cleanup (restore valid disabled config)
+ $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ enabled: false,
+ );
+ }
+
+ public function testUpdateSMTPEnabledNullWithInvalidCredentialsDoesNotThrow(): void
+ {
+ // Ensure SMTP is currently disabled so we aren't enforcing validation on an enabled config.
+ $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ enabled: false,
+ );
+
+ // With enabled omitted (null) and invalid credentials, the request must not throw.
+ // SMTP remains disabled because the credentials could not be validated.
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'localhost',
+ port: 12345,
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(false, $response['body']['smtpEnabled']);
+
+ // Cleanup (restore valid disabled config)
+ $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ enabled: false,
+ );
+ }
+
+ public function testUpdateSMTPEnabledNullWithValidCredentialsAutoEnables(): void
+ {
+ // Start from a disabled state.
+ $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ enabled: false,
+ );
+
+ // With enabled omitted (null) and valid credentials, SMTP must be auto-enabled.
+ $response = $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(true, $response['body']['smtpEnabled']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ // Create SMTP test tests
+
+ public function testCreateSMTPTest(): void
+ {
+ // First configure SMTP
+ $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $response = $this->createSMTPTest(['recipient@example.com']);
+
+ $this->assertSame(204, $response['headers']['status-code']);
+ $this->assertEmpty($response['body']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testCreateSMTPTestMultipleRecipients(): void
+ {
+ // First configure SMTP
+ $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $response = $this->createSMTPTest([
+ 'recipient1@example.com',
+ 'recipient2@example.com',
+ 'recipient3@example.com',
+ ]);
+
+ $this->assertSame(204, $response['headers']['status-code']);
+ $this->assertEmpty($response['body']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testCreateSMTPTestWhenSMTPDisabled(): void
+ {
+ // Ensure SMTP is disabled
+ $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ enabled: false,
+ );
+
+ $response = $this->createSMTPTest(['recipient@example.com']);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testCreateSMTPTestWithoutAuthentication(): void
+ {
+ $response = $this->createSMTPTest(['recipient@example.com'], false);
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ public function testCreateSMTPTestEmptyEmails(): void
+ {
+ // First configure SMTP
+ $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $response = $this->createSMTPTest([]);
+
+ $this->assertSame(204, $response['headers']['status-code']);
+ $this->assertEmpty($response['body']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testCreateSMTPTestInvalidEmail(): void
+ {
+ // First configure SMTP
+ $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $response = $this->createSMTPTest(['not-an-email']);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testCreateSMTPTestExceedsMaxEmails(): void
+ {
+ // First configure SMTP
+ $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $emails = [];
+ for ($i = 1; $i <= 11; $i++) {
+ $emails[] = "recipient{$i}@example.com";
+ }
+
+ $response = $this->createSMTPTest($emails);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testCreateSMTPTestMaxEmails(): void
+ {
+ // First configure SMTP
+ $this->updateSMTP(
+ senderName: 'Test Sender',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $emails = [];
+ for ($i = 1; $i <= 10; $i++) {
+ $emails[] = "recipient{$i}@example.com";
+ }
+
+ $response = $this->createSMTPTest($emails);
+
+ $this->assertSame(204, $response['headers']['status-code']);
+ $this->assertEmpty($response['body']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ // Integration tests
+
+ public function testCreateSMTPTestEmailDelivery(): void
+ {
+ $senderName = 'SMTP Test Sender';
+ $senderEmail = 'smtptest@appwrite.io';
+ $replyToEmail = 'smtpreply@appwrite.io';
+ $replyToName = 'SMTP Reply Team';
+ $recipientEmail = 'smtpdelivery-' . \uniqid() . '@appwrite.io';
+
+ // Configure SMTP with reply-to and auth credentials
+ $response = $this->updateSMTP(
+ senderName: $senderName,
+ senderEmail: $senderEmail,
+ host: 'maildev',
+ port: 1025,
+ replyToEmail: $replyToEmail,
+ replyToName: $replyToName,
+ username: 'user',
+ password: 'password',
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(true, $response['body']['smtpEnabled']);
+
+ // Trigger test email
+ $response = $this->createSMTPTest([$recipientEmail]);
+
+ $this->assertSame(204, $response['headers']['status-code']);
+
+ // Verify email arrived via maildev
+ $email = $this->getLastEmailByAddress($recipientEmail, function ($email) {
+ $this->assertSame('Custom SMTP email sample', $email['subject']);
+ });
+
+ $this->assertSame($senderEmail, $email['from'][0]['address']);
+ $this->assertSame($senderName, $email['from'][0]['name']);
+ $this->assertSame($replyToEmail, $email['replyTo'][0]['address']);
+ $this->assertSame($replyToName, $email['replyTo'][0]['name']);
+ $this->assertSame('Custom SMTP email sample', $email['subject']);
+ $this->assertStringContainsStringIgnoringCase('working correctly', $email['text']);
+ $this->assertStringContainsStringIgnoringCase('working correctly', $email['html']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ public function testMagicURLLoginUsesCustomSMTP(): void
+ {
+ $senderName = 'Custom Auth Mailer';
+ $senderEmail = 'authmailer@appwrite.io';
+ $recipientEmail = 'magicurl-' . \uniqid() . '@appwrite.io';
+
+ // Configure custom SMTP with auth credentials
+ $response = $this->updateSMTP(
+ senderName: $senderName,
+ senderEmail: $senderEmail,
+ host: 'maildev',
+ port: 1025,
+ username: 'user',
+ password: 'password',
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(true, $response['body']['smtpEnabled']);
+
+ // Trigger MagicURL login as a client (no auth headers needed)
+ $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], [
+ 'userId' => ID::unique(),
+ 'email' => $recipientEmail,
+ ]);
+
+ $this->assertSame(201, $response['headers']['status-code']);
+
+ // Verify the email arrived with custom SMTP sender details
+ $email = $this->getLastEmailByAddress($recipientEmail, function ($email) {
+ $this->assertStringContainsString('Login', $email['subject']);
+ });
+
+ $this->assertSame($senderEmail, $email['from'][0]['address']);
+ $this->assertSame($senderName, $email['from'][0]['name']);
+ $this->assertSame($this->getProject()['name'] . ' Login', $email['subject']);
+
+ // Cleanup
+ $this->updateSMTP(enabled: false);
+ }
+
+ // Helpers
+
+ protected function updateSMTP(
+ ?string $senderName = null,
+ ?string $senderEmail = null,
+ ?string $host = null,
+ ?int $port = null,
+ ?string $replyToEmail = null,
+ ?string $replyToName = null,
+ ?string $username = null,
+ ?string $password = null,
+ ?string $secure = null,
+ ?bool $enabled = null,
+ bool $authenticated = true,
+ ): mixed {
+ $headers = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ];
+
+ if ($authenticated) {
+ $headers = array_merge($headers, $this->getHeaders());
+ }
+
+ $params = [];
+
+ foreach (['senderName', 'senderEmail', 'host', 'port', 'replyToEmail', 'replyToName', 'username', 'password', 'secure', 'enabled'] as $key) {
+ if (!\is_null(${$key})) {
+ $params[$key] = ${$key};
+ }
+ }
+
+ return $this->client->call(Client::METHOD_PATCH, '/project/smtp', $headers, $params);
+ }
+
+ /**
+ * @param array $emails
+ */
+ protected function createSMTPTest(array $emails, bool $authenticated = true): mixed
+ {
+ $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/smtp/tests', $headers, [
+ 'emails' => $emails,
+ ]);
+ }
+}
diff --git a/tests/e2e/Services/Project/SMTPConsoleClientTest.php b/tests/e2e/Services/Project/SMTPConsoleClientTest.php
new file mode 100644
index 0000000000..e5962c0960
--- /dev/null
+++ b/tests/e2e/Services/Project/SMTPConsoleClientTest.php
@@ -0,0 +1,14 @@
+assertSame(404, $response['headers']['status-code']);
}
+ // Backwards compatibility
+
+ public function testUpdateServiceLegacyStatusPath(): void
+ {
+ $headers = array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
+ ], $this->getHeaders());
+
+ // Disable via the legacy `/status` alias
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/services/teams/status', $headers, [
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['$id']);
+ $this->assertSame(false, $response['body']['serviceStatusForTeams']);
+
+ // Re-enable via the legacy `/status` alias
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/services/teams/status', $headers, [
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(true, $response['body']['serviceStatusForTeams']);
+ }
+
// Helpers
protected function updateServiceStatus(string $serviceId, bool $enabled, bool $authenticated = true): mixed
@@ -249,10 +277,12 @@ trait ServicesBase
];
if ($authenticated) {
- $headers = array_merge($headers, $this->getHeaders());
+ $headers = array_merge($headers, $this->getHeaders(), [
+ 'x-appwrite-response-format' => '1.9.4',
+ ]);
}
- return $this->client->call(Client::METHOD_PATCH, '/project/services/' . $serviceId . '/status', $headers, [
+ return $this->client->call(Client::METHOD_PATCH, '/project/services/' . $serviceId, $headers, [
'enabled' => $enabled,
]);
}
diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php
new file mode 100644
index 0000000000..9e329dfc3b
--- /dev/null
+++ b/tests/e2e/Services/Project/TemplatesBase.php
@@ -0,0 +1,1332 @@
+getEmailTemplate('verification', 'en');
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('verification', $response['body']['templateId']);
+ $this->assertSame('en', $response['body']['locale']);
+ $this->assertNotEmpty($response['body']['subject']);
+ $this->assertNotEmpty($response['body']['message']);
+ }
+
+ public function testGetEmailTemplateDefaultLocale(): void
+ {
+ // When locale is omitted, the fallback locale (en) is applied server-side.
+ $response = $this->getEmailTemplate('recovery');
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('recovery', $response['body']['templateId']);
+ $this->assertSame('en', $response['body']['locale']);
+ $this->assertNotEmpty($response['body']['subject']);
+ $this->assertNotEmpty($response['body']['message']);
+ }
+
+ public function testGetEmailTemplateAllSupportedTypes(): void
+ {
+ $types = [
+ 'verification',
+ 'magicSession',
+ 'recovery',
+ 'invitation',
+ 'mfaChallenge',
+ 'sessionAlert',
+ 'otpSession',
+ ];
+
+ foreach ($types as $type) {
+ $response = $this->getEmailTemplate($type, 'en');
+
+ $this->assertSame(200, $response['headers']['status-code'], "type={$type}");
+ $this->assertSame($type, $response['body']['templateId']);
+ $this->assertSame('en', $response['body']['locale']);
+ $this->assertNotEmpty($response['body']['subject'], "type={$type} must have default subject");
+ $this->assertNotEmpty($response['body']['message'], "type={$type} must have default message");
+ }
+ }
+
+ public function testGetEmailTemplateNonDefaultLocale(): void
+ {
+ // Even a non-en locale that has no custom template must return defaults.
+ $response = $this->getEmailTemplate('verification', 'fr');
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('verification', $response['body']['templateId']);
+ $this->assertSame('fr', $response['body']['locale']);
+ $this->assertNotEmpty($response['body']['subject']);
+ $this->assertNotEmpty($response['body']['message']);
+ }
+
+ public function testGetEmailTemplateResponseModel(): void
+ {
+ $response = $this->getEmailTemplate('verification', 'en');
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertArrayHasKey('templateId', $response['body']);
+ $this->assertArrayHasKey('locale', $response['body']);
+ $this->assertArrayHasKey('subject', $response['body']);
+ $this->assertArrayHasKey('message', $response['body']);
+ $this->assertArrayHasKey('senderName', $response['body']);
+ $this->assertArrayHasKey('senderEmail', $response['body']);
+ $this->assertArrayHasKey('replyToEmail', $response['body']);
+ $this->assertArrayHasKey('replyToName', $response['body']);
+ }
+
+ public function testGetEmailTemplateInvalidType(): void
+ {
+ $response = $this->getEmailTemplate('notATemplate', 'en');
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testGetEmailTemplateInvalidLocale(): void
+ {
+ $response = $this->getEmailTemplate('verification', 'not-a-locale');
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testGetEmailTemplateWithoutAuthentication(): void
+ {
+ $response = $this->getEmailTemplate('verification', 'en', false);
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ public function testGetEmailTemplateReturnsCustomValues(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $subject = 'Custom invitation subject ' . \uniqid();
+ $message = 'Custom invitation body ' . \uniqid();
+
+ $update = $this->updateEmailTemplate(
+ templateId: 'invitation',
+ locale: 'en',
+ subject: $subject,
+ message: $message,
+ senderName: 'Invitation Sender',
+ senderEmail: 'invitation@appwrite.io',
+ replyToEmail: 'reply-invitation@appwrite.io',
+ replyToName: 'Invitation Reply',
+ );
+ $this->assertSame(200, $update['headers']['status-code']);
+
+ $get = $this->getEmailTemplate('invitation', 'en');
+
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertSame('invitation', $get['body']['templateId']);
+ $this->assertSame('en', $get['body']['locale']);
+ $this->assertSame($subject, $get['body']['subject']);
+ $this->assertSame($message, $get['body']['message']);
+ $this->assertSame('Invitation Sender', $get['body']['senderName']);
+ $this->assertSame('invitation@appwrite.io', $get['body']['senderEmail']);
+ $this->assertSame('reply-invitation@appwrite.io', $get['body']['replyToEmail']);
+ $this->assertSame('Invitation Reply', $get['body']['replyToName']);
+ }
+
+ public function testGetEmailTemplateCustomizationIsLocaleScoped(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $enSubject = 'EN only subject ' . \uniqid();
+ $update = $this->updateEmailTemplate(
+ templateId: 'mfaChallenge',
+ locale: 'en',
+ subject: $enSubject,
+ message: 'EN only message',
+ );
+ $this->assertSame(200, $update['headers']['status-code']);
+
+ // Another locale must still return its defaults — not the en customization.
+ $other = $this->getEmailTemplate('mfaChallenge', 'de');
+ $this->assertSame(200, $other['headers']['status-code']);
+ $this->assertSame('de', $other['body']['locale']);
+ $this->assertNotSame($enSubject, $other['body']['subject']);
+ }
+
+ // Update email template tests
+
+ public function testUpdateEmailTemplateRequiredFields(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $response = $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'en',
+ subject: 'Please verify your email',
+ message: 'Click here to verify: {{url}}',
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('verification', $response['body']['templateId']);
+ $this->assertSame('en', $response['body']['locale']);
+ $this->assertSame('Please verify your email', $response['body']['subject']);
+ $this->assertSame('Click here to verify: {{url}}', $response['body']['message']);
+ }
+
+ public function testUpdateEmailTemplateAllFields(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $response = $this->updateEmailTemplate(
+ templateId: 'recovery',
+ locale: 'en',
+ subject: 'Password reset',
+ message: 'Reset your password',
+ senderName: 'Security Team',
+ senderEmail: 'security@appwrite.io',
+ replyToEmail: 'noreply@appwrite.io',
+ replyToName: 'No Reply',
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('Password reset', $response['body']['subject']);
+ $this->assertSame('Reset your password', $response['body']['message']);
+ $this->assertSame('Security Team', $response['body']['senderName']);
+ $this->assertSame('security@appwrite.io', $response['body']['senderEmail']);
+ $this->assertSame('noreply@appwrite.io', $response['body']['replyToEmail']);
+ $this->assertSame('No Reply', $response['body']['replyToName']);
+ }
+
+ public function testUpdateEmailTemplateDefaultLocale(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ // Omit locale entirely; server falls back to `en`.
+ $response = $this->updateEmailTemplate(
+ templateId: 'sessionAlert',
+ locale: null,
+ subject: 'Session alert',
+ message: 'Someone signed in',
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('sessionAlert', $response['body']['templateId']);
+ $this->assertSame('en', $response['body']['locale']);
+ }
+
+ public function testUpdateEmailTemplateOverwritesPrevious(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $first = $this->updateEmailTemplate(
+ templateId: 'otpSession',
+ locale: 'en',
+ subject: 'First subject',
+ message: 'First body',
+ );
+ $this->assertSame(200, $first['headers']['status-code']);
+
+ $second = $this->updateEmailTemplate(
+ templateId: 'otpSession',
+ locale: 'en',
+ subject: 'Second subject',
+ message: 'Second body',
+ );
+ $this->assertSame(200, $second['headers']['status-code']);
+ $this->assertSame('Second subject', $second['body']['subject']);
+ $this->assertSame('Second body', $second['body']['message']);
+
+ $get = $this->getEmailTemplate('otpSession', 'en');
+ $this->assertSame('Second subject', $get['body']['subject']);
+ $this->assertSame('Second body', $get['body']['message']);
+ }
+
+ public function testUpdateEmailTemplatePartialAfterSeed(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ // Seed a fully configured template.
+ $seed = $this->updateEmailTemplate(
+ templateId: 'magicSession',
+ locale: 'en',
+ subject: 'Magic subject',
+ message: 'Magic body',
+ senderName: 'Magic Sender',
+ senderEmail: 'magic@appwrite.io',
+ replyToEmail: 'magic-reply@appwrite.io',
+ replyToName: 'Magic Reply',
+ );
+ $this->assertSame(200, $seed['headers']['status-code']);
+
+ // Once seeded, sending just one field is fine: previous subject/message persist.
+ $response = $this->updateEmailTemplate(
+ templateId: 'magicSession',
+ locale: 'en',
+ senderName: 'Updated Sender',
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('Updated Sender', $response['body']['senderName']);
+ $this->assertSame('Magic subject', $response['body']['subject']);
+ $this->assertSame('Magic body', $response['body']['message']);
+ $this->assertSame('magic@appwrite.io', $response['body']['senderEmail']);
+ $this->assertSame('magic-reply@appwrite.io', $response['body']['replyToEmail']);
+ $this->assertSame('Magic Reply', $response['body']['replyToName']);
+ }
+
+ public function testUpdateEmailTemplateDifferentLocales(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $enUpdate = $this->updateEmailTemplate(
+ templateId: 'invitation',
+ locale: 'en',
+ subject: 'English subject',
+ message: 'English body',
+ );
+ $this->assertSame(200, $enUpdate['headers']['status-code']);
+ $this->assertSame('en', $enUpdate['body']['locale']);
+ $this->assertSame('English subject', $enUpdate['body']['subject']);
+
+ $frUpdate = $this->updateEmailTemplate(
+ templateId: 'invitation',
+ locale: 'fr',
+ subject: 'Sujet francais',
+ message: 'Corps francais',
+ );
+ $this->assertSame(200, $frUpdate['headers']['status-code']);
+ $this->assertSame('fr', $frUpdate['body']['locale']);
+ $this->assertSame('Sujet francais', $frUpdate['body']['subject']);
+
+ // Locales remain independent.
+ $enGet = $this->getEmailTemplate('invitation', 'en');
+ $this->assertSame('English subject', $enGet['body']['subject']);
+
+ $frGet = $this->getEmailTemplate('invitation', 'fr');
+ $this->assertSame('Sujet francais', $frGet['body']['subject']);
+ }
+
+ public function testUpdateEmailTemplateResponseModel(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $response = $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'en',
+ subject: 'Model check subject',
+ message: 'Model check body',
+ senderName: 'Sender',
+ senderEmail: 'sender@appwrite.io',
+ replyToEmail: 'reply@appwrite.io',
+ replyToName: 'Reply',
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertArrayHasKey('templateId', $response['body']);
+ $this->assertArrayHasKey('locale', $response['body']);
+ $this->assertArrayHasKey('subject', $response['body']);
+ $this->assertArrayHasKey('message', $response['body']);
+ $this->assertArrayHasKey('senderName', $response['body']);
+ $this->assertArrayHasKey('senderEmail', $response['body']);
+ $this->assertArrayHasKey('replyToEmail', $response['body']);
+ $this->assertArrayHasKey('replyToName', $response['body']);
+ }
+
+ public function testUpdateEmailTemplateSubjectMaxLength(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $subject = \str_repeat('a', 255);
+ $response = $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'en',
+ subject: $subject,
+ message: 'Body',
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame($subject, $response['body']['subject']);
+ }
+
+ public function testUpdateEmailTemplateSubjectTooLong(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $response = $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'en',
+ subject: \str_repeat('a', 256),
+ message: 'Body',
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateEmailTemplateSenderNameEmptyAllowed(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ // senderName validator explicitly allows empty strings (Text(255, 0)).
+ $response = $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'en',
+ subject: 'Subject',
+ message: 'Message',
+ senderName: '',
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('', $response['body']['senderName']);
+ }
+
+ public function testUpdateEmailTemplateReplyToNameEmptyAllowed(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ // replyToName validator explicitly allows empty strings (Text(255, 0)).
+ $response = $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'en',
+ subject: 'Subject',
+ message: 'Message',
+ replyToName: '',
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('', $response['body']['replyToName']);
+ }
+
+ public function testUpdateEmailTemplateSenderNameTooLong(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $response = $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'en',
+ subject: 'Subject',
+ message: 'Message',
+ senderName: \str_repeat('a', 256),
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateEmailTemplateInvalidType(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $response = $this->updateEmailTemplate(
+ templateId: 'notATemplate',
+ locale: 'en',
+ subject: 'Subject',
+ message: 'Message',
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateEmailTemplateInvalidLocale(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $response = $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'not-a-locale',
+ subject: 'Subject',
+ message: 'Message',
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateEmailTemplateMissingSubjectOnFirstWrite(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ // 'recovery'/'de' was never customized, so there is no persisted subject
+ // to fall back on — the endpoint must reject the request.
+ $response = $this->updateEmailTemplate(
+ templateId: 'recovery',
+ locale: 'de',
+ subject: null,
+ message: 'Body only',
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+ }
+
+ public function testUpdateEmailTemplateMissingMessageOnFirstWrite(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ // 'invitation'/'es' was never customized, so there is no persisted message
+ // to fall back on — the endpoint must reject the request.
+ $response = $this->updateEmailTemplate(
+ templateId: 'invitation',
+ locale: 'es',
+ subject: 'Subject only',
+ message: null,
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+ }
+
+ public function testUpdateEmailTemplateEmptySubject(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ // Text(255) validator requires min length 1 — empty subject is rejected.
+ $response = $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'en',
+ subject: '',
+ message: 'Body',
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateEmailTemplateEmptyMessage(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $response = $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'en',
+ subject: 'Subject',
+ message: '',
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateEmailTemplateInvalidSenderEmail(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $response = $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'en',
+ subject: 'Subject',
+ message: 'Message',
+ senderEmail: 'not-an-email',
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateEmailTemplateInvalidReplyToEmail(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $response = $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'en',
+ subject: 'Subject',
+ message: 'Message',
+ replyToEmail: 'not-an-email',
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateEmailTemplateWithoutAuthentication(): void
+ {
+ $response = $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'en',
+ subject: 'Subject',
+ message: 'Message',
+ authenticated: false,
+ );
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ public function testUpdateEmailTemplateSenderFieldsCanBeCleared(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ // Step 1: Set a custom en verification template with sender and reply-to fields.
+ $first = $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'en',
+ subject: 'Verify your email',
+ message: 'Please verify: {{url}}',
+ senderName: 'Custom Sender',
+ senderEmail: 'custom-sender@appwrite.io',
+ replyToName: 'Custom Reply',
+ replyToEmail: 'custom-reply@appwrite.io',
+ );
+ $this->assertSame(200, $first['headers']['status-code']);
+ $this->assertSame('Custom Sender', $first['body']['senderName']);
+ $this->assertSame('custom-sender@appwrite.io', $first['body']['senderEmail']);
+ $this->assertSame('Custom Reply', $first['body']['replyToName']);
+ $this->assertSame('custom-reply@appwrite.io', $first['body']['replyToEmail']);
+
+ // Step 2: GET en verification template and ensure it reflects the custom values.
+ $get = $this->getEmailTemplate('verification', 'en');
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertSame('Custom Sender', $get['body']['senderName']);
+ $this->assertSame('custom-sender@appwrite.io', $get['body']['senderEmail']);
+ $this->assertSame('Custom Reply', $get['body']['replyToName']);
+ $this->assertSame('custom-reply@appwrite.io', $get['body']['replyToEmail']);
+
+ // Step 3: Update the same template, clearing sender and reply-to fields to empty strings.
+ $clear = $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'en',
+ senderName: '',
+ senderEmail: '',
+ replyToName: '',
+ replyToEmail: '',
+ );
+ $this->assertSame(200, $clear['headers']['status-code']);
+ $this->assertSame('', $clear['body']['senderName']);
+ $this->assertSame('', $clear['body']['senderEmail']);
+ $this->assertSame('', $clear['body']['replyToName']);
+ $this->assertSame('', $clear['body']['replyToEmail']);
+
+ // Step 4: GET again to confirm the cleared values persist.
+ $getAfter = $this->getEmailTemplate('verification', 'en');
+ $this->assertSame(200, $getAfter['headers']['status-code']);
+ $this->assertSame('', $getAfter['body']['senderName']);
+ $this->assertSame('', $getAfter['body']['senderEmail']);
+ $this->assertSame('', $getAfter['body']['replyToName']);
+ $this->assertSame('', $getAfter['body']['replyToEmail']);
+ }
+
+ public function testUpdateEmailTemplateBlockedWhenSMTPDisabled(): void
+ {
+ // Custom templates only make sense alongside a custom SMTP configuration.
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/smtp',
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()),
+ ['enabled' => false],
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(false, $response['body']['smtpEnabled']);
+
+ try {
+ $response = $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'en',
+ subject: 'Should be blocked',
+ message: 'Should be blocked',
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+ $this->assertStringContainsStringIgnoringCase('SMTP', $response['body']['message']);
+ } finally {
+ $this->ensureSMTPEnabled();
+ }
+ }
+
+ // List email template tests
+
+ public function testListEmailTemplatesReturnsSeededTemplate(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $subject = 'List subject ' . \uniqid();
+ $seed = $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'en',
+ subject: $subject,
+ message: 'List body',
+ );
+ $this->assertSame(200, $seed['headers']['status-code']);
+
+ $response = $this->listEmailTemplates();
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertArrayHasKey('templates', $response['body']);
+ $this->assertArrayHasKey('total', $response['body']);
+ $this->assertIsArray($response['body']['templates']);
+ $this->assertIsInt($response['body']['total']);
+ $this->assertGreaterThanOrEqual(1, $response['body']['total']);
+
+ $found = null;
+ foreach ($response['body']['templates'] as $template) {
+ if (
+ $template['templateId'] === 'verification'
+ && $template['locale'] === 'en'
+ && $template['subject'] === $subject
+ ) {
+ $found = $template;
+ break;
+ }
+ }
+ $this->assertNotNull($found, 'seeded verification/en template must appear in the list');
+ }
+
+ public function testListEmailTemplatesResponseModel(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $seed = $this->updateEmailTemplate(
+ templateId: 'invitation',
+ locale: 'en',
+ subject: 'Shape subject ' . \uniqid(),
+ message: 'Shape body',
+ senderName: 'Shape Sender',
+ senderEmail: 'shape@appwrite.io',
+ replyToEmail: 'shape-reply@appwrite.io',
+ replyToName: 'Shape Reply',
+ );
+ $this->assertSame(200, $seed['headers']['status-code']);
+
+ $response = $this->listEmailTemplates();
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']['templates']);
+
+ foreach ($response['body']['templates'] as $template) {
+ $this->assertArrayHasKey('templateId', $template);
+ $this->assertArrayHasKey('locale', $template);
+ $this->assertArrayHasKey('subject', $template);
+ $this->assertArrayHasKey('message', $template);
+ $this->assertArrayHasKey('senderName', $template);
+ $this->assertArrayHasKey('senderEmail', $template);
+ $this->assertArrayHasKey('replyToEmail', $template);
+ $this->assertArrayHasKey('replyToName', $template);
+ }
+ }
+
+ public function testListEmailTemplatesSeparatesLocales(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $runId = \uniqid();
+ $enSubject = "Multi-locale EN {$runId}";
+ $frSubject = "Multi-locale FR {$runId}";
+
+ $this->assertSame(200, $this->updateEmailTemplate(
+ templateId: 'recovery',
+ locale: 'en',
+ subject: $enSubject,
+ message: 'EN body',
+ )['headers']['status-code']);
+
+ $this->assertSame(200, $this->updateEmailTemplate(
+ templateId: 'recovery',
+ locale: 'fr',
+ subject: $frSubject,
+ message: 'FR body',
+ )['headers']['status-code']);
+
+ $response = $this->listEmailTemplates();
+ $this->assertSame(200, $response['headers']['status-code']);
+
+ $foundEn = false;
+ $foundFr = false;
+ foreach ($response['body']['templates'] as $template) {
+ if ($template['templateId'] === 'recovery' && $template['locale'] === 'en' && $template['subject'] === $enSubject) {
+ $foundEn = true;
+ }
+ if ($template['templateId'] === 'recovery' && $template['locale'] === 'fr' && $template['subject'] === $frSubject) {
+ $foundFr = true;
+ }
+ }
+
+ $this->assertTrue($foundEn, 'recovery/en must appear in the list');
+ $this->assertTrue($foundFr, 'recovery/fr must appear in the list');
+ }
+
+ public function testListEmailTemplatesUpdateDoesNotDuplicate(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $runId = \uniqid();
+ $firstSubject = "First {$runId}";
+ $secondSubject = "Second {$runId}";
+
+ $this->assertSame(200, $this->updateEmailTemplate(
+ templateId: 'mfaChallenge',
+ locale: 'en',
+ subject: $firstSubject,
+ message: 'Body',
+ )['headers']['status-code']);
+
+ $before = $this->listEmailTemplates();
+ $this->assertSame(200, $before['headers']['status-code']);
+ $beforeTotal = $before['body']['total'];
+
+ $this->assertSame(200, $this->updateEmailTemplate(
+ templateId: 'mfaChallenge',
+ locale: 'en',
+ subject: $secondSubject,
+ message: 'Body',
+ )['headers']['status-code']);
+
+ $after = $this->listEmailTemplates();
+ $this->assertSame(200, $after['headers']['status-code']);
+
+ // Same templateId/locale must remain a single entry, not accumulate.
+ $this->assertSame($beforeTotal, $after['body']['total']);
+
+ $matches = \array_values(\array_filter(
+ $after['body']['templates'],
+ fn ($t) => $t['templateId'] === 'mfaChallenge' && $t['locale'] === 'en',
+ ));
+ $this->assertCount(1, $matches);
+ $this->assertSame($secondSubject, $matches[0]['subject']);
+ }
+
+ public function testListEmailTemplatesTotalFalse(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ // Ensure at least one template exists so `templates` is non-empty.
+ $this->assertSame(200, $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'en',
+ subject: 'Total-false subject',
+ message: 'Body',
+ )['headers']['status-code']);
+
+ $response = $this->listEmailTemplates(total: false);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertIsInt($response['body']['total']);
+ $this->assertSame(0, $response['body']['total']);
+ $this->assertNotEmpty($response['body']['templates']);
+ }
+
+ public function testListEmailTemplatesTotalMatchesCount(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $this->assertSame(200, $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'en',
+ subject: 'Match subject',
+ message: 'Body',
+ )['headers']['status-code']);
+
+ $response = $this->listEmailTemplates();
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(\count($response['body']['templates']), $response['body']['total']);
+ }
+
+ public function testListEmailTemplatesWithLimit(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $runId = \uniqid();
+
+ $this->assertSame(200, $this->updateEmailTemplate(
+ templateId: 'verification',
+ locale: 'en',
+ subject: "Limit verification {$runId}",
+ message: 'Body',
+ )['headers']['status-code']);
+
+ $this->assertSame(200, $this->updateEmailTemplate(
+ templateId: 'recovery',
+ locale: 'en',
+ subject: "Limit recovery {$runId}",
+ message: 'Body',
+ )['headers']['status-code']);
+
+ $response = $this->listEmailTemplates([
+ Query::limit(1)->toString(),
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertCount(1, $response['body']['templates']);
+ $this->assertGreaterThanOrEqual(2, $response['body']['total']);
+ }
+
+ public function testListEmailTemplatesWithOffset(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $runId = \uniqid();
+
+ $this->assertSame(200, $this->updateEmailTemplate(
+ templateId: 'magicSession',
+ locale: 'en',
+ subject: "Offset magic {$runId}",
+ message: 'Body',
+ )['headers']['status-code']);
+
+ $this->assertSame(200, $this->updateEmailTemplate(
+ templateId: 'sessionAlert',
+ locale: 'en',
+ subject: "Offset session {$runId}",
+ message: 'Body',
+ )['headers']['status-code']);
+
+ $listAll = $this->listEmailTemplates();
+ $this->assertSame(200, $listAll['headers']['status-code']);
+ $totalAll = \count($listAll['body']['templates']);
+
+ $listOffset = $this->listEmailTemplates([
+ Query::offset(1)->toString(),
+ ]);
+
+ $this->assertSame(200, $listOffset['headers']['status-code']);
+ $this->assertCount($totalAll - 1, $listOffset['body']['templates']);
+ $this->assertSame($listAll['body']['total'], $listOffset['body']['total']);
+ }
+
+ public function testListEmailTemplatesOnlyReturnsCustomizedTemplates(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ // Seed exactly one template so we have a stable marker to count against.
+ $marker = 'Customized-only ' . \uniqid();
+ $this->assertSame(200, $this->updateEmailTemplate(
+ templateId: 'otpSession',
+ locale: 'en',
+ subject: $marker,
+ message: 'Body',
+ )['headers']['status-code']);
+
+ $response = $this->listEmailTemplates();
+ $this->assertSame(200, $response['headers']['status-code']);
+
+ // Every returned entry must be a real stored template (has templateId+locale set,
+ // not a synthesized default row for every possible type).
+ foreach ($response['body']['templates'] as $template) {
+ $this->assertNotEmpty($template['templateId']);
+ $this->assertNotEmpty($template['locale']);
+ }
+
+ // A `(templateId, locale)` pair that has never been customized in this test
+ // run must NOT show up. 'otpSession'/'pt-br' has no writer anywhere in the file.
+ $uncustomized = \array_filter(
+ $response['body']['templates'],
+ fn ($t) => $t['templateId'] === 'otpSession' && $t['locale'] === 'pt-br',
+ );
+ $this->assertEmpty($uncustomized, 'uncustomized (templateId, locale) pairs must not appear');
+ }
+
+ public function testListEmailTemplatesWithoutAuthentication(): void
+ {
+ $response = $this->listEmailTemplates(authenticated: false);
+
+ $this->assertSame(401, $response['headers']['status-code']);
+ }
+
+ // Backwards compatibility (x-appwrite-response-format: 1.9.1)
+
+ public function testGetEmailTemplateLegacyResponseFormat(): void
+ {
+ $response = $this->client->call(
+ Client::METHOD_GET,
+ '/project/templates/email/verification',
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
+ ], $this->getHeaders()),
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ // The 1.9.1 response filter renames templateId -> type and strips replyToName.
+ $this->assertArrayHasKey('type', $response['body']);
+ $this->assertArrayNotHasKey('templateId', $response['body']);
+ $this->assertArrayNotHasKey('replyToName', $response['body']);
+ $this->assertSame('verification', $response['body']['type']);
+ $this->assertSame('en', $response['body']['locale']);
+ }
+
+ public function testUpdateEmailTemplateLegacyRequestAndResponse(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ // Legacy clients send `type` + `replyTo`; request filter maps both.
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/templates/email',
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
+ ], $this->getHeaders()),
+ [
+ 'type' => 'magicSession',
+ 'locale' => 'en',
+ 'subject' => 'Legacy subject',
+ 'message' => 'Legacy body',
+ 'senderName' => 'Legacy Sender',
+ 'senderEmail' => 'legacy-sender@appwrite.io',
+ 'replyTo' => 'legacy-reply@appwrite.io',
+ ],
+ );
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertArrayHasKey('type', $response['body']);
+ $this->assertArrayNotHasKey('templateId', $response['body']);
+ $this->assertArrayHasKey('replyTo', $response['body']);
+ $this->assertArrayNotHasKey('replyToEmail', $response['body']);
+ $this->assertArrayNotHasKey('replyToName', $response['body']);
+ $this->assertSame('magicSession', $response['body']['type']);
+ $this->assertSame('Legacy subject', $response['body']['subject']);
+ $this->assertSame('Legacy body', $response['body']['message']);
+ $this->assertSame('Legacy Sender', $response['body']['senderName']);
+ $this->assertSame('legacy-sender@appwrite.io', $response['body']['senderEmail']);
+ $this->assertSame('legacy-reply@appwrite.io', $response['body']['replyTo']);
+
+ // Modern clients see the new field names for the exact same record.
+ $modern = $this->getEmailTemplate('magicSession', 'en');
+ $this->assertSame('magicSession', $modern['body']['templateId']);
+ $this->assertSame('legacy-reply@appwrite.io', $modern['body']['replyToEmail']);
+ }
+
+ public function testUpdateEmailTemplateLegacyInvalidType(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/templates/email',
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
+ ], $this->getHeaders()),
+ [
+ 'type' => 'notATemplate',
+ 'locale' => 'en',
+ 'subject' => 'Subject',
+ 'message' => 'Message',
+ ],
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ // Session alert integration
+
+ public function testSessionAlertUsesCustomTemplatePerLocale(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ // session-alerts lives under /projects (console scope), so it's driven with the
+ // root console session rather than the current test's project-scoped headers.
+ $alertsResponse = $this->client->call(
+ Client::METHOD_PATCH,
+ '/projects/' . $this->getProject()['$id'] . '/auth/session-alerts',
+ [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => 'console',
+ 'cookie' => 'a_session_console=' . $this->getRoot()['session'],
+ ],
+ ['enabled' => true],
+ );
+ $this->assertSame(200, $alertsResponse['headers']['status-code'], 'failed to enable session alerts');
+
+ $runId = \uniqid();
+ $enSubject = "EN alert subject {$runId}";
+ $enMessage = "EN alert body marker {$runId}";
+ $skSubject = "SK alert subject {$runId}";
+ $skMessage = "SK alert body marker {$runId}";
+
+ // Configure custom EN template via the default-locale path (omit `locale`).
+ $enUpdate = $this->updateEmailTemplate(
+ templateId: 'sessionAlert',
+ locale: null,
+ subject: $enSubject,
+ message: $enMessage,
+ );
+ $this->assertSame(200, $enUpdate['headers']['status-code']);
+ $this->assertSame('en', $enUpdate['body']['locale']);
+
+ // Configure custom SK template explicitly.
+ $skUpdate = $this->updateEmailTemplate(
+ templateId: 'sessionAlert',
+ locale: 'sk',
+ subject: $skSubject,
+ message: $skMessage,
+ );
+ $this->assertSame(200, $skUpdate['headers']['status-code']);
+
+ // Matrix of request-time locales and the custom template each one must resolve to.
+ // `de` has no custom template stored, so it must fall back to the `en` custom template.
+ $cases = [
+ ['requestLocale' => 'en', 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage],
+ ['requestLocale' => null, 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage],
+ ['requestLocale' => 'sk', 'expectedSubject' => $skSubject, 'expectedMessageMarker' => $skMessage],
+ ['requestLocale' => 'de', 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage],
+ ];
+
+ foreach ($cases as $case) {
+ $localeLabel = $case['requestLocale'] ?? 'none';
+ $email = "session-alert-{$runId}-{$localeLabel}@appwrite.io";
+ $password = 'password123';
+
+ // Fresh user per case so the session count starts at zero.
+ $create = $this->client->call(Client::METHOD_POST, '/account', [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-dev-key' => $this->getProject()['devKey'] ?? '',
+ ], [
+ 'userId' => ID::unique(),
+ 'email' => $email,
+ 'password' => $password,
+ 'name' => 'Session Alert ' . $localeLabel,
+ ]);
+ $this->assertSame(201, $create['headers']['status-code'], "create user ({$localeLabel})");
+
+ // First session must NOT trigger an alert (count === 1 returns early).
+ $first = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], [
+ 'email' => $email,
+ 'password' => $password,
+ ]);
+ $this->assertSame(201, $first['headers']['status-code'], "first session ({$localeLabel})");
+
+ // Second session — this one triggers the alert, with the test's request locale.
+ $headers = [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ];
+ if ($case['requestLocale'] !== null) {
+ $headers['x-appwrite-locale'] = $case['requestLocale'];
+ }
+ $second = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $headers, [
+ 'email' => $email,
+ 'password' => $password,
+ ]);
+ $this->assertSame(201, $second['headers']['status-code'], "second session ({$localeLabel})");
+
+ // The custom subject is uniquely tagged per run, so matching it proves both
+ // that an alert was sent and that the correct locale template was resolved.
+ $received = $this->getLastEmailByAddress($email, function ($mail) use ($case) {
+ $this->assertSame($case['expectedSubject'], $mail['subject']);
+ });
+
+ $this->assertSame($case['expectedSubject'], $received['subject'], "subject ({$localeLabel})");
+ $this->assertStringContainsString(
+ $case['expectedMessageMarker'],
+ $received['text'] . $received['html'],
+ "message marker ({$localeLabel})",
+ );
+ }
+ }
+
+ // Helpers
+
+ protected function getEmailTemplate(string $templateId, ?string $locale = null, bool $authenticated = true): mixed
+ {
+ $headers = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ];
+
+ if ($authenticated) {
+ $headers = \array_merge($headers, $this->getHeaders());
+ }
+
+ $params = [];
+ if ($locale !== null) {
+ $params['locale'] = $locale;
+ }
+
+ return $this->client->call(Client::METHOD_GET, '/project/templates/email/' . $templateId, $headers, $params);
+ }
+
+ protected function listEmailTemplates(?array $queries = null, ?bool $total = null, bool $authenticated = true): mixed
+ {
+ $headers = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ];
+
+ if ($authenticated) {
+ $headers = \array_merge($headers, $this->getHeaders());
+ }
+
+ $params = [];
+ if ($queries !== null) {
+ $params['queries'] = $queries;
+ }
+ if ($total !== null) {
+ $params['total'] = $total;
+ }
+
+ return $this->client->call(Client::METHOD_GET, '/project/templates/email', $headers, $params);
+ }
+
+ protected function updateEmailTemplate(
+ string $templateId,
+ ?string $locale = null,
+ ?string $subject = null,
+ ?string $message = null,
+ ?string $senderName = null,
+ ?string $senderEmail = null,
+ ?string $replyToEmail = null,
+ ?string $replyToName = null,
+ bool $authenticated = true,
+ ): mixed {
+ $headers = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ];
+
+ if ($authenticated) {
+ $headers = \array_merge($headers, $this->getHeaders());
+ }
+
+ $params = ['templateId' => $templateId];
+
+ foreach (['locale', 'subject', 'message', 'senderName', 'senderEmail', 'replyToEmail', 'replyToName'] as $key) {
+ if (!\is_null(${$key})) {
+ $params[$key] = ${$key};
+ }
+ }
+
+ return $this->client->call(Client::METHOD_PATCH, '/project/templates/email', $headers, $params);
+ }
+
+ // Console email template (default) tests
+
+ public function testGetConsoleEmailTemplate(): void
+ {
+ $response = $this->getConsoleEmailTemplate('verification', 'en');
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('verification', $response['body']['templateId']);
+ $this->assertSame('en', $response['body']['locale']);
+ $this->assertNotEmpty($response['body']['subject']);
+ $this->assertNotEmpty($response['body']['message']);
+ $this->assertSame('', $response['body']['senderName']);
+ $this->assertSame('', $response['body']['senderEmail']);
+ $this->assertSame('', $response['body']['replyToEmail']);
+ $this->assertSame('', $response['body']['replyToName']);
+ }
+
+ public function testGetConsoleEmailTemplateIgnoresCustomOverride(): void
+ {
+ $this->ensureSMTPEnabled();
+
+ // Set a custom override on the project template.
+ $this->updateEmailTemplate(
+ templateId: 'recovery',
+ locale: 'en',
+ subject: 'Custom subject',
+ message: 'Custom message',
+ senderName: 'Custom Sender',
+ senderEmail: 'custom@appwrite.io',
+ );
+
+ // Console endpoint must always return the built-in default, not the override.
+ $response = $this->getConsoleEmailTemplate('recovery', 'en');
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('recovery', $response['body']['templateId']);
+ $this->assertNotSame('Custom subject', $response['body']['subject']);
+ $this->assertSame('', $response['body']['senderName']);
+ $this->assertSame('', $response['body']['senderEmail']);
+ }
+
+ public function testGetConsoleEmailTemplateDefaultLocale(): void
+ {
+ $response = $this->getConsoleEmailTemplate('magicSession');
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('en', $response['body']['locale']);
+ $this->assertNotEmpty($response['body']['subject']);
+ }
+
+ public function testGetConsoleEmailTemplateNonDefaultLocale(): void
+ {
+ $response = $this->getConsoleEmailTemplate('verification', 'fr');
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('verification', $response['body']['templateId']);
+ $this->assertSame('fr', $response['body']['locale']);
+ $this->assertNotEmpty($response['body']['subject']);
+ $this->assertNotEmpty($response['body']['message']);
+ }
+
+ public function testGetConsoleEmailTemplateAllTypes(): void
+ {
+ $types = [
+ 'verification',
+ 'magicSession',
+ 'recovery',
+ 'invitation',
+ 'mfaChallenge',
+ 'sessionAlert',
+ 'otpSession',
+ ];
+
+ foreach ($types as $type) {
+ $response = $this->getConsoleEmailTemplate($type, 'en');
+ $this->assertSame(200, $response['headers']['status-code'], "type={$type}");
+ $this->assertNotEmpty($response['body']['subject'], "type={$type} must have subject");
+ $this->assertNotEmpty($response['body']['message'], "type={$type} must have message");
+ }
+ }
+
+ public function testGetConsoleEmailTemplateInvalidTemplateId(): void
+ {
+ $response = $this->getConsoleEmailTemplate('invalidTemplate', 'en');
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testGetConsoleEmailTemplateInvalidLocale(): void
+ {
+ $response = $this->getConsoleEmailTemplate('recovery', 'not-a-locale');
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ protected function getConsoleEmailTemplate(string $templateId, ?string $locale = null): mixed
+ {
+ $params = [];
+ if ($locale !== null) {
+ $params['locale'] = $locale;
+ }
+
+ return $this->client->call(Client::METHOD_GET, '/console/templates/email/' . $templateId, [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => 'console',
+ 'cookie' => 'a_session_console=' . $this->getRoot()['session'],
+ ], $params);
+ }
+
+ protected function ensureSMTPEnabled(): void
+ {
+ $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/smtp',
+ \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()),
+ [
+ 'enabled' => true,
+ 'senderName' => 'Mailer',
+ 'senderEmail' => 'mailer@appwrite.io',
+ 'host' => 'maildev',
+ 'port' => 1025,
+ 'username' => 'user',
+ 'password' => 'password',
+ ],
+ );
+ }
+}
diff --git a/tests/e2e/Services/Project/TemplatesConsoleClientTest.php b/tests/e2e/Services/Project/TemplatesConsoleClientTest.php
new file mode 100644
index 0000000000..d5431074e3
--- /dev/null
+++ b/tests/e2e/Services/Project/TemplatesConsoleClientTest.php
@@ -0,0 +1,14 @@
+assertCount(2, $collection['body']['attributes']);
$this->assertEquals('available', $collection['body']['attributes'][0]['status']);
$this->assertEquals('available', $collection['body']['attributes'][1]['status']);
- }, 15000, 500);
+ }, 60000, 500);
return ['databaseId' => $databaseId, 'actorsId' => $actorsId];
}
@@ -192,7 +192,7 @@ trait WebhooksBase
$this->assertCount(2, $table['body']['columns']);
$this->assertEquals('available', $table['body']['columns'][0]['status']);
$this->assertEquals('available', $table['body']['columns'][1]['status']);
- }, 15000, 500);
+ }, 60000, 500);
return ['databaseId' => $databaseId, 'actorsId' => $actorsId];
}
@@ -529,7 +529,7 @@ trait WebhooksBase
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']);
$this->assertNotEmpty($webhook['data']['key']);
$this->assertEquals($webhook['data']['key'], 'extra');
- }, 15000, 500);
+ }, 30000, 500);
$removed = $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/' . $extra['body']['key'], array_merge([
'content-type' => 'application/json',
@@ -896,7 +896,7 @@ trait WebhooksBase
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']);
$this->assertNotEmpty($webhook['data']['key']);
$this->assertEquals($webhook['data']['key'], 'extra');
- }, 15000, 500);
+ }, 30000, 500);
$removed = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/' . $extra['body']['key'], array_merge([
'content-type' => 'application/json',
@@ -1833,6 +1833,6 @@ trait WebhooksBase
// assert that the webhook is now disabled after 10 consecutive failures
$this->assertEquals($webhook['body']['enabled'], false);
$this->assertEquals($webhook['body']['attempts'], 10);
- }, 15000, 500);
+ }, 30000, 500);
}
}
diff --git a/tests/e2e/Services/ProjectWebhooks/WebhooksCustomServerTest.php b/tests/e2e/Services/ProjectWebhooks/WebhooksCustomServerTest.php
index 9085733b70..eb08da56f2 100644
--- a/tests/e2e/Services/ProjectWebhooks/WebhooksCustomServerTest.php
+++ b/tests/e2e/Services/ProjectWebhooks/WebhooksCustomServerTest.php
@@ -89,6 +89,7 @@ class WebhooksCustomServerTest extends Scope
$this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/variables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.3',
], $this->getHeaders()), [
'key' => 'key1',
'value' => 'value1',
@@ -699,6 +700,7 @@ class WebhooksCustomServerTest extends Scope
$variable = $this->client->call(Client::METHOD_POST, '/functions/' . $id . '/variables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.3',
], $this->getHeaders()), [
'key' => 'key1',
'value' => 'value1',
diff --git a/tests/e2e/Services/Projects/ProjectsBase.php b/tests/e2e/Services/Projects/ProjectsBase.php
index 122104e3d9..ef83e65d95 100644
--- a/tests/e2e/Services/Projects/ProjectsBase.php
+++ b/tests/e2e/Services/Projects/ProjectsBase.php
@@ -331,6 +331,7 @@ trait ProjectsBase
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/limit', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'limit' => 0,
]);
diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php
index 720ecedd50..82755d9d73 100644
--- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php
+++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php
@@ -6,7 +6,6 @@ use Appwrite\Extend\Exception;
use Appwrite\Tests\Async;
use PHPUnit\Framework\Attributes\Group;
use Tests\E2E\Client;
-use Tests\E2E\General\UsageTest;
use Tests\E2E\Scopes\ProjectConsole;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideClient;
@@ -47,6 +46,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4'
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test',
@@ -69,6 +69,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test',
@@ -90,6 +91,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => '',
@@ -102,6 +104,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test',
@@ -128,6 +131,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'MultiDB Project',
@@ -211,6 +215,7 @@ class ProjectsConsoleClientTest extends Scope
$getProject = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(404, $getProject['headers']['status-code']);
@@ -235,6 +240,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => $projectId,
'name' => 'Original Project',
@@ -250,6 +256,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => $projectId,
'name' => 'Project Duplicate',
@@ -299,6 +306,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Team 1 Project',
@@ -319,6 +327,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/team', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'teamId' => $team2,
]);
@@ -342,6 +351,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -354,6 +364,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders(), [
'search' => $id
]));
@@ -365,6 +376,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders(), [
'search' => 'Project Test'
]));
@@ -391,6 +403,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test 2',
@@ -409,6 +422,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::equal('teamId', [$team['body']['$id']])->toString(),
@@ -423,6 +437,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::limit(1)->toString(),
@@ -436,6 +451,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::offset(1)->toString(),
@@ -448,6 +464,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::equal('name', ['Project Test 2'])->toString(),
@@ -462,6 +479,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::orderDesc()->toString(),
@@ -475,6 +493,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -484,6 +503,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::cursorAfter(new Document(['$id' => $response['body']['projects'][0]['$id']]))->toString(),
@@ -499,6 +519,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::cursorAfter(new Document(['$id' => 'unknown']))->toString(),
@@ -525,6 +546,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Query Select Test Project',
@@ -541,6 +563,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::select(['$id', 'name'])->toString(),
@@ -570,6 +593,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4'
], $this->getHeaders()), [
'queries' => [
Query::select(['$id', 'name', 'teamId', 'description', '$createdAt', '$updatedAt'])->toString(),
@@ -601,6 +625,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::select(['$id', 'name', 'teamId'])->toString(),
@@ -632,6 +657,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::select(['$id', 'name'])->toString(),
@@ -662,6 +688,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::select(['$id', 'name', 'platforms'])->toString(),
@@ -691,6 +718,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::select(['$id', 'name', 'webhooks', 'keys'])->toString(),
@@ -720,6 +748,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::select(['*'])->toString(),
@@ -750,6 +779,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::select(['$id', 'invalidAttribute'])->toString(),
@@ -784,35 +814,709 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test',
'teamId' => $team['body']['$id'],
- 'region' => System::getEnv('_APP_REGION', 'default')
+ 'region' => System::getEnv('_APP_REGION', 'default'),
+ 'description' => 'My description',
+ 'logo' => 'https://google.com/logo.png',
+ 'url' => 'https://myapp.com/',
+ 'legalName' => 'Legal company',
+ 'legalCountry' => 'Slovakia',
+ 'legalState' => 'Custom state',
+ 'legalCity' => 'Košice',
+ 'legalAddress' => 'Main street 32',
+ 'legalTaxId' => 'TAXID_123456'
]);
$this->assertEquals(201, $response['headers']['status-code']);
$id = $response['body']['$id'];
+ // Increase ping 3x
+ for ($i = 0; $i < 3; $i++) {
+ $response = $this->client->call(
+ Client::METHOD_GET,
+ '/ping',
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ ], $this->getHeaders()),
+ );
+ $this->assertEquals(200, $response['headers']['status-code']);
+ }
+
+ // Configure SMTP
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/smtp',
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()),
+ [
+ 'enabled' => true,
+ 'senderName' => 'Custom sender',
+ 'senderEmail' => 'email@custom.com',
+ 'host' => 'maildev',
+ 'port' => 1025,
+ 'replyToEmail' => 'replyto@custom.com',
+ 'replyToName' => 'Reply sender',
+ ],
+ );
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ // Add mock numbers
+ $response = $this->client->call(Client::METHOD_POST, '/project/mock-phones', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'number' => '+421123456789',
+ 'otp' => '123456'
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+
+ // Add labels
+ $response = $this->client->call(Client::METHOD_PUT, '/project/labels', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'labels' => ['custom1', 'custom2']
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ // Create dev keys
+ $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/dev-keys', array_merge([
+ 'content-type' => 'application/json',
+ ], $this->getHeaders()), [
+ 'name' => 'Custom key 1',
+ 'expire' => '2099-05-07 09:23:30.713',
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/dev-keys', array_merge([
+ 'content-type' => 'application/json',
+ ], $this->getHeaders()), [
+ 'name' => 'Custom key 2',
+ 'expire' => '2099-05-07 11:23:30.713'
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_POST, '/project/mock-phones', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'number' => '+420987654321',
+ 'otp' => '654321'
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+
+ // Setup custom values for project policies
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'duration' => 135
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'total' => 54
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'total' => 7
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'total' => 9
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => true
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => true
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => true
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'userId' => true,
+ 'userEmail' => true,
+ 'userPhone' => true,
+ 'userName' => true,
+ 'userMFA' => true,
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => true
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ // Create webhook
+ $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'webhookId' => 'unique()',
+ 'name' => 'Webhook Test',
+ 'events' => ['users.*.create', 'users.*.update.email'],
+ 'url' => 'https://appwrite.io',
+ 'tls' => true,
+ 'authUsername' => 'username',
+ 'authPassword' => 'password',
+ ]);
+ $this->assertEquals(201, $webhook['headers']['status-code']);
+
+ // Create API key
+ $key = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'keyId' => ID::unique(),
+ 'name' => 'Key Test',
+ 'scopes' => ['teams.read', 'teams.write'],
+ ]);
+ $this->assertEquals(201, $key['headers']['status-code']);
+
+ // Create platform
+ $platform = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'platformId' => ID::unique(),
+ 'type' => 'web',
+ 'name' => 'Web App',
+ 'hostname' => 'localhost',
+ ]);
+ $this->assertEquals(201, $platform['headers']['status-code']);
+
+ // Configure OAuth provider
+ $oauth = $this->client->call(Client::METHOD_PATCH, '/project/oauth2/github', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'clientId' => 'github-client-id',
+ 'clientSecret' => 'github-client-secret',
+ 'enabled' => false,
+ ]);
+ $this->assertEquals(200, $oauth['headers']['status-code']);
+
/**
* Test for SUCCESS
*/
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
- $this->assertNotEmpty($response['body']);
$this->assertEquals($id, $response['body']['$id']);
$this->assertEquals('Project Test', $response['body']['name']);
+ $this->assertIsString($response['body']['$createdAt']);
+ $this->assertNotEmpty($response['body']['$createdAt']);
+ $this->assertNotFalse(\strtotime($response['body']['$createdAt']));
+
+ $this->assertIsString($response['body']['$updatedAt']);
+ $this->assertNotEmpty($response['body']['$updatedAt']);
+ $this->assertNotFalse(\strtotime($response['body']['$updatedAt']));
+
+ // $this->assertEquals('My description', $response['body']['description']); // No longer supported
+ $this->assertEquals($team['body']['$id'], $response['body']['teamId']);
+ $this->assertEquals('active', $response['body']['status']);
+ // $this->assertEquals('https://google.com/logo.png', $response['body']['logo']); // No longer supported
+ // $this->assertEquals('https://myapp.com/', $response['body']['url']); // No longer supported
+ // $this->assertEquals('Legal company', $response['body']['legalName']); // No longer supported
+ // $this->assertEquals('Slovakia', $response['body']['legalCountry']); // No longer supported
+ // $this->assertEquals('Custom state', $response['body']['legalState']); // No longer supported
+ // $this->assertEquals('Košice', $response['body']['legalCity']); // No longer supported
+ // $this->assertEquals('Main street 32', $response['body']['legalAddress']); // No longer supported
+ // $this->assertEquals('TAXID_123456', $response['body']['legalTaxId']); // No longer supported
+ $this->assertEquals(135, $response['body']['authDuration']);
+ $this->assertEquals(54, $response['body']['authLimit']);
+ $this->assertEquals(7, $response['body']['authSessionsLimit']);
+ $this->assertEquals(9, $response['body']['authPasswordHistory']);
+ $this->assertTrue($response['body']['authPasswordDictionary']);
+ $this->assertTrue($response['body']['authPersonalDataCheck']);
+ $this->assertFalse($response['body']['authDisposableEmails']);
+ $this->assertFalse($response['body']['authCanonicalEmails']);
+ $this->assertFalse($response['body']['authFreeEmails']);
+ $this->assertTrue($response['body']['authSessionAlerts']);
+ $this->assertTrue($response['body']['authMembershipsUserName']);
+ $this->assertTrue($response['body']['authMembershipsUserEmail']);
+ $this->assertTrue($response['body']['authMembershipsMfa']);
+ $this->assertTrue($response['body']['authMembershipsUserId']);
+ $this->assertTrue($response['body']['authMembershipsUserPhone']);
+ $this->assertTrue($response['body']['authInvalidateSessions']);
+ $this->assertTrue($response['body']['smtpEnabled']);
+ $this->assertSame('Custom sender', $response['body']['smtpSenderName']);
+ $this->assertSame('email@custom.com', $response['body']['smtpSenderEmail']);
+ $this->assertSame('Reply sender', $response['body']['smtpReplyToName']);
+ $this->assertSame('replyto@custom.com', $response['body']['smtpReplyToEmail']);
+ $this->assertSame('maildev', $response['body']['smtpHost']);
+ $this->assertSame(1025, $response['body']['smtpPort']);
+ $this->assertSame('', $response['body']['smtpUsername']);
+ $this->assertSame('', $response['body']['smtpPassword']); // Write only
+ $this->assertSame('', $response['body']['smtpSecure']);
+ $this->assertSame(3, $response['body']['pingCount']);
+
+ $this->assertIsString($response['body']['pingedAt']);
+ $this->assertNotEmpty($response['body']['pingedAt']);
+ $this->assertNotFalse(\strtotime($response['body']['pingedAt']));
+
+ $this->assertCount(2, $response['body']['labels']);
+ $this->assertEquals('custom1', $response['body']['labels'][0]);
+ $this->assertEquals('custom2', $response['body']['labels'][1]);
+
+ $this->assertCount(2, $response['body']['devKeys']);
+ $this->assertEquals('Custom key 1', $response['body']['devKeys'][0]['name']);
+ $this->assertEquals('Custom key 2', $response['body']['devKeys'][1]['name']);
+ $this->assertEquals('2099-05-07T09:23:30.713+00:00', $response['body']['devKeys'][0]['expire']);
+ $this->assertEquals('2099-05-07T11:23:30.713+00:00', $response['body']['devKeys'][1]['expire']);
+
+ foreach ($response['body']['devKeys'] as $devKey) {
+ $this->assertIsString($devKey['$id']);
+ $this->assertNotEmpty($devKey['$id']);
+
+ $this->assertIsString($devKey['secret']);
+ $this->assertNotEmpty($devKey['secret']);
+
+ $this->assertIsString($devKey['accessedAt']);
+ $this->assertEmpty($devKey['accessedAt']);
+
+ $this->assertIsString($devKey['$createdAt']);
+ $this->assertNotEmpty($devKey['$createdAt']);
+ $this->assertNotFalse(\strtotime($devKey['$createdAt']));
+
+ $this->assertIsString($devKey['$updatedAt']);
+ $this->assertNotEmpty($devKey['$updatedAt']);
+ $this->assertNotFalse(\strtotime($devKey['$updatedAt']));
+
+ $this->assertIsArray($devKey['sdks']);
+ $this->assertCount(0, $devKey['sdks']);
+ }
+
+ $this->assertCount(2, $response['body']['authMockNumbers']);
+ $this->assertEquals('+421123456789', $response['body']['authMockNumbers'][0]['phone']);
+ $this->assertEquals('+420987654321', $response['body']['authMockNumbers'][1]['phone']);
+ $this->assertEquals('123456', $response['body']['authMockNumbers'][0]['otp']);
+ $this->assertEquals('654321', $response['body']['authMockNumbers'][1]['otp']);
+
+ foreach ($response['body']['authMockNumbers'] as $mockNumber) {
+ $this->assertIsString($mockNumber['$createdAt']);
+ $this->assertNotEmpty($mockNumber['$createdAt']);
+ $this->assertNotFalse(\strtotime($mockNumber['$createdAt']));
+
+ $this->assertIsString($mockNumber['$updatedAt']);
+ $this->assertNotEmpty($mockNumber['$updatedAt']);
+ $this->assertNotFalse(\strtotime($mockNumber['$updatedAt']));
+
+ $this->assertIsString($mockNumber['phone']);
+ $this->assertNotEmpty($mockNumber['phone']);
+
+ $this->assertIsString($mockNumber['otp']);
+ $this->assertNotEmpty($mockNumber['otp']);
+ }
+
+ $this->assertIsArray($response['body']['oAuthProviders']);
+ $this->assertGreaterThan(0, count($response['body']['oAuthProviders']));
+
+ $githubProvider = null;
+ foreach ($response['body']['oAuthProviders'] as $provider) {
+ $this->assertIsString($provider['key']);
+ $this->assertNotEmpty($provider['key']);
+
+ $this->assertIsString($provider['name']);
+ $this->assertIsString($provider['appId']);
+ $this->assertIsString($provider['secret']);
+ $this->assertIsBool($provider['enabled']);
+
+ if ($provider['key'] === 'github') {
+ $githubProvider = $provider;
+ }
+ }
+
+ $this->assertNotNull($githubProvider, 'GitHub provider not found');
+ $this->assertEquals('github-client-id', $githubProvider['appId']);
+ $this->assertEquals('', $githubProvider['secret']); // Write only
+ $this->assertEquals(false, $githubProvider['enabled']);
+
+ $this->assertIsArray($response['body']['platforms']);
+ $this->assertCount(1, $response['body']['platforms']);
+ $this->assertIsString($response['body']['platforms'][0]['$id']);
+ $this->assertNotEmpty($response['body']['platforms'][0]['$id']);
+ $this->assertEquals('Web App', $response['body']['platforms'][0]['name']);
+ $this->assertEquals('web', $response['body']['platforms'][0]['type']);
+ $this->assertEquals('localhost', $response['body']['platforms'][0]['hostname']);
+
+ $this->assertIsString($response['body']['platforms'][0]['$createdAt']);
+ $this->assertNotEmpty($response['body']['platforms'][0]['$createdAt']);
+ $this->assertNotFalse(\strtotime($response['body']['platforms'][0]['$createdAt']));
+
+ $this->assertIsString($response['body']['platforms'][0]['$updatedAt']);
+ $this->assertNotEmpty($response['body']['platforms'][0]['$updatedAt']);
+ $this->assertNotFalse(\strtotime($response['body']['platforms'][0]['$updatedAt']));
+
+ $this->assertArrayHasKey('webhooks', $response['body']);
+ $this->assertIsArray($response['body']['webhooks']);
+ $this->assertCount(1, $response['body']['webhooks']);
+ $this->assertIsString($response['body']['webhooks'][0]['$id']);
+ $this->assertNotEmpty($response['body']['webhooks'][0]['$id']);
+ $this->assertEquals('Webhook Test', $response['body']['webhooks'][0]['name']);
+ $this->assertEquals('https://appwrite.io', $response['body']['webhooks'][0]['url']);
+ $this->assertContains('users.*.create', $response['body']['webhooks'][0]['events']);
+ $this->assertContains('users.*.update.email', $response['body']['webhooks'][0]['events']);
+ $this->assertCount(2, $response['body']['webhooks'][0]['events']);
+ $this->assertTrue($response['body']['webhooks'][0]['tls']);
+ $this->assertEquals('username', $response['body']['webhooks'][0]['authUsername']);
+ $this->assertEquals('password', $response['body']['webhooks'][0]['authPassword']);
+ $this->assertTrue($response['body']['webhooks'][0]['enabled']);
+ $this->assertIsString($response['body']['webhooks'][0]['secret']);
+ $this->assertNotEmpty($response['body']['webhooks'][0]['secret']);
+ $this->assertIsString($response['body']['webhooks'][0]['$createdAt']);
+ $this->assertNotEmpty($response['body']['webhooks'][0]['$createdAt']);
+ $this->assertNotFalse(\strtotime($response['body']['webhooks'][0]['$createdAt']));
+ $this->assertIsString($response['body']['webhooks'][0]['$updatedAt']);
+ $this->assertNotEmpty($response['body']['webhooks'][0]['$updatedAt']);
+ $this->assertNotFalse(\strtotime($response['body']['webhooks'][0]['$updatedAt']));
+
+ $this->assertArrayHasKey('keys', $response['body']);
+ $this->assertIsArray($response['body']['keys']);
+ $this->assertCount(1, $response['body']['keys']);
+ $this->assertIsString($response['body']['keys'][0]['$id']);
+ $this->assertNotEmpty($response['body']['keys'][0]['$id']);
+ $this->assertEquals('Key Test', $response['body']['keys'][0]['name']);
+ $this->assertContains('teams.read', $response['body']['keys'][0]['scopes']);
+ $this->assertContains('teams.write', $response['body']['keys'][0]['scopes']);
+ $this->assertCount(2, $response['body']['keys'][0]['scopes']);
+ $this->assertNotEmpty($response['body']['keys'][0]['secret']);
+ $this->assertEmpty($response['body']['keys'][0]['accessedAt']);
+ $this->assertIsArray($response['body']['keys'][0]['sdks']);
+ $this->assertCount(0, $response['body']['keys'][0]['sdks']);
+ $this->assertIsString($response['body']['keys'][0]['$createdAt']);
+ $this->assertNotEmpty($response['body']['keys'][0]['$createdAt']);
+ $this->assertNotFalse(\strtotime($response['body']['keys'][0]['$createdAt']));
+ $this->assertIsString($response['body']['keys'][0]['$updatedAt']);
+ $this->assertNotEmpty($response['body']['keys'][0]['$updatedAt']);
+ $this->assertNotFalse(\strtotime($response['body']['keys'][0]['$updatedAt']));
+
+ $authsKeys = [
+ 'authEmailPassword',
+ 'authUsersAuthMagicURL',
+ 'authEmailOtp',
+ 'authAnonymous',
+ 'authInvites',
+ 'authJWT',
+ 'authPhone',
+ ];
+ foreach ($authsKeys as $authsKey) {
+ $this->assertTrue($response['body'][$authsKey], 'Auth method should be enabled: ' . $authsKey);
+ }
+
+ $serviceKeys = [
+ 'serviceStatusForAccount',
+ 'serviceStatusForAvatars',
+ 'serviceStatusForDatabases',
+ 'serviceStatusForTablesdb',
+ 'serviceStatusForLocale',
+ 'serviceStatusForHealth',
+ 'serviceStatusForProject',
+ 'serviceStatusForStorage',
+ 'serviceStatusForTeams',
+ 'serviceStatusForUsers',
+ 'serviceStatusForVcs',
+ 'serviceStatusForSites',
+ 'serviceStatusForFunctions',
+ 'serviceStatusForProxy',
+ 'serviceStatusForGraphql',
+ 'serviceStatusForMigrations',
+ 'serviceStatusForMessaging',
+ ];
+ foreach ($serviceKeys as $serviceKey) {
+ $this->assertTrue($response['body'][$serviceKey], 'Service should be enabled: ' . $serviceKey);
+ }
+
+ $protocolKeys = [
+ 'protocolStatusForRest',
+ 'protocolStatusForGraphql',
+ 'protocolStatusForWebsocket',
+ ];
+ foreach ($protocolKeys as $protocolKey) {
+ $this->assertTrue($response['body'][$protocolKey], 'Protocol should be enabled: ' . $protocolKey);
+ }
+
+ // Ensure booleans can be falsy
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => false
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => false
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => false
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'userId' => false,
+ 'userEmail' => false,
+ 'userPhone' => false,
+ 'userName' => false,
+ 'userMFA' => false,
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => false
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ // Toggle auth methods, services, protocols
+
+ $authMethods = ['email-password', 'magic-url', 'email-otp', 'anonymous', 'invites', 'jwt', 'phone'];
+ foreach ($authMethods as $authMethod) {
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/auth-methods/' . $authMethod,
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()),
+ [
+ 'enabled' => false,
+ ],
+ );
+ $this->assertEquals(200, $response['headers']['status-code']);
+ }
+
+ $protocols = ['rest', 'graphql', 'websocket'];
+ foreach ($protocols as $protocol) {
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/protocols/' . $protocol,
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()),
+ [
+ 'enabled' => false,
+ ],
+ );
+ $this->assertEquals(200, $response['headers']['status-code']);
+ }
+
+ $services = [
+ 'account',
+ 'avatars',
+ 'databases',
+ 'tablesdb',
+ 'locale',
+ 'health',
+ 'project',
+ 'storage',
+ 'teams',
+ 'users',
+ 'vcs',
+ 'sites',
+ 'functions',
+ 'proxy',
+ 'graphql',
+ 'migrations',
+ 'messaging',
+ ];
+
+ foreach ($services as $service) {
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/services/' . $service,
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()),
+ [
+ 'enabled' => false,
+ ],
+ );
+ $this->assertEquals(200, $response['headers']['status-code']);
+ }
+
+ // Configure SMTP
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/smtp',
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()),
+ [
+ 'enabled' => false,
+ 'host' => 'customhost.com',
+ 'port' => 4444,
+ 'username' => 'myuser',
+ 'password' => 'mypassword',
+ 'secure' => 'ssl',
+ ],
+ );
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
+ ], $this->getHeaders()));
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $this->assertFalse($response['body']['authPasswordDictionary']);
+ $this->assertFalse($response['body']['authPersonalDataCheck']);
+ $this->assertFalse($response['body']['authSessionAlerts']);
+ $this->assertFalse($response['body']['authMembershipsUserName']);
+ $this->assertFalse($response['body']['authMembershipsUserEmail']);
+ $this->assertFalse($response['body']['authMembershipsMfa']);
+ $this->assertFalse($response['body']['authMembershipsUserId']);
+ $this->assertFalse($response['body']['authMembershipsUserPhone']);
+ $this->assertFalse($response['body']['authInvalidateSessions']);
+ $this->assertFalse($response['body']['smtpEnabled']);
+ $this->assertSame('customhost.com', $response['body']['smtpHost']);
+ $this->assertSame(4444, $response['body']['smtpPort']);
+ $this->assertSame('myuser', $response['body']['smtpUsername']);
+ $this->assertSame('', $response['body']['smtpPassword']); // Write only
+ $this->assertSame('ssl', $response['body']['smtpSecure']);
+
+ $authsKeys = [
+ 'authEmailPassword',
+ 'authUsersAuthMagicURL',
+ 'authEmailOtp',
+ 'authAnonymous',
+ 'authInvites',
+ 'authJWT',
+ 'authPhone',
+ ];
+ foreach ($authsKeys as $authsKey) {
+ $this->assertFalse($response['body'][$authsKey], 'Auth method should be disabled: ' . $authsKey);
+ }
+
+ $serviceKeys = [
+ 'serviceStatusForAccount',
+ 'serviceStatusForAvatars',
+ 'serviceStatusForDatabases',
+ 'serviceStatusForTablesdb',
+ 'serviceStatusForLocale',
+ 'serviceStatusForHealth',
+ 'serviceStatusForProject',
+ 'serviceStatusForStorage',
+ 'serviceStatusForTeams',
+ 'serviceStatusForUsers',
+ 'serviceStatusForVcs',
+ 'serviceStatusForSites',
+ 'serviceStatusForFunctions',
+ 'serviceStatusForProxy',
+ 'serviceStatusForGraphql',
+ 'serviceStatusForMigrations',
+ 'serviceStatusForMessaging',
+ ];
+ foreach ($serviceKeys as $serviceKey) {
+ $this->assertFalse($response['body'][$serviceKey], 'Service should be disabled: ' . $serviceKey);
+ }
+
+ $protocolKeys = [
+ 'protocolStatusForRest',
+ 'protocolStatusForGraphql',
+ 'protocolStatusForWebsocket',
+ ];
+ foreach ($protocolKeys as $protocolKey) {
+ $this->assertFalse($response['body'][$protocolKey], 'Protocol should be disabled: ' . $protocolKey);
+ }
+
/**
* Test for FAILURE
*/
$response = $this->client->call(Client::METHOD_GET, '/projects/empty', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(404, $response['headers']['status-code']);
@@ -821,9 +1525,10 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/'.$projectId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
- $this->assertEquals(400, $response['headers']['status-code']);
+ $this->assertEquals(404, $response['headers']['status-code']);
}
public function testGetProjectUsage(): void
@@ -831,49 +1536,6 @@ class ProjectsConsoleClientTest extends Scope
$this->markTestIncomplete(
'This test is failing right now due to functions collection.'
);
- /**
- * Test for SUCCESS
- */
- $response = $this->client->call(Client::METHOD_GET, '/project/usage', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'startDate' => UsageTest::getToday(),
- 'endDate' => UsageTest::getTomorrow(),
- ]);
-
- $this->assertEquals(200, $response['headers']['status-code']);
- $this->assertEquals(8, count($response['body']));
- $this->assertNotEmpty($response['body']);
- $this->assertIsArray($response['body']['requests']);
- $this->assertIsArray($response['body']['network']);
- $this->assertIsNumeric($response['body']['executionsTotal']);
- $this->assertIsNumeric($response['body']['rowsTotal']);
- $this->assertIsNumeric($response['body']['databasesTotal']);
- $this->assertIsNumeric($response['body']['bucketsTotal']);
- $this->assertIsNumeric($response['body']['usersTotal']);
- $this->assertIsNumeric($response['body']['filesStorageTotal']);
- $this->assertIsNumeric($response['body']['deploymentStorageTotal']);
- $this->assertIsNumeric($response['body']['authPhoneTotal']);
- $this->assertIsNumeric($response['body']['authPhoneEstimate']);
-
-
- /**
- * Test for FAILURE
- */
- $response = $this->client->call(Client::METHOD_GET, '/projects/empty', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()));
-
- $this->assertEquals(404, $response['headers']['status-code']);
-
- $response = $this->client->call(Client::METHOD_GET, '/projects/id-is-really-long-id-is-really-long-id-is-really-long-id-is-really-long', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()));
-
- $this->assertEquals(400, $response['headers']['status-code']);
}
public function testUpdateProject(): void
@@ -894,6 +1556,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test',
@@ -910,6 +1573,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test 2',
@@ -930,6 +1594,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => '',
@@ -954,6 +1619,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/smtp', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'enabled' => true,
'senderEmail' => 'mailer@appwrite.io',
@@ -971,13 +1637,15 @@ class ProjectsConsoleClientTest extends Scope
$this->assertEquals($smtpHost, $response['body']['smtpHost']);
$this->assertEquals($smtpPort, $response['body']['smtpPort']);
$this->assertEquals($smtpUsername, $response['body']['smtpUsername']);
- $this->assertEquals($smtpPassword, $response['body']['smtpPassword']);
+ // smtpPassword is write-only: the stored password must never leak in responses
+ $this->assertEquals('', $response['body']['smtpPassword']);
$this->assertEquals('', $response['body']['smtpSecure']);
// Check the project
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -987,7 +1655,8 @@ class ProjectsConsoleClientTest extends Scope
$this->assertEquals($smtpHost, $response['body']['smtpHost']);
$this->assertEquals($smtpPort, $response['body']['smtpPort']);
$this->assertEquals($smtpUsername, $response['body']['smtpUsername']);
- $this->assertEquals($smtpPassword, $response['body']['smtpPassword']);
+ // smtpPassword is write-only: the stored password must never leak in responses
+ $this->assertEquals('', $response['body']['smtpPassword']);
$this->assertEquals('', $response['body']['smtpSecure']);
/**
@@ -996,6 +1665,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/smtp', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'enabled' => true,
'senderEmail' => 'fail@appwrite.io',
@@ -1035,6 +1705,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test',
@@ -1121,6 +1792,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -1129,10 +1801,51 @@ class ProjectsConsoleClientTest extends Scope
$this->assertEquals('verification', $response['body']['type']);
$this->assertEquals('en-us', $response['body']['locale']);
+ /** Update Email template, fail due to SMTP disabled */
+ $projectWithoutSmtp = $this->setupProject([
+ 'projectId' => ID::unique(),
+ 'name' => 'Project Without SMTP',
+ 'region' => System::getEnv('_APP_REGION', 'default')
+ ]);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectWithoutSmtp . '/templates/email/verification/en-us', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
+ ], $this->getHeaders()), [
+ 'subject' => 'Please verify your email',
+ 'message' => 'Please verify your email {{url}}',
+ 'senderName' => 'Appwrite Custom',
+ 'senderEmail' => 'custom@appwrite.io',
+ ]);
+
+ $this->assertEquals(400, $response['headers']['status-code']);
+
+ /** Configure custom SMTP pointing to maildev, so changing template is allowed */
+ $smtpHost = 'maildev';
+ $smtpPort = 1025;
+ $smtpUsername = 'user';
+ $smtpPassword = 'password';
+ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/smtp', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
+ ], $this->getHeaders()), [
+ 'enabled' => true,
+ 'senderEmail' => 'mailer@appwrite.io',
+ 'senderName' => 'Mailer',
+ 'host' => $smtpHost,
+ 'port' => $smtpPort,
+ 'username' => $smtpUsername,
+ 'password' => $smtpPassword,
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
/** Update Email template */
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'subject' => 'Please verify your email',
'message' => 'Please verify your email {{url}}',
@@ -1152,6 +1865,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -1161,42 +1875,226 @@ class ProjectsConsoleClientTest extends Scope
$this->assertEquals('verification', $response['body']['type']);
$this->assertEquals('en-us', $response['body']['locale']);
$this->assertEquals('Please verify your email {{url}}', $response['body']['message']);
+ }
- // Temporary disabled until implemented
- // /** Get Default SMS Template */
- // $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/sms/verification/en-us', array_merge([
- // 'content-type' => 'application/json',
- // 'x-appwrite-project' => $this->getProject()['$id'],
- // ], $this->getHeaders()));
+ #[Group('smtpAndTemplates')]
+ public function testSessionAlertLocaleFallback(): void
+ {
+ $smtpHost = 'maildev';
+ $smtpPort = 1025;
+ $smtpUsername = 'user';
+ $smtpPassword = 'password';
- // $this->assertEquals(200, $response['headers']['status-code']);
- // $this->assertEquals('verification', $response['body']['type']);
- // $this->assertEquals('en-us', $response['body']['locale']);
- // $this->assertEquals('{{token}}', $response['body']['message']);
+ /** Create team */
+ $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'teamId' => ID::unique(),
+ 'name' => 'Session Alert Locale Fallback Test Team',
+ ]);
+ $this->assertEquals(201, $team['headers']['status-code']);
+ $teamId = $team['body']['$id'];
- // /** Update SMS template */
- // $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/sms/verification/en-us', array_merge([
- // 'content-type' => 'application/json',
- // 'x-appwrite-project' => $this->getProject()['$id'],
- // ], $this->getHeaders()), [
- // 'message' => 'Please verify your email {{token}}',
- // ]);
+ /** Create project */
+ $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
+ ], $this->getHeaders()), [
+ 'projectId' => ID::unique(),
+ 'name' => 'Session Alert Locale Fallback Test',
+ 'teamId' => $teamId,
+ 'region' => System::getEnv('_APP_REGION', 'default'),
+ ]);
+ $this->assertEquals(201, $project['headers']['status-code']);
+ $projectId = $project['body']['$id'];
- // $this->assertEquals(200, $response['headers']['status-code']);
- // $this->assertEquals('verification', $response['body']['type']);
- // $this->assertEquals('en-us', $response['body']['locale']);
- // $this->assertEquals('Please verify your email {{token}}', $response['body']['message']);
+ /** Configure custom SMTP pointing to maildev */
+ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/smtp', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
+ ], $this->getHeaders()), [
+ 'enabled' => true,
+ 'senderEmail' => 'mailer@appwrite.io',
+ 'senderName' => 'Mailer',
+ 'host' => $smtpHost,
+ 'port' => $smtpPort,
+ 'username' => $smtpUsername,
+ 'password' => $smtpPassword,
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
- // /** Get Updated SMS Template */
- // $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/sms/verification/en-us', array_merge([
- // 'content-type' => 'application/json',
- // 'x-appwrite-project' => $this->getProject()['$id'],
- // ], $this->getHeaders()));
+ /**
+ * Set custom sessionAlert template with no explicit locale.
+ * When locale is omitted, the server stores it under the request's
+ * default locale (en), which is the same slot used as the system-wide
+ * fallback when a session's locale has no dedicated template.
+ */
+ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
+ ], $this->getHeaders()), [
+ 'type' => 'sessionAlert',
+ // Intentionally no locale
+ 'subject' => 'Fallback sign-in alert',
+ 'message' => 'Fallback sign-in alert body',
+ 'senderName' => 'Fallback Mailer',
+ 'senderEmail' => 'fallback@appwrite.io',
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals('Fallback sign-in alert', $response['body']['subject']);
+ $this->assertEquals('Fallback sign-in alert body', $response['body']['message']);
+ $this->assertEquals('Fallback Mailer', $response['body']['senderName']);
+ $this->assertEquals('fallback@appwrite.io', $response['body']['senderEmail']);
- // $this->assertEquals(200, $response['headers']['status-code']);
- // $this->assertEquals('verification', $response['body']['type']);
- // $this->assertEquals('en-us', $response['body']['locale']);
- // $this->assertEquals('Please verify your email {{token}}', $response['body']['message']);
+ /** Set custom sessionAlert template for Slovak locale */
+ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
+ ], $this->getHeaders()), [
+ 'type' => 'sessionAlert',
+ 'locale' => 'sk',
+ 'subject' => 'Slovak sign-in alert',
+ 'message' => 'Slovak sign-in alert body',
+ 'senderName' => 'Slovak Mailer',
+ 'senderEmail' => 'sk@appwrite.io',
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals('Slovak sign-in alert', $response['body']['subject']);
+ $this->assertEquals('Slovak sign-in alert body', $response['body']['message']);
+ $this->assertEquals('Slovak Mailer', $response['body']['senderName']);
+ $this->assertEquals('sk@appwrite.io', $response['body']['senderEmail']);
+
+ /** Enable session alerts */
+ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/auth/session-alerts', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
+ ], $this->getHeaders()), [
+ 'alerts' => true,
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ /** Verify alerts are enabled */
+ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
+ ], $this->getHeaders()));
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertTrue($response['body']['authSessionAlerts']);
+
+ /** Create user (email + password) in the project */
+ $userEmail = 'session-alert-' . uniqid() . '@appwrite.io';
+ $password = 'password';
+ $response = $this->client->call(Client::METHOD_POST, '/account', [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], [
+ 'userId' => ID::unique(),
+ 'email' => $userEmail,
+ 'password' => $password,
+ 'name' => 'Session Alert User',
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+
+ /**
+ * Prime first session — the listener suppresses the alert on the very
+ * first session of a user, so this session is setup only.
+ */
+ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], [
+ 'email' => $userEmail,
+ 'password' => $password,
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+
+ /** Create a new session with no locale — expect fallback (en) template */
+ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], [
+ 'email' => $userEmail,
+ 'password' => $password,
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+
+ /**
+ * Emails are delivered asynchronously via the mail queue, so maildev may
+ * still be catching up. The probe callback forces getLastEmailByAddress
+ * to keep polling until an email matching the expected `from` address
+ * appears — i.e. we await the new email rather than returning an older
+ * one already in the inbox from a previous session.
+ */
+ $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) {
+ $this->assertEquals('fallback@appwrite.io', $email['from'][0]['address']);
+ });
+ $this->assertEquals('Fallback sign-in alert', $lastEmail['subject']);
+ $this->assertEquals('Fallback Mailer', $lastEmail['from'][0]['name']);
+ $this->assertStringContainsString('Fallback sign-in alert body', $lastEmail['html']);
+
+ /** Create a new session with German locale — expect fallback (en) template */
+ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-locale' => 'de',
+ ], [
+ 'email' => $userEmail,
+ 'password' => $password,
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+
+ /** Probe on `from` address ensures we await a fallback-shaped email */
+ $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) {
+ $this->assertEquals('fallback@appwrite.io', $email['from'][0]['address']);
+ });
+ $this->assertEquals('Fallback sign-in alert', $lastEmail['subject']);
+ $this->assertEquals('Fallback Mailer', $lastEmail['from'][0]['name']);
+ $this->assertStringContainsString('Fallback sign-in alert body', $lastEmail['html']);
+
+ /** Create a new session with Slovak locale — expect Slovak template */
+ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-locale' => 'sk',
+ ], [
+ 'email' => $userEmail,
+ 'password' => $password,
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+
+ /** Probe on `from` address ensures we await the Slovak email specifically */
+ $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) {
+ $this->assertEquals('sk@appwrite.io', $email['from'][0]['address']);
+ });
+ $this->assertEquals('Slovak sign-in alert', $lastEmail['subject']);
+ $this->assertEquals('Slovak Mailer', $lastEmail['from'][0]['name']);
+ $this->assertStringContainsString('Slovak sign-in alert body', $lastEmail['html']);
+
+ /** Cleanup — delete the project */
+ $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $projectId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals(204, $response['headers']['status-code']);
+
+ /** Cleanup — delete the team */
+ $response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals(204, $response['headers']['status-code']);
}
public function testUpdateProjectAuthDuration(): void
@@ -1208,6 +2106,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -1219,6 +2118,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'duration' => 10, // Set session duration to 10 seconds
]);
@@ -1286,6 +2186,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'duration' => 600, // seconds
]);
@@ -1306,6 +2207,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'duration' => TOKEN_EXPIRATION_LOGIN_LONG,
]);
@@ -1317,6 +2219,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -1340,6 +2243,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Session Invalidation Test Project',
@@ -1354,6 +2258,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -1362,6 +2267,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/session-invalidation', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'enabled' => false,
]);
@@ -1370,6 +2276,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -1378,6 +2285,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/session-invalidation', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'enabled' => true,
]);
@@ -1386,6 +2294,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -1410,6 +2319,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test',
@@ -1429,6 +2339,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/oauth2', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'provider' => $key,
'appId' => 'AppId-' . ucfirst($key),
@@ -1442,6 +2353,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -1453,7 +2365,7 @@ class ProjectsConsoleClientTest extends Scope
foreach ($response['body']['oAuthProviders'] as $responseProvider) {
if ($responseProvider['key'] === $key) {
$this->assertEquals('AppId-' . ucfirst($key), $responseProvider['appId']);
- $this->assertEquals('Secret-' . ucfirst($key), $responseProvider['secret']);
+ $this->assertEmpty($responseProvider['secret']);
$this->assertFalse($responseProvider['enabled']);
$asserted = true;
break;
@@ -1469,6 +2381,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/oauth2', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'provider' => $key,
'enabled' => $i === 0 ? false : true // On first provider, test enabled=false
@@ -1483,6 +2396,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -1512,6 +2426,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/oauth2', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'provider' => 'unknown',
'appId' => 'AppId',
@@ -1539,6 +2454,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test',
@@ -1583,6 +2499,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/' . $index, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'status' => false,
]);
@@ -1593,6 +2510,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -1679,6 +2597,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/' . $index, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'status' => true,
]);
@@ -1696,6 +2615,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/limit', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'limit' => 1,
]);
@@ -1774,6 +2694,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/limit', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'limit' => 0,
]);
@@ -1805,24 +2726,13 @@ class ProjectsConsoleClientTest extends Scope
'region' => System::getEnv('_APP_REGION', 'default')
]);
- /**
- * Test for failure
- */
- $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'limit' => 0,
- ]);
-
- $this->assertEquals(400, $response['headers']['status-code']);
-
/**
* Test for SUCCESS
*/
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'limit' => 1,
]);
@@ -1894,6 +2804,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'limit' => 10,
]);
@@ -1906,25 +2817,13 @@ class ProjectsConsoleClientTest extends Scope
$data = $this->setupProjectWithAuthLimit();
$id = $data['projectId'];
- /**
- * Test for Failure
- */
- $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'limit' => 25,
- ]);
-
- $this->assertEquals(400, $response['headers']['status-code']);
-
-
/**
* Test for Success
*/
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'limit' => 1,
]);
@@ -1998,6 +2897,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'limit' => 0,
]);
@@ -2244,6 +3144,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), []);
$this->assertEquals(400, $response['headers']['status-code']);
@@ -2253,6 +3154,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => [
'phone' => '+1655513432',
@@ -2266,6 +3168,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => [
[
@@ -2281,6 +3184,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => [
[
@@ -2296,6 +3200,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => [
[
@@ -2311,6 +3216,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => [
[
@@ -2326,6 +3232,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => [
[
@@ -2341,6 +3248,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => [
[
@@ -2368,6 +3276,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => $numbers
]);
@@ -2381,6 +3290,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => []
]);
@@ -2389,6 +3299,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => [
[
@@ -2481,6 +3392,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-dictionary', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'enabled' => true,
]);
@@ -2538,6 +3450,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'limit' => 0,
]);
@@ -2551,6 +3464,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-dictionary', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'enabled' => false,
]);
@@ -2570,6 +3484,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/personal-data', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'enabled' => true,
]);
@@ -2682,6 +3597,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/personal-data', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders()), [
'enabled' => false,
]);
@@ -2690,120 +3606,6 @@ class ProjectsConsoleClientTest extends Scope
$this->assertEquals(false, $response['body']['authPersonalDataCheck']);
}
- public function testUpdateProjectServicesAll(): void
- {
- $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'cookie' => 'a_session_console=' . $this->getRoot()['session'],
- ]), [
- 'teamId' => ID::unique(),
- 'name' => 'Project Test',
- ]);
-
- $this->assertEquals(201, $team['headers']['status-code']);
- $this->assertNotEmpty($team['body']['$id']);
-
- $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'cookie' => 'a_session_console=' . $this->getRoot()['session'],
- ]), [
- 'projectId' => ID::unique(),
- 'name' => 'Project Test',
- 'teamId' => $team['body']['$id'],
- 'region' => System::getEnv('_APP_REGION', 'default')
- ]);
-
- $this->assertEquals(201, $project['headers']['status-code']);
- $this->assertNotEmpty($project['body']['$id']);
-
- $id = $project['body']['$id'];
-
- // Bulk disable should no longer work
- $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/all', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-response-format' => '1.9.0',
- 'cookie' => 'a_session_console=' . $this->getRoot()['session'],
- ]), [
- 'status' => false,
- ]);
-
- $this->assertEquals(405, $response['headers']['status-code']);
- $this->assertEquals('general_not_implemented', $response['body']['type']);
-
- // Bulk enable should no longer work
- $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/all', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-response-format' => '1.9.0',
- 'cookie' => 'a_session_console=' . $this->getRoot()['session'],
- ]), [
- 'status' => true,
- ]);
-
- $this->assertEquals(405, $response['headers']['status-code']);
- $this->assertEquals('general_not_implemented', $response['body']['type']);
- }
-
- public function testUpdateProjectApisAll(): void
- {
- $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'cookie' => 'a_session_console=' . $this->getRoot()['session'],
- ]), [
- 'teamId' => ID::unique(),
- 'name' => 'Project Test',
- ]);
-
- $this->assertEquals(201, $team['headers']['status-code']);
- $this->assertNotEmpty($team['body']['$id']);
-
- $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'cookie' => 'a_session_console=' . $this->getRoot()['session'],
- ]), [
- 'projectId' => ID::unique(),
- 'name' => 'Project Test',
- 'teamId' => $team['body']['$id'],
- 'region' => System::getEnv('_APP_REGION', 'default')
- ]);
-
- $this->assertEquals(201, $project['headers']['status-code']);
- $this->assertNotEmpty($project['body']['$id']);
-
- $id = $project['body']['$id'];
-
- // Bulk disable should no longer work
- $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api/all', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-response-format' => '1.9.0',
- 'cookie' => 'a_session_console=' . $this->getRoot()['session'],
- ]), [
- 'status' => false,
- ]);
-
- $this->assertEquals(405, $response['headers']['status-code']);
- $this->assertEquals('general_not_implemented', $response['body']['type']);
-
- // Bulk enable should no longer work
- $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api/all', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-response-format' => '1.9.0',
- 'cookie' => 'a_session_console=' . $this->getRoot()['session'],
- ]), [
- 'status' => true,
- ]);
-
- $this->assertEquals(405, $response['headers']['status-code']);
- $this->assertEquals('general_not_implemented', $response['body']['type']);
- }
-
public function testUpdateProjectApiStatus(): void
{
$team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([
@@ -2821,6 +3623,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]), [
'projectId' => ID::unique(),
@@ -2856,6 +3659,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]));
@@ -2885,6 +3689,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]));
@@ -2910,6 +3715,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]), [
'projectId' => ID::unique(),
@@ -2940,6 +3746,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]));
@@ -2962,6 +3769,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]));
@@ -2985,6 +3793,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]), [
'projectId' => ID::unique(),
@@ -3025,6 +3834,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]));
@@ -3101,6 +3911,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]));
@@ -3177,6 +3988,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]));
@@ -3935,6 +4747,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test 2',
@@ -4120,6 +4933,7 @@ class ProjectsConsoleClientTest extends Scope
$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'],
@@ -4127,6 +4941,8 @@ class ProjectsConsoleClientTest extends Scope
$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'];
@@ -4980,6 +5796,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Amazing Project',
@@ -5005,6 +5822,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $project['headers']['status-code']);
@@ -5028,6 +5846,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(404, $project['headers']['status-code']);
@@ -5051,6 +5870,7 @@ class ProjectsConsoleClientTest extends Scope
$project1 = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Amazing Project 1',
@@ -5061,6 +5881,7 @@ class ProjectsConsoleClientTest extends Scope
$project2 = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Amazing Project 2',
@@ -6403,6 +7224,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Test project - Labels 1',
@@ -6447,6 +7269,7 @@ class ProjectsConsoleClientTest extends Scope
$projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::contains('labels', ['nonvip'])->toString(),
@@ -6459,6 +7282,7 @@ class ProjectsConsoleClientTest extends Scope
$projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::contains('labels', ['vip'])->toString(),
@@ -6470,6 +7294,7 @@ class ProjectsConsoleClientTest extends Scope
$projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::contains('labels', ['imagine'])->toString(),
@@ -6482,6 +7307,7 @@ class ProjectsConsoleClientTest extends Scope
$projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::contains('labels', ['nonvip', 'imagine'])->toString(),
@@ -6495,6 +7321,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Test project - Labels 2',
@@ -6523,6 +7350,7 @@ class ProjectsConsoleClientTest extends Scope
$projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::contains('labels', ['imagine'])->toString(),
@@ -6537,6 +7365,7 @@ class ProjectsConsoleClientTest extends Scope
$projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::contains('labels', ['vip'])->toString(),
@@ -6550,6 +7379,7 @@ class ProjectsConsoleClientTest extends Scope
$projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::contains('labels', ['vip'])->toString(),
@@ -6564,6 +7394,7 @@ class ProjectsConsoleClientTest extends Scope
$projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::contains('labels', ['vip', 'imagine'])->toString(),
@@ -6669,6 +7500,7 @@ class ProjectsConsoleClientTest extends Scope
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token,
]);
@@ -6765,6 +7597,7 @@ class ProjectsConsoleClientTest extends Scope
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token,
], [
'name' => $newProjectName,
@@ -6781,6 +7614,7 @@ class ProjectsConsoleClientTest extends Scope
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token,
], [
'name' => $newProjectName,
@@ -6978,6 +7812,7 @@ class ProjectsConsoleClientTest extends Scope
'x-appwrite-mode' => 'admin',
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token,
], [
+ 'variableId' => $variableId,
'key' => 'APP_TEST_' . $variableId,
'value' => 'TESTINGVALUE',
'secret' => false
@@ -6997,6 +7832,7 @@ class ProjectsConsoleClientTest extends Scope
'x-appwrite-mode' => 'admin',
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token,
], [
+ 'variableId' => $variableId,
'key' => 'APP_TEST_' . $variableId,
'value' => 'TESTINGVALUE',
'secret' => false
diff --git a/tests/e2e/Services/Projects/ProjectsCustomServerTest.php b/tests/e2e/Services/Projects/ProjectsCustomServerTest.php
index 313a4d53be..d87c2cbf78 100644
--- a/tests/e2e/Services/Projects/ProjectsCustomServerTest.php
+++ b/tests/e2e/Services/Projects/ProjectsCustomServerTest.php
@@ -10,6 +10,7 @@ use Utopia\System\System;
class ProjectsCustomServerTest extends Scope
{
+ use ProjectsBase;
use ProjectCustom;
use SideServer;
diff --git a/tests/e2e/Services/Projects/Schedules/SchedulesBase.php b/tests/e2e/Services/Projects/Schedules/SchedulesBase.php
index 681e39b662..4baaca4e5b 100644
--- a/tests/e2e/Services/Projects/Schedules/SchedulesBase.php
+++ b/tests/e2e/Services/Projects/Schedules/SchedulesBase.php
@@ -62,8 +62,8 @@ trait SchedulesBase
'scopes' => [
'functions.read',
'functions.write',
- 'execution.read',
- 'execution.write',
+ 'executions.read',
+ 'executions.write',
'messages.read',
'messages.write',
],
diff --git a/tests/e2e/Services/Proxy/ProxyBase.php b/tests/e2e/Services/Proxy/ProxyBase.php
index 59a853bfc8..d0e4c0d793 100644
--- a/tests/e2e/Services/Proxy/ProxyBase.php
+++ b/tests/e2e/Services/Proxy/ProxyBase.php
@@ -2,298 +2,802 @@
namespace Tests\E2E\Services\Proxy;
-use Appwrite\ID;
-use Appwrite\Tests\Async;
-use CURLFile;
use Tests\E2E\Client;
-use Utopia\Console;
+use Utopia\Database\Query;
+use Utopia\System\System;
trait ProxyBase
{
- use Async;
+ use ProxyHelpers;
- protected function listRules(array $params = []): mixed
+ protected function tearDown(): void
{
- $rule = $this->client->call(Client::METHOD_GET, '/proxy/rules', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), $params);
-
- return $rule;
- }
-
- protected function createAPIRule(string $domain): mixed
- {
- $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules/api', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'domain' => $domain,
+ // Cleanup for testRuleVerification test
+ // Required as it uses static domain name
+ $rules = $this->listRules([
+ 'queries' => [
+ Query::endsWith('domain', 'webapp.com')->toString(),
+ Query::limit(1000)->toString(),
+ ]
]);
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ foreach ($rules['body']['rules'] as $rule) {
+ $ruleId = $rule['$id'];
+ $response = $this->deleteRule($ruleId);
+ $this->assertEquals(204, $response['headers']['status-code']);
+ }
- return $rule;
+ if ($rules['body']['total'] > 0) {
+ $rules = $this->listRules([
+ 'queries' => [
+ Query::endsWith('domain', 'webapp.com')->toString(),
+ Query::limit(1)->toString()
+ ]
+ ]);
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $this->assertEquals(0, count($rules['body']['rules']));
+ $this->assertEquals(0, $rules['body']['total']);
+ }
}
- protected function updateRuleVerification(string $ruleId): mixed
- {
- $rule = $this->client->call(Client::METHOD_PATCH, '/proxy/rules/' . $ruleId . '/verification', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), []);
-
- return $rule;
- }
-
- protected function createSiteRule(string $domain, string $siteId, string $branch = ''): mixed
- {
- $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules/site', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'domain' => $domain,
- 'siteId' => $siteId,
- 'branch' => $branch,
- ]);
-
- return $rule;
- }
-
- protected function getRule(string $ruleId): mixed
- {
- $rule = $this->client->call(Client::METHOD_GET, '/proxy/rules/' . $ruleId, array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), []);
-
- return $rule;
- }
-
- protected function createRedirectRule(string $domain, string $url, int $statusCode, string $resourceType, string $resourceId): mixed
- {
- $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules/redirect', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'domain' => $domain,
- 'url' => $url,
- 'statusCode' => $statusCode,
- 'resourceType' => $resourceType,
- 'resourceId' => $resourceId,
- ]);
-
- return $rule;
- }
-
- protected function createFunctionRule(string $domain, string $functionId, string $branch = ''): mixed
- {
- $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules/function', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'domain' => $domain,
- 'functionId' => $functionId,
- 'branch' => $branch,
- ]);
-
- return $rule;
- }
-
- protected function deleteRule(string $ruleId): mixed
- {
- $rule = $this->client->call(Client::METHOD_DELETE, '/proxy/rules/' . $ruleId, array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), []);
-
- return $rule;
- }
-
- protected function setupAPIRule(string $domain): string
+ public function testCreateRule(): void
{
+ $domain = \uniqid() . '-api.myapp.com';
$rule = $this->createAPIRule($domain);
- $this->assertEquals(201, $rule['headers']['status-code'], 'Failed to setup rule: ' . \json_encode($rule));
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->assertEquals($domain, $rule['body']['domain']);
+ $this->assertEquals('manual', $rule['body']['trigger']);
+ $this->assertArrayHasKey('$id', $rule['body']);
+ $this->assertArrayHasKey('domain', $rule['body']);
+ $this->assertArrayHasKey('type', $rule['body']);
+ $this->assertArrayHasKey('redirectUrl', $rule['body']);
+ $this->assertArrayHasKey('redirectStatusCode', $rule['body']);
+ $this->assertArrayHasKey('deploymentResourceType', $rule['body']);
+ $this->assertArrayHasKey('deploymentId', $rule['body']);
+ $this->assertArrayHasKey('deploymentResourceId', $rule['body']);
+ $this->assertArrayHasKey('deploymentVcsProviderBranch', $rule['body']);
+ $this->assertArrayHasKey('logs', $rule['body']);
+ $this->assertArrayHasKey('renewAt', $rule['body']);
- return $rule['body']['$id'];
- }
+ $ruleId = $rule['body']['$id'];
- protected function setupRedirectRule(string $domain, string $url, int $statusCode, string $resourceType, string $resourceId): string
- {
- $rule = $this->createRedirectRule($domain, $url, $statusCode, $resourceType, $resourceId);
+ $rule = $this->createAPIRule($domain);
+ $this->assertEquals(409, $rule['headers']['status-code']);
- $this->assertEquals(201, $rule['headers']['status-code'], 'Failed to setup rule: ' . \json_encode($rule));
-
- return $rule['body']['$id'];
- }
-
- protected function setupFunctionRule(string $domain, string $functionId, string $branch = ''): string
- {
- $rule = $this->createFunctionRule($domain, $functionId, $branch);
-
- $this->assertEquals(201, $rule['headers']['status-code'], 'Failed to setup rule: ' . \json_encode($rule));
-
- return $rule['body']['$id'];
- }
-
- protected function setupSiteRule(string $domain, string $siteId, string $branch = ''): string
- {
- $rule = $this->createSiteRule($domain, $siteId, $branch);
-
- $this->assertEquals(201, $rule['headers']['status-code'], 'Failed to setup rule: ' . \json_encode($rule));
-
- return $rule['body']['$id'];
- }
-
- protected function cleanupRule(string $ruleId): void
- {
$rule = $this->deleteRule($ruleId);
- $this->assertEquals(204, $rule['headers']['status-code'], 'Failed to cleanup rule: ' . \json_encode($rule));
+
+ $this->assertEquals(204, $rule['headers']['status-code']);
}
- protected function cleanupSite(string $siteId): void
+ public function testCreateRuleDeletesOrphanedRule(): void
{
- $site = $this->client->call(Client::METHOD_DELETE, '/sites/' . $siteId, array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), []);
+ $domain = \uniqid() . '-orphan-api.custom.localhost';
+ $orphanProject = $this->getProject(true);
- $this->assertEquals(204, $site['headers']['status-code'], 'Failed to cleanup site: ' . \json_encode($site));
+ $orphanRule = $this->client->call(Client::METHOD_POST, '/proxy/rules/api', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $orphanProject['$id'],
+ 'x-appwrite-key' => $orphanProject['apiKey'],
+ ], [
+ 'domain' => $domain,
+ ]);
+
+ $this->assertEquals(201, $orphanRule['headers']['status-code']);
+ $this->assertEquals($domain, $orphanRule['body']['domain']);
+
+ $duplicateRule = $this->createAPIRule($domain);
+ $this->assertEquals(409, $duplicateRule['headers']['status-code']);
+
+ $deleteProject = $this->client->call(Client::METHOD_DELETE, '/projects/' . $orphanProject['$id'], [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $orphanProject['$id'],
+ 'x-appwrite-key' => $orphanProject['apiKey'],
+ ]);
+
+ $this->assertEquals(204, $deleteProject['headers']['status-code']);
+
+ // Project deletion removes the project document synchronously, while rule cleanup is queued.
+ // Creating the same domain now should clean up that orphaned rule before retrying.
+ $rule = $this->createAPIRule($domain);
+
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->assertEquals($domain, $rule['body']['domain']);
+
+ $rules = $this->listRules([
+ 'queries' => [
+ Query::equal('domain', [$domain])->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $this->assertEquals(1, $rules['body']['total']);
+ $this->assertEquals($rule['body']['$id'], $rules['body']['rules'][0]['$id']);
+
+ $this->cleanupRule($rule['body']['$id']);
}
- protected function cleanupFunction(string $functionId): void
+ public function testCreateRuleSetup(): void
{
- $function = $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), []);
-
- $this->assertEquals(204, $function['headers']['status-code'], 'Failed to cleanup function: ' . \json_encode($function));
+ $ruleId = $this->setupAPIRule(\uniqid() . '-api2.myapp.com');
+ $this->cleanupRule($ruleId);
}
- protected function setupSite(): mixed
+ public function testCreateRuleApex(): void
{
- // Site
- $site = $this->client->call(Client::METHOD_POST, '/sites', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey'],
- ]), [
- 'siteId' => ID::unique(),
- 'name' => 'Proxy site',
- 'framework' => 'other',
- 'adapter' => 'static',
- 'buildRuntime' => 'static-1',
- 'outputDirectory' => './',
- 'buildCommand' => '',
- 'installCommand' => '',
- 'fallbackFile' => '',
- ]);
-
- $this->assertEquals($site['headers']['status-code'], 201, 'Setup site failed with status code: ' . $site['headers']['status-code'] . ' and response: ' . json_encode($site['body'], JSON_PRETTY_PRINT));
-
- $siteId = $site['body']['$id'];
-
- // Deployment
- $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', array_merge([
- 'content-type' => 'multipart/form-data',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey'],
- ]), [
- 'code' => $this->packageSite('static'),
- 'activate' => 'true'
- ]);
-
- $this->assertEquals($deployment['headers']['status-code'], 202, 'Setup deployment failed with status code: ' . $deployment['headers']['status-code'] . ' and response: ' . json_encode($deployment['body'], JSON_PRETTY_PRINT));
- $deploymentId = $deployment['body']['$id'] ?? '';
-
- $this->assertEventually(function () use ($siteId, $deploymentId) {
- $site = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId, array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey'],
- ]));
- $this->assertEquals($deploymentId, $site['body']['deploymentId'], 'Deployment is not activated, deployment: ' . json_encode($site['body'], JSON_PRETTY_PRINT));
- }, 120000, 500);
-
- return ['siteId' => $siteId, 'deploymentId' => $deploymentId];
+ $domain = \uniqid() . '.com';
+ $rule = $this->createAPIRule($domain);
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->assertEquals('unverified', $rule['body']['status']);
}
- protected function setupFunction(): mixed
+ public function testCreateRuleVcs(): void
{
- // Function
- $function = $this->client->call(Client::METHOD_POST, '/functions', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey'],
- ]), [
- 'functionId' => ID::unique(),
- 'runtime' => 'node-22',
- 'name' => 'Proxy Function',
- 'entrypoint' => 'index.js',
- 'commands' => '',
- 'execute' => ['any']
+ $domain = \uniqid() . '-vcs.myapp.com';
+
+ $setup = $this->setupSite();
+ $siteId = $setup['siteId'];
+ $deploymentId = $setup['deploymentId'];
+
+ $this->assertNotEmpty($siteId);
+ $this->assertNotEmpty($deploymentId);
+
+ $rule = $this->createSiteRule('commit-' . $domain, $siteId);
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->cleanupRule($rule['body']['$id']);
+
+ $rule = $this->createSiteRule('branch-' . $domain, $siteId);
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->cleanupRule($rule['body']['$id']);
+
+ $rule = $this->createSiteRule('anything-' . $domain, $siteId);
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->cleanupRule($rule['body']['$id']);
+
+ $sitesDomain = \explode(',', System::getEnv('_APP_DOMAIN_SITES', ''))[0];
+ $domain = \uniqid() . '-vcs.' . $sitesDomain;
+
+ $rule = $this->createSiteRule('commit-' . $domain, $siteId);
+ $this->assertEquals(400, $rule['headers']['status-code']);
+
+ $rule = $this->createSiteRule('branch-' . $domain, $siteId);
+ $this->assertEquals(400, $rule['headers']['status-code']);
+
+ $rule = $this->createSiteRule('subdomain.anything-' . $domain, $siteId);
+ $this->assertEquals(400, $rule['headers']['status-code']);
+
+ $rule = $this->createSiteRule('anything-' . $domain, $siteId);
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->cleanupRule($rule['body']['$id']);
+ }
+
+ public function testCreateAPIRule(): void
+ {
+ $domain = \uniqid() . '-api.custom.localhost';
+
+ $proxyClient = new Client();
+ $proxyClient->setEndpoint('http://appwrite.test');
+ $proxyClient->addHeader('x-appwrite-hostname', $domain);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/versions');
+ $this->assertEquals(401, $response['headers']['status-code']);
+
+ $ruleId = $this->setupAPIRule($domain);
+
+ $this->assertNotEmpty($ruleId);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/versions');
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals(APP_VERSION_STABLE, $response['body']['server']);
+
+ $this->cleanupRule($ruleId);
+
+ $rule = $this->createAPIRule('http://' . $domain);
+ $this->assertEquals(400, $rule['headers']['status-code']);
+
+ $rule = $this->createAPIRule('https://' . $domain);
+ $this->assertEquals(400, $rule['headers']['status-code']);
+
+ $rule = $this->createAPIRule('wss://' . $domain);
+ $this->assertEquals(400, $rule['headers']['status-code']);
+
+ $rule = $this->createAPIRule($domain . '/some-path');
+ $this->assertEquals(400, $rule['headers']['status-code']);
+ }
+
+ public function testCreateRedirectRule(): void
+ {
+ $domain = \uniqid() . '-redirect.custom.localhost';
+
+ $proxyClient = new Client();
+ $proxyClient->setEndpoint('http://appwrite.test');
+ $proxyClient->addHeader('x-appwrite-hostname', $domain);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/todos/1');
+ $this->assertEquals(401, $response['headers']['status-code']);
+
+ $siteId = $this->setupSite()['siteId'];
+
+ $ruleId301 = $this->setupRedirectRule($domain, 'https://jsonplaceholder.typicode.com/todos/1', 301, 'site', $siteId);
+ $this->assertNotEmpty($ruleId301);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/todos/1');
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals(1, $response['body']['id']);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/');
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals(1, $response['body']['id']);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false);
+ $this->assertEquals(301, $response['headers']['status-code']);
+ $this->assertEquals('https://jsonplaceholder.typicode.com/todos/1', $response['headers']['location']);
+
+ $domain = \uniqid() . '-redirect-307.custom.localhost';
+ $ruleId307 = $this->setupRedirectRule($domain, 'https://jsonplaceholder.typicode.com/todos/1', 307, 'site', $siteId);
+ $this->assertNotEmpty($ruleId307);
+
+ $proxyClient = new Client();
+ $proxyClient->setEndpoint('http://appwrite.test');
+ $proxyClient->addHeader('x-appwrite-hostname', $domain);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false);
+ $this->assertEquals(307, $response['headers']['status-code']);
+ $this->assertEquals('https://jsonplaceholder.typicode.com/todos/1', $response['headers']['location']);
+
+ $rules = $this->listRules([
+ 'queries' => [
+ Query::equal('type', ['redirect'])->toString(),
+ Query::equal('trigger', ['manual'])->toString(),
+ Query::equal('deploymentResourceType', ['site'])->toString(),
+ Query::equal('deploymentResourceId', [$siteId])->toString(),
+ ],
]);
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $this->assertEquals(2, $rules['body']['total']);
- $this->assertEquals($function['headers']['status-code'], 201, 'Setup function failed with status code: ' . $function['headers']['status-code'] . ' and response: ' . json_encode($function['body'], JSON_PRETTY_PRINT));
+ // Delete rules before the site to avoid cascade-delete races.
+ $this->cleanupRule($ruleId301);
+ $this->cleanupRule($ruleId307);
+ $this->cleanupSite($siteId);
+ }
- $functionId = $function['body']['$id'];
+ public function testCreateFunctionRule(): void
+ {
+ $domain = \uniqid() . '-function.custom.localhost';
- // Deployment
- $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([
- 'content-type' => 'multipart/form-data',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey'],
- ]), [
- 'code' => $this->packageFunction('basic'),
- 'activate' => 'true'
- ]);
+ $proxyClient = new Client();
+ $proxyClient->setEndpoint('http://appwrite.test');
+ $proxyClient->addHeader('x-appwrite-hostname', $domain);
- $this->assertEquals($deployment['headers']['status-code'], 202, 'Setup deployment failed with status code: ' . $deployment['headers']['status-code'] . ' and response: ' . json_encode($deployment['body'], JSON_PRETTY_PRINT));
- $deploymentId = $deployment['body']['$id'] ?? '';
+ $response = $proxyClient->call(Client::METHOD_GET, '/ping');
+ $this->assertEquals(401, $response['headers']['status-code']);
+
+ $setup = $this->setupFunction();
+ $functionId = $setup['functionId'];
+ $deploymentId = $setup['deploymentId'];
+
+ $this->assertNotEmpty($functionId);
+ $this->assertNotEmpty($deploymentId);
+
+ $ruleId = $this->setupFunctionRule($domain, $functionId);
+ $this->assertNotEmpty($ruleId);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/ping');
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals($functionId, $response['body']['APPWRITE_FUNCTION_ID']);
+
+ $this->cleanupRule($ruleId);
+
+ $this->cleanupFunction($functionId);
$this->assertEventually(function () use ($functionId, $deploymentId) {
- $function = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId, array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey'],
- ]));
- $this->assertEquals($deploymentId, $function['body']['deploymentId'], 'Deployment is not activated, deployment: ' . json_encode($function['body'], JSON_PRETTY_PRINT));
- }, 100000, 500);
+ $rules = $this->listRules([
+ 'queries' => [
+ Query::limit(1)->toString(),
+ Query::equal('type', ['deployment'])->toString(),
+ Query::equal('deploymentResourceType', ['function'])->toString(),
+ Query::equal('deploymentResourceId', [$functionId])->toString(),
+ ]
+ ]);
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $this->assertEquals(0, $rules['body']['total']);
+ $this->assertCount(0, $rules['body']['rules']);
- return ['functionId' => $functionId, 'deploymentId' => $deploymentId];
+ $rules = $this->listRules([
+ 'queries' => [
+ Query::limit(1)->toString(),
+ Query::equal('type', ['deployment'])->toString(),
+ Query::equal('deploymentId', [$deploymentId])->toString()
+ ]
+ ]);
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $this->assertEquals(0, $rules['body']['total']);
+ $this->assertCount(0, $rules['body']['rules']);
+ });
}
- private function packageSite(string $site): CURLFile
+ public function testCreateSiteRule(): void
{
- $stdout = '';
- $stderr = '';
+ $domain = \uniqid() . '-site.custom.localhost';
- $folderPath = realpath(__DIR__ . '/../../../resources/sites') . "/$site";
- $tarPath = "$folderPath/code.tar.gz";
+ $proxyClient = new Client();
+ $proxyClient->setEndpoint('http://appwrite.test');
+ $proxyClient->addHeader('x-appwrite-hostname', $domain);
- Console::execute("cd $folderPath && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $stdout, $stderr);
+ $response = $proxyClient->call(Client::METHOD_GET, '/contact');
+ $this->assertEquals(401, $response['headers']['status-code']);
- if (filesize($tarPath) > 1024 * 1024 * 5) {
- throw new \Exception('Code package is too large. Use the chunked upload method instead.');
+ $setup = $this->setupSite();
+ $siteId = $setup['siteId'];
+ $deploymentId = $setup['deploymentId'];
+
+ $this->assertNotEmpty($siteId);
+ $this->assertNotEmpty($deploymentId);
+
+ $ruleId = $this->setupSiteRule($domain, $siteId);
+ $this->assertNotEmpty($ruleId);
+ $rule = $this->getRule($ruleId);
+ $this->assertSame(200, $rule['headers']['status-code']);
+ $this->assertSame('unverified', $rule['body']['status']);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/contact');
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertStringContainsString('Contact page', $response['body']);
+
+ // Wildcard domains automatically get verified status
+ $domains = [
+ \uniqid() . '.sites.localhost',
+ \uniqid() . '.rebranded.localhost',
+ ];
+ foreach ($domains as $domain) {
+ $wildcardRuleId = $this->setupSiteRule($domain, $siteId);
+ $this->assertNotEmpty($wildcardRuleId);
+ $rule = $this->getRule($wildcardRuleId);
+ $this->assertSame(200, $rule['headers']['status-code']);
+ $this->assertSame('verified', $rule['body']['status']);
+ $this->cleanupRule($wildcardRuleId);
}
- return new CURLFile($tarPath, 'application/x-gzip', \basename($tarPath));
+ $rules = $this->listRules([
+ 'queries' => [
+ Query::limit(1)->toString(),
+ Query::equal('trigger', ['deployment'])->toString(),
+ Query::equal('type', ['deployment'])->toString(),
+ Query::equal('deploymentResourceType', ['site'])->toString(),
+ Query::equal('deploymentResourceId', [$siteId])->toString(),
+ ]
+ ]);
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $this->assertGreaterThan(0, $rules['body']['total']);
+
+ $this->cleanupRule($ruleId);
+
+ $this->cleanupSite($siteId);
+
+ $this->assertEventually(function () use ($siteId, $deploymentId) {
+ $rules = $this->listRules([
+ 'queries' => [
+ Query::limit(1)->toString(),
+ Query::equal('type', ['deployment'])->toString(),
+ Query::equal('deploymentResourceType', ['site'])->toString(),
+ Query::equal('deploymentResourceId', [$siteId])->toString(),
+ ]
+ ]);
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $this->assertEquals(0, $rules['body']['total']);
+ $this->assertCount(0, $rules['body']['rules']);
+
+ $rules = $this->listRules([
+ 'queries' => [
+ Query::limit(1)->toString(),
+ Query::equal('type', ['deployment'])->toString(),
+ Query::equal('deploymentId', [$deploymentId])->toString()
+ ]
+ ]);
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $this->assertEquals(0, $rules['body']['total']);
+ $this->assertCount(0, $rules['body']['rules']);
+ });
}
- private function packageFunction(string $function): CURLFile
+ public function testCreateSiteBranchRule(): void
{
- $stdout = '';
- $stderr = '';
+ $domain = \uniqid() . '-site-branch.custom.localhost';
- $folderPath = realpath(__DIR__ . '/../../../resources/functions') . "/$function";
- $tarPath = "$folderPath/code.tar.gz";
+ $setup = $this->setupSite();
+ $siteId = $setup['siteId'];
+ $deploymentId = $setup['deploymentId'];
- Console::execute("cd $folderPath && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $stdout, $stderr);
+ $this->assertNotEmpty($siteId);
+ $this->assertNotEmpty($deploymentId);
- if (filesize($tarPath) > 1024 * 1024 * 5) {
- throw new \Exception('Code package is too large. Use the chunked upload method instead.');
+ $ruleId = $this->setupSiteRule($domain, $siteId, 'dev');
+ $this->assertNotEmpty($ruleId);
+
+ $rule = $this->getRule($ruleId);
+ $this->assertEquals(200, $rule['headers']['status-code']);
+
+ $this->cleanupRule($ruleId);
+ }
+
+ public function testCreateFunctionBranchRule(): void
+ {
+ $domain = \uniqid() . '-function-branch.custom.localhost';
+
+ $setup = $this->setupFunction();
+ $functionId = $setup['functionId'];
+ $deploymentId = $setup['deploymentId'];
+
+ $this->assertNotEmpty($functionId);
+ $this->assertNotEmpty($deploymentId);
+
+ $ruleId = $this->setupFunctionRule($domain, $functionId, 'dev');
+ $this->assertNotEmpty($ruleId);
+
+ $rule = $this->getRule($ruleId);
+ $this->assertEquals(200, $rule['headers']['status-code']);
+
+ $this->cleanupRule($ruleId);
+
+ $this->cleanupFunction($functionId);
+ }
+
+ public function testUpdateRule(): void
+ {
+ // Create function appwrite-network domain
+ $functionsDomain = \explode(',', System::getEnv('_APP_DOMAIN_FUNCTIONS', ''))[0];
+ $domain = \uniqid() . '-cname-api.' . $functionsDomain;
+
+ $rule = $this->createAPIRule($domain);
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->assertEquals('verified', $rule['body']['status']);
+
+ $this->cleanupRule($rule['body']['$id']);
+
+ // Create site appwrite-network domain
+ $sitesDomain = \explode(',', System::getEnv('_APP_DOMAIN_SITES', ''))[0];
+ $domain = \uniqid() . '-cname-api.' . $sitesDomain;
+
+ $rule = $this->createAPIRule($domain);
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->assertEquals('verified', $rule['body']['status']);
+
+ $this->cleanupRule($rule['body']['$id']);
+
+ // Create + update
+ $domain = \uniqid() . '-cname-api.custom.com';
+
+ $rule = $this->createAPIRule($domain);
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->assertEquals('unverified', $rule['body']['status']);
+
+ $ruleId = $rule['body']['$id'];
+
+ $rule = $this->updateRuleStatus($ruleId);
+ $this->assertEquals(400, $rule['headers']['status-code']);
+
+ $this->cleanupRule($ruleId);
+ }
+
+ public function testGetRule()
+ {
+ $domain = \uniqid() . '-get.custom.localhost';
+ $ruleId = $this->setupAPIRule($domain);
+
+ $this->assertNotEmpty($ruleId);
+
+ $rule = $this->getRule($ruleId);
+ $this->assertEquals(200, $rule['headers']['status-code']);
+ $this->assertEquals($domain, $rule['body']['domain']);
+ $this->assertEquals('manual', $rule['body']['trigger']);
+ $this->assertArrayHasKey('$id', $rule['body']);
+ $this->assertArrayHasKey('domain', $rule['body']);
+ $this->assertArrayHasKey('type', $rule['body']);
+ $this->assertArrayHasKey('redirectUrl', $rule['body']);
+ $this->assertArrayHasKey('redirectStatusCode', $rule['body']);
+ $this->assertArrayHasKey('deploymentResourceType', $rule['body']);
+ $this->assertArrayHasKey('deploymentId', $rule['body']);
+ $this->assertArrayHasKey('deploymentResourceId', $rule['body']);
+ $this->assertArrayHasKey('deploymentVcsProviderBranch', $rule['body']);
+ $this->assertArrayHasKey('logs', $rule['body']);
+ $this->assertArrayHasKey('renewAt', $rule['body']);
+
+ $this->cleanupRule($ruleId);
+ }
+
+ public function testListRules()
+ {
+ $rules = $this->listRules();
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ foreach ($rules['body']['rules'] as $rule) {
+ $rule = $this->deleteRule($rule['$id']);
+ $this->assertEquals(204, $rule['headers']['status-code']);
}
- return new CURLFile($tarPath, 'application/x-gzip', \basename($tarPath));
+ $rules = $this->listRules();
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $this->assertEquals(0, $rules['body']['total']);
+ $this->assertCount(0, $rules['body']['rules']);
+
+ $rule1Domain = \uniqid() . '-list1.custom.localhost';
+ $rule1Id = $this->setupAPIRule($rule1Domain);
+ $this->assertNotEmpty($rule1Id);
+
+ $rules = $this->listRules();
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $this->assertEquals(1, $rules['body']['total']);
+ $this->assertCount(1, $rules['body']['rules']);
+ $this->assertEquals($rule1Domain, $rules['body']['rules'][0]['domain']);
+
+ $this->assertEquals('manual', $rules['body']['rules'][0]['trigger']);
+ $this->assertArrayHasKey('$id', $rules['body']['rules'][0]);
+ $this->assertArrayHasKey('domain', $rules['body']['rules'][0]);
+ $this->assertArrayHasKey('type', $rules['body']['rules'][0]);
+ $this->assertArrayHasKey('redirectUrl', $rules['body']['rules'][0]);
+ $this->assertArrayHasKey('redirectStatusCode', $rules['body']['rules'][0]);
+ $this->assertArrayHasKey('deploymentResourceType', $rules['body']['rules'][0]);
+ $this->assertArrayHasKey('deploymentId', $rules['body']['rules'][0]);
+ $this->assertArrayHasKey('deploymentResourceId', $rules['body']['rules'][0]);
+ $this->assertArrayHasKey('deploymentVcsProviderBranch', $rules['body']['rules'][0]);
+ $this->assertArrayHasKey('logs', $rules['body']['rules'][0]);
+ $this->assertArrayHasKey('renewAt', $rules['body']['rules'][0]);
+
+ $rule2Domain = \uniqid() . '-list1.custom.localhost';
+ $rule2Id = $this->setupAPIRule($rule2Domain);
+ $this->assertNotEmpty($rule2Id);
+
+ $rules = $this->listRules();
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $this->assertEquals(2, $rules['body']['total']);
+ $this->assertCount(2, $rules['body']['rules']);
+
+ $rules = $this->listRules([
+ 'queries' => [
+ Query::limit(1)->toString()
+ ]
+ ]);
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $this->assertEquals(2, $rules['body']['total']);
+ $this->assertCount(1, $rules['body']['rules']);
+
+ $rules = $this->listRules([
+ 'queries' => [
+ Query::equal('$id', [$rule1Id])->toString()
+ ]
+ ]);
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $this->assertCount(1, $rules['body']['rules']);
+ $this->assertEquals($rule1Domain, $rules['body']['rules'][0]['domain']);
+
+ $rules = $this->listRules([
+ 'queries' => [
+ Query::orderDesc('$id')->toString()
+ ]
+ ]);
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $this->assertCount(2, $rules['body']['rules']);
+ $this->assertEquals($rule2Id, $rules['body']['rules'][0]['$id']);
+
+ $rules = $this->listRules([
+ 'queries' => [
+ Query::equal('domain', [$rule2Domain])->toString()
+ ]
+ ]);
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $this->assertCount(1, $rules['body']['rules']);
+ $this->assertEquals($rule2Id, $rules['body']['rules'][0]['$id']);
+
+ $rules = $this->listRules([
+ 'search' => $rule1Domain,
+ 'queries' => [ Query::orderDesc('$createdAt')->toString() ]
+ ]);
+
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $ruleIds = \array_column($rules['body']['rules'], '$id');
+ $this->assertContains($rule1Id, $ruleIds);
+
+ $rules = $this->listRules([
+ 'search' => $rule2Domain,
+ 'queries' => [ Query::orderDesc('$createdAt')->toString() ]
+ ]);
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $ruleIds = \array_column($rules['body']['rules'], '$id');
+ $this->assertContains($rule2Id, $ruleIds);
+
+ $rules = $this->listRules([
+ 'search' => $rule1Id,
+ 'queries' => [ Query::orderDesc('$createdAt')->toString() ]
+ ]);
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $ruleDomains = \array_column($rules['body']['rules'], 'domain');
+ $this->assertContains($rule1Domain, $ruleDomains);
+
+ $rules = $this->listRules([
+ 'search' => $rule2Id,
+ 'queries' => [ Query::orderDesc('$createdAt')->toString() ]
+ ]);
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $ruleDomains = \array_column($rules['body']['rules'], 'domain');
+ $this->assertContains($rule2Domain, $ruleDomains);
+
+ $rules = $this->listRules();
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ foreach ($rules['body']['rules'] as $rule) {
+ $rule = $this->deleteRule($rule['$id']);
+ $this->assertEquals(204, $rule['headers']['status-code']);
+ }
+
+ $rules = $this->listRules();
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $this->assertEquals(0, $rules['body']['total']);
+ $this->assertCount(0, $rules['body']['rules']);
+ }
+
+ public function testRuleVerification(): void
+ {
+
+ // 1. Site rule can verify
+ $site = $this->setupSite();
+ $siteId = $site['siteId'];
+
+ $rule = $this->createSiteRule('stage-site.webapp.com', $siteId);
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->assertEquals('verifying', $rule['body']['status']);
+ $this->assertEmpty($rule['body']['logs']);
+ $this->assertNotEmpty($rule['body']['$id']);
+ $ruleId = $rule['body']['$id'];
+
+ $rule = $this->updateRuleStatus($ruleId);
+ $this->assertEquals(200, $rule['headers']['status-code']);
+ $this->assertEquals($ruleId, $rule['body']['$id']);
+ $this->assertEquals('verifying', $rule['body']['status']);
+ $this->assertEmpty($rule['body']['logs']);
+
+ $this->cleanupRule($rule['body']['$id']);
+ $this->cleanupSite($siteId);
+
+ // 2. Function rule can verify
+ $function = $this->setupFunction();
+ $functionId = $function['functionId'];
+
+ $rule = $this->createFunctionRule('stage-function.webapp.com', $functionId);
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->assertEquals('verifying', $rule['body']['status']);
+ $this->assertEmpty($rule['body']['logs']);
+ $this->cleanupRule($rule['body']['$id']);
+
+ $rule = $this->createAPIRule('stage-site.webapp.com');
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->assertEquals('unverified', $rule['body']['status']);
+ $this->assertStringContainsString('has incorrect CNAME value', $rule['body']['logs']);
+ $this->cleanupRule($rule['body']['$id']);
+
+ $this->cleanupFunction($functionId);
+
+ // 3. Wrong A record fails to verify
+ $rule = $this->createAPIRule('wrong-a-webapp.com');
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->assertEquals('unverified', $rule['body']['status']);
+ $this->assertStringContainsString('is missing CNAME record', $rule['body']['logs']);
+
+ $ruleId = $rule['body']['$id'];
+ $rule = $this->updateRuleStatus($ruleId);
+ $this->assertEquals(400, $rule['headers']['status-code']);
+ $this->assertStringContainsString('is missing CNAME record', $rule['body']['message']);
+
+ $rule = $this->getRule($ruleId);
+ $this->assertEquals(200, $rule['headers']['status-code']);
+ $this->assertEquals('unverified', $rule['body']['status']);
+
+ $this->cleanupRule($ruleId);
+
+ // 4. Correct A record can verify
+ $rule = $this->createAPIRule('webapp.com');
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->assertEquals('verifying', $rule['body']['status']);
+ $this->assertEmpty($rule['body']['logs']);
+
+ $this->cleanupRule($rule['body']['$id']);
+
+ // 5. Correct CNAME record can verify (no CAA record)
+ $rule = $this->createAPIRule('stage.webapp.com');
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->assertEquals('verifying', $rule['body']['status']);
+ $this->assertEmpty($rule['body']['logs']);
+
+ $this->cleanupRule($rule['body']['$id']);
+
+ // 6. Missing CNAME record fails to verify
+ $rule = $this->createAPIRule('stage-missing-cname.webapp.com');
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->assertEquals('unverified', $rule['body']['status']);
+ $this->assertStringContainsString('is missing CNAME record', $rule['body']['logs']);
+
+ $ruleId = $rule['body']['$id'];
+ $rule = $this->updateRuleStatus($ruleId);
+ $this->assertEquals(400, $rule['headers']['status-code']);
+ $this->assertStringContainsString('is missing CNAME record', $rule['body']['message']);
+
+ $rule = $this->getRule($ruleId);
+ $this->assertEquals(200, $rule['headers']['status-code']);
+ $this->assertEquals('unverified', $rule['body']['status']);
+
+ $this->cleanupRule($ruleId);
+
+ // 7. Wrong CNAME record fails to verify
+ $rule = $this->createAPIRule('stage-wrong-cname.webapp.com');
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->assertEquals('unverified', $rule['body']['status']);
+ $this->assertStringContainsString('has incorrect CNAME value', $rule['body']['logs']);
+
+ $ruleId = $rule['body']['$id'];
+ $rule = $this->updateRuleStatus($ruleId);
+ $this->assertEquals(400, $rule['headers']['status-code']);
+ $this->assertStringContainsString('has incorrect CNAME value', $rule['body']['message']);
+
+ $rule = $this->getRule($ruleId);
+ $this->assertEquals(200, $rule['headers']['status-code']);
+ $this->assertEquals('unverified', $rule['body']['status']);
+
+ $this->cleanupRule($ruleId);
+
+ // 8. Wrong CAA record fails to verify
+ $rule = $this->createAPIRule('stage-wrong-caa.webapp.com');
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->assertEquals('unverified', $rule['body']['status']);
+ $this->assertStringContainsString('has incorrect CAA value', $rule['body']['logs']);
+
+ $ruleId = $rule['body']['$id'];
+ $rule = $this->updateRuleStatus($ruleId);
+ $this->assertEquals(400, $rule['headers']['status-code']);
+ $this->assertStringContainsString('has incorrect CAA value', $rule['body']['message']);
+
+ $rule = $this->getRule($ruleId);
+ $this->assertEquals(200, $rule['headers']['status-code']);
+ $this->assertEquals('unverified', $rule['body']['status']);
+
+ $this->cleanupRule($ruleId);
+
+ // 9. Correct CAA record can verify
+ $rule = $this->createAPIRule('stage-correct-caa.webapp.com');
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->assertEquals('verifying', $rule['body']['status']);
+ $this->assertEmpty($rule['body']['logs']);
+
+ $this->cleanupRule($rule['body']['$id']);
+ }
+
+ public function testUpdateRuleVerificationWithSameDataUpdatesTimestamp(): void
+ {
+ $domain = \uniqid() . '-timestamp-test.webapp.com';
+ $rule = $this->createAPIRule($domain);
+
+ $this->assertEquals(201, $rule['headers']['status-code']);
+ $this->assertEquals('unverified', $rule['body']['status']);
+ $this->assertNotEmpty($rule['body']['logs']);
+
+ $ruleId = $rule['body']['$id'];
+ $initialUpdatedAt = $rule['body']['$updatedAt'];
+ $initiallogs = $rule['body']['logs'];
+
+ sleep(1);
+
+ $updatedRule = $this->updateRuleStatus($ruleId);
+
+ $this->assertEquals(400, $updatedRule['headers']['status-code']);
+ $this->assertStringContainsString($initiallogs, $updatedRule['body']['message']);
+
+ $ruleAfterUpdate = $this->getRule($ruleId);
+ $this->assertEquals(200, $ruleAfterUpdate['headers']['status-code']);
+ $this->assertEquals('unverified', $ruleAfterUpdate['body']['status']);
+ $this->assertEquals($initiallogs, $ruleAfterUpdate['body']['logs']);
+ $this->assertNotEquals($initialUpdatedAt, $ruleAfterUpdate['body']['$updatedAt']);
+
+ $initialTime = new \DateTime($initialUpdatedAt);
+ $updatedTime = new \DateTime($ruleAfterUpdate['body']['$updatedAt']);
+ $this->assertGreaterThan($initialTime, $updatedTime);
+
+ $this->cleanupRule($ruleId);
}
}
diff --git a/tests/e2e/Services/Proxy/ProxyConsoleClientTest.php b/tests/e2e/Services/Proxy/ProxyConsoleClientTest.php
new file mode 100644
index 0000000000..68761f34a9
--- /dev/null
+++ b/tests/e2e/Services/Proxy/ProxyConsoleClientTest.php
@@ -0,0 +1,14 @@
+listRules([
- 'queries' => [
- Query::endsWith('domain', 'webapp.com')->toString(),
- Query::limit(1000)->toString(),
- ]
- ]);
- $this->assertEquals(200, $rules['headers']['status-code']);
- foreach ($rules['body']['rules'] as $rule) {
- $ruleId = $rule['$id'];
- $response = $this->deleteRule($ruleId);
- $this->assertEquals(204, $response['headers']['status-code']);
- }
-
- if ($rules['body']['total'] > 0) {
- $rules = $this->listRules([
- 'queries' => [
- Query::endsWith('domain', 'webapp.com')->toString(),
- Query::limit(1)->toString()
- ]
- ]);
- $this->assertEquals(200, $rules['headers']['status-code']);
- $this->assertEquals(0, count($rules['body']['rules']));
- $this->assertEquals(0, $rules['body']['total']);
- }
- }
-
- public function testCreateRule(): void
- {
- $domain = \uniqid() . '-api.myapp.com';
- $rule = $this->createAPIRule($domain);
-
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->assertEquals($domain, $rule['body']['domain']);
- $this->assertEquals('manual', $rule['body']['trigger']);
- $this->assertArrayHasKey('$id', $rule['body']);
- $this->assertArrayHasKey('domain', $rule['body']);
- $this->assertArrayHasKey('type', $rule['body']);
- $this->assertArrayHasKey('redirectUrl', $rule['body']);
- $this->assertArrayHasKey('redirectStatusCode', $rule['body']);
- $this->assertArrayHasKey('deploymentResourceType', $rule['body']);
- $this->assertArrayHasKey('deploymentId', $rule['body']);
- $this->assertArrayHasKey('deploymentResourceId', $rule['body']);
- $this->assertArrayHasKey('deploymentVcsProviderBranch', $rule['body']);
- $this->assertArrayHasKey('logs', $rule['body']);
- $this->assertArrayHasKey('renewAt', $rule['body']);
-
- $ruleId = $rule['body']['$id'];
-
- $rule = $this->createAPIRule($domain);
- $this->assertEquals(409, $rule['headers']['status-code']);
-
- $rule = $this->deleteRule($ruleId);
-
- $this->assertEquals(204, $rule['headers']['status-code']);
- }
-
- public function testCreateRuleSetup(): void
- {
- $ruleId = $this->setupAPIRule(\uniqid() . '-api2.myapp.com');
- $this->cleanupRule($ruleId);
- }
-
- public function testCreateRuleApex(): void
- {
- $domain = \uniqid() . '.com';
- $rule = $this->createAPIRule($domain);
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->assertEquals('created', $rule['body']['status']);
- }
-
- public function testCreateRuleVcs(): void
- {
- $domain = \uniqid() . '-vcs.myapp.com';
-
- $setup = $this->setupSite();
- $siteId = $setup['siteId'];
- $deploymentId = $setup['deploymentId'];
-
- $this->assertNotEmpty($siteId);
- $this->assertNotEmpty($deploymentId);
-
- $rule = $this->createSiteRule('commit-' . $domain, $siteId);
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->cleanupRule($rule['body']['$id']);
-
- $rule = $this->createSiteRule('branch-' . $domain, $siteId);
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->cleanupRule($rule['body']['$id']);
-
- $rule = $this->createSiteRule('anything-' . $domain, $siteId);
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->cleanupRule($rule['body']['$id']);
-
- $sitesDomain = \explode(',', System::getEnv('_APP_DOMAIN_SITES', ''))[0];
- $domain = \uniqid() . '-vcs.' . $sitesDomain;
-
- $rule = $this->createSiteRule('commit-' . $domain, $siteId);
- $this->assertEquals(400, $rule['headers']['status-code']);
-
- $rule = $this->createSiteRule('branch-' . $domain, $siteId);
- $this->assertEquals(400, $rule['headers']['status-code']);
-
- $rule = $this->createSiteRule('subdomain.anything-' . $domain, $siteId);
- $this->assertEquals(400, $rule['headers']['status-code']);
-
- $rule = $this->createSiteRule('anything-' . $domain, $siteId);
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->cleanupRule($rule['body']['$id']);
- }
-
- public function testCreateAPIRule(): void
- {
- $domain = \uniqid() . '-api.custom.localhost';
-
- $proxyClient = new Client();
- $proxyClient->setEndpoint('http://appwrite.test');
- $proxyClient->addHeader('x-appwrite-hostname', $domain);
-
- $response = $proxyClient->call(Client::METHOD_GET, '/versions');
- $this->assertEquals(401, $response['headers']['status-code']);
-
- $ruleId = $this->setupAPIRule($domain);
-
- $this->assertNotEmpty($ruleId);
-
- $response = $proxyClient->call(Client::METHOD_GET, '/versions');
- $this->assertEquals(200, $response['headers']['status-code']);
- $this->assertEquals(APP_VERSION_STABLE, $response['body']['server']);
-
- $this->cleanupRule($ruleId);
-
- $rule = $this->createAPIRule('http://' . $domain);
- $this->assertEquals(400, $rule['headers']['status-code']);
-
- $rule = $this->createAPIRule('https://' . $domain);
- $this->assertEquals(400, $rule['headers']['status-code']);
-
- $rule = $this->createAPIRule('wss://' . $domain);
- $this->assertEquals(400, $rule['headers']['status-code']);
-
- $rule = $this->createAPIRule($domain . '/some-path');
- $this->assertEquals(400, $rule['headers']['status-code']);
- }
-
- public function testCreateRedirectRule(): void
- {
- $domain = \uniqid() . '-redirect.custom.localhost';
-
- $proxyClient = new Client();
- $proxyClient->setEndpoint('http://appwrite.test');
- $proxyClient->addHeader('x-appwrite-hostname', $domain);
-
- $response = $proxyClient->call(Client::METHOD_GET, '/todos/1');
- $this->assertEquals(401, $response['headers']['status-code']);
-
- $siteId = $this->setupSite()['siteId'];
-
- $ruleId = $this->setupRedirectRule($domain, 'https://jsonplaceholder.typicode.com/todos/1', 301, 'site', $siteId);
- $this->assertNotEmpty($ruleId);
-
- $response = $proxyClient->call(Client::METHOD_GET, '/todos/1');
- $this->assertEquals(200, $response['headers']['status-code']);
- $this->assertEquals(1, $response['body']['id']);
-
- $response = $proxyClient->call(Client::METHOD_GET, '/');
- $this->assertEquals(200, $response['headers']['status-code']);
- $this->assertEquals(1, $response['body']['id']);
-
- $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false);
- $this->assertEquals(301, $response['headers']['status-code']);
- $this->assertEquals('https://jsonplaceholder.typicode.com/todos/1', $response['headers']['location']);
-
- $domain = \uniqid() . '-redirect-307.custom.localhost';
- $ruleId = $this->setupRedirectRule($domain, 'https://jsonplaceholder.typicode.com/todos/1', 307, 'site', $siteId);
- $this->assertNotEmpty($ruleId);
-
- $proxyClient = new Client();
- $proxyClient->setEndpoint('http://appwrite.test');
- $proxyClient->addHeader('x-appwrite-hostname', $domain);
-
- $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false);
- $this->assertEquals(307, $response['headers']['status-code']);
- $this->assertEquals('https://jsonplaceholder.typicode.com/todos/1', $response['headers']['location']);
-
- $rules = $this->listRules([
- 'queries' => [
- Query::equal('type', ['redirect'])->toString(),
- Query::equal('trigger', ['manual'])->toString(),
- Query::equal('deploymentResourceType', ['site'])->toString(),
- Query::equal('deploymentResourceId', [$siteId])->toString(),
- ],
- ]);
- $this->assertEquals(200, $rules['headers']['status-code']);
- $this->assertEquals(2, $rules['body']['total']);
-
- $this->cleanupSite($siteId);
- $this->cleanupRule($ruleId);
- }
-
- public function testCreateFunctionRule(): void
- {
- $domain = \uniqid() . '-function.custom.localhost';
-
- $proxyClient = new Client();
- $proxyClient->setEndpoint('http://appwrite.test');
- $proxyClient->addHeader('x-appwrite-hostname', $domain);
-
- $response = $proxyClient->call(Client::METHOD_GET, '/ping');
- $this->assertEquals(401, $response['headers']['status-code']);
-
- $setup = $this->setupFunction();
- $functionId = $setup['functionId'];
- $deploymentId = $setup['deploymentId'];
-
- $this->assertNotEmpty($functionId);
- $this->assertNotEmpty($deploymentId);
-
- $ruleId = $this->setupFunctionRule($domain, $functionId);
- $this->assertNotEmpty($ruleId);
-
- $response = $proxyClient->call(Client::METHOD_GET, '/ping');
- $this->assertEquals(200, $response['headers']['status-code']);
- $this->assertEquals($functionId, $response['body']['APPWRITE_FUNCTION_ID']);
-
- $this->cleanupRule($ruleId);
-
- $this->cleanupFunction($functionId);
-
- $this->assertEventually(function () use ($functionId, $deploymentId) {
- $rules = $this->listRules([
- 'queries' => [
- Query::limit(1)->toString(),
- Query::equal('type', ['deployment'])->toString(),
- Query::equal('deploymentResourceType', ['function'])->toString(),
- Query::equal('deploymentResourceId', [$functionId])->toString(),
- ]
- ]);
- $this->assertEquals(200, $rules['headers']['status-code']);
- $this->assertEquals(0, $rules['body']['total']);
- $this->assertCount(0, $rules['body']['rules']);
-
- $rules = $this->listRules([
- 'queries' => [
- Query::limit(1)->toString(),
- Query::equal('type', ['deployment'])->toString(),
- Query::equal('deploymentId', [$deploymentId])->toString()
- ]
- ]);
- $this->assertEquals(200, $rules['headers']['status-code']);
- $this->assertEquals(0, $rules['body']['total']);
- $this->assertCount(0, $rules['body']['rules']);
- });
- }
-
- public function testCreateSiteRule(): void
- {
- $domain = \uniqid() . '-site.custom.localhost';
-
- $proxyClient = new Client();
- $proxyClient->setEndpoint('http://appwrite.test');
- $proxyClient->addHeader('x-appwrite-hostname', $domain);
-
- $response = $proxyClient->call(Client::METHOD_GET, '/contact');
- $this->assertEquals(401, $response['headers']['status-code']);
-
- $setup = $this->setupSite();
- $siteId = $setup['siteId'];
- $deploymentId = $setup['deploymentId'];
-
- $this->assertNotEmpty($siteId);
- $this->assertNotEmpty($deploymentId);
-
- $ruleId = $this->setupSiteRule($domain, $siteId);
- $this->assertNotEmpty($ruleId);
- $rule = $this->getRule($ruleId);
- $this->assertSame(200, $rule['headers']['status-code']);
- $this->assertSame('created', $rule['body']['status']);
-
- $response = $proxyClient->call(Client::METHOD_GET, '/contact');
- $this->assertEquals(200, $response['headers']['status-code']);
- $this->assertStringContainsString('Contact page', $response['body']);
-
- // Wildcard domains automatically get verified status
- $domains = [
- \uniqid() . '.sites.localhost',
- \uniqid() . '.rebranded.localhost',
- ];
- foreach ($domains as $domain) {
- $wildcardRuleId = $this->setupSiteRule($domain, $siteId);
- $this->assertNotEmpty($wildcardRuleId);
- $rule = $this->getRule($wildcardRuleId);
- $this->assertSame(200, $rule['headers']['status-code']);
- $this->assertSame('verified', $rule['body']['status']);
- $this->cleanupRule($wildcardRuleId);
- }
-
- $rules = $this->listRules([
- 'queries' => [
- Query::limit(1)->toString(),
- Query::equal('trigger', ['deployment'])->toString(),
- Query::equal('type', ['deployment'])->toString(),
- Query::equal('deploymentResourceType', ['site'])->toString(),
- Query::equal('deploymentResourceId', [$siteId])->toString(),
- ]
- ]);
- $this->assertEquals(200, $rules['headers']['status-code']);
- $this->assertGreaterThan(0, $rules['body']['total']);
-
- $this->cleanupRule($ruleId);
-
- $this->cleanupSite($siteId);
-
- $this->assertEventually(function () use ($siteId, $deploymentId) {
- $rules = $this->listRules([
- 'queries' => [
- Query::limit(1)->toString(),
- Query::equal('type', ['deployment'])->toString(),
- Query::equal('deploymentResourceType', ['site'])->toString(),
- Query::equal('deploymentResourceId', [$siteId])->toString(),
- ]
- ]);
- $this->assertEquals(200, $rules['headers']['status-code']);
- $this->assertEquals(0, $rules['body']['total']);
- $this->assertCount(0, $rules['body']['rules']);
-
- $rules = $this->listRules([
- 'queries' => [
- Query::limit(1)->toString(),
- Query::equal('type', ['deployment'])->toString(),
- Query::equal('deploymentId', [$deploymentId])->toString()
- ]
- ]);
- $this->assertEquals(200, $rules['headers']['status-code']);
- $this->assertEquals(0, $rules['body']['total']);
- $this->assertCount(0, $rules['body']['rules']);
- });
- }
-
- public function testCreateSiteBranchRule(): void
- {
- $domain = \uniqid() . '-site-branch.custom.localhost';
-
- $setup = $this->setupSite();
- $siteId = $setup['siteId'];
- $deploymentId = $setup['deploymentId'];
-
- $this->assertNotEmpty($siteId);
- $this->assertNotEmpty($deploymentId);
-
- $ruleId = $this->setupSiteRule($domain, $siteId, 'dev');
- $this->assertNotEmpty($ruleId);
-
- $rule = $this->getRule($ruleId);
- $this->assertEquals(200, $rule['headers']['status-code']);
-
- $this->cleanupRule($ruleId);
- }
-
- public function testCreateFunctionBranchRule(): void
- {
- $domain = \uniqid() . '-function-branch.custom.localhost';
-
- $setup = $this->setupFunction();
- $functionId = $setup['functionId'];
- $deploymentId = $setup['deploymentId'];
-
- $this->assertNotEmpty($functionId);
- $this->assertNotEmpty($deploymentId);
-
- $ruleId = $this->setupFunctionRule($domain, $functionId, 'dev');
- $this->assertNotEmpty($ruleId);
-
- $rule = $this->getRule($ruleId);
- $this->assertEquals(200, $rule['headers']['status-code']);
-
- $this->cleanupRule($ruleId);
-
- $this->cleanupFunction($functionId);
- }
-
- public function testUpdateRule(): void
- {
- // Create function appwrite-network domain
- $functionsDomain = \explode(',', System::getEnv('_APP_DOMAIN_FUNCTIONS', ''))[0];
- $domain = \uniqid() . '-cname-api.' . $functionsDomain;
-
- $rule = $this->createAPIRule($domain);
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->assertEquals('verified', $rule['body']['status']);
-
- $this->cleanupRule($rule['body']['$id']);
-
- // Create site appwrite-network domain
- $sitesDomain = \explode(',', System::getEnv('_APP_DOMAIN_SITES', ''))[0];
- $domain = \uniqid() . '-cname-api.' . $sitesDomain;
-
- $rule = $this->createAPIRule($domain);
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->assertEquals('verified', $rule['body']['status']);
-
- $this->cleanupRule($rule['body']['$id']);
-
- // Create + update
- $domain = \uniqid() . '-cname-api.custom.com';
-
- $rule = $this->createAPIRule($domain);
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->assertEquals('created', $rule['body']['status']);
-
- $ruleId = $rule['body']['$id'];
-
- $rule = $this->updateRuleVerification($ruleId);
- $this->assertEquals(400, $rule['headers']['status-code']);
-
- $this->cleanupRule($ruleId);
- }
-
- public function testGetRule()
- {
- $domain = \uniqid() . '-get.custom.localhost';
- $ruleId = $this->setupAPIRule($domain);
-
- $this->assertNotEmpty($ruleId);
-
- $rule = $this->getRule($ruleId);
- $this->assertEquals(200, $rule['headers']['status-code']);
- $this->assertEquals($domain, $rule['body']['domain']);
- $this->assertEquals('manual', $rule['body']['trigger']);
- $this->assertArrayHasKey('$id', $rule['body']);
- $this->assertArrayHasKey('domain', $rule['body']);
- $this->assertArrayHasKey('type', $rule['body']);
- $this->assertArrayHasKey('redirectUrl', $rule['body']);
- $this->assertArrayHasKey('redirectStatusCode', $rule['body']);
- $this->assertArrayHasKey('deploymentResourceType', $rule['body']);
- $this->assertArrayHasKey('deploymentId', $rule['body']);
- $this->assertArrayHasKey('deploymentResourceId', $rule['body']);
- $this->assertArrayHasKey('deploymentVcsProviderBranch', $rule['body']);
- $this->assertArrayHasKey('logs', $rule['body']);
- $this->assertArrayHasKey('renewAt', $rule['body']);
-
- $this->cleanupRule($ruleId);
- }
-
- public function testListRules()
- {
- $rules = $this->listRules();
- $this->assertEquals(200, $rules['headers']['status-code']);
- foreach ($rules['body']['rules'] as $rule) {
- $rule = $this->deleteRule($rule['$id']);
- $this->assertEquals(204, $rule['headers']['status-code']);
- }
-
- $rules = $this->listRules();
- $this->assertEquals(200, $rules['headers']['status-code']);
- $this->assertEquals(0, $rules['body']['total']);
- $this->assertCount(0, $rules['body']['rules']);
-
- $rule1Domain = \uniqid() . '-list1.custom.localhost';
- $rule1Id = $this->setupAPIRule($rule1Domain);
- $this->assertNotEmpty($rule1Id);
-
- $rules = $this->listRules();
- $this->assertEquals(200, $rules['headers']['status-code']);
- $this->assertEquals(1, $rules['body']['total']);
- $this->assertCount(1, $rules['body']['rules']);
- $this->assertEquals($rule1Domain, $rules['body']['rules'][0]['domain']);
-
- $this->assertEquals('manual', $rules['body']['rules'][0]['trigger']);
- $this->assertArrayHasKey('$id', $rules['body']['rules'][0]);
- $this->assertArrayHasKey('domain', $rules['body']['rules'][0]);
- $this->assertArrayHasKey('type', $rules['body']['rules'][0]);
- $this->assertArrayHasKey('redirectUrl', $rules['body']['rules'][0]);
- $this->assertArrayHasKey('redirectStatusCode', $rules['body']['rules'][0]);
- $this->assertArrayHasKey('deploymentResourceType', $rules['body']['rules'][0]);
- $this->assertArrayHasKey('deploymentId', $rules['body']['rules'][0]);
- $this->assertArrayHasKey('deploymentResourceId', $rules['body']['rules'][0]);
- $this->assertArrayHasKey('deploymentVcsProviderBranch', $rules['body']['rules'][0]);
- $this->assertArrayHasKey('logs', $rules['body']['rules'][0]);
- $this->assertArrayHasKey('renewAt', $rules['body']['rules'][0]);
-
- $rule2Domain = \uniqid() . '-list1.custom.localhost';
- $rule2Id = $this->setupAPIRule($rule2Domain);
- $this->assertNotEmpty($rule2Id);
-
- $rules = $this->listRules();
- $this->assertEquals(200, $rules['headers']['status-code']);
- $this->assertEquals(2, $rules['body']['total']);
- $this->assertCount(2, $rules['body']['rules']);
-
- $rules = $this->listRules([
- 'queries' => [
- Query::limit(1)->toString()
- ]
- ]);
- $this->assertEquals(200, $rules['headers']['status-code']);
- $this->assertEquals(2, $rules['body']['total']);
- $this->assertCount(1, $rules['body']['rules']);
-
- $rules = $this->listRules([
- 'queries' => [
- Query::equal('$id', [$rule1Id])->toString()
- ]
- ]);
- $this->assertEquals(200, $rules['headers']['status-code']);
- $this->assertCount(1, $rules['body']['rules']);
- $this->assertEquals($rule1Domain, $rules['body']['rules'][0]['domain']);
-
- $rules = $this->listRules([
- 'queries' => [
- Query::orderDesc('$id')->toString()
- ]
- ]);
- $this->assertEquals(200, $rules['headers']['status-code']);
- $this->assertCount(2, $rules['body']['rules']);
- $this->assertEquals($rule2Id, $rules['body']['rules'][0]['$id']);
-
- $rules = $this->listRules([
- 'queries' => [
- Query::equal('domain', [$rule2Domain])->toString()
- ]
- ]);
- $this->assertEquals(200, $rules['headers']['status-code']);
- $this->assertCount(1, $rules['body']['rules']);
- $this->assertEquals($rule2Id, $rules['body']['rules'][0]['$id']);
-
- $rules = $this->listRules([
- 'search' => $rule1Domain,
- 'queries' => [ Query::orderDesc('$createdAt')->toString() ]
- ]);
-
- $this->assertEquals(200, $rules['headers']['status-code']);
- $ruleIds = \array_column($rules['body']['rules'], '$id');
- $this->assertContains($rule1Id, $ruleIds);
-
- $rules = $this->listRules([
- 'search' => $rule2Domain,
- 'queries' => [ Query::orderDesc('$createdAt')->toString() ]
- ]);
- $this->assertEquals(200, $rules['headers']['status-code']);
- $ruleIds = \array_column($rules['body']['rules'], '$id');
- $this->assertContains($rule2Id, $ruleIds);
-
- $rules = $this->listRules([
- 'search' => $rule1Id,
- 'queries' => [ Query::orderDesc('$createdAt')->toString() ]
- ]);
- $this->assertEquals(200, $rules['headers']['status-code']);
- $ruleDomains = \array_column($rules['body']['rules'], 'domain');
- $this->assertContains($rule1Domain, $ruleDomains);
-
- $rules = $this->listRules([
- 'search' => $rule2Id,
- 'queries' => [ Query::orderDesc('$createdAt')->toString() ]
- ]);
- $this->assertEquals(200, $rules['headers']['status-code']);
- $ruleDomains = \array_column($rules['body']['rules'], 'domain');
- $this->assertContains($rule2Domain, $ruleDomains);
-
- $rules = $this->listRules();
- $this->assertEquals(200, $rules['headers']['status-code']);
- foreach ($rules['body']['rules'] as $rule) {
- $rule = $this->deleteRule($rule['$id']);
- $this->assertEquals(204, $rule['headers']['status-code']);
- }
-
- $rules = $this->listRules();
- $this->assertEquals(200, $rules['headers']['status-code']);
- $this->assertEquals(0, $rules['body']['total']);
- $this->assertCount(0, $rules['body']['rules']);
- }
-
- public function testRuleVerification(): void
- {
-
- // 1. Site rule can verify
- $site = $this->setupSite();
- $siteId = $site['siteId'];
-
- $rule = $this->createSiteRule('stage-site.webapp.com', $siteId);
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->assertEquals('verifying', $rule['body']['status']);
- $this->assertEmpty($rule['body']['logs']);
- $this->assertNotEmpty($rule['body']['$id']);
- $ruleId = $rule['body']['$id'];
-
- $rule = $this->updateRuleVerification($ruleId);
- $this->assertEquals(200, $rule['headers']['status-code']);
- $this->assertEquals($ruleId, $rule['body']['$id']);
- $this->assertEquals('verifying', $rule['body']['status']);
- $this->assertEmpty($rule['body']['logs']);
-
- $this->cleanupRule($rule['body']['$id']);
- $this->cleanupSite($siteId);
-
- // 2. Function rule can verify
- $function = $this->setupFunction();
- $functionId = $function['functionId'];
-
- $rule = $this->createFunctionRule('stage-function.webapp.com', $functionId);
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->assertEquals('verifying', $rule['body']['status']);
- $this->assertEmpty($rule['body']['logs']);
- $this->cleanupRule($rule['body']['$id']);
-
- $rule = $this->createAPIRule('stage-site.webapp.com');
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->assertEquals('created', $rule['body']['status']);
- $this->assertStringContainsString('has incorrect CNAME value', $rule['body']['logs']);
- $this->cleanupRule($rule['body']['$id']);
-
- $this->cleanupFunction($functionId);
-
- // 3. Wrong A record fails to verify
- $rule = $this->createAPIRule('wrong-a-webapp.com');
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->assertEquals('created', $rule['body']['status']);
- $this->assertStringContainsString('is missing CNAME record', $rule['body']['logs']);
-
- $ruleId = $rule['body']['$id'];
- $rule = $this->updateRuleVerification($ruleId);
- $this->assertEquals(400, $rule['headers']['status-code']);
- $this->assertStringContainsString('is missing CNAME record', $rule['body']['message']);
-
- $rule = $this->getRule($ruleId);
- $this->assertEquals(200, $rule['headers']['status-code']);
- $this->assertEquals('created', $rule['body']['status']);
-
- $this->cleanupRule($ruleId);
-
- // 4. Correct A record can verify
- $rule = $this->createAPIRule('webapp.com');
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->assertEquals('verifying', $rule['body']['status']);
- $this->assertEmpty($rule['body']['logs']);
-
- $this->cleanupRule($rule['body']['$id']);
-
- // 5. Correct CNAME record can verify (no CAA record)
- $rule = $this->createAPIRule('stage.webapp.com');
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->assertEquals('verifying', $rule['body']['status']);
- $this->assertEmpty($rule['body']['logs']);
-
- $this->cleanupRule($rule['body']['$id']);
-
- // 6. Missing CNAME record fails to verify
- $rule = $this->createAPIRule('stage-missing-cname.webapp.com');
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->assertEquals('created', $rule['body']['status']);
- $this->assertStringContainsString('is missing CNAME record', $rule['body']['logs']);
-
- $ruleId = $rule['body']['$id'];
- $rule = $this->updateRuleVerification($ruleId);
- $this->assertEquals(400, $rule['headers']['status-code']);
- $this->assertStringContainsString('is missing CNAME record', $rule['body']['message']);
-
- $rule = $this->getRule($ruleId);
- $this->assertEquals(200, $rule['headers']['status-code']);
- $this->assertEquals('created', $rule['body']['status']);
-
- $this->cleanupRule($ruleId);
-
- // 7. Wrong CNAME record fails to verify
- $rule = $this->createAPIRule('stage-wrong-cname.webapp.com');
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->assertEquals('created', $rule['body']['status']);
- $this->assertStringContainsString('has incorrect CNAME value', $rule['body']['logs']);
-
- $ruleId = $rule['body']['$id'];
- $rule = $this->updateRuleVerification($ruleId);
- $this->assertEquals(400, $rule['headers']['status-code']);
- $this->assertStringContainsString('has incorrect CNAME value', $rule['body']['message']);
-
- $rule = $this->getRule($ruleId);
- $this->assertEquals(200, $rule['headers']['status-code']);
- $this->assertEquals('created', $rule['body']['status']);
-
- $this->cleanupRule($ruleId);
-
- // 8. Wrong CAA record fails to verify
- $rule = $this->createAPIRule('stage-wrong-caa.webapp.com');
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->assertEquals('created', $rule['body']['status']);
- $this->assertStringContainsString('has incorrect CAA value', $rule['body']['logs']);
-
- $ruleId = $rule['body']['$id'];
- $rule = $this->updateRuleVerification($ruleId);
- $this->assertEquals(400, $rule['headers']['status-code']);
- $this->assertStringContainsString('has incorrect CAA value', $rule['body']['message']);
-
- $rule = $this->getRule($ruleId);
- $this->assertEquals(200, $rule['headers']['status-code']);
- $this->assertEquals('created', $rule['body']['status']);
-
- $this->cleanupRule($ruleId);
-
- // 9. Correct CAA record can verify
- $rule = $this->createAPIRule('stage-correct-caa.webapp.com');
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->assertEquals('verifying', $rule['body']['status']);
- $this->assertEmpty($rule['body']['logs']);
-
- $this->cleanupRule($rule['body']['$id']);
- }
-
- public function testUpdateRuleVerificationWithSameDataUpdatesTimestamp(): void
- {
- $domain = \uniqid() . '-timestamp-test.webapp.com';
- $rule = $this->createAPIRule($domain);
-
- $this->assertEquals(201, $rule['headers']['status-code']);
- $this->assertEquals('created', $rule['body']['status']);
- $this->assertNotEmpty($rule['body']['logs']);
-
- $ruleId = $rule['body']['$id'];
- $initialUpdatedAt = $rule['body']['$updatedAt'];
- $initiallogs = $rule['body']['logs'];
-
- sleep(1);
-
- $updatedRule = $this->updateRuleVerification($ruleId);
-
- $this->assertEquals(400, $updatedRule['headers']['status-code']);
- $this->assertStringContainsString($initiallogs, $updatedRule['body']['message']);
-
- $ruleAfterUpdate = $this->getRule($ruleId);
- $this->assertEquals(200, $ruleAfterUpdate['headers']['status-code']);
- $this->assertEquals('created', $ruleAfterUpdate['body']['status']);
- $this->assertEquals($initiallogs, $ruleAfterUpdate['body']['logs']);
- $this->assertNotEquals($initialUpdatedAt, $ruleAfterUpdate['body']['$updatedAt']);
-
- $initialTime = new \DateTime($initialUpdatedAt);
- $updatedTime = new \DateTime($ruleAfterUpdate['body']['$updatedAt']);
- $this->assertGreaterThan($initialTime, $updatedTime);
-
- $this->cleanupRule($ruleId);
- }
}
diff --git a/tests/e2e/Services/Proxy/ProxyHelpers.php b/tests/e2e/Services/Proxy/ProxyHelpers.php
new file mode 100644
index 0000000000..6f15abdad8
--- /dev/null
+++ b/tests/e2e/Services/Proxy/ProxyHelpers.php
@@ -0,0 +1,293 @@
+client->call(Client::METHOD_GET, '/proxy/rules', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), $params);
+
+ return $rule;
+ }
+
+ protected function createAPIRule(string $domain): mixed
+ {
+ $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules/api', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'domain' => $domain,
+ ]);
+
+ return $rule;
+ }
+
+ protected function updateRuleStatus(string $ruleId): mixed
+ {
+ $rule = $this->client->call(Client::METHOD_PATCH, '/proxy/rules/' . $ruleId . '/status', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), []);
+
+ return $rule;
+ }
+
+ protected function createSiteRule(string $domain, string $siteId, string $branch = ''): mixed
+ {
+ $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules/site', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'domain' => $domain,
+ 'siteId' => $siteId,
+ 'branch' => $branch,
+ ]);
+
+ return $rule;
+ }
+
+ protected function getRule(string $ruleId): mixed
+ {
+ $rule = $this->client->call(Client::METHOD_GET, '/proxy/rules/' . $ruleId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), []);
+
+ return $rule;
+ }
+
+ protected function createRedirectRule(string $domain, string $url, int $statusCode, string $resourceType, string $resourceId): mixed
+ {
+ $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules/redirect', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'domain' => $domain,
+ 'url' => $url,
+ 'statusCode' => $statusCode,
+ 'resourceType' => $resourceType,
+ 'resourceId' => $resourceId,
+ ]);
+
+ return $rule;
+ }
+
+ protected function createFunctionRule(string $domain, string $functionId, string $branch = ''): mixed
+ {
+ $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules/function', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'domain' => $domain,
+ 'functionId' => $functionId,
+ 'branch' => $branch,
+ ]);
+
+ return $rule;
+ }
+
+ protected function deleteRule(string $ruleId): mixed
+ {
+ $rule = $this->client->call(Client::METHOD_DELETE, '/proxy/rules/' . $ruleId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), []);
+
+ return $rule;
+ }
+
+ protected function setupAPIRule(string $domain): string
+ {
+ $rule = $this->createAPIRule($domain);
+
+ $this->assertEquals(201, $rule['headers']['status-code'], 'Failed to setup rule: ' . \json_encode($rule));
+
+ return $rule['body']['$id'];
+ }
+
+ protected function setupRedirectRule(string $domain, string $url, int $statusCode, string $resourceType, string $resourceId): string
+ {
+ $rule = $this->createRedirectRule($domain, $url, $statusCode, $resourceType, $resourceId);
+
+ $this->assertEquals(201, $rule['headers']['status-code'], 'Failed to setup rule: ' . \json_encode($rule));
+
+ return $rule['body']['$id'];
+ }
+
+ protected function setupFunctionRule(string $domain, string $functionId, string $branch = ''): string
+ {
+ $rule = $this->createFunctionRule($domain, $functionId, $branch);
+
+ $this->assertEquals(201, $rule['headers']['status-code'], 'Failed to setup rule: ' . \json_encode($rule));
+
+ return $rule['body']['$id'];
+ }
+
+ protected function setupSiteRule(string $domain, string $siteId, string $branch = ''): string
+ {
+ $rule = $this->createSiteRule($domain, $siteId, $branch);
+
+ $this->assertEquals(201, $rule['headers']['status-code'], 'Failed to setup rule: ' . \json_encode($rule));
+
+ return $rule['body']['$id'];
+ }
+
+ protected function cleanupRule(string $ruleId): void
+ {
+ $rule = $this->deleteRule($ruleId);
+ $this->assertEquals(204, $rule['headers']['status-code'], 'Failed to cleanup rule: ' . \json_encode($rule));
+ }
+
+ protected function cleanupSite(string $siteId): void
+ {
+ $site = $this->client->call(Client::METHOD_DELETE, '/sites/' . $siteId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), []);
+
+ $this->assertEquals(204, $site['headers']['status-code'], 'Failed to cleanup site: ' . \json_encode($site));
+ }
+
+ protected function cleanupFunction(string $functionId): void
+ {
+ $function = $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), []);
+
+ $this->assertEquals(204, $function['headers']['status-code'], 'Failed to cleanup function: ' . \json_encode($function));
+ }
+
+ protected function setupSite(): mixed
+ {
+ // Site
+ $site = $this->client->call(Client::METHOD_POST, '/sites', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'siteId' => ID::unique(),
+ 'name' => 'Proxy site',
+ 'framework' => 'other',
+ 'adapter' => 'static',
+ 'buildRuntime' => 'static-1',
+ 'outputDirectory' => './',
+ 'buildCommand' => '',
+ 'installCommand' => '',
+ 'fallbackFile' => '',
+ ]);
+
+ $this->assertEquals($site['headers']['status-code'], 201, 'Setup site failed with status code: ' . $site['headers']['status-code'] . ' and response: ' . json_encode($site['body'], JSON_PRETTY_PRINT));
+
+ $siteId = $site['body']['$id'];
+
+ // Deployment
+ $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', array_merge([
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'code' => $this->packageSite('static'),
+ 'activate' => 'true'
+ ]);
+
+ $this->assertEquals($deployment['headers']['status-code'], 202, 'Setup deployment failed with status code: ' . $deployment['headers']['status-code'] . ' and response: ' . json_encode($deployment['body'], JSON_PRETTY_PRINT));
+ $deploymentId = $deployment['body']['$id'] ?? '';
+
+ $this->assertEventually(function () use ($siteId, $deploymentId) {
+ $site = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals($deploymentId, $site['body']['deploymentId'], 'Deployment is not activated, deployment: ' . json_encode($site['body'], JSON_PRETTY_PRINT));
+ }, 120000, 500);
+
+ return ['siteId' => $siteId, 'deploymentId' => $deploymentId];
+ }
+
+ protected function setupFunction(): mixed
+ {
+ // Function
+ $function = $this->client->call(Client::METHOD_POST, '/functions', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'functionId' => ID::unique(),
+ 'runtime' => 'node-22',
+ 'name' => 'Proxy Function',
+ 'entrypoint' => 'index.js',
+ 'commands' => '',
+ 'execute' => ['any']
+ ]);
+
+ $this->assertEquals($function['headers']['status-code'], 201, 'Setup function failed with status code: ' . $function['headers']['status-code'] . ' and response: ' . json_encode($function['body'], JSON_PRETTY_PRINT));
+
+ $functionId = $function['body']['$id'];
+
+ // Deployment
+ $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'code' => $this->packageFunction('basic'),
+ 'activate' => 'true'
+ ]);
+
+ $this->assertEquals($deployment['headers']['status-code'], 202, 'Setup deployment failed with status code: ' . $deployment['headers']['status-code'] . ' and response: ' . json_encode($deployment['body'], JSON_PRETTY_PRINT));
+ $deploymentId = $deployment['body']['$id'] ?? '';
+
+ $this->assertEventually(function () use ($functionId, $deploymentId) {
+ $function = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals($deploymentId, $function['body']['deploymentId'], 'Deployment is not activated, deployment: ' . json_encode($function['body'], JSON_PRETTY_PRINT));
+ }, 100000, 500);
+
+ return ['functionId' => $functionId, 'deploymentId' => $deploymentId];
+ }
+
+ private function packageSite(string $site): CURLFile
+ {
+ $stdout = '';
+ $stderr = '';
+
+ $folderPath = realpath(__DIR__ . '/../../../resources/sites') . "/$site";
+ $tarPath = "$folderPath/code.tar.gz";
+
+ Console::execute("cd $folderPath && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $stdout, $stderr);
+
+ if (filesize($tarPath) > 1024 * 1024 * 5) {
+ throw new \Exception('Code package is too large. Use the chunked upload method instead.');
+ }
+
+ return new CURLFile($tarPath, 'application/x-gzip', \basename($tarPath));
+ }
+
+ private function packageFunction(string $function): CURLFile
+ {
+ $stdout = '';
+ $stderr = '';
+
+ $folderPath = realpath(__DIR__ . '/../../../resources/functions') . "/$function";
+ $tarPath = "$folderPath/code.tar.gz";
+
+ Console::execute("cd $folderPath && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $stdout, $stderr);
+
+ if (filesize($tarPath) > 1024 * 1024 * 5) {
+ throw new \Exception('Code package is too large. Use the chunked upload method instead.');
+ }
+
+ return new CURLFile($tarPath, 'application/x-gzip', \basename($tarPath));
+ }
+}
diff --git a/tests/e2e/Services/Realtime/RealtimeConsoleClientTest.php b/tests/e2e/Services/Realtime/RealtimeConsoleClientTest.php
index 3da00898c9..e8946e54d5 100644
--- a/tests/e2e/Services/Realtime/RealtimeConsoleClientTest.php
+++ b/tests/e2e/Services/Realtime/RealtimeConsoleClientTest.php
@@ -257,7 +257,7 @@ class RealtimeConsoleClientTest extends Scope
$this->assertEquals('error', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertEquals(1003, $response['data']['code']);
- $this->assertEquals('Payload is not valid.', $response['data']['message']);
+ $this->assertEquals('Payload is not valid. Session is required', $response['data']['message']);
$client->send(\json_encode([
'type' => 'unknown',
diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php
index edce428e0f..4d37a8944b 100644
--- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php
+++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php
@@ -164,6 +164,20 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope
return $response;
}
+ /**
+ * @param array> $payloadEntries
+ * @return array
+ */
+ private function sendUnsubscribeMessage(WebSocketClient $client, array $payloadEntries): array
+ {
+ $client->send(\json_encode([
+ 'type' => 'unsubscribe',
+ 'data' => $payloadEntries,
+ ]));
+
+ return \json_decode($client->receive(), true);
+ }
+
/**
* subscriptionId: update with id from connected, create by omitting id, explicit new id,
* duplicate id in one bulk (last wins), mixed bulk, idempotent repeat, empty queries → select-all.
@@ -293,6 +307,282 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope
$client->close();
}
+ /**
+ * Update a subscription's queries/channels by reusing its subscriptionId.
+ * Verifies the update takes effect on live event filtering (not just the response echo),
+ * sibling subscriptions are untouched, unknown ids upsert as new, empty queries fall
+ * back to select-all, and a removed id can be recreated by subscribing again.
+ */
+ public function testUpdateSubscriptionAndEdgeCases(): void
+ {
+ $user = $this->getUser();
+ $userId = $user['$id'] ?? '';
+ $session = $user['session'] ?? '';
+ $projectId = $this->getProject()['$id'];
+ $headers = [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_' . $projectId . '=' . $session,
+ ];
+
+ $queryString = \http_build_query(['project' => $projectId]);
+ $client = new WebSocketClient(
+ 'ws://appwrite.test/v1/realtime?' . $queryString,
+ [
+ 'headers' => $headers,
+ 'timeout' => 10,
+ ]
+ );
+ $connected = \json_decode($client->receive(), true);
+ $this->assertEquals('connected', $connected['type'] ?? null);
+
+ $triggerAccountEvent = function () use ($projectId, $session): void {
+ $this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'cookie' => 'a_session_' . $projectId . '=' . $session,
+ ]), ['name' => 'Update Sub Test ' . \uniqid()]);
+ };
+
+ // subA matches current user, subB never matches
+ $created = $this->sendSubscribeMessage($client, [
+ [
+ 'channels' => ['account'],
+ 'queries' => [Query::equal('$id', [$userId])->toString()],
+ ],
+ [
+ 'channels' => ['account'],
+ 'queries' => [Query::equal('$id', ['no-match-initial'])->toString()],
+ ],
+ ]);
+ $subA = $created['data']['subscriptions'][0]['subscriptionId'];
+ $subB = $created['data']['subscriptions'][1]['subscriptionId'];
+ $this->assertNotSame($subA, $subB);
+
+ $triggerAccountEvent();
+ $event = \json_decode($client->receive(), true);
+ $this->assertEquals('event', $event['type']);
+ $this->assertSame([$subA], $event['data']['subscriptions']);
+
+ // Swap: A -> non-matching, B -> matching. Same ids returned, server-side filter swaps.
+ $swap = $this->sendSubscribeMessage($client, [
+ [
+ 'subscriptionId' => $subA,
+ 'channels' => ['account'],
+ 'queries' => [Query::equal('$id', ['no-match-swapped'])->toString()],
+ ],
+ [
+ 'subscriptionId' => $subB,
+ 'channels' => ['account'],
+ 'queries' => [Query::equal('$id', [$userId])->toString()],
+ ],
+ ]);
+ $this->assertSame($subA, $swap['data']['subscriptions'][0]['subscriptionId']);
+ $this->assertSame($subB, $swap['data']['subscriptions'][1]['subscriptionId']);
+
+ $triggerAccountEvent();
+ $event = \json_decode($client->receive(), true);
+ $this->assertEquals('event', $event['type']);
+ $this->assertSame([$subB], $event['data']['subscriptions']);
+
+ // Sibling isolation: updating only subA must leave subB's matching filter intact.
+ $isolation = $this->sendSubscribeMessage($client, [[
+ 'subscriptionId' => $subA,
+ 'channels' => ['account'],
+ 'queries' => [Query::equal('$id', [$userId])->toString()],
+ ]]);
+ $this->assertSame($subA, $isolation['data']['subscriptions'][0]['subscriptionId']);
+
+ $triggerAccountEvent();
+ $event = \json_decode($client->receive(), true);
+ $this->assertEquals('event', $event['type']);
+ $this->assertEqualsCanonicalizing([$subA, $subB], $event['data']['subscriptions']);
+
+ // Empty queries on update -> select-all; subA still matches every event on the channel.
+ $empty = $this->sendSubscribeMessage($client, [[
+ 'subscriptionId' => $subA,
+ 'channels' => ['account'],
+ 'queries' => [],
+ ]]);
+ $this->assertSame($subA, $empty['data']['subscriptions'][0]['subscriptionId']);
+
+ $triggerAccountEvent();
+ $event = \json_decode($client->receive(), true);
+ $this->assertEquals('event', $event['type']);
+ $this->assertEqualsCanonicalizing([$subA, $subB], $event['data']['subscriptions']);
+
+ // Unknown subscriptionId upserts as a new subscription.
+ $ghostId = ID::unique();
+ $ghost = $this->sendSubscribeMessage($client, [[
+ 'subscriptionId' => $ghostId,
+ 'channels' => ['account'],
+ 'queries' => [Query::equal('$id', [$userId])->toString()],
+ ]]);
+ $this->assertSame($ghostId, $ghost['data']['subscriptions'][0]['subscriptionId']);
+ $this->assertNotSame($subA, $ghostId);
+ $this->assertNotSame($subB, $ghostId);
+
+ $triggerAccountEvent();
+ $event = \json_decode($client->receive(), true);
+ $this->assertEquals('event', $event['type']);
+ $this->assertEqualsCanonicalizing([$subA, $subB, $ghostId], $event['data']['subscriptions']);
+
+ // Update after unsubscribe: subscribing with the removed id recreates it.
+ $unsub = $this->sendUnsubscribeMessage($client, [['subscriptionId' => $subA]]);
+ $this->assertTrue($unsub['data']['subscriptions'][0]['removed']);
+
+ $triggerAccountEvent();
+ $event = \json_decode($client->receive(), true);
+ $this->assertEquals('event', $event['type']);
+ $this->assertEqualsCanonicalizing([$subB, $ghostId], $event['data']['subscriptions']);
+
+ $recreated = $this->sendSubscribeMessage($client, [[
+ 'subscriptionId' => $subA,
+ 'channels' => ['account'],
+ 'queries' => [Query::equal('$id', [$userId])->toString()],
+ ]]);
+ $this->assertSame($subA, $recreated['data']['subscriptions'][0]['subscriptionId']);
+
+ $triggerAccountEvent();
+ $event = \json_decode($client->receive(), true);
+ $this->assertEquals('event', $event['type']);
+ $this->assertEqualsCanonicalizing([$subA, $subB, $ghostId], $event['data']['subscriptions']);
+
+ $client->close();
+ }
+
+ public function testUnsubscribeRemovesOnlyMatchingSubscription(): void
+ {
+ $user = $this->getUser();
+ $userId = $user['$id'] ?? '';
+ $session = $user['session'] ?? '';
+ $projectId = $this->getProject()['$id'];
+ $headers = [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_' . $projectId . '=' . $session,
+ ];
+
+ $queryString = \http_build_query(['project' => $projectId]);
+ $client = new WebSocketClient(
+ 'ws://appwrite.test/v1/realtime?' . $queryString,
+ [
+ 'headers' => $headers,
+ 'timeout' => 10,
+ ]
+ );
+
+ $connected = \json_decode($client->receive(), true);
+ $this->assertEquals('connected', $connected['type'] ?? null);
+
+ // Two subscriptions on the `account` channel, both matching the current user
+ $r1 = $this->sendSubscribeMessage($client, [[
+ 'channels' => ['account'],
+ 'queries' => [Query::equal('$id', [$userId])->toString()],
+ ]]);
+ $subA = $r1['data']['subscriptions'][0]['subscriptionId'];
+
+ $r2 = $this->sendSubscribeMessage($client, [[
+ 'channels' => ['account'],
+ 'queries' => [Query::select(['*'])->toString()],
+ ]]);
+ $subB = $r2['data']['subscriptions'][0]['subscriptionId'];
+
+ $this->assertNotSame($subA, $subB);
+
+ // Trigger an event -- both subscriptions should match
+ $name = 'Unsubscribe Test ' . \uniqid();
+ $this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'cookie' => 'a_session_' . $projectId . '=' . $session,
+ ]), ['name' => $name]);
+
+ $event = \json_decode($client->receive(), true);
+ $this->assertEquals('event', $event['type']);
+ $this->assertEqualsCanonicalizing([$subA, $subB], $event['data']['subscriptions']);
+
+ // Unsubscribe subA only
+ $unsubA = $this->sendUnsubscribeMessage($client, [['subscriptionId' => $subA]]);
+ $this->assertEquals('response', $unsubA['type']);
+ $this->assertEquals('unsubscribe', $unsubA['data']['to']);
+ $this->assertTrue($unsubA['data']['success']);
+ $this->assertCount(1, $unsubA['data']['subscriptions']);
+ $this->assertSame($subA, $unsubA['data']['subscriptions'][0]['subscriptionId']);
+ $this->assertTrue($unsubA['data']['subscriptions'][0]['removed']);
+
+ // Trigger another event -- only subB should match now
+ $name = 'Unsubscribe Test ' . \uniqid();
+ $this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'cookie' => 'a_session_' . $projectId . '=' . $session,
+ ]), ['name' => $name]);
+
+ $event = \json_decode($client->receive(), true);
+ $this->assertEquals('event', $event['type']);
+ $this->assertSame([$subB], $event['data']['subscriptions']);
+
+ // Idempotent: unsubscribing subA again reports removed=false
+ $unsubAgain = $this->sendUnsubscribeMessage($client, [['subscriptionId' => $subA]]);
+ $this->assertTrue($unsubAgain['data']['success']);
+ $this->assertFalse($unsubAgain['data']['subscriptions'][0]['removed']);
+
+ // Connection is still alive -- ping still works
+ $client->send(\json_encode(['type' => 'ping']));
+ $pong = \json_decode($client->receive(), true);
+ $this->assertEquals('pong', $pong['type']);
+
+ // Invalid payloads are rejected
+ $errNonString = $this->sendUnsubscribeMessage($client, [['subscriptionId' => 123]]);
+ $this->assertEquals('error', $errNonString['type']);
+ $this->assertStringContainsString('subscriptionId', $errNonString['data']['message']);
+
+ $errEmpty = $this->sendUnsubscribeMessage($client, [['subscriptionId' => '']]);
+ $this->assertEquals('error', $errEmpty['type']);
+
+ $errMissing = $this->sendUnsubscribeMessage($client, [['channels' => ['foo']]]);
+ $this->assertEquals('error', $errMissing['type']);
+
+ $errNonList = $this->sendUnsubscribeMessage($client, ['subscriptionId' => $subB]);
+ $this->assertEquals('error', $errNonList['type']);
+
+ // A batch with a valid id followed by an invalid one must be rejected atomically:
+ // the valid id must remain subscribed, not be quietly removed before validation fails.
+ $partial = $this->sendUnsubscribeMessage($client, [
+ ['subscriptionId' => $subB],
+ ['subscriptionId' => 999],
+ ]);
+ $this->assertEquals('error', $partial['type']);
+
+ $name = 'Partial Rejection Test ' . \uniqid();
+ $this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'cookie' => 'a_session_' . $projectId . '=' . $session,
+ ]), ['name' => $name]);
+
+ $event = \json_decode($client->receive(), true);
+ $this->assertEquals('event', $event['type']);
+ $this->assertSame([$subB], $event['data']['subscriptions']);
+
+ // Bulk unsubscribe: remaining subB plus a never-existed id -- response mirrors input order
+ $bulk = $this->sendUnsubscribeMessage($client, [
+ ['subscriptionId' => $subB],
+ ['subscriptionId' => 'does-not-exist'],
+ ]);
+ $this->assertTrue($bulk['data']['success']);
+ $this->assertCount(2, $bulk['data']['subscriptions']);
+ $this->assertSame($subB, $bulk['data']['subscriptions'][0]['subscriptionId']);
+ $this->assertTrue($bulk['data']['subscriptions'][0]['removed']);
+ $this->assertSame('does-not-exist', $bulk['data']['subscriptions'][1]['subscriptionId']);
+ $this->assertFalse($bulk['data']['subscriptions'][1]['removed']);
+
+ $client->close();
+ }
+
public function testInvalidQueryShouldNotSubscribe(): void
{
$user = $this->getUser();
@@ -513,7 +803,7 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope
$client->receive();
$this->fail('Expected TimeoutException - event should be filtered by updated query');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$client->close();
diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php
index 9c768f00d1..7fccd6839f 100644
--- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php
+++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php
@@ -239,7 +239,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('error', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertEquals(1003, $response['data']['code']);
- $this->assertEquals('Payload is not valid.', $response['data']['message']);
+ $this->assertEquals('Payload is not valid. Session is required', $response['data']['message']);
$client->send(\json_encode([
'type' => 'unknown',
@@ -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']);
@@ -3295,6 +3341,277 @@ class RealtimeCustomClientTest extends Scope
$client->close();
}
+ public function testChannelMirrorEventsAcrossDatabasesAndTablesdb(): void
+ {
+ $user = $this->getUser();
+ $session = $user['session'] ?? '';
+ $projectId = $this->getProject()['$id'];
+
+ /**
+ * Case 1: Trigger event through /databases route and verify both
+ * legacy collections/documents and tables/rows events are generated.
+ */
+ $legacyDatabase = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]), [
+ 'databaseId' => ID::unique(),
+ 'name' => 'Mirror Legacy DB',
+ ]);
+ $this->assertEquals(201, $legacyDatabase['headers']['status-code']);
+ $legacyDatabaseId = $legacyDatabase['body']['$id'];
+
+ $legacyCollection = $this->client->call(Client::METHOD_POST, '/databases/' . $legacyDatabaseId . '/collections', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]), [
+ 'collectionId' => ID::unique(),
+ 'name' => 'Legacy Collection',
+ 'permissions' => [
+ Permission::read(Role::any()),
+ Permission::create(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ 'documentSecurity' => true,
+ ]);
+ $legacyCollectionId = $legacyCollection['body']['$id'];
+
+ $attribute = $this->client->call(Client::METHOD_POST, '/databases/' . $legacyDatabaseId . '/collections/' . $legacyCollectionId . '/attributes/string', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]), [
+ 'key' => 'name',
+ 'size' => 256,
+ 'required' => true,
+ ]);
+ $this->assertEquals(202, $attribute['headers']['status-code']);
+
+ $this->assertEventually(function () use ($legacyDatabaseId, $legacyCollectionId) {
+ $attribute = $this->client->call(Client::METHOD_GET, '/databases/' . $legacyDatabaseId . '/collections/' . $legacyCollectionId . '/attributes/name', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]));
+
+ $this->assertEquals('available', $attribute['body']['status']);
+ }, 30000, 250);
+
+ $legacyClient = $this->getWebsocket([
+ "databases.{$legacyDatabaseId}.collections.{$legacyCollectionId}.documents",
+ "databases.{$legacyDatabaseId}.tables.{$legacyCollectionId}.rows",
+ ], [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_' . $projectId . '=' . $session,
+ ]);
+
+ $connected = json_decode($legacyClient->receive(), true);
+ $this->assertEquals('connected', $connected['type']);
+
+ $legacyDocumentId = ID::unique();
+ $document = $this->client->call(Client::METHOD_POST, '/databases/' . $legacyDatabaseId . '/collections/' . $legacyCollectionId . '/documents', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]), [
+ 'documentId' => $legacyDocumentId,
+ 'data' => [
+ 'name' => 'legacy-route-create',
+ ],
+ 'permissions' => [
+ Permission::read(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ ]);
+ $this->assertEquals(201, $document['headers']['status-code']);
+
+ $legacyEvent = json_decode($legacyClient->receive(), true);
+ $this->assertEquals('event', $legacyEvent['type']);
+ $this->assertContains(
+ "databases.{$legacyDatabaseId}.collections.{$legacyCollectionId}.documents.{$legacyDocumentId}.create",
+ $legacyEvent['data']['events']
+ );
+ $this->assertContains(
+ "databases.{$legacyDatabaseId}.tables.{$legacyCollectionId}.rows.{$legacyDocumentId}.create",
+ $legacyEvent['data']['events']
+ );
+ $legacyClient->close();
+
+ /**
+ * Case 2: Trigger event through /tablesdb route and verify both
+ * tables/rows and legacy collections/documents events are generated.
+ */
+ $tablesDatabase = $this->client->call(Client::METHOD_POST, '/tablesdb', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], $this->getHeaders()), [
+ 'databaseId' => ID::unique(),
+ 'name' => 'Mirror TablesDB',
+ ]);
+ $this->assertEquals(201, $tablesDatabase['headers']['status-code']);
+ $tablesDatabaseId = $tablesDatabase['body']['$id'];
+
+ $table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $tablesDatabaseId . '/tables', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], $this->getHeaders()), [
+ 'tableId' => ID::unique(),
+ 'name' => 'Mirror Table',
+ 'permissions' => [
+ Permission::read(Role::any()),
+ Permission::create(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ ]);
+ $this->assertEquals(201, $table['headers']['status-code']);
+ $tableId = $table['body']['$id'];
+
+ $column = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $tablesDatabaseId . '/tables/' . $tableId . '/columns/string', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], $this->getHeaders()), [
+ 'key' => 'name',
+ 'size' => 256,
+ 'required' => true,
+ ]);
+ $this->assertEquals(202, $column['headers']['status-code']);
+
+ $this->assertEventually(function () use ($tablesDatabaseId, $tableId) {
+ $column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $tablesDatabaseId . '/tables/' . $tableId . '/columns/name', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], $this->getHeaders()));
+
+ $this->assertEquals('available', $column['body']['status']);
+ }, 120000, 500);
+
+ $tablesClient = $this->getWebsocket([
+ "databases.{$tablesDatabaseId}.tables.{$tableId}.rows",
+ "databases.{$tablesDatabaseId}.collections.{$tableId}.documents",
+ ], [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_' . $projectId . '=' . $session,
+ ]);
+
+ $connected = json_decode($tablesClient->receive(), true);
+ $this->assertEquals('connected', $connected['type']);
+
+ $rowId = ID::unique();
+ $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $tablesDatabaseId . '/tables/' . $tableId . '/rows', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], $this->getHeaders()), [
+ 'rowId' => $rowId,
+ 'data' => [
+ 'name' => 'tablesdb-route-create',
+ ],
+ 'permissions' => [
+ Permission::read(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ ]);
+ $this->assertEquals(201, $row['headers']['status-code']);
+
+ $tablesEvent = json_decode($tablesClient->receive(), true);
+ $this->assertEquals('event', $tablesEvent['type']);
+ $this->assertContains(
+ "databases.{$tablesDatabaseId}.tables.{$tableId}.rows.{$rowId}.create",
+ $tablesEvent['data']['events']
+ );
+ $this->assertContains(
+ "databases.{$tablesDatabaseId}.collections.{$tableId}.documents.{$rowId}.create",
+ $tablesEvent['data']['events']
+ );
+ $tablesClient->close();
+
+ /**
+ * Case 3: Trigger event through /documentsdb route and verify only
+ * documentsdb events are generated (no databases/tablesdb mirrors).
+ */
+ $documentsDatabase = $this->client->call(Client::METHOD_POST, '/documentsdb', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]), [
+ 'databaseId' => ID::unique(),
+ 'name' => 'Mirror DocumentsDB',
+ ]);
+ $this->assertEquals(201, $documentsDatabase['headers']['status-code']);
+ $documentsDatabaseId = $documentsDatabase['body']['$id'];
+
+ $documentsCollection = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $documentsDatabaseId . '/collections', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]), [
+ 'collectionId' => ID::unique(),
+ 'name' => 'Mirror Documents Collection',
+ 'permissions' => [
+ Permission::read(Role::any()),
+ Permission::create(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ 'documentSecurity' => true,
+ ]);
+ $this->assertEquals(201, $documentsCollection['headers']['status-code']);
+ $documentsCollectionId = $documentsCollection['body']['$id'];
+
+ $documentsClient = $this->getWebsocket([
+ "documentsdb.{$documentsDatabaseId}.collections.{$documentsCollectionId}.documents",
+ 'documents',
+ ], [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_' . $projectId . '=' . $session,
+ ]);
+
+ $connected = json_decode($documentsClient->receive(), true);
+ $this->assertEquals('connected', $connected['type']);
+
+ $documentsDocumentId = ID::unique();
+ $documentsDocument = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $documentsDatabaseId . '/collections/' . $documentsCollectionId . '/documents', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], $this->getHeaders()), [
+ 'documentId' => $documentsDocumentId,
+ 'data' => [
+ 'name' => 'documentsdb-route-create',
+ ],
+ 'permissions' => [
+ Permission::read(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ ]);
+ $this->assertEquals(201, $documentsDocument['headers']['status-code']);
+
+ $documentsEvent = json_decode($documentsClient->receive(), true);
+ $this->assertEquals('event', $documentsEvent['type']);
+ $this->assertContains(
+ "documentsdb.{$documentsDatabaseId}.collections.{$documentsCollectionId}.documents.{$documentsDocumentId}.create",
+ $documentsEvent['data']['events']
+ );
+ $this->assertEmpty(
+ array_filter(
+ $documentsEvent['data']['events'],
+ fn (string $event) => \str_starts_with($event, 'databases.') || \str_starts_with($event, 'tablesdb.')
+ )
+ );
+ $documentsClient->close();
+ }
+
public function testChannelDatabaseTransactionMultipleOperations()
{
$user = $this->getUser();
@@ -3557,7 +3874,7 @@ class RealtimeCustomClientTest extends Scope
$this->fail('Should not receive any event after rollback');
} catch (TimeoutException $e) {
// Expected - no event should be triggered
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$client->close();
@@ -3968,7 +4285,16 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals(256, $name['body']['size']);
$this->assertTrue($name['body']['required']);
- sleep(2);
+ $this->assertEventually(function () use ($databaseId, $actorsId) {
+ $column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/name', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], $this->getHeaders()));
+
+ $this->assertEquals(200, $column['headers']['status-code']);
+ $this->assertEquals('available', $column['body']['status']);
+ }, 120000, 500);
/**
* Test Document Create
@@ -3996,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']);
@@ -4053,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']);
@@ -4121,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']);
@@ -4192,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']);
@@ -4238,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']);
@@ -4302,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']);
@@ -4344,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']);
@@ -4386,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']);
@@ -4437,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']);
@@ -4480,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']);
@@ -4509,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']);
@@ -4556,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']);
@@ -4677,12 +5003,14 @@ 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']);
$this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']);
$this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']);
+ $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.create", $response['data']['events']);
+ $this->assertEmpty(array_filter($response['data']['events'], fn (string $event) => \str_starts_with($event, 'databases.') || \str_starts_with($event, 'tablesdb.')));
$this->assertNotEmpty($response['data']['payload']);
$this->assertEquals('Chris Evans', $response['data']['payload']['name']);
@@ -4710,10 +5038,12 @@ 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']);
+ $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']);
+ $this->assertEmpty(array_filter($response['data']['events'], fn (string $event) => \str_starts_with($event, 'databases.') || \str_starts_with($event, 'tablesdb.')));
$this->assertNotEmpty($response['data']['payload']);
$this->assertEquals('Chris Evans 2', $response['data']['payload']['name']);
@@ -4752,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']);
@@ -4796,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']);
@@ -4814,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']);
@@ -4849,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']);
@@ -4877,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']);
@@ -4905,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']);
@@ -4942,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']);
@@ -4971,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']);
@@ -5000,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']);
@@ -5047,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']);
@@ -5152,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']);
@@ -5183,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']);
@@ -5202,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']);
@@ -5241,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']);
@@ -5256,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();
@@ -5359,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']);
@@ -5367,13 +5697,11 @@ class RealtimeCustomClientTest extends Scope
$this->assertArrayHasKey('$id', $response['data']['payload']);
$this->assertEquals(15, $response['data']['payload']['score']);
- sleep(1);
-
try {
$client->receive();
$this->fail('Should not receive duplicate event');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
// Test Document Decrement
@@ -5392,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']);
@@ -5400,13 +5728,11 @@ class RealtimeCustomClientTest extends Scope
$this->assertArrayHasKey('$id', $response['data']['payload']);
$this->assertEquals(12, $response['data']['payload']['score']);
- sleep(1);
-
try {
$client->receive();
$this->fail('Should not receive duplicate event');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$client->close();
diff --git a/tests/e2e/Services/Realtime/RealtimeQueryBase.php b/tests/e2e/Services/Realtime/RealtimeQueryBase.php
index 04ed56dae6..5ab5c26253 100644
--- a/tests/e2e/Services/Realtime/RealtimeQueryBase.php
+++ b/tests/e2e/Services/Realtime/RealtimeQueryBase.php
@@ -101,7 +101,7 @@ trait RealtimeQueryBase
$data = $client->receive();
$this->fail('Expected TimeoutException - event should be filtered');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$client->close();
@@ -206,7 +206,7 @@ trait RealtimeQueryBase
$client->receive();
$this->fail('Expected TimeoutException - event should be filtered');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$client->close();
@@ -304,7 +304,7 @@ trait RealtimeQueryBase
$client->receive();
$this->fail('Expected TimeoutException - event should be filtered');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$client->close();
@@ -398,7 +398,7 @@ trait RealtimeQueryBase
$client->receive();
$this->fail('Expected TimeoutException - event should be filtered');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$client->close();
@@ -492,7 +492,7 @@ trait RealtimeQueryBase
$client->receive();
$this->fail('Expected TimeoutException - event should be filtered');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$client->close();
@@ -604,7 +604,7 @@ trait RealtimeQueryBase
$client->receive();
$this->fail('Expected TimeoutException - event should be filtered');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$client->close();
@@ -716,7 +716,7 @@ trait RealtimeQueryBase
$client->receive();
$this->fail('Expected TimeoutException - event should be filtered');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$client->close();
@@ -810,7 +810,7 @@ trait RealtimeQueryBase
$client->receive();
$this->fail('Expected TimeoutException - event should be filtered');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$client->close();
@@ -903,7 +903,7 @@ trait RealtimeQueryBase
$client->receive();
$this->fail('Expected TimeoutException - event should be filtered');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$client->close();
@@ -1019,7 +1019,7 @@ trait RealtimeQueryBase
$client->receive();
$this->fail('Expected TimeoutException - event should be filtered');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
// Create document with priority > 5 but status != 'active' - should NOT receive event
@@ -1041,7 +1041,7 @@ trait RealtimeQueryBase
$client->receive();
$this->fail('Expected TimeoutException - event should be filtered');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$client->close();
@@ -1157,7 +1157,7 @@ trait RealtimeQueryBase
$client->receive();
$this->fail('Expected TimeoutException - event should be filtered');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$client->close();
@@ -1296,7 +1296,7 @@ trait RealtimeQueryBase
$client->receive();
$this->fail('Expected TimeoutException - event should be filtered');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
// Create document with score >= 80 but category != 'premium' or 'vip' - should NOT receive event
@@ -1318,7 +1318,7 @@ trait RealtimeQueryBase
$client->receive();
$this->fail('Expected TimeoutException - event should be filtered');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$client->close();
@@ -1511,7 +1511,7 @@ trait RealtimeQueryBase
$client->receive();
$this->fail('Expected TimeoutException - event should be filtered for scoped channel query');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$client->close();
@@ -1583,7 +1583,7 @@ trait RealtimeQueryBase
$client->receive();
$this->fail('Expected TimeoutException - event should be filtered');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$client->close();
@@ -1692,7 +1692,7 @@ trait RealtimeQueryBase
$client->receive();
$this->fail('Expected TimeoutException - event should be filtered (neither query matches)');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
// Create document with matching ID but wrong status - should NOT receive event (only one query matches)
@@ -1713,7 +1713,7 @@ trait RealtimeQueryBase
$client->receive();
$this->fail('Expected TimeoutException - event should be filtered (ID matches but status does not)');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$client->close();
@@ -1870,7 +1870,7 @@ trait RealtimeQueryBase
$clientQ2->receive();
$this->fail('Expected TimeoutException - event should be filtered for clientQ2 (active document)');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
// clientComplex: should receive event, subscriptions should not be empty (query matched)
@@ -1912,7 +1912,7 @@ trait RealtimeQueryBase
$clientQ1->receive();
$this->fail('Expected TimeoutException - event should be filtered for clientQ1 (pending document)');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
// clientQ2: should receive event, subscriptions should not be empty (query matched)
@@ -1929,7 +1929,7 @@ trait RealtimeQueryBase
$clientComplex->receive();
$this->fail('Expected TimeoutException - event should be filtered for complex subscription (pending document)');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$clientAll->close();
@@ -2043,7 +2043,7 @@ trait RealtimeQueryBase
$clientQ2->receive();
$this->fail('Expected TimeoutException - clientQ2 should not receive active document');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
// 2) pending document -> only queryStatusPending subscription should see it
@@ -2073,7 +2073,7 @@ trait RealtimeQueryBase
$clientQ1->receive();
$this->fail('Expected TimeoutException - clientQ1 should not receive pending document');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$clientQ1->close();
@@ -2252,7 +2252,7 @@ trait RealtimeQueryBase
$data = $client->receive();
$this->fail('Expected TimeoutException - document does not match query after permission change');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
// Create a NEW document with a different ID - should NOT receive event
@@ -2279,7 +2279,7 @@ trait RealtimeQueryBase
$data = $client->receive();
$this->fail('Expected TimeoutException - new document does not match original query after permission change');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
// Create a document with the ORIGINAL matching ID - should receive event
@@ -2439,11 +2439,478 @@ trait RealtimeQueryBase
$clientWithNonMatchingQuery->receive();
$this->fail('Expected TimeoutException - client with non-matching query should not receive event');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
$clientNoQuery->close();
$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
+ */
+ 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();
+ }
}
diff --git a/tests/e2e/Services/Sites/SitesBase.php b/tests/e2e/Services/Sites/SitesBase.php
index c3377faad8..7b9c5e86b0 100644
--- a/tests/e2e/Services/Sites/SitesBase.php
+++ b/tests/e2e/Services/Sites/SitesBase.php
@@ -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);
diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php
index 69dbd7fdf0..9cca689780 100644
--- a/tests/e2e/Services/Sites/SitesCustomServerTest.php
+++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php
@@ -104,14 +104,17 @@ class SitesCustomServerTest extends Scope
$this->assertEquals('./', $site['body']['outputDirectory']);
$variable = $this->createVariable($siteId, [
+ 'variableId' => ID::unique(),
'key' => 'siteKey1',
'value' => 'siteValue1',
]);
$variable2 = $this->createVariable($siteId, [
+ 'variableId' => ID::unique(),
'key' => 'siteKey2',
'value' => 'siteValue2',
]);
$variable3 = $this->createVariable($siteId, [
+ 'variableId' => ID::unique(),
'key' => 'siteKey3',
'value' => 'siteValue3',
]);
@@ -211,6 +214,7 @@ class SitesCustomServerTest extends Scope
$this->assertEquals('Test Site', $site['body']['name']);
$variable = $this->createVariable($siteId, [
+ 'variableId' => ID::unique(),
'key' => 'siteKey1',
'value' => 'siteValue1',
'secret' => false,
@@ -223,6 +227,7 @@ class SitesCustomServerTest extends Scope
$this->assertEquals(false, $variable['body']['secret']);
$variable2 = $this->createVariable($siteId, [
+ 'variableId' => ID::unique(),
'key' => 'siteKey2',
'value' => 'siteValue2',
'secret' => false,
@@ -235,6 +240,7 @@ class SitesCustomServerTest extends Scope
$this->assertEquals(false, $variable2['body']['secret']);
$secretVariable = $this->createVariable($siteId, [
+ 'variableId' => ID::unique(),
'key' => 'siteKey3',
'value' => 'siteValue3',
'secret' => true,
@@ -330,6 +336,316 @@ class SitesCustomServerTest extends Scope
$this->cleanupSite($siteId);
}
+ public function testListVariablesWithLimit(): void
+ {
+ $site = $this->createSite([
+ 'buildRuntime' => 'node-22',
+ 'fallbackFile' => '',
+ 'framework' => 'other',
+ 'name' => 'Test List Variables Limit',
+ 'outputDirectory' => './',
+ 'siteId' => ID::unique()
+ ]);
+ $siteId = $site['body']['$id'] ?? '';
+ $this->assertEquals(201, $site['headers']['status-code']);
+
+ $variable1 = $this->createVariable($siteId, [
+ 'variableId' => ID::unique(),
+ 'key' => 'LIMIT_KEY_1',
+ 'value' => 'limit-value-1',
+ ]);
+ $this->assertEquals(201, $variable1['headers']['status-code']);
+
+ $variable2 = $this->createVariable($siteId, [
+ 'variableId' => ID::unique(),
+ 'key' => 'LIMIT_KEY_2',
+ 'value' => 'limit-value-2',
+ ]);
+ $this->assertEquals(201, $variable2['headers']['status-code']);
+
+ // List with limit of 1
+ $response = $this->listVariables($siteId, [
+ 'queries' => [
+ Query::limit(1)->toString(),
+ ],
+ 'total' => true,
+ ]);
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertCount(1, $response['body']['variables']);
+ $this->assertGreaterThanOrEqual(2, $response['body']['total']);
+
+ $this->cleanupSite($siteId);
+ }
+
+ public function testListVariablesWithoutTotal(): void
+ {
+ $site = $this->createSite([
+ 'buildRuntime' => 'node-22',
+ 'fallbackFile' => '',
+ 'framework' => 'other',
+ 'name' => 'Test List Variables No Total',
+ 'outputDirectory' => './',
+ 'siteId' => ID::unique()
+ ]);
+ $siteId = $site['body']['$id'] ?? '';
+ $this->assertEquals(201, $site['headers']['status-code']);
+
+ $variable = $this->createVariable($siteId, [
+ 'variableId' => ID::unique(),
+ 'key' => 'NO_TOTAL_KEY',
+ 'value' => 'no-total-value',
+ ]);
+ $this->assertEquals(201, $variable['headers']['status-code']);
+
+ // List with total=false
+ $response = $this->listVariables($siteId, [
+ 'total' => false,
+ ]);
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals(0, $response['body']['total']);
+ $this->assertGreaterThanOrEqual(1, \count($response['body']['variables']));
+
+ $this->cleanupSite($siteId);
+ }
+
+ public function testListVariablesCursorPagination(): void
+ {
+ $site = $this->createSite([
+ 'buildRuntime' => 'node-22',
+ 'fallbackFile' => '',
+ 'framework' => 'other',
+ 'name' => 'Test List Variables Cursor',
+ 'outputDirectory' => './',
+ 'siteId' => ID::unique()
+ ]);
+ $siteId = $site['body']['$id'] ?? '';
+ $this->assertEquals(201, $site['headers']['status-code']);
+
+ $variable1 = $this->createVariable($siteId, [
+ 'variableId' => ID::unique(),
+ 'key' => 'CURSOR_KEY_1',
+ 'value' => 'cursor-value-1',
+ ]);
+ $this->assertEquals(201, $variable1['headers']['status-code']);
+
+ $variable2 = $this->createVariable($siteId, [
+ 'variableId' => ID::unique(),
+ 'key' => 'CURSOR_KEY_2',
+ 'value' => 'cursor-value-2',
+ ]);
+ $this->assertEquals(201, $variable2['headers']['status-code']);
+
+ // Get first page with limit 1
+ $page1 = $this->listVariables($siteId, [
+ 'queries' => [
+ Query::limit(1)->toString(),
+ ],
+ 'total' => true,
+ ]);
+
+ $this->assertEquals(200, $page1['headers']['status-code']);
+ $this->assertCount(1, $page1['body']['variables']);
+ $cursorId = $page1['body']['variables'][0]['$id'];
+
+ // Get next page using cursor
+ $page2 = $this->listVariables($siteId, [
+ 'queries' => [
+ Query::limit(1)->toString(),
+ Query::cursorAfter(new Document(['$id' => $cursorId]))->toString(),
+ ],
+ 'total' => true,
+ ]);
+
+ $this->assertEquals(200, $page2['headers']['status-code']);
+ $this->assertCount(1, $page2['body']['variables']);
+ $this->assertNotEquals($cursorId, $page2['body']['variables'][0]['$id']);
+
+ $this->cleanupSite($siteId);
+ }
+
+ public function testUpdateVariableKey(): void
+ {
+ $site = $this->createSite([
+ 'buildRuntime' => 'node-22',
+ 'fallbackFile' => '',
+ 'framework' => 'other',
+ 'name' => 'Test Update Variable Key',
+ 'outputDirectory' => './',
+ 'siteId' => ID::unique()
+ ]);
+ $siteId = $site['body']['$id'] ?? '';
+ $this->assertEquals(201, $site['headers']['status-code']);
+
+ $variable = $this->createVariable($siteId, [
+ 'variableId' => ID::unique(),
+ 'key' => 'KEY_BEFORE',
+ 'value' => 'unchanged-value',
+ 'secret' => false
+ ]);
+ $this->assertEquals(201, $variable['headers']['status-code']);
+ $variableId = $variable['body']['$id'];
+
+ // Update only key
+ $response = $this->updateVariable($siteId, $variableId, [
+ 'key' => 'KEY_AFTER',
+ ]);
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals('KEY_AFTER', $response['body']['key']);
+ $this->assertEquals('unchanged-value', $response['body']['value']);
+
+ $this->cleanupSite($siteId);
+ }
+
+ public function testUpdateVariableValueOnly(): void
+ {
+ $site = $this->createSite([
+ 'buildRuntime' => 'node-22',
+ 'fallbackFile' => '',
+ 'framework' => 'other',
+ 'name' => 'Test Update Variable Value',
+ 'outputDirectory' => './',
+ 'siteId' => ID::unique()
+ ]);
+ $siteId = $site['body']['$id'] ?? '';
+ $this->assertEquals(201, $site['headers']['status-code']);
+
+ $variable = $this->createVariable($siteId, [
+ 'variableId' => ID::unique(),
+ 'key' => 'UNCHANGED_KEY',
+ 'value' => 'value-before',
+ 'secret' => false
+ ]);
+ $this->assertEquals(201, $variable['headers']['status-code']);
+ $variableId = $variable['body']['$id'];
+
+ // Update only value
+ $response = $this->updateVariable($siteId, $variableId, [
+ 'value' => 'value-after',
+ ]);
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals('UNCHANGED_KEY', $response['body']['key']);
+ $this->assertEquals('value-after', $response['body']['value']);
+
+ $this->cleanupSite($siteId);
+ }
+
+ public function testUpdateVariableNoOp(): void
+ {
+ $site = $this->createSite([
+ 'buildRuntime' => 'node-22',
+ 'fallbackFile' => '',
+ 'framework' => 'other',
+ 'name' => 'Test Update Variable NoOp',
+ 'outputDirectory' => './',
+ 'siteId' => ID::unique()
+ ]);
+ $siteId = $site['body']['$id'] ?? '';
+ $this->assertEquals(201, $site['headers']['status-code']);
+
+ $variable = $this->createVariable($siteId, [
+ 'variableId' => ID::unique(),
+ 'key' => 'NOOP_KEY',
+ 'value' => 'noop-value',
+ 'secret' => false
+ ]);
+ $this->assertEquals(201, $variable['headers']['status-code']);
+ $variableId = $variable['body']['$id'];
+
+ // Update with no parameters should fail with 400
+ $response = $this->updateVariable($siteId, $variableId, []);
+
+ $this->assertEquals(400, $response['headers']['status-code']);
+
+ $this->cleanupSite($siteId);
+ }
+
+ public function testUpdateVariableNotFound(): void
+ {
+ $site = $this->createSite([
+ 'buildRuntime' => 'node-22',
+ 'fallbackFile' => '',
+ 'framework' => 'other',
+ 'name' => 'Test Update Variable Not Found',
+ 'outputDirectory' => './',
+ 'siteId' => ID::unique()
+ ]);
+ $siteId = $site['body']['$id'] ?? '';
+ $this->assertEquals(201, $site['headers']['status-code']);
+
+ $response = $this->updateVariable($siteId, 'non-existent-id', [
+ 'key' => 'NEW_KEY',
+ 'value' => 'new-value',
+ ]);
+
+ $this->assertEquals(404, $response['headers']['status-code']);
+ $this->assertEquals('variable_not_found', $response['body']['type']);
+
+ $this->cleanupSite($siteId);
+ }
+
+ public function testCreateVariableInvalidId(): void
+ {
+ $site = $this->createSite([
+ 'buildRuntime' => 'node-22',
+ 'fallbackFile' => '',
+ 'framework' => 'other',
+ 'name' => 'Test Invalid Variable ID',
+ 'outputDirectory' => './',
+ 'siteId' => ID::unique()
+ ]);
+ $siteId = $site['body']['$id'] ?? '';
+ $this->assertEquals(201, $site['headers']['status-code']);
+
+ $variable = $this->createVariable($siteId, [
+ 'variableId' => '!invalid-id!',
+ 'key' => 'INVALID_ID_KEY',
+ 'value' => 'value',
+ ]);
+
+ $this->assertEquals(400, $variable['headers']['status-code']);
+
+ $this->cleanupSite($siteId);
+ }
+
+ public function testCreateVariableDuplicateId(): void
+ {
+ $site = $this->createSite([
+ 'buildRuntime' => 'node-22',
+ 'fallbackFile' => '',
+ 'framework' => 'other',
+ 'name' => 'Test Duplicate Variable ID',
+ 'outputDirectory' => './',
+ 'siteId' => ID::unique()
+ ]);
+ $siteId = $site['body']['$id'] ?? '';
+ $this->assertEquals(201, $site['headers']['status-code']);
+
+ $variableId = ID::unique();
+
+ $variable = $this->createVariable($siteId, [
+ 'variableId' => $variableId,
+ 'key' => 'DUP_ID_KEY_1',
+ 'value' => 'value1',
+ ]);
+ $this->assertEquals(201, $variable['headers']['status-code']);
+
+ // Attempt to create with same ID
+ $duplicate = $this->createVariable($siteId, [
+ 'variableId' => $variableId,
+ 'key' => 'DUP_ID_KEY_2',
+ 'value' => 'value2',
+ ]);
+
+ $this->assertEquals(409, $duplicate['headers']['status-code']);
+ $this->assertEquals('variable_already_exists', $duplicate['body']['type']);
+
+ $this->cleanupSite($siteId);
+ }
+
// This is first Sites test with Proxy
// If this fails, it may not be related to variables; but Router flow failing
public function testVariablesE2E(): void
@@ -351,6 +667,7 @@ class SitesCustomServerTest extends Scope
$domain = $this->setupSiteDomain($siteId);
$secretVariable = $this->createVariable($siteId, [
+ 'variableId' => ID::unique(),
'key' => 'name',
'value' => 'Appwrite',
]);
@@ -801,8 +1118,6 @@ class SitesCustomServerTest extends Scope
'siteId' => ID::unique()
]);
- $this->assertNotNull($siteId);
-
/**
* Test for SUCCESS
*/
@@ -868,6 +1183,313 @@ class SitesCustomServerTest extends Scope
// // TODO: Implement testCreateDeploymentFromCLI() later
// }
+ public function testCreateDeploymentWithSingleContentRangeChunk(): void
+ {
+ $siteId = $this->setupSite([
+ 'buildRuntime' => 'node-22',
+ 'fallbackFile' => '',
+ 'framework' => 'other',
+ 'name' => 'Test Site Single Chunk Range',
+ 'outputDirectory' => './',
+ 'providerBranch' => 'main',
+ 'providerRootDirectory' => './',
+ 'siteId' => ID::unique()
+ ]);
+
+ $code = $this->packageSite('static-single-file');
+ $size = \filesize($code->getFilename());
+
+ $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', array_merge([
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'content-range' => 'bytes 0-' . ($size - 1) . '/' . $size,
+ ], $this->getHeaders()), [
+ 'code' => $code,
+ 'activate' => true,
+ ]);
+
+ $this->assertEquals(202, $deployment['headers']['status-code']);
+ $this->assertNotEmpty($deployment['body']['$id']);
+
+ $deploymentId = $deployment['body']['$id'];
+
+ $this->assertEventually(function () use ($siteId, $deploymentId) {
+ $deployment = $this->getDeployment($siteId, $deploymentId);
+
+ $this->assertEquals(200, $deployment['headers']['status-code']);
+ $this->assertEquals('ready', $deployment['body']['status']);
+ }, 120000, 500);
+
+ $this->cleanupSite($siteId);
+ }
+
+ public function testCreateDeploymentOutOfOrder(): void
+ {
+ $siteId = $this->setupSite([
+ 'buildRuntime' => 'node-22',
+ 'fallbackFile' => '',
+ 'framework' => 'other',
+ 'name' => 'Test Site Out of Order Upload',
+ 'outputDirectory' => './',
+ 'providerBranch' => 'main',
+ 'providerRootDirectory' => './',
+ 'siteId' => ID::unique()
+ ]);
+
+ // Create a temporary large site package for chunked upload
+ $tempDir = sys_get_temp_dir() . '/appwrite-test-site-' . uniqid();
+ mkdir($tempDir, 0777, true);
+ file_put_contents($tempDir . '/index.html', 'Hello World');
+ // Add a large dummy file to make the package span multiple chunks
+ file_put_contents($tempDir . '/large.bin', random_bytes(12 * 1024 * 1024)); // 12MB non-compressible
+
+ $codePath = $tempDir . '/code.tar.gz';
+ Console::execute("cd $tempDir && tar --exclude code.tar.gz -czf code.tar.gz .", '', $this->stdout, $this->stderr);
+
+ $totalSize = filesize($codePath);
+ $chunkSize = 5 * 1024 * 1024; // 5MB chunks
+ $mimeType = 'application/x-gzip';
+ $chunksTotal = (int) ceil($totalSize / $chunkSize);
+
+ $this->assertGreaterThanOrEqual(2, $chunksTotal, 'Test file must span at least 2 chunks');
+
+ // Read all chunks into memory
+ $handle = fopen($codePath, "rb");
+ $this->assertNotFalse($handle, "Could not open test resource: $codePath");
+ $chunks = [];
+ for ($i = 0; $i < $chunksTotal; $i++) {
+ $start = $i * $chunkSize;
+ $end = min($start + $chunkSize, $totalSize);
+ $length = $end - $start;
+ $data = fread($handle, $length);
+ $chunks[] = [
+ 'data' => $data,
+ 'start' => $start,
+ 'end' => $end - 1,
+ 'index' => $i,
+ ];
+ }
+ fclose($handle);
+
+ // Upload chunks in out-of-order sequence: last chunk first, then first, then second
+ $uploadOrder = [count($chunks) - 1, 0, 1];
+ $deploymentId = '';
+ $deployment = null;
+
+ foreach ($uploadOrder as $chunkIndex) {
+ $chunk = $chunks[$chunkIndex];
+ $curlFile = new \CURLFile(
+ 'data://' . $mimeType . ';base64,' . base64_encode($chunk['data']),
+ $mimeType,
+ 'code.tar.gz'
+ );
+
+ $headers = [
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'content-range' => 'bytes ' . $chunk['start'] . '-' . $chunk['end'] . '/' . $totalSize,
+ ];
+
+ if (!empty($deploymentId)) {
+ $headers['x-appwrite-id'] = $deploymentId;
+ }
+
+ $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', array_merge($headers, $this->getHeaders()), [
+ 'code' => $curlFile,
+ 'activate' => true,
+ ]);
+
+ $this->assertEquals(202, $deployment['headers']['status-code']);
+ $deploymentId = $deployment['body']['$id'];
+ }
+
+ // Upload remaining chunks in any order to complete the file
+ $remainingChunks = [];
+ for ($i = 2; $i < count($chunks) - 1; $i++) {
+ $remainingChunks[] = $i;
+ }
+ shuffle($remainingChunks);
+
+ foreach ($remainingChunks as $chunkIndex) {
+ $chunk = $chunks[$chunkIndex];
+ $curlFile = new \CURLFile(
+ 'data://' . $mimeType . ';base64,' . base64_encode($chunk['data']),
+ $mimeType,
+ 'code.tar.gz'
+ );
+
+ $headers = [
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'content-range' => 'bytes ' . $chunk['start'] . '-' . $chunk['end'] . '/' . $totalSize,
+ 'x-appwrite-id' => $deploymentId,
+ ];
+
+ $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', array_merge($headers, $this->getHeaders()), [
+ 'code' => $curlFile,
+ 'activate' => true,
+ ]);
+
+ $this->assertEquals(202, $deployment['headers']['status-code']);
+ }
+
+
+
+ // Wait for build to complete
+ $this->assertEventually(function () use ($siteId, $deploymentId) {
+ $deployment = $this->getDeployment($siteId, $deploymentId);
+ $this->assertEquals(200, $deployment['headers']['status-code']);
+ $this->assertEquals('ready', $deployment['body']['status']);
+ }, 120000, 500);
+
+ // Clean up temp files
+ unlink($codePath);
+ unlink($tempDir . '/index.html');
+ unlink($tempDir . '/large.bin');
+ rmdir($tempDir);
+
+ $this->cleanupSite($siteId);
+ }
+
+ public function testCreateDeploymentParallelChunksLargeFile(): void
+ {
+ $siteId = $this->setupSite([
+ 'buildRuntime' => 'node-22',
+ 'fallbackFile' => '',
+ 'framework' => 'other',
+ 'name' => 'Test Site Parallel Chunk Deployment',
+ 'outputDirectory' => './',
+ 'providerBranch' => 'main',
+ 'providerRootDirectory' => './',
+ 'siteId' => ID::unique()
+ ]);
+
+ $deploymentId = ID::unique();
+ $tmpDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'appwrite-parallel-site-deployment-' . $deploymentId;
+
+ mkdir($tmpDirectory);
+
+ try {
+ file_put_contents($tmpDirectory . DIRECTORY_SEPARATOR . 'index.html', 'Hello World');
+ file_put_contents($tmpDirectory . DIRECTORY_SEPARATOR . 'large.bin', random_bytes(20 * 1024 * 1024));
+
+ $source = $tmpDirectory . DIRECTORY_SEPARATOR . 'code.tar.gz';
+ Console::execute('cd ' . $tmpDirectory . ' && tar --exclude code.tar.gz -czf code.tar.gz .', '', $this->stdout, $this->stderr);
+
+ $totalSize = filesize($source);
+ $chunkSize = 5 * 1024 * 1024;
+ $chunksTotal = (int) ceil($totalSize / $chunkSize);
+
+ $this->assertGreaterThanOrEqual(4, $chunksTotal, 'Test deployment must span at least 4 chunks');
+
+ $requests = [];
+ $sourceHandle = fopen($source, 'rb');
+ $this->assertNotFalse($sourceHandle, 'Could not open deployment package');
+
+ try {
+ for ($i = 0; $i < $chunksTotal; $i++) {
+ $start = $i * $chunkSize;
+ $end = min($start + $chunkSize, $totalSize) - 1;
+ $length = $end - $start + 1;
+ $chunkPath = $tmpDirectory . DIRECTORY_SEPARATOR . 'chunk-' . $i . '.part';
+
+ fseek($sourceHandle, $start);
+ file_put_contents($chunkPath, fread($sourceHandle, $length));
+
+ $requests[] = [
+ 'headers' => [
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ 'x-appwrite-id' => $deploymentId,
+ 'content-range' => 'bytes ' . $start . '-' . $end . '/' . $totalSize,
+ ],
+ 'chunkPath' => $chunkPath,
+ ];
+ }
+ } finally {
+ fclose($sourceHandle);
+ }
+
+ $responses = [];
+ $endpoint = parse_url($this->client->getEndpoint());
+ $scheme = $endpoint['scheme'] ?? 'http';
+ $host = $endpoint['host'] ?? 'appwrite';
+ $port = $endpoint['port'] ?? ($scheme === 'https' ? 443 : 80);
+ $basePath = rtrim($endpoint['path'] ?? '', '/');
+
+ \Swoole\Coroutine\run(function () use ($basePath, $host, $port, $requests, $scheme, $siteId, &$responses): void {
+ $wg = new \Swoole\Coroutine\WaitGroup();
+
+ foreach ($requests as $index => $request) {
+ $wg->add();
+ \Swoole\Coroutine::create(function () use ($basePath, $host, $index, $port, $request, &$responses, $scheme, $siteId, $wg): void {
+ try {
+ for ($attempt = 0; $attempt < 3; $attempt++) {
+ $client = new \Swoole\Coroutine\Http\Client($host, (int) $port, $scheme === 'https');
+ $client->set([
+ 'timeout' => 300,
+ 'ssl_verify_peer' => false,
+ 'ssl_verify_host' => false,
+ ]);
+ $client->setHeaders($request['headers']);
+ $client->setMethod(Client::METHOD_POST);
+ $client->setData([
+ 'activate' => true,
+ ]);
+ $client->addFile($request['chunkPath'], 'code', 'application/x-gzip', 'code.tar.gz');
+ $client->execute($basePath . '/sites/' . $siteId . '/deployments');
+
+ $responses[$index] = [
+ 'body' => $client->body,
+ 'error' => $client->errMsg,
+ 'headers' => $client->headers ?? [],
+ 'statusCode' => $client->statusCode,
+ ];
+
+ $client->close();
+
+ if ($responses[$index]['statusCode'] !== 429) {
+ break;
+ }
+
+ $retryAfter = (float) ($responses[$index]['headers']['retry-after'] ?? 0.1);
+ \Swoole\Coroutine::sleep(max($retryAfter, 0.1));
+ }
+ } finally {
+ $wg->done();
+ }
+ });
+ }
+
+ $wg->wait();
+ });
+
+ ksort($responses);
+
+ foreach ($responses as $response) {
+ $this->assertSame('', $response['error']);
+ $this->assertContains($response['statusCode'], [202], (string) $response['body']);
+ }
+
+ $this->assertEventually(function () use ($siteId, $deploymentId) {
+ $deployment = $this->getDeployment($siteId, $deploymentId);
+
+ $this->assertEquals(200, $deployment['headers']['status-code']);
+ $this->assertEquals('ready', $deployment['body']['status']);
+ $this->assertEquals($deploymentId, $deployment['body']['$id']);
+ }, 120000, 500);
+ } finally {
+ $this->cleanupSite($siteId);
+
+ if (is_dir($tmpDirectory)) {
+ foreach (glob($tmpDirectory . DIRECTORY_SEPARATOR . '*') ?: [] as $file) {
+ unlink($file);
+ }
+ rmdir($tmpDirectory);
+ }
+ }
+ }
+
public function testCreateDeployment()
{
$siteId = $this->setupSite([
@@ -881,8 +1503,6 @@ class SitesCustomServerTest extends Scope
'siteId' => ID::unique()
]);
- $this->assertNotNull($siteId);
-
$deployment = $this->createDeployment($siteId, [
'siteId' => $siteId,
'code' => $this->packageSite('static-single-file'),
@@ -943,8 +1563,6 @@ class SitesCustomServerTest extends Scope
'siteId' => ID::unique()
]);
- $this->assertNotNull($siteId);
-
$deployment = $this->createDeployment($siteId, [
'code' => $this->packageSite('static-single-file'),
'activate' => 'false'
@@ -995,8 +1613,6 @@ class SitesCustomServerTest extends Scope
'siteId' => ID::unique()
]);
- $this->assertNotNull($siteId);
-
$deployment = $this->createDeployment($siteId, [
'code' => $this->packageSite('static-single-file'),
'activate' => 'false'
@@ -1040,8 +1656,6 @@ class SitesCustomServerTest extends Scope
'siteId' => ID::unique()
]);
- $this->assertNotNull($siteId);
-
$deployment = $this->createDeployment($siteId, [
'code' => $this->packageSite('static-single-file'),
'activate' => 'false'
@@ -1243,8 +1857,6 @@ class SitesCustomServerTest extends Scope
'siteId' => ID::unique()
]);
- $this->assertNotNull($siteId);
-
$deployment = $this->createDeployment($siteId, [
'code' => $this->packageSite('static-single-file'),
'activate' => 'false'
@@ -1294,8 +1906,6 @@ class SitesCustomServerTest extends Scope
'siteId' => ID::unique()
]);
- $this->assertNotNull($siteId);
-
/**
* Test for SUCCESS
*/
@@ -1383,8 +1993,6 @@ class SitesCustomServerTest extends Scope
'siteId' => ID::unique()
]);
- $this->assertNotNull($siteId);
-
$deployment = $this->createDeployment($siteId, [
'code' => $this->packageSite('static-single-file'),
'activate' => 'false'
@@ -1427,8 +2035,6 @@ class SitesCustomServerTest extends Scope
'siteId' => ID::unique()
]);
- $this->assertNotNull($siteId);
-
$site = $this->deleteSite($siteId);
$this->assertEquals(204, $site['headers']['status-code']);
@@ -2016,7 +2622,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']);
@@ -2024,7 +2630,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']);
@@ -2166,6 +2772,7 @@ class SitesCustomServerTest extends Scope
// Poll for execution logs to be written (async)
// Filter by requestPath to avoid picking up screenshot worker executions
+ // Wait for both the execution entry AND its logs field to be populated
$logs = null;
$timeout = 120;
$start = \time();
@@ -2175,12 +2782,13 @@ class SitesCustomServerTest extends Scope
Query::equal('requestPath', ['/logs-inline'])->toString(),
Query::limit(1)->toString(),
]);
- if (!empty($logs['body']['executions'])) {
+ if (!empty($logs['body']['executions']) && !empty($logs['body']['executions'][0]['logs'])) {
break;
}
\usleep(500000);
}
$this->assertNotEmpty($logs['body']['executions'], 'Execution logs were not available within timeout');
+ $this->assertNotNull($logs['body']['executions'][0]['logs'], 'Execution logs content was not populated within timeout');
$this->assertEquals(200, $logs['headers']['status-code']);
$this->assertStringContainsString($deploymentId, $logs['body']['executions'][0]['deploymentId']);
$this->assertStringContainsString("GET", $logs['body']['executions'][0]['requestMethod']);
@@ -2214,11 +2822,21 @@ class SitesCustomServerTest extends Scope
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertStringContainsString("Action logs printed.", $response['body']);
- $logs = $this->listLogs($siteId, [
- Query::orderDesc('$createdAt')->toString(),
- Query::equal('requestPath', ['/logs-action'])->toString(),
- Query::limit(1)->toString(),
- ]);
+ $logs = null;
+ $start = \time();
+ while (\time() - $start < $timeout) {
+ $logs = $this->listLogs($siteId, [
+ Query::orderDesc('$createdAt')->toString(),
+ Query::equal('requestPath', ['/logs-action'])->toString(),
+ Query::limit(1)->toString(),
+ ]);
+ if (!empty($logs['body']['executions']) && !empty($logs['body']['executions'][0]['logs'])) {
+ break;
+ }
+ \usleep(500000);
+ }
+ $this->assertNotEmpty($logs['body']['executions'], 'Action execution logs were not available within timeout');
+ $this->assertNotNull($logs['body']['executions'][0]['logs'], 'Action execution logs content was not populated within timeout');
$this->assertEquals(200, $logs['headers']['status-code']);
$this->assertStringContainsString($deploymentId, $logs['body']['executions'][0]['deploymentId']);
$this->assertStringContainsString("GET", $logs['body']['executions'][0]['requestMethod']);
@@ -2860,7 +3478,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);
@@ -2902,7 +3520,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']);
@@ -2917,7 +3535,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']);
diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php
index d1cb548016..375e526fcf 100644
--- a/tests/e2e/Services/Storage/StorageBase.php
+++ b/tests/e2e/Services/Storage/StorageBase.php
@@ -391,7 +391,7 @@ trait StorageBase
'bucketId' => ID::unique(),
'name' => 'Test Bucket 2',
'fileSecurity' => true,
- 'maximumFileSize' => 6000000000, //6GB
+ 'maximumFileSize' => 6000000001,
'allowedFileExtensions' => ["jpg", "png"],
'permissions' => [
Permission::read(Role::any()),
@@ -957,6 +957,68 @@ trait StorageBase
$this->assertNotEquals($imageBefore->getImageBlob(), $imageAfter->getImageBlob());
}
+ public function testFilePreviewCacheControlOnCacheHit(): void
+ {
+ $data = $this->setupBucketFile();
+ $bucketId = $data['bucketId'];
+ $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'fileId' => ID::unique(),
+ 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'),
+ 'permissions' => [
+ Permission::read(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ ]);
+ $this->assertEquals(201, $file['headers']['status-code']);
+ $this->assertNotEmpty($file['body']['$id']);
+
+ $fileId = $file['body']['$id'];
+ $params = [
+ 'width' => 123,
+ 'height' => 45,
+ 'output' => 'png',
+ 'quality' => 80,
+ ];
+ $headers = array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders());
+
+ $preview = $this->client->call(
+ Client::METHOD_GET,
+ '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview',
+ $headers,
+ $params
+ );
+
+ $this->assertEquals(200, $preview['headers']['status-code']);
+ $this->assertEquals('image/png', $preview['headers']['content-type']);
+ $this->assertEquals('private, max-age=2592000', $preview['headers']['cache-control']);
+ $this->assertEquals('miss', $preview['headers']['x-appwrite-cache']);
+ $this->assertNotEmpty($preview['body']);
+
+ $cachedPreview = [];
+ $this->assertEventually(function () use (&$cachedPreview, $bucketId, $fileId, $headers, $params) {
+ $cachedPreview = $this->client->call(
+ Client::METHOD_GET,
+ '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview',
+ $headers,
+ $params
+ );
+
+ $this->assertEquals('hit', $cachedPreview['headers']['x-appwrite-cache']);
+ });
+
+ $this->assertEquals(200, $cachedPreview['headers']['status-code']);
+ $this->assertEquals('image/png', $cachedPreview['headers']['content-type']);
+ $this->assertStringStartsWith('private, max-age=', $cachedPreview['headers']['cache-control']);
+ $this->assertEquals($preview['body'], $cachedPreview['body']);
+ }
+
public function testFilePreviewZstdCompression(): void
{
$data = $this->setupZstdCompressionBucket();
@@ -1050,6 +1112,28 @@ trait StorageBase
$this->assertEquals(404, $file['headers']['status-code']);
}
+ public function testFilePreviewAvifPublic(): void
+ {
+ $data = $this->setupBucketFile();
+ $bucketId = $data['bucketId'];
+ $fileId = $data['fileId'];
+ $projectId = $this->getProject()['$id'];
+
+ // Matches the customer's URL pattern: no headers, project + output in query string only
+ $preview = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', [
+ 'content-type' => 'application/json',
+ ], [
+ 'project' => $projectId,
+ 'width' => 1080,
+ 'quality' => 40,
+ 'output' => 'avif',
+ ]);
+
+ $this->assertEquals(200, $preview['headers']['status-code']);
+ $this->assertEquals('image/avif', $preview['headers']['content-type']);
+ $this->assertNotEmpty($preview['body']);
+ }
+
public function testFilePreview(): void
{
$data = $this->setupBucketFile();
@@ -1069,6 +1153,49 @@ trait StorageBase
$this->assertEquals(200, $preview['headers']['status-code']);
$this->assertEquals('image/webp', $preview['headers']['content-type']);
$this->assertNotEmpty($preview['body']);
+
+ // Preview PNG as avif
+ $avifPreview = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'width' => 1080,
+ 'quality' => 40,
+ 'output' => 'avif',
+ ]);
+
+ $this->assertEquals(200, $avifPreview['headers']['status-code']);
+ $this->assertEquals('image/avif', $avifPreview['headers']['content-type']);
+ $this->assertNotEmpty($avifPreview['body']);
+
+ // Preview JPEG as avif
+ $jpegFile = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'fileId' => ID::unique(),
+ 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/disk-a/kitten-1.jpg'), 'image/jpeg', 'kitten-1.jpg'),
+ 'permissions' => [
+ Permission::read(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ ]);
+
+ $this->assertEquals(201, $jpegFile['headers']['status-code']);
+
+ $avifFromJpeg = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $jpegFile['body']['$id'] . '/preview', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'width' => 1080,
+ 'quality' => 40,
+ 'output' => 'avif',
+ ]);
+
+ $this->assertEquals(200, $avifFromJpeg['headers']['status-code']);
+ $this->assertEquals('image/avif', $avifFromJpeg['headers']['content-type']);
+ $this->assertNotEmpty($avifFromJpeg['body']);
}
public function testDeletePartiallyUploadedFile(): void
@@ -1162,6 +1289,331 @@ trait StorageBase
$this->assertEquals(204, $deleteBucketResponse['headers']['status-code']);
}
+ public function testCreateBucketFileOutOfOrder(): void
+ {
+ // Create a bucket for this test
+ $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'bucketId' => ID::unique(),
+ 'name' => 'Test Bucket Out of Order Upload',
+ 'fileSecurity' => true,
+ 'permissions' => [
+ Permission::read(Role::any()),
+ Permission::create(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ ]);
+
+ $this->assertEquals(201, $bucket['headers']['status-code']);
+ $bucketId = $bucket['body']['$id'];
+
+ // Prepare a file that spans at least 3 chunks
+ $source = __DIR__ . "/../../../resources/disk-a/large-file.mp4";
+ $totalSize = \filesize($source);
+ $chunkSize = 5 * 1024 * 1024; // 5MB chunks
+ $mimeType = mime_content_type($source);
+ $chunksTotal = (int) ceil($totalSize / $chunkSize);
+
+ // Read all chunks into memory
+ $handle = fopen($source, "rb");
+ $this->assertNotFalse($handle, "Could not open test resource: $source");
+ $chunks = [];
+ for ($i = 0; $i < $chunksTotal; $i++) {
+ $start = $i * $chunkSize;
+ $end = min($start + $chunkSize, $totalSize);
+ $length = $end - $start;
+ $data = fread($handle, $length);
+ $chunks[] = [
+ 'data' => $data,
+ 'start' => $start,
+ 'end' => $end - 1,
+ 'index' => $i,
+ ];
+ }
+ fclose($handle);
+
+ // We need at least 3 chunks for a meaningful out-of-order test
+ $this->assertGreaterThanOrEqual(3, count($chunks), 'Test file must span at least 3 chunks');
+
+ // Upload chunks in out-of-order sequence: last chunk first, then first, then middle
+ $uploadOrder = [count($chunks) - 1, 0, 1]; // last, first, second (for 3+ chunks)
+ $fileId = ID::unique();
+ $id = '';
+ $uploadedFile = null;
+
+ foreach ($uploadOrder as $chunkIndex) {
+ $chunk = $chunks[$chunkIndex];
+ $curlFile = new \CURLFile(
+ 'data://' . $mimeType . ';base64,' . base64_encode($chunk['data']),
+ $mimeType,
+ 'large-file.mp4'
+ );
+
+ $headers = [
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'content-range' => 'bytes ' . $chunk['start'] . '-' . $chunk['end'] . '/' . $totalSize,
+ ];
+
+ if (!empty($id)) {
+ $headers['x-appwrite-id'] = $id;
+ }
+
+ $uploadedFile = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge($headers, $this->getHeaders()), [
+ 'fileId' => $fileId,
+ 'file' => $curlFile,
+ 'permissions' => [
+ Permission::read(Role::any()),
+ ],
+ ]);
+
+ $this->assertEquals(201, $uploadedFile['headers']['status-code']);
+ $id = $uploadedFile['body']['$id'];
+ }
+
+ // Upload remaining chunks in any order to complete the file
+ $remainingChunks = [];
+ for ($i = 2; $i < count($chunks) - 1; $i++) {
+ $remainingChunks[] = $i;
+ }
+ // Shuffle remaining chunks for extra randomness
+ shuffle($remainingChunks);
+
+ foreach ($remainingChunks as $chunkIndex) {
+ $chunk = $chunks[$chunkIndex];
+ $curlFile = new \CURLFile(
+ 'data://' . $mimeType . ';base64,' . base64_encode($chunk['data']),
+ $mimeType,
+ 'large-file.mp4'
+ );
+
+ $headers = [
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'content-range' => 'bytes ' . $chunk['start'] . '-' . $chunk['end'] . '/' . $totalSize,
+ 'x-appwrite-id' => $id,
+ ];
+
+ $uploadedFile = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge($headers, $this->getHeaders()), [
+ 'fileId' => $fileId,
+ 'file' => $curlFile,
+ 'permissions' => [
+ Permission::read(Role::any()),
+ ],
+ ]);
+
+ $this->assertEquals(201, $uploadedFile['headers']['status-code']);
+ }
+
+ // Verify the final upload response indicates completion
+ $this->assertEquals($chunksTotal, $uploadedFile['body']['chunksTotal']);
+ $this->assertEquals($chunksTotal, $uploadedFile['body']['chunksUploaded']);
+
+ // Verify the file can be downloaded and matches the original
+ $download = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $id . '/download', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+
+ $this->assertEquals(200, $download['headers']['status-code']);
+ $this->assertEquals($totalSize, strlen($download['body']));
+ $this->assertEquals(md5_file($source), md5($download['body']));
+
+ // Clean up
+ $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId . '/files/' . $id, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+
+ $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId, [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]);
+ }
+
+ public function testCreateBucketFileParallelChunksLargeFile(): void
+ {
+ $totalSize = 20 * 1024 * 1024;
+ $chunkSize = 5 * 1024 * 1024;
+ $chunksTotal = (int) ceil($totalSize / $chunkSize);
+
+ $this->assertGreaterThanOrEqual(4, $chunksTotal, 'Test file must span at least 4 chunks');
+
+ $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'bucketId' => ID::unique(),
+ 'name' => 'Test Bucket Parallel Chunk Upload',
+ 'fileSecurity' => true,
+ 'maximumFileSize' => $totalSize,
+ 'permissions' => [
+ Permission::read(Role::any()),
+ Permission::create(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ ]);
+
+ $this->assertEquals(201, $bucket['headers']['status-code']);
+
+ $bucketId = $bucket['body']['$id'];
+ $fileId = ID::unique();
+ $tmpDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'appwrite-parallel-upload-' . $fileId;
+ $source = $tmpDirectory . DIRECTORY_SEPARATOR . 'large-parallel-upload.bin';
+
+ mkdir($tmpDirectory);
+
+ try {
+ $handle = fopen($source, 'wb');
+ $this->assertNotFalse($handle, 'Could not create test file');
+
+ $remaining = $totalSize;
+ $block = str_repeat(hash('sha256', $fileId, binary: true), 1024);
+ while ($remaining > 0) {
+ $bytes = substr($block, 0, min(strlen($block), $remaining));
+ fwrite($handle, $bytes);
+ $remaining -= strlen($bytes);
+ }
+ fclose($handle);
+
+ $requests = [];
+
+ $sourceHandle = fopen($source, 'rb');
+ $this->assertNotFalse($sourceHandle, 'Could not open test file');
+
+ for ($i = 0; $i < $chunksTotal; $i++) {
+ $start = $i * $chunkSize;
+ $end = min($start + $chunkSize, $totalSize) - 1;
+ $length = $end - $start + 1;
+ $chunkPath = $tmpDirectory . DIRECTORY_SEPARATOR . 'chunk-' . $i . '.part';
+
+ fseek($sourceHandle, $start);
+ file_put_contents($chunkPath, fread($sourceHandle, $length));
+
+ $requests[] = [
+ 'headers' => [
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ 'content-range' => 'bytes ' . $start . '-' . $end . '/' . $totalSize,
+ ],
+ 'chunkPath' => $chunkPath,
+ ];
+ }
+ fclose($sourceHandle);
+
+ $responses = [];
+ $endpoint = parse_url($this->client->getEndpoint());
+ $scheme = $endpoint['scheme'] ?? 'http';
+ $host = $endpoint['host'] ?? 'appwrite';
+ $port = $endpoint['port'] ?? ($scheme === 'https' ? 443 : 80);
+ $basePath = rtrim($endpoint['path'] ?? '', '/');
+
+ \Swoole\Coroutine\run(function () use ($basePath, $bucketId, $fileId, $host, $port, $requests, $scheme, &$responses): void {
+ $wg = new \Swoole\Coroutine\WaitGroup();
+
+ foreach ($requests as $index => $request) {
+ $wg->add();
+ \Swoole\Coroutine::create(function () use ($basePath, $bucketId, $fileId, $host, $index, $port, $request, &$responses, $scheme, $wg): void {
+ try {
+ for ($attempt = 0; $attempt < 3; $attempt++) {
+ $client = new \Swoole\Coroutine\Http\Client($host, (int) $port, $scheme === 'https');
+ $client->set([
+ 'timeout' => 300,
+ 'ssl_verify_peer' => false,
+ 'ssl_verify_host' => false,
+ ]);
+ $client->setHeaders($request['headers']);
+ $client->setMethod(Client::METHOD_POST);
+ $client->setData([
+ 'fileId' => $fileId,
+ 'permissions[0]' => Permission::read(Role::any()),
+ 'permissions[1]' => Permission::delete(Role::any()),
+ ]);
+ $client->addFile($request['chunkPath'], 'file', 'application/octet-stream', 'large-parallel-upload.bin');
+ $client->execute($basePath . '/storage/buckets/' . $bucketId . '/files');
+
+ $responses[$index] = [
+ 'body' => $client->body,
+ 'error' => $client->errMsg,
+ 'headers' => $client->headers ?? [],
+ 'statusCode' => $client->statusCode,
+ ];
+
+ $client->close();
+
+ if ($responses[$index]['statusCode'] !== 429) {
+ break;
+ }
+
+ $retryAfter = (float) ($responses[$index]['headers']['retry-after'] ?? 0.1);
+ \Swoole\Coroutine::sleep(max($retryAfter, 0.1));
+ }
+ } finally {
+ $wg->done();
+ }
+ });
+ }
+
+ $wg->wait();
+ });
+
+ ksort($responses);
+
+ foreach ($responses as $response) {
+ $this->assertSame('', $response['error']);
+ $this->assertContains($response['statusCode'], [200, 201], (string) $response['body']);
+ }
+
+ $uploadedFile = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]));
+
+ $this->assertEquals(200, $uploadedFile['headers']['status-code']);
+ $this->assertEquals($chunksTotal, $uploadedFile['body']['chunksTotal']);
+ $this->assertEquals($chunksTotal, $uploadedFile['body']['chunksUploaded']);
+
+ $download = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/download', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]));
+
+ $this->assertEquals(200, $download['headers']['status-code']);
+ $this->assertEquals($totalSize, strlen($download['body']));
+ $this->assertEquals(hash_file('sha256', $source), hash('sha256', $download['body']));
+ } finally {
+ if (isset($bucketId)) {
+ $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId . '/files/' . $fileId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]));
+
+ $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId, [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]);
+ }
+
+ foreach (glob($tmpDirectory . DIRECTORY_SEPARATOR . '*') ?: [] as $file) {
+ unlink($file);
+ }
+
+ if (is_dir($tmpDirectory)) {
+ rmdir($tmpDirectory);
+ }
+ }
+ }
+
public function testDeleteBucketFile(): void
{
// Create a fresh file just for deletion testing (not using cache since we delete it)
diff --git a/tests/e2e/Services/TablesDB/DatabasesNumericTypesTest.php b/tests/e2e/Services/TablesDB/DatabasesNumericTypesTest.php
new file mode 100644
index 0000000000..4280bfece9
--- /dev/null
+++ b/tests/e2e/Services/TablesDB/DatabasesNumericTypesTest.php
@@ -0,0 +1,482 @@
+getProject()['$id'] ?? 'default';
+ if (!empty(self::$setupCache[$cacheKey])) {
+ return self::$setupCache[$cacheKey];
+ }
+
+ $projectId = $this->getProject()['$id'];
+ $apiKey = $this->getProject()['apiKey'];
+ $headers = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $apiKey,
+ ];
+
+ $database = $this->client->call(Client::METHOD_POST, '/tablesdb', $headers, [
+ 'databaseId' => ID::unique(),
+ 'name' => 'Numeric Types Test Database',
+ ]);
+
+ $this->assertEquals(201, $database['headers']['status-code']);
+ $databaseId = $database['body']['$id'];
+
+ $table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $headers, [
+ 'tableId' => ID::unique(),
+ 'name' => 'Numeric Types Table',
+ 'rowSecurity' => true,
+ 'permissions' => [
+ Permission::create(Role::any()),
+ Permission::read(Role::any()),
+ ],
+ ]);
+
+ $this->assertEquals(201, $table['headers']['status-code']);
+ $tableId = $table['body']['$id'];
+
+ // Create integer column
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', $headers, [
+ 'key' => 'integer_field',
+ 'required' => false,
+ 'min' => -10,
+ 'max' => 10,
+ 'default' => 0,
+ ]);
+
+ // Create bigint column
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/bigint', $headers, [
+ 'key' => 'bigint_field',
+ 'required' => false,
+ 'min' => -9007199254740991,
+ 'max' => 9007199254740991,
+ 'default' => 9007199254740000,
+ ]);
+
+ // Create unsigned integer column
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', $headers, [
+ 'key' => 'unsigned_int_field',
+ 'required' => false,
+ 'min' => 0,
+ 'max' => 100,
+ 'default' => 0,
+ 'signed' => false,
+ ]);
+
+ // Create unsigned bigint column
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/bigint', $headers, [
+ 'key' => 'unsigned_bigint_field',
+ 'required' => false,
+ 'min' => 0,
+ 'max' => 9223372036854775807,
+ 'default' => 0,
+ 'signed' => false,
+ ]);
+
+ // Cache before waiting so that if waitForAllAttributes times out,
+ // subsequent calls don't try to re-create the same columns (causing 409)
+ self::$setupCache[$cacheKey] = [
+ 'databaseId' => $databaseId,
+ 'tableId' => $tableId,
+ ];
+
+ // Wait for all columns to be available
+ $this->waitForAllAttributes($databaseId, $tableId);
+
+ return self::$setupCache[$cacheKey];
+ }
+
+ /**
+ * Setup database/table without caching so mutations (update/delete) don't
+ * affect other tests that might be executed in a different order.
+ */
+ protected function setupFreshDatabaseAndTable(): array
+ {
+ $projectId = $this->getProject()['$id'];
+ $apiKey = $this->getProject()['apiKey'];
+ $headers = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $apiKey,
+ ];
+
+ $database = $this->client->call(Client::METHOD_POST, '/tablesdb', $headers, [
+ 'databaseId' => ID::unique(),
+ 'name' => 'Numeric Types Test Database',
+ ]);
+
+ $this->assertEquals(201, $database['headers']['status-code']);
+ $databaseId = $database['body']['$id'];
+
+ $table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $headers, [
+ 'tableId' => ID::unique(),
+ 'name' => 'Numeric Types Table',
+ 'rowSecurity' => true,
+ 'permissions' => [
+ Permission::create(Role::any()),
+ Permission::read(Role::any()),
+ ],
+ ]);
+
+ $this->assertEquals(201, $table['headers']['status-code']);
+ $tableId = $table['body']['$id'];
+
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', $headers, [
+ 'key' => 'integer_field',
+ 'required' => false,
+ 'min' => -10,
+ 'max' => 10,
+ 'default' => 0,
+ ]);
+
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/bigint', $headers, [
+ 'key' => 'bigint_field',
+ 'required' => false,
+ 'min' => -9007199254740991,
+ 'max' => 9007199254740991,
+ 'default' => 9007199254740000,
+ ]);
+
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', $headers, [
+ 'key' => 'unsigned_int_field',
+ 'required' => false,
+ 'max' => 100,
+ 'default' => 0,
+ 'signed' => false,
+ ]);
+
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/bigint', $headers, [
+ 'key' => 'unsigned_bigint_field',
+ 'required' => false,
+ 'max' => 9223372036854775807,
+ 'default' => 0,
+ 'signed' => false,
+ ]);
+
+ $this->waitForAllAttributes($databaseId, $tableId);
+
+ return [
+ 'databaseId' => $databaseId,
+ 'tableId' => $tableId,
+ ];
+ }
+
+ public function testCreateDatabase(): void
+ {
+ $database = $this->client->call(Client::METHOD_POST, '/tablesdb', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'databaseId' => ID::unique(),
+ 'name' => 'Numeric Types Test Database',
+ ]);
+
+ $this->assertEquals(201, $database['headers']['status-code']);
+ }
+
+ public function testCreateTable(): void
+ {
+ $data = $this->setupDatabaseAndTable();
+
+ $table = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $data['databaseId'] . '/tables/' . $data['tableId'], [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]);
+
+ $this->assertEquals(200, $table['headers']['status-code']);
+ $this->assertEquals($data['tableId'], $table['body']['$id']);
+ }
+
+ public function testGetIntegerAndBigIntColumns(): void
+ {
+ $data = $this->setupDatabaseAndTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ $integerColumn = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer_field', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]);
+
+ $this->assertEquals(200, $integerColumn['headers']['status-code']);
+ $this->assertEquals('integer_field', $integerColumn['body']['key']);
+ $this->assertEquals('integer', $integerColumn['body']['type']);
+ $this->assertEquals(false, $integerColumn['body']['required']);
+ $this->assertEquals(false, $integerColumn['body']['array']);
+ $this->assertEquals(-10, $integerColumn['body']['min']);
+ $this->assertEquals(10, $integerColumn['body']['max']);
+ $this->assertEquals(0, $integerColumn['body']['default']);
+
+ $bigintColumn = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/bigint_field', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]);
+
+ $this->assertEquals(200, $bigintColumn['headers']['status-code']);
+ $this->assertEquals('bigint_field', $bigintColumn['body']['key']);
+
+ $this->assertEquals('bigint', $bigintColumn['body']['type']);
+ $this->assertEquals(false, $bigintColumn['body']['required']);
+ $this->assertEquals(false, $bigintColumn['body']['array']);
+ $this->assertEquals(-9007199254740991, $bigintColumn['body']['min']);
+ $this->assertEquals(9007199254740991, $bigintColumn['body']['max']);
+ $this->assertEquals(9007199254740000, $bigintColumn['body']['default']);
+ }
+
+ public function testGetUnsignedIntegerAndBigIntColumns(): void
+ {
+ $data = $this->setupDatabaseAndTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ $unsignedIntColumn = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/unsigned_int_field', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]);
+
+ $this->assertEquals(200, $unsignedIntColumn['headers']['status-code']);
+ $this->assertEquals('unsigned_int_field', $unsignedIntColumn['body']['key']);
+ $this->assertEquals('integer', $unsignedIntColumn['body']['type']);
+ $this->assertEquals(false, $unsignedIntColumn['body']['required']);
+ $this->assertEquals(false, $unsignedIntColumn['body']['array']);
+ $this->assertEquals(false, $unsignedIntColumn['body']['signed']);
+ $this->assertEquals(0, $unsignedIntColumn['body']['min']);
+ $this->assertEquals(100, $unsignedIntColumn['body']['max']);
+ $this->assertEquals(0, $unsignedIntColumn['body']['default']);
+
+ $unsignedBigintColumn = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/unsigned_bigint_field', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]);
+
+ $this->assertEquals(200, $unsignedBigintColumn['headers']['status-code']);
+ $this->assertEquals('unsigned_bigint_field', $unsignedBigintColumn['body']['key']);
+ $this->assertEquals('bigint', $unsignedBigintColumn['body']['type']);
+ $this->assertEquals(false, $unsignedBigintColumn['body']['required']);
+ $this->assertEquals(false, $unsignedBigintColumn['body']['array']);
+ $this->assertEquals(false, $unsignedBigintColumn['body']['signed']);
+ $this->assertEquals(0, $unsignedBigintColumn['body']['min']);
+ $this->assertEquals(9223372036854775807, $unsignedBigintColumn['body']['max']);
+ $this->assertEquals(0, $unsignedBigintColumn['body']['default']);
+ }
+
+ public function testListColumnsWithNumericTypes(): void
+ {
+ $data = $this->setupDatabaseAndTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ $columns = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]);
+
+ $this->assertEquals(200, $columns['headers']['status-code']);
+ $this->assertIsArray($columns['body']['columns']);
+ $this->assertGreaterThan(0, $columns['body']['total']);
+
+ $columnKeys = array_map(fn ($col) => $col['key'], $columns['body']['columns']);
+ $this->assertContains('integer_field', $columnKeys);
+ $this->assertContains('bigint_field', $columnKeys);
+ $this->assertContains('unsigned_int_field', $columnKeys);
+ $this->assertContains('unsigned_bigint_field', $columnKeys);
+
+ $columnTypeByKey = [];
+ foreach ($columns['body']['columns'] as $col) {
+ $columnTypeByKey[$col['key']] = $col['type'];
+ }
+
+ $this->assertEquals('integer', $columnTypeByKey['integer_field']);
+ $this->assertEquals('bigint', $columnTypeByKey['bigint_field']);
+ $this->assertEquals('integer', $columnTypeByKey['unsigned_int_field']);
+ $this->assertEquals('bigint', $columnTypeByKey['unsigned_bigint_field']);
+ }
+
+ public function testCreateRowWithIntegerAndBigIntTypes(): void
+ {
+ $data = $this->setupDatabaseAndTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'rowId' => ID::unique(),
+ 'data' => [
+ 'integer_field' => 5,
+ 'bigint_field' => 456,
+ 'unsigned_int_field' => 50,
+ 'unsigned_bigint_field' => 9007199254740000,
+ ],
+ 'permissions' => [
+ Permission::read(Role::any()),
+ ],
+ ]);
+
+ $this->assertEquals(201, $row['headers']['status-code']);
+ $this->assertEquals(5, $row['body']['integer_field']);
+ $this->assertEquals(456, $row['body']['bigint_field']);
+ $this->assertEquals(50, $row['body']['unsigned_int_field']);
+ $this->assertEquals(9007199254740000, $row['body']['unsigned_bigint_field']);
+ }
+
+ public function testUpdateIntegerAndBigIntColumns(): void
+ {
+ $data = $this->setupFreshDatabaseAndTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ // Update integer column
+ $updateInteger = $this->client->call(
+ Client::METHOD_PATCH,
+ '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer/integer_field',
+ [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ],
+ [
+ 'required' => false,
+ 'min' => -20,
+ 'max' => 20,
+ 'default' => 3,
+ ]
+ );
+
+ $this->assertEquals(200, $updateInteger['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId) {
+ $column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer_field', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]);
+
+ $this->assertEquals(200, $column['headers']['status-code']);
+ $this->assertEquals(-20, $column['body']['min']);
+ $this->assertEquals(20, $column['body']['max']);
+ $this->assertEquals(3, $column['body']['default']);
+ }, 30000, 250);
+
+ // Update bigint column
+ $updateBigint = $this->client->call(
+ Client::METHOD_PATCH,
+ '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/bigint/bigint_field',
+ [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ],
+ [
+ 'required' => false,
+ 'min' => -999,
+ 'max' => 999,
+ 'default' => 10,
+ ]
+ );
+
+ $this->assertEquals(200, $updateBigint['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId) {
+ $column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/bigint_field', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]);
+
+ $this->assertEquals(200, $column['headers']['status-code']);
+ $this->assertEquals(-999, $column['body']['min']);
+ $this->assertEquals(999, $column['body']['max']);
+ $this->assertEquals(10, $column['body']['default']);
+ }, 30000, 250);
+ }
+
+ public function testDeleteIntegerAndBigIntColumns(): void
+ {
+ $data = $this->setupFreshDatabaseAndTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ // Delete integer column
+ $deleteInteger = $this->client->call(
+ Client::METHOD_DELETE,
+ '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer_field',
+ [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]
+ );
+
+ $this->assertEquals(204, $deleteInteger['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId) {
+ $column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer_field', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]);
+
+ $this->assertEquals(404, $column['headers']['status-code']);
+ }, 30000, 250);
+
+ // Delete bigint column
+ $deleteBigint = $this->client->call(
+ Client::METHOD_DELETE,
+ '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/bigint_field',
+ [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]
+ );
+
+ $this->assertEquals(204, $deleteBigint['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId) {
+ $column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/bigint_field', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]);
+
+ $this->assertEquals(404, $column['headers']['status-code']);
+ }, 30000, 250);
+ }
+}
diff --git a/tests/e2e/Services/Teams/TeamsBaseClient.php b/tests/e2e/Services/Teams/TeamsBaseClient.php
index 80d73b3bc0..5b04108f71 100644
--- a/tests/e2e/Services/Teams/TeamsBaseClient.php
+++ b/tests/e2e/Services/Teams/TeamsBaseClient.php
@@ -254,7 +254,7 @@ trait TeamsBaseClient
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']['$id']);
$this->assertFalse($response['body']['mfa']);
- $this->assertNotEmpty($response['body']['userId']);
+ $this->assertArrayHasKey('userId', $response['body']);
$this->assertArrayHasKey('userName', $response['body']);
$this->assertArrayHasKey('userEmail', $response['body']);
$this->assertNotEmpty($response['body']['teamId']);
diff --git a/tests/e2e/Services/Teams/TeamsConsoleClientTest.php b/tests/e2e/Services/Teams/TeamsConsoleClientTest.php
index 2a1367d749..da19a26c87 100644
--- a/tests/e2e/Services/Teams/TeamsConsoleClientTest.php
+++ b/tests/e2e/Services/Teams/TeamsConsoleClientTest.php
@@ -14,6 +14,65 @@ class TeamsConsoleClientTest extends Scope
use ProjectConsole;
use SideClient;
+ public function testConsoleMembershipPrivacyDefaults(): void
+ {
+ $teamData = $this->createTeamHelper();
+ $membershipData = $this->createAndAcceptMembershipHelper($teamData['teamUid'], $teamData['teamName']);
+
+ $teamUid = $teamData['teamUid'];
+ $projectId = $this->getProject()['$id'];
+ $owner = $this->getUser();
+ $memberHeaders = [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'cookie' => 'a_session_' . $projectId . '=' . $membershipData['session'],
+ ];
+
+ $ownerMemberships = $this->client->call(Client::METHOD_GET, '/teams/' . $teamUid . '/memberships', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], $this->getHeaders()));
+
+ $this->assertEquals(200, $ownerMemberships['headers']['status-code']);
+ $this->assertEquals(2, $ownerMemberships['body']['total']);
+
+ $ownerMembershipsByUser = [];
+ foreach ($ownerMemberships['body']['memberships'] as $membership) {
+ $ownerMembershipsByUser[$membership['userId']] = $membership;
+ }
+
+ $this->assertArrayHasKey($owner['$id'], $ownerMembershipsByUser);
+ $this->assertContains('owner', $ownerMembershipsByUser[$owner['$id']]['roles']);
+
+ $this->assertArrayHasKey($membershipData['userUid'], $ownerMembershipsByUser);
+ $this->assertNotContains('owner', $ownerMembershipsByUser[$membershipData['userUid']]['roles']);
+ $this->assertSame($membershipData['userUid'], $ownerMembershipsByUser[$membershipData['userUid']]['userId']);
+ $this->assertSame($membershipData['name'], $ownerMembershipsByUser[$membershipData['userUid']]['userName']);
+ $this->assertSame($membershipData['email'], $ownerMembershipsByUser[$membershipData['userUid']]['userEmail']);
+ $this->assertFalse($ownerMembershipsByUser[$membershipData['userUid']]['mfa']);
+
+ $memberMemberships = $this->client->call(Client::METHOD_GET, '/teams/' . $teamUid . '/memberships', $memberHeaders);
+
+ $this->assertEquals(200, $memberMemberships['headers']['status-code']);
+ $this->assertEquals(2, $memberMemberships['body']['total']);
+
+ $memberMembershipsByUser = [];
+ foreach ($memberMemberships['body']['memberships'] as $membership) {
+ $memberMembershipsByUser[$membership['userId']] = $membership;
+ }
+
+ $this->assertArrayHasKey($owner['$id'], $memberMembershipsByUser);
+ $this->assertSame($owner['$id'], $memberMembershipsByUser[$owner['$id']]['userId']);
+ $this->assertSame($owner['name'], $memberMembershipsByUser[$owner['$id']]['userName']);
+ $this->assertSame($owner['email'], $memberMembershipsByUser[$owner['$id']]['userEmail']);
+ $this->assertFalse($memberMembershipsByUser[$owner['$id']]['mfa']);
+ $this->assertContains('owner', $memberMembershipsByUser[$owner['$id']]['roles']);
+
+ $this->assertArrayHasKey($membershipData['userUid'], $memberMembershipsByUser);
+ $this->assertNotContains('owner', $memberMembershipsByUser[$membershipData['userUid']]['roles']);
+ }
+
public function testTeamCreateMembershipConsole(): void
{
$teamData = $this->createTeamHelper();
diff --git a/tests/e2e/Services/Tokens/TokensConsoleClientTest.php b/tests/e2e/Services/Tokens/TokensConsoleClientTest.php
index 601bf1d2d0..80e406eac9 100644
--- a/tests/e2e/Services/Tokens/TokensConsoleClientTest.php
+++ b/tests/e2e/Services/Tokens/TokensConsoleClientTest.php
@@ -147,7 +147,6 @@ class TokensConsoleClientTest extends Scope
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 86400 * 365 * 10, 10); // 10 years maxAge
try {
$payload = $jwt->decode($token['body']['secret']);
- $this->assertIsArray($payload, 'JWT payload should decode to an array');
$this->assertArrayHasKey('tokenId', $payload, 'JWT payload should contain tokenId');
$this->assertArrayHasKey('resourceId', $payload, 'JWT payload should contain resourceId');
$this->assertArrayHasKey('resourceType', $payload, 'JWT payload should contain resourceType');
@@ -204,7 +203,6 @@ class TokensConsoleClientTest extends Scope
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 86400 * 365 * 10, 10); // 10 years maxAge
try {
$payload = $jwt->decode($token['body']['secret']);
- $this->assertIsArray($payload, 'JWT payload should decode to an array');
$this->assertArrayHasKey('exp', $payload, 'JWT payload should contain exp field');
$expectedExp = (new \DateTime($expiry))->getTimestamp();
@@ -226,7 +224,6 @@ class TokensConsoleClientTest extends Scope
// Verify JWT does not contain exp for infinite expiry using native JWT decode
try {
$payload = $jwt->decode($token['body']['secret']);
- $this->assertIsArray($payload, 'JWT payload should decode to an array');
$this->assertArrayNotHasKey('exp', $payload, 'JWT payload should not contain exp field for infinite expiry');
} catch (JWTException $e) {
$this->fail('Failed to decode JWT: ' . $e->getMessage());
@@ -265,7 +262,6 @@ class TokensConsoleClientTest extends Scope
// Verify the JWT token is valid and contains correct information
try {
$payload = $jwt->decode($token['secret']);
- $this->assertIsArray($payload, 'JWT payload should decode to an array');
$this->assertArrayHasKey('tokenId', $payload, 'JWT payload should contain tokenId');
$this->assertArrayHasKey('resourceId', $payload, 'JWT payload should contain resourceId');
$this->assertArrayHasKey('resourceType', $payload, 'JWT payload should contain resourceType');
diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php
index 3255d9a67f..b06e2d88e1 100644
--- a/tests/e2e/Services/Users/UsersBase.php
+++ b/tests/e2e/Services/Users/UsersBase.php
@@ -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
*/
diff --git a/tests/e2e/Services/VCS/VCSConsoleClientTest.php b/tests/e2e/Services/VCS/VCSConsoleClientTest.php
index 854e7110f1..23007339de 100644
--- a/tests/e2e/Services/VCS/VCSConsoleClientTest.php
+++ b/tests/e2e/Services/VCS/VCSConsoleClientTest.php
@@ -513,6 +513,59 @@ class VCSConsoleClientTest extends Scope
$this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'main');
$this->assertEquals($repositoryBranches['body']['branches'][1]['name'], 'test');
+ $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'search' => 'tes',
+ ]);
+
+ $this->assertEquals(200, $repositoryBranches['headers']['status-code']);
+ $this->assertEquals($repositoryBranches['body']['total'], 1);
+ $this->assertCount(1, $repositoryBranches['body']['branches']);
+ $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'test');
+
+ $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [
+ Query::limit(1)->toString(),
+ Query::offset(1)->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(200, $repositoryBranches['headers']['status-code']);
+ $this->assertEquals($repositoryBranches['body']['total'], 2);
+ $this->assertCount(1, $repositoryBranches['body']['branches']);
+ $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'test');
+
+ $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [
+ Query::limit(1)->toString(),
+ Query::cursorAfter(new \Utopia\Database\Document(['$id' => 'main']))->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(200, $repositoryBranches['headers']['status-code']);
+ $this->assertEquals($repositoryBranches['body']['total'], 2);
+ $this->assertCount(1, $repositoryBranches['body']['branches']);
+ $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'test');
+
+ $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [
+ Query::limit(1)->toString(),
+ Query::cursorBefore(new \Utopia\Database\Document(['$id' => 'test']))->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(200, $repositoryBranches['headers']['status-code']);
+ $this->assertEquals($repositoryBranches['body']['total'], 2);
+ $this->assertCount(1, $repositoryBranches['body']['branches']);
+ $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'main');
+
/**
* Test for FAILURE
*/
@@ -522,6 +575,16 @@ class VCSConsoleClientTest extends Scope
], $this->getHeaders()));
$this->assertEquals(404, $repositoryBranches['headers']['status-code']);
+
+ $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [
+ Query::cursorAfter(new \Utopia\Database\Document(['$id' => 'missing-branch']))->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(400, $repositoryBranches['headers']['status-code']);
}
public function testCreateFunctionUsingVCS(): void
diff --git a/tests/e2e/Traits/DatabaseFixture.php b/tests/e2e/Traits/DatabaseFixture.php
deleted file mode 100644
index f3ba10e765..0000000000
--- a/tests/e2e/Traits/DatabaseFixture.php
+++ /dev/null
@@ -1,239 +0,0 @@
-ensureFixturesCreated();
- return self::$fixtureDatabaseId;
- }
-
- protected function getFixtureMoviesId(): string
- {
- $this->ensureFixturesCreated();
- return self::$fixtureMoviesId;
- }
-
- protected function getFixtureActorsId(): string
- {
- $this->ensureFixturesCreated();
- return self::$fixtureActorsId;
- }
-
- protected function getFixtureDocumentIds(): array
- {
- $this->ensureFixturesCreated();
- return self::$fixtureDocumentIds;
- }
-
- protected function ensureFixturesCreated(): void
- {
- if (self::$fixturesInitialized) {
- return;
- }
-
- $this->createDatabaseFixtures();
- self::$fixturesInitialized = true;
- }
-
- protected function createDatabaseFixtures(): void
- {
- $config = $this->getSchemaApiConfig();
- $isTablesDB = $config['basePath'] === '/tablesdb';
-
- // Create database
- $database = $this->client->call(Client::METHOD_POST, $config['basePath'], [
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey']
- ], [
- 'databaseId' => ID::unique(),
- 'name' => 'Fixture Database'
- ]);
-
- self::$fixtureDatabaseId = $database['body']['$id'];
- $databaseId = self::$fixtureDatabaseId;
-
- $collectionEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'];
- $collectionKey = $isTablesDB ? 'tableId' : 'collectionId';
- $docKey = $isTablesDB ? 'rowId' : 'documentId';
- $docEndpoint = $isTablesDB ? 'rows' : 'documents';
-
- // Create Movies collection
- $movies = $this->client->call(Client::METHOD_POST, $collectionEndpoint, [
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey']
- ], [
- $collectionKey => ID::unique(),
- 'name' => 'Movies',
- ($isTablesDB ? 'rowSecurity' : 'documentSecurity') => true,
- 'permissions' => [
- Permission::create(Role::users()),
- Permission::read(Role::users()),
- Permission::update(Role::users()),
- Permission::delete(Role::users()),
- ],
- ]);
-
- self::$fixtureMoviesId = $movies['body']['$id'];
-
- // Create Actors collection
- $actors = $this->client->call(Client::METHOD_POST, $collectionEndpoint, [
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey']
- ], [
- $collectionKey => ID::unique(),
- 'name' => 'Actors',
- ($isTablesDB ? 'rowSecurity' : 'documentSecurity') => true,
- 'permissions' => [
- Permission::create(Role::users()),
- Permission::read(Role::users()),
- Permission::update(Role::users()),
- Permission::delete(Role::users()),
- ],
- ]);
-
- self::$fixtureActorsId = $actors['body']['$id'];
-
- // Create attributes on Movies
- $attrEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureMoviesId . '/' . $config['attributePath'];
-
- $this->client->call(Client::METHOD_POST, $attrEndpoint . '/string', [
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey']
- ], [
- 'key' => 'title',
- 'size' => 256,
- 'required' => true,
- ]);
-
- $this->client->call(Client::METHOD_POST, $attrEndpoint . '/string', [
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey']
- ], [
- 'key' => 'description',
- 'size' => 512,
- 'required' => false,
- 'default' => '',
- ]);
-
- $this->client->call(Client::METHOD_POST, $attrEndpoint . '/integer', [
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey']
- ], [
- 'key' => 'releaseYear',
- 'required' => false,
- 'default' => 0,
- ]);
-
- $this->client->call(Client::METHOD_POST, $attrEndpoint . '/float', [
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey']
- ], [
- 'key' => 'rating',
- 'required' => false,
- 'default' => 0.0,
- ]);
-
- $this->client->call(Client::METHOD_POST, $attrEndpoint . '/boolean', [
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey']
- ], [
- 'key' => 'active',
- 'required' => false,
- 'default' => true,
- ]);
-
- // Create attributes on Actors
- $actorAttrEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureActorsId . '/' . $config['attributePath'];
-
- $this->client->call(Client::METHOD_POST, $actorAttrEndpoint . '/string', [
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey']
- ], [
- 'key' => 'name',
- 'size' => 256,
- 'required' => true,
- ]);
-
- $this->waitForAllAttributes($databaseId, self::$fixtureMoviesId);
- $this->waitForAllAttributes($databaseId, self::$fixtureActorsId);
-
- // Create indexes
- $indexEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureMoviesId . '/' . $config['indexPath'];
-
- $this->client->call(Client::METHOD_POST, $indexEndpoint, [
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey']
- ], [
- 'key' => 'title_index',
- 'type' => 'key',
- 'attributes' => ['title'],
- ]);
-
- $this->waitForAllIndexes($databaseId, self::$fixtureMoviesId);
-
- // Create sample documents
- $docsEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureMoviesId . '/' . $docEndpoint;
-
- $sampleMovies = [
- ['title' => 'Inception', 'description' => 'A mind-bending thriller', 'releaseYear' => 2010, 'rating' => 8.8, 'active' => true],
- ['title' => 'The Matrix', 'description' => 'A sci-fi classic', 'releaseYear' => 1999, 'rating' => 8.7, 'active' => true],
- ['title' => 'Interstellar', 'description' => 'Space exploration epic', 'releaseYear' => 2014, 'rating' => 8.6, 'active' => true],
- ];
-
- foreach ($sampleMovies as $movie) {
- $doc = $this->client->call(Client::METHOD_POST, $docsEndpoint, array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- $docKey => ID::unique(),
- 'data' => $movie,
- 'permissions' => [
- Permission::read(Role::users()),
- Permission::update(Role::user($this->getUser()['$id'])),
- Permission::delete(Role::user($this->getUser()['$id'])),
- ],
- ]);
-
- self::$fixtureDocumentIds[] = $doc['body']['$id'];
- }
- }
-
- public static function tearDownAfterClass(): void
- {
- self::$fixtureDatabaseId = null;
- self::$fixtureMoviesId = null;
- self::$fixtureActorsId = null;
- self::$fixtureDocumentIds = [];
- self::$fixturesInitialized = false;
-
- parent::tearDownAfterClass();
- }
-}
diff --git a/tests/extensions/Async/Eventually.php b/tests/extensions/Async/Eventually.php
index 10f6b41eee..d8c9dc998d 100644
--- a/tests/extensions/Async/Eventually.php
+++ b/tests/extensions/Async/Eventually.php
@@ -11,7 +11,7 @@ final class Eventually extends Constraint
{
}
- public function evaluate(mixed $probe, string $description = '', bool $returnResult = false): ?bool
+ public function evaluate(mixed $probe, string $description = '', bool $returnResult = false): bool
{
if (!is_callable($probe)) {
throw new \Exception('Probe must be a callable');
diff --git a/tests/extensions/RetrySubscriber.php b/tests/extensions/RetrySubscriber.php
index 08623dc261..ff09b187d4 100644
--- a/tests/extensions/RetrySubscriber.php
+++ b/tests/extensions/RetrySubscriber.php
@@ -16,13 +16,6 @@ class RetrySubscriber implements FailedSubscriber
*/
private static array $retryCounts = [];
- /**
- * Track tests that should be retried
- *
- * @var array
- */
- private static array $pendingRetries = [];
-
public function notify(Failed $event): void
{
$this->handleTestFailure($event->test(), $event->throwable()->asString());
@@ -98,6 +91,5 @@ class RetrySubscriber implements FailedSubscriber
public static function reset(): void
{
self::$retryCounts = [];
- self::$pendingRetries = [];
}
}
diff --git a/tests/resources/functions/dynamic-api-key/index.js b/tests/resources/functions/ephemeral-api-key/index.js
similarity index 100%
rename from tests/resources/functions/dynamic-api-key/index.js
rename to tests/resources/functions/ephemeral-api-key/index.js
diff --git a/tests/resources/functions/dynamic-api-key/package-lock.json b/tests/resources/functions/ephemeral-api-key/package-lock.json
similarity index 93%
rename from tests/resources/functions/dynamic-api-key/package-lock.json
rename to tests/resources/functions/ephemeral-api-key/package-lock.json
index 2d86fe18d3..3756c13c0c 100644
--- a/tests/resources/functions/dynamic-api-key/package-lock.json
+++ b/tests/resources/functions/ephemeral-api-key/package-lock.json
@@ -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": {
diff --git a/tests/resources/functions/dynamic-api-key/package.json b/tests/resources/functions/ephemeral-api-key/package.json
similarity index 89%
rename from tests/resources/functions/dynamic-api-key/package.json
rename to tests/resources/functions/ephemeral-api-key/package.json
index 19b8158131..35abec4874 100644
--- a/tests/resources/functions/dynamic-api-key/package.json
+++ b/tests/resources/functions/ephemeral-api-key/package.json
@@ -1,5 +1,5 @@
{
- "name": "dynamic-api-key",
+ "name": "ephemeral-api-key",
"version": "1.0.0",
"main": "index.js",
"scripts": {
diff --git a/tests/resources/functions/dynamic-api-key/setup.sh b/tests/resources/functions/ephemeral-api-key/setup.sh
similarity index 100%
rename from tests/resources/functions/dynamic-api-key/setup.sh
rename to tests/resources/functions/ephemeral-api-key/setup.sh
diff --git a/tests/unit/Advisor/AuthTest.php b/tests/unit/Advisor/AuthTest.php
new file mode 100644
index 0000000000..c2d4a93755
--- /dev/null
+++ b/tests/unit/Advisor/AuthTest.php
@@ -0,0 +1,37 @@
+getLabels()['sdk'];
+
+ $this->assertSame([AuthType::ADMIN, AuthType::KEY], $method->getAuth());
+ }
+
+ public static function advisorActionsProvider(): array
+ {
+ return [
+ 'get report' => [new GetReport()],
+ 'list reports' => [new ListReports()],
+ 'delete report' => [new DeleteReport()],
+ 'get insight' => [new GetInsight()],
+ 'list insights' => [new ListInsights()],
+ ];
+ }
+}
diff --git a/tests/unit/Advisor/Validator/CTAsTest.php b/tests/unit/Advisor/Validator/CTAsTest.php
new file mode 100644
index 0000000000..5511910072
--- /dev/null
+++ b/tests/unit/Advisor/Validator/CTAsTest.php
@@ -0,0 +1,241 @@
+assertFalse($validator->isValid('not-an-array'));
+ $this->assertFalse($validator->isValid(42));
+ $this->assertFalse($validator->isValid(null));
+ }
+
+ public function testAcceptsEmptyArray(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertTrue($validator->isValid([]));
+ }
+
+ public function testAcceptsCompleteEntry(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertTrue($validator->isValid([[
+ 'label' => 'Create missing index',
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ 'params' => [
+ 'databaseId' => 'main',
+ 'tableId' => 'orders',
+ ],
+ ]]));
+ }
+
+ public function testAcceptsEntryWithoutParams(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertTrue($validator->isValid([[
+ 'label' => 'Create missing index',
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ ]]));
+ }
+
+ public function testRejectsEntryMissingRequiredKeys(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertFalse($validator->isValid([['label' => 'x']]));
+ $this->assertFalse($validator->isValid([['label' => 'x', 'service' => 'tablesDB']]));
+ $this->assertFalse($validator->isValid([['label' => 'x', 'method' => 'createIndex']]));
+ }
+
+ public function testRejectsEntryWithEmptyStrings(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertFalse($validator->isValid([[
+ 'label' => '',
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ ]]));
+ }
+
+ public function testRejectsEntryWithNonStringFields(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertFalse($validator->isValid([[
+ 'label' => 123,
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ ]]));
+ }
+
+ public function testRejectsEntryWithScalarParams(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertFalse($validator->isValid([[
+ 'label' => 'Create missing index',
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ 'params' => 'not-a-map',
+ ]]));
+ }
+
+ public function testReportsArrayType(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertTrue($validator->isArray());
+ $this->assertSame($validator::TYPE_ARRAY, $validator->getType());
+ }
+
+ public function testRejectsMoreThanMaxCount(): void
+ {
+ $validator = new CTAs(maxCount: 3);
+
+ $entries = [];
+ for ($i = 0; $i < 4; $i++) {
+ $entries[] = [
+ 'label' => 'Label ' . $i,
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ ];
+ }
+
+ $this->assertFalse($validator->isValid($entries));
+ $this->assertStringContainsString('maximum of 3', $validator->getDescription());
+ }
+
+ public function testAcceptsExactlyMaxCount(): void
+ {
+ $validator = new CTAs(maxCount: 3);
+
+ $entries = [];
+ for ($i = 0; $i < 3; $i++) {
+ $entries[] = [
+ 'label' => 'Label ' . $i,
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ ];
+ }
+
+ $this->assertTrue($validator->isValid($entries));
+ }
+
+ public function testAcceptsObjectParams(): void
+ {
+ $validator = new CTAs();
+
+ $entry = [
+ 'label' => 'Create missing index',
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ 'params' => new \stdClass(),
+ ];
+
+ $this->assertTrue($validator->isValid([$entry]));
+ }
+
+ public function testRejectsEntryWithEmptyService(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertFalse($validator->isValid([[
+ 'label' => 'Create missing index',
+ 'service' => '',
+ 'method' => 'createIndex',
+ ]]));
+ }
+
+ public function testRejectsEntryWithEmptyMethod(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertFalse($validator->isValid([[
+ 'label' => 'Create missing index',
+ 'service' => 'tablesDB',
+ 'method' => '',
+ ]]));
+ }
+
+ public function testRejectsUnknownService(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertFalse($validator->isValid([[
+ 'label' => 'Create missing index',
+ 'service' => 'nonExistentService',
+ 'method' => 'createIndex',
+ ]]));
+ $this->assertStringContainsString('service', $validator->getDescription());
+ }
+
+ public function testRejectsUnknownMethod(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertFalse($validator->isValid([[
+ 'label' => 'Create missing index',
+ 'service' => 'tablesDB',
+ 'method' => 'nonExistentMethod',
+ ]]));
+ $this->assertStringContainsString('method', $validator->getDescription());
+ }
+
+ public function testAcceptsCustomAllowedLists(): void
+ {
+ $validator = new CTAs(
+ allowedServices: ['custom'],
+ allowedMethods: ['doThing'],
+ );
+
+ $this->assertTrue($validator->isValid([[
+ 'label' => 'Custom action',
+ 'service' => 'custom',
+ 'method' => 'doThing',
+ ]]));
+
+ $this->assertFalse($validator->isValid([[
+ 'label' => 'Custom action',
+ 'service' => 'tablesDB',
+ 'method' => 'doThing',
+ ]]));
+ }
+
+ public function testDefaultMaxCountIsSixteen(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertSame(CTAs::MAX_COUNT_DEFAULT, 16);
+
+ $entries = [];
+ for ($i = 0; $i < 16; $i++) {
+ $entries[] = [
+ 'label' => 'Label ' . $i,
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ ];
+ }
+
+ $this->assertTrue($validator->isValid($entries));
+
+ $entries[] = [
+ 'label' => 'Label 16',
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ ];
+
+ $this->assertFalse($validator->isValid($entries));
+ }
+}
diff --git a/tests/unit/Auth/KeyTest.php b/tests/unit/Auth/KeyTest.php
index 58fe3113e1..bcdb46180f 100644
--- a/tests/unit/Auth/KeyTest.php
+++ b/tests/unit/Auth/KeyTest.php
@@ -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;
}
}
diff --git a/tests/unit/Event/EventTest.php b/tests/unit/Event/EventTest.php
index d050ce5f64..471dd5ad08 100644
--- a/tests/unit/Event/EventTest.php
+++ b/tests/unit/Event/EventTest.php
@@ -5,6 +5,7 @@ namespace Tests\Unit\Event;
use Appwrite\Event\Event;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
+use Utopia\Database\Document;
require_once __DIR__ . '/../../../app/init.php';
@@ -115,7 +116,7 @@ class EventTest extends TestCase
'rowId' => 'prolog',
]);
- $this->assertCount(22, $event);
+ $this->assertCount(42, $event);
$this->assertContains('databases.chaptersDB.tables.chapters.rows.prolog.create', $event);
$this->assertContains('databases.chaptersDB.tables.chapters.rows.prolog', $event);
$this->assertContains('databases.chaptersDB.tables.chapters.rows.*.create', $event);
@@ -156,4 +157,62 @@ class EventTest extends TestCase
$this->assertInstanceOf(InvalidArgumentException::class, $th, 'An invalid exception was thrown');
}
}
+
+ public function testGenerateMirrorEvents(): void
+ {
+ $legacyDatabase = new Document(['type' => 'legacy']);
+ $tableRowEvents = Event::generateEvents('databases.[databaseId].tables.[tableId].rows.[rowId].update', [
+ 'databaseId' => 'factory-db',
+ 'tableId' => 'assembly',
+ 'rowId' => 'row-123',
+ ], $legacyDatabase);
+ $this->assertContains('databases.factory-db.collections.assembly.documents.row-123.update', $tableRowEvents);
+
+ $collectionDocumentEvents = Event::generateEvents('databases.[databaseId].collections.[collectionId].documents.[documentId].update', [
+ 'databaseId' => 'factory-db',
+ 'collectionId' => 'assembly',
+ 'documentId' => 'doc-123',
+ ], $legacyDatabase);
+ $this->assertContains('databases.factory-db.tables.assembly.rows.doc-123.update', $collectionDocumentEvents);
+
+ $tableColumnEvents = Event::generateEvents('databases.[databaseId].tables.[tableId].columns.[columnId].create', [
+ 'databaseId' => 'factory-db',
+ 'tableId' => 'assembly',
+ 'columnId' => 'status',
+ ], $legacyDatabase);
+ $this->assertContains('databases.factory-db.collections.assembly.attributes.status.create', $tableColumnEvents);
+
+ $collectionAttributeEvents = Event::generateEvents('databases.[databaseId].collections.[collectionId].attributes.[attributeId].create', [
+ 'databaseId' => 'factory-db',
+ 'collectionId' => 'assembly',
+ 'attributeId' => 'status',
+ ], $legacyDatabase);
+ $this->assertContains('databases.factory-db.tables.assembly.columns.status.create', $collectionAttributeEvents);
+
+ $tablesDb = new Document(['type' => 'tablesdb']);
+ $tablesDbEvents = Event::generateEvents('databases.[databaseId].tables.[tableId].rows.[rowId].update', [
+ 'databaseId' => 'factory-db',
+ 'tableId' => 'assembly',
+ 'rowId' => 'row-123',
+ ], $tablesDb);
+ $this->assertContains('databases.factory-db.collections.assembly.documents.row-123.update', $tablesDbEvents);
+ $this->assertContains('tablesdb.factory-db.tables.assembly.rows.row-123.update', $tablesDbEvents);
+ $tableIdWithReservedWordEvents = Event::generateEvents('databases.[databaseId].tables.[tableId].rows.[rowId].update', [
+ 'databaseId' => 'factory-db',
+ 'tableId' => 'rows-archive',
+ 'rowId' => 'row-123',
+ ], $legacyDatabase);
+ $this->assertContains('databases.factory-db.collections.rows-archive.documents.row-123.update', $tableIdWithReservedWordEvents);
+ $this->assertNotContains('databases.factory-db.collections.documents-archive.documents.row-123.update', $tableIdWithReservedWordEvents);
+
+ $documentsDb = new Document(['type' => 'documentsdb']);
+ $documentsDbEvents = Event::generateEvents('databases.[databaseId].collections.[collectionId].documents.[documentId].update', [
+ 'databaseId' => 'factory-db',
+ 'collectionId' => 'assembly',
+ 'documentId' => 'doc-123',
+ ], $documentsDb);
+ $this->assertContains('documentsdb.factory-db.collections.assembly.documents.doc-123.update', $documentsDbEvents);
+ $this->assertNotContains('documentsdb.factory-db.tables.assembly.rows.doc-123.update', $documentsDbEvents);
+ $this->assertNotContains('databases.factory-db.collections.assembly.documents.doc-123.update', $documentsDbEvents);
+ }
}
diff --git a/tests/unit/Messaging/MessagingChannelsTest.php b/tests/unit/Messaging/MessagingChannelsTest.php
index fc2d839ca6..af6592ef92 100644
--- a/tests/unit/Messaging/MessagingChannelsTest.php
+++ b/tests/unit/Messaging/MessagingChannelsTest.php
@@ -203,7 +203,6 @@ class MessagingChannelsTest extends TestCase
* Making sure the right clients receive the event.
*/
$this->assertStringEndsWith($index, $receiverId);
- $this->assertIsArray($queryKeys);
}
}
}
@@ -240,7 +239,6 @@ class MessagingChannelsTest extends TestCase
* Making sure the right clients receive the event.
*/
$this->assertStringEndsWith($index, $receiverId);
- $this->assertIsArray($queryKeys);
}
}
}
diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php
index 4b2474c760..12c99a83ef 100644
--- a/tests/unit/Messaging/MessagingTest.php
+++ b/tests/unit/Messaging/MessagingTest.php
@@ -147,6 +147,193 @@ class MessagingTest extends TestCase
$this->assertEmpty($realtime->subscriptions);
}
+ public function testSubscribeUnionsChannelsAndRoles(): void
+ {
+ $realtime = new Realtime();
+
+ $realtime->subscribe(
+ '1',
+ 1,
+ 'sub-a',
+ [Role::user(ID::custom('123'))->toString()],
+ ['documents'],
+ );
+
+ $realtime->subscribe(
+ '1',
+ 1,
+ 'sub-b',
+ [Role::users()->toString()],
+ ['files'],
+ );
+
+ $connection = $realtime->connections[1];
+
+ $this->assertContains('documents', $connection['channels']);
+ $this->assertContains('files', $connection['channels']);
+ $this->assertContains(Role::user(ID::custom('123'))->toString(), $connection['roles']);
+ $this->assertContains(Role::users()->toString(), $connection['roles']);
+ $this->assertCount(2, $connection['channels']);
+ $this->assertCount(2, $connection['roles']);
+ }
+
+ public function testUnsubscribeSubscriptionRemovesOnlyOneSubscription(): void
+ {
+ $realtime = new Realtime();
+
+ $realtime->subscribe(
+ '1',
+ 1,
+ 'sub-a',
+ [Role::user(ID::custom('123'))->toString()],
+ ['documents'],
+ );
+
+ $realtime->subscribe(
+ '1',
+ 1,
+ 'sub-b',
+ [Role::users()->toString()],
+ ['files'],
+ );
+
+ $removed = $realtime->unsubscribeSubscription(1, 'sub-a');
+
+ $this->assertTrue($removed);
+ $this->assertArrayHasKey(1, $realtime->connections);
+
+ // sub-a is fully cleaned from the tree
+ $this->assertArrayNotHasKey(
+ Role::user(ID::custom('123'))->toString(),
+ $realtime->subscriptions['1']
+ );
+
+ // sub-b still delivers
+ $event = [
+ 'project' => '1',
+ 'roles' => [Role::users()->toString()],
+ 'data' => [
+ 'channels' => ['files'],
+ ],
+ ];
+ $receivers = array_keys($realtime->getSubscribers($event));
+ $this->assertEquals([1], $receivers);
+
+ // Channels recomputed: sub-a's channel is gone
+ $this->assertSame(['files'], $realtime->connections[1]['channels']);
+
+ // Roles are connection-level auth context — union of both subscribe calls preserved
+ $this->assertContains(Role::user(ID::custom('123'))->toString(), $realtime->connections[1]['roles']);
+ $this->assertContains(Role::users()->toString(), $realtime->connections[1]['roles']);
+ }
+
+ public function testUnsubscribeSubscriptionIsIdempotent(): void
+ {
+ $realtime = new Realtime();
+
+ $realtime->subscribe(
+ '1',
+ 1,
+ 'sub-a',
+ [Role::users()->toString()],
+ ['documents'],
+ );
+
+ $this->assertFalse($realtime->unsubscribeSubscription(1, 'does-not-exist'));
+ $this->assertFalse($realtime->unsubscribeSubscription(99, 'sub-a'));
+
+ // Original sub is untouched
+ $event = [
+ 'project' => '1',
+ 'roles' => [Role::users()->toString()],
+ 'data' => [
+ 'channels' => ['documents'],
+ ],
+ ];
+ $this->assertEquals([1], array_keys($realtime->getSubscribers($event)));
+ }
+
+ public function testUnsubscribeSubscriptionKeepsConnectionWhenLastSubRemoved(): void
+ {
+ $realtime = new Realtime();
+
+ $realtime->subscribe(
+ '1',
+ 1,
+ 'sub-a',
+ [Role::users()->toString()],
+ ['documents'],
+ );
+
+ $this->assertTrue($realtime->unsubscribeSubscription(1, 'sub-a'));
+
+ $this->assertArrayHasKey(1, $realtime->connections);
+ $this->assertSame([], $realtime->connections[1]['channels']);
+ // Roles preserved so a later resubscribe on the same connection still has auth context
+ $this->assertSame([Role::users()->toString()], $realtime->connections[1]['roles']);
+ $this->assertArrayNotHasKey('1', $realtime->subscriptions);
+ }
+
+ public function testResubscribeAfterUnsubscribingLastSubDelivers(): void
+ {
+ $realtime = new Realtime();
+
+ $realtime->subscribe(
+ '1',
+ 1,
+ 'sub-a',
+ [Role::users()->toString()],
+ ['documents'],
+ );
+
+ $this->assertTrue($realtime->unsubscribeSubscription(1, 'sub-a'));
+
+ // Simulate the message-based subscribe path reading stored roles
+ $storedRoles = $realtime->connections[1]['roles'];
+ $this->assertNotEmpty($storedRoles, 'connection roles must survive per-subscription removal');
+
+ $realtime->subscribe('1', 1, 'sub-b', $storedRoles, ['files']);
+
+ $event = [
+ 'project' => '1',
+ 'roles' => [Role::users()->toString()],
+ 'data' => [
+ 'channels' => ['files'],
+ ],
+ ];
+ $this->assertEquals([1], array_keys($realtime->getSubscribers($event)));
+ }
+
+ public function testSubscribeAfterOnOpenEmptySentinelPreservesUnion(): void
+ {
+ $realtime = new Realtime();
+
+ // Mirrors the onOpen empty-channels path: subscribe with '' id, empty channels
+ $realtime->subscribe(
+ '1',
+ 1,
+ '',
+ [Role::users()->toString()],
+ [],
+ [],
+ 'user-123',
+ );
+
+ // Now a real subscription comes in via the subscribe message type
+ $realtime->subscribe(
+ '1',
+ 1,
+ 'sub-a',
+ [Role::user(ID::custom('user-123'))->toString()],
+ ['documents'],
+ );
+
+ $this->assertSame('user-123', $realtime->connections[1]['userId']);
+ $this->assertContains('documents', $realtime->connections[1]['channels']);
+ $this->assertContains(Role::users()->toString(), $realtime->connections[1]['roles']);
+ $this->assertContains(Role::user(ID::custom('user-123'))->toString(), $realtime->connections[1]['roles']);
+ }
+
public function testConvertChannelsGuest(): void
{
$user = new Document([
@@ -209,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
{
/**
@@ -330,4 +759,371 @@ 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");
+ }
+ }
+
+ public function testFromPayloadPresenceChannels(): void
+ {
+ $presenceId = ID::custom('presence123');
+
+ $result = Realtime::fromPayload(
+ event: 'presences.' . $presenceId . '.upsert',
+ payload: new Document([
+ '$id' => $presenceId,
+ '$permissions' => [
+ Permission::read(Role::any()),
+ Permission::update(Role::users()),
+ Permission::delete(Role::users()),
+ ],
+ ]),
+ );
+
+ $this->assertContains('presences', $result['channels']);
+ $this->assertContains('presences.' . $presenceId, $result['channels']);
+ $this->assertContains(Role::any()->toString(), $result['roles']);
+ }
+
+ public function testExtractDeletedPresenceIdReturnsIdForDeleteEvent(): void
+ {
+ $event = [
+ 'project' => 'proj',
+ 'data' => [
+ 'events' => [
+ 'presences.abc.delete',
+ 'presences.*.delete',
+ 'presences.abc',
+ ],
+ 'payload' => ['$id' => 'abc'],
+ ],
+ ];
+
+ $this->assertSame('abc', Realtime::extractDeletedPresenceId($event));
+ }
+
+ public function testExtractDeletedPresenceIdRejectsNonDeleteEvents(): void
+ {
+ $this->assertNull(Realtime::extractDeletedPresenceId([
+ 'data' => [
+ 'events' => ['presences.abc.upsert'],
+ 'payload' => ['$id' => 'abc'],
+ ],
+ ]));
+
+ // Unrelated resource that happens to end with `.delete` must not trigger.
+ $this->assertNull(Realtime::extractDeletedPresenceId([
+ 'data' => [
+ 'events' => ['documents.abc.delete'],
+ 'payload' => ['$id' => 'abc'],
+ ],
+ ]));
+
+ // Missing payload ID — the event names look right but we have nothing to remove.
+ $this->assertNull(Realtime::extractDeletedPresenceId([
+ 'data' => [
+ 'events' => ['presences.abc.delete'],
+ 'payload' => [],
+ ],
+ ]));
+ }
+
+ public function testRemovePresenceFromConnectionsScopedToProject(): void
+ {
+ $realtime = new Realtime();
+
+ // Two connections in different projects both holding the same presence ID; only
+ // the matching project should be touched.
+ $realtime->connections[1] = [
+ 'projectId' => 'proj-a',
+ 'presences' => ['p1' => new Document(['$id' => 'p1']), 'p2' => new Document(['$id' => 'p2'])],
+ ];
+ $realtime->connections[2] = [
+ 'projectId' => 'proj-b',
+ 'presences' => ['p1' => new Document(['$id' => 'p1'])],
+ ];
+
+ $removed = $realtime->removePresenceFromConnections('proj-a', 'p1');
+
+ $this->assertSame(1, $removed);
+ $this->assertArrayNotHasKey('p1', $realtime->connections[1]['presences']);
+ $this->assertArrayHasKey('p2', $realtime->connections[1]['presences']);
+ $this->assertArrayHasKey('p1', $realtime->connections[2]['presences']);
+ }
+
+ public function testRemovePresenceFromConnectionsNoMatchIsNoOp(): void
+ {
+ $realtime = new Realtime();
+ $realtime->connections[1] = [
+ 'projectId' => 'proj-a',
+ 'presences' => ['p1' => new Document(['$id' => 'p1'])],
+ ];
+
+ $this->assertSame(0, $realtime->removePresenceFromConnections('proj-a', 'missing'));
+ $this->assertSame(0, $realtime->removePresenceFromConnections('', 'p1'));
+ $this->assertSame(0, $realtime->removePresenceFromConnections('proj-a', ''));
+ $this->assertArrayHasKey('p1', $realtime->connections[1]['presences']);
+ }
}
diff --git a/tests/unit/Network/Validators/DNSTest.php b/tests/unit/Network/Validators/DNSTest.php
index 6e4a78022f..845d01e723 100644
--- a/tests/unit/Network/Validators/DNSTest.php
+++ b/tests/unit/Network/Validators/DNSTest.php
@@ -33,10 +33,7 @@ class DNSTest extends TestCase
$result = $validator->isValid('nonexistent-domain-' . \uniqid() . '.com');
$this->assertEquals(false, $result);
- $this->assertIsInt($validator->count);
- $this->assertIsString($validator->value);
- $this->assertIsArray($validator->records);
- $this->assertIsString($validator->getDescription());
+ $this->assertNotEmpty($validator->getDescription());
}
public function testCoreDNSFailure(): void
diff --git a/tests/unit/Platform/Modules/Installer/ModuleTest.php b/tests/unit/Platform/Modules/Installer/ModuleTest.php
index 507a4e25f6..87babcfb16 100644
--- a/tests/unit/Platform/Modules/Installer/ModuleTest.php
+++ b/tests/unit/Platform/Modules/Installer/ModuleTest.php
@@ -157,7 +157,7 @@ class ModuleTest extends TestCase
$platform->init(Service::TYPE_HTTP);
// If we get here without exceptions, route registration succeeded
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
public function testModuleHasNoTaskServices(): void
@@ -267,14 +267,6 @@ class ModuleTest extends TestCase
}
}
- public function testValidateClassHasCsrfMethod(): void
- {
- $this->assertTrue(
- method_exists(Validate::class, 'validateCsrf'),
- 'Validate class should expose validateCsrf method'
- );
- }
-
private function getAction(string $name): Action
{
$services = $this->module->getServicesByType(Service::TYPE_HTTP);
diff --git a/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php b/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php
index 6c36e6d732..c8cfd6d884 100644
--- a/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php
+++ b/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php
@@ -19,14 +19,7 @@ class StateTest extends TestCase
$this->tempDir = sys_get_temp_dir() . '/appwrite-installer-test-' . uniqid();
mkdir($this->tempDir, 0755, true);
- $root = dirname(__DIR__, 6);
- $this->state = new State([
- 'public' => $root . '/public',
- 'init' => $root . '/app/init.php',
- 'views' => $root . '/app/views/install',
- 'vendor' => $root . '/vendor/autoload.php',
- 'installPhp' => $root . '/src/Appwrite/Platform/Tasks/Install.php',
- ]);
+ $this->state = new State();
// Preserve env state
$env = getenv('APPWRITE_INSTALLER_CONFIG');
@@ -273,7 +266,6 @@ class StateTest extends TestCase
public function testReadProgressFileReturnsDefaultForMissing(): void
{
$data = $this->state->readProgressFile('nonexistent-id-' . uniqid());
- $this->assertIsArray($data);
$this->assertArrayHasKey('installId', $data);
$this->assertArrayHasKey('steps', $data);
$this->assertEmpty($data['steps']);
@@ -291,7 +283,6 @@ class StateTest extends TestCase
]);
$data = $this->state->readProgressFile($installId);
- $this->assertIsArray($data);
$this->assertArrayHasKey('steps', $data);
$this->assertArrayHasKey(Server::STEP_ENV_VARS, $data['steps']);
$this->assertEquals(Server::STATUS_IN_PROGRESS, $data['steps'][Server::STEP_ENV_VARS]['status']);
@@ -604,7 +595,6 @@ class StateTest extends TestCase
file_put_contents($path, 'not valid json {{{');
$data = $this->state->readProgressFile($installId);
- $this->assertIsArray($data);
$this->assertArrayHasKey('installId', $data);
$this->assertArrayHasKey('steps', $data);
$this->assertEmpty($data['steps']);
@@ -618,7 +608,6 @@ class StateTest extends TestCase
file_put_contents($path, '');
$data = $this->state->readProgressFile($installId);
- $this->assertIsArray($data);
$this->assertArrayHasKey('installId', $data);
$this->assertEmpty($data['steps']);
}
@@ -631,7 +620,6 @@ class StateTest extends TestCase
file_put_contents($path, '"just a string"');
$data = $this->state->readProgressFile($installId);
- $this->assertIsArray($data);
$this->assertEmpty($data['steps']);
}
diff --git a/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php b/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php
index c453dcade4..0a360783ac 100644
--- a/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php
+++ b/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php
@@ -22,7 +22,6 @@ class AppDomainTest extends TestCase
public function testDescription(): void
{
$this->assertNotEmpty($this->validator->getDescription());
- $this->assertIsString($this->validator->getDescription());
}
public function testIsArray(): void
diff --git a/tests/unit/URL/URLTest.php b/tests/unit/URL/URLTest.php
index ceca1c6304..597d77f74c 100644
--- a/tests/unit/URL/URLTest.php
+++ b/tests/unit/URL/URLTest.php
@@ -11,7 +11,6 @@ class URLTest extends TestCase
{
$url = URL::parse('https://appwrite.io:8080/path?query=string¶m=value');
- $this->assertIsArray($url);
$this->assertEquals('https', $url['scheme']);
$this->assertEquals('appwrite.io', $url['host']);
$this->assertEquals('8080', $url['port']);
@@ -20,7 +19,6 @@ class URLTest extends TestCase
$url = URL::parse('https://appwrite.io');
- $this->assertIsArray($url);
$this->assertEquals('https', $url['scheme']);
$this->assertEquals('appwrite.io', $url['host']);
$this->assertEquals(null, $url['port']);
@@ -29,7 +27,6 @@ class URLTest extends TestCase
$url = URL::parse('appwrite-callback-project://');
- $this->assertIsArray($url);
$this->assertEquals('appwrite-callback-project', $url['scheme']);
$this->assertEquals('', $url['host']);
$this->assertEquals(null, $url['port']);
@@ -47,7 +44,6 @@ class URLTest extends TestCase
'query' => 'query=string¶m=value',
]);
- $this->assertIsString($url);
$this->assertEquals('https://appwrite.io:8080/path?query=string¶m=value', $url);
$url = URL::unparse([
@@ -58,7 +54,6 @@ class URLTest extends TestCase
'query' => 'query=string¶m=value',
]);
- $this->assertIsString($url);
$this->assertEquals('https://appwrite.io/path?query=string¶m=value', $url);
$url = URL::unparse([
@@ -69,7 +64,6 @@ class URLTest extends TestCase
'query' => '',
]);
- $this->assertIsString($url);
$this->assertEquals('https://appwrite.io/', $url);
$url = URL::unparse([
@@ -80,7 +74,6 @@ class URLTest extends TestCase
'fragment' => 'bottom',
]);
- $this->assertIsString($url);
$this->assertEquals('https://appwrite.io/#bottom', $url);
$url = URL::unparse([
@@ -93,7 +86,6 @@ class URLTest extends TestCase
'fragment' => 'bottom',
]);
- $this->assertIsString($url);
$this->assertEquals('https://eldad:fux@appwrite.io/#bottom', $url);
$url = URL::unparse([
@@ -106,7 +98,6 @@ class URLTest extends TestCase
'fragment' => '',
]);
- $this->assertIsString($url);
$this->assertEquals('https://appwrite.io/#', $url);
}
@@ -114,7 +105,6 @@ class URLTest extends TestCase
{
$result = URL::parseQuery('param1=value1¶m2=value2');
- $this->assertIsArray($result);
$this->assertEquals(['param1' => 'value1', 'param2' => 'value2'], $result);
}
@@ -122,7 +112,6 @@ class URLTest extends TestCase
{
$result = URL::unparseQuery(['param1' => 'value1', 'param2' => 'value2']);
- $this->assertIsString($result);
$this->assertEquals('param1=value1¶m2=value2', $result);
}
}
diff --git a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php
index f7d73eb287..d5507327be 100644
--- a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php
+++ b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php
@@ -659,7 +659,7 @@ class RuntimeQueryTest extends TestCase
$query = Query::select(['*']);
// Should not throw
RuntimeQuery::validateSelectQuery($query);
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
public function testValidateSelectQueryWithSpecificFields(): void
@@ -694,7 +694,7 @@ class RuntimeQueryTest extends TestCase
$query = Query::equal('name', ['John']);
// Should not throw for non-select queries
RuntimeQuery::validateSelectQuery($query);
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
// Filter tests with select("*")
diff --git a/tests/unit/Utopia/Request/Filters/ThrowingFilter.php b/tests/unit/Utopia/Request/Filters/ThrowingFilter.php
new file mode 100644
index 0000000000..8e02b92e39
--- /dev/null
+++ b/tests/unit/Utopia/Request/Filters/ThrowingFilter.php
@@ -0,0 +1,24 @@
+calls++;
+ throw new \Exception($this->reason, $this->code);
+ }
+}
diff --git a/tests/unit/Utopia/RequestTest.php b/tests/unit/Utopia/RequestTest.php
index d5cd5d800a..2247ff71f1 100644
--- a/tests/unit/Utopia/RequestTest.php
+++ b/tests/unit/Utopia/RequestTest.php
@@ -5,10 +5,12 @@ namespace Tests\Unit\Utopia;
use Appwrite\SDK\Method;
use Appwrite\SDK\Parameter;
use Appwrite\Utopia\Request;
+use Appwrite\Utopia\Request\Filter;
use PHPUnit\Framework\TestCase;
use Swoole\Http\Request as SwooleRequest;
use Tests\Unit\Utopia\Request\Filters\First;
use Tests\Unit\Utopia\Request\Filters\Second;
+use Tests\Unit\Utopia\Request\Filters\ThrowingFilter;
use Utopia\Http\Route;
class RequestTest extends TestCase
@@ -23,7 +25,6 @@ class RequestTest extends TestCase
public function testFilters(): void
{
$this->assertFalse($this->request->hasFilters());
- $this->assertIsArray($this->request->getFilters());
$this->assertEmpty($this->request->getFilters());
$this->request->addFilter(new First());
@@ -162,6 +163,140 @@ class RequestTest extends TestCase
$this->assertSame($secondRoute, $secondRequest->getRoute());
}
+ public function testGetHeaderReturnsStringValue(): void
+ {
+ $this->request->addHeader('referer', 'https://example.com');
+
+ $this->assertSame('https://example.com', $this->request->getHeader('referer'));
+ }
+
+ public function testGetHeaderReturnsDefaultWhenMissing(): void
+ {
+ $this->assertSame('', $this->request->getHeader('referer'));
+ $this->assertSame('fallback', $this->request->getHeader('referer', 'fallback'));
+ }
+
+ public function testGetHeaderCoercesArrayToFirstElement(): void
+ {
+ $swoole = new SwooleRequest();
+ $swoole->header = ['referer' => ['https://a.example', 'https://b.example']];
+ $request = new Request($swoole);
+
+ $this->assertSame('https://a.example', $request->getHeader('referer'));
+ }
+
+ public function testGetHeaderReturnsDefaultWhenValueNotString(): void
+ {
+ $swoole = new SwooleRequest();
+ $swoole->header = ['referer' => 123];
+ $request = new Request($swoole);
+
+ $this->assertSame('fallback', $request->getHeader('referer', 'fallback'));
+ }
+
+ public function testGetParamsCachesRawParamsWhenFilterThrows4xx(): void
+ {
+ /*
+ * Regression: when a request filter throws a 4xx exception during
+ * Request::getParams() (e.g. RequestV20 rejecting an unparseable
+ * queries[]), the framework's error path calls getParams() again to
+ * build error-hook arguments. Without caching, that second call
+ * re-runs the filter and re-throws, which the framework wraps as
+ * "Error handler had an error: ..." (HTTP 500), masking the intended
+ * 400. This test pins that behavior: the first call throws (so the
+ * action's argument resolution aborts), but the second call returns
+ * the raw, pre-filter params without re-invoking filters.
+ */
+ $filter = new ThrowingFilter(400, 'invalid input');
+
+ $this->setupSingleMethodRoute($filter);
+ $this->request->setQueryString(['foo' => 'bar']);
+
+ $threw = false;
+ try {
+ $this->request->getParams();
+ } catch (\Throwable $e) {
+ $threw = true;
+ $this->assertSame(400, $e->getCode());
+ $this->assertSame('invalid input', $e->getMessage());
+ }
+ $this->assertTrue($threw, 'First getParams() call must rethrow the filter exception.');
+ $this->assertSame(1, $filter->calls, 'Filter ran once on the first call.');
+
+ // Second call: framework's error hook arg resolution. Must return raw
+ // params without re-invoking the filter.
+ $params = $this->request->getParams();
+ $this->assertSame(['foo' => 'bar'], $params);
+ $this->assertSame(1, $filter->calls, 'Filter must not run again after a cached 4xx failure.');
+ }
+
+ public function testGetParamsDoesNotCacheRawParamsForServerError(): void
+ {
+ /*
+ * 5xx filter throws indicate genuine server-side problems, not
+ * user-input mistakes. They must keep rethrowing on every call so
+ * the framework's normal error handling sees the failure each time
+ * — caching raw params would silently swallow real bugs.
+ */
+ $filter = new ThrowingFilter(500, 'boom');
+
+ $this->setupSingleMethodRoute($filter);
+ $this->request->setQueryString(['foo' => 'bar']);
+
+ for ($attempt = 1; $attempt <= 2; $attempt++) {
+ $threw = false;
+ try {
+ $this->request->getParams();
+ } catch (\Throwable $e) {
+ $threw = true;
+ $this->assertSame(500, $e->getCode());
+ }
+ $this->assertTrue($threw, "Call #$attempt must rethrow.");
+ $this->assertSame($attempt, $filter->calls, "Filter must run on call #$attempt.");
+ }
+ }
+
+ public function testGetParamsDoesNotCacheRawParamsForUncodedException(): void
+ {
+ // \Exception with the default code of 0 is treated as "unknown" and
+ // must propagate every call — same reasoning as 5xx.
+ $filter = new ThrowingFilter(0, 'unknown');
+
+ $this->setupSingleMethodRoute($filter);
+ $this->request->setQueryString(['foo' => 'bar']);
+
+ for ($attempt = 1; $attempt <= 2; $attempt++) {
+ $threw = false;
+ try {
+ $this->request->getParams();
+ } catch (\Throwable) {
+ $threw = true;
+ }
+ $this->assertTrue($threw, "Call #$attempt must rethrow.");
+ $this->assertSame($attempt, $filter->calls, "Filter must run on call #$attempt.");
+ }
+ }
+
+ /**
+ * Helper to attach a route with a single SDK method and one filter.
+ */
+ private function setupSingleMethodRoute(Filter $filter): void
+ {
+ $route = new Route(Request::METHOD_GET, '/single');
+ $route->label('sdk', new Method(
+ namespace: 'namespace',
+ group: 'group',
+ name: 'method',
+ description: 'description',
+ auth: [],
+ responses: [],
+ ));
+
+ $this->request->addHeader('EXAMPLE', 'VALUE');
+ $this->request->setRoute($route);
+ $this->request->addFilter($filter);
+ }
+
/**
* Helper to attach a route with multiple SDK methods to the request.
*/
diff --git a/tests/unit/Utopia/ResponseTest.php b/tests/unit/Utopia/ResponseTest.php
index be8cfdc216..74c68303f4 100644
--- a/tests/unit/Utopia/ResponseTest.php
+++ b/tests/unit/Utopia/ResponseTest.php
@@ -26,7 +26,6 @@ class ResponseTest extends TestCase
public function testFilters(): void
{
$this->assertFalse($this->response->hasFilters());
- $this->assertIsArray($this->response->getFilters());
$this->assertEmpty($this->response->getFilters());
$this->response->addFilter(new First());
@@ -35,10 +34,11 @@ class ResponseTest extends TestCase
$this->assertTrue($this->response->hasFilters());
$this->assertCount(2, $this->response->getFilters());
- $output = $this->response->applyFilters([
+ $content = [
'initial' => true,
'first' => false
- ], 'test');
+ ];
+ $output = $this->response->applyFilters($content, 'test', raw: new Document($content));
$this->assertArrayHasKey('initial', $output);
$this->assertTrue($output['initial']);
diff --git a/tests/unit/Vcs/CommentTest.php b/tests/unit/Vcs/CommentTest.php
new file mode 100644
index 0000000000..29973089c6
--- /dev/null
+++ b/tests/unit/Vcs/CommentTest.php
@@ -0,0 +1,154 @@
+ 'localhost']);
+ $comment->addBuild(
+ new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']),
+ new Document(['$id' => 'func1', 'name' => 'Test Function']),
+ 'function',
+ 'ready',
+ 'dep1',
+ ['type' => 'logs'],
+ ''
+ );
+
+ $first = $comment->generateComment();
+ $firstTip = $this->extractTip($first);
+
+ $this->assertNotNull($firstTip);
+ $this->assertNotEmpty($firstTip);
+
+ $second = $comment->generateComment();
+ $secondTip = $this->extractTip($second);
+
+ $this->assertEquals($firstTip, $secondTip);
+ }
+
+ public function testTipIsRestoredFromParsedComment(): void
+ {
+ $comment = new Comment(['consoleHostname' => 'localhost']);
+ $comment->addBuild(
+ new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']),
+ new Document(['$id' => 'func1', 'name' => 'Test Function']),
+ 'function',
+ 'ready',
+ 'dep1',
+ ['type' => 'logs'],
+ ''
+ );
+
+ $original = $comment->generateComment();
+ $originalTip = $this->extractTip($original);
+
+ $parsed = new Comment(['consoleHostname' => 'localhost']);
+ $parsed->parseComment($original);
+ $parsed->addBuild(
+ new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']),
+ new Document(['$id' => 'func2', 'name' => 'Another Function']),
+ 'function',
+ 'building',
+ 'dep2',
+ ['type' => 'logs'],
+ ''
+ );
+
+ $regenerated = $parsed->generateComment();
+ $regeneratedTip = $this->extractTip($regenerated);
+
+ $this->assertEquals($originalTip, $regeneratedTip);
+ }
+
+ public function testBackwardCompatibilityWithOldStateFormat(): void
+ {
+ $oldBuilds = [
+ 'project1_func1' => [
+ 'projectName' => 'Test Project',
+ 'projectId' => 'project1',
+ 'region' => 'default',
+ 'resourceName' => 'Test Function',
+ 'resourceId' => 'func1',
+ 'resourceType' => 'function',
+ 'buildStatus' => 'ready',
+ 'deploymentId' => 'dep1',
+ 'action' => ['type' => 'logs'],
+ 'previewUrl' => '',
+ ],
+ ];
+
+ $oldState = '[appwrite]: #' . \base64_encode(\json_encode($oldBuilds)) . "\n\n";
+ $oldState .= "> [!TIP]\n> Old tip that should be ignored\n\n";
+
+ $comment = new Comment(['consoleHostname' => 'localhost']);
+ $comment->parseComment($oldState);
+
+ $new = $comment->generateComment();
+ $newTip = $this->extractTip($new);
+
+ $this->assertNotNull($newTip);
+ $this->assertNotEquals('Old tip that should be ignored', $newTip);
+ $this->assertContains($newTip, $this->getTips());
+ }
+
+ public function testParseOldStateFormatWithOnlyBuilds(): void
+ {
+ $oldBuilds = [
+ 'project1_func1' => [
+ 'projectName' => 'Test Project',
+ 'projectId' => 'project1',
+ 'region' => 'default',
+ 'resourceName' => 'Test Function',
+ 'resourceId' => 'func1',
+ 'resourceType' => 'function',
+ 'buildStatus' => 'ready',
+ 'deploymentId' => 'dep1',
+ 'action' => ['type' => 'logs'],
+ 'previewUrl' => '',
+ ],
+ ];
+
+ $state = '[appwrite]: #' . \base64_encode(\json_encode($oldBuilds)) . "\n\n";
+
+ $comment = new Comment(['consoleHostname' => 'localhost']);
+ $comment->parseComment($state);
+
+ $this->assertEquals(false, $comment->isEmpty());
+
+ $first = $comment->generateComment();
+ $firstTip = $this->extractTip($first);
+
+ $this->assertNotNull($firstTip);
+ $this->assertNotEmpty($firstTip);
+ $this->assertContains($firstTip, $this->getTips());
+
+ $second = $comment->generateComment();
+ $secondTip = $this->extractTip($second);
+
+ $this->assertEquals($firstTip, $secondTip);
+ }
+
+ private function extractTip(string $comment): ?string
+ {
+ if (\preg_match('/> \[!TIP\]\n> (.+)/', $comment, $matches)) {
+ return $matches[1];
+ }
+
+ return null;
+ }
+
+ private function getTips(): array
+ {
+ $reflection = new \ReflectionClass(Comment::class);
+ $property = $reflection->getProperty('tips');
+
+ return $property->getValue(new Comment(['consoleHostname' => 'localhost']));
+ }
+}