Merge pull request #11650 from appwrite/feat-public-project-keys

Feat: Public keys API
This commit is contained in:
Matej Bačo
2026-04-08 11:15:04 +02:00
committed by GitHub
32 changed files with 1488 additions and 406 deletions
-14
View File
@@ -3,13 +3,6 @@
// List of scopes for organization (teams) API keys
return [
"platforms.read" => [
"description" => 'Access to read project\'s platforms',
],
"platforms.write" => [
"description" =>
'Access to create, update, and delete project\'s platforms',
],
"projects.read" => [
"description" => 'Access to read organization\'s projects',
],
@@ -17,13 +10,6 @@ return [
"description" =>
"Access to create, update, and delete projects in organization",
],
"keys.read" => [
"description" => 'Access to read project\'s API keys',
],
"keys.write" => [
"description" =>
"Access to create, update, and delete project\'s API keys",
],
"devKeys.read" => [
"description" => 'Access to read project\'s development keys',
],
+16
View File
@@ -188,4 +188,20 @@ return [ // List of publicly visible scopes
"description" =>
"Access to update project\'s information",
],
"keys.read" => [
"description" =>
"Access to read project\'s keys",
],
"keys.write" => [
"description" =>
"Access to create, update, and delete project\'s keys",
],
"platforms.read" => [
"description" =>
"Access to read project\'s platforms",
],
"platforms.write" => [
"description" =>
"Access to create, update, and delete project\'s platforms",
],
];
-292
View File
@@ -5,28 +5,18 @@ use Appwrite\Auth\Validator\MockNumber;
use Appwrite\Event\Delete;
use Appwrite\Event\Mail;
use Appwrite\Extend\Exception;
use Appwrite\Network\Platform;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Deprecated;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Template\Template;
use Appwrite\Utopia\Database\Validator\CustomId;
use Appwrite\Utopia\Database\Validator\Queries\Keys;
use Appwrite\Utopia\Response;
use PHPMailer\PHPMailer\PHPMailer;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\Database\Validator\Datetime as DatetimeValidator;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Database\Validator\UID;
use Utopia\Emails\Validator\Email;
use Utopia\Http\Http;
@@ -769,288 +759,6 @@ Http::delete('/v1/projects/:projectId')
$response->noContent();
});
// Keys
Http::post('/v1/projects/:projectId/keys')
->desc('Create key')
->groups(['api', 'projects'])
->label('scope', 'keys.write')
->label('sdk', new Method(
namespace: 'projects',
group: 'keys',
name: 'createKey',
description: '/docs/references/projects/create-key.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_KEY,
)
]
))
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
// TODO: When migrating to Platform API, mark keyId required for consistency
->param('keyId', 'unique()', fn (Database $dbForPlatform) => new CustomId($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', true, ['dbForPlatform'])->param('name', null, new Text(128), 'Key name. Max length: 128 chars.')
->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.')
->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true)
->inject('response')
->inject('dbForPlatform')
->action(function (string $projectId, string $keyId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) {
$keyId = $keyId == 'unique()' ? ID::unique() : $keyId;
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$key = new Document([
'$id' => $keyId,
'$permissions' => [
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
'resourceInternalId' => $project->getSequence(),
'resourceId' => $project->getId(),
'resourceType' => 'projects',
'name' => $name,
'scopes' => $scopes,
'expire' => $expire,
'sdks' => [],
'accessedAt' => null,
'secret' => API_KEY_STANDARD . '_' . \bin2hex(\random_bytes(128)),
]);
try {
$key = $dbForPlatform->createDocument('keys', $key);
} catch (Duplicate) {
throw new Exception(Exception::KEY_ALREADY_EXISTS);
}
$dbForPlatform->purgeCachedDocument('projects', $project->getId());
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($key, Response::MODEL_KEY);
});
Http::get('/v1/projects/:projectId/keys')
->desc('List keys')
->groups(['api', 'projects'])
->label('scope', 'keys.read')
->label('sdk', new Method(
namespace: 'projects',
group: 'keys',
name: 'listKeys',
description: '/docs/references/projects/list-keys.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_KEY_LIST,
)
]
))
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
->param('queries', [], new Keys(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Keys::ALLOWED_ATTRIBUTES), true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('dbForPlatform')
->action(function (string $projectId, array $queries, bool $includeTotal, Response $response, Database $dbForPlatform) {
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
// Backwards compatibility
if (\count(Query::getByType($queries, [Query::TYPE_LIMIT])) === 0) {
$queries[] = Query::limit(5000);
}
$queries[] = Query::equal('resourceType', ['projects']);
$queries[] = Query::equal('resourceInternalId', [$project->getSequence()]);
$cursor = Query::getCursorQueries($queries, false);
$cursor = \reset($cursor);
if ($cursor !== false) {
$validator = new Cursor();
if (!$validator->isValid($cursor)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$keyId = $cursor->getValue();
$cursorDocument = $dbForPlatform->getDocument('keys', $keyId);
if ($cursorDocument->isEmpty()) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Key '{$keyId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$filterQueries = Query::groupByType($queries)['filters'];
$keys = $dbForPlatform->find('keys', $queries);
$response->dynamic(new Document([
'keys' => $keys,
'total' => $includeTotal ? $dbForPlatform->count('keys', $filterQueries, APP_LIMIT_COUNT) : 0,
]), Response::MODEL_KEY_LIST);
});
Http::get('/v1/projects/:projectId/keys/:keyId')
->desc('Get key')
->groups(['api', 'projects'])
->label('scope', 'keys.read')
->label('sdk', new Method(
namespace: 'projects',
group: 'keys',
name: 'getKey',
description: '/docs/references/projects/get-key.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_KEY,
)
]
))
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key unique ID.', false, ['dbForPlatform'])
->inject('response')
->inject('dbForPlatform')
->action(function (string $projectId, string $keyId, Response $response, Database $dbForPlatform) {
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$key = $dbForPlatform->findOne('keys', [
Query::equal('$id', [$keyId]),
Query::equal('resourceType', ['projects']),
Query::equal('resourceInternalId', [$project->getSequence()]),
]);
if ($key->isEmpty()) {
throw new Exception(Exception::KEY_NOT_FOUND);
}
$response->dynamic($key, Response::MODEL_KEY);
});
Http::put('/v1/projects/:projectId/keys/:keyId')
->desc('Update key')
->groups(['api', 'projects'])
->label('scope', 'keys.write')
->label('sdk', new Method(
namespace: 'projects',
group: 'keys',
name: 'updateKey',
description: '/docs/references/projects/update-key.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_KEY,
)
]
))
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key unique ID.', false, ['dbForPlatform'])
->param('name', null, new Text(128), 'Key name. Max length: 128 chars.')
->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.')
->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true)
->inject('response')
->inject('dbForPlatform')
->action(function (string $projectId, string $keyId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) {
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$key = $dbForPlatform->findOne('keys', [
Query::equal('$id', [$keyId]),
Query::equal('resourceType', ['projects']),
Query::equal('resourceInternalId', [$project->getSequence()]),
]);
if ($key->isEmpty()) {
throw new Exception(Exception::KEY_NOT_FOUND);
}
$key
->setAttribute('name', $name)
->setAttribute('scopes', $scopes)
->setAttribute('expire', $expire);
$dbForPlatform->updateDocument('keys', $key->getId(), $key);
$dbForPlatform->purgeCachedDocument('projects', $project->getId());
$response->dynamic($key, Response::MODEL_KEY);
});
Http::delete('/v1/projects/:projectId/keys/:keyId')
->desc('Delete key')
->groups(['api', 'projects'])
->label('scope', 'keys.write')
->label('sdk', new Method(
namespace: 'projects',
group: 'keys',
name: 'deleteKey',
description: '/docs/references/projects/delete-key.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key unique ID.', false, ['dbForPlatform'])
->inject('response')
->inject('dbForPlatform')
->action(function (string $projectId, string $keyId, Response $response, Database $dbForPlatform) {
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$key = $dbForPlatform->findOne('keys', [
Query::equal('$id', [$keyId]),
Query::equal('resourceType', ['projects']),
Query::equal('resourceInternalId', [$project->getSequence()]),
]);
if ($key->isEmpty()) {
throw new Exception(Exception::KEY_NOT_FOUND);
}
$dbForPlatform->deleteDocument('keys', $key->getId());
$dbForPlatform->purgeCachedDocument('projects', $project->getId());
$response->noContent();
});
// JWT Keys
Http::post('/v1/projects/:projectId/jwts')
Generated
+68 -84
View File
@@ -2708,16 +2708,16 @@
},
{
"name": "symfony/http-client",
"version": "v7.4.7",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "1010624285470eb60e88ed10035102c75b4ea6af"
"reference": "01933e626c3de76bea1e22641e205e78f6a34342"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/1010624285470eb60e88ed10035102c75b4ea6af",
"reference": "1010624285470eb60e88ed10035102c75b4ea6af",
"url": "https://api.github.com/repos/symfony/http-client/zipball/01933e626c3de76bea1e22641e205e78f6a34342",
"reference": "01933e626c3de76bea1e22641e205e78f6a34342",
"shasum": ""
},
"require": {
@@ -2785,7 +2785,7 @@
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v7.4.7"
"source": "https://github.com/symfony/http-client/tree/v7.4.8"
},
"funding": [
{
@@ -2805,7 +2805,7 @@
"type": "tidelift"
}
],
"time": "2026-03-05T11:16:58+00:00"
"time": "2026-03-30T12:55:43+00:00"
},
{
"name": "symfony/http-client-contracts",
@@ -4325,16 +4325,16 @@
},
{
"name": "utopia-php/http",
"version": "0.34.16",
"version": "0.34.18",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/http.git",
"reference": "2b4021ba3f9d476264ce9fd6703d6c79de9add7f"
"reference": "c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/http/zipball/2b4021ba3f9d476264ce9fd6703d6c79de9add7f",
"reference": "2b4021ba3f9d476264ce9fd6703d6c79de9add7f",
"url": "https://api.github.com/repos/utopia-php/http/zipball/c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc",
"reference": "c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc",
"shasum": ""
},
"require": {
@@ -4373,9 +4373,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/http/issues",
"source": "https://github.com/utopia-php/http/tree/0.34.16"
"source": "https://github.com/utopia-php/http/tree/0.34.18"
},
"time": "2026-03-20T10:39:07+00:00"
"time": "2026-04-07T08:06:39+00:00"
},
{
"name": "utopia-php/image",
@@ -4696,16 +4696,16 @@
},
{
"name": "utopia-php/platform",
"version": "0.12.0",
"version": "0.12.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/platform.git",
"reference": "068ee46228f0c3972e6b569f2c86b6c80fe583d8"
"reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/platform/zipball/068ee46228f0c3972e6b569f2c86b6c80fe583d8",
"reference": "068ee46228f0c3972e6b569f2c86b6c80fe583d8",
"url": "https://api.github.com/repos/utopia-php/platform/zipball/2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc",
"reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc",
"shasum": ""
},
"require": {
@@ -4741,9 +4741,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/platform/issues",
"source": "https://github.com/utopia-php/platform/tree/0.12.0"
"source": "https://github.com/utopia-php/platform/tree/0.12.1"
},
"time": "2026-03-31T14:44:23+00:00"
"time": "2026-04-08T04:11:31+00:00"
},
{
"name": "utopia-php/pools",
@@ -5502,16 +5502,16 @@
"packages-dev": [
{
"name": "appwrite/sdk-generator",
"version": "1.14.0",
"version": "1.17.6",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-generator.git",
"reference": "7e7e257b10a8c1384a237e7d8d73452e2108901e"
"reference": "8888a9fd11260d389874424268ecbe0d956eb550"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/7e7e257b10a8c1384a237e7d8d73452e2108901e",
"reference": "7e7e257b10a8c1384a237e7d8d73452e2108901e",
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/8888a9fd11260d389874424268ecbe0d956eb550",
"reference": "8888a9fd11260d389874424268ecbe0d956eb550",
"shasum": ""
},
"require": {
@@ -5547,22 +5547,22 @@
"description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms",
"support": {
"issues": "https://github.com/appwrite/sdk-generator/issues",
"source": "https://github.com/appwrite/sdk-generator/tree/1.14.0"
"source": "https://github.com/appwrite/sdk-generator/tree/1.17.6"
},
"time": "2026-03-26T12:50:11+00:00"
"time": "2026-04-08T05:37:23+00:00"
},
{
"name": "brianium/paratest",
"version": "v7.19.2",
"version": "v7.20.0",
"source": {
"type": "git",
"url": "https://github.com/paratestphp/paratest.git",
"reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9"
"reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paratestphp/paratest/zipball/66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9",
"reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9",
"url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d",
"reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d",
"shasum": ""
},
"require": {
@@ -5586,7 +5586,7 @@
"ext-pcntl": "*",
"ext-pcov": "*",
"ext-posix": "*",
"phpstan/phpstan": "^2.1.40",
"phpstan/phpstan": "^2.1.44",
"phpstan/phpstan-deprecation-rules": "^2.0.4",
"phpstan/phpstan-phpunit": "^2.0.16",
"phpstan/phpstan-strict-rules": "^2.0.10",
@@ -5630,7 +5630,7 @@
],
"support": {
"issues": "https://github.com/paratestphp/paratest/issues",
"source": "https://github.com/paratestphp/paratest/tree/v7.19.2"
"source": "https://github.com/paratestphp/paratest/tree/v7.20.0"
},
"funding": [
{
@@ -5642,7 +5642,7 @@
"type": "paypal"
}
],
"time": "2026-03-09T14:33:17+00:00"
"time": "2026-03-29T15:46:14+00:00"
},
{
"name": "czproject/git-php",
@@ -6258,11 +6258,11 @@
},
{
"name": "phpstan/phpstan",
"version": "2.1.44",
"version": "2.1.46",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/4a88c083c668b2c364a425c9b3171b2d9ea5d218",
"reference": "4a88c083c668b2c364a425c9b3171b2d9ea5d218",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25",
"reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25",
"shasum": ""
},
"require": {
@@ -6307,7 +6307,7 @@
"type": "github"
}
],
"time": "2026-03-25T17:34:21+00:00"
"time": "2026-04-01T09:25:14+00:00"
},
{
"name": "phpunit/php-code-coverage",
@@ -6657,16 +6657,16 @@
},
{
"name": "phpunit/phpunit",
"version": "12.5.14",
"version": "12.5.17",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0"
"reference": "85b62adab1a340982df64e66daa4a4435eb5723b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0",
"reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/85b62adab1a340982df64e66daa4a4435eb5723b",
"reference": "85b62adab1a340982df64e66daa4a4435eb5723b",
"shasum": ""
},
"require": {
@@ -6688,7 +6688,7 @@
"sebastian/cli-parser": "^4.2.0",
"sebastian/comparator": "^7.1.4",
"sebastian/diff": "^7.0.0",
"sebastian/environment": "^8.0.3",
"sebastian/environment": "^8.0.4",
"sebastian/exporter": "^7.0.2",
"sebastian/global-state": "^8.0.2",
"sebastian/object-enumerator": "^7.0.0",
@@ -6735,31 +6735,15 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14"
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.17"
},
"funding": [
{
"url": "https://phpunit.de/sponsors.html",
"type": "custom"
},
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
"type": "tidelift"
"url": "https://phpunit.de/sponsoring.html",
"type": "other"
}
],
"time": "2026-02-18T12:38:40+00:00"
"time": "2026-04-08T03:04:19+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -6832,16 +6816,16 @@
},
{
"name": "sebastian/comparator",
"version": "7.1.4",
"version": "7.1.5",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
"reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6"
"reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6",
"reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6",
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c284f55811f43d555e51e8e5c166ac40d3e33c63",
"reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63",
"shasum": ""
},
"require": {
@@ -6900,7 +6884,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues",
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
"source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4"
"source": "https://github.com/sebastianbergmann/comparator/tree/7.1.5"
},
"funding": [
{
@@ -6920,7 +6904,7 @@
"type": "tidelift"
}
],
"time": "2026-01-24T09:28:48+00:00"
"time": "2026-04-08T04:43:00+00:00"
},
{
"name": "sebastian/complexity",
@@ -7744,16 +7728,16 @@
},
{
"name": "symfony/console",
"version": "v8.0.7",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a"
"reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a",
"reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a",
"url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7",
"reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7",
"shasum": ""
},
"require": {
@@ -7810,7 +7794,7 @@
"terminal"
],
"support": {
"source": "https://github.com/symfony/console/tree/v8.0.7"
"source": "https://github.com/symfony/console/tree/v8.0.8"
},
"funding": [
{
@@ -7830,7 +7814,7 @@
"type": "tidelift"
}
],
"time": "2026-03-06T14:06:22+00:00"
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/polyfill-ctype",
@@ -8164,16 +8148,16 @@
},
{
"name": "symfony/process",
"version": "v8.0.5",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
"reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674"
"reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/process/zipball/b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674",
"reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674",
"url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc",
"reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc",
"shasum": ""
},
"require": {
@@ -8205,7 +8189,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/process/tree/v8.0.5"
"source": "https://github.com/symfony/process/tree/v8.0.8"
},
"funding": [
{
@@ -8225,20 +8209,20 @@
"type": "tidelift"
}
],
"time": "2026-01-26T15:08:38+00:00"
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/string",
"version": "v8.0.6",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
"reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4"
"reference": "ae9488f874d7603f9d2dfbf120203882b645d963"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4",
"reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4",
"url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963",
"reference": "ae9488f874d7603f9d2dfbf120203882b645d963",
"shasum": ""
},
"require": {
@@ -8295,7 +8279,7 @@
"utf8"
],
"support": {
"source": "https://github.com/symfony/string/tree/v8.0.6"
"source": "https://github.com/symfony/string/tree/v8.0.8"
},
"funding": [
{
@@ -8315,7 +8299,7 @@
"type": "tidelift"
}
],
"time": "2026-02-09T10:14:57+00:00"
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "textalk/websocket",
@@ -0,0 +1,119 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\Keys;
use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\CustomId;
use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate as DuplicateException;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Datetime;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Nullable;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
class Create extends Base
{
use HTTP;
public static function getName()
{
return 'createProjectKey';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/project/keys')
->httpAlias('/v1/projects/:projectId/keys')
->desc('Create project key')
->groups(['api', 'project'])
->label('scope', 'keys.write')
->label('event', 'keys.[keyId].create')
->label('audits.event', 'project.key.create')
->label('audits.resource', 'project.key/{response.$id}')
->label('sdk', new Method(
namespace: 'project',
group: 'keys',
name: 'createKey',
description: <<<EOT
Create a new API key. It's recommended to have multiple API keys with strict scopes for separate functions within your project.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_KEY,
)
],
))
->param('keyId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform'])
->param('name', null, new Text(128), 'Key name. Max length: 128 chars.')
->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.')
->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true)
->inject('response')
->inject('queueForEvents')
->inject('dbForPlatform')
->inject('project')
->inject('authorization')
->callback($this->action(...));
}
/**
* @param array<string>|null $scopes
*/
public function action(
string $keyId,
string $name,
?array $scopes,
?string $expire,
Response $response,
QueueEvent $queueForEvents,
Database $dbForPlatform,
Document $project,
Authorization $authorization,
) {
$keyId = ($keyId == 'unique()') ? ID::unique() : $keyId;
$key = new Document([
'$id' => $keyId,
'$permissions' => [],
'resourceInternalId' => $project->getSequence(),
'resourceId' => $project->getId(),
'resourceType' => 'projects',
'name' => $name,
'scopes' => $scopes ?? [],
'expire' => $expire,
'sdks' => [],
'accessedAt' => null,
'secret' => API_KEY_STANDARD . '_' . \bin2hex(\random_bytes(128)),
]);
try {
$key = $authorization->skip(fn () => $dbForPlatform->createDocument('keys', $key));
} catch (DuplicateException) {
throw new Exception(Exception::KEY_ALREADY_EXISTS);
}
$authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
$queueForEvents->setParam('keyId', $key->getId());
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($key, Response::MODEL_KEY);
}
}
@@ -0,0 +1,90 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\Keys;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class Delete extends Base
{
use HTTP;
public static function getName()
{
return 'deleteProjectKey';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
->setHttpPath('/v1/project/keys/:keyId')
->httpAlias('/v1/projects/:projectId/keys/:keyId')
->desc('Delete project key')
->groups(['api', 'project'])
->label('scope', 'keys.write')
->label('event', 'keys.[keyId].delete')
->label('audits.event', 'project.key.delete')
->label('audits.resource', 'project.key/{request.keyId}')
->label('sdk', new Method(
namespace: 'project',
group: 'keys',
name: 'deleteKey',
description: <<<EOT
Delete a key by its unique ID. Once deleted, the key can no longer be used to authenticate API calls.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform'])
->inject('response')
->inject('dbForPlatform')
->inject('queueForEvents')
->inject('project')
->inject('authorization')
->callback($this->action(...));
}
public function action(
string $keyId,
Response $response,
Database $dbForPlatform,
Event $queueForEvents,
Document $project,
Authorization $authorization,
) {
$key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId));
if ($key->isEmpty() || $key->getAttribute('resourceType', '') !== 'projects' || $key->getAttribute('resourceInternalId', '') !== $project->getSequence()) {
throw new Exception(Exception::KEY_NOT_FOUND);
}
if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('keys', $key->getId()))) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove document from DB');
};
$authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
$queueForEvents->setParam('keyId', $key->getId());
$response->noContent();
}
}
@@ -0,0 +1,74 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\Keys;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class Get extends Base
{
use HTTP;
public static function getName()
{
return 'getProjectKey';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/project/keys/:keyId')
->httpAlias('/v1/projects/:projectId/keys/:keyId')
->desc('Get project key')
->groups(['api', 'project'])
->label('scope', 'keys.read')
->label('sdk', new Method(
namespace: 'project',
group: 'keys',
name: 'getKey',
description: <<<EOT
Get a key by its unique ID.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_KEY,
)
]
))
->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform'])
->inject('response')
->inject('dbForPlatform')
->inject('project')
->inject('authorization')
->callback($this->action(...));
}
public function action(
string $keyId,
Response $response,
Database $dbForPlatform,
Document $project,
Authorization $authorization,
) {
$key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId));
if ($key->isEmpty() || $key->getAttribute('resourceType', '') !== 'projects' || $key->getAttribute('resourceInternalId', '') !== $project->getSequence()) {
throw new Exception(Exception::KEY_NOT_FOUND);
}
$response->dynamic($key, Response::MODEL_KEY);
}
}
@@ -0,0 +1,111 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\Keys;
use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Datetime;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Nullable;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
class Update extends Base
{
use HTTP;
public static function getName()
{
return 'updateProjectKey';
}
public function __construct()
{
$this->setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
->setHttpPath('/v1/project/keys/:keyId')
->httpAlias('/v1/projects/:projectId/keys/:keyId')
->desc('Update project key')
->groups(['api', 'project'])
->label('scope', 'keys.write')
->label('event', 'keys.[keyId].update')
->label('audits.event', 'project.key.update')
->label('audits.resource', 'project.key/{response.$id}')
->label('sdk', new Method(
namespace: 'project',
group: 'keys',
name: 'updateKey',
description: <<<EOT
Update a key by its unique ID. Use this endpoint to update the name, scopes, or expiration time of an API key.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_KEY,
)
]
))
->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform'])
->param('name', null, new Text(128), 'Key name. Max length: 128 chars.')
->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.')
->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true)
->inject('response')
->inject('queueForEvents')
->inject('dbForPlatform')
->inject('project')
->inject('authorization')
->callback($this->action(...));
}
/**
* @param array<string>|null $scopes
*/
public function action(
string $keyId,
string $name,
?array $scopes,
?string $expire,
Response $response,
QueueEvent $queueForEvents,
Database $dbForPlatform,
Document $project,
Authorization $authorization,
) {
$key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId));
if ($key->isEmpty() || $key->getAttribute('resourceType', '') !== 'projects' || $key->getAttribute('resourceInternalId', '') !== $project->getSequence()) {
throw new Exception(Exception::KEY_NOT_FOUND);
}
$updates = new Document([
'name' => $name,
'scopes' => $scopes ?? [],
'expire' => $expire,
]);
try {
$key = $authorization->skip(fn () => $dbForPlatform->updateDocument('keys', $key->getId(), $updates));
} catch (Duplicate) {
throw new Exception(Exception::KEY_ALREADY_EXISTS);
}
$authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
$queueForEvents->setParam('keyId', $key->getId());
$response->dynamic($key, Response::MODEL_KEY);
}
}
@@ -0,0 +1,127 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\Keys;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\Keys;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
class XList extends Base
{
use HTTP;
public static function getName()
{
return 'listProjectKeys';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/project/keys')
->httpAlias('/v1/projects/:projectId/keys')
->desc('List project keys')
->groups(['api', 'project'])
->label('scope', 'keys.read')
->label('sdk', new Method(
namespace: 'project',
group: 'keys',
name: 'listKeys',
description: <<<EOT
Get a list of all API keys from the current project.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_KEY_LIST,
)
]
))
->param('queries', [], new Keys(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Keys::ALLOWED_ATTRIBUTES), true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('project')
->inject('response')
->inject('dbForPlatform')
->inject('authorization')
->callback($this->action(...));
}
/**
* @param array<string> $queries
*/
public function action(
array $queries,
bool $includeTotal,
Document $project,
Response $response,
Database $dbForPlatform,
Authorization $authorization,
) {
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
// Backwards compatibility
if (\count(Query::getByType($queries, [Query::TYPE_LIMIT])) === 0) {
$queries[] = Query::limit(5000);
}
$queries[] = Query::equal('resourceType', ['projects']);
$queries[] = Query::equal('resourceInternalId', [$project->getSequence()]);
$cursor = Query::getCursorQueries($queries, false);
$cursor = \reset($cursor);
if ($cursor !== false) {
$validator = new Cursor();
if (!$validator->isValid($cursor)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$keyId = $cursor->getValue();
$cursorDocument = $authorization->skip(fn () => $dbForPlatform->findOne('keys', [
Query::equal('$id', [$keyId]),
Query::equal('resourceType', ['projects']),
Query::equal('resourceInternalId', [$project->getSequence()]),
]));
if ($cursorDocument->isEmpty()) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Key '{$keyId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
$keys = $authorization->skip(fn () => $dbForPlatform->find('keys', $queries));
$total = $includeTotal ? $authorization->skip(fn () => $dbForPlatform->count('keys', $filterQueries, APP_LIMIT_COUNT)) : 0;
} catch (OrderException $e) {
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
}
$response->dynamic(new Document([
'keys' => $keys,
'total' => $total,
]), Response::MODEL_KEY_LIST);
}
}
@@ -35,7 +35,7 @@ class Create extends Action
->setHttpPath('/v1/project/platforms/android')
->desc('Create project Android platform')
->groups(['api', 'project'])
->label('scope', 'project.write')
->label('scope', 'platforms.write')
->label('event', 'platforms.[platformId].create')
->label('audits.event', 'project.platform.create')
->label('audits.resource', 'project.platform/{response.$id}')
@@ -33,7 +33,7 @@ class Update extends Action
->setHttpPath('/v1/project/platforms/android/:platformId')
->desc('Update project Android platform')
->groups(['api', 'project'])
->label('scope', 'project.write')
->label('scope', 'platforms.write')
->label('event', 'platforms.[platformId].update')
->label('audits.event', 'project.platform.update')
->label('audits.resource', 'project.platform/{response.$id}')
@@ -35,7 +35,7 @@ class Create extends Action
->setHttpPath('/v1/project/platforms/apple')
->desc('Create project Apple platform')
->groups(['api', 'project'])
->label('scope', 'project.write')
->label('scope', 'platforms.write')
->label('event', 'platforms.[platformId].create')
->label('audits.event', 'project.platform.create')
->label('audits.resource', 'project.platform/{response.$id}')
@@ -33,7 +33,7 @@ class Update extends Action
->setHttpPath('/v1/project/platforms/apple/:platformId')
->desc('Update project Apple platform')
->groups(['api', 'project'])
->label('scope', 'project.write')
->label('scope', 'platforms.write')
->label('event', 'platforms.[platformId].update')
->label('audits.event', 'project.platform.update')
->label('audits.resource', 'project.platform/{response.$id}')
@@ -33,7 +33,7 @@ class Delete extends Action
->httpAlias('/v1/projects/:projectId/platforms/:platformId')
->desc('Delete project platform')
->groups(['api', 'project'])
->label('scope', 'project.write')
->label('scope', 'platforms.write')
->label('event', 'platforms.[platformId].delete')
->label('audits.event', 'project.platform.delete')
->label('audits.resource', 'project.platform/{response.$id}')
@@ -32,7 +32,7 @@ class Get extends Action
->httpAlias('/v1/projects/:projectId/platforms/:platformId')
->desc('Get project platform')
->groups(['api', 'project'])
->label('scope', 'project.read')
->label('scope', 'platforms.read')
->label('sdk', new Method(
namespace: 'project',
group: 'platforms',
@@ -35,7 +35,7 @@ class Create extends Action
->setHttpPath('/v1/project/platforms/linux')
->desc('Create project Linux platform')
->groups(['api', 'project'])
->label('scope', 'project.write')
->label('scope', 'platforms.write')
->label('event', 'platforms.[platformId].create')
->label('audits.event', 'project.platform.create')
->label('audits.resource', 'project.platform/{response.$id}')
@@ -33,7 +33,7 @@ class Update extends Action
->setHttpPath('/v1/project/platforms/linux/:platformId')
->desc('Update project Linux platform')
->groups(['api', 'project'])
->label('scope', 'project.write')
->label('scope', 'platforms.write')
->label('event', 'platforms.[platformId].update')
->label('audits.event', 'project.platform.update')
->label('audits.resource', 'project.platform/{response.$id}')
@@ -43,7 +43,7 @@ class Create extends Action
->httpAlias('/v1/projects/:projectId/platforms')
->desc('Create project web platform')
->groups(['api', 'project'])
->label('scope', 'project.write')
->label('scope', 'platforms.write')
->label('event', 'platforms.[platformId].create')
->label('audits.event', 'project.platform.create')
->label('audits.resource', 'project.platform/{response.$id}')
@@ -35,7 +35,7 @@ class Update extends Action
->httpAlias('/v1/projects/:projectId/platforms/:platformId')
->desc('Update project web platform')
->groups(['api', 'project'])
->label('scope', 'project.write')
->label('scope', 'platforms.write')
->label('event', 'platforms.[platformId].update')
->label('audits.event', 'project.platform.update')
->label('audits.resource', 'project.platform/{response.$id}')
@@ -35,7 +35,7 @@ class Create extends Action
->setHttpPath('/v1/project/platforms/windows')
->desc('Create project Windows platform')
->groups(['api', 'project'])
->label('scope', 'project.write')
->label('scope', 'platforms.write')
->label('event', 'platforms.[platformId].create')
->label('audits.event', 'project.platform.create')
->label('audits.resource', 'project.platform/{response.$id}')
@@ -33,7 +33,7 @@ class Update extends Action
->setHttpPath('/v1/project/platforms/windows/:platformId')
->desc('Update project Windows platform')
->groups(['api', 'project'])
->label('scope', 'project.write')
->label('scope', 'platforms.write')
->label('event', 'platforms.[platformId].update')
->label('audits.event', 'project.platform.update')
->label('audits.resource', 'project.platform/{response.$id}')
@@ -36,7 +36,7 @@ class XList extends Action
->httpAlias('/v1/projects/:projectId/platforms')
->desc('List project platforms')
->groups(['api', 'project'])
->label('scope', 'project.read')
->label('scope', 'platforms.read')
->label('sdk', new Method(
namespace: 'project',
group: 'platforms',
@@ -34,7 +34,7 @@ class Delete extends Action
->label('scope', 'project.write')
->label('event', 'variables.[variableId].delete')
->label('audits.event', 'project.variable.delete')
->label('audits.resource', 'project.variable/{response.$id}')
->label('audits.resource', 'project.variable/{request.variableId}')
->label('sdk', new Method(
namespace: 'project',
group: 'variables',
@@ -3,6 +3,11 @@
namespace Appwrite\Platform\Modules\Project\Services;
use Appwrite\Platform\Modules\Project\Http\Init;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Update as UpdateKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\XList as ListKeys;
use Appwrite\Platform\Modules\Project\Http\Project\Labels\Update as UpdateProjectLabels;
use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Create as CreateAndroidPlatform;
use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Update as UpdateAndroidPlatform;
@@ -43,6 +48,13 @@ class Http extends Service
$this->addAction(DeleteVariable::getName(), new DeleteVariable());
$this->addAction(UpdateVariable::getName(), new UpdateVariable());
// Keys
$this->addAction(CreateKey::getName(), new CreateKey());
$this->addAction(ListKeys::getName(), new ListKeys());
$this->addAction(GetKey::getName(), new GetKey());
$this->addAction(DeleteKey::getName(), new DeleteKey());
$this->addAction(UpdateKey::getName(), new UpdateKey());
// Platforms
$this->addAction(DeletePlatform::getName(), new DeletePlatform());
$this->addAction(UpdateWebPlatform::getName(), new UpdateWebPlatform());
+5 -1
View File
@@ -379,7 +379,11 @@ class Migrations extends Action
'webhooks.read',
'webhooks.write',
'project.read',
'project.write'
'project.write',
'keys.read',
'keys.write',
'platforms.read',
'platforms.write',
]
]);
@@ -71,6 +71,9 @@ class V21 extends Filter
case 'webhooks.create':
$content = $this->fillWebhookid($content);
break;
case 'project.createKey':
$content = $this->fillKeyId($content);
break;
case 'project.createVariable':
$content = $this->fillVariableId($content);
break;
@@ -122,6 +125,12 @@ class V21 extends Filter
return $content;
}
protected function fillKeyId(array $content): array
{
$content['keyId'] = $content['keyId'] ?? 'unique()';
return $content;
}
protected function fillVariableId(array $content): array
{
$content['variableId'] = $content['variableId'] ?? 'unique()';
+5 -1
View File
@@ -164,7 +164,11 @@ trait ProjectCustom
'webhooks.read',
'webhooks.write',
'project.read',
'project.write'
'project.write',
'keys.read',
'keys.write',
'platforms.read',
'platforms.write',
],
]);
+806
View File
@@ -0,0 +1,806 @@
<?php
namespace Tests\E2E\Services\Project;
use Appwrite\Tests\Async;
use Tests\E2E\Client;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Query;
use Utopia\Database\Validator\Datetime as DatetimeValidator;
trait KeysBase
{
use Async;
// =========================================================================
// Create key tests
// =========================================================================
public function testCreateKey(): void
{
$key = $this->createKey(
ID::unique(),
'My API Key',
['users.read', 'users.write'],
);
$this->assertSame(201, $key['headers']['status-code']);
$this->assertNotEmpty($key['body']['$id']);
$this->assertSame('My API Key', $key['body']['name']);
$this->assertSame(['users.read', 'users.write'], $key['body']['scopes']);
$this->assertNotEmpty($key['body']['secret']);
$this->assertSame('', $key['body']['expire']);
$this->assertSame('', $key['body']['accessedAt']);
$this->assertSame([], $key['body']['sdks']);
$dateValidator = new DatetimeValidator();
$this->assertSame(true, $dateValidator->isValid($key['body']['$createdAt']));
$this->assertSame(true, $dateValidator->isValid($key['body']['$updatedAt']));
// Verify via GET
$get = $this->getKey($key['body']['$id']);
$this->assertSame(200, $get['headers']['status-code']);
$this->assertSame($key['body']['$id'], $get['body']['$id']);
$this->assertSame('My API Key', $get['body']['name']);
$this->assertSame(['users.read', 'users.write'], $get['body']['scopes']);
// Verify via LIST
$list = $this->listKeys(null, true);
$this->assertSame(200, $list['headers']['status-code']);
$this->assertGreaterThanOrEqual(1, $list['body']['total']);
$this->assertGreaterThanOrEqual(1, \count($list['body']['keys']));
// Cleanup
$this->deleteKey($key['body']['$id']);
}
public function testCreateKeyWithExpire(): void
{
$expire = '2030-01-01T00:00:00.000+00:00';
$key = $this->createKey(
ID::unique(),
'Expiring Key',
['users.read'],
$expire,
);
$this->assertSame(201, $key['headers']['status-code']);
$this->assertSame($expire, $key['body']['expire']);
// Verify via GET
$get = $this->getKey($key['body']['$id']);
$this->assertSame(200, $get['headers']['status-code']);
$this->assertSame($expire, $get['body']['expire']);
// Cleanup
$this->deleteKey($key['body']['$id']);
}
public function testCreateKeyWithNullScopes(): void
{
$key = $this->createKey(
ID::unique(),
'Null Scopes Key',
null,
);
$this->assertSame(201, $key['headers']['status-code']);
$this->assertSame([], $key['body']['scopes']);
// Cleanup
$this->deleteKey($key['body']['$id']);
}
public function testCreateKeyWithoutAuthentication(): void
{
$response = $this->createKey(
ID::unique(),
'No Auth Key',
['users.read'],
null,
false
);
$this->assertSame(401, $response['headers']['status-code']);
}
public function testCreateKeyInvalidId(): void
{
$key = $this->createKey(
'!invalid-id!',
'Invalid ID Key',
['users.read'],
);
$this->assertSame(400, $key['headers']['status-code']);
}
public function testCreateKeyMissingName(): void
{
$response = $this->createKey(
ID::unique(),
null,
['users.read'],
);
$this->assertSame(400, $response['headers']['status-code']);
}
public function testCreateKeyInvalidScope(): void
{
$response = $this->createKey(
ID::unique(),
'Invalid Scope Key',
['invalid.scope'],
);
$this->assertSame(400, $response['headers']['status-code']);
}
public function testCreateKeyDuplicateId(): void
{
$keyId = ID::unique();
$key = $this->createKey(
$keyId,
'Key Dup 1',
['users.read'],
);
$this->assertSame(201, $key['headers']['status-code']);
// Attempt to create with same ID
$duplicate = $this->createKey(
$keyId,
'Key Dup 2',
['users.write'],
);
$this->assertSame(409, $duplicate['headers']['status-code']);
$this->assertSame('key_already_exists', $duplicate['body']['type']);
// Cleanup
$this->deleteKey($keyId);
}
public function testCreateKeyCustomId(): void
{
$customId = 'my-custom-key-id';
$key = $this->createKey(
$customId,
'Custom ID Key',
['users.read'],
);
$this->assertSame(201, $key['headers']['status-code']);
$this->assertSame($customId, $key['body']['$id']);
// Verify via GET
$get = $this->getKey($customId);
$this->assertSame(200, $get['headers']['status-code']);
$this->assertSame($customId, $get['body']['$id']);
// Cleanup
$this->deleteKey($customId);
}
// =========================================================================
// Update key tests
// =========================================================================
public function testUpdateKey(): void
{
$key = $this->createKey(
ID::unique(),
'Original Key',
['users.read'],
);
$this->assertSame(201, $key['headers']['status-code']);
$keyId = $key['body']['$id'];
// Update name, scopes, and expire
$expire = '2031-06-15T12:00:00.000+00:00';
$updated = $this->updateKey($keyId, 'Updated Key', ['users.write', 'databases.read'], $expire);
$this->assertSame(200, $updated['headers']['status-code']);
$this->assertSame($keyId, $updated['body']['$id']);
$this->assertSame('Updated Key', $updated['body']['name']);
$this->assertSame(['users.write', 'databases.read'], $updated['body']['scopes']);
$this->assertSame($expire, $updated['body']['expire']);
// Verify update persisted via GET
$get = $this->getKey($keyId);
$this->assertSame(200, $get['headers']['status-code']);
$this->assertSame('Updated Key', $get['body']['name']);
$this->assertSame(['users.write', 'databases.read'], $get['body']['scopes']);
$this->assertSame($expire, $get['body']['expire']);
// Cleanup
$this->deleteKey($keyId);
}
public function testUpdateKeyName(): void
{
$key = $this->createKey(
ID::unique(),
'Name Before',
['users.read'],
);
$this->assertSame(201, $key['headers']['status-code']);
$keyId = $key['body']['$id'];
$updated = $this->updateKey($keyId, 'Name After', ['users.read']);
$this->assertSame(200, $updated['headers']['status-code']);
$this->assertSame('Name After', $updated['body']['name']);
$this->assertSame(['users.read'], $updated['body']['scopes']);
// Cleanup
$this->deleteKey($keyId);
}
public function testUpdateKeyScopes(): void
{
$key = $this->createKey(
ID::unique(),
'Scopes Key',
['users.read'],
);
$this->assertSame(201, $key['headers']['status-code']);
$keyId = $key['body']['$id'];
$updated = $this->updateKey($keyId, 'Scopes Key', ['databases.read', 'databases.write']);
$this->assertSame(200, $updated['headers']['status-code']);
$this->assertSame(['databases.read', 'databases.write'], $updated['body']['scopes']);
// Cleanup
$this->deleteKey($keyId);
}
public function testUpdateKeySetExpire(): void
{
$key = $this->createKey(
ID::unique(),
'No Expire Key',
['users.read'],
);
$this->assertSame(201, $key['headers']['status-code']);
$this->assertSame('', $key['body']['expire']);
$keyId = $key['body']['$id'];
$expire = '2032-12-31T23:59:59.000+00:00';
$updated = $this->updateKey($keyId, 'No Expire Key', ['users.read'], $expire);
$this->assertSame(200, $updated['headers']['status-code']);
$this->assertSame($expire, $updated['body']['expire']);
// Cleanup
$this->deleteKey($keyId);
}
public function testUpdateKeyRemoveExpire(): void
{
$key = $this->createKey(
ID::unique(),
'Expire Key',
['users.read'],
'2030-01-01T00:00:00.000+00:00',
);
$this->assertSame(201, $key['headers']['status-code']);
$keyId = $key['body']['$id'];
// Remove expire by setting to null
$updated = $this->updateKey($keyId, 'Expire Key', ['users.read'], null);
$this->assertSame(200, $updated['headers']['status-code']);
$this->assertSame('', $updated['body']['expire']);
// Cleanup
$this->deleteKey($keyId);
}
public function testUpdateKeyWithoutAuthentication(): void
{
$key = $this->createKey(
ID::unique(),
'Auth Update Key',
['users.read'],
);
$this->assertSame(201, $key['headers']['status-code']);
$keyId = $key['body']['$id'];
// Attempt update without authentication
$response = $this->updateKey($keyId, 'Updated Name', ['users.read'], null, false);
$this->assertSame(401, $response['headers']['status-code']);
// Cleanup
$this->deleteKey($keyId);
}
public function testUpdateKeyNotFound(): void
{
$updated = $this->updateKey('non-existent-id', 'New Name', ['users.read']);
$this->assertSame(404, $updated['headers']['status-code']);
$this->assertSame('key_not_found', $updated['body']['type']);
}
public function testUpdateKeyInvalidScope(): void
{
$key = $this->createKey(
ID::unique(),
'Invalid Scope Update',
['users.read'],
);
$this->assertSame(201, $key['headers']['status-code']);
$keyId = $key['body']['$id'];
$updated = $this->updateKey($keyId, 'Invalid Scope Update', ['invalid.scope']);
$this->assertSame(400, $updated['headers']['status-code']);
// Cleanup
$this->deleteKey($keyId);
}
// =========================================================================
// Get key tests
// =========================================================================
public function testGetKey(): void
{
$key = $this->createKey(
ID::unique(),
'Get Test Key',
['users.read', 'databases.read'],
);
$this->assertSame(201, $key['headers']['status-code']);
$keyId = $key['body']['$id'];
$get = $this->getKey($keyId);
$this->assertSame(200, $get['headers']['status-code']);
$this->assertSame($keyId, $get['body']['$id']);
$this->assertSame('Get Test Key', $get['body']['name']);
$this->assertSame(['users.read', 'databases.read'], $get['body']['scopes']);
$this->assertNotEmpty($get['body']['secret']);
$this->assertSame('', $get['body']['expire']);
$this->assertSame('', $get['body']['accessedAt']);
$this->assertSame([], $get['body']['sdks']);
$dateValidator = new DatetimeValidator();
$this->assertSame(true, $dateValidator->isValid($get['body']['$createdAt']));
$this->assertSame(true, $dateValidator->isValid($get['body']['$updatedAt']));
// Cleanup
$this->deleteKey($keyId);
}
public function testGetKeyNotFound(): void
{
$get = $this->getKey('non-existent-id');
$this->assertSame(404, $get['headers']['status-code']);
$this->assertSame('key_not_found', $get['body']['type']);
}
public function testGetKeyWithoutAuthentication(): void
{
$key = $this->createKey(
ID::unique(),
'Auth Get Key',
['users.read'],
);
$this->assertSame(201, $key['headers']['status-code']);
$keyId = $key['body']['$id'];
// Attempt GET without authentication
$response = $this->getKey($keyId, false);
$this->assertSame(401, $response['headers']['status-code']);
// Cleanup
$this->deleteKey($keyId);
}
// =========================================================================
// List keys tests
// =========================================================================
public function testListKeys(): void
{
// Create multiple keys
$key1 = $this->createKey(
ID::unique(),
'List Key Alpha',
['users.read'],
);
$this->assertSame(201, $key1['headers']['status-code']);
$key2 = $this->createKey(
ID::unique(),
'List Key Beta',
['databases.read'],
);
$this->assertSame(201, $key2['headers']['status-code']);
$key3 = $this->createKey(
ID::unique(),
'List Key Gamma',
['users.write'],
);
$this->assertSame(201, $key3['headers']['status-code']);
// List all
$list = $this->listKeys(null, true);
$this->assertSame(200, $list['headers']['status-code']);
$this->assertGreaterThanOrEqual(3, $list['body']['total']);
$this->assertGreaterThanOrEqual(3, \count($list['body']['keys']));
$this->assertIsArray($list['body']['keys']);
// Verify structure of returned keys
foreach ($list['body']['keys'] as $key) {
$this->assertArrayHasKey('$id', $key);
$this->assertArrayHasKey('$createdAt', $key);
$this->assertArrayHasKey('$updatedAt', $key);
$this->assertArrayHasKey('name', $key);
$this->assertArrayHasKey('scopes', $key);
$this->assertArrayHasKey('secret', $key);
$this->assertArrayHasKey('expire', $key);
$this->assertArrayHasKey('accessedAt', $key);
$this->assertArrayHasKey('sdks', $key);
}
// Cleanup
$this->deleteKey($key1['body']['$id']);
$this->deleteKey($key2['body']['$id']);
$this->deleteKey($key3['body']['$id']);
}
public function testListKeysWithLimit(): void
{
$key1 = $this->createKey(
ID::unique(),
'Limit Key 1',
['users.read'],
);
$this->assertSame(201, $key1['headers']['status-code']);
$key2 = $this->createKey(
ID::unique(),
'Limit Key 2',
['users.write'],
);
$this->assertSame(201, $key2['headers']['status-code']);
// List with limit 1
$list = $this->listKeys([
Query::limit(1)->toString(),
], true);
$this->assertSame(200, $list['headers']['status-code']);
$this->assertCount(1, $list['body']['keys']);
$this->assertGreaterThanOrEqual(2, $list['body']['total']);
// Cleanup
$this->deleteKey($key1['body']['$id']);
$this->deleteKey($key2['body']['$id']);
}
public function testListKeysWithoutTotal(): void
{
$key = $this->createKey(
ID::unique(),
'No Total Key',
['users.read'],
);
$this->assertSame(201, $key['headers']['status-code']);
// List with total=false
$list = $this->listKeys(null, false);
$this->assertSame(200, $list['headers']['status-code']);
$this->assertSame(0, $list['body']['total']);
$this->assertGreaterThanOrEqual(1, \count($list['body']['keys']));
// Cleanup
$this->deleteKey($key['body']['$id']);
}
public function testListKeysCursorPagination(): void
{
$key1 = $this->createKey(
ID::unique(),
'Cursor Key 1',
['users.read'],
);
$this->assertSame(201, $key1['headers']['status-code']);
$key2 = $this->createKey(
ID::unique(),
'Cursor Key 2',
['users.write'],
);
$this->assertSame(201, $key2['headers']['status-code']);
// Get first page with limit 1
$page1 = $this->listKeys([
Query::limit(1)->toString(),
], true);
$this->assertSame(200, $page1['headers']['status-code']);
$this->assertCount(1, $page1['body']['keys']);
$cursorId = $page1['body']['keys'][0]['$id'];
// Get next page using cursor
$page2 = $this->listKeys([
Query::limit(1)->toString(),
Query::cursorAfter(new Document(['$id' => $cursorId]))->toString(),
], true);
$this->assertSame(200, $page2['headers']['status-code']);
$this->assertCount(1, $page2['body']['keys']);
$this->assertNotEquals($cursorId, $page2['body']['keys'][0]['$id']);
// Cleanup
$this->deleteKey($key1['body']['$id']);
$this->deleteKey($key2['body']['$id']);
}
public function testListKeysWithoutAuthentication(): void
{
$response = $this->listKeys(null, null, false);
$this->assertSame(401, $response['headers']['status-code']);
}
public function testListKeysInvalidCursor(): void
{
$list = $this->listKeys([
Query::cursorAfter(new Document(['$id' => 'non-existent-id']))->toString(),
], true);
$this->assertSame(400, $list['headers']['status-code']);
}
// =========================================================================
// Delete key tests
// =========================================================================
public function testDeleteKey(): void
{
$key = $this->createKey(
ID::unique(),
'Delete Key',
['users.read'],
);
$this->assertSame(201, $key['headers']['status-code']);
$keyId = $key['body']['$id'];
// Verify it exists
$get = $this->getKey($keyId);
$this->assertSame(200, $get['headers']['status-code']);
// Delete
$delete = $this->deleteKey($keyId);
$this->assertSame(204, $delete['headers']['status-code']);
$this->assertEmpty($delete['body']);
// Verify it no longer exists
$get = $this->getKey($keyId);
$this->assertSame(404, $get['headers']['status-code']);
$this->assertSame('key_not_found', $get['body']['type']);
}
public function testDeleteKeyNotFound(): void
{
$delete = $this->deleteKey('non-existent-id');
$this->assertSame(404, $delete['headers']['status-code']);
$this->assertSame('key_not_found', $delete['body']['type']);
}
public function testDeleteKeyWithoutAuthentication(): void
{
$key = $this->createKey(
ID::unique(),
'Delete Auth Key',
['users.read'],
);
$this->assertSame(201, $key['headers']['status-code']);
$keyId = $key['body']['$id'];
// Attempt DELETE without authentication
$response = $this->deleteKey($keyId, false);
$this->assertSame(401, $response['headers']['status-code']);
// Verify it still exists
$get = $this->getKey($keyId);
$this->assertSame(200, $get['headers']['status-code']);
// Cleanup
$this->deleteKey($keyId);
}
public function testDeleteKeyRemovedFromList(): void
{
$key = $this->createKey(
ID::unique(),
'Delete List Key',
['users.read'],
);
$this->assertSame(201, $key['headers']['status-code']);
$keyId = $key['body']['$id'];
// Get list count before delete
$listBefore = $this->listKeys(null, true);
$this->assertSame(200, $listBefore['headers']['status-code']);
$countBefore = $listBefore['body']['total'];
// Delete
$delete = $this->deleteKey($keyId);
$this->assertSame(204, $delete['headers']['status-code']);
// Get list count after delete
$listAfter = $this->listKeys(null, true);
$this->assertSame(200, $listAfter['headers']['status-code']);
$this->assertSame($countBefore - 1, $listAfter['body']['total']);
// Verify the deleted key is not in the list
$ids = \array_column($listAfter['body']['keys'], '$id');
$this->assertNotContains($keyId, $ids);
}
public function testDeleteKeyDoubleDelete(): void
{
$key = $this->createKey(
ID::unique(),
'Double Delete Key',
['users.read'],
);
$this->assertSame(201, $key['headers']['status-code']);
$keyId = $key['body']['$id'];
// First delete succeeds
$delete = $this->deleteKey($keyId);
$this->assertSame(204, $delete['headers']['status-code']);
// Second delete returns 404
$delete = $this->deleteKey($keyId);
$this->assertSame(404, $delete['headers']['status-code']);
$this->assertSame('key_not_found', $delete['body']['type']);
}
// =========================================================================
// Helpers
// =========================================================================
/**
* @param array<string>|null $scopes
*/
protected function createKey(string $keyId, ?string $name, ?array $scopes = null, ?string $expire = null, bool $authenticated = true): mixed
{
$params = [
'keyId' => $keyId,
'scopes' => $scopes,
];
if ($name !== null) {
$params['name'] = $name;
}
if ($expire !== null) {
$params['expire'] = $expire;
}
$headers = [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
];
if ($authenticated) {
$headers = array_merge($headers, $this->getHeaders());
}
return $this->client->call(Client::METHOD_POST, '/project/keys', $headers, $params);
}
/**
* @param array<string>|null $scopes
*/
protected function updateKey(string $keyId, ?string $name = null, ?array $scopes = null, ?string $expire = null, bool $authenticated = true): mixed
{
$params = [];
if ($name !== null) {
$params['name'] = $name;
}
if ($scopes !== null) {
$params['scopes'] = $scopes;
}
if ($expire !== null) {
$params['expire'] = $expire;
}
$headers = [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
];
if ($authenticated) {
$headers = array_merge($headers, $this->getHeaders());
}
return $this->client->call(Client::METHOD_PUT, '/project/keys/' . $keyId, $headers, $params);
}
protected function getKey(string $keyId, bool $authenticated = true): mixed
{
$headers = [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
];
if ($authenticated) {
$headers = array_merge($headers, $this->getHeaders());
}
return $this->client->call(Client::METHOD_GET, '/project/keys/' . $keyId, $headers);
}
/**
* @param array<string>|null $queries
*/
protected function listKeys(?array $queries, ?bool $total, bool $authenticated = true): mixed
{
$headers = [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
];
if ($authenticated) {
$headers = array_merge($headers, $this->getHeaders());
}
return $this->client->call(Client::METHOD_GET, '/project/keys', $headers, [
'queries' => $queries,
'total' => $total,
]);
}
protected function deleteKey(string $keyId, bool $authenticated = true): mixed
{
$headers = [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
];
if ($authenticated) {
$headers = array_merge($headers, $this->getHeaders());
}
return $this->client->call(Client::METHOD_DELETE, '/project/keys/' . $keyId, $headers);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Tests\E2E\Services\Project;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideConsole;
class KeysConsoleClientTest extends Scope
{
use KeysBase;
use ProjectCustom;
use SideConsole;
}
@@ -0,0 +1,14 @@
<?php
namespace Tests\E2E\Services\Project;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideServer;
class KeysCustomServerTest extends Scope
{
use KeysBase;
use ProjectCustom;
use SideServer;
}
@@ -120,6 +120,7 @@ trait ProjectsBase
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-response-format' => '1.8.0',
], $this->getHeaders()), [
'name' => 'Key Test',
'scopes' => ['teams.read', 'teams.write'],
@@ -3161,6 +3161,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-response-format' => '1.8.0',
], $this->getHeaders()), [
'name' => 'Key Custom',
'scopes' => ['teams.read', 'teams.write'],
@@ -3246,6 +3247,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-response-format' => '1.8.0',
], $this->getHeaders()), [
'name' => 'Key Test 2',
'scopes' => ['users.read'],
@@ -3621,6 +3623,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-response-format' => '1.8.0',
], $this->getHeaders()), [
'name' => 'Key For Deletion',
'scopes' => ['teams.read', 'teams.write'],