From fd2b85a3afb21c8a8f265297331c999816b9a37b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 25 Mar 2026 18:54:21 +0530 Subject: [PATCH] poc --- .env | 6 +- app/init/models.php | 3 + app/realtime.php | 89 ++ diff.txt | 375 ++++++ docs/scope-flow.md | 91 ++ logs.log | 279 +++++ src/Appwrite/Platform/Appwrite.php | 2 + .../Platform/Modules/Presence/HTTP/Create.php | 91 ++ .../Platform/Modules/Presence/HTTP/Get.php | 102 ++ .../Modules/Presence/HTTP/Iterative/XList.php | 69 + .../Platform/Modules/Presence/Module.php | 14 + .../Modules/Presence/Services/Http.php | 19 + src/Appwrite/Utopia/Response.php | 4 + .../Utopia/Response/Model/Presence.php | 72 ++ tests/e2e/Services/Realtime/PresenceBase.php | 1107 +++++++++++++++++ .../Realtime/PresenceIterativeTest.php | 232 ++++ 16 files changed, 2552 insertions(+), 3 deletions(-) create mode 100644 diff.txt create mode 100644 docs/scope-flow.md create mode 100644 logs.log create mode 100644 src/Appwrite/Platform/Modules/Presence/HTTP/Create.php create mode 100644 src/Appwrite/Platform/Modules/Presence/HTTP/Get.php create mode 100644 src/Appwrite/Platform/Modules/Presence/HTTP/Iterative/XList.php create mode 100644 src/Appwrite/Platform/Modules/Presence/Module.php create mode 100644 src/Appwrite/Platform/Modules/Presence/Services/Http.php create mode 100644 src/Appwrite/Utopia/Response/Model/Presence.php create mode 100644 tests/e2e/Services/Realtime/PresenceBase.php create mode 100644 tests/e2e/Services/Realtime/PresenceIterativeTest.php diff --git a/.env b/.env index 9abfa756e1..4903a8b491 100644 --- a/.env +++ b/.env @@ -40,9 +40,9 @@ _APP_REDIS_PORT=6379 _APP_REDIS_PASS= _APP_REDIS_USER= COMPOSE_PROFILES=mariadb,mongodb,postgresql -_APP_DB_ADAPTER=mongodb -_APP_DB_HOST=mongodb -_APP_DB_PORT=27017 +_APP_DB_ADAPTER=mariadb +_APP_DB_HOST=mariadb +_APP_DB_PORT=3306 _APP_DB_SCHEMA=appwrite _APP_DB_USER=user _APP_DB_PASS=password diff --git a/app/init/models.php b/app/init/models.php index bf6d67dd95..7342eb1175 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -108,6 +108,7 @@ use Appwrite\Utopia\Response\Model\None; use Appwrite\Utopia\Response\Model\Phone; use Appwrite\Utopia\Response\Model\Platform; use Appwrite\Utopia\Response\Model\Preferences; +use Appwrite\Utopia\Response\Model\Presence; use Appwrite\Utopia\Response\Model\Project; use Appwrite\Utopia\Response\Model\Provider; use Appwrite\Utopia\Response\Model\ProviderRepository; @@ -172,6 +173,7 @@ Response::setModel(new BaseList('Databases List', Response::MODEL_DATABASE_LIST, Response::setModel(new BaseList('Indexes List', Response::MODEL_INDEX_LIST, 'indexes', Response::MODEL_INDEX)); Response::setModel(new BaseList('Column Indexes List', Response::MODEL_COLUMN_INDEX_LIST, 'indexes', Response::MODEL_COLUMN_INDEX)); Response::setModel(new BaseList('Users List', Response::MODEL_USER_LIST, 'users', Response::MODEL_USER)); +Response::setModel(new BaseList('Presence List', Response::MODEL_PRESENCE_LIST, 'presences', Response::MODEL_PRESENCE)); Response::setModel(new BaseList('Sessions List', Response::MODEL_SESSION_LIST, 'sessions', Response::MODEL_SESSION)); Response::setModel(new BaseList('Identities List', Response::MODEL_IDENTITY_LIST, 'identities', Response::MODEL_IDENTITY)); Response::setModel(new BaseList('Logs List', Response::MODEL_LOG_LIST, 'logs', Response::MODEL_LOG)); @@ -334,6 +336,7 @@ Response::setModel(new DevKey()); Response::setModel(new MockNumber()); Response::setModel(new AuthProvider()); Response::setModel(new Platform()); +Response::setModel(new Presence()); Response::setModel(new Variable()); Response::setModel(new Country()); Response::setModel(new Continent()); diff --git a/app/realtime.php b/app/realtime.php index d3305ca7f8..3a7b4d1890 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -241,6 +241,32 @@ if (!function_exists('triggerStats')) { } } +if (!function_exists('createPresence')) { + function createPresence(Document $project, User $user, string $status, array $permissions, ?DateTime $expiry = null): Document + { + $dbForProject = getProjectDB($project); + sort($permissions); + return $dbForProject->withTransaction(function () use ($dbForProject, $user, $status, $permissions, $expiry) { + $document = $dbForProject->upsertDocument('presenceLogs', new Document([ + 'userInternalId' => $user->getSequence(), + 'userId' => $user->getId(), + '$permissions' => $permissions, + 'perms_md5' => md5(json_encode($permissions)), + 'expiry' => $expiry, + 'status' => $status, + 'source' => 'realtime' + ])); + $dbForProject->upsertDocument('presence', new Document([ + '$id' => $user->getId(), + 'userInternalId' => $user->getSequence(), + 'userId' => $user->getId(), + ])); + + return $document; + }); + } +} + $realtime = getRealtime(); /** @@ -960,7 +986,70 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re } break; + case 'presence': + if (!array_key_exists('session', $message['data'])) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.'); + } + if ($project === null || $project->isEmpty()) { + throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing project context. Reconnect to the project first.'); + } + + $store = new Store(); + $store->decode($message['data']['session']); + + /** @var User $user */ + $user = $database->getDocument('users', $store->getProperty('id', '')); + + $proofForToken = new Token(); + $proofForToken->setHash(new Sha()); + + if ( + empty($user->getId()) + || !$user->sessionVerify($store->getProperty('secret', ''), $proofForToken) + ) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Session is not valid.'); + } + + if ( + !array_key_exists('status', $message['data']) + || !array_key_exists('permissions', $message['data']) + ) { + throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Status and permissions are required.'); + } + + $status = $message['data']['status']; + if (!is_string($status) || $status === '') { + throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Status must be a non-empty string.'); + } + + $permissions = $message['data']['permissions']; + if (!is_array($permissions)) { + throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Permissions must be an array.'); + } + + $expiry = null; + // $expiryValue = $message['data']['expiry'] ?? null; + // if (is_string($expiryValue) && $expiryValue !== '') { + // try { + // $expiry = new DateTime($expiryValue); + // } catch (Throwable) { + // throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.'); + // } + // } + + $document = createPresence($project, $user, $status, $permissions, $expiry); + + $server->send([$connection], json_encode([ + 'type' => 'presence', + 'data' => [ + '$id' => $document->getId(), + 'status' => $status, + 'userId' => $user->getId(), + ] + ])); + + break; default: throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); } diff --git a/diff.txt b/diff.txt new file mode 100644 index 0000000000..de827c0b5b --- /dev/null +++ b/diff.txt @@ -0,0 +1,375 @@ +diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php +index 06312d9cb2..ec23a9a112 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\Presence; + use Appwrite\Platform\Modules\Project; + use Appwrite\Platform\Modules\Projects; + use Appwrite\Platform\Modules\Proxy; +@@ -31,6 +32,7 @@ class Appwrite extends Platform + $this->addModule(new Projects\Module()); + $this->addModule(new Functions\Module()); + $this->addModule(new Health\Module()); ++ $this->addModule(new Presence\Module()); + $this->addModule(new Sites\Module()); + $this->addModule(new Console\Module()); + $this->addModule(new Proxy\Module()); +diff --git a/src/Appwrite/Platform/Modules/Presence/HTTP/Iterative/XList.php b/src/Appwrite/Platform/Modules/Presence/HTTP/Iterative/XList.php +index 165c4b8f88..f86b9feda5 100644 +--- a/src/Appwrite/Platform/Modules/Presence/HTTP/Iterative/XList.php ++++ b/src/Appwrite/Platform/Modules/Presence/HTTP/Iterative/XList.php +@@ -1,6 +1,6 @@ + setHttpMethod(self::HTTP_REQUEST_METHOD_GET) ++ // TODO: create a separate scope ++ ->label('scope', 'documents.read') + ->setHttpPath('/v1/iterative/presence') + ->inject('response') + ->inject('dbForProject') ++ ->inject('authorization') + ->callback($this->action(...)); + } + // Since POC so not adding queries or advanced filtering now + // just for getting group based presence list based on permissions +- public function action(UtopiaResponse $response, Database $dbForProject): void ++ public function action(UtopiaResponse $response, Database $dbForProject, Authorization $authorization): void + { + try { + $presenceLogs = []; +- $users = $dbForProject->find('presence',[]); ++ $users = $authorization->skip(fn () => $dbForProject->find('presence', [])); + foreach ($users as $user) { +- $presenceLogs[] = $dbForProject->findOne('presenceLogs',[ +- Query::equal('userId', $user['userId']), ++ $presenceLogs[] = $dbForProject->findOne('presenceLogs', [ ++ Query::equal('userId', [$user['userId']]), + Query::orderDesc('$updatedAt'), + Query::limit(1), + ]); +diff --git a/src/Appwrite/Platform/Modules/Presence/Services/Http.php b/src/Appwrite/Platform/Modules/Presence/Services/Http.php +index 1afeed6164..fa3dc42197 100644 +--- a/src/Appwrite/Platform/Modules/Presence/Services/Http.php ++++ b/src/Appwrite/Platform/Modules/Presence/Services/Http.php +@@ -2,7 +2,9 @@ + + namespace Appwrite\Platform\Modules\Presence\Services; + +-use Appwrite\Platform\Modules\Presence\Http\Iterative\XList as ListPresence; ++use Appwrite\Platform\Modules\Presence\HTTP\Create as CreatePresence; ++use Appwrite\Platform\Modules\Presence\HTTP\Iterative\XList as ListPresence; ++use Appwrite\Platform\Modules\Presence\HTTP\Iterative\XListUnique as ListPresenceUnique; + use Utopia\Platform\Service; + + class Http extends Service +@@ -10,6 +12,8 @@ class Http extends Service + public function __construct() + { + $this->type = Service::TYPE_HTTP; ++ $this->addAction(CreatePresence::getName(), new CreatePresence()); + $this->addAction(ListPresence::getName(), new ListPresence()); ++ $this->addAction(ListPresenceUnique::getName(), new ListPresenceUnique()); + } + } +diff --git a/tests/e2e/Services/Realtime/PresenceBase.php b/tests/e2e/Services/Realtime/PresenceBase.php +index e69de29bb2..5124708aa2 100644 +--- a/tests/e2e/Services/Realtime/PresenceBase.php ++++ b/tests/e2e/Services/Realtime/PresenceBase.php +@@ -0,0 +1,251 @@ ++> ++ */ ++ private array $timings = [ ++ 'createPresenceApi' => [], ++ 'createPresenceRealtime' => [], ++ 'listPresence' => [], ++ ]; ++ ++ abstract protected function getNumberOfUsersToCreate(): int; ++ ++ abstract protected function getListPresenceURL(): string; ++ ++ protected function getSeedPresenceDocumentsCount(): int ++ { ++ return 0; ++ } ++ ++ public function testListPresenceBenchmarkWithPermissionGroups(): void ++ { ++ $this->seedPresenceLoad(); ++ ++ $usersToCreate = \max(2, $this->getNumberOfUsersToCreate()); ++ $users = []; ++ for ($i = 0; $i < $usersToCreate; $i++) { ++ $users[] = $this->getUser(true); ++ } ++ ++ $expectedByViewer = []; ++ foreach ($users as $viewer) { ++ $expectedByViewer[$viewer['$id']] = []; ++ } ++ ++ // Uniform visibility: each user's presence is readable by a rotating half-set of users. ++ $window = \max(1, (int) \floor($usersToCreate / 2)); ++ foreach ($users as $ownerIndex => $owner) { ++ $status = 'uniform-presence-' . $ownerIndex; ++ $permissions = []; ++ ++ for ($offset = 0; $offset < $window; $offset++) { ++ $viewerIndex = ($ownerIndex + $offset) % $usersToCreate; ++ $viewer = $users[$viewerIndex]; ++ $permissions[] = Permission::read(Role::user($viewer['$id'])); ++ $expectedByViewer[$viewer['$id']][$owner['$id']] = $status; ++ } ++ ++ $this->reportPresence($owner, $status, $permissions); ++ \usleep(50000); ++ } ++ ++ foreach ($users as $viewer) { ++ $benchmark = $this->fetchPresenceListAs($viewer); ++ $this->assertEquals(200, $benchmark['response']['headers']['status-code']); ++ $this->assertGreaterThan(0.0, $benchmark['elapsedMs']); ++ ++ $rows = $this->extractPresenceRows($benchmark['response']['body']); ++ $visibleMap = $this->indexPresenceRowsByUserId($rows); ++ $expectedVisible = $expectedByViewer[$viewer['$id']]; ++ ++ foreach ($expectedVisible as $ownerUserId => $status) { ++ $this->assertArrayHasKey($ownerUserId, $visibleMap, 'Viewer cannot see expected user: ' . $ownerUserId); ++ $this->assertEquals($status, $visibleMap[$ownerUserId]['status'] ?? null); ++ } ++ ++ foreach ($users as $owner) { ++ $ownerUserId = $owner['$id']; ++ if (!isset($expectedVisible[$ownerUserId])) { ++ $this->assertArrayNotHasKey($ownerUserId, $visibleMap, 'Viewer should not see unauthorized user: ' . $ownerUserId); ++ } ++ } ++ } ++ ++ $this->printTimingSummary(); ++ } ++ ++ private function seedPresenceLoad(): void ++ { ++ $seedCount = \max(0, $this->getSeedPresenceDocumentsCount()); ++ ++ if ($seedCount === 0) { ++ return; ++ } ++ ++ for ($i = 0; $i < $seedCount; $i++) { ++ $seedUser = $this->getUser(true); ++ ++ $this->createPresenceViaApi( ++ $seedUser, ++ 'seed-load-' . $i, ++ [Permission::read(Role::user($seedUser['$id']))] ++ ); ++ } ++ } ++ ++ private function reportPresence(array $user, string $status, array $permissions): void ++ { ++ $start = \microtime(true); ++ $projectId = $this->getProject()['$id']; ++ $client = $this->getWebsocket(['account'], [ ++ 'origin' => 'http://localhost', ++ 'cookie' => 'a_session_' . $projectId . '=' . $user['session'], ++ ]); ++ ++ // connected payload ++ $client->receive(); ++ ++ $expiry = \gmdate('Y-m-d\TH:i:s\Z', \time() + 120); ++ ++ $client->send(\json_encode([ ++ 'type' => 'presence', ++ 'data' => [ ++ 'session' => $user['session'], ++ 'status' => $status, ++ 'permissions' => $permissions, ++ 'expiry' => $expiry, ++ ], ++ ])); ++ ++ \usleep(50000); ++ $client->close(); ++ ++ $elapsedMs = (\microtime(true) - $start) * 1000; ++ $this->recordTiming('createPresenceRealtime', $elapsedMs); ++ } ++ ++ private function createPresenceViaApi(array $user, string $status, array $permissions): void ++ { ++ $projectId = $this->getProject()['$id']; ++ $headers = [ ++ 'content-type' => 'application/json', ++ 'origin' => 'http://localhost', ++ 'x-appwrite-project' => $projectId, ++ 'cookie' => 'a_session_' . $projectId . '=' . $user['session'], ++ ]; ++ ++ $payload = [ ++ 'status' => $status, ++ 'permissions' => $permissions, ++ ]; ++ ++ $start = \microtime(true); ++ ++ $response = $this->client->call(Client::METHOD_POST, '/iterative/presence', $headers, $payload); ++ ++ $elapsedMs = (\microtime(true) - $start) * 1000; ++ $this->recordTiming('createPresenceApi', $elapsedMs); ++ $this->assertEquals( ++ 201, ++ $response['headers']['status-code'], ++ 'Create presence failed: ' . json_encode($response['body'] ?? []) ++ ); ++ } ++ ++ private function fetchPresenceListAs(array $user): array ++ { ++ $projectId = $this->getProject()['$id']; ++ ++ $start = \microtime(true); ++ $response = $this->client->call(Client::METHOD_GET, $this->getListPresenceURL(), [ ++ 'content-type' => 'application/json', ++ 'origin' => 'http://localhost', ++ 'x-appwrite-project' => $projectId, ++ 'cookie' => 'a_session_' . $projectId . '=' . $user['session'], ++ ]); ++ $elapsedMs = (\microtime(true) - $start) * 1000; ++ $this->recordTiming('listPresence', $elapsedMs); ++ ++ return [ ++ 'elapsedMs' => $elapsedMs, ++ 'response' => $response, ++ ]; ++ } ++ ++ private function extractPresenceRows(array $body): array ++ { ++ foreach (['presences', 'presence', 'documents', 'rows'] as $key) { ++ $value = $body[$key] ?? null; ++ if (\is_array($value)) { ++ return $value; ++ } ++ } ++ ++ return []; ++ } ++ ++ private function indexPresenceRowsByUserId(array $rows): array ++ { ++ $indexed = []; ++ foreach ($rows as $row) { ++ if (!\is_array($row)) { ++ continue; ++ } ++ ++ $userId = $row['userId'] ?? null; ++ if (!\is_string($userId) || $userId === '') { ++ continue; ++ } ++ ++ $indexed[$userId] = $row; ++ } ++ ++ return $indexed; ++ } ++ ++ private function recordTiming(string $operation, float $elapsedMs): void ++ { ++ $this->timings[$operation][] = $elapsedMs; ++ } ++ ++ private function printTimingSummary(): void ++ { ++ foreach ($this->timings as $operation => $values) { ++ if (empty($values)) { ++ continue; ++ } ++ ++ $avg = \array_sum($values) / \count($values); ++ $min = \min($values); ++ $max = \max($values); ++ ++ \fwrite( ++ \STDOUT, ++ \sprintf( ++ "\n[Presence Benchmark] %s count=%d avg=%.2fms min=%.2fms max=%.2fms\n", ++ $operation, ++ \count($values), ++ $avg, ++ $min, ++ $max ++ ) ++ ); ++ } ++ } ++} +diff --git a/tests/e2e/Services/Realtime/PresenceIterativeTest.php b/tests/e2e/Services/Realtime/PresenceIterativeTest.php +index e69de29bb2..cb0ec8baa2 100644 +--- a/tests/e2e/Services/Realtime/PresenceIterativeTest.php ++++ b/tests/e2e/Services/Realtime/PresenceIterativeTest.php +@@ -0,0 +1,21 @@ ++isEmpty()` is `true`, so Appwrite selects the **guests** role: + +```php +$role = $user->isEmpty() + ? Role::guests()->toString() + : Role::users()->toString(); +``` + +### 1.2 Default scopes for the role +Appwrite loads the default scope list from `app/config/roles.php`: + +```php +$scopes = $roles[$role]['scopes']; +``` + +For `guests`, `documents.write` is included by default. + +### 1.3 Validate the route’s declared scope +Each endpoint declares the required scope using `->label('scope', ...)`. +The request is allowed to proceed only if the endpoint scope is present in the role’s scope list: + +```php +$allowed = (array) $route->getLabel('scope', 'none'); +if (empty(array_intersect($allowed, $scopes))) { + throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, ...); +} +``` + +## 2. “Guest” userId: endpoint scope does not imply a DB user identity + +The “guests” role is used for the HTTP endpoint scope gate. +Database permission decisions later depend on the *authorization roles* and the presence of a `userId`. + +When there is no authenticated user, Appwrite may still create a “guest” `Document` for auditing purposes, but with an **empty** `'$id'`: + +```php +$user = new User([ + '$id' => '', + 'type' => ACTIVITY_TYPE_GUEST, + ... +]); +``` + +In the DocumentsDB `createDocument` handler, default per-user `$permissions` are only added when `!empty($user->getId())`: + +```php +if (!empty($user->getId()) && !$isPrivilegedUser) { + // add default 'read/update/delete' permissions for the current user +} +``` + +So even though the endpoint scope gate may succeed for guests, the database layer still requires that the **collection/database permissions** permit the create operation for the guest/authorization context. + +## 3. After the scope gate: database create authorization + +For `documentsdb` document creation, `src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php` checks: + +1. The target database/collection exists and is enabled (unless API key/privileged). +2. For each document, the authorization validator must accept the operation: + - `create` permission is validated against `$collection->getPermissionsByType(Database::PERMISSION_CREATE)` + - `update` is validated against collection/document rules (and may depend on `documentSecurity`) + +If the collection does not grant `create` to a role present in the request’s authorization context (e.g. `guests` / `any` / other configured roles), the request fails with `USER_UNAUTHORIZED`. + +## 4. Bulk create extra restriction (unauth) + +For bulk creates, there is an additional guard in the same DocumentsDB create handler: + +```php +if ($isBulk && !$isAPIKey && !$isPrivilegedUser) { + throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE); +} +``` + +So bulk create is further restricted beyond the endpoint scope gate. + +## Where to look (quick pointers) + +- HTTP scope gate: `app/controllers/shared/api.php` +- Default role->scope mapping: `app/config/roles.php` +- DocumentsDB create + DB permission checks: `src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php` + diff --git a/logs.log b/logs.log new file mode 100644 index 0000000000..35002b7f8d --- /dev/null +++ b/logs.log @@ -0,0 +1,279 @@ + └── Creating database: logs... +database.setup · 3ms · 0315c704 + + database.name  logs + database.collections_created 0 + level  info + +──────────────────────────────────────────────────────────── + └── Creating database: appwrite... +database.setup · 5.1ms · e2f2aedf + + database.name  appwrite + database.collections_created 0 + level  info + +──────────────────────────────────────────────────────────── +http.server.start · 0ms · 59b604f0 + + server.workers  60 + server.payload_size 12582912 + server.master_pid  1 + server.manager_pid  7 + level  info + +──────────────────────────────────────────────────────────── +http.request · 85ms · 691453e9 + + http.method  POST + http.path  /v1/account + http.response.code 201 + level  info + +──────────────────────────────────────────────────────────── +http.request · 82.4ms · c84b3c1b + + http.method  POST + http.path  /v1/account/sessions/email + http.response.code 201 + level  info + +──────────────────────────────────────────────────────────── +http.request · 3.4ms · 7bfbeb70 + + http.method  GET + http.path  /v1/account + http.response.code 200 + level  info + +──────────────────────────────────────────────────────────── +http.request · 8ms · b2fc006d + + http.method  POST + http.path  /v1/teams + http.response.code 201 + level  info + +──────────────────────────────────────────────────────────── +http.request · 658.5ms · 9085360f + + http.method  POST + http.path  /v1/projects + http.response.code 201 + level  info + +──────────────────────────────────────────────────────────── +http.request · 4.3ms · 38681d3b + + http.method  POST + http.path  /v1/projects/:projectId/keys + http.response.code 201 + level  info + +──────────────────────────────────────────────────────────── +http.request · 3.9ms · 15566177 + + http.method  POST + http.path  /v1/projects/:projectId/dev-keys + http.response.code 201 + level  info + +──────────────────────────────────────────────────────────── +http.request · 4.8ms · a8ecb3f4 + + http.method  POST + http.path  /v1/webhooks + http.response.code 201 + level  info + +──────────────────────────────────────────────────────────── +http.request · 123.9ms · ab97717c + + http.method  PATCH + http.path  /v1/projects/:projectId/smtp + http.response.code 200 + level  info + +──────────────────────────────────────────────────────────── +[Presence][Create] presenceId=69c3c4c56ace451371a1 creatorUserId=69c3c4c54841d17f980b creatorUserInternalId=1 status=seed-load-0 permissions=["read(\"user:69c3c4c54841d17f980b\")"] allowedReadRoles=["user:69c3c4c54841d17f980b"] expiry=null +[Presence][Create] presenceId=69c3c4c588d8b30491a4 creatorUserId=69c3c4c56bb8f17caf7a creatorUserInternalId=2 status=seed-load-1 permissions=["read(\"user:69c3c4c56bb8f17caf7a\")"] allowedReadRoles=["user:69c3c4c56bb8f17caf7a"] expiry=null +[Presence][Create] presenceId=69c3c4c59eb137bbfafb creatorUserId=69c3c4c589c2990fa68b creatorUserInternalId=3 status=seed-load-2 permissions=["read(\"user:69c3c4c589c2990fa68b\")"] allowedReadRoles=["user:69c3c4c589c2990fa68b"] expiry=null +[Presence][Create] presenceId=69c3c4c5b57384645c88 creatorUserId=69c3c4c59f9c34c4d5f7 creatorUserInternalId=4 status=seed-load-3 permissions=["read(\"user:69c3c4c59f9c34c4d5f7\")"] allowedReadRoles=["user:69c3c4c59f9c34c4d5f7"] expiry=null +[Presence][Create] presenceId=69c3c4c5cab9908e0156 creatorUserId=69c3c4c5b61873b720cb creatorUserInternalId=5 status=seed-load-4 permissions=["read(\"user:69c3c4c5b61873b720cb\")"] allowedReadRoles=["user:69c3c4c5b61873b720cb"] expiry=null +[Presence][Create] presenceId=69c3c4c5e1192f7986fd creatorUserId=69c3c4c5cb7be0a29bcd creatorUserInternalId=6 status=seed-load-5 permissions=["read(\"user:69c3c4c5cb7be0a29bcd\")"] allowedReadRoles=["user:69c3c4c5cb7be0a29bcd"] expiry=null +[Presence][Create] presenceId=69c3c4c60334c89b7103 creatorUserId=69c3c4c5e2057801da30 creatorUserInternalId=7 status=seed-load-6 permissions=["read(\"user:69c3c4c5e2057801da30\")"] allowedReadRoles=["user:69c3c4c5e2057801da30"] expiry=null +[Presence][Create] presenceId=69c3c4c6195a3a1d88e8 creatorUserId=69c3c4c6044b3ee83478 creatorUserInternalId=8 status=seed-load-7 permissions=["read(\"user:69c3c4c6044b3ee83478\")"] allowedReadRoles=["user:69c3c4c6044b3ee83478"] expiry=null +[Presence][Create] presenceId=69c3c4c62f7ae5c297e6 creatorUserId=69c3c4c61a3bc31f5004 creatorUserInternalId=9 status=seed-load-8 permissions=["read(\"user:69c3c4c61a3bc31f5004\")"] allowedReadRoles=["user:69c3c4c61a3bc31f5004"] expiry=null +[Presence][Create] presenceId=69c3c4c645a5783d2247 creatorUserId=69c3c4c63095cd7eb79b creatorUserInternalId=10 status=seed-load-9 permissions=["read(\"user:69c3c4c63095cd7eb79b\")"] allowedReadRoles=["user:69c3c4c63095cd7eb79b"] expiry=null +[Presence][Create] presenceId=69c3c4c65c9eb21b4da4 creatorUserId=69c3c4c646d615986db9 creatorUserInternalId=11 status=seed-load-10 permissions=["read(\"user:69c3c4c646d615986db9\")"] allowedReadRoles=["user:69c3c4c646d615986db9"] expiry=null +[Presence][Create] presenceId=69c3c4c672ad6e939983 creatorUserId=69c3c4c65da521f6ded7 creatorUserInternalId=12 status=seed-load-11 permissions=["read(\"user:69c3c4c65da521f6ded7\")"] allowedReadRoles=["user:69c3c4c65da521f6ded7"] expiry=null +[Presence][Create] presenceId=69c3c4c6885529a9ee0c creatorUserId=69c3c4c673867d685ef5 creatorUserInternalId=13 status=seed-load-12 permissions=["read(\"user:69c3c4c673867d685ef5\")"] allowedReadRoles=["user:69c3c4c673867d685ef5"] expiry=null +[Presence][Create] presenceId=69c3c4c6a475ce934b73 creatorUserId=69c3c4c6894169cd73d6 creatorUserInternalId=14 status=seed-load-13 permissions=["read(\"user:69c3c4c6894169cd73d6\")"] allowedReadRoles=["user:69c3c4c6894169cd73d6"] expiry=null +[Presence][Create] presenceId=69c3c4c6be613d23f503 creatorUserId=69c3c4c6a63983e2609d creatorUserInternalId=15 status=seed-load-14 permissions=["read(\"user:69c3c4c6a63983e2609d\")"] allowedReadRoles=["user:69c3c4c6a63983e2609d"] expiry=null +[Presence][Create] presenceId=69c3c4c6d6c7e4efff52 creatorUserId=69c3c4c6bf2ba633e9b0 creatorUserInternalId=16 status=seed-load-15 permissions=["read(\"user:69c3c4c6bf2ba633e9b0\")"] allowedReadRoles=["user:69c3c4c6bf2ba633e9b0"] expiry=null +[Presence][Create] presenceId=69c3c4c6eef7edd3454a creatorUserId=69c3c4c6d7f8bb919b94 creatorUserInternalId=17 status=seed-load-16 permissions=["read(\"user:69c3c4c6d7f8bb919b94\")"] allowedReadRoles=["user:69c3c4c6d7f8bb919b94"] expiry=null +[Presence][Create] presenceId=69c3c4c712aa150e02ff creatorUserId=69c3c4c6efce3ea5c64f creatorUserInternalId=18 status=seed-load-17 permissions=["read(\"user:69c3c4c6efce3ea5c64f\")"] allowedReadRoles=["user:69c3c4c6efce3ea5c64f"] expiry=null +[Presence][Create] presenceId=69c3c4c72947176267d4 creatorUserId=69c3c4c713b02d43af5d creatorUserInternalId=19 status=seed-load-18 permissions=["read(\"user:69c3c4c713b02d43af5d\")"] allowedReadRoles=["user:69c3c4c713b02d43af5d"] expiry=null +[Presence][Create] presenceId=69c3c4c745ee2bc05dd7 creatorUserId=69c3c4c72a2f5a455615 creatorUserInternalId=20 status=seed-load-19 permissions=["read(\"user:69c3c4c72a2f5a455615\")"] allowedReadRoles=["user:69c3c4c72a2f5a455615"] expiry=null +[Presence][Create] presenceId=69c3c4c76cc5619d7952 creatorUserId=69c3c4c746ef8ccf63d2 creatorUserInternalId=21 status=seed-load-20 permissions=["read(\"user:69c3c4c746ef8ccf63d2\")"] allowedReadRoles=["user:69c3c4c746ef8ccf63d2"] expiry=null +[Presence][Create] presenceId=69c3c4c78b0f72e6f308 creatorUserId=69c3c4c76e4696874cd2 creatorUserInternalId=22 status=seed-load-21 permissions=["read(\"user:69c3c4c76e4696874cd2\")"] allowedReadRoles=["user:69c3c4c76e4696874cd2"] expiry=null +[Presence][Create] presenceId=69c3c4c7a5861027bbb2 creatorUserId=69c3c4c78c95a0ad7f3b creatorUserInternalId=23 status=seed-load-22 permissions=["read(\"user:69c3c4c78c95a0ad7f3b\")"] allowedReadRoles=["user:69c3c4c78c95a0ad7f3b"] expiry=null +[Presence][Create] presenceId=69c3c4c7bdcc9346f6cb creatorUserId=69c3c4c7a6525f92b5ea creatorUserInternalId=24 status=seed-load-23 permissions=["read(\"user:69c3c4c7a6525f92b5ea\")"] allowedReadRoles=["user:69c3c4c7a6525f92b5ea"] expiry=null +[Presence][Create] presenceId=69c3c4c7d4748916ca1b creatorUserId=69c3c4c7be86cb172ba1 creatorUserInternalId=25 status=seed-load-24 permissions=["read(\"user:69c3c4c7be86cb172ba1\")"] allowedReadRoles=["user:69c3c4c7be86cb172ba1"] expiry=null +[Presence][Create] presenceId=69c3c4c7eb109ba5aab3 creatorUserId=69c3c4c7d53b8709c3f6 creatorUserInternalId=26 status=seed-load-25 permissions=["read(\"user:69c3c4c7d53b8709c3f6\")"] allowedReadRoles=["user:69c3c4c7d53b8709c3f6"] expiry=null +[Presence][Create] presenceId=69c3c4c80d3eee93bec5 creatorUserId=69c3c4c7ebc6cd745fba creatorUserInternalId=27 status=seed-load-26 permissions=["read(\"user:69c3c4c7ebc6cd745fba\")"] allowedReadRoles=["user:69c3c4c7ebc6cd745fba"] expiry=null +[Presence][Create] presenceId=69c3c4c826222fc63b29 creatorUserId=69c3c4c80dd361298b2d creatorUserInternalId=28 status=seed-load-27 permissions=["read(\"user:69c3c4c80dd361298b2d\")"] allowedReadRoles=["user:69c3c4c80dd361298b2d"] expiry=null +[Presence][Create] presenceId=69c3c4c83ff385f56e03 creatorUserId=69c3c4c82702aaef7601 creatorUserInternalId=29 status=seed-load-28 permissions=["read(\"user:69c3c4c82702aaef7601\")"] allowedReadRoles=["user:69c3c4c82702aaef7601"] expiry=null +[Presence][Create] presenceId=69c3c4c8588d838b15c4 creatorUserId=69c3c4c840c38a2cb2a1 creatorUserInternalId=30 status=seed-load-29 permissions=["read(\"user:69c3c4c840c38a2cb2a1\")"] allowedReadRoles=["user:69c3c4c840c38a2cb2a1"] expiry=null +[Presence][Create] presenceId=69c3c4c872553a3bd691 creatorUserId=69c3c4c8593f7a759d90 creatorUserInternalId=31 status=seed-load-30 permissions=["read(\"user:69c3c4c8593f7a759d90\")"] allowedReadRoles=["user:69c3c4c8593f7a759d90"] expiry=null +[Presence][Create] presenceId=69c3c4c88a3ed8171be7 creatorUserId=69c3c4c87364129b8b10 creatorUserInternalId=32 status=seed-load-31 permissions=["read(\"user:69c3c4c87364129b8b10\")"] allowedReadRoles=["user:69c3c4c87364129b8b10"] expiry=null +[Presence][Create] presenceId=69c3c4c8a166e5f6918f creatorUserId=69c3c4c88b50b7da379c creatorUserInternalId=33 status=seed-load-32 permissions=["read(\"user:69c3c4c88b50b7da379c\")"] allowedReadRoles=["user:69c3c4c88b50b7da379c"] expiry=null +[Presence][Create] presenceId=69c3c4c8b833b4c4c039 creatorUserId=69c3c4c8a29626880e8a creatorUserInternalId=34 status=seed-load-33 permissions=["read(\"user:69c3c4c8a29626880e8a\")"] allowedReadRoles=["user:69c3c4c8a29626880e8a"] expiry=null +[Presence][Create] presenceId=69c3c4c8cdce4601b40e creatorUserId=69c3c4c8b8d555460ce4 creatorUserInternalId=35 status=seed-load-34 permissions=["read(\"user:69c3c4c8b8d555460ce4\")"] allowedReadRoles=["user:69c3c4c8b8d555460ce4"] expiry=null +[Presence][Create] presenceId=69c3c4c8e354c645edbd creatorUserId=69c3c4c8ce681f6b14b5 creatorUserInternalId=36 status=seed-load-35 permissions=["read(\"user:69c3c4c8ce681f6b14b5\")"] allowedReadRoles=["user:69c3c4c8ce681f6b14b5"] expiry=null +[Presence][Create] presenceId=69c3c4c91855e8fa04bc creatorUserId=69c3c4c8e42809a8447f creatorUserInternalId=37 status=seed-load-36 permissions=["read(\"user:69c3c4c8e42809a8447f\")"] allowedReadRoles=["user:69c3c4c8e42809a8447f"] expiry=null +[Presence][Create] presenceId=69c3c4c9314ab1324ec3 creatorUserId=69c3c4c919560c20b571 creatorUserInternalId=38 status=seed-load-37 permissions=["read(\"user:69c3c4c919560c20b571\")"] allowedReadRoles=["user:69c3c4c919560c20b571"] expiry=null +[Presence][Create] presenceId=69c3c4c9484eef2ff740 creatorUserId=69c3c4c932129f7ca7e4 creatorUserInternalId=39 status=seed-load-38 permissions=["read(\"user:69c3c4c932129f7ca7e4\")"] allowedReadRoles=["user:69c3c4c932129f7ca7e4"] expiry=null +[Presence][Create] presenceId=69c3c4c95e8ec4aca79a creatorUserId=69c3c4c94910abcc8aec creatorUserInternalId=40 status=seed-load-39 permissions=["read(\"user:69c3c4c94910abcc8aec\")"] allowedReadRoles=["user:69c3c4c94910abcc8aec"] expiry=null +[Presence][Create] presenceId=69c3c4c975518c7aa1bd creatorUserId=69c3c4c95f24f9af0831 creatorUserInternalId=41 status=seed-load-40 permissions=["read(\"user:69c3c4c95f24f9af0831\")"] allowedReadRoles=["user:69c3c4c95f24f9af0831"] expiry=null +[Presence][Create] presenceId=69c3c4c98c02f23b36f3 creatorUserId=69c3c4c9760f86b1abbb creatorUserInternalId=42 status=seed-load-41 permissions=["read(\"user:69c3c4c9760f86b1abbb\")"] allowedReadRoles=["user:69c3c4c9760f86b1abbb"] expiry=null +[Presence][Create] presenceId=69c3c4c9a5806bf0e1ae creatorUserId=69c3c4c98c98e5d1af05 creatorUserInternalId=43 status=seed-load-42 permissions=["read(\"user:69c3c4c98c98e5d1af05\")"] allowedReadRoles=["user:69c3c4c98c98e5d1af05"] expiry=null +[Presence][Create] presenceId=69c3c4c9bf94a1fadde5 creatorUserId=69c3c4c9a62794290964 creatorUserInternalId=44 status=seed-load-43 permissions=["read(\"user:69c3c4c9a62794290964\")"] allowedReadRoles=["user:69c3c4c9a62794290964"] expiry=null +[Presence][Create] presenceId=69c3c4c9d8ec34edf8dd creatorUserId=69c3c4c9c096487b20d6 creatorUserInternalId=45 status=seed-load-44 permissions=["read(\"user:69c3c4c9c096487b20d6\")"] allowedReadRoles=["user:69c3c4c9c096487b20d6"] expiry=null +[Presence][Create] presenceId=69c3c4ca0125bc4b8a1f creatorUserId=69c3c4c9d9b2f95e2274 creatorUserInternalId=46 status=seed-load-45 permissions=["read(\"user:69c3c4c9d9b2f95e2274\")"] allowedReadRoles=["user:69c3c4c9d9b2f95e2274"] expiry=null +[Presence][Create] presenceId=69c3c4ca1b3d271733bf creatorUserId=69c3c4ca020b86e230fa creatorUserInternalId=47 status=seed-load-46 permissions=["read(\"user:69c3c4ca020b86e230fa\")"] allowedReadRoles=["user:69c3c4ca020b86e230fa"] expiry=null +[Presence][Create] presenceId=69c3c4ca35506c30928c creatorUserId=69c3c4ca1c19fdab77fe creatorUserInternalId=48 status=seed-load-47 permissions=["read(\"user:69c3c4ca1c19fdab77fe\")"] allowedReadRoles=["user:69c3c4ca1c19fdab77fe"] expiry=null +[Presence][Create] presenceId=69c3c4ca4e2e607ea1b4 creatorUserId=69c3c4ca368b01d28694 creatorUserInternalId=49 status=seed-load-48 permissions=["read(\"user:69c3c4ca368b01d28694\")"] allowedReadRoles=["user:69c3c4ca368b01d28694"] expiry=null +[Presence][Create] presenceId=69c3c4ca6bc2cda50bf7 creatorUserId=69c3c4ca4f12d1833b1b creatorUserInternalId=50 status=seed-load-49 permissions=["read(\"user:69c3c4ca4f12d1833b1b\")"] allowedReadRoles=["user:69c3c4ca4f12d1833b1b"] expiry=null +[Presence][Create] presenceId=69c3c4ca82e2a56f06be creatorUserId=69c3c4ca6c88186bfb08 creatorUserInternalId=51 status=seed-load-50 permissions=["read(\"user:69c3c4ca6c88186bfb08\")"] allowedReadRoles=["user:69c3c4ca6c88186bfb08"] expiry=null +[Presence][Create] presenceId=69c3c4ca993f6ee33f80 creatorUserId=69c3c4ca83803d70d020 creatorUserInternalId=52 status=seed-load-51 permissions=["read(\"user:69c3c4ca83803d70d020\")"] allowedReadRoles=["user:69c3c4ca83803d70d020"] expiry=null +[Presence][Create] presenceId=69c3c4caafaa4b1ab97d creatorUserId=69c3c4ca9a41e097a1f4 creatorUserInternalId=53 status=seed-load-52 permissions=["read(\"user:69c3c4ca9a41e097a1f4\")"] allowedReadRoles=["user:69c3c4ca9a41e097a1f4"] expiry=null +[Presence][Create] presenceId=69c3c4cac5afd9a17d7a creatorUserId=69c3c4cab04b5d8d847b creatorUserInternalId=54 status=seed-load-53 permissions=["read(\"user:69c3c4cab04b5d8d847b\")"] allowedReadRoles=["user:69c3c4cab04b5d8d847b"] expiry=null +[Presence][Create] presenceId=69c3c4cadcceb3ef2f18 creatorUserId=69c3c4cac6da2c7334bc creatorUserInternalId=55 status=seed-load-54 permissions=["read(\"user:69c3c4cac6da2c7334bc\")"] allowedReadRoles=["user:69c3c4cac6da2c7334bc"] expiry=null +[Presence][Create] presenceId=69c3c4cb012d0ac72269 creatorUserId=69c3c4cadd9b588677f6 creatorUserInternalId=56 status=seed-load-55 permissions=["read(\"user:69c3c4cadd9b588677f6\")"] allowedReadRoles=["user:69c3c4cadd9b588677f6"] expiry=null +[Presence][Create] presenceId=69c3c4cb171b6055fb23 creatorUserId=69c3c4cb01dba80b770f creatorUserInternalId=57 status=seed-load-56 permissions=["read(\"user:69c3c4cb01dba80b770f\")"] allowedReadRoles=["user:69c3c4cb01dba80b770f"] expiry=null +[Presence][Create] presenceId=69c3c4cb2d4888836fc7 creatorUserId=69c3c4cb17ff0602b51f creatorUserInternalId=58 status=seed-load-57 permissions=["read(\"user:69c3c4cb17ff0602b51f\")"] allowedReadRoles=["user:69c3c4cb17ff0602b51f"] expiry=null +[Presence][Create] presenceId=69c3c4cb434f9b80288f creatorUserId=69c3c4cb2df430669fc9 creatorUserInternalId=59 status=seed-load-58 permissions=["read(\"user:69c3c4cb2df430669fc9\")"] allowedReadRoles=["user:69c3c4cb2df430669fc9"] expiry=null +[Presence][Create] presenceId=69c3c4cb593ab1108adb creatorUserId=69c3c4cb4429dacfa37b creatorUserInternalId=60 status=seed-load-59 permissions=["read(\"user:69c3c4cb4429dacfa37b\")"] allowedReadRoles=["user:69c3c4cb4429dacfa37b"] expiry=null +[Presence][Create] presenceId=69c3c4cb705ac2108e84 creatorUserId=69c3c4cb59fc972454ca creatorUserInternalId=61 status=seed-load-60 permissions=["read(\"user:69c3c4cb59fc972454ca\")"] allowedReadRoles=["user:69c3c4cb59fc972454ca"] expiry=null +[Presence][Create] presenceId=69c3c4cb86f28aa29eb6 creatorUserId=69c3c4cb7117244e97a9 creatorUserInternalId=62 status=seed-load-61 permissions=["read(\"user:69c3c4cb7117244e97a9\")"] allowedReadRoles=["user:69c3c4cb7117244e97a9"] expiry=null +[Presence][Create] presenceId=69c3c4cb9de034bffd1b creatorUserId=69c3c4cb87dcaffd33af creatorUserInternalId=63 status=seed-load-62 permissions=["read(\"user:69c3c4cb87dcaffd33af\")"] allowedReadRoles=["user:69c3c4cb87dcaffd33af"] expiry=null +[Presence][Create] presenceId=69c3c4cbb4f00f24c7a4 creatorUserId=69c3c4cb9f026ad79750 creatorUserInternalId=64 status=seed-load-63 permissions=["read(\"user:69c3c4cb9f026ad79750\")"] allowedReadRoles=["user:69c3c4cb9f026ad79750"] expiry=null +[Presence][Create] presenceId=69c3c4cbcab6da5b5ab0 creatorUserId=69c3c4cbb59aa1049c7d creatorUserInternalId=65 status=seed-load-64 permissions=["read(\"user:69c3c4cbb59aa1049c7d\")"] allowedReadRoles=["user:69c3c4cbb59aa1049c7d"] expiry=null +[Presence][Create] presenceId=69c3c4cbe369257073e6 creatorUserId=69c3c4cbcbafae64b2d5 creatorUserInternalId=66 status=seed-load-65 permissions=["read(\"user:69c3c4cbcbafae64b2d5\")"] allowedReadRoles=["user:69c3c4cbcbafae64b2d5"] expiry=null +[Presence][Create] presenceId=69c3c4cc0528e1889ffa creatorUserId=69c3c4cbe4234c8a7436 creatorUserInternalId=67 status=seed-load-66 permissions=["read(\"user:69c3c4cbe4234c8a7436\")"] allowedReadRoles=["user:69c3c4cbe4234c8a7436"] expiry=null +[Presence][Create] presenceId=69c3c4cc1be86868f379 creatorUserId=69c3c4cc05da299e391c creatorUserInternalId=68 status=seed-load-67 permissions=["read(\"user:69c3c4cc05da299e391c\")"] allowedReadRoles=["user:69c3c4cc05da299e391c"] expiry=null +[Presence][Create] presenceId=69c3c4cc32ab71359be8 creatorUserId=69c3c4cc1d34921f5d3d creatorUserInternalId=69 status=seed-load-68 permissions=["read(\"user:69c3c4cc1d34921f5d3d\")"] allowedReadRoles=["user:69c3c4cc1d34921f5d3d"] expiry=null +[Presence][Create] presenceId=69c3c4cc48ec1ee35390 creatorUserId=69c3c4cc3395c6dbf6b4 creatorUserInternalId=70 status=seed-load-69 permissions=["read(\"user:69c3c4cc3395c6dbf6b4\")"] allowedReadRoles=["user:69c3c4cc3395c6dbf6b4"] expiry=null +[Presence][Create] presenceId=69c3c4cc5f200fbd5f4c creatorUserId=69c3c4cc49bba8351229 creatorUserInternalId=71 status=seed-load-70 permissions=["read(\"user:69c3c4cc49bba8351229\")"] allowedReadRoles=["user:69c3c4cc49bba8351229"] expiry=null +[Presence][Create] presenceId=69c3c4cc770048b9c583 creatorUserId=69c3c4cc600e94c356fb creatorUserInternalId=72 status=seed-load-71 permissions=["read(\"user:69c3c4cc600e94c356fb\")"] allowedReadRoles=["user:69c3c4cc600e94c356fb"] expiry=null +[Presence][Create] presenceId=69c3c4cc8e1b4aacfe66 creatorUserId=69c3c4cc77c94de533a9 creatorUserInternalId=73 status=seed-load-72 permissions=["read(\"user:69c3c4cc77c94de533a9\")"] allowedReadRoles=["user:69c3c4cc77c94de533a9"] expiry=null +[Presence][Create] presenceId=69c3c4cca54115b2073a creatorUserId=69c3c4cc8ec226e16aa5 creatorUserInternalId=74 status=seed-load-73 permissions=["read(\"user:69c3c4cc8ec226e16aa5\")"] allowedReadRoles=["user:69c3c4cc8ec226e16aa5"] expiry=null +[Presence][Create] presenceId=69c3c4ccbc03600bda1c creatorUserId=69c3c4cca657ec7b7eae creatorUserInternalId=75 status=seed-load-74 permissions=["read(\"user:69c3c4cca657ec7b7eae\")"] allowedReadRoles=["user:69c3c4cca657ec7b7eae"] expiry=null +[Presence][Create] presenceId=69c3c4ccd24f44be3a9b creatorUserId=69c3c4ccbcb98856f5f1 creatorUserInternalId=76 status=seed-load-75 permissions=["read(\"user:69c3c4ccbcb98856f5f1\")"] allowedReadRoles=["user:69c3c4ccbcb98856f5f1"] expiry=null +[Presence][Create] presenceId=69c3c4cce9253a1cc7ef creatorUserId=69c3c4ccd2fdda2721b3 creatorUserInternalId=77 status=seed-load-76 permissions=["read(\"user:69c3c4ccd2fdda2721b3\")"] allowedReadRoles=["user:69c3c4ccd2fdda2721b3"] expiry=null +[Presence][Create] presenceId=69c3c4cd0c5b8c4f36da creatorUserId=69c3c4cce9fe57f1aa39 creatorUserInternalId=78 status=seed-load-77 permissions=["read(\"user:69c3c4cce9fe57f1aa39\")"] allowedReadRoles=["user:69c3c4cce9fe57f1aa39"] expiry=null +[Presence][Create] presenceId=69c3c4cd22d2340473bf creatorUserId=69c3c4cd0d72b78e9721 creatorUserInternalId=79 status=seed-load-78 permissions=["read(\"user:69c3c4cd0d72b78e9721\")"] allowedReadRoles=["user:69c3c4cd0d72b78e9721"] expiry=null +[Presence][Create] presenceId=69c3c4cd390bf0aa2f24 creatorUserId=69c3c4cd23c36931906d creatorUserInternalId=80 status=seed-load-79 permissions=["read(\"user:69c3c4cd23c36931906d\")"] allowedReadRoles=["user:69c3c4cd23c36931906d"] expiry=null +[Presence][Create] presenceId=69c3c4cd4f3fa151fece creatorUserId=69c3c4cd39faa29575ad creatorUserInternalId=81 status=seed-load-80 permissions=["read(\"user:69c3c4cd39faa29575ad\")"] allowedReadRoles=["user:69c3c4cd39faa29575ad"] expiry=null +[Presence][Create] presenceId=69c3c4cd66700b2ebeb1 creatorUserId=69c3c4cd504c8d82de8c creatorUserInternalId=82 status=seed-load-81 permissions=["read(\"user:69c3c4cd504c8d82de8c\")"] allowedReadRoles=["user:69c3c4cd504c8d82de8c"] expiry=null +[Presence][Create] presenceId=69c3c4cd7d7e4c3e39b7 creatorUserId=69c3c4cd6722c11f5d3c creatorUserInternalId=83 status=seed-load-82 permissions=["read(\"user:69c3c4cd6722c11f5d3c\")"] allowedReadRoles=["user:69c3c4cd6722c11f5d3c"] expiry=null +[Presence][Create] presenceId=69c3c4cd9500ad23f2d1 creatorUserId=69c3c4cd7f5b5d824f19 creatorUserInternalId=84 status=seed-load-83 permissions=["read(\"user:69c3c4cd7f5b5d824f19\")"] allowedReadRoles=["user:69c3c4cd7f5b5d824f19"] expiry=null +[Presence][Create] presenceId=69c3c4cdab6430527db1 creatorUserId=69c3c4cd95ed047abaf8 creatorUserInternalId=85 status=seed-load-84 permissions=["read(\"user:69c3c4cd95ed047abaf8\")"] allowedReadRoles=["user:69c3c4cd95ed047abaf8"] expiry=null +[Presence][Create] presenceId=69c3c4cdce5d90e08839 creatorUserId=69c3c4cdaeea5ad72cee creatorUserInternalId=86 status=seed-load-85 permissions=["read(\"user:69c3c4cdaeea5ad72cee\")"] allowedReadRoles=["user:69c3c4cdaeea5ad72cee"] expiry=null +[Presence][Create] presenceId=69c3c4cdec1a72eb157f creatorUserId=69c3c4cdcf21d0f4fa0f creatorUserInternalId=87 status=seed-load-86 permissions=["read(\"user:69c3c4cdcf21d0f4fa0f\")"] allowedReadRoles=["user:69c3c4cdcf21d0f4fa0f"] expiry=null +[Presence][Create] presenceId=69c3c4ce0f34c32e62f1 creatorUserId=69c3c4cded1340d16f05 creatorUserInternalId=88 status=seed-load-87 permissions=["read(\"user:69c3c4cded1340d16f05\")"] allowedReadRoles=["user:69c3c4cded1340d16f05"] expiry=null +[Presence][Create] presenceId=69c3c4ce2805daf2a243 creatorUserId=69c3c4ce0fe17c4125c5 creatorUserInternalId=89 status=seed-load-88 permissions=["read(\"user:69c3c4ce0fe17c4125c5\")"] allowedReadRoles=["user:69c3c4ce0fe17c4125c5"] expiry=null +[Presence][Create] presenceId=69c3c4ce3fbeb7fa150d creatorUserId=69c3c4ce28b4931bca5a creatorUserInternalId=90 status=seed-load-89 permissions=["read(\"user:69c3c4ce28b4931bca5a\")"] allowedReadRoles=["user:69c3c4ce28b4931bca5a"] expiry=null +[Presence][Create] presenceId=69c3c4ce57ab854ca9db creatorUserId=69c3c4ce409823582a3a creatorUserInternalId=91 status=seed-load-90 permissions=["read(\"user:69c3c4ce409823582a3a\")"] allowedReadRoles=["user:69c3c4ce409823582a3a"] expiry=null +[Presence][Create] presenceId=69c3c4ce6e1975b7549f creatorUserId=69c3c4ce5888c8058712 creatorUserInternalId=92 status=seed-load-91 permissions=["read(\"user:69c3c4ce5888c8058712\")"] allowedReadRoles=["user:69c3c4ce5888c8058712"] expiry=null +[Presence][Create] presenceId=69c3c4ce858176eef53e creatorUserId=69c3c4ce6ef972bf230f creatorUserInternalId=93 status=seed-load-92 permissions=["read(\"user:69c3c4ce6ef972bf230f\")"] allowedReadRoles=["user:69c3c4ce6ef972bf230f"] expiry=null +[Presence][Create] presenceId=69c3c4ce9b542d1a21cd creatorUserId=69c3c4ce865fbfd4d52c creatorUserInternalId=94 status=seed-load-93 permissions=["read(\"user:69c3c4ce865fbfd4d52c\")"] allowedReadRoles=["user:69c3c4ce865fbfd4d52c"] expiry=null +[Presence][Create] presenceId=69c3c4ceb1255ea5eb53 creatorUserId=69c3c4ce9befaa675543 creatorUserInternalId=95 status=seed-load-94 permissions=["read(\"user:69c3c4ce9befaa675543\")"] allowedReadRoles=["user:69c3c4ce9befaa675543"] expiry=null +[Presence][Create] presenceId=69c3c4cec7e3c02d8c69 creatorUserId=69c3c4ceb1fed3ad5846 creatorUserInternalId=96 status=seed-load-95 permissions=["read(\"user:69c3c4ceb1fed3ad5846\")"] allowedReadRoles=["user:69c3c4ceb1fed3ad5846"] expiry=null +[Presence][Create] presenceId=69c3c4cedde388757f65 creatorUserId=69c3c4cec8d5f2a62f19 creatorUserInternalId=97 status=seed-load-96 permissions=["read(\"user:69c3c4cec8d5f2a62f19\")"] allowedReadRoles=["user:69c3c4cec8d5f2a62f19"] expiry=null +[Presence][Create] presenceId=69c3c4cf0301ecb8f64d creatorUserId=69c3c4cedeb597b92294 creatorUserInternalId=98 status=seed-load-97 permissions=["read(\"user:69c3c4cedeb597b92294\")"] allowedReadRoles=["user:69c3c4cedeb597b92294"] expiry=null +[Presence][Create] presenceId=69c3c4cf18ec2a87aab1 creatorUserId=69c3c4cf03c05c68c5ff creatorUserInternalId=99 status=seed-load-98 permissions=["read(\"user:69c3c4cf03c05c68c5ff\")"] allowedReadRoles=["user:69c3c4cf03c05c68c5ff"] expiry=null +[Presence][Create] presenceId=69c3c4cf2fd9eb2fd41e creatorUserId=69c3c4cf19b8e319f413 creatorUserInternalId=100 status=seed-load-99 permissions=["read(\"user:69c3c4cf19b8e319f413\")"] allowedReadRoles=["user:69c3c4cf19b8e319f413"] expiry=null +[Presence][XList] requesterUserId=69c3c4cf30b59e4a5549 requesterUserInternalId=101 requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c54841d17f980b presenceId=69c3c4c56ace451371a1 allowedReadRoles=["user:69c3c4c54841d17f980b"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c54841d17f980b". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c54841d17f980b"]" was given. status=seed-load-0 +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c56bb8f17caf7a presenceId=69c3c4c588d8b30491a4 allowedReadRoles=["user:69c3c4c56bb8f17caf7a"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c56bb8f17caf7a". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c56bb8f17caf7a"]" was given. status=seed-load-1 +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c589c2990fa68b presenceId=69c3c4c59eb137bbfafb allowedReadRoles=["user:69c3c4c589c2990fa68b"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c589c2990fa68b". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c589c2990fa68b"]" was given. status=seed-load-2 +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c59f9c34c4d5f7 presenceId=69c3c4c5b57384645c88 allowedReadRoles=["user:69c3c4c59f9c34c4d5f7"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c59f9c34c4d5f7". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c59f9c34c4d5f7"]" was given. status=seed-load-3 +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c5b61873b720cb presenceId=69c3c4c5cab9908e0156 allowedReadRoles=["user:69c3c4c5b61873b720cb"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c5b61873b720cb". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c5b61873b720cb"]" was given. status=seed-load-4 +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c5cb7be0a29bcd presenceId=69c3c4c5e1192f7986fd allowedReadRoles=["user:69c3c4c5cb7be0a29bcd"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c5cb7be0a29bcd". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c5cb7be0a29bcd"]" was given. status=seed-load-5 +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c5e2057801da30 presenceId=69c3c4c60334c89b7103 allowedReadRoles=["user:69c3c4c5e2057801da30"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c5e2057801da30". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c5e2057801da30"]" was given. status=seed-load-6 +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c6044b3ee83478 presenceId=69c3c4c6195a3a1d88e8 allowedReadRoles=["user:69c3c4c6044b3ee83478"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c6044b3ee83478". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c6044b3ee83478"]" was given. status=seed-load-7 +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c61a3bc31f5004 presenceId=69c3c4c62f7ae5c297e6 allowedReadRoles=["user:69c3c4c61a3bc31f5004"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c61a3bc31f5004". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c61a3bc31f5004"]" was given. status=seed-load-8 +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c63095cd7eb79b presenceId=69c3c4c645a5783d2247 allowedReadRoles=["user:69c3c4c63095cd7eb79b"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c63095cd7eb79b". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c63095cd7eb79b"]" was given. status=seed-load-9 +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c646d615986db9 presenceId=69c3c4c65c9eb21b4da4 allowedReadRoles=["user:69c3c4c646d615986db9"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c646d615986db9". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c646d615986db9"]" was given. status=seed-load-10 +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c65da521f6ded7 presenceId=69c3c4c672ad6e939983 allowedReadRoles=["user:69c3c4c65da521f6ded7"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c65da521f6ded7". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c65da521f6ded7"]" was given. status=seed-load-11 +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c673867d685ef5 presenceId=69c3c4c6885529a9ee0c allowedReadRoles=["user:69c3c4c673867d685ef5"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c673867d685ef5". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c673867d685ef5"]" was given. status=seed-load-12 +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c6894169cd73d6 presenceId=69c3c4c6a475ce934b73 allowedReadRoles=["user:69c3c4c6894169cd73d6"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c6894169cd73d6". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c6894169cd73d6"]" was given. status=seed-load-13 +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c6a63983e2609d presenceId=69c3c4c6be613d23f503 allowedReadRoles=["user:69c3c4c6a63983e2609d"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c6a63983e2609d". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c6a63983e2609d"]" was given. status=seed-load-14 +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c6bf2ba633e9b0 presenceId=69c3c4c6d6c7e4efff52 allowedReadRoles=["user:69c3c4c6bf2ba633e9b0"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c6bf2ba633e9b0". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c6bf2ba633e9b0"]" was given. status=seed-load-15 +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c6d7f8bb919b94 presenceId=69c3c4c6eef7edd3454a allowedReadRoles=["user:69c3c4c6d7f8bb919b94"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c6d7f8bb919b94". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c6d7f8bb919b94"]" was given. status=seed-load-16 +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c6efce3ea5c64f presenceId=69c3c4c712aa150e02ff allowedReadRoles=["user:69c3c4c6efce3ea5c64f"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c6efce3ea5c64f". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c6efce3ea5c64f"]" was given. status=seed-load-17 +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c713b02d43af5d presenceId=69c3c4c72947176267d4 allowedReadRoles=["user:69c3c4c713b02d43af5d"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c713b02d43af5d". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c713b02d43af5d"]" was given. status=seed-load-18 +[Presence][XList][DENY] requesterUserId=69c3c4cf30b59e4a5549 presenceOwnerUserId=69c3c4c72a2f5a455615 presenceId=69c3c4c745ee2bc05dd7 allowedReadRoles=["user:69c3c4c72a2f5a455615"] requesterRoles=["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549/unverified","users/unverified"] validatorDescription=Missing "read" permission for role "user:69c3c4c72a2f5a455615". Only "["any","users","user:69c3c4cf30b59e4a5549","user:69c3c4cf30b59e4a5549\/unverified","users\/unverified"]" scopes are allowed and "["user:69c3c4c72a2f5a455615"]" was given. status=seed-load-19 +Worker 1 stopped successfully +Worker 2 stopped successfully +Worker 3 stopped successfully +Worker 4 stopped successfully +Worker 8 stopped successfully +Worker 6 stopped successfully +Worker 5 stopped successfully +Worker 36 stopped successfully +Worker 7 stopped successfully +Worker 23 stopped successfully +Worker 9 stopped successfully +Worker 49 stopped successfully +Worker 24 stopped successfully +Worker 50 stopped successfully +Worker 31 stopped successfully +Worker 22 stopped successfully +Worker 11 stopped successfully +Worker 21 stopped successfully +Worker 25 stopped successfully +Worker 52 stopped successfully +Worker 12 stopped successfully +Worker 32 stopped successfully +Worker 20 stopped successfully +Worker 19 stopped successfully +Worker 53 stopped successfully +Worker 39 stopped successfully +Worker 26 stopped successfully +Worker 54 stopped successfully +Worker 13 stopped successfully +Worker 10 stopped successfully +Worker 55 stopped successfully +Worker 28 stopped successfully +Worker 34 stopped successfully +Worker 40 stopped successfully +Worker 27 stopped successfully +Worker 29 stopped successfully +Worker 18 stopped successfully +Worker 35 stopped successfully +Worker 14 stopped successfully +Worker 30 stopped successfully +Worker 37 stopped successfully +Worker 33 stopped successfully +Worker 56 stopped successfully +Worker 15 stopped successfully +Worker 41 stopped successfully +Worker 57 stopped successfully +Worker 58 stopped successfully +Worker 51 stopped successfully +Worker 42 stopped successfully +Worker 16 stopped successfully +Worker 59 stopped successfully +Worker 60 stopped successfully +Worker 17 stopped successfully +Worker 48 stopped successfully +Worker 47 stopped successfully +Worker 46 stopped successfully +Worker 45 stopped successfully +Worker 44 stopped successfully +Worker 38 stopped successfully +Worker 43 stopped successfully +Worker 61 stopped successfully diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index 06312d9cb2..ec23a9a112 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\Presence; use Appwrite\Platform\Modules\Project; use Appwrite\Platform\Modules\Projects; use Appwrite\Platform\Modules\Proxy; @@ -31,6 +32,7 @@ class Appwrite extends Platform $this->addModule(new Projects\Module()); $this->addModule(new Functions\Module()); $this->addModule(new Health\Module()); + $this->addModule(new Presence\Module()); $this->addModule(new Sites\Module()); $this->addModule(new Console\Module()); $this->addModule(new Proxy\Module()); diff --git a/src/Appwrite/Platform/Modules/Presence/HTTP/Create.php b/src/Appwrite/Platform/Modules/Presence/HTTP/Create.php new file mode 100644 index 0000000000..4a7a300ef5 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presence/HTTP/Create.php @@ -0,0 +1,91 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/iterative/presence') + ->groups(['api', 'presence']) + ->label('scope', 'documents.write') + ->param('status', '', new Text(255), 'Presence status.') + ->param('permissions', [], new ArrayList(new Text(255), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Permissions list.') + ->param('expiry', null, new Text(64, 0), 'Presence expiry in ISO 8601 format.', true) + ->inject('response') + ->inject('user') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action( + string $status, + array $permissions, + ?string $expiry, + Response $response, + Document $user, + Database $dbForProject + ): void { + if (empty($user->getId())) { + throw new Exception(Exception::USER_UNAUTHORIZED, 'User is unauthorized.'); + } + + sort($permissions); + + $parsedExpiry = null; + if (!empty($expiry)) { + try { + $parsedExpiry = new DateTime($expiry); + } catch (\Throwable) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Expiry is not a valid datetime.'); + } + } + $presenceLog = $dbForProject->upsertDocument('presenceLogs', new Document([ + // Stable identity so repeated createPresence calls overwrite the latest state + // instead of creating multiple documents per user. + 'userInternalId' => $user->getSequence(), + 'userId' => $user->getId(), + '$permissions' => $permissions, + 'perms_md5' => md5((string) json_encode($permissions)), + 'status' => $status, + 'expiry' => $parsedExpiry, + 'source' => 'realtime' + ])); + + $dbForProject->upsertDocument('presence', new Document([ + // Stable identity so the "presence" table doesn't accumulate duplicates + '$id' => $user->getId(), + 'userInternalId' => $user->getSequence(), + 'userId' => $user->getId(), + ])); + + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic(new Document([ + '$id' => $presenceLog->getId(), + 'userId' => $presenceLog->getAttribute('userId'), + 'status' => $presenceLog->getAttribute('status'), + 'expiry' => $presenceLog->getAttribute('expiry'), + '$createdAt' => $presenceLog->getCreatedAt(), + '$updatedAt' => $presenceLog->getUpdatedAt(), + ]), Response::MODEL_PRESENCE); + } +} diff --git a/src/Appwrite/Platform/Modules/Presence/HTTP/Get.php b/src/Appwrite/Platform/Modules/Presence/HTTP/Get.php new file mode 100644 index 0000000000..42011ad9f1 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presence/HTTP/Get.php @@ -0,0 +1,102 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + // TODO: create a separate scope + ->groups(['api', 'presence']) + ->label('scope', 'documents.read') + ->setHttpPath('/v1/presence:/:presenceId') + ->param('presenceId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Presence ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->inject('user') + ->callback($this->action(...)); + } + // Since POC so not adding queries or advanced filtering now + // just for getting group based presence list based on permissions + public function action(string $presenceId, UtopiaResponse $response, Database $dbForProject, Authorization $authorization, Document $user): void + { + try { + // `presenceLogs` are permissioned per-document (via `$permissions`), + // but DB-level auth for this metadata collection may be missing. + // So we fetch without auth checks, then enforce read permission using the document itself. + $document = $authorization->skip(fn () => $dbForProject->getDocument('presenceLogs', $presenceId)); + } catch (OrderException $e) { + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order column '{$e->getAttribute()}' had a null value. Cursor pagination requires all rows order column values are non-null."); + } catch (QueryException) { + throw new Exception(Exception::GENERAL_QUERY_INVALID); + } + + Console::info( + \sprintf( + '[Presence][Get] presenceId=%s requesterUserId=%s requesterUserInternalId=%s requesterRoles=%s', + $presenceId, + $user->getId(), + (string) $user->getSequence(), + \json_encode($authorization->getRoles(), JSON_UNESCAPED_SLASHES) + ) + ); + + if ($document->isEmpty()) { + throw new Exception(Exception::DOCUMENT_NOT_FOUND, 'Presence not found.'); + } + + // Document permission context + $documentUserId = $document->getAttribute('userId'); + $documentReadRoles = $document->getRead(); + Console::info( + \sprintf( + '[Presence][Get] presenceId=%s documentUserId=%s documentAllowedReadRoles=%s documentAllPermissions=%s', + $presenceId, + (string) $documentUserId, + \json_encode($documentReadRoles, JSON_UNESCAPED_SLASHES), + \json_encode($document->getPermissions(), JSON_UNESCAPED_SLASHES) + ) + ); + + if ( + !($authorization->isValid( + new Input(Database::PERMISSION_READ, $document->getRead()) + )) + ) { + Console::info( + \sprintf( + '[Presence][Get][DENY] presenceId=%s requesterUserId=%s allowedReadRoles=%s requestedReadRoles=%s validatorDescription=%s', + $presenceId, + $user->getId(), + \json_encode($documentReadRoles, JSON_UNESCAPED_SLASHES), + \json_encode($authorization->getRoles(), JSON_UNESCAPED_SLASHES), + $authorization->getDescription() + ) + ); + // Don't leak existence for unauthorized callers. + throw new Exception(Exception::DOCUMENT_NOT_FOUND, 'Presence not found.'); + } + + $response->dynamic($document, UtopiaResponse::MODEL_PRESENCE); + } +} diff --git a/src/Appwrite/Platform/Modules/Presence/HTTP/Iterative/XList.php b/src/Appwrite/Platform/Modules/Presence/HTTP/Iterative/XList.php new file mode 100644 index 0000000000..ecd551e494 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presence/HTTP/Iterative/XList.php @@ -0,0 +1,69 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + // TODO: create a separate scope + ->groups(['api', 'presence']) + ->label('scope', 'documents.read') + ->setHttpPath('/v1/iterative/presence') + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->inject('user') + ->callback($this->action(...)); + } + // Since POC so not adding queries or advanced filtering now + // just for getting group based presence list based on permissions + public function action(UtopiaResponse $response, Database $dbForProject, Authorization $authorization, Document $user): void + { + try { + $presenceLogs = []; + + $users = $authorization->skip(fn () => $dbForProject->find('presence', [ + Query::limit(10000), + ])); + foreach ($users as $user) { + $presenceLog = $dbForProject->findOne('presenceLogs', [ + Query::equal('userId', [$user['userId']]), + Query::orderDesc('$updatedAt'), + // Tie-breaker: `$updatedAt` has only millisecond precision. + Query::orderDesc('$id'), + Query::limit(1), + ]); + $presenceLogs[] = $presenceLog; + } + } catch (OrderException $e) { + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order column '{$e->getAttribute()}' had a null value. Cursor pagination requires all rows order column values are non-null."); + } catch (QueryException) { + throw new Exception(Exception::GENERAL_QUERY_INVALID); + } + + $response->dynamic(new Document([ + 'presences' => $presenceLogs, + 'total' => \count($presenceLogs), + ]), UtopiaResponse::MODEL_PRESENCE_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Presence/Module.php b/src/Appwrite/Platform/Modules/Presence/Module.php new file mode 100644 index 0000000000..18082a4068 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presence/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/Presence/Services/Http.php b/src/Appwrite/Platform/Modules/Presence/Services/Http.php new file mode 100644 index 0000000000..31316edff4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presence/Services/Http.php @@ -0,0 +1,19 @@ +type = Service::TYPE_HTTP; + $this->addAction(CreatePresence::getName(), new CreatePresence()); + $this->addAction(ListPresence::getName(), new ListPresence()); + $this->addAction(GetPresence::getName(), new GetPresence()); + } +} diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index c2fc520da3..5a9db96527 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -127,6 +127,10 @@ class Response extends SwooleResponse public const MODEL_JWT = 'jwt'; public const MODEL_PREFERENCES = 'preferences'; + // Presence + public const MODEL_PRESENCE = 'presence'; + public const MODEL_PRESENCE_LIST = 'presenceList'; + // MFA public const MODEL_MFA_TYPE = 'mfaType'; public const MODEL_MFA_FACTORS = 'mfaFactors'; diff --git a/src/Appwrite/Utopia/Response/Model/Presence.php b/src/Appwrite/Utopia/Response/Model/Presence.php new file mode 100644 index 0000000000..b42f142b8b --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/Presence.php @@ -0,0 +1,72 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Presence ID.', + 'default' => '', + 'example' => '67e0c52f001f6d12a345', + ]) + ->addRule('userId', [ + 'type' => self::TYPE_STRING, + 'description' => 'User ID.', + 'default' => '', + 'example' => '67e0c3fe002a4b98d210', + ]) + ->addRule('status', [ + 'type' => self::TYPE_STRING, + 'description' => 'Presence status.', + 'default' => '', + 'example' => 'online', + ]) + ->addRule('expiry', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Presence expiration date in ISO 8601 format.', + 'required' => false, + 'default' => null, + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('$createdAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Project creation date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('$updatedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Project update date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Presence'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_PRESENCE; + } +} diff --git a/tests/e2e/Services/Realtime/PresenceBase.php b/tests/e2e/Services/Realtime/PresenceBase.php new file mode 100644 index 0000000000..d079ed2907 --- /dev/null +++ b/tests/e2e/Services/Realtime/PresenceBase.php @@ -0,0 +1,1107 @@ +> + */ + private array $timings = [ + 'createPresenceApi' => [], + 'createPresenceRealtime' => [], + 'listPresence' => [], + ]; + + abstract protected function getNumberOfUsersToCreate(): int; + + abstract protected function getListPresenceURL(): string; + + protected function getSeedPresenceDocumentsCount(): int + { + return 0; + } + + public function testListPresenceBenchmarkWithPermissionGroups(): void + { + // Benchmark A - Real traffic benchmark: + // - Create 500 presence docs through websocket (realtime path) + // - Permissions: `read(any)` so all viewers should see all presences + // - Send presence rounds every ~30s (configurable), and call listPresence 5 times per round + + $totalUsers = 500; + $cycles = (int) (\getenv('PRESENCE_BENCH_CYCLES') ?: 2); + $intervalSeconds = (int) (\getenv('PRESENCE_BENCH_INTERVAL_SECONDS') ?: 30); + $occurrences = 5; + $ownerDelayMs = (int) (\getenv('PRESENCE_BENCH_OWNER_DELAY_MS') ?: 5000); + $ownerDelayMs = \max(0, $ownerDelayMs); + + $users = []; + for ($i = 0; $i < $totalUsers; $i++) { + $users[] = $this->getUser(true); + } + + $viewer = $users[0]; + $permissions = [Permission::read(Role::any())]; + + $realtimeConnections = $totalUsers * $cycles; + $listCalls = $occurrences * $cycles; + + Console::info(\sprintf( + '[Presence Benchmark A] totalUsers=%d cycles=%d intervalSeconds=%d realtimeConnections=%d listCalls=%d permissions=%s', + $totalUsers, + $cycles, + $intervalSeconds, + $realtimeConnections, + $listCalls, + \json_encode($permissions, JSON_UNESCAPED_SLASHES) + )); + + $overallListElapsed = []; + + $expectedOwnerIds = [ + $users[0]['$id'], + $users[(int) \floor($totalUsers / 2)]['$id'], + $users[$totalUsers - 1]['$id'], + ]; + + for ($cycle = 0; $cycle < $cycles; $cycle++) { + // Send presence updates for all owners (each owner gets one websocket connection). + foreach ($users as $ownerIndex => $owner) { + $status = 'benchA-presence-c' . $cycle . '-o' . $ownerIndex; + $this->reportPresence($owner, $status, $permissions); + if ($ownerDelayMs > 0) { + \usleep($ownerDelayMs); + } + } + + // Allow realtime write to settle. + \usleep(100000); + + $cycleListElapsed = []; + for ($i = 0; $i < $occurrences; $i++) { + $benchmark = $this->fetchPresenceListAs($viewer); + $this->assertEquals(200, $benchmark['response']['headers']['status-code']); + $this->assertGreaterThan(0.0, $benchmark['elapsedMs']); + + $cycleListElapsed[] = $benchmark['elapsedMs']; + $overallListElapsed[] = $benchmark['elapsedMs']; + + if ($i === $occurrences - 1) { + $rows = $this->extractPresenceRows($benchmark['response']['body']); + $visibleMap = $this->indexPresenceRowsByUserId($rows); + + $this->assertEquals( + $totalUsers, + \count($visibleMap), + \sprintf( + 'Expected viewer to see all %d presences, got %d', + $totalUsers, + \count($visibleMap) + ) + ); + + foreach ($expectedOwnerIds as $ownerUserId) { + // We only validate statuses for a few sample users to keep the benchmark lightweight. + $this->assertEquals( + 'benchA-presence-c' . $cycle . '-o' . $this->getOwnerIndexByUserId($users, $ownerUserId), + $visibleMap[$ownerUserId]['status'] ?? null + ); + } + + Console::info(\sprintf( + '[Presence Benchmark A] cycle=%d listPresence occurrence=%d elapsedMs=%.2f rows=%d', + $cycle, + $i + 1, + $benchmark['elapsedMs'], + \count($visibleMap) + )); + } else { + Console::info(\sprintf( + '[Presence Benchmark A] cycle=%d listPresence occurrence=%d elapsedMs=%.2f', + $cycle, + $i + 1, + $benchmark['elapsedMs'] + )); + } + } + + $avg = \array_sum($cycleListElapsed) / \max(1, \count($cycleListElapsed)); + Console::info(\sprintf( + '[Presence Benchmark A] cycle=%d listPresence average over %d calls: %.2fms', + $cycle, + $occurrences, + $avg + )); + + if ($cycle < $cycles - 1) { + \usleep($intervalSeconds * 1000000); + } + } + + $overallAvg = \array_sum($overallListElapsed) / \max(1, \count($overallListElapsed)); + Console::info(\sprintf( + '[Presence Benchmark A Summary] totalUsers=%d pattern=read(any)->viewer sees all owners realtimeConnections=%d listCalls=%d listPresenceAvg=%.2fms', + $totalUsers, + $realtimeConnections, + $listCalls, + $overallAvg + )); + + $this->printTimingSummary(); + } + + public function testListPresenceBenchmarkReadUsersWindow500100(): void + { + // Benchmark B - Rotation window permissions: + // - Create 500 presence docs through websocket (realtime path) + // - Permissions: for each owner, allow reading for exactly 100 viewers (1/5th) + // - Send presence rounds every ~30s (configurable), and call listPresence 5 times per round + + $totalUsers = 500; + $window = 100; + $cycles = (int) (\getenv('PRESENCE_BENCH_CYCLES') ?: 2); + $intervalSeconds = (int) (\getenv('PRESENCE_BENCH_INTERVAL_SECONDS') ?: 30); + $occurrences = 5; + $ownerDelayMs = (int) (\getenv('PRESENCE_BENCH_OWNER_DELAY_MS') ?: 5000); + $ownerDelayMs = \max(0, $ownerDelayMs); + + $users = []; + for ($i = 0; $i < $totalUsers; $i++) { + $users[] = $this->getUser(true); + } + + $viewerIndex = 0; + $viewer = $users[$viewerIndex]; + + $realtimeConnections = $totalUsers * $cycles; + $listCalls = $occurrences * $cycles; + + Console::info(\sprintf( + '[Presence Benchmark B] totalUsers=%d window=%d cycles=%d intervalSeconds=%d realtimeConnections=%d listCalls=%d', + $totalUsers, + $window, + $cycles, + $intervalSeconds, + $realtimeConnections, + $listCalls + )); + + $expectedOwnerIndices = $this->getRotationExpectedOwnerIndices($viewerIndex, $totalUsers, $window); + $expectedOwnerIds = \array_map(static fn (int $ownerIndex) => $users[$ownerIndex]['$id'], $expectedOwnerIndices); + + $sampleOwnerIndices = [ + $expectedOwnerIndices[0] ?? 0, + $expectedOwnerIndices[(int) \floor(\count($expectedOwnerIndices) / 2)] ?? 0, + $expectedOwnerIndices[\count($expectedOwnerIndices) - 1] ?? 0, + ]; + + $overallListElapsed = []; + + for ($cycle = 0; $cycle < $cycles; $cycle++) { + foreach ($users as $ownerIndex => $owner) { + $permissions = []; + for ($offset = 0; $offset < $window; $offset++) { + $viewerForOwnerIndex = ($ownerIndex + $offset) % $totalUsers; + $permissions[] = Permission::read(Role::user($users[$viewerForOwnerIndex]['$id'])); + } + + $status = 'benchB-presence-c' . $cycle . '-o' . $ownerIndex; + $this->reportPresence($owner, $status, $permissions); + if ($ownerDelayMs > 0) { + \usleep($ownerDelayMs); + } + } + + \usleep(100000); + + $cycleListElapsed = []; + for ($i = 0; $i < $occurrences; $i++) { + $benchmark = $this->fetchPresenceListAs($viewer); + $this->assertEquals(200, $benchmark['response']['headers']['status-code']); + $this->assertGreaterThan(0.0, $benchmark['elapsedMs']); + + $cycleListElapsed[] = $benchmark['elapsedMs']; + $overallListElapsed[] = $benchmark['elapsedMs']; + + if ($i === $occurrences - 1) { + $rows = $this->extractPresenceRows($benchmark['response']['body']); + $visibleMap = $this->indexPresenceRowsByUserId($rows); + + $this->assertEquals( + \count($expectedOwnerIds), + \count($visibleMap), + 'Expected viewer to see exactly window presences.' + ); + + foreach ($sampleOwnerIndices as $sampleOwnerIndex) { + $ownerUserId = $users[$sampleOwnerIndex]['$id']; + $expectedStatus = 'benchB-presence-c' . $cycle . '-o' . $sampleOwnerIndex; + $this->assertEquals( + $expectedStatus, + $visibleMap[$ownerUserId]['status'] ?? null + ); + } + + Console::info(\sprintf( + '[Presence Benchmark B] cycle=%d listPresence occurrence=%d elapsedMs=%.2f rows=%d', + $cycle, + $i + 1, + $benchmark['elapsedMs'], + \count($visibleMap) + )); + } else { + Console::info(\sprintf( + '[Presence Benchmark B] cycle=%d listPresence occurrence=%d elapsedMs=%.2f', + $cycle, + $i + 1, + $benchmark['elapsedMs'] + )); + } + } + + $avg = \array_sum($cycleListElapsed) / \max(1, \count($cycleListElapsed)); + Console::info(\sprintf( + '[Presence Benchmark B] cycle=%d listPresence average over %d calls: %.2fms', + $cycle, + $occurrences, + $avg + )); + + if ($cycle < $cycles - 1) { + \usleep($intervalSeconds * 1000000); + } + } + + $overallAvg = \array_sum($overallListElapsed) / \max(1, \count($overallListElapsed)); + Console::info(\sprintf( + '[Presence Benchmark B Summary] totalUsers=%d window=%d pattern=viewer sees rotating window realtimeConnections=%d listCalls=%d listPresenceAvg=%.2fms', + $totalUsers, + $window, + $realtimeConnections, + $listCalls, + $overallAvg + )); + + $this->printTimingSummary(); + } + + public function testListPresenceBenchmarkRoleUsersUnverified500Users(): void + { + // Benchmark C - Role dimension unverified: + // - Create 500 presence docs through websocket (realtime path) + // - Permissions: read Role.users(unverified) for every owner + // - Uses the default state where newly created users are expected to be unverified + // - Send presence rounds every ~30s (configurable), and call listPresence 5 times per round + + $totalUsers = 500; + $cycles = (int) (\getenv('PRESENCE_BENCH_CYCLES') ?: 2); + $intervalSeconds = (int) (\getenv('PRESENCE_BENCH_INTERVAL_SECONDS') ?: 30); + $occurrences = 5; + $ownerDelayMs = (int) (\getenv('PRESENCE_BENCH_OWNER_DELAY_MS') ?: 5000); + $ownerDelayMs = \max(0, $ownerDelayMs); + + $users = []; + for ($i = 0; $i < $totalUsers; $i++) { + $users[] = $this->getUser(true); + } + + $viewer = $users[0]; + $permissions = [Permission::read(Role::users(Roles::DIMENSION_UNVERIFIED))]; + + $realtimeConnections = $totalUsers * $cycles; + $listCalls = $occurrences * $cycles; + + Console::info(\sprintf( + '[Presence Benchmark C] totalUsers=%d cycles=%d intervalSeconds=%d realtimeConnections=%d listCalls=%d permissions=%s', + $totalUsers, + $cycles, + $intervalSeconds, + $realtimeConnections, + $listCalls, + \json_encode($permissions, JSON_UNESCAPED_SLASHES) + )); + + $overallListElapsed = []; + + $sampleOwnerIndices = [ + 0, + (int) \floor($totalUsers / 2), + $totalUsers - 1, + ]; + + for ($cycle = 0; $cycle < $cycles; $cycle++) { + foreach ($users as $ownerIndex => $owner) { + $status = 'benchC-presence-c' . $cycle . '-o' . $ownerIndex; + $this->reportPresence($owner, $status, $permissions); + if ($ownerDelayMs > 0) { + \usleep($ownerDelayMs); + } + } + + \usleep(100000); + + $cycleListElapsed = []; + for ($i = 0; $i < $occurrences; $i++) { + $benchmark = $this->fetchPresenceListAs($viewer); + $this->assertEquals(200, $benchmark['response']['headers']['status-code']); + $this->assertGreaterThan(0.0, $benchmark['elapsedMs']); + + $cycleListElapsed[] = $benchmark['elapsedMs']; + $overallListElapsed[] = $benchmark['elapsedMs']; + + if ($i === $occurrences - 1) { + $rows = $this->extractPresenceRows($benchmark['response']['body']); + $visibleMap = $this->indexPresenceRowsByUserId($rows); + + $this->assertEquals( + $totalUsers, + \count($visibleMap), + 'Expected unverified viewer to see all presences.' + ); + + foreach ($sampleOwnerIndices as $sampleOwnerIndex) { + $ownerUserId = $users[$sampleOwnerIndex]['$id']; + $expectedStatus = 'benchC-presence-c' . $cycle . '-o' . $sampleOwnerIndex; + $this->assertEquals( + $expectedStatus, + $visibleMap[$ownerUserId]['status'] ?? null + ); + } + + Console::info(\sprintf( + '[Presence Benchmark C] cycle=%d listPresence occurrence=%d elapsedMs=%.2f rows=%d', + $cycle, + $i + 1, + $benchmark['elapsedMs'], + \count($visibleMap) + )); + } else { + Console::info(\sprintf( + '[Presence Benchmark C] cycle=%d listPresence occurrence=%d elapsedMs=%.2f', + $cycle, + $i + 1, + $benchmark['elapsedMs'] + )); + } + } + + $avg = \array_sum($cycleListElapsed) / \max(1, \count($cycleListElapsed)); + Console::info(\sprintf( + '[Presence Benchmark C] cycle=%d listPresence average over %d calls: %.2fms', + $cycle, + $occurrences, + $avg + )); + + if ($cycle < $cycles - 1) { + \usleep($intervalSeconds * 1000000); + } + } + + $overallAvg = \array_sum($overallListElapsed) / \max(1, \count($overallListElapsed)); + Console::info(\sprintf( + '[Presence Benchmark C Summary] totalUsers=%d pattern=Role.users(unverified) viewer sees all realtimeConnections=%d listCalls=%d listPresenceAvg=%.2fms', + $totalUsers, + $realtimeConnections, + $listCalls, + $overallAvg + )); + + $this->printTimingSummary(); + } + + public function testListPresenceBenchmarkRoleUsersVerified500Users(): void + { + // Benchmark D - Role dimension verified: + // - Create 500 presence docs through websocket (realtime path) + // - Permissions: read Role.users(verified) for every owner + // - Marks all created users as verified via PATCH /users/:userId/verification + // - Send presence rounds every ~30s (configurable), and call listPresence 5 times per round + + $totalUsers = 500; + $cycles = (int) (\getenv('PRESENCE_BENCH_CYCLES') ?: 2); + $intervalSeconds = (int) (\getenv('PRESENCE_BENCH_INTERVAL_SECONDS') ?: 30); + $occurrences = 5; + $ownerDelayMs = (int) (\getenv('PRESENCE_BENCH_OWNER_DELAY_MS') ?: 5000); + $ownerDelayMs = \max(0, $ownerDelayMs); + + $users = []; + for ($i = 0; $i < $totalUsers; $i++) { + $users[] = $this->getUser(true); + } + + // Make all users verified so Role.users(verified) matches requester roles. + $this->setUsersEmailVerification($users, true); + + $viewer = $users[0]; + $permissions = [Permission::read(Role::users(Roles::DIMENSION_VERIFIED))]; + + $realtimeConnections = $totalUsers * $cycles; + $listCalls = $occurrences * $cycles; + + Console::info(\sprintf( + '[Presence Benchmark D] totalUsers=%d cycles=%d intervalSeconds=%d realtimeConnections=%d listCalls=%d permissions=%s', + $totalUsers, + $cycles, + $intervalSeconds, + $realtimeConnections, + $listCalls, + \json_encode($permissions, JSON_UNESCAPED_SLASHES) + )); + + $overallListElapsed = []; + + $sampleOwnerIndices = [ + 0, + (int) \floor($totalUsers / 2), + $totalUsers - 1, + ]; + + for ($cycle = 0; $cycle < $cycles; $cycle++) { + foreach ($users as $ownerIndex => $owner) { + $status = 'benchD-presence-c' . $cycle . '-o' . $ownerIndex; + $this->reportPresence($owner, $status, $permissions); + if ($ownerDelayMs > 0) { + \usleep($ownerDelayMs); + } + } + + \usleep(100000); + + $cycleListElapsed = []; + for ($i = 0; $i < $occurrences; $i++) { + $benchmark = $this->fetchPresenceListAs($viewer); + $this->assertEquals(200, $benchmark['response']['headers']['status-code']); + $this->assertGreaterThan(0.0, $benchmark['elapsedMs']); + + $cycleListElapsed[] = $benchmark['elapsedMs']; + $overallListElapsed[] = $benchmark['elapsedMs']; + + if ($i === $occurrences - 1) { + $rows = $this->extractPresenceRows($benchmark['response']['body']); + $visibleMap = $this->indexPresenceRowsByUserId($rows); + + $this->assertEquals( + $totalUsers, + \count($visibleMap), + 'Expected verified viewer to see all presences.' + ); + + foreach ($sampleOwnerIndices as $sampleOwnerIndex) { + $ownerUserId = $users[$sampleOwnerIndex]['$id']; + $expectedStatus = 'benchD-presence-c' . $cycle . '-o' . $sampleOwnerIndex; + $this->assertEquals( + $expectedStatus, + $visibleMap[$ownerUserId]['status'] ?? null + ); + } + + Console::info(\sprintf( + '[Presence Benchmark D] cycle=%d listPresence occurrence=%d elapsedMs=%.2f rows=%d', + $cycle, + $i + 1, + $benchmark['elapsedMs'], + \count($visibleMap) + )); + } else { + Console::info(\sprintf( + '[Presence Benchmark D] cycle=%d listPresence occurrence=%d elapsedMs=%.2f', + $cycle, + $i + 1, + $benchmark['elapsedMs'] + )); + } + } + + $avg = \array_sum($cycleListElapsed) / \max(1, \count($cycleListElapsed)); + Console::info(\sprintf( + '[Presence Benchmark D] cycle=%d listPresence average over %d calls: %.2fms', + $cycle, + $occurrences, + $avg + )); + + if ($cycle < $cycles - 1) { + \usleep($intervalSeconds * 1000000); + } + } + + $overallAvg = \array_sum($overallListElapsed) / \max(1, \count($overallListElapsed)); + Console::info(\sprintf( + '[Presence Benchmark D Summary] totalUsers=%d pattern=Role.users(verified) viewer sees all realtimeConnections=%d listCalls=%d listPresenceAvg=%.2fms', + $totalUsers, + $realtimeConnections, + $listCalls, + $overallAvg + )); + + $this->printTimingSummary(); + } + + public function testListPresenceBenchmarkAppendHistoryTwoPermissions500Users(): void + { + // Benchmark E - Append/history dataset (simulated via multiple presenceLogs per owner) + // Setup: + // - Create 500 owner users + // - Mark 250 as verified, 250 as unverified + // - For each owner, report twice: + // 1) older version: permissions allowing unverified viewers + // 2) newer version: permissions allowing verified viewers + // Result: + // - A "latest-only" listPresence implementation will let verified viewers see the newest versions, + // but unverified viewers will hit a permission denial and (currently) won't fall back to older ones. + // - This benchmark measures listPresence latency for both groups. + + $totalUsers = 500; + $verifiedCount = 250; + $unverifiedCount = $totalUsers - $verifiedCount; + $samplePerGroup = 5; + + $cycles = (int) (\getenv('PRESENCE_BENCH_HISTORY_CYCLES') ?: 1); + $intervalSeconds = (int) (\getenv('PRESENCE_BENCH_INTERVAL_SECONDS') ?: 30); + $versionDelayUs = (int) (\getenv('PRESENCE_BENCH_VERSION_DELAY_US') ?: 5000); + $versionDelayUs = \max(0, $versionDelayUs); + + // Default to no extra delay between owners for this benchmark. + // (You can throttle via PRESENCE_BENCH_OWNER_DELAY_MS if needed.) + $ownerDelayMs = (int) (\getenv('PRESENCE_BENCH_OWNER_DELAY_MS') ?: 0); + $ownerDelayMs = \max(0, $ownerDelayMs); + + $users = []; + for ($i = 0; $i < $totalUsers; $i++) { + $users[] = $this->getUser(true); + } + + $verifiedOwners = \array_slice($users, 0, $verifiedCount); + $unverifiedOwners = \array_slice($users, $verifiedCount, $unverifiedCount); + + $this->setUsersEmailVerification($verifiedOwners, true); + $this->setUsersEmailVerification($unverifiedOwners, false); + + $permissionsOlder = [Permission::read(Role::users(Roles::DIMENSION_UNVERIFIED))]; + $permissionsNewer = [Permission::read(Role::users(Roles::DIMENSION_VERIFIED))]; + + // Validate the "latest-only" semantics by checking known owner statuses. + // Keep this small to avoid making the benchmark too slow. + $sampleOwnerIndices = [ + 0, + (int) \floor($totalUsers / 2), + $totalUsers - 1, + ]; + + Console::info(\sprintf( + '[Presence Benchmark E] totalUsers=%d verifiedOwners=%d unverifiedOwners=%d cycles=%d intervalSeconds=%d samplePerGroup=%d ownerDelayMs=%d versionDelayUs=%d', + $totalUsers, + $verifiedCount, + $unverifiedCount, + $cycles, + $intervalSeconds, + $samplePerGroup, + $ownerDelayMs, + $versionDelayUs + )); + + $verifiedListElapsed = []; + $unverifiedListElapsed = []; + + for ($cycle = 0; $cycle < $cycles; $cycle++) { + foreach ($users as $ownerIndex => $owner) { + $statusOlder = 'benchE-older-unverified-c' . $cycle . '-o' . $ownerIndex; + $this->reportPresence($owner, $statusOlder, $permissionsOlder); + + if ($versionDelayUs > 0) { + \usleep($versionDelayUs); + } + + $statusNewer = 'benchE-newer-verified-c' . $cycle . '-o' . $ownerIndex; + $this->reportPresence($owner, $statusNewer, $permissionsNewer); + + if ($ownerDelayMs > 0) { + \usleep($ownerDelayMs); + } + } + + // Allow realtime write to settle. + \usleep(100); + + for ($i = 0; $i < $samplePerGroup; $i++) { + $verifiedViewer = $verifiedOwners[$i]; + $unverifiedViewer = $unverifiedOwners[$i]; + + // Verified viewer read + $verifiedBenchmark = $this->fetchPresenceListAs($verifiedViewer); + $this->assertEquals(200, $verifiedBenchmark['response']['headers']['status-code']); + $this->assertGreaterThan(0.0, $verifiedBenchmark['elapsedMs']); + + $verifiedRows = $this->extractPresenceRows($verifiedBenchmark['response']['body']); + $verifiedVisibleMap = $this->indexPresenceRowsByUserId($verifiedRows); + + $verifiedListElapsed[] = $verifiedBenchmark['elapsedMs']; + Console::info(\sprintf( + '[Presence Benchmark E] cycle=%d verifiedViewerSample=%d elapsedMs=%.2f rows=%d', + $cycle, + $i, + $verifiedBenchmark['elapsedMs'], + \count($verifiedVisibleMap) + )); + + $verifiedRowCount = \count($verifiedVisibleMap); + $this->assertEquals( + $totalUsers, + $verifiedRowCount, + 'Expected verified viewer to see all owners in this append/history benchmark.' + ); + + foreach ($sampleOwnerIndices as $ownerIndex) { + $ownerUserId = $users[$ownerIndex]['$id']; + $expectedStatus = 'benchE-newer-verified-c' . $cycle . '-o' . $ownerIndex; + $this->assertEquals( + $expectedStatus, + $verifiedVisibleMap[$ownerUserId]['status'] ?? null, + 'Verified viewer status mismatch for ownerUserId=' . $ownerUserId + ); + } + + // Unverified viewer read + $unverifiedBenchmark = $this->fetchPresenceListAs($unverifiedViewer); + $this->assertEquals(200, $unverifiedBenchmark['response']['headers']['status-code']); + $this->assertGreaterThan(0.0, $unverifiedBenchmark['elapsedMs']); + + $unverifiedRows = $this->extractPresenceRows($unverifiedBenchmark['response']['body']); + $unverifiedVisibleMap = $this->indexPresenceRowsByUserId($unverifiedRows); + + $unverifiedListElapsed[] = $unverifiedBenchmark['elapsedMs']; + Console::info(\sprintf( + '[Presence Benchmark E] cycle=%d unverifiedViewerSample=%d elapsedMs=%.2f rows=%d', + $cycle, + $i, + $unverifiedBenchmark['elapsedMs'], + \count($unverifiedVisibleMap) + )); + + $unverifiedRowCount = \count($unverifiedVisibleMap); + if ($unverifiedRowCount === 0) { + $this->assertEquals(0, $unverifiedRowCount, 'Expected unverified viewer to see 0 rows (latest-only semantics).'); + } else { + // If/when fallback-to-older-readable is implemented, unverified should see all owners. + $this->assertEquals( + $totalUsers, + $unverifiedRowCount, + 'Expected unverified viewer to see all owners when fallback-to-older-readable is implemented.' + ); + + foreach ($sampleOwnerIndices as $ownerIndex) { + $ownerUserId = $users[$ownerIndex]['$id']; + $expectedStatus = 'benchE-older-unverified-c' . $cycle . '-o' . $ownerIndex; + $this->assertEquals( + $expectedStatus, + $unverifiedVisibleMap[$ownerUserId]['status'] ?? null, + 'Unverified viewer status mismatch for ownerUserId=' . $ownerUserId + ); + } + } + } + + if ($cycle < $cycles - 1) { + \usleep($intervalSeconds); + } + } + + $verifiedAvg = \array_sum($verifiedListElapsed) / \max(1, \count($verifiedListElapsed)); + $unverifiedAvg = \array_sum($unverifiedListElapsed) / \max(1, \count($unverifiedListElapsed)); + + Console::info(\sprintf( + '[Presence Benchmark E Summary] totalUsers=%d pattern=two presenceLogs per owner (older: unverified permissions, newer: verified permissions); listPresence calls per cycle=%d (verified=%d, unverified=%d); listPresenceAvg verified=%.2fms unverified=%.2fms', + $totalUsers, + $samplePerGroup * 2, + $samplePerGroup, + $samplePerGroup, + $verifiedAvg, + $unverifiedAvg + )); + + $this->printTimingSummary(); + } + + public function testListPresenceBenchmarkStatusPerViewerGroup500UsersFetch10(): void + { + // Benchmark F - group-based status: + // - Create 500 users + // - Partition viewers into 5 groups of 100 users each + // - Each owner gets a single presence with: + // - permissions allowing exactly the viewer group it belongs to (100 viewers) + // - a status string encoding the viewer group + // - For 10 sampled viewers (2 per group), fetch listPresence and assert: + // - the viewer sees exactly 100 owners + // - sample owners have the expected status + + $totalUsers = 500; + $groupSize = 100; + $numGroups = (int) (\floor($totalUsers / $groupSize)); + $viewerCount = 10; // 2 viewers per group + + $cycles = (int) (\getenv('PRESENCE_BENCH_GROUP_CYCLES') ?: 1); + $intervalSeconds = (int) (\getenv('PRESENCE_BENCH_INTERVAL_SECONDS') ?: 30); + $occurrences = (int) (\getenv('PRESENCE_BENCH_OCCURRENCES') ?: 5); + + $ownerDelayMs = (int) (\getenv('PRESENCE_BENCH_OWNER_DELAY_MS') ?: 0); + $ownerDelayMs = \max(0, $ownerDelayMs); + + // Pick 2 viewers from each group: first two indices within group. + $viewerIndices = []; + for ($g = 0; $g < $numGroups; $g++) { + $base = $g * $groupSize; + $viewerIndices[] = $base; + if ($viewerCount >= 10) { + // keep exactly 10; should trigger only if group math changes + break; + } + $viewerIndices[] = $base + 1; + } + $viewerIndices = \array_slice($viewerIndices, 0, $viewerCount); + + $statusSampleOwnerOffsets = [0, 50, 99]; + + $users = []; + for ($i = 0; $i < $totalUsers; $i++) { + $users[] = $this->getUser(true); + } + + // Precompute permissions per viewer group to avoid rebuilding huge permission arrays repeatedly. + $permissionsByViewerGroup = []; + for ($g = 0; $g < $numGroups; $g++) { + $permissions = []; + $start = $g * $groupSize; + $end = $start + $groupSize; + for ($viewerIndex = $start; $viewerIndex < $end; $viewerIndex++) { + $permissions[] = Permission::read(Role::user($users[$viewerIndex]['$id'])); + } + $permissionsByViewerGroup[$g] = $permissions; + } + + $realtimeConnections = $totalUsers * $cycles; + $listCalls = $viewerCount * $occurrences * $cycles; + + Console::info(\sprintf( + '[Presence Benchmark F] totalUsers=%d groupSize=%d numGroups=%d viewerCount=%d cycles=%d occurrences=%d intervalSeconds=%d realtimeConnections=%d listCalls=%d ownerDelayMs=%d', + $totalUsers, + $groupSize, + $numGroups, + $viewerCount, + $cycles, + $occurrences, + $intervalSeconds, + $realtimeConnections, + $listCalls, + $ownerDelayMs + )); + + $listElapsed = []; + + for ($cycle = 0; $cycle < $cycles; $cycle++) { + // Send presence updates for all owners. + foreach ($users as $ownerIndex => $owner) { + $ownerGroup = (int) \floor($ownerIndex / $groupSize); + $permissions = $permissionsByViewerGroup[$ownerGroup]; + $status = 'benchF-g' . $ownerGroup . '-o' . $ownerIndex . '-c' . $cycle; + $this->reportPresence($owner, $status, $permissions); + + if ($ownerDelayMs > 0) { + \usleep($ownerDelayMs * 1000); + } + } + + // Allow realtime write to settle. + \usleep(100000); + + for ($occurrence = 0; $occurrence < $occurrences; $occurrence++) { + foreach ($viewerIndices as $viewerSampleIndex) { + $viewer = $users[$viewerSampleIndex]; + $group = (int) \floor($viewerSampleIndex / $groupSize); + + $benchmark = $this->fetchPresenceListAs($viewer); + $this->assertEquals(200, $benchmark['response']['headers']['status-code']); + $this->assertGreaterThan(0.0, $benchmark['elapsedMs']); + $listElapsed[] = $benchmark['elapsedMs']; + + $rows = $this->extractPresenceRows($benchmark['response']['body']); + $visibleMap = $this->indexPresenceRowsByUserId($rows); + + $this->assertEquals( + $groupSize, + \count($visibleMap), + 'Expected viewer group=' . $group . ' to see exactly ' . $groupSize . ' owners.' + ); + + // Validate status for sample owners only (keeps runtime reasonable). + foreach ($statusSampleOwnerOffsets as $offset) { + $ownerIndex = ($group * $groupSize) + $offset; + $ownerUserId = $users[$ownerIndex]['$id']; + $expectedStatus = 'benchF-g' . $group . '-o' . $ownerIndex . '-c' . $cycle; + $this->assertEquals( + $expectedStatus, + $visibleMap[$ownerUserId]['status'] ?? null, + 'Status mismatch for viewerGroup=' . $group . ' ownerIndex=' . $ownerIndex + ); + } + } + + Console::info(\sprintf( + '[Presence Benchmark F] cycle=%d occurrence=%d listCallsThisOcc=%d', + $cycle, + $occurrence + 1, + $viewerCount + )); + } + } + + $avg = \array_sum($listElapsed) / \max(1, \count($listElapsed)); + Console::info(\sprintf( + '[Presence Benchmark F Summary] totalUsers=%d pattern=viewerGroupStatus; realtimeConnections=%d listCalls=%d listPresenceAvg=%.2fms', + $totalUsers, + $realtimeConnections, + $listCalls, + $avg + )); + + $this->printTimingSummary(); + } + + private function seedPresenceLoad(): void + { + $seedCount = \max(0, $this->getSeedPresenceDocumentsCount()); + + if ($seedCount === 0) { + return; + } + + for ($i = 0; $i < $seedCount; $i++) { + $seedUser = $this->getUser(true); + + $this->createPresenceViaApi( + $seedUser, + 'seed-load-' . $i, + [Permission::read(Role::user($seedUser['$id']))] + ); + } + } + + private function reportPresence(array $user, string $status, array $permissions): void + { + $start = \microtime(true); + $projectId = $this->getProject()['$id']; + $client = $this->getWebsocket(['account'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $user['session'], + ]); + + // connected payload + $connectedPayloadRaw = $client->receive(); + $connectedPayload = \json_decode((string) $connectedPayloadRaw, true); + $this->assertIsArray($connectedPayload, 'Expected websocket connected payload JSON.'); + $this->assertEquals('connected', $connectedPayload['type'] ?? null, 'Websocket connection did not succeed.'); + + $client->send(\json_encode([ + 'type' => 'presence', + 'data' => [ + 'session' => $user['session'], + 'status' => $status, + 'permissions' => $permissions, + ], + ])); + + $presencePayloadRaw = $client->receive(); + $presencePayload = \json_decode((string) $presencePayloadRaw, true); + $this->assertIsArray($presencePayload, 'Expected websocket presence payload JSON.'); + $this->assertEquals('presence', $presencePayload['type'] ?? null, 'Presence ack type mismatch.'); + + $client->close(); + + $elapsedMs = (\microtime(true) - $start) * 1000; + $this->recordTiming('createPresenceRealtime', $elapsedMs); + } + + private function createPresenceViaApi(array $user, string $status, array $permissions): void + { + $projectId = $this->getProject()['$id']; + $headers = [ + 'content-type' => 'application/json', + 'origin' => 'http://localhost', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $user['session'], + ]; + + $payload = [ + 'status' => $status, + 'permissions' => $permissions, + ]; + + $start = \microtime(true); + + $response = $this->client->call(Client::METHOD_POST, '/iterative/presence', $headers, $payload); + + $elapsedMs = (\microtime(true) - $start) * 1000; + $this->recordTiming('createPresenceApi', $elapsedMs); + $this->assertEquals( + 201, + $response['headers']['status-code'], + 'Create presence failed: ' . json_encode($response['body'] ?? []) + ); + } + + private function fetchPresenceListAs(array $user): array + { + $projectId = $this->getProject()['$id']; + + $start = \microtime(true); + $response = $this->client->call(Client::METHOD_GET, $this->getListPresenceURL(), [ + 'content-type' => 'application/json', + 'origin' => 'http://localhost', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $user['session'], + ]); + $elapsedMs = (\microtime(true) - $start) * 1000; + $this->recordTiming('listPresence', $elapsedMs); + + return [ + 'elapsedMs' => $elapsedMs, + 'response' => $response, + ]; + } + + private function extractPresenceRows(array $body): array + { + foreach (['presences', 'presence', 'documents', 'rows'] as $key) { + $value = $body[$key] ?? null; + if (\is_array($value)) { + return $value; + } + } + + return []; + } + + private function indexPresenceRowsByUserId(array $rows): array + { + $indexed = []; + foreach ($rows as $row) { + if (!\is_array($row)) { + continue; + } + + $userId = $row['userId'] ?? null; + if (!\is_string($userId) || $userId === '') { + continue; + } + + $indexed[$userId] = $row; + } + + return $indexed; + } + + /** + * @param array> $users + */ + private function getOwnerIndexByUserId(array $users, string $userId): int + { + foreach ($users as $index => $user) { + if (($user['$id'] ?? null) === $userId) { + return (int) $index; + } + } + + $this->fail('Could not find owner userId=' . $userId); + + return 0; + } + + private function getRotationExpectedOwnerIndices(int $viewerIndex, int $totalUsers, int $window): array + { + $indices = []; + for ($offset = 0; $offset < $window; $offset++) { + $indices[] = ($viewerIndex - $offset + $totalUsers) % $totalUsers; + } + + return $indices; + } + + /** + * @param array> $users + */ + private function setUsersEmailVerification(array $users, bool $emailVerification): void + { + $projectId = $this->getProject()['$id']; + + // Route `/v1/users/:userId/verification` only supports ADMIN or KEY auth + // (not normal user sessions), so use the console session cookie. + $adminHeaders = [ + 'content-type' => 'application/json', + 'origin' => 'http://localhost', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-mode' => 'admin', + ]; + + foreach ($users as $user) { + $response = $this->client->call( + Client::METHOD_PATCH, + '/users/' . $user['$id'] . '/verification', + $adminHeaders, + [ + 'emailVerification' => $emailVerification, + ], + ); + + $this->assertEquals( + 200, + $response['headers']['status-code'], + 'Failed to update user emailVerification for userId=' . $user['$id'] + ); + } + } + + private function recordTiming(string $operation, float $elapsedMs): void + { + $this->timings[$operation][] = $elapsedMs; + } + + private function printTimingSummary(): void + { + foreach ($this->timings as $operation => $values) { + if (empty($values)) { + continue; + } + + $avg = \array_sum($values) / \count($values); + $min = \min($values); + $max = \max($values); + + \fwrite( + \STDOUT, + \sprintf( + "\n[Presence Benchmark] %s count=%d avg=%.2fms min=%.2fms max=%.2fms\n", + $operation, + \count($values), + $avg, + $min, + $max + ) + ); + } + } +} diff --git a/tests/e2e/Services/Realtime/PresenceIterativeTest.php b/tests/e2e/Services/Realtime/PresenceIterativeTest.php new file mode 100644 index 0000000000..7ceacc1ae6 --- /dev/null +++ b/tests/e2e/Services/Realtime/PresenceIterativeTest.php @@ -0,0 +1,232 @@ + 'application/json', + 'origin' => 'http://localhost', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $user['session'], + ]; + } + + public function testPresenceApiCreateGetAndList(): void + { + $projectId = $this->getProject()['$id']; + + $owner = $this->getUser(true); + $viewer = $this->getUser(true); + $other = $this->getUser(true); + + $status = 'api-presence-test-' . \uniqid(); + $permissions = [Permission::read(Role::user($viewer['$id']))]; + + // Create via HTTP + $createHeaders = $this->getPresenceCookieHeaders($projectId, $owner); + $payload = [ + 'status' => $status, + 'permissions' => $permissions, + ]; + + $createResponse = $this->client->call( + Client::METHOD_POST, + '/iterative/presence', + $createHeaders, + $payload + ); + + $this->assertEquals(201, $createResponse['headers']['status-code'], 'Create presence failed.'); + + $presenceId = $createResponse['body']['$id'] ?? null; + $this->assertIsString($presenceId, 'Missing $id in create response body.'); + $this->assertEquals($status, $createResponse['body']['status'] ?? null); + + // GET presence as the same viewer + $getResponse = $this->client->call( + Client::METHOD_GET, + '/presence:/' . $presenceId, + $this->getPresenceCookieHeaders($projectId, $viewer) + ); + + $this->assertEquals(200, $getResponse['headers']['status-code']); + $this->assertEquals($status, $getResponse['body']['status'] ?? null); + + // GET presence as an unrelated user should not succeed + $otherGetResponse = $this->client->call( + Client::METHOD_GET, + '/presence:/' . $presenceId, + $this->getPresenceCookieHeaders($projectId, $other) + ); + + $this->assertNotEquals(200, $otherGetResponse['headers']['status-code']); + + // List presence as viewer should include owner + $listResponse = $this->client->call( + Client::METHOD_GET, + $this->getListPresenceURL(), + $this->getPresenceCookieHeaders($projectId, $viewer) + ); + $this->assertEquals(200, $listResponse['headers']['status-code']); + + $rows = $this->extractPresenceRows($listResponse['body']); + $found = false; + foreach ($rows as $row) { + if (($row['userId'] ?? null) === $owner['$id']) { + $found = true; + $this->assertEquals($status, $row['status'] ?? null); + } + } + + $this->assertTrue($found, 'Viewer should see owner presence in list.'); + + // List presence as other should not include owner + $otherListResponse = $this->client->call( + Client::METHOD_GET, + $this->getListPresenceURL(), + $this->getPresenceCookieHeaders($projectId, $other) + ); + $this->assertEquals(200, $otherListResponse['headers']['status-code']); + + $otherRows = $this->extractPresenceRows($otherListResponse['body']); + $otherFound = false; + foreach ($otherRows as $row) { + if (($row['userId'] ?? null) === $owner['$id']) { + $otherFound = true; + break; + } + } + + $this->assertFalse($otherFound, 'Other user should not see owner presence in list.'); + } + + public function testPresenceRealtimeCreateGetAndList(): void + { + $projectId = $this->getProject()['$id']; + + $owner = $this->getUser(true); + $viewer = $this->getUser(true); + $other = $this->getUser(true); + + $status = 'realtime-presence-test-' . \uniqid(); + $permissions = [Permission::read(Role::user($viewer['$id']))]; + + $channels = ['account']; + $queryString = http_build_query(['project' => $projectId, 'channels' => $channels]); + + $wsClient = new WebSocketClient( + 'ws://appwrite.test/v1/realtime?' . $queryString, + [ + 'headers' => [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $owner['session'], + ], + 'timeout' => 2, + ] + ); + + // connected payload + $connectedPayloadRaw = $wsClient->receive(); + $connectedPayload = \json_decode((string) $connectedPayloadRaw, true); + $this->assertIsArray($connectedPayload, 'Expected websocket connected payload JSON.'); + $this->assertEquals('connected', $connectedPayload['type'] ?? null); + + // Send presence + $wsClient->send(\json_encode([ + 'type' => 'presence', + 'data' => [ + 'session' => $owner['session'], + 'status' => $status, + 'permissions' => $permissions, + ], + ])); + + // presence ack payload + $presenceAckRaw = $wsClient->receive(); + $presenceAck = \json_decode((string) $presenceAckRaw, true); + $this->assertIsArray($presenceAck, 'Expected websocket presence ack JSON.'); + $this->assertEquals('presence', $presenceAck['type'] ?? null); + + $wsClient->close(); + + // Allow realtime write to settle + \usleep(100000); + + // List presence as viewer should include owner + $listResponse = $this->client->call( + Client::METHOD_GET, + $this->getListPresenceURL(), + $this->getPresenceCookieHeaders($projectId, $viewer) + ); + $this->assertEquals(200, $listResponse['headers']['status-code']); + + $rows = $this->extractPresenceRows($listResponse['body']); + $ownerRow = null; + foreach ($rows as $row) { + if (($row['userId'] ?? null) === $owner['$id']) { + $ownerRow = $row; + break; + } + } + + $this->assertIsArray($ownerRow, 'Expected viewer to see owner presence in list.'); + $this->assertEquals($status, $ownerRow['status'] ?? null); + + // GET presence as the same viewer + $presenceId = $ownerRow['$id'] ?? $ownerRow['id'] ?? null; + $this->assertIsString($presenceId, 'Missing presence id in list row.'); + + $getResponse = $this->client->call( + Client::METHOD_GET, + '/presence:/' . $presenceId, + $this->getPresenceCookieHeaders($projectId, $viewer) + ); + + $this->assertEquals(200, $getResponse['headers']['status-code']); + $this->assertEquals($status, $getResponse['body']['status'] ?? null); + + // GET presence as an unrelated user should not succeed + $otherGetResponse = $this->client->call( + Client::METHOD_GET, + '/presence:/' . $presenceId, + $this->getPresenceCookieHeaders($projectId, $other) + ); + + $this->assertNotEquals(200, $otherGetResponse['headers']['status-code']); + } +}