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/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 d8256ddc7a..8cc3b3e113 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
@@ -210,7 +216,7 @@ jobs:
with:
script: |
const allDatabases = ['MariaDB', 'PostgreSQL', 'MongoDB'];
- const allModes = ['dedicated', 'shared_v1', 'shared_v2'];
+ const allModes = ['dedicated', 'shared'];
const defaultDatabases = ['MongoDB'];
const defaultModes = ['dedicated'];
@@ -257,32 +263,30 @@ jobs:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Login to GHCR
+ uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- - name: Build Appwrite
+ - name: Build and push Appwrite
uses: docker/build-push-action@v6
with:
context: .
- push: false
- tags: ${{ env.IMAGE }}
- load: true
+ push: true
+ tags: ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- outputs: type=docker,dest=/tmp/${{ env.IMAGE }}.tar
target: development
build-args: |
DEBUG=false
TESTING=true
VERSION=dev
- - name: Upload Docker Image
- uses: actions/upload-artifact@v7
- with:
- name: ${{ env.IMAGE }}
- path: /tmp/${{ env.IMAGE }}.tar
- retention-days: 1
-
unit:
name: Tests / Unit
runs-on: ubuntu-latest
@@ -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
-
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Login to GHCR
+ uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Pull Docker Image
+ run: |
+ docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
+ docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
+
- name: Load and Start Appwrite
timeout-minutes: 5
run: |
- docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
@@ -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
-
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Login to GHCR
+ uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Pull Docker Image
+ run: |
+ docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
+ docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
+
- name: Load and Start Appwrite
timeout-minutes: 5
run: |
- docker load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
@@ -395,6 +411,7 @@ jobs:
permissions:
contents: read
pull-requests: write
+ packages: read
strategy:
fail-fast: false
matrix:
@@ -429,6 +446,8 @@ jobs:
include:
- service: Databases
runner: blacksmith-4vcpu-ubuntu-2404
+ paratest_processes: 3
+ timeout_minutes: 30
- service: Sites
runner: blacksmith-4vcpu-ubuntu-2404
- service: Functions
@@ -439,18 +458,18 @@ jobs:
runner: blacksmith-4vcpu-ubuntu-2404
- service: TablesDB
runner: blacksmith-4vcpu-ubuntu-2404
+ paratest_processes: 3
+ timeout_minutes: 30
+ - service: Migrations
+ paratest_processes: 1
steps:
- name: Checkout repository
uses: actions/checkout@v6
- - 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
@@ -474,18 +493,26 @@ jobs:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Login to GHCR
+ uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Pull Docker Image
+ run: |
+ docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
+ docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
+
- name: Load and Start Appwrite
timeout-minutes: 5
env:
_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
@@ -502,7 +529,7 @@ jobs:
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 }}
@@ -515,9 +542,16 @@ jobs:
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,6 +566,7 @@ jobs:
permissions:
contents: read
pull-requests: write
+ packages: read
strategy:
fail-fast: false
matrix:
@@ -539,12 +574,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
-
- - name: Download Docker Image
- uses: actions/download-artifact@v7
with:
- name: ${{ env.IMAGE }}
- path: /tmp
+ fetch-depth: 1
- name: Login to Docker Hub
uses: docker/login-action@v4
@@ -552,18 +583,26 @@ jobs:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Login to GHCR
+ uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Pull Docker Image
+ run: |
+ docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
+ docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
+
- name: Load and Start Appwrite
timeout-minutes: 5
env:
_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
@@ -594,6 +633,7 @@ jobs:
permissions:
contents: read
pull-requests: write
+ packages: read
strategy:
fail-fast: false
matrix:
@@ -602,29 +642,31 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- - name: Download Docker Image
- uses: actions/download-artifact@v7
- with:
- name: ${{ env.IMAGE }}
- path: /tmp
-
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Login to GHCR
+ uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Pull Docker Image
+ run: |
+ docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
+ docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
+
- name: Load and Start Appwrite
timeout-minutes: 5
env:
_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
@@ -658,19 +700,20 @@ 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
with:
- name: ${{ env.IMAGE }}
- path: /tmp
+ fetch-depth: 1
- name: Login to Docker Hub
uses: docker/login-action@v4
@@ -678,79 +721,153 @@ jobs:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- - name: Load and Start Appwrite
+ - name: Login to GHCR
+ uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Pull Appwrite image
run: |
- sed -i 's/traefik/localhost/g' .env
- docker load --input /tmp/${{ env.IMAGE }}.tar
- docker compose up -d
- sleep 10
+ 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: Install Oha
+ - name: Setup k6
+ uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2
+ with:
+ k6-version: ${{ env.K6_VERSION }}
+
+ - name: Prepare benchmark before
+ id: benchmark_before_prepare
+ continue-on-error: true
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
+ 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: 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
+ - 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: |
- 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
+ docker tag ${{ env.IMAGE }}:before ${{ env.IMAGE }}
+ docker compose up -d --wait --no-build
- - name: Benchmark Latest
- run: oha -z 180s http://localhost/v1/health/version --output-format json > benchmark-latest.json
+ - name: Prepare benchmark files
+ run: rm -f benchmark-before-summary.json benchmark-after-summary.json benchmark-before-samples.json benchmark-after-samples.json
- - name: Prepare comment
+ - name: Benchmark before
+ if: steps.benchmark_before_start.outcome == 'success'
+ continue-on-error: true
+ uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d
+ 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: |
- 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
+ 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@a15e2072ede004e8d46141e33d7f7dad8ad08d9d
+ 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: always()
+ uses: actions/github-script@v8
+ env:
+ BENCHMARK_BASE_REF: ${{ github.event.pull_request.base.ref }}
+ BENCHMARK_HEAD_REF: ${{ github.event.pull_request.head.ref }}
+ with:
+ script: |
+ const comment = require('./.github/workflows/benchmark-comment.js');
+ await comment({ github, context, core });
- name: Save results
uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
- name: benchmark.json
- path: benchmark.json
+ 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: Find Comment
- if: github.event.pull_request.head.repo.full_name == github.repository
- uses: peter-evans/find-comment@v3
- id: fc
- with:
- issue-number: ${{ github.event.pull_request.number }}
- comment-author: 'github-actions[bot]'
- body-includes: Benchmark results
-
- - name: Comment on PR
- if: github.event.pull_request.head.repo.full_name == github.repository
- uses: peter-evans/create-or-update-comment@v4
- with:
- comment-id: ${{ steps.fc.outputs.comment-id }}
- issue-number: ${{ github.event.pull_request.number }}
- body-path: benchmark.txt
- edit-mode: replace
+ - 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..4b6b13d35d 100644
--- a/.github/workflows/cleanup-cache.yml
+++ b/.github/workflows/cleanup-cache.yml
@@ -5,6 +5,11 @@ on:
types:
- closed
+permissions:
+ actions: write
+ contents: read
+ packages: write
+
jobs:
cleanup:
runs-on: ubuntu-latest
@@ -36,4 +41,29 @@ jobs:
done
done
env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ 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/nightly.yml b/.github/workflows/nightly.yml
index 5cbec8f867..c4289678bb 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -24,9 +24,11 @@ jobs:
ignore-unfixed: 'false'
severity: 'CRITICAL,HIGH'
- name: Upload Docker Image Scan Results
- uses: github/codeql-action/upload-sarif@v2
+ uses: github/codeql-action/upload-sarif@v4
+ if: always() && hashFiles('trivy-image-results.sarif') != ''
with:
sarif_file: 'trivy-image-results.sarif'
+ category: 'trivy-image'
scan-code:
name: Scan Code
@@ -42,6 +44,8 @@ jobs:
output: 'trivy-fs-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Code Scan Results
- uses: github/codeql-action/upload-sarif@v2
+ uses: github/codeql-action/upload-sarif@v4
+ if: always() && hashFiles('trivy-fs-results.sarif') != ''
with:
sarif_file: 'trivy-fs-results.sarif'
+ category: 'trivy-source'
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 a6267fa341..ada155c4dc 100644
--- a/app/cli.php
+++ b/app/cli.php
@@ -157,12 +157,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 +189,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 +226,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 +239,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);
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..fa112bcb6f 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,
],
@@ -1408,4 +1408,19 @@ 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,
+ ],
];
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..04175ac1d5 100644
--- a/app/config/roles.php
+++ b/app/config/roles.php
@@ -21,8 +21,8 @@ $member = [
'projects.read',
'locale.read',
'avatars.read',
- 'execution.read',
- 'execution.write',
+ 'executions.read',
+ 'executions.write',
'targets.read',
'targets.write',
'subscribers.write',
@@ -55,6 +55,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 +81,8 @@ $admins = [
'sites.write',
'log.read',
'log.write',
- 'execution.read',
- 'execution.write',
+ 'executions.read',
+ 'executions.write',
'rules.read',
'rules.write',
'migrations.read',
@@ -115,7 +123,7 @@ return [
'files.write',
'locale.read',
'avatars.read',
- 'execution.write',
+ 'executions.write',
],
],
User::ROLE_USERS => [
diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php
index 6c7f75c08e..7a61524b87 100644
--- a/app/config/scopes/project.php
+++ b/app/config/scopes/project.php
@@ -1,207 +1,362 @@
[
- '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',
+ ],
+
+ // 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',
+ ],
+ 'rules.read' => [
+ 'description' => 'Access to read proxy rules.',
+ 'category' => 'Other',
+ ],
+ 'rules.write' => [
+ 'description' => 'Access to create, update, and delete proxy rules.',
+ 'category' => 'Other',
],
];
diff --git a/app/config/sdks.php b/app/config/sdks.php
index 47dc8845b6..e89265b05e 100644
--- a/app/config/sdks.php
+++ b/app/config/sdks.php
@@ -300,6 +300,26 @@ 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'),
+ ],
],
],
diff --git a/app/config/services.php b/app/config/services.php
index 548f659a81..cf2714f8c5 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',
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/controllers/api/account.php b/app/controllers/api/account.php
index ba2ef87d3a..c6a5fd6f97 100644
--- a/app/controllers/api/account.php
+++ b/app/controllers/api/account.php
@@ -133,9 +133,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 +332,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 (($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 (($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 (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -453,7 +450,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',
@@ -837,7 +834,7 @@ Http::patch('/v1/account/sessions/:sessionId')
throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED);
}
- if (!empty($provider) && $className !== null && \class_exists($className)) {
+ if (!empty($provider) && \class_exists($className)) {
$appId = $project->getAttribute('oAuthProviders', [])[$provider . 'Appid'] ?? '';
$appSecret = $project->getAttribute('oAuthProviders', [])[$provider . 'Secret'] ?? '{}';
@@ -1604,7 +1601,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.');
}
@@ -1621,7 +1618,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]),
]);
@@ -1634,7 +1631,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]),
]);
@@ -1646,7 +1643,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) {
@@ -1679,15 +1676,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 (($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 (($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 (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
$failureRedirect(Exception::USER_EMAIL_FREE);
}
@@ -1820,15 +1817,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 (($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 (($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 (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
$failureRedirect(Exception::USER_EMAIL_FREE);
}
@@ -1954,7 +1951,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;
@@ -2178,15 +2175,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 (($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 (($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 (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -2272,7 +2269,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();
@@ -2302,8 +2302,8 @@ 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 = '';
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -2312,8 +2312,13 @@ 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
@@ -2330,8 +2335,13 @@ 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'] ?? '';
@@ -2339,7 +2349,8 @@ Http::post('/v1/account/tokens/magic-url')
}
$queueForMails
- ->setSmtpReplyTo($replyTo)
+ ->setSmtpReplyToEmail($replyToEmail)
+ ->setSmtpReplyToName($replyToName)
->setSmtpSenderEmail($senderEmail)
->setSmtpSenderName($senderName);
}
@@ -2485,15 +2496,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 (($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 (($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 (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -2582,7 +2593,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();
@@ -2618,7 +2631,8 @@ 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 = '';
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -2627,8 +2641,13 @@ 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
@@ -2645,8 +2664,13 @@ 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'] ?? '';
@@ -2654,7 +2678,8 @@ Http::post('/v1/account/tokens/email')
}
$queueForMails
- ->setSmtpReplyTo($replyTo)
+ ->setSmtpReplyToEmail($replyToEmail)
+ ->setSmtpReplyToName($replyToName)
->setSmtpSenderEmail($senderEmail)
->setSmtpSenderName($senderName);
}
@@ -2975,11 +3000,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'];
@@ -3274,7 +3294,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) {
@@ -3397,15 +3417,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 (($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 (($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 (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -3437,9 +3457,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()) {
@@ -3526,9 +3543,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()) {
@@ -3733,7 +3747,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
@@ -3750,7 +3766,8 @@ 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 = '';
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -3759,8 +3776,13 @@ 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
@@ -3777,8 +3799,13 @@ 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'] ?? '';
@@ -3786,7 +3813,8 @@ Http::post('/v1/account/recovery')
}
$queueForMails
- ->setSmtpReplyTo($replyTo)
+ ->setSmtpReplyToEmail($replyToEmail)
+ ->setSmtpReplyToName($replyToName)
->setSmtpSenderEmail($senderEmail)
->setSmtpSenderName($senderName);
}
@@ -4041,7 +4069,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();
@@ -4067,7 +4097,8 @@ 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 = '';
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -4076,8 +4107,13 @@ 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
@@ -4094,8 +4130,13 @@ 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'] ?? '';
@@ -4103,7 +4144,8 @@ Http::post('/v1/account/verifications/email')
}
$queueForMails
- ->setSmtpReplyTo($replyTo)
+ ->setSmtpReplyToEmail($replyToEmail)
+ ->setSmtpReplyToName($replyToName)
->setSmtpSenderEmail($senderEmail)
->setSmtpSenderName($senderName);
}
@@ -4340,11 +4382,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'))
@@ -4619,7 +4656,7 @@ 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',
diff --git a/app/controllers/api/messaging.php b/app/controllers/api/messaging.php
index 2a0012bd30..58c6a2c29e 100644
--- a/app/controllers/api/messaging.php
+++ b/app/controllers/api/messaging.php
@@ -482,7 +482,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 {
@@ -3207,10 +3206,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)) {
@@ -3386,10 +3381,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),
@@ -3527,10 +3518,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),
@@ -4660,7 +4647,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 5b82e6c1a3..494aa11150 100644
--- a/app/controllers/api/projects.php
+++ b/app/controllers/api/projects.php
@@ -1,33 +1,20 @@
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)
@@ -138,372 +97,11 @@ Http::patch('/v1/projects/:projectId/oauth2')
$response->dynamic($project, Response::MODEL_PROJECT);
});
-Http::patch('/v1/projects/:projectId/auth/session-alerts')
- ->desc('Update project sessions emails')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateSessionAlerts',
- description: '/docs/references/projects/update-session-alerts.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('alerts', false, new Boolean(true), 'Set to true to enable session emails.')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, bool $alerts, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $auths = $project->getAttribute('auths', []);
- $auths['sessionAlerts'] = $alerts;
-
- $dbForPlatform->updateDocument('projects', $project->getId(), $project
- ->setAttribute('auths', $auths));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/auth/memberships-privacy')
- ->desc('Update project memberships privacy attributes')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateMembershipsPrivacy',
- description: '/docs/references/projects/update-memberships-privacy.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('userName', true, new Boolean(true), 'Set to true to show userName to members of a team.')
- ->param('userEmail', true, new Boolean(true), 'Set to true to show email to members of a team.')
- ->param('mfa', true, new Boolean(true), 'Set to true to show mfa to members of a team.')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, bool $userName, bool $userEmail, bool $mfa, Response $response, Database $dbForPlatform) {
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $auths = $project->getAttribute('auths', []);
-
- $auths['membershipsUserName'] = $userName;
- $auths['membershipsUserEmail'] = $userEmail;
- $auths['membershipsMfa'] = $mfa;
-
- $dbForPlatform->updateDocument('projects', $project->getId(), $project
- ->setAttribute('auths', $auths));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/auth/limit')
- ->desc('Update project users limit')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateAuthLimit',
- description: '/docs/references/projects/update-auth-limit.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('limit', false, new Range(0, APP_LIMIT_USERS), 'Set the max number of users allowed in this project. Use 0 for unlimited.')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $auths = $project->getAttribute('auths', []);
- $auths['limit'] = $limit;
-
- $dbForPlatform->updateDocument('projects', $project->getId(), $project
- ->setAttribute('auths', $auths));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/auth/duration')
- ->desc('Update project authentication duration')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateAuthDuration',
- description: '/docs/references/projects/update-auth-duration.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('duration', 31536000, new Range(0, 31536000), 'Project session length in seconds. Max length: 31536000 seconds.')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, int $duration, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $auths = $project->getAttribute('auths', []);
- $auths['duration'] = $duration;
-
- $dbForPlatform->updateDocument('projects', $project->getId(), $project
- ->setAttribute('auths', $auths));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/auth/:method')
- ->desc('Update project auth method status. Use this endpoint to enable or disable a given auth method for this project.')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateAuthStatus',
- description: '/docs/references/projects/update-auth-status.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('method', '', new WhiteList(\array_keys(Config::getParam('auth')), true), 'Auth Method. Possible values: ' . implode(',', \array_keys(Config::getParam('auth'))), false)
- ->param('status', false, new Boolean(true), 'Set the status of this auth method.')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $method, bool $status, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
- $auth = Config::getParam('auth')[$method] ?? [];
- $authKey = $auth['key'] ?? '';
- $status = ($status === '1' || $status === 'true' || $status === 1 || $status === true);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $auths = $project->getAttribute('auths', []);
- $auths[$authKey] = $status;
-
- $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('auths', $auths));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/auth/password-history')
- ->desc('Update authentication password history. Use this endpoint to set the number of password history to save and 0 to disable password history.')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateAuthPasswordHistory',
- description: '/docs/references/projects/update-auth-password-history.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('limit', 0, new Range(0, APP_LIMIT_USER_PASSWORD_HISTORY), 'Set the max number of passwords to store in user history. User can\'t choose a new password that is already stored in the password history list. Max number of passwords allowed in history is' . APP_LIMIT_USER_PASSWORD_HISTORY . '. Default value is 0')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $auths = $project->getAttribute('auths', []);
- $auths['passwordHistory'] = $limit;
-
- $dbForPlatform->updateDocument('projects', $project->getId(), $project
- ->setAttribute('auths', $auths));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/auth/password-dictionary')
- ->desc('Update authentication password dictionary status. Use this endpoint to enable or disable the dicitonary check for user password')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateAuthPasswordDictionary',
- description: '/docs/references/projects/update-auth-password-dictionary.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(false), 'Set whether or not to enable checking user\'s password against most commonly used passwords. Default is false.')
- ->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['passwordDictionary'] = $enabled;
-
- $dbForPlatform->updateDocument('projects', $project->getId(), $project
- ->setAttribute('auths', $auths));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/auth/personal-data')
- ->desc('Update personal data check')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updatePersonalDataCheck',
- description: '/docs/references/projects/update-personal-data-check.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(false), 'Set whether or not to check a password for similarity with personal data. Default is false.')
- ->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['personalDataCheck'] = $enabled;
-
- $dbForPlatform->updateDocument('projects', $project->getId(), $project
- ->setAttribute('auths', $auths));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/auth/max-sessions')
- ->desc('Update project user sessions limit')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateAuthSessionsLimit',
- description: '/docs/references/projects/update-auth-sessions-limit.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('limit', false, new Range(1, APP_LIMIT_USER_SESSIONS_MAX), 'Set the max number of users allowed in this project. Value allowed is between 1-' . APP_LIMIT_USER_SESSIONS_MAX . '. Default is ' . APP_LIMIT_USER_SESSIONS_DEFAULT)
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $auths = $project->getAttribute('auths', []);
- $auths['maxSessions'] = $limit;
-
- $dbForPlatform->updateDocument('projects', $project->getId(), $project
- ->setAttribute('auths', $auths));
-
- $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')
@@ -533,755 +131,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 a8875fc442..abcecac396 100644
--- a/app/controllers/api/users.php
+++ b/app/controllers/api/users.php
@@ -856,7 +856,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 +1535,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);
}
}
@@ -1595,9 +1595,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 +1678,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 +1688,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 +2246,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 +2314,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 +2470,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 +2521,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(
@@ -2842,6 +2836,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 b4f4a5c1d1..eb4899a3d8 100644
--- a/app/controllers/general.php
+++ b/app/controllers/general.php
@@ -26,6 +26,8 @@ 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\Response;
use Appwrite\Utopia\Response\Filters\V16 as ResponseV16;
use Appwrite\Utopia\Response\Filters\V17 as ResponseV17;
@@ -34,6 +36,8 @@ 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\View;
use Executor\Executor;
use MaxMind\Db\Reader;
@@ -67,7 +71,7 @@ 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)
{
- $host = $request->getHostname() ?? '';
+ $host = $request->getHostname();
if (!empty($previewHostname)) {
$host = $previewHostname;
}
@@ -118,7 +122,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);
}
@@ -200,12 +204,6 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
$deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $activeDeploymentId));
}
- if ($deployment->getAttribute('resourceType', '') === 'functions') {
- $type = 'function';
- } elseif ($deployment->getAttribute('resourceType', '') === 'sites') {
- $type = 'site';
- }
-
if ($deployment->isEmpty()) {
$resourceType = $rule->getAttribute('deploymentResourceType', '');
$resourceId = $rule->getAttribute('deploymentResourceId', '');
@@ -215,6 +213,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 +308,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 +336,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 +399,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
'projectId' => $project->getId(),
'scopes' => $resource->getAttribute('scopes', [])
]);
- $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $jwtKey;
+ $headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $jwtKey;
$headers['x-appwrite-trigger'] = 'http';
$headers['x-appwrite-user-jwt'] = '';
@@ -459,10 +464,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']
]);
}
@@ -678,9 +683,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 +693,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 +722,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', '');
@@ -852,7 +853,7 @@ Http::init()
/*
* Appwrite Router
*/
- $hostname = $request->getHostname() ?? '';
+ $hostname = $request->getHostname();
$platformHostnames = $platform['hostnames'] ?? [];
// Only run Router when external domain
if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) {
@@ -897,6 +898,12 @@ 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());
+ }
}
$localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', ''));
@@ -921,6 +928,12 @@ Http::init()
*/
$responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', ''));
if ($responseFormat) {
+ if (version_compare($responseFormat, '1.9.3', '<')) {
+ $response->addFilter(new ResponseV24());
+ }
+ if (version_compare($responseFormat, '1.9.2', '<')) {
+ $response->addFilter(new ResponseV23());
+ }
if (version_compare($responseFormat, '1.9.1', '<')) {
$response->addFilter(new ResponseV22());
}
@@ -1499,9 +1512,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);
@@ -1616,7 +1629,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');
}
@@ -1695,7 +1708,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 5567281e67..c9e4f8b47d 100644
--- a/app/controllers/shared/api.php
+++ b/app/controllers/shared/api.php
@@ -44,9 +44,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);
@@ -54,11 +54,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,
};
@@ -182,7 +183,8 @@ Http::init()
// Handle special app role case
if ($apiKey->getRole() === User::ROLE_APPS) {
// Disable authorization checks for project API keys
- if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_DYNAMIC) && $apiKey->getProjectId() === $project->getId()) {
+ // Dynamic supported for backwards compatibility
+ if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_EPHEMERAL || $apiKey->getType() === 'dynamic') && $apiKey->getProjectId() === $project->getId()) {
$authorization->setDefaultStatus(false);
}
@@ -263,8 +265,7 @@ 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,
});
$auditContext->user = $userClone;
}
@@ -385,7 +386,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([
@@ -415,9 +416,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,
@@ -646,7 +644,7 @@ Http::init()
if (! empty($data) && ! $cacheLog->isEmpty()) {
$parts = explode('/', $cacheLog->getAttribute('resourceType', ''));
- $type = $parts[0] ?? null;
+ $type = $parts[0];
if ($type === 'bucket' && (! $isImageTransformation || ! $isDisabled)) {
$bucketId = $parts[1] ?? null;
@@ -757,7 +755,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)) {
@@ -902,7 +905,7 @@ 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) {
$auditContext->resource = $resource;
}
@@ -937,7 +940,7 @@ Http::shutdown()
}
$auditUser = $auditContext->user;
- if (! empty($auditContext->resource) && ! \is_null($auditUser) && ! $auditUser->isEmpty()) {
+ if (! empty($auditContext->resource) && ! $auditUser->isEmpty()) {
/**
* audits.payload is switched to default true
* in order to auto audit payload for all endpoints
@@ -971,15 +974,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 afcc2d2d0f..b72f3b7f34 100644
--- a/app/http.php
+++ b/app/http.php
@@ -413,27 +413,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');
- // 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);
diff --git a/app/init/constants.php b/app/init/constants.php
index 5db8411e36..c12f8a4c5f 100644
--- a/app/init/constants.php
+++ b/app/init/constants.php
@@ -1,6 +1,7 @@
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..54c0053a33 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],
};
}
@@ -249,11 +249,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 +318,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,7 +328,6 @@ $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();
diff --git a/app/init/resources.php b/app/init/resources.php
index d1bb7584bf..96457294de 100644
--- a/app/init/resources.php
+++ b/app/init/resources.php
@@ -159,10 +159,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);
@@ -266,7 +272,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);
diff --git a/app/init/resources/request.php b/app/init/resources/request.php
index 3f6196c460..70d691370d 100644
--- a/app/init/resources/request.php
+++ b/app/init/resources/request.php
@@ -204,9 +204,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
@@ -223,6 +230,11 @@ return function (Container $container): void {
$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);
@@ -230,6 +242,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);
@@ -375,7 +388,7 @@ return function (Container $container): void {
return $dbForPlatform->findOne('rules', [
Query::equal('domain', [$domain]),
- ]) ?? new Document();
+ ]);
});
$permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence();
@@ -478,14 +491,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()] : ''));
@@ -575,10 +584,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;
@@ -692,8 +703,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 {
@@ -1084,7 +1102,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)) {
@@ -1294,6 +1312,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)
@@ -1316,6 +1340,7 @@ return function (Container $container): void {
if (\in_array($databaseHost, $dbTypeSharedTables)) {
$database
->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($databaseDSN->getParam('namespace'));
} else {
@@ -1327,6 +1352,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 {
diff --git a/app/init/worker/message.php b/app/init/worker/message.php
index c505d4cb3a..17796fadcd 100644
--- a/app/init/worker/message.php
+++ b/app/init/worker/message.php
@@ -14,6 +14,7 @@ use Appwrite\Utopia\Database\Documents\User;
use Utopia\Audit\Adapter\Database as AdapterDatabase;
use Utopia\Audit\Audit as UtopiaAudit;
use Utopia\Cache\Cache;
+use Utopia\Config\Config;
use Utopia\Console;
use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
@@ -90,8 +91,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 {
@@ -130,8 +138,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 {
@@ -152,8 +167,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 {
@@ -210,6 +232,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()) {
@@ -222,6 +252,7 @@ return function (Container $container): void {
if (\in_array($databaseHost, $dbTypeSharedTables)) {
$database
->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
->setTenant($projectDocument->getSequence())
->setNamespace($databaseDSN->getParam('namespace'));
} else {
@@ -233,6 +264,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 {
@@ -257,6 +289,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);
@@ -264,6 +301,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);
@@ -368,7 +406,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..826d751b14 100644
--- a/app/realtime.php
+++ b/app/realtime.php
@@ -38,6 +38,7 @@ use Utopia\DSN\DSN;
use Utopia\Logger\Log;
use Utopia\Pools\Group;
use Utopia\Registry\Registry;
+use Utopia\Span\Span;
use Utopia\System\System;
use Utopia\Telemetry\Adapter\None as NoTelemetry;
use Utopia\WebSocket\Adapter;
@@ -45,6 +46,10 @@ use Utopia\WebSocket\Server;
require_once __DIR__ . '/init.php';
+if (System::getEnv('_APP_EDITION', 'self-hosted') === 'self-hosted') {
+ require_once __DIR__ . '/init/span.php';
+}
+
/** @var Registry $register */
$register = $GLOBALS['register'] ?? throw new \RuntimeException('Registry not initialized');
@@ -125,8 +130,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 {
@@ -262,7 +273,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
@@ -394,10 +407,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 +544,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 +575,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 +583,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,6 +603,12 @@ $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'));
+ }
}
}
@@ -592,6 +651,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 +694,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 +719,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.connectionId', $connection);
+ Span::add('realtime.inboundBytes', $rawSize);
+ if (!empty($request->getOrigin())) {
+ Span::add('realtime.origin', $request->getOrigin());
+ }
try {
/** @var Document $project */
@@ -685,8 +782,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 +801,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 +839,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 +874,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,8 +893,9 @@ $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) {
logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization);
@@ -799,6 +905,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
if (!\is_int($code)) {
$code = 500;
}
+ $responseCode = $code;
$message = $th->getMessage();
@@ -816,7 +923,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,16 +933,44 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
Console::error('[Error] Code: ' . $response['data']['code']);
Console::error('[Error] Message: ' . $response['data']['message']);
}
+ Span::error($th);
+ } finally {
+ Span::add('realtime.success', $success);
+ Span::add('realtime.responseCode', $responseCode);
+ Span::add('realtime.subscriptionMode', $subscriptionMode);
+ Span::add('realtime.channelCount', $channelCount);
+ Span::add('realtime.subscriptionCount', $subscriptionCount);
+ Span::add('realtime.outboundBytes', $outboundBytes);
+ if (!empty($project?->getId())) {
+ Span::add('realtime.projectId', $project->getId());
+ }
+ if (!empty($logUser?->getId())) {
+ Span::add('realtime.userId', $logUser->getId());
+ }
+ Span::current()?->finish();
}
});
-$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId) {
+$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId, $register) {
$project = null;
$authorization = null;
+ $projectId = $realtime->connections[$connection]['projectId'] ?? null;
+ $rawSize = \strlen($message);
+ $messageType = 'invalid';
+ $subscriptionDelta = 0;
+ $subscriptionsRequested = 0;
+ $subscriptionsRemoved = 0;
+ $outboundBytes = 0;
+ $responseCode = 200;
+ $success = false;
+
+ Span::init('realtime.message');
+ Span::add('realtime.connectionId', $connection);
+ Span::add('realtime.inboundBytes', $rawSize);
+ Span::add('realtime.containerId', $containerId);
+
try {
- $rawSize = \strlen($message);
$response = new Response(new SwooleResponse());
- $projectId = $realtime->connections[$connection]['projectId'] ?? null;
// Get authorization from connection (stored during onOpen)
$authorization = $realtime->connections[$connection]['authorization'] ?? null;
@@ -883,6 +1020,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message format is not valid.');
}
+ $messageType = $message['type'] ?? 'invalid';
+
+ if (!\is_scalar($messageType)) {
+ throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type 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.');
@@ -895,6 +1038,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
]);
$server->send([$connection], $pongPayloadJson);
+ $outboundBytes += \strlen($pongPayloadJson);
if ($project !== null && !$project->isEmpty()) {
$pongOutboundBytes = \strlen($pongPayloadJson);
@@ -940,7 +1084,13 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
$authorization = $realtime->connections[$connection]['authorization'] ?? null;
$projectId = $realtime->connections[$connection]['projectId'] ?? null;
+ // Capture the pre-auth userId so we can rebind any account channels
+ // that were stored under it (e.g. guest who subscribed to `account`
+ // and now authenticates). unsubscribe() below clears the connection
+ // entry, so we must read it first.
+ $previousUserId = $realtime->connections[$connection]['userId'] ?? '';
+ $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection));
$meta = $realtime->getSubscriptionMetadata($connection);
$realtime->unsubscribe($connection);
@@ -948,13 +1098,18 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
if (!empty($projectId)) {
foreach ($meta as $subscriptionId => $subscription) {
$queries = Query::parseQueries($subscription['queries'] ?? []);
+ $channels = Realtime::rebindAccountChannels(
+ $subscription['channels'] ?? [],
+ $previousUserId,
+ $user->getId()
+ );
$realtime->subscribe(
$projectId,
$connection,
$subscriptionId,
$roles,
- $subscription['channels'] ?? [],
+ $channels,
$queries,
$user->getId()
);
@@ -965,6 +1120,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
$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'));
+ }
+
$user = $response->output($user, Response::MODEL_ACCOUNT);
$authResponsePayloadJson = json_encode([
@@ -977,6 +1138,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
]);
$server->send([$connection], $authResponsePayloadJson);
+ $outboundBytes += \strlen($authResponsePayloadJson);
if ($project !== null && !$project->isEmpty()) {
$authOutboundBytes = \strlen($authResponsePayloadJson);
@@ -1009,6 +1171,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
// bulk validation + parsing before subscribing
$parsedPayloads = [];
+ $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection));
foreach ($message['data'] as $payload) {
if (!\is_array($payload)) {
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each subscribe payload must be an object.');
@@ -1037,22 +1200,28 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
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) {
$subscriptionId = $parsedPayload['subscriptionId'];
- $channels = \array_keys(Realtime::convertChannels($parsedPayload['channels'], $userId));
+ $channels = $parsedPayload['convertedChannels'];
$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;
+ $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection));
+ $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
+ $subscriptionsRequested = \count($parsedPayloads);
+ if ($subscriptionDelta !== 0) {
+ $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
+ }
$responsePayload = json_encode([
'type' => 'response',
@@ -1062,7 +1231,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
'subscriptions' => \array_map(function (array $parsedPayload) {
return [
'subscriptionId' => $parsedPayload['subscriptionId'],
- 'channels' => $parsedPayload['channels'],
+ 'channels' => $parsedPayload['convertedChannels'],
'queries' => \array_map(fn ($q) => $q->toString(), $parsedPayload['queries']),
];
}, $parsedPayloads),
@@ -1070,6 +1239,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
]);
$server->send([$connection], $responsePayload);
+ $outboundBytes += \strlen($responsePayload);
if ($project !== null && !$project->isEmpty()) {
$subscribeOutboundBytes = \strlen($responsePayload);
@@ -1083,15 +1253,79 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
break;
+ case 'unsubscribe':
+ if (!\is_array($message['data']) || !\array_is_list($message['data'])) {
+ throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.');
+ }
+
+ $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection));
+
+ // Validate every payload before executing any removal so an invalid entry
+ // later in the batch does not leave earlier entries half-applied on the server.
+ $validatedIds = [];
+ foreach ($message['data'] as $payload) {
+ if (
+ !\is_array($payload)
+ || !\array_key_exists('subscriptionId', $payload)
+ || !\is_string($payload['subscriptionId'])
+ || $payload['subscriptionId'] === ''
+ ) {
+ throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each unsubscribe payload must include a non-empty subscriptionId.');
+ }
+ $validatedIds[] = $payload['subscriptionId'];
+ }
+
+ $unsubscribeResults = [];
+ foreach ($validatedIds as $subscriptionId) {
+ $wasRemoved = $realtime->unsubscribeSubscription($connection, $subscriptionId);
+ $unsubscribeResults[] = [
+ 'subscriptionId' => $subscriptionId,
+ 'removed' => $wasRemoved,
+ ];
+ }
+ $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection));
+ $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
+ $subscriptionsRequested = \count($validatedIds);
+ $subscriptionsRemoved = \count(\array_filter($unsubscribeResults, fn (array $item) => $item['removed']));
+ if ($subscriptionDelta !== 0) {
+ $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
+ }
+
+ $unsubscribeResponsePayload = json_encode([
+ 'type' => 'response',
+ 'data' => [
+ 'to' => 'unsubscribe',
+ 'success' => true,
+ 'subscriptions' => $unsubscribeResults,
+ ],
+ ]);
+
+ $server->send([$connection], $unsubscribeResponsePayload);
+ $outboundBytes += \strlen($unsubscribeResponsePayload);
+
+ if ($project !== null && !$project->isEmpty()) {
+ $unsubscribeOutboundBytes = \strlen($unsubscribeResponsePayload);
+
+ if ($unsubscribeOutboundBytes > 0) {
+ triggerStats([
+ METRIC_REALTIME_OUTBOUND => $unsubscribeOutboundBytes,
+ ], $project->getId());
+ }
+ }
+
+ break;
+
default:
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.');
}
+ $success = true;
} catch (Throwable $th) {
logError($th, 'realtimeMessage', project: $project, authorization: $authorization);
$code = $th->getCode();
if (!is_int($code)) {
$code = 500;
}
+ $responseCode = $code;
$message = $th->getMessage();
@@ -1108,19 +1342,52 @@ $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());
}
+ Span::error($th);
+ } finally {
+ Span::add('realtime.success', $success);
+ Span::add('realtime.responseCode', $responseCode);
+ Span::add('realtime.subscriptionDelta', $subscriptionDelta);
+ Span::add('realtime.subscriptionsRequested', $subscriptionsRequested);
+ Span::add('realtime.subscriptionsRemoved', $subscriptionsRemoved);
+ Span::add('realtime.subscribe.subscriptionsCount', $subscriptionsRequested);
+ Span::add('realtime.outboundBytes', $outboundBytes);
+ Span::add('realtime.projectId', $project?->getId() ?? $projectId);
+ Span::add('realtime.userId', $realtime->connections[$connection]['userId'] ?? null);
+ Span::add('realtime.messageType', $messageType);
+ Span::current()?->finish();
}
});
$server->onClose(function (int $connection) use ($realtime, $stats, $register) {
+ $projectId = null;
+ $userId = null;
+ $subscriptionsBeforeClose = 0;
+ $success = false;
+
+ Span::init('realtime.close');
+ Span::add('realtime.connectionId', $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'];
@@ -1128,12 +1395,30 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) {
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('realtime.projectId', $projectId);
+ }
+ if (!empty($userId)) {
+ Span::add('realtime.userId', $userId);
+ }
+ Span::add('realtime.subscriptionsBeforeClose', $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..1bf36b7f6d 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
@@ -907,7 +891,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
appwrite-task-scheduler-executions:
image: /:
@@ -936,7 +919,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 +947,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
appwrite-assistant:
@@ -1068,13 +1049,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 +1185,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..12b822c4eb 100644
--- a/app/worker.php
+++ b/app/worker.php
@@ -129,7 +129,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 6bd5b3e0cc..f9c1072108 100644
--- a/composer.json
+++ b/composer.json
@@ -49,10 +49,9 @@
"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/agents": "1.2.*",
"utopia-php/analytics": "0.15.*",
"utopia-php/audit": "2.2.*",
"utopia-php/auth": "0.5.*",
@@ -70,19 +69,20 @@
"utopia-php/dsn": "0.2.1",
"utopia-php/http": "0.34.*",
"utopia-php/fetch": "0.5.*",
+ "utopia-php/validators": "0.2.*",
"utopia-php/image": "0.8.*",
"utopia-php/locale": "0.8.*",
"utopia-php/logger": "0.6.*",
"utopia-php/messaging": "0.22.*",
- "utopia-php/migration": "dev-big-int as 1.8.6",
- "utopia-php/platform": "0.12.*",
+ "utopia-php/migration": "1.9.*",
+ "utopia-php/platform": "0.13.*",
"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/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.*",
@@ -93,7 +93,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 d456c314a3..d8c19b75ac 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": "bf05e373a346cd9f07f1dcccd5e7eff1",
+ "content-hash": "29610e3ef365af01d018077f2638ffb3",
"packages": [
{
"name": "adhocore/jwt",
@@ -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",
@@ -2887,7 +2887,7 @@
},
{
"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 +2948,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 +2972,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 +3028,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 +3052,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 +3108,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 +3132,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 +3188,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,7 +3208,7 @@
"type": "tidelift"
}
],
- "time": "2026-04-10T16:50:15+00:00"
+ "time": "2026-04-26T13:10:57+00:00"
},
{
"name": "symfony/service-contracts",
@@ -3351,16 +3351,16 @@
},
{
"name": "utopia-php/abuse",
- "version": "1.2.2",
+ "version": "1.2.3",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/abuse.git",
- "reference": "20bee84fd14dbe81d50ecabf1ffd81cceca06152"
+ "reference": "53f4274939353522ba331f55bcff6e6011ffc56c"
},
"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/53f4274939353522ba331f55bcff6e6011ffc56c",
+ "reference": "53f4274939353522ba331f55bcff6e6011ffc56c",
"shasum": ""
},
"require": {
@@ -3397,9 +3397,9 @@
],
"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.2.3"
},
- "time": "2026-02-02T10:43:10+00:00"
+ "time": "2026-04-29T11:19:08+00:00"
},
{
"name": "utopia-php/agents",
@@ -3502,16 +3502,16 @@
},
{
"name": "utopia-php/audit",
- "version": "2.2.1",
+ "version": "2.2.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/audit.git",
- "reference": "e3e2d6ad5c7f6377d9237df296a12eb7943892fd"
+ "reference": "90886c202e7983999e6b6a8201004d5ab61d4b57"
},
"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/90886c202e7983999e6b6a8201004d5ab61d4b57",
+ "reference": "90886c202e7983999e6b6a8201004d5ab61d4b57",
"shasum": ""
},
"require": {
@@ -3545,9 +3545,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.2.2"
},
- "time": "2026-02-02T10:39:25+00:00"
+ "time": "2026-05-04T06:48:58+00:00"
},
{
"name": "utopia-php/auth",
@@ -3658,16 +3658,16 @@
},
{
"name": "utopia-php/cli",
- "version": "0.23.1",
+ "version": "0.23.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/cli.git",
- "reference": "8d1955b8bc4dc631f45d7c7df689ed7b63f70621"
+ "reference": "145b91fef827853bcceaa3ab8ca2b1d6faaca2ab"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/cli/zipball/8d1955b8bc4dc631f45d7c7df689ed7b63f70621",
- "reference": "8d1955b8bc4dc631f45d7c7df689ed7b63f70621",
+ "url": "https://api.github.com/repos/utopia-php/cli/zipball/145b91fef827853bcceaa3ab8ca2b1d6faaca2ab",
+ "reference": "145b91fef827853bcceaa3ab8ca2b1d6faaca2ab",
"shasum": ""
},
"require": {
@@ -3703,9 +3703,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.2"
},
- "time": "2026-04-05T15:27:35+00:00"
+ "time": "2026-04-27T09:19:04+00:00"
},
{
"name": "utopia-php/compression",
@@ -3854,18 +3854,19 @@
"source": {
"type": "git",
"url": "https://github.com/utopia-php/database.git",
- "reference": "6ff2d7b081f99280ffb85dd6efa710c9aeb3e620"
+ "reference": "dd1077a1548a2a0a07469181e3421f35e27daf3b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/database/zipball/6ff2d7b081f99280ffb85dd6efa710c9aeb3e620",
- "reference": "6ff2d7b081f99280ffb85dd6efa710c9aeb3e620",
+ "url": "https://api.github.com/repos/utopia-php/database/zipball/dd1077a1548a2a0a07469181e3421f35e27daf3b",
+ "reference": "dd1077a1548a2a0a07469181e3421f35e27daf3b",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-mongodb": "*",
"ext-pdo": "*",
+ "ext-redis": "*",
"php": ">=8.4",
"utopia-php/cache": "1.*",
"utopia-php/console": "0.1.*",
@@ -3905,7 +3906,7 @@
"issues": "https://github.com/utopia-php/database/issues",
"source": "https://github.com/utopia-php/database/tree/big-init"
},
- "time": "2026-04-16T08:39:46+00:00"
+ "time": "2026-05-05T13:22:35+00:00"
},
{
"name": "utopia-php/detector",
@@ -4062,16 +4063,16 @@
},
{
"name": "utopia-php/domains",
- "version": "1.0.5",
+ "version": "1.0.6",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/domains.git",
- "reference": "0edf6bb2b07f30db849a267027077bf5abb994c6"
+ "reference": "c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6"
},
"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/c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6",
+ "reference": "c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6",
"shasum": ""
},
"require": {
@@ -4118,9 +4119,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/1.0.6"
},
- "time": "2026-03-03T09:20:50+00:00"
+ "time": "2026-04-29T11:08:10+00:00"
},
{
"name": "utopia-php/dsn",
@@ -4271,21 +4272,20 @@
},
{
"name": "utopia-php/http",
- "version": "0.34.20",
+ "version": "0.34.24",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/http.git",
- "reference": "d6b360d555022d16c16d40be51f86180364819f8"
+ "reference": "d1eced0627c5a9fceddf53992ed97d664b810d33"
},
"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/d1eced0627c5a9fceddf53992ed97d664b810d33",
+ "reference": "d1eced0627c5a9fceddf53992ed97d664b810d33",
"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.*",
@@ -4295,11 +4295,14 @@
"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 +4322,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/0.34.24"
},
- "time": "2026-04-12T14:25:22+00:00"
+ "time": "2026-04-24T12:16:53+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 +4346,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 +4373,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",
@@ -4526,16 +4531,16 @@
},
{
"name": "utopia-php/migration",
- "version": "dev-big-int",
+ "version": "1.9.6",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
- "reference": "9e8708883677661ac568ba41f7a0b79cd6f04253"
+ "reference": "b164631404ec759f8c368fe2321f44c22bc258ab"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/migration/zipball/9e8708883677661ac568ba41f7a0b79cd6f04253",
- "reference": "9e8708883677661ac568ba41f7a0b79cd6f04253",
+ "url": "https://api.github.com/repos/utopia-php/migration/zipball/b164631404ec759f8c368fe2321f44c22bc258ab",
+ "reference": "b164631404ec759f8c368fe2321f44c22bc258ab",
"shasum": ""
},
"require": {
@@ -4546,7 +4551,7 @@
"php": ">=8.1",
"utopia-php/database": "dev-big-init as 5.4",
"utopia-php/dsn": "0.2.*",
- "utopia-php/storage": "1.0.*"
+ "utopia-php/storage": "2.*"
},
"require-dev": {
"ext-pdo": "*",
@@ -4575,22 +4580,22 @@
],
"support": {
"issues": "https://github.com/utopia-php/migration/issues",
- "source": "https://github.com/utopia-php/migration/tree/big-int"
+ "source": "https://github.com/utopia-php/migration/tree/1.9.6"
},
- "time": "2026-03-30T10:03:42+00:00"
+ "time": "2026-04-30T08:11:07+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,22 +4641,22 @@
],
"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": "0.13.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/platform.git",
- "reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc"
+ "reference": "d23af5349a7ea9ee11f9920a13626226f985522e"
},
"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/d23af5349a7ea9ee11f9920a13626226f985522e",
+ "reference": "d23af5349a7ea9ee11f9920a13626226f985522e",
"shasum": ""
},
"require": {
@@ -4687,9 +4692,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/0.13.0"
},
- "time": "2026-04-08T04:11:31+00:00"
+ "time": "2026-04-17T09:57:18+00:00"
},
{
"name": "utopia-php/pools",
@@ -5016,16 +5021,16 @@
},
{
"name": "utopia-php/storage",
- "version": "1.0.1",
+ "version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/storage.git",
- "reference": "f014be445f0baa635d0764e1673196f412511618"
+ "reference": "8a2e3a86fd01aaed675884146665308c2122264e"
},
"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/8a2e3a86fd01aaed675884146665308c2122264e",
+ "reference": "8a2e3a86fd01aaed675884146665308c2122264e",
"shasum": ""
},
"require": {
@@ -5039,9 +5044,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,9 +5067,9 @@
],
"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.1"
},
- "time": "2026-02-23T05:59:32+00:00"
+ "time": "2026-04-29T09:05:48+00:00"
},
{
"name": "utopia-php/system",
@@ -5180,16 +5184,16 @@
},
{
"name": "utopia-php/validators",
- "version": "0.2.0",
+ "version": "0.2.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/validators.git",
- "reference": "30b6030a5b100fc1dff34506e5053759594b2a20"
+ "reference": "5d7d494e64457cd4eb67fdcfd9481f2c89796aa6"
},
"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/5d7d494e64457cd4eb67fdcfd9481f2c89796aa6",
+ "reference": "5d7d494e64457cd4eb67fdcfd9481f2c89796aa6",
"shasum": ""
},
"require": {
@@ -5219,9 +5223,9 @@
],
"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.2"
},
- "time": "2026-01-13T09:16:51+00:00"
+ "time": "2026-04-27T16:30:24+00:00"
},
{
"name": "utopia-php/vcs",
@@ -5381,16 +5385,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 +5403,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 +5426,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 +5449,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 +5461,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.25.1",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-generator.git",
- "reference": "c714ee52659ef5968b3372ff4da0e407140a6250"
+ "reference": "f21a556b9acdbf75bbdcdc90a078af641646eade"
},
"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/f21a556b9acdbf75bbdcdc90a078af641646eade",
+ "reference": "f21a556b9acdbf75bbdcdc90a078af641646eade",
"shasum": ""
},
"require": {
@@ -5507,9 +5512,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.25.1"
},
- "time": "2026-04-11T02:42:32+00:00"
+ "time": "2026-04-28T11:12:22+00:00"
},
{
"name": "brianium/paratest",
@@ -5791,16 +5796,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 +5816,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 +5860,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 +6223,11 @@
},
{
"name": "phpstan/phpstan",
- "version": "2.1.46",
+ "version": "2.1.54",
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25",
- "reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
+ "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
"shasum": ""
},
"require": {
@@ -6267,20 +6272,20 @@
"type": "github"
}
],
- "time": "2026-04-01T09:25:14+00:00"
+ "time": "2026-04-29T13:31:09+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 +6294,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 +6340,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 +6360,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 +6621,16 @@
},
{
"name": "phpunit/phpunit",
- "version": "12.5.17",
+ "version": "12.5.23",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "85b62adab1a340982df64e66daa4a4435eb5723b"
+ "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/85b62adab1a340982df64e66daa4a4435eb5723b",
- "reference": "85b62adab1a340982df64e66daa4a4435eb5723b",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969",
+ "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969",
"shasum": ""
},
"require": {
@@ -6640,15 +6644,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 +6699,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.23"
},
"funding": [
{
@@ -6703,7 +6707,7 @@
"type": "other"
}
],
- "time": "2026-04-08T03:04:19+00:00"
+ "time": "2026-04-18T06:12:49+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -6776,16 +6780,16 @@
},
{
"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 +6848,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 +6868,7 @@
"type": "tidelift"
}
],
- "time": "2026-04-08T04:43:00+00:00"
+ "time": "2026-04-14T08:23:15+00:00"
},
{
"name": "sebastian/complexity",
@@ -6993,16 +6997,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 +7021,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "8.0-dev"
+ "dev-main": "8.1-dev"
}
},
"autoload": {
@@ -7045,7 +7049,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,7 +7069,7 @@
"type": "tidelift"
}
],
- "time": "2026-03-15T07:05:40+00:00"
+ "time": "2026-04-15T12:13:01+00:00"
},
{
"name": "sebastian/exporter",
@@ -7778,7 +7782,7 @@
},
{
"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 +7841,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 +7865,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 +7923,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 +7943,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,7 +8008,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0"
},
"funding": [
{
@@ -8028,7 +8032,7 @@
},
{
"name": "symfony/polyfill-php81",
- "version": "v1.34.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php81.git",
@@ -8084,7 +8088,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php81/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0"
},
"funding": [
{
@@ -8446,18 +8450,11 @@
"version": "dev-big-init",
"alias": "5.7",
"alias_normalized": "5.7.0.0"
- },
- {
- "package": "utopia-php/migration",
- "version": "dev-big-int",
- "alias": "1.8.6",
- "alias_normalized": "1.8.6.0"
}
],
"minimum-stability": "dev",
"stability-flags": {
- "utopia-php/database": 20,
- "utopia-php/migration": 20
+ "utopia-php/database": 20
},
"prefer-stable": true,
"prefer-lowest": false,
diff --git a/docker-compose.yml b/docker-compose.yml
index 391d71fb48..da5efac438 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:7.8.45
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
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/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/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/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-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-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/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/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/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-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-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-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/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/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/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/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/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/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/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php
index 2ffcbc9aa4..9b3d68519f 100644
--- a/src/Appwrite/Bus/Listeners/Mails.php
+++ b/src/Appwrite/Bus/Listeners/Mails.php
@@ -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');
@@ -131,7 +133,8 @@ class Mails extends Listener
->setSmtpUsername($smtp['username'] ?? '')
->setSmtpPassword($smtp['password'] ?? '')
->setSmtpSecure($smtp['secure'] ?? '')
- ->setSmtpReplyTo($customTemplate['replyTo'] ?? $smtp['replyTo'] ?? '')
+ ->setSmtpReplyToEmail($customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '') // Includes backwards compatibility
+ ->setSmtpReplyToName($customTemplate['replyToName'] ?? $smtp['replyToName'] ?? '')
->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'));
}
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/Event.php b/src/Appwrite/Event/Event.php
index fae2d0e843..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;
diff --git a/src/Appwrite/Event/Mail.php b/src/Appwrite/Event/Mail.php
index d8f25489c6..0685586c60 100644
--- a/src/Appwrite/Event/Mail.php
+++ b/src/Appwrite/Event/Mail.php
@@ -251,14 +251,26 @@ class Mail extends Event
}
/**
- * Set SMTP reply to
+ * Set SMTP reply-to email
*
- * @param string $replyTo
+ * @param string $email
* @return self
*/
- public function setSmtpReplyTo(string $replyTo): self
+ public function setSmtpReplyToEmail(string $email): self
{
- $this->smtp['replyTo'] = $replyTo;
+ $this->smtp['replyToEmail'] = $email;
+ return $this;
+ }
+
+ /**
+ * Set SMTP reply-to name
+ *
+ * @param string $name
+ * @return self
+ */
+ public function setSmtpReplyToName(string $name): self
+ {
+ $this->smtp['replyToName'] = $name;
return $this;
}
@@ -333,13 +345,23 @@ class Mail extends Event
}
/**
- * Get SMTP reply to
+ * Get SMTP reply-to email
*
* @return string
*/
- public function getSmtpReplyTo(): string
+ public function getSmtpReplyToEmail(): string
{
- return $this->smtp['replyTo'] ?? '';
+ return $this->smtp['replyToEmail'] ?? '';
+ }
+
+ /**
+ * Get SMTP reply-to name
+ *
+ * @return string
+ */
+ public function getSmtpReplyToName(): string
+ {
+ return $this->smtp['replyToName'] ?? '';
}
/**
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..6fc3e88635 100644
--- a/src/Appwrite/Extend/Exception.php
+++ b/src/Appwrite/Extend/Exception.php
@@ -384,6 +384,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';
diff --git a/src/Appwrite/GraphQL/Types/Mapper.php b/src/Appwrite/GraphQL/Types/Mapper.php
index 53474b855a..55810fd74e 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) {
diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php
index f1d806bcc5..5a9c02a2bd 100644
--- a/src/Appwrite/Messaging/Adapter/Realtime.php
+++ b/src/Appwrite/Messaging/Adapter/Realtime.php
@@ -14,6 +14,28 @@ use Utopia\Database\Query;
class Realtime extends MessagingAdapter
{
+ public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert', 'delete'];
+
+ // Resources whose channels receive an action-suffixed sibling at publish time.
+ // The suffix loop in fromPayload() treats any channel whose last OR second-to-last
+ // segment matches an entry here as a candidate for `.{action}` suffixing.
+ //
+ // `functions` is intentionally a parent-only entry: fromPayload publishes
+ // `functions.{functionId}` (suffixed to `functions.{functionId}.{action}`) but
+ // never emits a bare `functions` channel — so subscribing to bare
+ // `functions.{action}` is a silent no-op. Per-function filters
+ // (`functions.{functionId}.{action}`) are the supported form.
+ private const RESOURCE_LEAF_NAMES = [
+ 'documents',
+ 'rows',
+ 'files',
+ 'executions',
+ 'functions',
+ 'account',
+ 'teams',
+ 'memberships',
+ ];
+
/**
* Connection Tree
*
@@ -45,8 +67,6 @@ class Realtime extends MessagingAdapter
/**
* Get the PubSubPool instance, initializing it lazily if needed.
* This allows unit tests to work without requiring the global $register.
- *
- * @return PubSubPool
*/
private function getPubSubPool(): PubSubPool
{
@@ -54,6 +74,7 @@ class Realtime extends MessagingAdapter
global $register;
$this->pubSubPool = new PubSubPool($register->get('pools')->get('pubsub'));
}
+
return $this->pubSubPool;
}
@@ -114,14 +135,24 @@ 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))),
];
+
+ if (\array_key_exists('authorization', $existing)) {
+ $entry['authorization'] = $existing['authorization'];
+ }
+
+ $this->connections[$identifier] = $entry;
}
/**
@@ -137,7 +168,7 @@ class Realtime extends MessagingAdapter
$roles = $this->connections[$connection]['roles'] ?? [];
$channels = $this->connections[$connection]['channels'] ?? [];
- if (!$projectId || empty($roles) || empty($channels)) {
+ if (! $projectId || empty($roles) || empty($channels)) {
return [];
}
@@ -158,7 +189,7 @@ class Realtime extends MessagingAdapter
if (!isset($subscriptions[$subscriptionId])) {
$subscriptions[$subscriptionId] = [
'channels' => [],
- 'queries' => $data['strings'] ?? []
+ 'queries' => $data['strings'] ?? [],
];
}
if (!\in_array($channel, $subscriptions[$subscriptionId]['channels'])) {
@@ -206,6 +237,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 +327,7 @@ class Realtime extends MessagingAdapter
*/
public function hasSubscriber(string $projectId, string $role, string $channel = ''): bool
{
- //TODO: look into moving it to an abstract class in the parent class
+ // TODO: look into moving it to an abstract class in the parent class
if (empty($channel)) {
return array_key_exists($projectId, $this->subscriptions)
&& array_key_exists($role, $this->subscriptions[$projectId]);
@@ -236,6 +348,7 @@ class Realtime extends MessagingAdapter
* @param array $roles
* @param array $options
* @return void
+ *
* @throws \Exception
*/
public function send(string $projectId, array $payload, array $events, array $channels, array $roles, array $options = []): void
@@ -256,8 +369,8 @@ class Realtime extends MessagingAdapter
'events' => $events,
'channels' => $channels,
'timestamp' => DateTime::formatTz(DateTime::now()),
- 'payload' => $payload
- ]
+ 'payload' => $payload,
+ ],
]));
}
@@ -270,7 +383,6 @@ class Realtime extends MessagingAdapter
* - 1.5 ms | 1,000 Connections / 10,000 Subscriptions
* - 15 ms | 10,000 Connections / 100,000 Subscriptions
*
- * @param array $event
* @return array Map of connection IDs to matched query groups
*/
public function getSubscribers(array $event): array
@@ -286,7 +398,7 @@ class Realtime extends MessagingAdapter
foreach ($this->subscriptions[$event['project']] as $role => $subscriptionsByChannel) {
foreach ($event['data']['channels'] as $channel) {
if (
- !\array_key_exists($channel, $subscriptionsByChannel)
+ ! \array_key_exists($channel, $subscriptionsByChannel)
|| (!\in_array($role, $event['roles']) && !\in_array(Role::any()->toString(), $event['roles']))
) {
continue;
@@ -319,6 +431,12 @@ class Realtime extends MessagingAdapter
/**
* Converts the channels from the Query Params into an array.
+ * Also renames the account channel to account.USER_ID, rewrites action-suffixed
+ * account variants (`account.create`, `account.update`, `account.upsert`,
+ * `account.delete`) to `account.USER_ID.{action}` so they match the channels
+ * fromPayload() publishes for top-level user events, and removes all other
+ * illegal account channel variations (e.g. another user's `account.{otherId}`).
+ *
* Also renames the account channel to account.USER_ID and removes all illegal account channel variations.
* @param array $channels
* @param string $userId
@@ -330,27 +448,94 @@ class Realtime extends MessagingAdapter
foreach ($channels as $key => $value) {
switch (true) {
- case str_starts_with($key, 'account.'):
- unset($channels[$key]);
- break;
-
case $key === 'account':
if (!empty($userId)) {
- $channels['account.' . $userId] = $value;
+ $channels['account.'.$userId] = $value;
}
break;
+
+ case \in_array(\substr($key, \strlen('account.')), self::SUPPORTED_ACTIONS, true) && str_starts_with($key, 'account.'):
+ // Authenticated: rewrite `account.{action}` → `account.{userId}.{action}`
+ // so the subscriber only receives their own account events.
+ // Guest: keep the literal `account.{action}` so the action filter
+ // applies to the broadcast `account.{action}` channel that fromPayload
+ // emits for top-level user events. On in-band auth, rebindAccountChannels
+ // rewrites the literal to the user-scoped form.
+ if (!empty($userId)) {
+ unset($channels[$key]);
+ $action = \substr($key, \strlen('account.'));
+ $channels['account.'.$userId.'.'.$action] = $value;
+ }
+ break;
+
+ case str_starts_with($key, 'account.'):
+ unset($channels[$key]);
+ break;
}
}
return $channels;
}
+ /**
+ * Rewrites stored account channels to match a new userId. Used when in-band
+ * authentication changes the connection's user identity:
+ *
+ * - guest → authenticated: rewrites the literal `account.{action}` form
+ * that convertChannels preserves for guests into `account.{userId}.{action}`.
+ * - reauth as a different user: rewrites `account.{oldUserId}` and
+ * `account.{oldUserId}.{action}` to the new userId.
+ *
+ * Returns channels unchanged when there's nothing to do — same user, or an
+ * empty target (defensive: avoids producing malformed `account.` strings if
+ * a caller ever passes `$newUserId = ''`, e.g. an in-band logout flow).
+ */
+ public static function rebindAccountChannels(array $channels, string $oldUserId, string $newUserId): array
+ {
+ if ($newUserId === '' || $oldUserId === $newUserId) {
+ return $channels;
+ }
+
+ return \array_map(function (string $channel) use ($oldUserId, $newUserId) {
+ if (!\str_starts_with($channel, 'account.')) {
+ return $channel;
+ }
+
+ // Guest origin: literal `account.{action}` (preserved by convertChannels
+ // for unauthenticated connections) becomes `account.{newUserId}.{action}`.
+ if ($oldUserId === '') {
+ $suffix = \substr($channel, \strlen('account.'));
+ if (\in_array($suffix, self::SUPPORTED_ACTIONS, true)) {
+ return 'account.'.$newUserId.'.'.$suffix;
+ }
+
+ return $channel;
+ }
+
+ // Authenticated → different user.
+ if ($channel === 'account.'.$oldUserId) {
+ return 'account.'.$newUserId;
+ }
+
+ $oldPrefix = 'account.'.$oldUserId.'.';
+ if (\str_starts_with($channel, $oldPrefix)) {
+ $action = \substr($channel, \strlen($oldPrefix));
+ if (\in_array($action, self::SUPPORTED_ACTIONS, true)) {
+ return 'account.'.$newUserId.'.'.$action;
+ }
+ }
+
+ return $channel;
+ }, $channels);
+ }
+
/**
* Constructs subscriptions from query parameters.
*
* @param array $channelNames
* @param callable $getQueryParam
* @return array [index => ['channels' => string[], 'queries' => Query[]]]
+ *
* @throws QueryException
*/
public static function constructSubscriptions(array $channelNames, callable $getQueryParam): array
@@ -361,6 +546,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 +560,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 +576,11 @@ class Realtime extends MessagingAdapter
if (empty($subscriptions[0]['queries'])) {
$subscriptions[0]['queries'] = [Query::select(['*'])];
}
+
continue;
}
- if (!\is_array($params)) {
+ if (! \is_array($params)) {
$params = [$params];
}
@@ -421,6 +607,7 @@ class Realtime extends MessagingAdapter
* Converts the queries from the Query Params into an array.
* @param array|string $queries
* @return array
+ *
* @throws QueryException
*/
public static function convertQueries(mixed $queries): array
@@ -433,7 +620,7 @@ class Realtime extends MessagingAdapter
$query = array_pop($stack);
$method = $query->getMethod();
- if (!in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) {
+ if (! in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) {
throw new QueryException(
"Query method '{$method}' is not supported in Realtime queries. Allowed: {$allowed}"
);
@@ -589,11 +776,53 @@ class Realtime extends MessagingAdapter
break;
}
+ // Action is the last segment for plain CRUD events (e.g. `documents.X.create`),
+ // and the second-to-last segment for attribute-trailing events
+ // (e.g. `users.U.update.email`, `teams.T.update.prefs`,
+ // `teams.T.memberships.M.update.status`). Without the second-to-last fallback
+ $count = \count($parts);
+ $action = null;
+ if (\in_array($parts[$count - 1], self::SUPPORTED_ACTIONS, true)) {
+ $action = $parts[$count - 1];
+ } elseif ($count >= 2 && \in_array($parts[$count - 2], self::SUPPORTED_ACTIONS, true)) {
+ $action = $parts[$count - 2];
+ }
+
+ // The `users` branch emits only user-level account channels
+ // (`account`, `account.{userId}`) regardless of event depth, so nested events
+ // like `users.U.sessions.S.create` or `users.U.challenges.C.create` would
+ // otherwise be suffixed as `account.create` — making a subscription to
+ // `account.create` receive unrelated session/challenge/recovery/verification
+ // events. Restrict suffixing to top-level user events where the action sits
+ // at parts[2] (`users.U.create`, `users.U.update.email`, etc.).
+ if (
+ $action !== null
+ && $parts[0] === 'users'
+ && ($parts[2] ?? null) !== $action
+ ) {
+ $action = null;
+ }
+
+ if ($action !== null && !empty($channels)) {
+ $augmented = $channels;
+ foreach ($channels as $channel) {
+ $segments = \explode('.', $channel);
+ $segCount = \count($segments);
+ $leafIsResource = \in_array($segments[$segCount - 1], self::RESOURCE_LEAF_NAMES, true);
+ $parentIsResource = $segCount >= 2 && \in_array($segments[$segCount - 2], self::RESOURCE_LEAF_NAMES, true);
+
+ if ($leafIsResource || $parentIsResource) {
+ $augmented[] = $channel. '.' .$action;
+ }
+ }
+ $channels = \array_values(\array_unique($augmented));
+ }
+
return [
'channels' => $channels,
'roles' => $roles,
'permissionsChanged' => $permissionsChanged,
- 'projectId' => $projectId
+ 'projectId' => $projectId,
];
}
diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php
index a01031de9b..359925e368 100644
--- a/src/Appwrite/Migration/Migration.php
+++ b/src/Appwrite/Migration/Migration.php
@@ -94,6 +94,8 @@ abstract class Migration
'1.8.1' => 'V23',
'1.9.0' => 'V24',
'1.9.1' => 'V24',
+ '1.9.2' => 'V24',
+ '1.9.3' => '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/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..88788b73fc 100644
--- a/src/Appwrite/Platform/Appwrite.php
+++ b/src/Appwrite/Platform/Appwrite.php
@@ -9,6 +9,7 @@ use Appwrite\Platform\Modules\Core;
use Appwrite\Platform\Modules\Databases;
use Appwrite\Platform\Modules\Functions;
use Appwrite\Platform\Modules\Health;
+use Appwrite\Platform\Modules\Migrations;
use Appwrite\Platform\Modules\Project;
use Appwrite\Platform\Modules\Projects;
use Appwrite\Platform\Modules\Proxy;
@@ -39,6 +40,7 @@ class Appwrite extends Platform
$this->addModule(new Storage\Module());
$this->addModule(new VCS\Module());
$this->addModule(new Webhooks\Module());
+ $this->addModule(new Migrations\Module());
$this->addModule(new Project\Module());
}
}
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..38d61b7d24 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();
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..7bcc78e974 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
@@ -170,11 +170,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)
@@ -223,7 +218,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 +250,8 @@ 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 = '';
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -262,8 +260,13 @@ 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
@@ -280,8 +283,13 @@ 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'] ?? '';
@@ -289,7 +297,8 @@ class Create extends Action
}
$queueForMails
- ->setSmtpReplyTo($replyTo)
+ ->setSmtpReplyToEmail($replyToEmail)
+ ->setSmtpReplyToName($replyToName)
->setSmtpSenderEmail($senderEmail)
->setSmtpSenderName($senderName);
}
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..c43c0fc4bf 100644
--- a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php
+++ b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php
@@ -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..85dfec3cfd 100644
--- a/src/Appwrite/Platform/Modules/Compute/Base.php
+++ b/src/Appwrite/Platform/Modules/Compute/Base.php
@@ -68,7 +68,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);
}
@@ -169,7 +169,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);
}
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/Key/XList.php b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php
new file mode 100644
index 0000000000..d951e93886
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/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/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..2540ae8e01 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,7 @@ use Appwrite\Platform\Modules\Console\Http\Redirects\Recover\Get as RedirectReco
use Appwrite\Platform\Modules\Console\Http\Redirects\Register\Get as RedirectRegister;
use Appwrite\Platform\Modules\Console\Http\Redirects\Root\Get as RedirectRoot;
use Appwrite\Platform\Modules\Console\Http\Resources\Get as GetResourceAvailability;
+use Appwrite\Platform\Modules\Console\Http\Scopes\Key\XList as ListKeyScopes;
use Appwrite\Platform\Modules\Console\Http\Variables\Get as GetVariables;
use Utopia\Platform\Service;
@@ -28,6 +30,8 @@ class Http extends Service
$this->addAction(Web::getName(), new Web());
$this->addAction(GetVariables::getName(), new GetVariables());
+ $this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers());
+ $this->addAction(ListKeyScopes::getName(), new ListKeyScopes());
$this->addAction(CreateAssistantQuery::getName(), new CreateAssistantQuery());
$this->addAction(GetResourceAvailability::getName(), new GetResourceAvailability());
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 3b28bdeffb..4e5203b13f 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
@@ -26,9 +26,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.
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..8100a2c51b 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
@@ -14,10 +14,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.
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 24cba578a9..633a2bbc86 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
@@ -293,16 +293,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
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..3a49d6c665 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
@@ -22,6 +22,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 +81,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());
@@ -126,8 +128,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 +182,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 {
@@ -206,6 +210,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 +235,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/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/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/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/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/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/Boolean/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Create.php
index ddfb023d25..10cd65bc98 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}')
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..64e73e310e 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')
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..f4d606637d 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')
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..d0b2ed3e4b 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')
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..e58ae115fc 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')
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..b8e81820aa 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')
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..c2faec9aeb 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')
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..1a965c19dc 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')
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..c2f480d5d0 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}')
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..8e2dbd911d 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')
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..f0b8099f02 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')
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..138ee482c3 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}')
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..a03a34f310 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}')
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..87544926fe 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')
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..17f60f61c1 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')
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..a8fde7d271 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')
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..19b33594b7 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')
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..7595f16c45 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')
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/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php
index e683aafba1..d377bed184 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}')
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..ca7e4fc2da 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')
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/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/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/Workers/Databases.php b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php
index a50e8f8bdf..39902aea53 100644
--- a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php
+++ b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php
@@ -54,7 +54,7 @@ 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');
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php
index 65b6ffd5bb..757edc0484 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php
@@ -175,15 +175,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
@@ -202,9 +195,14 @@ class Create extends Action
$metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)];
if (!$deployment->isEmpty()) {
$chunks = $deployment->getAttribute('sourceChunksTotal', 1);
+ $uploaded = $deployment->getAttribute('sourceChunksUploaded', 0);
$metadata = $deployment->getAttribute('sourceMetadata', []);
- if ($chunk === -1) {
- $chunk = $chunks;
+
+ if ($uploaded === $chunks) {
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($deployment, Response::MODEL_DEPLOYMENT);
+ return;
}
}
@@ -252,6 +250,8 @@ class Create extends Action
'sourcePath' => $path,
'sourceSize' => $fileSize,
'totalSize' => $fileSize,
+ 'sourceChunksTotal' => $chunks,
+ 'sourceChunksUploaded' => $chunksUploaded,
'activate' => $activate,
'sourceMetadata' => $metadata,
'type' => $type
@@ -266,6 +266,7 @@ class Create extends Action
} else {
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
'sourceSize' => $fileSize,
+ 'sourceChunksUploaded' => $chunksUploaded,
'sourceMetadata' => $metadata,
]));
}
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/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..9f15cf9d1e 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php
@@ -60,7 +60,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(
@@ -145,21 +145,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 +228,7 @@ class Create extends Base
$executionId = ID::unique();
$headers['x-appwrite-execution-id'] = $executionId;
- $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey;
+ $headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $apiKey;
$headers['x-appwrite-trigger'] = 'http';
$headers['x-appwrite-user-id'] = $user->getId();
$headers['x-appwrite-user-jwt'] = $jwt;
@@ -370,10 +357,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']
]);
}
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..7b294f3f90 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php
@@ -375,7 +375,7 @@ class Create extends Base
}
$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 +391,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(),
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php
index 71fc99a30e..7d6572d336 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php
@@ -162,10 +162,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');
}
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/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php
index 5648596826..f6d77c2a0d 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php
@@ -77,11 +77,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);
}
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..54d7a647a3 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php
@@ -85,7 +85,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);
}
diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php
index 0071b03d2d..352fb56e28 100644
--- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php
+++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php
@@ -102,7 +102,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');
@@ -144,7 +144,8 @@ class Builds extends Action
$log,
$executor,
$plan,
- $platform
+ $platform,
+ (int) ($payload['timeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900))
);
break;
@@ -179,7 +180,8 @@ class Builds extends Action
Log $log,
Executor $executor,
array $plan,
- array $platform
+ array $platform,
+ int $timeout
): void {
Console::info('Deployment action started');
@@ -204,7 +206,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');
}
@@ -224,10 +226,6 @@ class Builds extends Action
$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');
- }
-
// Realtime preparation
$event = "{$resource->getCollection()}.[{$resourceKey}].deployments.[deploymentId].update";
$queueForRealtime
@@ -592,10 +590,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 +624,7 @@ class Builds extends Action
$vars = [
...$vars,
'APPWRITE_FUNCTION_API_ENDPOINT' => $endpoint,
- 'APPWRITE_FUNCTION_API_KEY' => API_KEY_DYNAMIC . '_' . $apiKey,
+ 'APPWRITE_FUNCTION_API_KEY' => API_KEY_EPHEMERAL . '_' . $apiKey,
'APPWRITE_FUNCTION_ID' => $resource->getId(),
'APPWRITE_FUNCTION_NAME' => $resource->getAttribute('name'),
'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(),
@@ -644,7 +639,7 @@ class Builds extends Action
$vars = [
...$vars,
'APPWRITE_SITE_API_ENDPOINT' => $endpoint,
- 'APPWRITE_SITE_API_KEY' => API_KEY_DYNAMIC . '_' . $apiKey,
+ 'APPWRITE_SITE_API_KEY' => API_KEY_EPHEMERAL . '_' . $apiKey,
'APPWRITE_SITE_ID' => $resource->getId(),
'APPWRITE_SITE_NAME' => $resource->getAttribute('name'),
'APPWRITE_SITE_DEPLOYMENT' => $deployment->getId(),
@@ -830,7 +825,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;
@@ -1260,21 +1256,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 +1265,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 +1288,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 +1421,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 423bf0bd41..7d1cdc4980 100644
--- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php
+++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php
@@ -20,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;
@@ -44,6 +46,7 @@ class Screenshots extends Action
->inject('dbForProject')
->inject('project')
->inject('deviceForFiles')
+ ->inject('telemetry')
->callback($this->action(...));
}
@@ -53,17 +56,19 @@ 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');
@@ -162,8 +167,8 @@ class Screenshots extends Action
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;
@@ -268,8 +273,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/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/Failed/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php
index 6d77cc6e16..7602de45d3 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
@@ -16,6 +16,7 @@ use Appwrite\Event\Publisher\Screenshot;
use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
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;
@@ -123,6 +124,7 @@ class Get extends Base
System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $publisherForScreenshots,
System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $queueForMessaging,
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/Migrations/Http/Migrations/Appwrite/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php
new file mode 100644
index 0000000000..006ab3ae90
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php
@@ -0,0 +1,110 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/migrations/appwrite')
+ ->desc('Create Appwrite migration')
+ ->groups(['api', 'migrations'])
+ ->label('scope', 'migrations.write')
+ ->label('event', 'migrations.[migrationId].create')
+ ->label('audits.event', 'migration.create')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'createAppwriteMigration',
+ description: '/docs/references/migrations/migration-appwrite.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_ACCEPTED,
+ model: Response::MODEL_MIGRATION,
+ )
+ ]
+ ))
+ ->param('resources', [], new ArrayList(new WhiteList(AppwriteSource::getSupportedResources())), 'List of resources to migrate')
+ ->param('endpoint', '', new URL(), 'Source Appwrite endpoint')
+ ->param('projectId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Source Project ID', false, ['dbForProject'])
+ ->param('apiKey', '', new Text(512), 'Source API Key')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('project')
+ ->inject('platform')
+ ->inject('queueForEvents')
+ ->inject('publisherForMigrations')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ array $resources,
+ string $endpoint,
+ string $projectId,
+ string $apiKey,
+ Response $response,
+ Database $dbForProject,
+ Document $project,
+ array $platform,
+ Event $queueForEvents,
+ MigrationPublisher $publisherForMigrations
+ ): void {
+ $migration = $dbForProject->createDocument('migrations', new Document([
+ '$id' => ID::unique(),
+ 'status' => 'pending',
+ 'stage' => 'init',
+ 'source' => AppwriteSource::getName(),
+ 'destination' => AppwriteSource::getName(),
+ 'credentials' => [
+ 'endpoint' => $endpoint,
+ 'projectId' => $projectId,
+ 'apiKey' => $apiKey,
+ ],
+ 'resources' => $resources,
+ 'statusCounters' => '{}',
+ 'resourceData' => '{}',
+ 'errors' => [],
+ ]));
+
+ $queueForEvents->setParam('migrationId', $migration->getId());
+
+ $publisherForMigrations->enqueue(new MigrationMessage(
+ project: $project,
+ migration: $migration,
+ platform: $platform,
+ ));
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($migration, Response::MODEL_MIGRATION);
+ }
+}
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..5cc21241c3
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php
@@ -0,0 +1,220 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/migrations/csv/imports')
+ ->httpAlias('/v1/migrations/csv')
+ ->desc('Import documents from a CSV')
+ ->groups(['api', 'migrations'])
+ ->label('scope', 'migrations.write')
+ ->label('event', 'migrations.[migrationId].create')
+ ->label('audits.event', 'migration.create')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'createCSVImport',
+ description: '/docs/references/migrations/migration-csv-import.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_ACCEPTED,
+ model: Response::MODEL_MIGRATION,
+ )
+ ]
+ ))
+ ->param('bucketId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).', false, ['dbForProject'])
+ ->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File ID.', false, ['dbForProject'])
+ ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.')
+ ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->inject('project')
+ ->inject('platform')
+ ->inject('deviceForFiles')
+ ->inject('deviceForMigrations')
+ ->inject('queueForEvents')
+ ->inject('publisherForMigrations')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $bucketId,
+ string $fileId,
+ string $resourceId,
+ bool $internalFile,
+ Response $response,
+ Database $dbForProject,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ Document $project,
+ array $platform,
+ Device $deviceForFiles,
+ Device $deviceForMigrations,
+ Event $queueForEvents,
+ MigrationPublisher $publisherForMigrations
+ ): void {
+ $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) {
+ if ($internalFile) {
+ return $dbForPlatform->getDocument('buckets', 'default');
+ }
+ return $dbForProject->getDocument('buckets', $bucketId);
+ });
+
+ if ($bucket->isEmpty()) {
+ throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
+ }
+
+ $file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId));
+ if ($file->isEmpty()) {
+ throw new Exception(Exception::STORAGE_FILE_NOT_FOUND);
+ }
+
+ $path = $file->getAttribute('path', '');
+ if (!$deviceForFiles->exists($path)) {
+ throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path);
+ }
+
+ // No encryption or compression on files above 20MB.
+ $hasEncryption = !empty($file->getAttribute('openSSLCipher'));
+ $compression = $file->getAttribute('algorithm', Compression::NONE);
+ $hasCompression = $compression !== Compression::NONE;
+
+ $migrationId = ID::unique();
+ $newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.csv');
+
+ if ($hasEncryption || $hasCompression) {
+ $source = $deviceForFiles->read($path);
+
+ if ($hasEncryption) {
+ $source = OpenSSL::decrypt(
+ $source,
+ $file->getAttribute('openSSLCipher'),
+ System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')),
+ 0,
+ hex2bin($file->getAttribute('openSSLIV')),
+ hex2bin($file->getAttribute('openSSLTag'))
+ );
+ }
+
+ if ($hasCompression) {
+ switch ($compression) {
+ case Compression::ZSTD:
+ $source = (new Zstd())->decompress($source);
+ break;
+ case Compression::GZIP:
+ $source = (new GZIP())->decompress($source);
+ break;
+ }
+ }
+
+ // Manual write after decryption and/or decompression
+ if (!$deviceForMigrations->write($newPath, $source, 'text/csv')) {
+ throw new \Exception('Unable to copy file');
+ }
+ } elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) {
+ throw new \Exception('Unable to copy file');
+ }
+
+ [$databaseId] = \explode(':', $resourceId, 2);
+ $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
+ $databaseType = $database->getAttribute('type');
+ if (!\in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) {
+ throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv');
+ }
+ $fileSize = $deviceForMigrations->getFileSize($newPath);
+ $resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]);
+ $resourceType = self::resourceTypeForDatabaseType($databaseType);
+
+ $migration = $dbForProject->createDocument('migrations', new Document([
+ '$id' => $migrationId,
+ 'status' => 'pending',
+ 'stage' => 'init',
+ 'source' => CSV::getName(),
+ 'destination' => AppwriteSource::getName(),
+ 'resources' => $resources,
+ 'resourceId' => $resourceId,
+ 'resourceType' => $resourceType,
+ 'statusCounters' => '{}',
+ 'resourceData' => '{}',
+ 'errors' => [],
+ 'options' => [
+ 'path' => $newPath,
+ 'size' => $fileSize,
+ ],
+ ]));
+
+ $queueForEvents->setParam('migrationId', $migration->getId());
+
+ $publisherForMigrations->enqueue(new MigrationMessage(
+ project: $project,
+ migration: $migration,
+ ));
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($migration, Response::MODEL_MIGRATION);
+ }
+
+ private static function transferGroupForDatabaseType(string $databaseType): string
+ {
+ return match ($databaseType) {
+ DATABASE_TYPE_LEGACY,
+ DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB,
+ DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB,
+ DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB,
+ default => throw new \LogicException('Unknown database type: ' . $databaseType),
+ };
+ }
+
+ private static function resourceTypeForDatabaseType(string $databaseType): string
+ {
+ return match ($databaseType) {
+ DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB,
+ DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB,
+ default => Resource::TYPE_DATABASE,
+ };
+ }
+}
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..55081b2645
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php
@@ -0,0 +1,221 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/migrations/json/imports')
+ ->desc('Import documents from a JSON')
+ ->groups(['api', 'migrations'])
+ ->label('scope', 'migrations.write')
+ ->label('event', 'migrations.[migrationId].create')
+ ->label('audits.event', 'migration.create')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'createJSONImport',
+ description: '/docs/references/migrations/migration-json-import.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_ACCEPTED,
+ model: Response::MODEL_MIGRATION,
+ )
+ ]
+ ))
+ ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).')
+ ->param('fileId', '', new UID(), 'File ID.')
+ ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.')
+ ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->inject('project')
+ ->inject('platform')
+ ->inject('deviceForFiles')
+ ->inject('deviceForMigrations')
+ ->inject('queueForEvents')
+ ->inject('publisherForMigrations')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $bucketId,
+ string $fileId,
+ string $resourceId,
+ bool $internalFile,
+ Response $response,
+ Database $dbForProject,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ Document $project,
+ array $platform,
+ Device $deviceForFiles,
+ Device $deviceForMigrations,
+ Event $queueForEvents,
+ MigrationPublisher $publisherForMigrations
+ ): void {
+ $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) {
+ if ($internalFile) {
+ return $dbForPlatform->getDocument('buckets', 'default');
+ }
+ return $dbForProject->getDocument('buckets', $bucketId);
+ });
+
+ if ($bucket->isEmpty()) {
+ throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
+ }
+
+ $file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId));
+ if ($file->isEmpty()) {
+ throw new Exception(Exception::STORAGE_FILE_NOT_FOUND);
+ }
+
+ $path = $file->getAttribute('path', '');
+ if (!$deviceForFiles->exists($path)) {
+ throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path);
+ }
+
+ // No encryption or compression on files above 20MB.
+ $hasEncryption = !empty($file->getAttribute('openSSLCipher'));
+ $compression = $file->getAttribute('algorithm', Compression::NONE);
+ $hasCompression = $compression !== Compression::NONE;
+
+ $migrationId = ID::unique();
+ $newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.json');
+
+ if ($hasEncryption || $hasCompression) {
+ $source = $deviceForFiles->read($path);
+
+ if ($hasEncryption) {
+ $source = OpenSSL::decrypt(
+ $source,
+ $file->getAttribute('openSSLCipher'),
+ System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')),
+ 0,
+ hex2bin($file->getAttribute('openSSLIV')),
+ hex2bin($file->getAttribute('openSSLTag'))
+ );
+ }
+
+ if ($hasCompression) {
+ switch ($compression) {
+ case Compression::ZSTD:
+ $source = (new Zstd())->decompress($source);
+ break;
+ case Compression::GZIP:
+ $source = (new GZIP())->decompress($source);
+ break;
+ }
+ }
+
+ // Manual write after decryption and/or decompression
+ if (!$deviceForMigrations->write($newPath, $source, 'application/json')) {
+ throw new \Exception('Unable to copy file');
+ }
+ } elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) {
+ throw new \Exception('Unable to copy file');
+ }
+
+ $fileSize = $deviceForMigrations->getFileSize($newPath);
+
+ [$databaseId] = \explode(':', $resourceId, 2);
+ $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
+ if ($database->isEmpty()) {
+ throw new Exception(Exception::DATABASE_NOT_FOUND);
+ }
+ $databaseType = $database->getAttribute('type');
+ $resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]);
+ $resourceType = self::resourceTypeForDatabaseType($databaseType);
+
+ $migration = $dbForProject->createDocument('migrations', new Document([
+ '$id' => $migrationId,
+ 'status' => 'pending',
+ 'stage' => 'init',
+ 'source' => JSONSource::getName(),
+ 'destination' => AppwriteSource::getName(),
+ 'resources' => $resources,
+ 'resourceId' => $resourceId,
+ 'resourceType' => $resourceType,
+ 'statusCounters' => '{}',
+ 'resourceData' => '{}',
+ 'errors' => [],
+ 'options' => [
+ 'path' => $newPath,
+ 'size' => $fileSize,
+ ],
+ ]));
+
+ $queueForEvents->setParam('migrationId', $migration->getId());
+
+ $publisherForMigrations->enqueue(new MigrationMessage(
+ project: $project,
+ migration: $migration,
+ platform: $platform,
+ ));
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($migration, Response::MODEL_MIGRATION);
+ }
+
+ private static function transferGroupForDatabaseType(string $databaseType): string
+ {
+ return match ($databaseType) {
+ DATABASE_TYPE_LEGACY,
+ DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB,
+ DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB,
+ DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB,
+ default => throw new \LogicException('Unknown database type: ' . $databaseType),
+ };
+ }
+
+ private static function resourceTypeForDatabaseType(string $databaseType): string
+ {
+ return match ($databaseType) {
+ DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB,
+ DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB,
+ default => Resource::TYPE_DATABASE,
+ };
+ }
+}
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/Project/Http/Project/AuthMethods/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php
new file mode 100644
index 0000000000..0d1cd83203
--- /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. Use this endpoint to enable or disable a given auth method for this project.')
+ ->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..0a60e4ce4d
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php
@@ -0,0 +1,81 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/project')
+ ->httpAlias('/v1/projects/:projectId')
+ ->desc('Delete project')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.write')
+ ->label('event', 'project.delete')
+ ->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('queueForDeletes')
+ ->inject('authorization')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ Response $response,
+ Database $dbForPlatform,
+ DeleteQueue $queueForDeletes,
+ Authorization $authorization,
+ Document $project,
+ ) {
+ $queueForDeletes
+ ->setProject($project)
+ ->setType(DELETE_TYPE_DOCUMENT)
+ ->setDocument($project);
+
+ if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('projects', $project->getId()))) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove project from DB');
+ }
+
+ $response->noContent();
+ }
+}
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)
+ ->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..ae46a59c67
--- /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('provider', '', new Text(128), 'OAuth2 provider key. For example: github, google, apple.')
+ ->inject('response')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $provider,
+ Response $response,
+ Document $project,
+ ): void {
+ $providers = Config::getParam('oAuthProviders', []);
+ if (!\array_key_exists($provider, $providers) || !($providers[$provider]['enabled'] ?? false)) {
+ throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED);
+ }
+
+ $actions = Base::getProviderActions();
+ if (!isset($actions[$provider])) {
+ throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED);
+ }
+
+ $updateClass = $actions[$provider];
+ $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..9b985f4aed
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php
@@ -0,0 +1,55 @@
+ '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)
+ ->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)
+ ->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: <<inject('response')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ Response $response,
+ Document $project,
+ ): void {
+ $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);
+ }
+
+ $response->dynamic(new Document([
+ 'total' => \count($documents),
+ '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 89%
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 71c20faca7..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,6 +1,6 @@
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.[protocolId].update')
@@ -40,9 +41,9 @@ class Update extends Action
->label('sdk', new Method(
namespace: 'project',
group: null,
- name: 'updateProtocolStatus',
+ name: 'updateProtocol',
description: <<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('queueForMails')
+ ->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,
+ Mail $queueForMails,
+ 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) {
+ $queueForMails
+ ->setSmtpHost($host)
+ ->setSmtpPort($port)
+ ->setSmtpUsername($username)
+ ->setSmtpPassword($password)
+ ->setSmtpSecure($secure)
+ ->setSmtpReplyToEmail($replyToEmail)
+ ->setSmtpReplyToName($replyToName)
+ ->setSmtpSenderEmail($senderEmail)
+ ->setSmtpSenderName($senderName)
+ ->setRecipient($email)
+ ->setName('')
+ ->setBodyTemplate(APP_CE_CONFIG_DIR . '/locale/templates/email-base-styled.tpl')
+ ->setBody($template->render())
+ ->setVariables([])
+ ->setSubject($subject)
+ ->trigger();
+ }
+
+ $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..97e723f52c
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php
@@ -0,0 +1,173 @@
+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)), 'SMTP server username. Leave empty for no authorization.', optional: true)
+ ->param('password', null, new Nullable(new Text(256)), 'SMTP server password. Leave empty for no authorization. This property is stored securely and cannot be read in future (write-only).', optional: true)
+ ->param('senderEmail', null, new Nullable(new Email()), 'Email address shown in inbox as the sender of the email.', optional: true)
+ ->param('senderName', null, new Nullable(new Text(256)), 'Name shown in inbox as the sender of the email.', optional: true)
+ ->param('replyToEmail', null, new Nullable(new Email()), 'Email used when user replies to the email.', optional: true)
+ ->param('replyToName', null, new Nullable(new Text(256)), 'Name used when user replies to the email.', 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
+ $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) {
+ $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 89%
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 f3d9654789..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,6 +1,6 @@
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.[serviceId].update')
@@ -40,9 +41,9 @@ class Update extends Action
->label('sdk', new Method(
namespace: 'project',
group: null,
- name: 'updateServiceStatus',
+ name: 'updateService',
description: <<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..ef93abf683
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php
@@ -0,0 +1,144 @@
+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()), 'Email of the sender.', optional: true)
+ ->param('replyToEmail', null, new Nullable(new Email()), 'Reply to email.', 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
+ $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/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php
index a2c94928e2..609de96530 100644
--- a/src/Appwrite/Platform/Modules/Project/Services/Http.php
+++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php
@@ -3,12 +3,64 @@
namespace Appwrite\Platform\Modules\Project\Services;
use Appwrite\Platform\Modules\Project\Http\Init;
+use Appwrite\Platform\Modules\Project\Http\Project\AuthMethods\Update as UpdateAuthMethod;
+use Appwrite\Platform\Modules\Project\Http\Project\Delete as DeleteProject;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey;
+use Appwrite\Platform\Modules\Project\Http\Project\Keys\Ephemeral\Create as CreateEphemeralKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\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 +74,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 +109,19 @@ class Http extends Service
$this->addAction(Init::getName(), new Init());
// Project
+ $this->addAction(DeleteProject::getName(), new DeleteProject());
$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,6 +132,7 @@ 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());
@@ -73,5 +152,74 @@ class Http extends Service
$this->addAction(CreateLinuxPlatform::getName(), new CreateLinuxPlatform());
$this->addAction(GetPlatform::getName(), new GetPlatform());
$this->addAction(ListPlatforms::getName(), new ListPlatforms());
+
+ // 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 9070962e7d..363c99dc1f 100644
--- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php
+++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php
@@ -21,8 +21,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;
@@ -109,7 +107,7 @@ class Create extends Action
$auth = Config::getParam('auth', []);
$auths = [
'limit' => 0,
- 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT,
+ 'maxSessions' => 0,
'passwordHistory' => 0,
'passwordDictionary' => false,
'duration' => TOKEN_EXPIRATION_LOGIN_LONG,
@@ -122,6 +120,8 @@ class Create extends Action
'membershipsUserName' => false,
'membershipsUserEmail' => false,
'membershipsMfa' => false,
+ 'membershipsUserId' => false,
+ 'membershipsUserPhone' => false,
'invalidateSessions' => true
];
@@ -209,32 +209,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;
@@ -244,27 +228,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'] ?? [];
@@ -279,37 +247,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/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php
index 8e420e87f2..0d2a951388 100644
--- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php
+++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php
@@ -109,7 +109,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..8baf54c790 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Action.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Action.php
@@ -164,9 +164,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/Redirect/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php
index 8a265ba5bb..5964a20772 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php
@@ -84,7 +84,8 @@ class Create extends Action
$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()) {
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php
index 8a6964209f..71ea5ceb2f 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php
@@ -177,15 +177,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
@@ -204,9 +197,14 @@ class Create extends Action
$metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)];
if (!$deployment->isEmpty()) {
$chunks = $deployment->getAttribute('sourceChunksTotal', 1);
+ $uploaded = $deployment->getAttribute('sourceChunksUploaded', 0);
$metadata = $deployment->getAttribute('sourceMetadata', []);
- if ($chunk === -1) {
- $chunk = $chunks;
+
+ if ($uploaded === $chunks) {
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($deployment, Response::MODEL_DEPLOYMENT);
+ return;
}
}
@@ -262,6 +260,8 @@ class Create extends Action
'sourcePath' => $path,
'sourceSize' => $fileSize,
'totalSize' => $fileSize,
+ 'sourceChunksTotal' => $chunks,
+ 'sourceChunksUploaded' => $chunksUploaded,
'activate' => $activate,
'sourceMetadata' => $metadata,
'type' => $type,
@@ -309,6 +309,7 @@ class Create extends Action
} else {
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
'sourceSize' => $fileSize,
+ 'sourceChunksUploaded' => $chunksUploaded,
'sourceMetadata' => $metadata,
]));
}
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/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php
index dd9bedffb5..3c0d090b7b 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php
@@ -164,10 +164,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');
}
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/Delete.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php
index 703806f1aa..d61c9892cf 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php
@@ -67,11 +67,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);
}
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..08cdd4ac38 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php
@@ -79,7 +79,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);
}
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..2ce5ef97f5 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php
@@ -204,15 +204,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;
}
/**
@@ -249,18 +242,15 @@ class Create extends Action
$uploaded = $file->getAttribute('chunksUploaded', 0);
$metadata = $file->getAttribute('metadata', []);
- if ($chunk === -1) {
- $chunk = $chunks;
- }
-
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;
+ if (empty($contentRange)) {
+ throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS);
+ }
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_OK)
+ ->dynamic($file, Response::MODEL_FILE);
+ return;
}
}
@@ -384,14 +374,11 @@ class Create extends Action
->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));
}
@@ -431,15 +418,11 @@ class Create extends Action
->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
+ * 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);
- }
-
try {
$file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file));
} catch (NotFoundException) {
@@ -468,8 +451,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/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php
index f0ee045214..4fa5006db8 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
@@ -54,7 +54,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',
@@ -200,7 +200,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);
@@ -243,7 +243,7 @@ class Get extends Action
$image->crop((int) $width, (int) $height, $gravity);
- if (!empty($opacity) || $opacity === 0) {
+ if (!empty($opacity)) {
$image->setOpacity($opacity);
}
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..e174029031 100644
--- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
+++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
@@ -324,7 +324,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 +343,8 @@ 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 = '';
if ($smtpEnabled) {
if (! empty($smtp['senderEmail'])) {
@@ -350,8 +353,13 @@ 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
@@ -368,8 +376,13 @@ 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'] ?? '';
@@ -377,7 +390,8 @@ class Create extends Action
}
$queueForMails
- ->setSmtpReplyTo($replyTo)
+ ->setSmtpReplyToEmail($replyToEmail)
+ ->setSmtpReplyToName($replyToName)
->setSmtpSenderEmail($senderEmail)
->setSmtpSenderName($senderName);
}
@@ -407,11 +421,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();
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/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..33d7e984fb 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php
@@ -107,7 +107,7 @@ trait Deployment
$activate = true;
}
- $owner = $github->getOwnerName($providerInstallationId) ?? '';
+ $owner = $github->getOwnerName($providerInstallationId);
try {
$repositoryName = $github->getRepositoryName($providerRepositoryId);
} catch (RepositoryNotFound $e) {
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..8ead94b7cb 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
@@ -73,9 +73,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,7 +83,7 @@ class XList extends Action
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
- $branches = $github->listBranches($owner, $repositoryName) ?? [];
+ $branches = $github->listBranches($owner, $repositoryName);
$response->dynamic(new Document([
'branches' => \array_map(function ($branch) {
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 6295fcd03b..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);
}
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 f5502a5986..7308dc003f 100644
--- a/src/Appwrite/Platform/Tasks/Interval.php
+++ b/src/Appwrite/Platform/Tasks/Interval.php
@@ -75,7 +75,6 @@ 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 [
[
@@ -135,50 +134,4 @@ class Interval extends Action
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);
- }
}
diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php
index 526ea304de..b1580f0e68 100644
--- a/src/Appwrite/Platform/Tasks/SDKs.php
+++ b/src/Appwrite/Platform/Tasks/SDKs.php
@@ -5,6 +5,7 @@ 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\CursorPlugin;
use Appwrite\SDK\Language\Dart;
@@ -181,7 +182,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 +452,9 @@ 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;
default:
throw new \Exception('Language "' . $language['key'] . '" not supported');
}
@@ -1146,7 +1150,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/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/ScheduleFunctions.php b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php
index f867884801..75908c99c7 100644
--- a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php
+++ b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php
@@ -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)");
@@ -128,9 +129,6 @@ class ScheduleFunctions extends ScheduleBase
$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/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 e5a7950945..f6b0345381 100644
--- a/src/Appwrite/Platform/Workers/Audits.php
+++ b/src/Appwrite/Platform/Workers/Audits.php
@@ -58,7 +58,7 @@ class Audits extends Action
*/
public function action(Message $message, callable $getAudit): Commit|NoCommit
{
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new Exception('Missing payload');
diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php
index 34234971d9..4d04a3c92c 100644
--- a/src/Appwrite/Platform/Workers/Certificates.php
+++ b/src/Appwrite/Platform/Workers/Certificates.php
@@ -94,7 +94,7 @@ class Certificates extends Action
array $plan,
ValidatorAuthorization $authorization,
): void {
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new Exception('Missing payload');
diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php
index f4978780a1..a5fe352b07 100644
--- a/src/Appwrite/Platform/Workers/Deletes.php
+++ b/src/Appwrite/Platform/Workers/Deletes.php
@@ -96,7 +96,7 @@ class Deletes extends Action
DeleteEvent $queueForDeletes,
callable $getAudit,
): void {
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new Exception('Missing payload');
@@ -304,7 +304,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 {
@@ -632,6 +633,89 @@ 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());
+ }
+
/**
* @var Database $dbForProject
*/
@@ -651,11 +735,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 +768,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 +807,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();
diff --git a/src/Appwrite/Platform/Workers/Executions.php b/src/Appwrite/Platform/Workers/Executions.php
index 99e20be035..404b04ce76 100644
--- a/src/Appwrite/Platform/Workers/Executions.php
+++ b/src/Appwrite/Platform/Workers/Executions.php
@@ -34,7 +34,7 @@ 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()) {
diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php
index 0899fbacb4..8167fb975d 100644
--- a/src/Appwrite/Platform/Workers/Functions.php
+++ b/src/Appwrite/Platform/Workers/Functions.php
@@ -68,7 +68,7 @@ class Functions extends Action
Executor $executor,
callable $isResourceBlocked
): void {
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new AppwriteException(
@@ -258,7 +258,7 @@ class Functions extends Action
jwt: $jwt,
event: null,
eventData: null,
- executionId: $execution->getId() ?? null
+ executionId: $execution->getId()
);
break;
}
@@ -434,10 +434,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'] = '';
@@ -488,12 +488,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']
]);
}
@@ -688,7 +688,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..03adebc4b5 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');
@@ -257,7 +257,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 +271,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..3fd86baea9 100644
--- a/src/Appwrite/Platform/Workers/Migrations.php
+++ b/src/Appwrite/Platform/Workers/Migrations.php
@@ -56,7 +56,7 @@ class Migrations extends Action
protected ?Device $deviceForFiles;
protected ?Document $project;
- protected Document $sourceProject;
+ protected ?Document $sourceProject = null;
/**
* @var callable
@@ -74,7 +74,6 @@ class Migrations extends Action
*/
protected array $sourceReport = [];
- private string $source;
/**
* @var callable|null
*/
@@ -130,7 +129,7 @@ class Migrations extends Action
array $plan,
Authorization $authorization,
): void {
- $migrationMessage = Migration::fromArray($message->getPayload() ?? []);
+ $migrationMessage = Migration::fromArray($message->getPayload());
$this->getDatabasesDB = $getDatabasesDB;
$this->getProjectDB = $getProjectDB;
@@ -195,9 +194,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('Source projectId is required for Appwrite migrations');
+ }
+
+ 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('Source project not found for provided projectId');
+ }
+
+ $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 +248,7 @@ class Migrations extends Action
$credentials['endpoint'],
$credentials['apiKey'],
$getDatabasesDB,
- SourceAppwrite::SOURCE_DATABASE,
+ $useAppwriteApiSource ? SourceAppwrite::SOURCE_API : SourceAppwrite::SOURCE_DATABASE,
$projectDB,
$queries
),
@@ -323,6 +338,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 +407,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;
}
/**
@@ -578,9 +609,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);
}
diff --git a/src/Appwrite/Platform/Workers/StatsResources.php b/src/Appwrite/Platform/Workers/StatsResources.php
index db214f5d32..2706d33e2a 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');
}
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..a7f4595966 100644
--- a/src/Appwrite/Platform/Workers/Webhooks.php
+++ b/src/Appwrite/Platform/Workers/Webhooks.php
@@ -57,7 +57,7 @@ class Webhooks extends Action
public function action(Message $message, Document $project, Database $dbForPlatform, Mail $queueForMails, UsagePublisher $publisherForUsage, Log $log, array $plan): void
{
$this->errors = [];
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
@@ -106,51 +106,47 @@ class Webhooks extends Action
$httpPass = $webhook->getAttribute('httpPass');
$ch = \curl_init($webhook->getAttribute('url'));
- try {
- \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
- \curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
- \curl_setopt($ch, CURLOPT_HEADER, 0);
- \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
- \curl_setopt($ch, CURLOPT_TIMEOUT, 15);
- \curl_setopt($ch, CURLOPT_MAXFILESIZE, self::MAX_FILE_SIZE);
- \curl_setopt($ch, CURLOPT_USERAGENT, \sprintf(
- APP_USERAGENT,
- System::getEnv('_APP_VERSION', 'UNKNOWN'),
- System::getEnv('_APP_EMAIL_SECURITY', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS', APP_EMAIL_SECURITY))
- ));
- \curl_setopt(
- $ch,
- CURLOPT_HTTPHEADER,
- [
- 'Content-Type: application/json',
- 'Content-Length: ' . \strlen($payload),
- 'X-' . APP_NAME . '-Webhook-Id: ' . $webhook->getId(),
- 'X-' . APP_NAME . '-Webhook-Events: ' . implode(',', $events),
- 'X-' . APP_NAME . '-Webhook-Name: ' . $webhook->getAttribute('name', ''),
- 'X-' . APP_NAME . '-Webhook-User-Id: ' . $user->getId(),
- 'X-' . APP_NAME . '-Webhook-Project-Id: ' . $project->getId(),
- 'X-' . APP_NAME . '-Webhook-Signature: ' . $signature,
- ]
- );
- \curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
+ \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
+ \curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
+ \curl_setopt($ch, CURLOPT_HEADER, 0);
+ \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
+ \curl_setopt($ch, CURLOPT_TIMEOUT, 15);
+ \curl_setopt($ch, CURLOPT_MAXFILESIZE, self::MAX_FILE_SIZE);
+ \curl_setopt($ch, CURLOPT_USERAGENT, \sprintf(
+ APP_USERAGENT,
+ System::getEnv('_APP_VERSION', 'UNKNOWN'),
+ System::getEnv('_APP_EMAIL_SECURITY', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS', APP_EMAIL_SECURITY))
+ ));
+ \curl_setopt(
+ $ch,
+ CURLOPT_HTTPHEADER,
+ [
+ 'Content-Type: application/json',
+ 'Content-Length: ' . \strlen($payload),
+ 'X-' . APP_NAME . '-Webhook-Id: ' . $webhook->getId(),
+ 'X-' . APP_NAME . '-Webhook-Events: ' . implode(',', $events),
+ 'X-' . APP_NAME . '-Webhook-Name: ' . $webhook->getAttribute('name', ''),
+ 'X-' . APP_NAME . '-Webhook-User-Id: ' . $user->getId(),
+ 'X-' . APP_NAME . '-Webhook-Project-Id: ' . $project->getId(),
+ 'X-' . APP_NAME . '-Webhook-Signature: ' . $signature,
+ ]
+ );
+ \curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
- if (!$webhook->getAttribute('security', true)) {
- \curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
- \curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
- }
-
- if (!empty($httpUser) && !empty($httpPass)) {
- \curl_setopt($ch, CURLOPT_USERPWD, "$httpUser:$httpPass");
- \curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
- }
-
- $responseBody = \curl_exec($ch);
- $curlError = \curl_error($ch);
- $statusCode = \curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
- } finally {
- \curl_close($ch);
+ if (!$webhook->getAttribute('security', true)) {
+ \curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
+ \curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
}
+ if (!empty($httpUser) && !empty($httpPass)) {
+ \curl_setopt($ch, CURLOPT_USERPWD, "$httpUser:$httpPass");
+ \curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
+ }
+
+ $responseBody = \curl_exec($ch);
+ $curlError = \curl_error($ch);
+ $statusCode = \curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+
if (!empty($curlError) || $statusCode >= 400) {
$dbForPlatform->increaseDocumentAttribute('webhooks', $webhook->getId(), 'attempts', 1);
$webhook = $dbForPlatform->getDocument('webhooks', $webhook->getId());
diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php
index ce1eb97203..30df5acf52 100644
--- a/src/Appwrite/SDK/Specification/Format.php
+++ b/src/Appwrite/SDK/Specification/Format.php
@@ -40,6 +40,9 @@ abstract class Format
'license.url' => '',
];
+ /**
+ * @var list, parameter: string, excludeKeys?: list, exclude?: bool}>
+ */
private const array OAUTH_PROVIDER_BLACKLIST = [
[
'namespace' => 'account',
@@ -67,6 +70,9 @@ abstract class Format
],
];
+ /**
+ * @var list, parameter: string, excludeKeys?: list, exclude?: bool}>
+ */
private const array PROVIDER_USAGE_BLACKLIST = [
[
'namespace' => 'users',
@@ -78,6 +84,9 @@ abstract class Format
],
];
+ /**
+ * @var list, parameter: string, required?: bool, nullable?: bool}>
+ */
private const array REQUEST_PARAMETER_OVERRIDES = [
[
'namespace' => 'project',
@@ -109,24 +118,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'],
@@ -751,6 +743,15 @@ abstract class Format
break;
case 'project':
switch ($method) {
+ case 'getEmailTemplate':
+ case 'updateEmailTemplate':
+ switch ($param) {
+ case 'templateId':
+ return 'EmailTemplateType';
+ case 'locale':
+ return 'EmailTemplateLocale';
+ }
+ break;
case 'getUsage':
switch ($param) {
case 'period':
@@ -763,7 +764,6 @@ abstract class Format
switch ($method) {
case 'getEmailTemplate':
case 'updateEmailTemplate':
- case 'deleteEmailTemplate':
switch ($param) {
case 'type':
return 'EmailTemplateType';
@@ -771,16 +771,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':
@@ -969,7 +959,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)
@@ -978,8 +968,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;
}
@@ -988,6 +982,14 @@ abstract class Format
return $config;
}
+ /**
+ * @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
{
if ($param === 'type' && \str_starts_with($model, 'platform') && $model !== 'platformList') {
diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php
index 3060a1a2fe..962bc8948a 100644
--- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php
+++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php
@@ -114,16 +114,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 +185,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 +291,7 @@ class OpenAPI3 extends Format
}
if (!(\is_array($model)) && $model->isNone()) {
- $temp['responses'][(string)$response->getCode() ?? '500'] = [
+ $temp['responses'][(string)$response->getCode()] = [
'description' => in_array($produces, [
'image/*',
'image/jpeg',
@@ -312,7 +312,7 @@ class OpenAPI3 extends Format
$usedModels[] = $m->getType();
}
- $temp['responses'][(string)$response->getCode() ?? '500'] = [
+ $temp['responses'][(string)$response->getCode()] = [
'description' => $modelDescription,
'content' => [
$produces => [
@@ -326,7 +326,7 @@ class OpenAPI3 extends Format
} 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 => [
@@ -339,9 +339,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']);
}
}
@@ -385,7 +385,7 @@ class OpenAPI3 extends Format
$isNullable = $validator instanceof Nullable;
$parameter = $this->getRequestParameterConfig(
- $sdk->getNamespace() ?? '',
+ $sdk->getNamespace(),
$methodName,
$name,
$param['optional'],
@@ -404,13 +404,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:
@@ -478,6 +474,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:
@@ -628,7 +625,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 = [];
@@ -644,7 +641,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)) {
@@ -652,7 +649,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();
@@ -682,7 +679,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 = [];
@@ -698,7 +695,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)) {
@@ -706,7 +703,7 @@ class OpenAPI3 extends Format
}
}
if ($validator->getType() === 'integer') {
- $node['schema']['format'] = $validator->getFormat() ?? 'int32';
+ $node['schema']['format'] = $validator->getFormat();
}
}
break;
@@ -783,25 +780,17 @@ 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;
}
diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php
index 8d47766117..d07d957577 100644
--- a/src/Appwrite/SDK/Specification/Format/Swagger2.php
+++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php
@@ -114,17 +114,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 +193,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 +298,7 @@ class Swagger2 extends Format
}
if (!(\is_array($model)) && $model->isNone()) {
- $temp['responses'][(string)$response->getCode() ?? '500'] = [
+ $temp['responses'][(string)$response->getCode()] = [
'description' => in_array($produces, [
'image/*',
'image/jpeg',
@@ -320,7 +320,7 @@ 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' => \array_filter([
'x-oneOf' => \array_map(function ($m) {
@@ -332,7 +332,7 @@ class Swagger2 extends Format
} 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(),
@@ -341,9 +341,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']);
}
}
@@ -387,7 +387,7 @@ class Swagger2 extends Format
$isNullable = $validator instanceof Nullable;
$parameter = $this->getRequestParameterConfig(
- $sdk->getNamespace() ?? '',
+ $sdk->getNamespace(),
$methodName,
$name,
$param['optional'],
@@ -406,13 +406,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:
@@ -471,6 +467,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:
@@ -624,7 +621,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();
@@ -672,7 +669,7 @@ class Swagger2 extends Format
}
}
if ($validator->getType() === 'integer') {
- $node['format'] = $validator->getFormat() ?? 'int32';
+ $node['format'] = $validator->getFormat();
}
}
break;
@@ -758,11 +755,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']) {
diff --git a/src/Appwrite/Utopia/Database/Validator/Attributes.php b/src/Appwrite/Utopia/Database/Validator/Attributes.php
index 34562a2536..54aaf135f9 100644
--- a/src/Appwrite/Utopia/Database/Validator/Attributes.php
+++ b/src/Appwrite/Utopia/Database/Validator/Attributes.php
@@ -189,13 +189,13 @@ class Attributes extends Validator
}
// 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;
}
@@ -349,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/Webhooks.php b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php
index 07e27f06cb..587ad58ea4 100644
--- a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php
+++ b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php
@@ -51,18 +51,25 @@ class Webhooks extends Base
*/
public function isValid($value): bool
{
- if (\is_array($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 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/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/Response.php b/src/Appwrite/Utopia/Response.php
index 8a4b5c8c0b..e02e70b3d8 100644
--- a/src/Appwrite/Utopia/Response.php
+++ b/src/Appwrite/Utopia/Response.php
@@ -253,9 +253,21 @@ class Response extends SwooleResponse
public const MODEL_WEBHOOK_LIST = 'webhookList';
public const MODEL_KEY = 'key';
public const MODEL_KEY_LIST = 'keyList';
+ public const MODEL_EPHEMERAL_KEY = 'ephemeralKey';
public const MODEL_DEV_KEY = 'devKey';
public const MODEL_DEV_KEY_LIST = 'devKeyList';
public const MODEL_MOCK_NUMBER = 'mockNumber';
+ 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';
@@ -267,8 +279,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';
@@ -281,6 +334,11 @@ class Response extends SwooleResponse
// 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';
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/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/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/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/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/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..3dbc892631
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Google.php
@@ -0,0 +1,47 @@
+ 'google',
+ ];
+
+ public function getProviderLabel(): string
+ {
+ return 'Google';
+ }
+
+ public function getClientIdExample(): string
+ {
+ return '120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com';
+ }
+
+ public function getClientSecretExample(): string
+ {
+ return 'GOCSPX-2k8gsR0000000000000000VNahJj';
+ }
+
+ /**
+ * 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..e4f0919666
--- /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/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php
index 4cb038fc37..36be3b751f 100644
--- a/src/Appwrite/Utopia/Response/Model/Project.php
+++ b/src/Appwrite/Utopia/Response/Model/Project.php
@@ -181,6 +181,18 @@ class Project extends Model
'default' => false,
'example' => true,
])
+ ->addRule('authMembershipsUserId', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'Whether or not to show user IDs in the teams membership response.',
+ 'default' => false,
+ 'example' => true,
+ ])
+ ->addRule('authMembershipsUserPhone', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'Whether or not to show user phone numbers in the teams membership response.',
+ 'default' => false,
+ 'example' => true,
+ ])
->addRule('authInvalidateSessions', [
'type' => self::TYPE_BOOLEAN,
'description' => 'Whether or not all existing sessions should be invalidated on password change',
@@ -247,7 +259,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' => '',
@@ -273,9 +291,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,
@@ -409,11 +427,12 @@ class Project extends Model
$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'] ?? '');
}
@@ -463,7 +482,7 @@ class Project extends Model
$document->setAttribute('authLimit', $authValues['limit'] ?? 0);
$document->setAttribute('authDuration', $authValues['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG);
- $document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT);
+ $document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? 0);
$document->setAttribute('authPasswordHistory', $authValues['passwordHistory'] ?? 0);
$document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false);
$document->setAttribute('authPersonalDataCheck', $authValues['personalDataCheck'] ?? false);
@@ -472,9 +491,11 @@ class Project extends Model
$document->setAttribute('authFreeEmails', $authValues['freeEmails'] ?? false);
$document->setAttribute('authMockNumbers', $authValues['mockNumbers'] ?? []);
$document->setAttribute('authSessionAlerts', $authValues['sessionAlerts'] ?? false);
- $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? true);
- $document->setAttribute('authMembershipsUserEmail', $authValues['membershipsUserEmail'] ?? true);
- $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? true);
+ $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? false);
+ $document->setAttribute('authMembershipsUserEmail', $authValues['membershipsUserEmail'] ?? false);
+ $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? false);
+ $document->setAttribute('authMembershipsUserId', $authValues['membershipsUserId'] ?? false);
+ $document->setAttribute('authMembershipsUserPhone', $authValues['membershipsUserPhone'] ?? false);
$document->setAttribute('authInvalidateSessions', $authValues['invalidateSessions'] ?? false);
foreach ($auth as $method) {
@@ -504,7 +525,7 @@ class Project extends Model
'key' => $key,
'name' => $provider['name'] ?? '',
'appId' => $providerValues[$key . 'Appid'] ?? '',
- 'secret' => $providerValues[$key . 'Secret'] ?? '',
+ 'secret' => '', // Write-only: never expose the stored value
'enabled' => $providerValues[$key . 'Enabled'] ?? false,
]);
}
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/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/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php
index 148b29c1d1..4dc0174e50 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',
@@ -148,6 +148,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 +196,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') {
@@ -245,7 +247,7 @@ 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);
diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php
index f899f06bad..eb74867c9c 100644
--- a/src/Executor/Executor.php
+++ b/src/Executor/Executor.php
@@ -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);
@@ -418,8 +417,6 @@ class Executor
throw new Exception($curlErrorMessage . ' with status code ' . $responseStatus, $responseStatus);
}
- curl_close($ch);
-
$responseHeaders['status-code'] = $responseStatus;
return [
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..f7bb54024d 100644
--- a/tests/benchmarks/http.js
+++ b/tests/benchmarks/http.js
@@ -1,34 +1,627 @@
+/*
+ * 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`, {
+ 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/UsageTest.php b/tests/e2e/General/UsageTest.php
index f6eb963967..4f557e8959 100644
--- a/tests/e2e/General/UsageTest.php
+++ b/tests/e2e/General/UsageTest.php
@@ -1605,8 +1605,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..99219ebf99 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,14 @@ 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',
],
]);
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 9f825c3c89..cd2c43381c 100644
--- a/tests/e2e/Services/Account/AccountConsoleClientTest.php
+++ b/tests/e2e/Services/Account/AccountConsoleClientTest.php
@@ -203,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 49f0c4c245..da788c3caa 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,
@@ -2050,6 +2051,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 +2137,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 +3365,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 +3696,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,
diff --git a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php
index 373383e3ec..c8f921f2ec 100644
--- a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php
+++ b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php
@@ -41,4 +41,138 @@ 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']);
+ }
}
diff --git a/tests/e2e/Services/Console/ConsoleCustomServerTest.php b/tests/e2e/Services/Console/ConsoleCustomServerTest.php
index 3748bbe546..f06011843f 100644
--- a/tests/e2e/Services/Console/ConsoleCustomServerTest.php
+++ b/tests/e2e/Services/Console/ConsoleCustomServerTest.php
@@ -24,4 +24,54 @@ 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']);
+ }
}
diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php
index f5f1d1864c..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',
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/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/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php
index ba518ee0b6..899c0ff71f 100644
--- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php
+++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php
@@ -567,6 +567,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 +700,7 @@ class FunctionsCustomServerTest extends Scope
$this->assertEquals(200, $function['headers']['status-code']);
$this->assertEquals($deploymentId, $function['body']['deploymentId']);
- // Test starter code is used and that dynamic keys work
+ // Test starter code is used and that ephemeral keys work
$execution = $this->createExecution($functionId, [
'path' => '/ping',
]);
@@ -1041,6 +1079,118 @@ 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 testUpdateDeployment(): void
{
$data = $this->setupTestDeployment();
@@ -2091,7 +2241,7 @@ class FunctionsCustomServerTest extends Scope
]);
$deploymentId = $this->setupDeployment($functionId, [
- 'code' => $this->packageFunction('dynamic-api-key'),
+ 'code' => $this->packageFunction('ephemeral-api-key'),
'activate' => true,
]);
diff --git a/tests/e2e/Services/GraphQL/FunctionsClientTest.php b/tests/e2e/Services/GraphQL/FunctionsClientTest.php
index 8dc2fe337f..ed436ad075 100644
--- a/tests/e2e/Services/GraphQL/FunctionsClientTest.php
+++ b/tests/e2e/Services/GraphQL/FunctionsClientTest.php
@@ -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..572fde49bf 100644
--- a/tests/e2e/Services/GraphQL/FunctionsServerTest.php
+++ b/tests/e2e/Services/GraphQL/FunctionsServerTest.php
@@ -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/StorageClientTest.php b/tests/e2e/Services/GraphQL/StorageClientTest.php
index 25041e843b..dd89819c34 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);
}
/**
diff --git a/tests/e2e/Services/GraphQL/StorageServerTest.php b/tests/e2e/Services/GraphQL/StorageServerTest.php
index cc4c8ecec3..1377ef9207 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
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/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php
index 76bedb74b5..0d38ef77d8 100644
--- a/tests/e2e/Services/Migrations/MigrationsBase.php
+++ b/tests/e2e/Services/Migrations/MigrationsBase.php
@@ -1302,6 +1302,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!
};
@@ -4222,7 +4223,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/Project/AuthMethodsBase.php b/tests/e2e/Services/Project/AuthMethodsBase.php
new file mode 100644
index 0000000000..afa58a3640
--- /dev/null
+++ b/tests/e2e/Services/Project/AuthMethodsBase.php
@@ -0,0 +1,337 @@
+ 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'],
+ ], $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());
+ }
+
+ 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..5451435c3c
--- /dev/null
+++ b/tests/e2e/Services/Project/OAuth2Base.php
@@ -0,0 +1,2611 @@
+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);
+ }
+
+ // =========================================================================
+ // 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 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
+ {
+ $response = $this->getOAuth2Provider('not-a-real-provider');
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('project_provider_unsupported', $response['body']['type']);
+ }
+
+ public function testGetOAuth2ProviderRegisteredInConfigButNoUpdateClass(): void
+ {
+ // `mock` is present in oAuthProviders config (enabled: true) but is NOT
+ // registered in Base::getProviderActions(). Get::action has two
+ // separate `unsupported` throw branches — testGetOAuth2ProviderUnsupported
+ // covers the first (provider missing from config); this covers the
+ // second (provider in config but missing from the action registry).
+ $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,
+ ]);
+ }
+
+ // =========================================================================
+ // 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,
+ ]);
+ }
+
+ // =========================================================================
+ // 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 $provider, 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/' . $provider,
+ $headers,
+ );
+ }
+
+ protected function listOAuth2Providers(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',
+ $headers,
+ );
+ }
+}
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',
+ ];
+
+ // 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..04906c6c2b
--- /dev/null
+++ b/tests/e2e/Services/Project/PoliciesBase.php
@@ -0,0 +1,1183 @@
+ ['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());
+ }
+
+ 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'],
+ ]);
+ }
+
+ 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,
+ ];
+
+ // 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..2d0e15a70f
--- /dev/null
+++ b/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php
@@ -0,0 +1,68 @@
+getProject()['$id'];
+ $apiKey = $this->getProject()['apiKey'];
+
+ $serverHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $apiKey,
+ ];
+
+ // "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..c2dfd7be5e
--- /dev/null
+++ b/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php
@@ -0,0 +1,152 @@
+getProject()['$id'];
+ $apiKey = $this->getProject()['apiKey'];
+
+ $serverHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $apiKey,
+ ];
+
+ // 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..3284fed16f
--- /dev/null
+++ b/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php
@@ -0,0 +1,104 @@
+getProject()['$id'];
+ $apiKey = $this->getProject()['apiKey'];
+
+ $serverHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $apiKey,
+ ];
+
+ $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..1500a1dcfa
--- /dev/null
+++ b/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php
@@ -0,0 +1,121 @@
+getProject()['$id'];
+ $apiKey = $this->getProject()['apiKey'];
+ $password = 'password1234';
+
+ $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,
+ ];
+
+ $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..71562f52a5
--- /dev/null
+++ b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php
@@ -0,0 +1,92 @@
+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,
+ ];
+
+ $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..c9de2be9a5
--- /dev/null
+++ b/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php
@@ -0,0 +1,119 @@
+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,
+ ];
+
+ $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..5ddcd8aaa1
--- /dev/null
+++ b/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php
@@ -0,0 +1,87 @@
+getProject()['$id'];
+ $apiKey = $this->getProject()['apiKey'];
+
+ $serverHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $apiKey,
+ ];
+
+ $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']);
+ }
+
+ 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..f828994ea3 100644
--- a/tests/e2e/Services/Project/ProtocolsBase.php
+++ b/tests/e2e/Services/Project/ProtocolsBase.php
@@ -241,6 +241,33 @@ 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'],
+ ], $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
@@ -254,7 +281,7 @@ trait ProtocolsBase
$headers = array_merge($headers, $this->getHeaders());
}
- 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..748fb3502b
--- /dev/null
+++ b/tests/e2e/Services/Project/SMTPBase.php
@@ -0,0 +1,1153 @@
+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
+ {
+ $response = $this->updateSMTP(
+ senderName: '',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ public function testUpdateSMTPEmptySenderEmail(): void
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: '',
+ host: 'maildev',
+ port: 1025,
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ 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 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
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ username: '',
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ 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
+ {
+ $response = $this->updateSMTP(
+ senderName: 'Test',
+ senderEmail: 'sender@example.com',
+ host: 'maildev',
+ port: 1025,
+ password: '',
+ );
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ }
+
+ 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'],
+ ], $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
@@ -252,7 +279,7 @@ trait ServicesBase
$headers = array_merge($headers, $this->getHeaders());
}
- 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..b240c945b3
--- /dev/null
+++ b/tests/e2e/Services/Project/TemplatesBase.php
@@ -0,0 +1,1170 @@
+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 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);
+ }
+
+ 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 @@
+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 7b9848e38f..6936de9aff 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;
@@ -831,49 +830,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
@@ -971,7 +927,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']);
// Check the project
@@ -987,7 +944,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']);
/**
@@ -1121,6 +1079,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 +1088,45 @@ 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 */
+ $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}}',
+ '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 +1146,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 +1156,223 @@ 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'],
+ ], $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'],
+ ], $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'],
+ ], $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
@@ -1219,6 +1395,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 +1463,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 +1484,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,
]);
@@ -1362,6 +1541,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,
]);
@@ -1378,6 +1558,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,
]);
@@ -1453,7 +1634,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;
@@ -1583,6 +1764,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,
]);
@@ -1679,6 +1861,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 +1879,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 +1958,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 +1990,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 +2068,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 +2081,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 +2161,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,
]);
@@ -2258,6 +2422,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,
]);
@@ -2315,6 +2480,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,
]);
@@ -2328,6 +2494,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,
]);
@@ -2347,6 +2514,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,
]);
@@ -2459,6 +2627,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,
]);
@@ -2467,120 +2636,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([
@@ -3897,6 +3952,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'],
@@ -3904,6 +3960,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'];
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/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 ca07d45f46..813ef70ff0 100644
--- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php
+++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php
@@ -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']);
@@ -3828,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();
@@ -4276,7 +4322,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(8, $response['data']['channels']);
+ $this->assertCount(16, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $rowId, $response['data']['channels']);
$this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']);
@@ -4333,7 +4379,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(8, $response['data']['channels']);
+ $this->assertCount(16, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -4401,7 +4447,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(8, $response['data']['channels']);
+ $this->assertCount(16, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains('rows', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['channels']);
@@ -4472,7 +4518,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(8, $response['data']['channels']);
+ $this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']);
@@ -4518,7 +4564,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(8, $response['data']['channels']);
+ $this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']);
@@ -4582,7 +4628,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(8, $response['data']['channels']);
+ $this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
@@ -4624,7 +4670,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(8, $response['data']['channels']);
+ $this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
@@ -4666,7 +4712,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(8, $response['data']['channels']);
+ $this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
@@ -4717,7 +4763,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(8, $response['data']['channels']);
+ $this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
@@ -4760,7 +4806,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(8, $response['data']['channels']);
+ $this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
@@ -4789,7 +4835,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(8, $response['data']['channels']);
+ $this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
@@ -4836,7 +4882,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(8, $response['data']['channels']);
+ $this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']);
@@ -4957,7 +5003,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
+ $this->assertCount(6, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']);
$this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']);
@@ -4992,7 +5038,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
+ $this->assertCount(6, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -5036,7 +5082,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
+ $this->assertCount(6, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -5080,7 +5126,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
+ $this->assertCount(6, $response['data']['channels']);
$this->assertNotEmpty($response['data']['payload']);
$this->assertIsArray($response['data']['payload']);
$this->assertArrayHasKey('$id', $response['data']['payload']);
@@ -5098,7 +5144,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
+ $this->assertCount(6, $response['data']['channels']);
$this->assertNotEmpty($response['data']['payload']);
$this->assertIsArray($response['data']['payload']);
$this->assertArrayHasKey('$id', $response['data']['payload']);
@@ -5133,7 +5179,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
+ $this->assertCount(6, $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
$this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']);
$this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
@@ -5161,7 +5207,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
+ $this->assertCount(6, $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
$this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']);
$this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
@@ -5189,7 +5235,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
+ $this->assertCount(6, $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
$this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']);
$this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
@@ -5226,7 +5272,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
+ $this->assertCount(6, $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
$this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']);
$this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
@@ -5255,7 +5301,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
+ $this->assertCount(6, $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
$this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']);
$this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
@@ -5284,7 +5330,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
+ $this->assertCount(6, $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
$this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']);
$this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
@@ -5331,7 +5377,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
+ $this->assertCount(6, $response['data']['channels']);
$this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']);
$this->assertContains("documentsdb.*.collections.*.documents.*.upsert", $response['data']['events']);
@@ -5436,7 +5482,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
// vectorsdb channels should include 3 items like documentsdb
- $this->assertCount(3, $response['data']['channels']);
+ $this->assertCount(6, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']);
$this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']);
@@ -5467,7 +5513,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
+ $this->assertCount(6, $response['data']['channels']);
$this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']);
$this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']);
$this->assertNotEmpty($response['data']['payload']);
@@ -5486,7 +5532,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
+ $this->assertCount(6, $response['data']['channels']);
$this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']);
$this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']);
@@ -5525,7 +5571,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
+ $this->assertCount(6, $response['data']['channels']);
$this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $response['data']['payload']['$id'] . '.create', $response['data']['events']);
$this->assertContains('vectorsdb.*.collections.*.documents.*.create', $response['data']['events']);
$this->assertContains('vectorsdb.' . $databaseId . '.collections.*.documents.*.create', $response['data']['events']);
@@ -5540,7 +5586,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
+ $this->assertCount(6, $response['data']['channels']);
$this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $response['data']['payload']['$id'] . '.create', $response['data']['events']);
$client->close();
@@ -5643,7 +5689,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(8, $response['data']['channels']);
+ $this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']);
$this->assertNotEmpty($response['data']['payload']);
@@ -5655,7 +5701,7 @@ class RealtimeCustomClientTest extends Scope
$client->receive();
$this->fail('Should not receive duplicate event');
} catch (TimeoutException $e) {
- $this->assertTrue(true);
+ $this->addToAssertionCount(1);
}
// Test Document Decrement
@@ -5674,7 +5720,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(8, $response['data']['channels']);
+ $this->assertCount(16, $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']);
$this->assertNotEmpty($response['data']['payload']);
@@ -5686,7 +5732,7 @@ class RealtimeCustomClientTest extends Scope
$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..7d9257c699 100644
--- a/tests/e2e/Services/Sites/SitesCustomServerTest.php
+++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php
@@ -801,8 +801,6 @@ class SitesCustomServerTest extends Scope
'siteId' => ID::unique()
]);
- $this->assertNotNull($siteId);
-
/**
* Test for SUCCESS
*/
@@ -868,6 +866,174 @@ 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 testCreateDeployment()
{
$siteId = $this->setupSite([
@@ -881,8 +1047,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 +1107,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 +1157,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 +1200,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 +1401,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 +1450,6 @@ class SitesCustomServerTest extends Scope
'siteId' => ID::unique()
]);
- $this->assertNotNull($siteId);
-
/**
* Test for SUCCESS
*/
@@ -1383,8 +1537,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 +1579,6 @@ class SitesCustomServerTest extends Scope
'siteId' => ID::unique()
]);
- $this->assertNotNull($siteId);
-
$site = $this->deleteSite($siteId);
$this->assertEquals(204, $site['headers']['status-code']);
@@ -2016,7 +2166,7 @@ class SitesCustomServerTest extends Scope
'previewAuthDisabled' => true,
]);
$response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [
- 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey,
+ 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertStringContainsString("Hello Appwrite", $response['body']);
@@ -2024,7 +2174,7 @@ class SitesCustomServerTest extends Scope
$this->assertGreaterThan($contentLength, $response['headers']['content-length']);
$response = $proxyClient->call(Client::METHOD_GET, '/non-existing-path', followRedirects: false, headers: [
- 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey,
+ 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey,
]);
$this->assertEquals(404, $response['headers']['status-code']);
$this->assertStringContainsString("Page not found", $response['body']);
@@ -2860,7 +3010,7 @@ class SitesCustomServerTest extends Scope
]);
$response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [
- 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey,
+ 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey,
]);
$this->assertEquals(400, $response['headers']['status-code']);
$deployment = $this->getDeployment($siteId, $deploymentId);
@@ -2902,7 +3052,7 @@ class SitesCustomServerTest extends Scope
// deployment is still building error page
$response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [
- 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey,
+ 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey,
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertStringContainsString("Deployment is still building", $response['body']);
@@ -2917,7 +3067,7 @@ class SitesCustomServerTest extends Scope
// deployment failed error page
$response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [
- 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey,
+ 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey,
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertStringContainsString("Deployment build failed", $response['body']);
diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php
index d1cb548016..29f7d70435 100644
--- a/tests/e2e/Services/Storage/StorageBase.php
+++ b/tests/e2e/Services/Storage/StorageBase.php
@@ -1050,6 +1050,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 +1091,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 +1227,153 @@ 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 testDeleteBucketFile(): void
{
// Create a fresh file just for deletion testing (not using cache since we delete it)
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/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/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/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..bf901bbe43 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,270 @@ class MessagingTest extends TestCase
$this->assertContains(Role::any()->toString(), $result['roles']);
$this->assertContains(Role::team('123abc')->toString(), $result['roles']);
}
+ public function testFromPayloadEmitsActionSuffixedChannels(): void
+ {
+ $result = Realtime::fromPayload(
+ event: 'databases.database_id.collections.collection_id.documents.document_id.create',
+ payload: new Document([
+ '$id' => ID::custom('document_id'),
+ '$collection' => ID::custom('collection_id'),
+ '$collectionId' => 'collection_id',
+ '$permissions' => [Permission::read(Role::any())],
+ ]),
+ database: new Document(['$id' => ID::custom('database_id')]),
+ collection: new Document([
+ '$id' => ID::custom('collection_id'),
+ '$permissions' => [Permission::read(Role::any())],
+ ])
+ );
+
+ // Base channels remain.
+ $this->assertContains('documents', $result['channels']);
+ $this->assertContains('databases.database_id.collections.collection_id.documents', $result['channels']);
+ $this->assertContains('databases.database_id.collections.collection_id.documents.document_id', $result['channels']);
+
+ // Action-suffixed variants are appended for every base channel.
+ $this->assertContains('documents.create', $result['channels']);
+ $this->assertContains('databases.database_id.collections.collection_id.documents.create', $result['channels']);
+ $this->assertContains('databases.database_id.collections.collection_id.documents.document_id.create', $result['channels']);
+
+ // No mismatched action suffixes leak in.
+ $this->assertNotContains('documents.update', $result['channels']);
+ $this->assertNotContains('documents.delete', $result['channels']);
+ }
+
+ public function testFromPayloadEmitsActionSuffixForEveryAction(): void
+ {
+ foreach (['create', 'update', 'upsert', 'delete'] as $action) {
+ $result = Realtime::fromPayload(
+ event: "databases.database_id.collections.collection_id.documents.document_id.{$action}",
+ payload: new Document([
+ '$id' => ID::custom('document_id'),
+ '$collection' => ID::custom('collection_id'),
+ '$collectionId' => 'collection_id',
+ '$permissions' => [Permission::read(Role::any())],
+ ]),
+ database: new Document(['$id' => ID::custom('database_id')]),
+ collection: new Document([
+ '$id' => ID::custom('collection_id'),
+ '$permissions' => [Permission::read(Role::any())],
+ ])
+ );
+
+ $this->assertContains("documents.{$action}", $result['channels'], "documents.{$action} missing");
+ $this->assertContains(
+ "databases.database_id.collections.collection_id.documents.document_id.{$action}",
+ $result['channels'],
+ "specific-doc {$action} channel missing"
+ );
+ }
+ }
+
+ public function testFromPayloadDoesNotSuffixWhenNoAction(): void
+ {
+ // Synthetic event without an action segment: e.g. an attribute event whose
+ // last segment is not a known action and whose second-to-last segment is
+ // also not a known action.
+ $result = Realtime::fromPayload(
+ event: 'buckets.bucket_id.files.file_id.update',
+ payload: new Document([
+ '$id' => ID::custom('file_id'),
+ 'bucketId' => 'bucket_id',
+ '$permissions' => [Permission::read(Role::any())],
+ ]),
+ bucket: new Document([
+ '$id' => ID::custom('bucket_id'),
+ '$permissions' => [Permission::read(Role::any())],
+ ])
+ );
+
+ // Action-suffixed variants for the file event.
+ $this->assertContains('files.update', $result['channels']);
+ $this->assertContains('buckets.bucket_id.files.update', $result['channels']);
+ $this->assertContains('buckets.bucket_id.files.file_id.update', $result['channels']);
+
+ // Base channels remain.
+ $this->assertContains('files', $result['channels']);
+ $this->assertContains('buckets.bucket_id.files', $result['channels']);
+ $this->assertContains('buckets.bucket_id.files.file_id', $result['channels']);
+ }
+
+ public function testFromPayloadDoesNotSuffixAdminChannels(): void
+ {
+ // Function execution event emits resource-leaf channels (executions / functions)
+ // alongside admin channels (console / projects.X). Admin channels must NOT
+ // get an action suffix — only the resource-leaf channels do.
+ $result = Realtime::fromPayload(
+ event: 'functions.function_id.executions.execution_id.create',
+ payload: new Document([
+ '$id' => ID::custom('execution_id'),
+ 'functionId' => 'function_id',
+ '$read' => [Role::any()->toString()],
+ '$permissions' => [Permission::read(Role::any())],
+ ]),
+ project: new Document([
+ '$id' => ID::custom('project_id'),
+ 'teamId' => '123abc',
+ ])
+ );
+
+ // Resource-leaf channels are suffixed.
+ $this->assertContains('executions', $result['channels']);
+ $this->assertContains('executions.create', $result['channels']);
+ $this->assertContains('executions.execution_id', $result['channels']);
+ $this->assertContains('executions.execution_id.create', $result['channels']);
+ $this->assertContains('functions.function_id', $result['channels']);
+ $this->assertContains('functions.function_id.create', $result['channels']);
+
+ // Admin channels are NOT suffixed.
+ $this->assertContains('console', $result['channels']);
+ $this->assertNotContains('console.create', $result['channels']);
+ $this->assertContains('projects.project_id', $result['channels']);
+ $this->assertNotContains('projects.project_id.create', $result['channels']);
+
+ // The bare `functions` channel is never emitted by fromPayload (only
+ // `functions.{functionId}` is). The per-function action variant
+ // (`functions.{functionId}.create`) is the supported subscription
+ // form — bare `functions.create` would be a silent no-op and must
+ // therefore NOT appear in the published channel set either.
+ $this->assertNotContains('functions', $result['channels']);
+ $this->assertNotContains('functions.create', $result['channels']);
+ }
+
+ public function testFromPayloadHandlesAttributeTrailingActionEvents(): void
+ {
+ // `users.[userId].update.{attr}` (e.g. .email, .prefs, .name) — action is the
+ // second-to-last segment, not the last one. The suffix must still be `.update`.
+ $userResult = Realtime::fromPayload(
+ event: 'users.user_id.update.email',
+ payload: new Document(['$id' => ID::custom('user_id')])
+ );
+
+ $this->assertContains('account', $userResult['channels']);
+ $this->assertContains('account.user_id', $userResult['channels']);
+ $this->assertContains('account.update', $userResult['channels']);
+ $this->assertContains('account.user_id.update', $userResult['channels']);
+ // The attribute name must NOT leak into the channel namespace.
+ $this->assertNotContains('account.email', $userResult['channels']);
+ $this->assertNotContains('account.user_id.email', $userResult['channels']);
+
+ // `teams.[teamId].update.prefs` — same shape at the team level.
+ $teamResult = Realtime::fromPayload(
+ event: 'teams.team_id.update.prefs',
+ payload: new Document(['$id' => ID::custom('team_id')])
+ );
+
+ $this->assertContains('teams', $teamResult['channels']);
+ $this->assertContains('teams.team_id', $teamResult['channels']);
+ $this->assertContains('teams.update', $teamResult['channels']);
+ $this->assertContains('teams.team_id.update', $teamResult['channels']);
+ $this->assertNotContains('teams.prefs', $teamResult['channels']);
+ $this->assertNotContains('teams.team_id.prefs', $teamResult['channels']);
+
+ // `teams.[teamId].memberships.[membershipId].update.{attr}` — same again, deeper.
+ $membershipResult = Realtime::fromPayload(
+ event: 'teams.team_id.memberships.membership_id.update.status',
+ payload: new Document(['$id' => ID::custom('membership_id')])
+ );
+
+ $this->assertContains('memberships', $membershipResult['channels']);
+ $this->assertContains('memberships.membership_id', $membershipResult['channels']);
+ $this->assertContains('memberships.update', $membershipResult['channels']);
+ $this->assertContains('memberships.membership_id.update', $membershipResult['channels']);
+ $this->assertNotContains('memberships.status', $membershipResult['channels']);
+ $this->assertNotContains('memberships.membership_id.status', $membershipResult['channels']);
+ }
+
+ public function testFromPayloadDoesNotSuffixAccountForNestedUserEvents(): void
+ {
+ // Nested user events (challenges/sessions/recovery/verification) emit only
+ // user-level account channels in fromPayload. The trailing action belongs to
+ // the nested resource, NOT to the user account. A subscriber to
+ // `account.create` must not receive `users.U.challenges.C.create` or
+ // `users.U.sessions.S.delete` events — that would silently leak unrelated
+ // MFA / session traffic into account-level filters.
+ foreach (['challenges', 'sessions', 'recovery', 'verification'] as $sub) {
+ foreach (['create', 'update', 'delete'] as $action) {
+ $result = Realtime::fromPayload(
+ event: "users.user_id.{$sub}.sub_id.{$action}",
+ payload: new Document(['$id' => ID::custom('sub_id')])
+ );
+
+ $this->assertContains('account', $result['channels'], "{$sub}.{$action} should still emit base account channel");
+ $this->assertContains('account.user_id', $result['channels'], "{$sub}.{$action} should still emit user-scoped account channel");
+ $this->assertNotContains("account.{$action}", $result['channels'], "{$sub}.{$action} must NOT leak action suffix onto account channel");
+ $this->assertNotContains("account.user_id.{$action}", $result['channels'], "{$sub}.{$action} must NOT leak action suffix onto user-scoped account channel");
+ }
+ }
+
+ // Top-level user events SHOULD still suffix — guard against an over-eager fix
+ // that suppresses the suffix for legitimate account-level CRUD.
+ $createResult = Realtime::fromPayload(
+ event: 'users.user_id.create',
+ payload: new Document(['$id' => ID::custom('user_id')])
+ );
+ $this->assertContains('account.create', $createResult['channels']);
+ $this->assertContains('account.user_id.create', $createResult['channels']);
+
+ $updateResult = Realtime::fromPayload(
+ event: 'users.user_id.update.email',
+ payload: new Document(['$id' => ID::custom('user_id')])
+ );
+ $this->assertContains('account.update', $updateResult['channels']);
+ $this->assertContains('account.user_id.update', $updateResult['channels']);
+ }
+
+ public function testActionSuffixDeliversOnlyMatchingActionEndToEnd(): void
+ {
+ $realtime = new Realtime();
+
+ // Subscriber A scopes to creates; Subscriber B scopes to deletes.
+ $realtime->subscribe('1', 1, 'sub-create', [Role::any()->toString()], ['documents.create']);
+ $realtime->subscribe('1', 2, 'sub-delete', [Role::any()->toString()], ['documents.delete']);
+
+ // Simulate what fromPayload would publish for a create event.
+ $createEvent = [
+ 'project' => '1',
+ 'roles' => [Role::any()->toString()],
+ 'data' => [
+ 'channels' => ['documents', 'documents.create'],
+ 'payload' => ['$id' => 'doc'],
+ ],
+ ];
+ $createReceivers = $realtime->getSubscribers($createEvent);
+ $this->assertArrayHasKey(1, $createReceivers);
+ $this->assertArrayNotHasKey(2, $createReceivers);
+
+ // Delete event.
+ $deleteEvent = [
+ 'project' => '1',
+ 'roles' => [Role::any()->toString()],
+ 'data' => [
+ 'channels' => ['documents', 'documents.delete'],
+ 'payload' => ['$id' => 'doc'],
+ ],
+ ];
+ $deleteReceivers = $realtime->getSubscribers($deleteEvent);
+ $this->assertArrayHasKey(2, $deleteReceivers);
+ $this->assertArrayNotHasKey(1, $deleteReceivers);
+ }
+
+ public function testPlainChannelStillReceivesAllActionsEndToEnd(): void
+ {
+ $realtime = new Realtime();
+
+ $realtime->subscribe('1', 1, 'sub-all', [Role::any()->toString()], ['documents']);
+
+ foreach (['create', 'update', 'upsert', 'delete'] as $action) {
+ $event = [
+ 'project' => '1',
+ 'roles' => [Role::any()->toString()],
+ 'data' => [
+ 'channels' => ['documents', "documents.{$action}"],
+ 'payload' => ['$id' => 'doc'],
+ ],
+ ];
+ $this->assertArrayHasKey(1, $realtime->getSubscribers($event), "plain `documents` should match {$action} event");
+ }
+ }
}
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..f5a30a5500 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());