From 7f8f09ee5153a4584e2603992a547a9062edd7bd Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 6 Sep 2026 15:36:41 +0200 Subject: [PATCH] feat(admin)!: move the admin api to rest and fix its defects (#2515) --- fluxer_admin/openapi-admin.json | 16489 ++++++++-------- fluxer_admin/src/acl.rs | 1 + fluxer_admin/src/api/admin_api_keys.rs | 14 +- fluxer_admin/src/api/applications.rs | 53 +- fluxer_admin/src/api/archives.rs | 38 +- fluxer_admin/src/api/assets.rs | 11 +- fluxer_admin/src/api/audit.rs | 69 +- fluxer_admin/src/api/bans.rs | 392 +- fluxer_admin/src/api/bulk.rs | 111 +- fluxer_admin/src/api/client.rs | 98 +- fluxer_admin/src/api/codes.rs | 2 +- fluxer_admin/src/api/discovery.rs | 40 +- fluxer_admin/src/api/generated.rs | 8 + fluxer_admin/src/api/guild_assets.rs | 4 +- fluxer_admin/src/api/guilds.rs | 211 +- fluxer_admin/src/api/instance_config.rs | 52 +- fluxer_admin/src/api/jobs.rs | 84 +- fluxer_admin/src/api/limit_config.rs | 6 +- fluxer_admin/src/api/messages.rs | 111 +- fluxer_admin/src/api/reports.rs | 146 +- fluxer_admin/src/api/search.rs | 11 +- fluxer_admin/src/api/system.rs | 14 +- fluxer_admin/src/api/system_dm.rs | 2 +- fluxer_admin/src/api/types/instance_config.rs | 10 - fluxer_admin/src/api/users.rs | 294 +- fluxer_admin/src/api/voice.rs | 55 +- fluxer_admin/src/middleware/auth.rs | 2 +- fluxer_admin/src/routes/guild_tabs.rs | 2 +- fluxer_admin/src/routes/guilds.rs | 4 +- fluxer_admin/src/routes/users.rs | 1 - fluxer_admin/src/templates/pages/jobs_list.rs | 4 +- fluxer_admin/tests/htmx_acceptance.rs | 70 +- .../tests/parity/fixtures/api_routes.json | 22 +- .../pkgs/worker/src/contracts/WorkerTypes.ts | 1 + fluxer_api/src/api/admin/AdminRepository.ts | 16 + fluxer_api/src/api/admin/AdminService.ts | 12 +- fluxer_api/src/api/admin/IAdminRepository.ts | 4 + .../controllers/AdminApiKeyAdminController.ts | 79 +- .../controllers/ApplicationAdminController.ts | 136 +- .../controllers/ArchiveAdminController.ts | 106 +- .../admin/controllers/AssetAdminController.ts | 12 +- .../controllers/AuditLogAdminController.ts | 57 +- .../admin/controllers/BanAdminController.ts | 1121 +- .../admin/controllers/BulkAdminController.ts | 216 +- .../admin/controllers/CodesAdminController.ts | 8 +- .../controllers/DiscoveryAdminController.ts | 206 +- .../controllers/GatewayAdminController.ts | 90 +- .../admin/controllers/GuildAdminController.ts | 686 +- .../InstanceConfigAdminController.ts | 85 +- .../admin/controllers/JobsAdminController.ts | 95 +- .../controllers/LimitConfigAdminController.ts | 16 +- .../controllers/MessageAdminController.ts | 279 +- .../controllers/ReportAdminController.ts | 127 +- .../controllers/SearchAdminController.ts | 86 +- .../controllers/SystemAdminController.ts | 8 +- .../controllers/SystemDmAdminController.ts | 8 +- .../admin/controllers/UserAdminController.ts | 964 +- .../admin/controllers/VoiceAdminController.ts | 196 +- .../repositories/AdminApiKeyRepository.ts | 34 +- .../repositories/AdminArchiveRepository.ts | 6 + .../repositories/IAdminApiKeyRepository.ts | 6 + .../api/admin/services/AdminApiKeyService.ts | 99 +- .../admin/services/AdminApplicationService.ts | 19 +- .../api/admin/services/AdminArchiveService.ts | 12 +- .../admin/services/AdminAssetPurgeService.ts | 18 +- .../api/admin/services/AdminAuditService.ts | 9 + .../services/AdminBanManagementService.ts | 178 +- .../api/admin/services/AdminReportService.ts | 3 +- .../api/admin/services/AdminSearchService.ts | 14 +- .../api/admin/services/AdminVoiceService.ts | 4 + .../tests/AdminApiKeyACLValidation.test.ts | 26 + .../tests/AdminApiKeyAuthentication.test.ts | 64 +- .../tests/AdminApiKeyAuthorization.test.ts | 69 +- .../admin/tests/AdminApiKeyLifecycle.test.ts | 70 +- .../admin/tests/AdminApiKeyManagement.test.ts | 213 +- .../admin/tests/AdminApiKeyRevocation.test.ts | 2 +- .../api/admin/tests/AdminApplications.test.ts | 151 + .../AdminArchiveRetryClearsFailure.test.ts | 98 + .../api/admin/tests/AdminArchivesList.test.ts | 67 +- .../api/admin/tests/AdminBlocklistUrl.test.ts | 80 + .../admin/tests/AdminDeletionQueue.test.ts | 130 +- .../tests/AdminEndpointsAuthorization.test.ts | 176 +- .../tests/AdminGuildApplications.test.ts | 7 +- .../api/admin/tests/AdminGuildRoutes.test.ts | 219 + .../admin/tests/AdminJobsAndBulkJobs.test.ts | 140 + .../tests/AdminLastActiveIpSearch.test.ts | 17 +- .../AdminOAuth2ApplicationRequirement.test.ts | 48 +- .../admin/tests/AdminRepositoryIpBans.test.ts | 42 + .../admin/tests/AdminSearchEndpoints.test.ts | 216 +- .../tests/AdminSearchFieldCoverage.test.ts | 72 +- .../tests/AdminSetUserAclsValidation.test.ts | 72 + .../tests/AdminUserChangeLogAndFlags.test.ts | 117 +- .../admin/tests/AdminUserDirectory.test.ts | 174 + .../AdminUserWebAuthnCredentialDelete.test.ts | 12 +- .../tests/DiscoveryAdminOperations.test.ts | 348 +- .../tests/GatewayAdminController.test.ts | 45 + ...eConfigPendingRegistrationApproval.test.ts | 8 +- ...anceConfigPendingRegistrationAudit.test.ts | 17 +- .../InstancePolicyDirectMessages.test.ts | 97 + .../admin/tests/SecurityAccessControl.test.ts | 15 +- .../admin/tests/VoiceAdminController.test.ts | 101 +- .../AuthSessionCacheInvalidation.test.ts | 4 +- .../src/api/auth/tests/AuthTestUtils.ts | 4 +- .../src/api/auth/tests/Registration.test.ts | 6 +- fluxer_api/src/api/auth/tests/SsoFlow.test.ts | 11 +- .../src/api/database/types/JobLedgerTypes.ts | 2 +- .../repositories/GuildDiscoveryRepository.ts | 13 +- .../guild/services/GuildDiscoveryService.ts | 6 +- .../DiscoveryApplicationLifecycle.test.ts | 8 +- .../DiscoveryApplicationValidation.test.ts | 8 +- .../tests/DiscoverySearchAndJoin.test.ts | 4 +- .../src/api/jobs/IJobLedgerRepository.ts | 4 +- .../src/api/jobs/JobLedgerRepository.test.ts | 135 + .../src/api/jobs/JobLedgerRepository.ts | 37 +- .../api/middleware/ContentFilterMiddleware.ts | 8 +- .../repositories/ApplicationRepository.ts | 12 +- .../tests/OAuth2ScopeEnforcement.test.ts | 39 +- fluxer_api/src/api/report/ReportService.ts | 5 +- .../api/report/tests/ContentReporting.test.ts | 4 +- fluxer_api/src/api/worker/WorkerService.ts | 49 +- .../api/worker/tasks/RefreshSearchIndex.ts | 2 +- .../api/worker/tasks/SyncDiscoveryIndex.ts | 2 +- .../worker/tests/WorkerServiceLedger.test.ts | 99 + .../app/components/setup/SetupWizardClient.ts | 8 +- .../src/features/app/constants/Endpoints.ts | 7 +- knip.json | 1 + .../openapi/src/registry/ParameterRegistry.ts | 7 + .../schema/src/domains/admin/AdminAclType.ts | 14 + .../domains/admin/AdminApplicationSchemas.ts | 38 +- .../domains/admin/AdminBlocklistSchemas.ts | 154 + .../src/domains/admin/AdminBulkSchemas.ts | 57 + .../src/domains/admin/AdminGuildSchemas.ts | 79 +- .../admin/AdminMessageBrowseSchemas.ts | 42 +- .../src/domains/admin/AdminMessageSchemas.ts | 40 +- .../schema/src/domains/admin/AdminSchemas.ts | 216 +- .../domains/admin/AdminUserSchemas.test.ts | 5 + .../src/domains/admin/AdminUserSchemas.ts | 222 +- .../src/domains/admin/AdminVoiceSchemas.ts | 30 + .../schema/src/domains/admin/JobsSchemas.ts | 63 +- .../src/domains/common/CommonParamSchemas.ts | 14 +- .../domains/guild/GuildDiscoverySchemas.ts | 68 +- .../tests/AdminResponseSchemas.test.ts | 122 + .../src/domains/tests/JobsSchemas.test.ts | 19 + 143 files changed, 15238 insertions(+), 12790 deletions(-) create mode 100644 fluxer_api/src/api/admin/tests/AdminApplications.test.ts create mode 100644 fluxer_api/src/api/admin/tests/AdminArchiveRetryClearsFailure.test.ts create mode 100644 fluxer_api/src/api/admin/tests/AdminBlocklistUrl.test.ts create mode 100644 fluxer_api/src/api/admin/tests/AdminGuildRoutes.test.ts create mode 100644 fluxer_api/src/api/admin/tests/AdminJobsAndBulkJobs.test.ts create mode 100644 fluxer_api/src/api/admin/tests/AdminSetUserAclsValidation.test.ts create mode 100644 fluxer_api/src/api/admin/tests/AdminUserDirectory.test.ts create mode 100644 fluxer_api/src/api/admin/tests/GatewayAdminController.test.ts create mode 100644 fluxer_api/src/api/admin/tests/InstancePolicyDirectMessages.test.ts create mode 100644 fluxer_api/src/api/jobs/JobLedgerRepository.test.ts create mode 100644 fluxer_api/src/api/worker/tests/WorkerServiceLedger.test.ts create mode 100644 packages/schema/src/domains/admin/AdminAclType.ts create mode 100644 packages/schema/src/domains/admin/AdminBlocklistSchemas.ts create mode 100644 packages/schema/src/domains/admin/AdminBulkSchemas.ts create mode 100644 packages/schema/src/domains/tests/AdminResponseSchemas.test.ts create mode 100644 packages/schema/src/domains/tests/JobsSchemas.test.ts diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 3374f0c95..5a0ee22fd 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -9,6 +9,59 @@ }, "servers": [{"url": "https://api.fluxer.app/v1", "description": "Production API"}], "paths": { + "/admin/acls": { + "get": { + "operationId": "list_admin_acls", + "summary": "List admin permissions", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminAclListResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Returns every access control permission the admin API recognises. This is the registry admin accounts and admin API keys draw their permissions from. Requires AUTHENTICATE permission.", + "security": [{"adminApiKey": []}] + } + }, "/admin/api-keys": { "post": { "operationId": "create_admin_api_key", @@ -121,10 +174,134 @@ "security": [{"adminApiKey": []}] } }, - "/admin/api-keys/{keyId}": { + "/admin/api-keys/{key_id}": { + "get": { + "operationId": "get_admin_api_key", + "summary": "Get admin API key", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListAdminApiKeyResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Retrieves a single API key created by the authenticated admin. Returns metadata including creation time, last used time, and assigned permissions. The actual key material is never returned.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "key_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the key" + } + ] + }, + "patch": { + "operationId": "update_admin_api_key", + "summary": "Update admin API key", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListAdminApiKeyResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Renames an API key or replaces the access control lists (ACLs) it carries. The key may only carry permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "key_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the key" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateAdminApiKeyRequest"}}} + } + }, "delete": { "operationId": "delete_admin_api_key", - "summary": "Delete admin API key", + "summary": "Revoke admin API key", "tags": ["Admin"], "responses": { "200": { @@ -173,19 +350,25 @@ "description": "Revokes an API key, immediately invalidating it for all future operations. This action cannot be undone.", "security": [{"adminApiKey": []}], "parameters": [ - {"name": "keyId", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The keyId"} + { + "name": "key_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the key" + } ] } }, - "/admin/applications/list-by-guild": { - "post": { - "operationId": "admin_list_guild_applications", - "summary": "List applications installed in a guild", + "/admin/applications": { + "get": { + "operationId": "list_admin_applications", + "summary": "List applications", "tags": ["Admin"], "responses": { "200": { "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListGuildApplicationsResponse"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListApplicationsResponse"}}} }, "400": { "description": "Bad Request - The request was malformed or contained invalid data", @@ -226,75 +409,28 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Lists OAuth2 applications whose bot users are members of a guild. Requires APPLICATION_LOOKUP or APPLICATION_LIST_BY_OWNER permission.", + "description": "Lists OAuth2 applications and bots. Pass owner_id to list the applications a user owns, or guild_id to list the applications whose bot users are members of a guild. Exactly one of the two is required. owner_id requires APPLICATION_LIST_BY_OWNER permission, guild_id requires APPLICATION_LOOKUP permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListGuildApplicationsRequest"}}} - } - } - }, - "/admin/applications/list-by-owner": { - "post": { - "operationId": "admin_list_user_applications", - "summary": "List applications owned by a user", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListUserApplicationsResponse"}}} + "parameters": [ + { + "name": "owner_id", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + { + "name": "guild_id", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} } - }, - "description": "Lists all applications (OAuth2 clients and bots) owned by a specific user. Requires APPLICATION_LIST_BY_OWNER permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListUserApplicationsRequest"}}} - } + ] } }, - "/admin/applications/lookup": { - "post": { - "operationId": "lookup_application", - "summary": "Look up application", + "/admin/applications/{application_id}": { + "get": { + "operationId": "get_admin_application", + "summary": "Get application", "tags": ["Admin"], "responses": { "200": { @@ -342,16 +478,19 @@ }, "description": "Retrieves complete application details including ownership, bot user, OAuth2 redirect URIs, and credential status. Requires APPLICATION_LOOKUP permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LookupApplicationRequest"}}} - } - } - }, - "/admin/applications/transfer-ownership": { - "post": { - "operationId": "transfer_application_ownership", - "summary": "Transfer application ownership", + "parameters": [ + { + "name": "application_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the OAuth2 application" + } + ] + }, + "patch": { + "operationId": "update_admin_application", + "summary": "Update application", "tags": ["Admin"], "responses": { "200": { @@ -397,8 +536,17 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Transfers application ownership to another user. Used when owner is inactive or for administrative recovery. Logged to audit log. Requires APPLICATION_TRANSFER_OWNERSHIP permission.", + "description": "Updates an application. Transfers ownership to the user given by new_owner_id, which is used when the owner is inactive or for administrative recovery. Logged to audit log. Requires APPLICATION_TRANSFER_OWNERSHIP permission.", "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "application_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the OAuth2 application" + } + ], "requestBody": { "required": true, "content": { @@ -407,66 +555,9 @@ } } }, - "/admin/archives/guild": { - "post": { - "operationId": "trigger_guild_archive", - "summary": "Trigger guild archive", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminArchiveResponseSchema"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Initiates a data export for a guild (server). Creates an archive containing all guild data including channels, messages, members, roles, and settings.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TriggerGuildArchiveRequest"}}} - } - } - }, - "/admin/archives/list": { - "post": { - "operationId": "list_archives", + "/admin/archives": { + "get": { + "operationId": "list_admin_archives", "summary": "List archives", "tags": ["Admin"], "responses": { @@ -515,72 +606,43 @@ }, "description": "Query and filter created archives by type (user or guild), subject ID, requestor, and expiration status. Admins with limited ACLs see only archives matching their permissions.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListArchivesRequest"}}} - } - } - }, - "/admin/archives/user": { - "post": { - "operationId": "trigger_user_archive", - "summary": "Trigger user archive", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminArchiveResponseSchema"}}} + "parameters": [ + { + "name": "subject_type", + "in": "query", + "required": false, + "schema": {"type": "string", "enum": ["user", "guild", "all"], "description": "Type of archives to list"} }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + { + "name": "subject_id", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + { + "name": "requested_by", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + { + "name": "limit", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Maximum number of archives to return (1-200, default 50)"} }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + { + "name": "include_expired", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Whether to include archives past their expires_at"} } - }, - "description": "Initiates a data export for a user. Creates an archive containing all the user's data (messages, server memberships, preferences, etc.) for export or compliance purposes.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TriggerUserArchiveRequest"}}} - } + ] } }, - "/admin/archives/{subjectType}/{subjectId}/{archiveId}": { + "/admin/archives/{subject_type}/{subject_id}/{archive_id}": { "get": { - "operationId": "get_archive_details", + "operationId": "get_admin_archive", "summary": "Get archive details", "tags": ["Admin"], "responses": { @@ -631,32 +693,32 @@ "security": [{"adminApiKey": []}], "parameters": [ { - "name": "subjectType", + "name": "subject_type", "in": "path", "required": true, "schema": {"type": "string"}, - "description": "The subjectType" + "description": "The subject type" }, { - "name": "subjectId", + "name": "subject_id", "in": "path", "required": true, "schema": {"type": "string"}, - "description": "The subjectId" + "description": "The subject id" }, { - "name": "archiveId", + "name": "archive_id", "in": "path", "required": true, "schema": {"type": "string"}, - "description": "The archiveId" + "description": "The archive id" } ] } }, - "/admin/archives/{subjectType}/{subjectId}/{archiveId}/download": { + "/admin/archives/{subject_type}/{subject_id}/{archive_id}/download": { "get": { - "operationId": "get_archive_download_url", + "operationId": "get_admin_archive_download", "summary": "Get archive download URL", "tags": ["Admin"], "responses": { @@ -707,90 +769,33 @@ "security": [{"adminApiKey": []}], "parameters": [ { - "name": "subjectType", + "name": "subject_type", "in": "path", "required": true, "schema": {"type": "string"}, - "description": "The subjectType" + "description": "The subject type" }, { - "name": "subjectId", + "name": "subject_id", "in": "path", "required": true, "schema": {"type": "string"}, - "description": "The subjectId" + "description": "The subject id" }, { - "name": "archiveId", + "name": "archive_id", "in": "path", "required": true, "schema": {"type": "string"}, - "description": "The archiveId" + "description": "The archive id" } ] } }, - "/admin/assets/purge": { - "post": { - "operationId": "purge_guild_assets", - "summary": "Purge guild assets", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PurgeGuildAssetsResponseSchema"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Delete and clean up all assets belonging to a guild, including icons, banners, and other media. This is a destructive operation used for cleanup during guild management or compliance actions.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PurgeGuildAssetsRequest"}}} - } - } - }, "/admin/audit-logs": { - "post": { - "operationId": "list_audit_logs", - "summary": "List audit logs", + "get": { + "operationId": "list_admin_audit_logs", + "summary": "List admin audit logs", "tags": ["Admin"], "responses": { "200": { @@ -836,23 +841,76 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Retrieve a paginated list of audit logs with optional filtering by date range, action type, or actor. Used for tracking administrative operations and compliance auditing.", + "description": "Retrieve a paginated page of audit logs with optional filtering by acting admin, target type, or target ID. Passing q runs a full-text search across the audit log index instead of paging through the log in order, and sort_by with sort_order then order the matches. Used for tracking administrative operations, compliance auditing, and incident response.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListAuditLogsRequest"}}} - } + "parameters": [ + { + "name": "q", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Free-text query run against the audit log search index"} + }, + { + "name": "admin_user_id", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} + }, + { + "name": "target_type", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Filter by target entity type"} + }, + { + "name": "target_id", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Filter by target entity ID (user, channel, role, invite code, etc.)" + } + }, + { + "name": "sort_by", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["createdAt", "relevance"], + "description": "Field to sort audit logs by" + } + }, + { + "name": "sort_order", + "in": "query", + "required": false, + "schema": {"type": "string", "enum": ["asc", "desc"], "description": "Sort order direction"} + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Maximum number of entries to return"} + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Number of entries to skip"} + } + ] } }, - "/admin/audit-logs/search": { - "post": { - "operationId": "search_audit_logs", - "summary": "Search audit logs", + "/admin/audit-logs/{log_id}": { + "get": { + "operationId": "get_admin_audit_log", + "summary": "Get admin audit log entry", "tags": ["Admin"], "responses": { "200": { "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuditLogsListResponseSchema"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminAuditLogResponseSchema"}}} }, "400": { "description": "Bad Request - The request was malformed or contained invalid data", @@ -893,77 +951,22 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Perform a full-text search across audit logs for specific events or changes. Allows targeted queries for compliance investigations or incident response.", + "description": "Retrieve a single admin audit log entry by ID, with the same resolved user, guild, and channel summaries the listing returns. Used to inspect one administrative operation during compliance investigations or incident response.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SearchAuditLogsRequest"}}} - } + "parameters": [ + {"name": "log_id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The log id"} + ] } }, - "/admin/bans/avatar-hash/add": { - "post": { - "operationId": "add_avatar_hash_ban", - "summary": "Add avatar hash ban", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Ban one or more 8-char MD5-prefix avatar hashes. Avatars matching the banned hash will be rejected on upload.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanAvatarHashRequest"}}} - } - } - }, - "/admin/bans/avatar-hash/check": { - "post": { - "operationId": "check_avatar_hash_ban_status", - "summary": "Check avatar hash ban status", + "/admin/blocklists": { + "get": { + "operationId": "list_admin_blocklist_types", + "summary": "List blocklists", "tags": ["Admin"], "responses": { "200": { "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanCheckResponseSchema"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminBlocklistTypeListResponse"}}} }, "400": { "description": "Bad Request - The request was malformed or contained invalid data", @@ -1004,131 +1007,21 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Query whether any of the provided avatar hashes are banned.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CheckAvatarHashRequest"}}} - } + "description": "List every blocklist this instance maintains, the request field that carries an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.", + "security": [{"adminApiKey": []}] } }, - "/admin/bans/avatar-hash/remove": { - "post": { - "operationId": "remove_avatar_hash_ban", - "summary": "Remove avatar hash ban", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Lift a previously applied avatar-hash ban.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CheckAvatarHashRequest"}}} - } - } - }, - "/admin/bans/email/add": { - "post": { - "operationId": "add_email_ban", - "summary": "Add email ban", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Ban one or more email addresses from registering or creating accounts. Users attempting to use banned emails will be blocked.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanEmailRequest"}}} - } - } - }, - "/admin/bans/email/check": { - "post": { - "operationId": "check_email_ban_status", - "summary": "Check email ban status", + "/admin/blocklists/{list_type}/entries": { + "get": { + "operationId": "list_admin_blocklist_entries", + "summary": "List blocklist entries", "tags": ["Admin"], "responses": { "200": { "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanCheckResponseSchema"}}} + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/AdminBlocklistEntryListResponse"}} + } }, "400": { "description": "Bad Request - The request was malformed or contained invalid data", @@ -1169,18 +1062,46 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Query whether one or more email addresses are currently banned from registration. Returns the ban status and metadata.", + "description": "Page through the entries of a blocklist, ordered by value. Pass the next_after cursor of the previous page as after to fetch the next page. The profile-substring blocklist requires a scope.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanEmailRequest"}}} - } - } - }, - "/admin/bans/email/remove": { + "parameters": [ + { + "name": "list_type", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The list type" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Maximum number of entries to return"} + }, + { + "name": "after", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Return entries ordered after this value, taken from the next_after cursor of the previous page" + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "schema": { + "enum": ["username", "global_name", "nickname", "bio", "pronouns"], + "type": "string", + "description": "Profile field to list. Required on the profile-substring blocklist and rejected on every other blocklist." + } + } + ] + }, "post": { - "operationId": "remove_email_ban", - "summary": "Remove email ban", + "operationId": "create_admin_blocklist_entry", + "summary": "Add blocklist entry", "tags": ["Admin"], "responses": { "204": {"description": "No Content"}, @@ -1223,72 +1144,25 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Lift a previously applied email ban, allowing the address to be used for new registrations. Used for appeals or error correction.", + "description": "Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list, or that IPInfo reports as a high blast-radius carrier NAT, is refused with 400 IP_BAN_DECLINED and recorded in the audit log.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanEmailRequest"}}} - } - } - }, - "/admin/bans/file-sha/add": { - "post": { - "operationId": "add_file_sha_ban", - "summary": "Add file SHA ban", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + "parameters": [ + { + "name": "list_type", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The list type" } - }, - "description": "Ban one or more files by SHA hash. Uploads matching the banned hash will be rejected.", - "security": [{"adminApiKey": []}], + ], "requestBody": { "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanFileShaRequest"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminBlocklistEntryCreateRequest"}}} } - } - }, - "/admin/bans/file-sha/bulk-add": { - "post": { - "operationId": "bulk_ban_file_shas", - "summary": "Bulk-ban file SHAs as a background job", + }, + "put": { + "operationId": "bulk_create_admin_blocklist_entries", + "summary": "Bulk-add blocklist entries", "tags": ["Admin"], "responses": { "200": { @@ -1334,18 +1208,88 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Enqueue a background job that bans many file SHAs at once. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.", + "description": "Enqueue a background job that adds many entries to a blocklist at once. Returns a job_id immediately; observe progress at /admin/jobs/:job_id. Only the file-sha blocklist accepts this operation, reported as supports_bulk_create by GET /admin/blocklists.", "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "list_type", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The list type" + } + ], "requestBody": { "required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkBanFileShasRequest"}}} } + }, + "delete": { + "operationId": "bulk_delete_admin_blocklist_entries", + "summary": "Bulk-remove blocklist entries", + "tags": ["Admin"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Remove several entries from a blocklist in one request. The request body is the shape the blocklist named by list_type accepts. Only the avatar-hash and profile-substring blocklists accept this operation, reported as supports_bulk_delete by GET /admin/blocklists.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "list_type", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The list type" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminBlocklistBulkDeleteRequest"}}} + } } }, - "/admin/bans/file-sha/check": { - "post": { - "operationId": "check_file_sha_ban_status", - "summary": "Check file SHA ban status", + "/admin/blocklists/{list_type}/entries/{entry_value}": { + "get": { + "operationId": "get_admin_blocklist_entry", + "summary": "Check blocklist entry", "tags": ["Admin"], "responses": { "200": { @@ -1391,18 +1335,38 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Query whether one or more file SHA hashes are currently banned.", + "description": "Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a URL can match a banned domain. The profile-substring blocklist requires a scope.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CheckFileShaRequest"}}} - } - } - }, - "/admin/bans/file-sha/remove": { - "post": { - "operationId": "remove_file_sha_ban", - "summary": "Remove file SHA ban", + "parameters": [ + { + "name": "list_type", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The list type" + }, + { + "name": "entry_value", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The entry value" + }, + { + "name": "scope", + "in": "query", + "required": false, + "schema": { + "enum": ["username", "global_name", "nickname", "bio", "pronouns"], + "type": "string", + "description": "Profile field the entry is scoped to. Required on the profile-substring blocklist and rejected on every other blocklist." + } + } + ] + }, + "patch": { + "operationId": "update_admin_blocklist_entry", + "summary": "Update blocklist entry", "tags": ["Admin"], "responses": { "204": {"description": "No Content"}, @@ -1445,18 +1409,32 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Lift a previously applied file SHA ban, allowing uploads of that file again.", + "description": "Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries carry fields accept this operation, reported as supports_update by GET /admin/blocklists.", "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "list_type", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The list type" + }, + { + "name": "entry_value", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The entry value" + } + ], "requestBody": { "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UnbanFileShaRequest"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminBlocklistEntryUpdateRequest"}}} } - } - }, - "/admin/bans/ip/add": { - "post": { - "operationId": "add_ip_ban", - "summary": "Add IP ban", + }, + "delete": { + "operationId": "delete_admin_blocklist_entry", + "summary": "Remove blocklist entry", "tags": ["Admin"], "responses": { "204": {"description": "No Content"}, @@ -1499,789 +1477,40 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Ban one or more IP addresses from accessing the platform. Users connecting from banned IPs will be denied service. Can be applied retroactively.", + "description": "Remove an entry from a blocklist. The value is percent-encoded in the path and is canonicalized the same way it was on add, so an IP covered only by a broader CIDR entry cannot be removed through the narrower address. The profile-substring blocklist requires a scope.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanIpRequest"}}} - } - } - }, - "/admin/bans/ip/check": { - "post": { - "operationId": "check_ip_ban_status", - "summary": "Check IP ban status", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanCheckResponseSchema"}}} + "parameters": [ + { + "name": "list_type", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The list type" }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + { + "name": "entry_value", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The entry value" }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } + { + "name": "scope", + "in": "query", + "required": false, + "schema": { + "enum": ["username", "global_name", "nickname", "bio", "pronouns"], + "type": "string", + "description": "Profile field the entry is scoped to. Required on the profile-substring blocklist and rejected on every other blocklist." } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } - }, - "description": "Query whether one or more IP addresses are currently banned. Returns the ban status and any associated metadata like reason or expiration.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanIpRequest"}}} - } + ] } }, - "/admin/bans/ip/remove": { + "/admin/bulk-jobs": { "post": { - "operationId": "remove_ip_ban", - "summary": "Remove IP ban", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Lift a previously applied IP ban, allowing traffic from those addresses again. Used for appeals or when bans were applied in error.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanIpRequest"}}} - } - } - }, - "/admin/bans/phrase/add": { - "post": { - "operationId": "add_phrase_ban", - "summary": "Add phrase ban", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Ban a phrase. Matching is case-insensitive and also normalizes common bypass tricks such as inserted whitespace, punctuation, invisible characters, and compatibility glyphs.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanPhraseRequest"}}} - } - } - }, - "/admin/bans/phrase/check": { - "post": { - "operationId": "check_phrase_ban_status", - "summary": "Check phrase ban status", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanCheckResponseSchema"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Query whether a phrase is currently banned.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanPhraseRequest"}}} - } - } - }, - "/admin/bans/phrase/remove": { - "post": { - "operationId": "remove_phrase_ban", - "summary": "Remove phrase ban", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Lift a previously applied phrase ban, allowing messages containing that phrase again.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanPhraseRequest"}}} - } - } - }, - "/admin/bans/profile-substring/add": { - "post": { - "operationId": "add_profile_substring_ban", - "summary": "Add profile-substring ban", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Ban a substring within a specific profile field (username, global_name, nickname, bio, or pronouns). Matching reuses the phrase blocklist normalization (whitespace, punctuation, zero-width, lookalikes).", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanProfileSubstringRequest"}}} - } - } - }, - "/admin/bans/profile-substring/check": { - "post": { - "operationId": "check_profile_substring_ban_status", - "summary": "Check profile-substring ban status", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanCheckResponseSchema"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Query whether any of the provided substrings are banned for the given scope.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanProfileSubstringRequest"}}} - } - } - }, - "/admin/bans/profile-substring/remove": { - "post": { - "operationId": "remove_profile_substring_ban", - "summary": "Remove profile-substring ban", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Lift a previously applied profile-substring ban.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanProfileSubstringRequest"}}} - } - } - }, - "/admin/bans/url-domain/add": { - "post": { - "operationId": "add_url_domain_ban", - "summary": "Add URL domain ban", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Ban an entire URL domain from being linked on the platform. All URLs under the banned domain will be blocked.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanUrlDomainRequest"}}} - } - } - }, - "/admin/bans/url-domain/check": { - "post": { - "operationId": "check_url_domain_ban_status", - "summary": "Check URL domain ban status", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanCheckResponseSchema"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Query whether a URL domain is currently banned from being linked.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanUrlDomainRequest"}}} - } - } - }, - "/admin/bans/url-domain/remove": { - "post": { - "operationId": "remove_url_domain_ban", - "summary": "Remove URL domain ban", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Lift a previously applied URL domain ban, allowing links to that domain again.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UnbanUrlDomainRequest"}}} - } - } - }, - "/admin/bans/url/add": { - "post": { - "operationId": "add_url_ban", - "summary": "Add URL ban", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Ban one or more URLs from being posted on the platform. Messages containing banned URLs will be blocked.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanUrlRequest"}}} - } - } - }, - "/admin/bans/url/check": { - "post": { - "operationId": "check_url_ban_status", - "summary": "Check URL ban status", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanCheckResponseSchema"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Query whether one or more URLs are currently banned from being posted.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CheckUrlBlocklistRequest"}}} - } - } - }, - "/admin/bans/url/remove": { - "post": { - "operationId": "remove_url_ban", - "summary": "Remove URL ban", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Lift a previously applied URL ban, allowing the URL to be posted again.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UnbanUrlRequest"}}} - } - } - }, - "/admin/bulk/add-guild-members": { - "post": { - "operationId": "bulk_add_guild_members", - "summary": "Bulk add guild members", + "operationId": "create_admin_bulk_job", + "summary": "Queue a bulk job", "tags": ["Admin"], "responses": { "200": { @@ -2327,23 +1556,23 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Enqueue a background job that adds multiple users to a guild. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.", + "description": "Enqueue one background administrative job. The `task` discriminator selects both the body variant and the ACL evaluated for the request: `update_user_flags` needs bulk:update:user_flags, `update_suspicious_activity_flags` needs bulk:update:suspicious_activity, `update_guild_features` needs bulk:update:guild_features, `add_guild_members` needs bulk:add:guild_members, `schedule_user_deletion` needs bulk:delete:users, and `delete_user_messages` needs bulk:delete:user_messages. Returns a job_id immediately; observe progress at /admin/jobs/:job_id. Note: the schedule_user_deletion worker skips Stripe refunds, session termination, and identifier banning — apply those separately for high-risk accounts.", "security": [{"adminApiKey": []}], "requestBody": { "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkAddGuildMembersRequest"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminBulkJobCreateRequest"}}} } } }, - "/admin/bulk/delete-user-messages": { - "post": { - "operationId": "bulk_delete_user_messages", - "summary": "Bulk delete user messages", + "/admin/channels/{channel_id}/messages": { + "get": { + "operationId": "list_admin_channel_messages", + "summary": "List channel messages", "tags": ["Admin"], "responses": { "200": { "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkJobResponse"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BrowseChannelResponse"}}} }, "400": { "description": "Bad Request - The request was malformed or contained invalid data", @@ -2384,23 +1613,41 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Enqueue a background job that deletes every message authored by each of the given users across all channels. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.", + "description": "Pages through the messages of a channel, newest first, with cursor-based pagination. Requires MESSAGE_LOOKUP permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkDeleteUserMessagesRequest"}}} - } + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Number of messages to return (1-100, default 50)"} + }, + { + "name": "before", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} + }, + {"name": "after", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/SnowflakeType"}} + ] } }, - "/admin/bulk/schedule-user-deletion": { - "post": { - "operationId": "schedule_bulk_user_deletion", - "summary": "Schedule bulk user deletion", + "/admin/channels/{channel_id}/messages/{message_id}": { + "get": { + "operationId": "get_admin_message", + "summary": "Get message", "tags": ["Admin"], "responses": { "200": { "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkJobResponse"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LookupMessageResponse"}}} }, "400": { "description": "Bad Request - The request was malformed or contained invalid data", @@ -2441,23 +1688,42 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Enqueue a background job that schedules account deletions for multiple users. Returns a job_id immediately; observe progress at /admin/jobs/:job_id. Note: the worker version skips Stripe refunds, session termination, and identifier banning — apply those separately for high-risk accounts.", + "description": "Retrieves complete message details including content, attachments, edits, and metadata, together with the messages surrounding it. Requires MESSAGE_LOOKUP permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkScheduleUserDeletionRequest"}}} - } - } - }, - "/admin/bulk/update-guild-features": { - "post": { - "operationId": "bulk_update_guild_features", - "summary": "Bulk update guild features", + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the message" + }, + { + "name": "context_limit", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "How many messages surrounding the requested message to return as context (1-100, default 50)" + } + } + ] + }, + "delete": { + "operationId": "delete_admin_message", + "summary": "Delete message", "tags": ["Admin"], "responses": { "200": { "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkJobResponse"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteMessageResponse"}}} }, "400": { "description": "Bad Request - The request was malformed or contained invalid data", @@ -2498,191 +1764,30 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Enqueue a background job that modifies guild features across multiple servers. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.", + "description": "Deletes a single message permanently and purges its attachments. Used for removing inappropriate or harmful content. Logged to audit log. Requires MESSAGE_DELETE permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkUpdateGuildFeaturesRequest"}}} - } - } - }, - "/admin/bulk/update-suspicious-activity-flags": { - "post": { - "operationId": "bulk_update_suspicious_activity_flags", - "summary": "Bulk update suspicious activity flags", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkJobResponse"}}} + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + { + "name": "message_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the message" } - }, - "description": "Enqueue a background job that modifies suspicious activity flags for multiple users. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/BulkUpdateSuspiciousActivityFlagsRequest"}} - } - } - } - }, - "/admin/bulk/update-user-flags": { - "post": { - "operationId": "bulk_update_user_flags", - "summary": "Bulk update user flags", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkJobResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Enqueue a background job that modifies user flags (e.g., verified, bot, system) for multiple users. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkUpdateUserFlagsRequest"}}} - } - } - }, - "/admin/codes/gift": { - "post": { - "operationId": "generate_gift_codes", - "summary": "Generate gift codes", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CodesResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Create one-use Plutonium gift codes with an explicit positive duration. Lifetime gifts are not supported.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GenerateGiftCodesRequest"}}} - } + ] } }, "/admin/discovery/applications": { "get": { - "operationId": "list_pending_discovery_applications", - "summary": "List all pending discovery applications", + "operationId": "list_admin_discovery_applications", + "summary": "List discovery applications", "tags": ["Admin"], "responses": { "200": { @@ -2739,10 +1844,10 @@ "security": [{"adminApiKey": []}] } }, - "/admin/discovery/applications/{guild_id}/approve": { - "post": { - "operationId": "approve_discovery_application", - "summary": "Approve discovery application", + "/admin/discovery/applications/{guild_id}": { + "patch": { + "operationId": "update_admin_discovery_application", + "summary": "Review discovery application", "tags": ["Admin"], "responses": { "200": { @@ -2788,7 +1893,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Approve a pending discovery application. Requires DISCOVERY_REVIEW permission.", + "description": "Approve or reject a pending discovery application. Requires DISCOVERY_REVIEW permission.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -2801,14 +1906,275 @@ ], "requestBody": { "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DiscoveryAdminReviewRequest"}}} + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/DiscoveryAdminApplicationUpdateRequest"}} + } } } }, - "/admin/discovery/applications/{guild_id}/reject": { - "post": { - "operationId": "reject_discovery_application", - "summary": "Reject discovery application", + "/admin/discovery/categories": { + "get": { + "operationId": "list_admin_discovery_categories", + "summary": "List discovery categories", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DiscoveryCategoryListResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Returns every discovery category a listing can be filed under. Requires DISCOVERY_REVIEW permission.", + "security": [{"adminApiKey": []}] + } + }, + "/admin/discovery/categories/{category_id}/listings": { + "get": { + "operationId": "list_admin_discovery_category_listings", + "summary": "List guilds in a discovery category", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {"type": "array", "items": {"$ref": "#/components/schemas/DiscoveryAdminListedGuildResponse"}} + } + } + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Returns an offset page of the guilds listed under one discovery category, most members first, enriched with guild metadata. Requires DISCOVERY_REVIEW permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "category_id", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The category id" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "format": "int32", + "description": "Number of listings to return" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 10000, + "format": "int32", + "description": "Pagination offset" + } + } + ] + } + }, + "/admin/discovery/listings": { + "get": { + "operationId": "list_admin_discovery_listings", + "summary": "List discovery listings", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {"type": "array", "items": {"$ref": "#/components/schemas/DiscoveryAdminListedGuildResponse"}} + } + } + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Returns every approved/listed discovery guild, enriched with guild metadata. No pagination. Requires DISCOVERY_REVIEW permission.", + "security": [{"adminApiKey": []}] + }, + "patch": { + "operationId": "bulk_update_admin_discovery_listing_category", + "summary": "Move discovery listings to a category", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/DiscoveryAdminListingBulkCategoryResponse"}} + } + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Files every named discovery listing under one category. Every guild is attempted and the ones that could not be moved are reported. Requires DISCOVERY_REVIEW permission.", + "security": [{"adminApiKey": []}], + "requestBody": { + "required": true, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/DiscoveryAdminListingBulkCategoryRequest"}} + } + } + } + }, + "/admin/discovery/listings/{guild_id}": { + "patch": { + "operationId": "update_admin_discovery_listing", + "summary": "Update discovery listing", "tags": ["Admin"], "responses": { "200": { @@ -2854,7 +2220,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Reject a pending discovery application. Requires DISCOVERY_REVIEW permission.", + "description": "Edit the description, category, language, or tags of a discovery listing without delisting the guild. Requires DISCOVERY_REVIEW permission.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -2867,14 +2233,12 @@ ], "requestBody": { "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DiscoveryAdminRejectRequest"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DiscoveryApplicationPatchRequest"}}} } - } - }, - "/admin/discovery/guilds/{guild_id}/remove": { - "post": { - "operationId": "remove_from_discovery", - "summary": "Remove guild from discovery", + }, + "delete": { + "operationId": "delete_admin_discovery_listing", + "summary": "Remove discovery listing", "tags": ["Admin"], "responses": { "200": { @@ -2937,66 +2301,9 @@ } } }, - "/admin/discovery/listed": { - "get": { - "operationId": "list_discovery_listed_guilds", - "summary": "List all guilds currently listed in discovery", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": {"type": "array", "items": {"$ref": "#/components/schemas/DiscoveryAdminListedGuildResponse"}} - } - } - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Returns every approved/listed discovery guild, enriched with guild metadata. No pagination. Requires DISCOVERY_REVIEW permission.", - "security": [{"adminApiKey": []}] - } - }, "/admin/gateway/memory-stats": { - "post": { - "operationId": "get_guild_memory_statistics", + "get": { + "operationId": "get_admin_gateway_memory_stats", "summary": "Get guild memory statistics", "tags": ["Admin"], "responses": { @@ -3045,16 +2352,23 @@ }, "description": "Returns heap and resident memory usage per guild. Requires GATEWAY_MEMORY_STATS permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetProcessMemoryStatsRequest"}}} - } + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Maximum number of guild processes to return (100-1000, default 100)" + } + } + ] } }, - "/admin/gateway/reload-all": { + "/admin/gateway/reloads": { "post": { - "operationId": "reload_all_specified_guilds", - "summary": "Reload specified guilds", + "operationId": "create_admin_gateway_reload", + "summary": "Reload gateway guilds", "tags": ["Admin"], "responses": { "200": { @@ -3110,7 +2424,7 @@ }, "/admin/gateway/stats": { "get": { - "operationId": "get_gateway_node_statistics", + "operationId": "get_admin_gateway_stats", "summary": "Get gateway node statistics", "tags": ["Admin"], "responses": { @@ -3163,7 +2477,7 @@ }, "/admin/gateway/voice-state-counts": { "get": { - "operationId": "get_gateway_voice_state_counts", + "operationId": "get_admin_gateway_voice_state_counts", "summary": "Get gateway voice state counts", "tags": ["Admin"], "responses": { @@ -3216,9 +2530,460 @@ "security": [{"adminApiKey": []}] } }, - "/admin/guilds/audit-logs": { + "/admin/gift-codes": { "post": { - "operationId": "list_guild_audit_logs_admin", + "operationId": "create_admin_gift_codes", + "summary": "Issue gift codes", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CodesResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Create one-use Plutonium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. Not available on self-hosted instances. Requires GIFT_CODES_GENERATE permission.", + "security": [{"adminApiKey": []}], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GenerateGiftCodesRequest"}}} + } + } + }, + "/admin/guilds": { + "get": { + "operationId": "list_admin_guilds", + "summary": "List guilds", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SearchGuildsResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Searches guilds by name, ID, and other criteria. Supports full-text search and pagination through limit and offset. Requires GUILD_LOOKUP permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Free-text query matched against the guild name and vanity URL code" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Maximum guilds to return (1-200, default 50)"} + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Guilds to skip before returning results (default 0)"} + } + ] + } + }, + "/admin/guilds/{guild_id}": { + "get": { + "operationId": "get_admin_guild", + "summary": "Get guild", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LookupGuildResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Retrieves complete guild details including metadata, settings, channels, roles, and statistics. Requires GUILD_LOOKUP permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" + } + ] + }, + "patch": { + "operationId": "update_admin_guild", + "summary": "Update guild", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildUpdateResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Partially updates a guild. The permissions required are selected by the fields present in the body and are evaluated with all-of semantics: name requires GUILD_UPDATE_NAME, vanity_url_code requires GUILD_UPDATE_VANITY, new_owner_id requires GUILD_TRANSFER_OWNERSHIP, add_features and remove_features require GUILD_UPDATE_FEATURES, and fields together with every other setting requires GUILD_UPDATE_SETTINGS. A body carrying no field requires the wildcard permission. Every applied change is logged to the audit log.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateGuildRequest"}}} + } + }, + "delete": { + "operationId": "delete_admin_guild", + "summary": "Delete guild", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SuccessResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Permanently deletes a guild. Deletes all channels, messages, and settings. Irreversible operation with no recovery window. Logged to audit log. Requires GUILD_DELETE permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" + } + ] + } + }, + "/admin/guilds/{guild_id}/archives": { + "post": { + "operationId": "create_admin_guild_archive", + "summary": "Create guild archive", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminArchiveResponseSchema"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Initiates a data export for a guild (server). Creates an archive containing all guild data including channels, messages, members, roles, and settings.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminArchiveCreateRequest"}}} + } + } + }, + "/admin/guilds/{guild_id}/assets": { + "delete": { + "operationId": "purge_admin_guild_assets", + "summary": "Purge guild assets", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PurgeGuildAssetsResponseSchema"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Delete and clean up emoji and sticker assets belonging to a guild, including their stored media. An ID owned by another guild is reported in errors and left untouched, and an ID with no record still queues its media for removal. This is a destructive operation used for cleanup during guild management or compliance actions.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PurgeGuildAssetsRequest"}}} + } + } + }, + "/admin/guilds/{guild_id}/audit-logs": { + "get": { + "operationId": "list_admin_guild_audit_logs", "summary": "List guild audit logs", "tags": ["Admin"], "responses": { @@ -3267,15 +3032,40 @@ }, "description": "Returns in-app guild audit log entries for a guild without requiring VIEW_AUDIT_LOG membership permission. Supports pagination via before/after log IDs and filtering by user_id or action_type. Requires GUILD_AUDIT_LOG_VIEW permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListGuildAuditLogsRequest"}}} - } + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" + }, + {"name": "limit", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/Int32Type"}}, + { + "name": "before", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} + }, + {"name": "after", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/SnowflakeType"}}, + { + "name": "user_id", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} + }, + { + "name": "action_type", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/AuditLogActionType"} + } + ] } }, - "/admin/guilds/ban-member": { - "post": { - "operationId": "admin_ban_guild_member", + "/admin/guilds/{guild_id}/bans/{user_id}": { + "put": { + "operationId": "ban_admin_guild_member", "summary": "Ban guild member", "tags": ["Admin"], "responses": { @@ -3319,809 +3109,33 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Permanently bans a user from a guild. Prevents user from joining. Logged to audit log. Requires GUILD_BAN_MEMBER permission.", + "description": "Bans a user from a guild, optionally deleting their recent messages. Prevents the user from joining until the ban expires or is removed. Logged to audit log. Requires GUILD_BAN_MEMBER permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanGuildMemberRequest"}}} - } - } - }, - "/admin/guilds/clear-fields": { - "post": { - "operationId": "clear_guild_fields", - "summary": "Clear guild fields", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" } - }, - "description": "Clears specified optional guild fields such as icon, banner, or description. Logged to audit log. Requires GUILD_UPDATE_SETTINGS permission.", - "security": [{"adminApiKey": []}], + ], "requestBody": { "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ClearGuildFieldsRequest"}}} - } - } - }, - "/admin/guilds/delete": { - "post": { - "operationId": "admin_delete_guild", - "summary": "Delete guild", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SuccessResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Permanently deletes a guild. Deletes all channels, messages, and settings. Irreversible operation. Logged to audit log. Requires GUILD_DELETE permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteGuildRequest"}}} - } - } - }, - "/admin/guilds/force-add-user": { - "post": { - "operationId": "force_add_user_to_guild", - "summary": "Force add user to guild", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SuccessResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Forcefully adds a user to a guild. Bypasses normal invite flow for administrative account recovery. Logged to audit log. Requires GUILD_FORCE_ADD_MEMBER permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ForceAddUserToGuildRequest"}}} - } - } - }, - "/admin/guilds/kick-member": { - "post": { - "operationId": "kick_guild_member", - "summary": "Kick guild member", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Temporarily removes a user from a guild. User can rejoin. Logged to audit log. Requires GUILD_KICK_MEMBER permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/KickGuildMemberRequest"}}} - } - } - }, - "/admin/guilds/list-members": { - "post": { - "operationId": "admin_list_guild_members", - "summary": "List guild members", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListGuildMembersResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Lists all guild members with pagination. Returns member IDs, join dates, and roles. Requires GUILD_LIST_MEMBERS permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListGuildMembersRequest"}}} - } - } - }, - "/admin/guilds/lookup": { - "post": { - "operationId": "lookup_guild", - "summary": "Look up guild", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LookupGuildResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Retrieves complete guild details including metadata, settings, and statistics. Look up by guild ID or vanity slug. Requires GUILD_LOOKUP permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LookupGuildRequest"}}} - } - } - }, - "/admin/guilds/reload": { - "post": { - "operationId": "reload_guild", - "summary": "Reload guild", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SuccessResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Reloads a single guild state from database. Used to recover from corruption or sync issues. Logged to audit log. Requires GUILD_RELOAD permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReloadGuildRequest"}}} - } - } - }, - "/admin/guilds/search": { - "post": { - "operationId": "search_guilds", - "summary": "Search guilds", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SearchGuildsResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Searches guilds by name, ID, and other criteria. Supports full-text search and filtering. Requires GUILD_LOOKUP permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SearchGuildsRequest"}}} - } - } - }, - "/admin/guilds/shutdown": { - "post": { - "operationId": "shutdown_guild", - "summary": "Shutdown guild", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SuccessResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Shuts down and unloads a guild from the gateway. Guild data remains in database. Used for emergency resource cleanup. Logged to audit log. Requires GUILD_SHUTDOWN permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ShutdownGuildRequest"}}} - } - } - }, - "/admin/guilds/transfer-ownership": { - "post": { - "operationId": "admin_transfer_guild_ownership", - "summary": "Transfer guild ownership", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildUpdateResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Transfers guild ownership to another user. Used when owner is inactive or for administrative recovery. Logged to audit log. Requires GUILD_TRANSFER_OWNERSHIP permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TransferGuildOwnershipRequest"}}} - } - } - }, - "/admin/guilds/update-features": { - "post": { - "operationId": "update_guild_features", - "summary": "Update guild features", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildUpdateResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Enables or disables guild feature flags. Modifies verification levels and community settings. Changes are logged to audit log. Requires GUILD_UPDATE_FEATURES permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateGuildFeaturesRequest"}}} - } - } - }, - "/admin/guilds/update-name": { - "post": { - "operationId": "update_guild_name", - "summary": "Update guild name", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildUpdateResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Changes a guild name. Used for removing inappropriate names or correcting display issues. Logged to audit log. Requires GUILD_UPDATE_NAME permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateGuildNameRequest"}}} - } - } - }, - "/admin/guilds/update-settings": { - "post": { - "operationId": "update_guild_settings", - "summary": "Update guild settings", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildUpdateResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Modifies guild configuration including description, region, language and other settings. Logged to audit log. Requires GUILD_UPDATE_SETTINGS permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateGuildSettingsRequest"}}} - } - } - }, - "/admin/guilds/update-vanity": { - "post": { - "operationId": "update_guild_vanity", - "summary": "Update guild vanity", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildUpdateResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Updates a guild vanity URL slug. Sets custom short URL and prevents duplicate slugs. Logged to audit log. Requires GUILD_UPDATE_VANITY permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateGuildVanityRequest"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanGuildMemberBody"}}} } } }, "/admin/guilds/{guild_id}/emojis": { "get": { - "operationId": "admin_list_guild_emojis", + "operationId": "list_admin_guild_emojis", "summary": "List guild emojis", "tags": ["Admin"], "responses": { @@ -4181,9 +3195,340 @@ ] } }, + "/admin/guilds/{guild_id}/members": { + "get": { + "operationId": "list_admin_guild_members", + "summary": "List guild members", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListGuildMembersResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Lists all guild members with pagination. Returns member IDs, join dates, and roles. Requires GUILD_LIST_MEMBERS permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Maximum members to return (1-200, default 50)"} + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Members to skip before returning results (default 0)"} + } + ] + } + }, + "/admin/guilds/{guild_id}/members/{user_id}": { + "put": { + "operationId": "add_admin_guild_member", + "summary": "Add guild member", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SuccessResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Forcefully adds a user to a guild. Bypasses normal invite flow for administrative account recovery. Logged to audit log. Requires GUILD_FORCE_ADD_MEMBER permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" + }, + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ] + }, + "delete": { + "operationId": "kick_admin_guild_member", + "summary": "Remove guild member", + "tags": ["Admin"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Temporarily removes a user from a guild. User can rejoin. Logged to audit log. Requires GUILD_KICK_MEMBER permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" + }, + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ] + } + }, + "/admin/guilds/{guild_id}/reloads": { + "post": { + "operationId": "create_admin_guild_reload", + "summary": "Reload guild", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SuccessResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Reloads a single guild state from database. Used to recover from corruption or sync issues. Logged to audit log. Requires GUILD_RELOAD permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" + } + ] + } + }, + "/admin/guilds/{guild_id}/shutdowns": { + "post": { + "operationId": "create_admin_guild_shutdown", + "summary": "Shut down guild", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SuccessResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Shuts down and unloads a guild from the gateway. Guild data remains in database. Used for emergency resource cleanup. Logged to audit log. Requires GUILD_SHUTDOWN permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" + } + ] + } + }, "/admin/guilds/{guild_id}/stickers": { "get": { - "operationId": "admin_list_guild_stickers", + "operationId": "list_admin_guild_stickers", "summary": "List guild stickers", "tags": ["Admin"], "responses": { @@ -4243,66 +3588,9 @@ ] } }, - "/admin/instance-config/branding-asset": { - "post": { - "operationId": "upload_instance_branding_asset", - "summary": "Upload or clear an instance branding asset", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceConfigResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Uploads a branding image served by the media proxy and stores its URL, or clears it when no image is provided. Requires INSTANCE_CONFIG_UPDATE permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BrandingAssetUploadRequest"}}} - } - } - }, - "/admin/instance-config/get": { - "post": { - "operationId": "get_instance_config", + "/admin/instance/config": { + "get": { + "operationId": "get_admin_instance_config", "summary": "Get instance configuration", "tags": ["Admin"], "responses": { @@ -4351,296 +3639,9 @@ }, "description": "Retrieves instance-wide configuration including webhooks and SSO configuration. Requires INSTANCE_CONFIG_VIEW permission.", "security": [{"adminApiKey": []}] - } - }, - "/admin/instance-config/integrations/smtp/test": { - "post": { - "operationId": "test_instance_smtp_config", - "summary": "Validate SMTP configuration", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceEmailSmtpTestResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Validates that an SMTP configuration can authenticate and accept a connection. Requires INSTANCE_CONFIG_UPDATE permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceEmailSmtpTestRequest"}}} - } - } - }, - "/admin/instance-config/pending-registrations/approve": { - "post": { - "operationId": "approve_pending_registration", - "summary": "Approve a pending registration", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceConfigResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Approves a registration waiting for manual review by removing its pending registration trait. Requires INSTANCE_CONFIG_UPDATE permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PendingRegistrationActionRequest"}}} - } - } - }, - "/admin/instance-config/pending-registrations/reject": { - "post": { - "operationId": "reject_pending_registration", - "summary": "Reject a pending registration", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceConfigResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Rejects a registration waiting for manual review and prevents the account from logging in. Requires INSTANCE_CONFIG_UPDATE permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PendingRegistrationActionRequest"}}} - } - } - }, - "/admin/instance-config/registration-urls/create": { - "post": { - "operationId": "create_registration_url", - "summary": "Create an admin-issued registration URL", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateRegistrationUrlResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Creates a one-time-display registration URL that can be sent manually by an administrator. Requires INSTANCE_CONFIG_UPDATE permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateRegistrationUrlRequest"}}} - } - } - }, - "/admin/instance-config/registration-urls/revoke": { - "post": { - "operationId": "revoke_registration_url", - "summary": "Revoke an admin-issued registration URL", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceConfigResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Revokes an admin-issued registration URL so it can no longer be used. Requires INSTANCE_CONFIG_UPDATE permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RegistrationUrlActionRequest"}}} - } - } - }, - "/admin/instance-config/update": { - "post": { - "operationId": "update_instance_config", + }, + "patch": { + "operationId": "update_admin_instance_config", "summary": "Update instance configuration", "tags": ["Admin"], "responses": { @@ -4695,10 +3696,422 @@ } } }, - "/admin/jobs/active": { + "/admin/instance/config/branding-assets": { "post": { - "operationId": "list_active_jobs", - "summary": "List active (queued + running) jobs", + "operationId": "create_admin_instance_branding_asset", + "summary": "Upload an instance branding asset", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceConfigResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Uploads a branding image served by the media proxy and stores its URL, or clears it when no image is provided. Requires INSTANCE_CONFIG_UPDATE permission.", + "security": [{"adminApiKey": []}], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BrandingAssetUploadRequest"}}} + } + } + }, + "/admin/instance/config/smtp-tests": { + "post": { + "operationId": "create_admin_instance_smtp_test", + "summary": "Run an SMTP configuration test", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceEmailSmtpTestResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Validates that an SMTP configuration can authenticate and accept a connection. Requires INSTANCE_CONFIG_UPDATE permission.", + "security": [{"adminApiKey": []}], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceEmailSmtpTestRequest"}}} + } + } + }, + "/admin/instance/pending-registrations/{user_id}": { + "patch": { + "operationId": "update_admin_pending_registration", + "summary": "Approve or reject a pending registration", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceConfigResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Decides a registration waiting for manual review. Approving removes its pending registration trait, rejecting also prevents the account from logging in. Requires INSTANCE_CONFIG_UPDATE permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PendingRegistrationActionRequest"}}} + } + } + }, + "/admin/instance/registration-urls": { + "post": { + "operationId": "create_admin_registration_url", + "summary": "Create an admin-issued registration URL", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateRegistrationUrlResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Creates a one-time-display registration URL that can be sent manually by an administrator. Requires INSTANCE_CONFIG_UPDATE permission.", + "security": [{"adminApiKey": []}], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateRegistrationUrlRequest"}}} + } + } + }, + "/admin/instance/registration-urls/{registration_url_id}": { + "delete": { + "operationId": "revoke_admin_registration_url", + "summary": "Revoke an admin-issued registration URL", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceConfigResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Revokes an admin-issued registration URL so it can no longer be used. Requires INSTANCE_CONFIG_UPDATE permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "registration_url_id", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The registration url id" + } + ] + } + }, + "/admin/jobs": { + "get": { + "operationId": "list_admin_jobs", + "summary": "List jobs", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListJobsResponseSchema"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Paginated, filterable list of background jobs from the human-facing ledger. Walks back through day-buckets and applies status / task-type / requester filters in-process. The three cursor query parameters come from the previous page `next_cursor` and must be supplied together.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Maximum number of jobs to return (1-200, default 50)"} + }, + { + "name": "cursor_bucket_day", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Day bucket to resume from as a YYYY-MM-DD UTC date, taken from next_cursor.bucket_day" + } + }, + { + "name": "cursor_created_at", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Creation time to resume before as an ISO 8601 timestamp, taken from next_cursor.created_at" + } + }, + { + "name": "cursor_job_id", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} + }, + { + "name": "max_lookback_days", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "How many day buckets to scan back through (1-60, default 14)"} + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "enum": ["queued", "running", "succeeded", "cancelled", "deadletter"], + "type": "string", + "description": "Filter by job status" + } + }, + { + "name": "task_type", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Filter by task type"} + }, + { + "name": "requested_by_user_id", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} + } + ] + } + }, + "/admin/jobs/active": { + "get": { + "operationId": "list_admin_active_jobs", + "summary": "List active jobs", "tags": ["Admin"], "responses": { "200": { @@ -4744,70 +4157,13 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Polling endpoint for the Jobs page. Returns only currently-active jobs (queued or running) so the UI can refresh progress without scanning historical data.", + "description": "Polling endpoint for the Jobs page. Returns only currently-active jobs (queued or running) from their own index, so the UI can refresh progress without scanning historical day-buckets.", "security": [{"adminApiKey": []}] } }, - "/admin/jobs/cancel": { - "post": { - "operationId": "cancel_job", - "summary": "Request cancellation of a running job", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CancelJobResponseSchema"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Mark a job as cancel-requested. The handler must be cooperatively cancellable — it will see the flag at its next `helpers.shouldCancel()` check. Returns `{cancelled: false}` for already-terminal jobs.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CancelJobRequest"}}} - } - } - }, - "/admin/jobs/get": { - "post": { - "operationId": "get_job", + "/admin/jobs/{job_id}": { + "get": { + "operationId": "get_admin_job", "summary": "Get job detail", "tags": ["Admin"], "responses": { @@ -4856,21 +4212,20 @@ }, "description": "Fetch a single job ledger entry with full payload, result, and progress.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetJobRequest"}}} - } + "parameters": [ + {"name": "job_id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The job id"} + ] } }, - "/admin/jobs/list": { - "post": { - "operationId": "list_jobs", - "summary": "List jobs", + "/admin/jobs/{job_id}/cancellation": { + "put": { + "operationId": "create_admin_job_cancellation", + "summary": "Request cancellation of a running job", "tags": ["Admin"], "responses": { "200": { "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListJobsResponseSchema"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CancelJobResponseSchema"}}} }, "400": { "description": "Bad Request - The request was malformed or contained invalid data", @@ -4911,17 +4266,16 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Paginated, filterable list of background jobs from the human-facing ledger. Walks back through day-buckets and applies status / task-type / requester filters in-process.", + "description": "Mark a job as cancel-requested. The handler must be cooperatively cancellable — it will see the flag at its next `helpers.shouldCancel()` check. Returns `{cancelled: false}` for already-terminal jobs.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListJobsRequest"}}} - } + "parameters": [ + {"name": "job_id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The job id"} + ] } }, - "/admin/limit-config/get": { - "post": { - "operationId": "get_limit_config", + "/admin/limit-config": { + "get": { + "operationId": "get_admin_limit_config", "summary": "Get limit configuration", "tags": ["Admin"], "responses": { @@ -4970,12 +4324,10 @@ }, "description": "Retrieves rate limit configuration including message limits, upload limits, and request throttles. Shows defaults, metadata, and any modifications from defaults. Requires INSTANCE_LIMIT_CONFIG_VIEW permission.", "security": [{"adminApiKey": []}] - } - }, - "/admin/limit-config/update": { - "post": { - "operationId": "update_limit_config", - "summary": "Update limit configuration", + }, + "put": { + "operationId": "replace_admin_limit_config", + "summary": "Replace limit configuration", "tags": ["Admin"], "responses": { "200": { @@ -5021,7 +4373,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Updates rate limit configuration including message throughput, upload sizes, and request throttles. Changes apply immediately to all new operations. Requires INSTANCE_LIMIT_CONFIG_UPDATE permission.", + "description": "Replaces the stored limit configuration, which covers message throughput, upload sizes, and request throttles, with the supplied document. Changes apply immediately to all new operations. Requires INSTANCE_LIMIT_CONFIG_UPDATE permission.", "security": [{"adminApiKey": []}], "requestBody": { "required": true, @@ -5029,15 +4381,15 @@ } } }, - "/admin/messages/browse": { - "post": { - "operationId": "browse_channel_messages", - "summary": "Browse channel messages", + "/admin/messages": { + "get": { + "operationId": "search_admin_messages", + "summary": "Search messages", "tags": ["Admin"], "responses": { "200": { "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BrowseChannelResponse"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminMessageSearchResponse"}}} }, "400": { "description": "Bad Request - The request was malformed or contained invalid data", @@ -5078,246 +4430,64 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Browses messages in a channel with cursor-based pagination. Returns messages in reverse chronological order. Requires MESSAGE_LOOKUP permission.", + "description": "Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message carrying that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BrowseChannelRequest"}}} - } - } - }, - "/admin/messages/delete": { - "post": { - "operationId": "admin_delete_message", - "summary": "Delete single message", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteMessageResponse"}}} + "parameters": [ + { + "name": "channel_id", + "in": "query", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + { + "name": "q", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Free-text query matched against message content"} }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + { + "name": "message_id", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + { + "name": "attachment_id", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } + { + "name": "filename", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "The filename of the attachment named by attachment_id"} + }, + { + "name": "context_limit", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "How many messages surrounding a message resolved by message_id or attachment_id to return as context (1-100, default 50)" } }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Deletes a single message permanently. Used for removing inappropriate or harmful content. Logged to audit log. Requires MESSAGE_DELETE permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteMessageRequest"}}} - } - } - }, - "/admin/messages/delete-all": { - "post": { - "operationId": "delete_all_user_messages", - "summary": "Delete all user messages", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteAllUserMessagesResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Maximum number of messages to return when searching (1-100, default 25)" } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } - }, - "description": "Deletes all messages from a specific user across all channels. Permanent operation used for account suspension or policy violation. Requires MESSAGE_DELETE_ALL permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteAllUserMessagesRequest"}}} - } + ] } }, - "/admin/messages/lookup": { + "/admin/messages/ncmec-reports": { "post": { - "operationId": "lookup_message", - "summary": "Look up message details", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LookupMessageResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Retrieves complete message details including content, attachments, edits, and metadata. Look up by message ID and channel. Requires MESSAGE_LOOKUP permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LookupMessageRequest"}}} - } - } - }, - "/admin/messages/lookup-by-attachment": { - "post": { - "operationId": "lookup_message_by_attachment", - "summary": "Look up message by attachment", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LookupMessageResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Finds and retrieves message containing a specific attachment by ID. Used to locate messages with sensitive or illegal content. Requires MESSAGE_LOOKUP permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LookupMessageByAttachmentRequest"}}} - } - } - }, - "/admin/messages/report-to-ncmec": { - "post": { - "operationId": "report_message_attachment_to_ncmec", - "summary": "Report an image attachment to NCMEC", + "operationId": "create_admin_ncmec_report", + "summary": "Report an attachment to NCMEC", "tags": ["Admin"], "responses": { "200": { @@ -5373,124 +4543,10 @@ } } }, - "/admin/messages/search": { - "post": { - "operationId": "search_channel_messages", - "summary": "Search channel messages", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SearchChannelMessagesResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Searches messages within a channel by content. Requires MESSAGE_LOOKUP permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SearchChannelMessagesRequest"}}} - } - } - }, - "/admin/messages/shred": { - "post": { - "operationId": "queue_message_shred", - "summary": "Queue message shred operation", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MessageShredResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Queues bulk message shredding with attachment deletion. Returns job ID to track progress asynchronously. Used for large-scale content removal. Requires MESSAGE_SHRED permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MessageShredRequest"}}} - } - } - }, - "/admin/messages/shred-status": { - "post": { - "operationId": "get_message_shred_status", - "summary": "Get message shred status", + "/admin/messages/shreds/{job_id}": { + "get": { + "operationId": "get_admin_message_shred", + "summary": "Get message shred job", "tags": ["Admin"], "responses": { "200": { @@ -5536,23 +4592,22 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Polls status of a queued message shred operation. Returns progress percentage and whether the job is complete. Requires MESSAGE_SHRED permission.", + "description": "Returns the progress of a queued message shred job, including whether it is complete. Requires MESSAGE_SHRED permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MessageShredStatusRequest"}}} - } + "parameters": [ + {"name": "job_id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The job id"} + ] } }, - "/admin/reports/list": { - "post": { - "operationId": "list_reports", + "/admin/reports": { + "get": { + "operationId": "list_admin_reports", "summary": "List reports", "tags": ["Admin"], "responses": { "200": { "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListReportsResponse"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminReportListResponse"}}} }, "400": { "description": "Bad Request - The request was malformed or contained invalid data", @@ -5593,132 +4648,112 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Lists user and content reports with optional status filtering and pagination. Requires REPORT_VIEW permission.", + "description": "Lists user and content reports with pagination. Filtering by status alone reads them straight from the database; supplying a free-text query or any of the entity, category and resolver filters searches the report index instead and adds the total, offset and limit of the page to the response. Reporter contact details are redacted unless the caller also holds REPORT_VIEW_REPORTER_PII. Requires REPORT_VIEW permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListReportsRequest"}}} - } - } - }, - "/admin/reports/resolve": { - "post": { - "operationId": "resolve_report", - "summary": "Resolve report", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ResolveReportResponse"}}} + "parameters": [ + { + "name": "q", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Free-text query matched against the searchable report fields"} }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["pending", "resolved"], + "description": "Only return reports with this status" } }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Closes and resolves a report with optional public comment. Marks report as handled and creates audit log entry. Requires REPORT_RESOLVE permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ResolveReportRequest"}}} - } - } - }, - "/admin/reports/search": { - "post": { - "operationId": "search_reports", - "summary": "Search reports", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SearchReportsResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } + { + "name": "report_type", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["message", "user", "guild"], + "description": "Only return reports about this kind of entity" } }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + { + "name": "category", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Only return reports filed under this category"} + }, + { + "name": "reporter_id", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} + }, + { + "name": "reported_user_id", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} + }, + { + "name": "reported_guild_id", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} + }, + { + "name": "reported_channel_id", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} + }, + { + "name": "guild_context_id", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} + }, + { + "name": "resolved_by_admin_id", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} + }, + { + "name": "sort_by", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["created_at", "reported_at", "resolved_at"], + "description": "The field to sort the reports by" + } + }, + { + "name": "sort_order", + "in": "query", + "required": false, + "schema": {"type": "string", "enum": ["asc", "desc"], "description": "The direction to sort the reports in"} + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Maximum number of reports to return (1-200, default 50)"} + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Number of reports to skip"} } - }, - "description": "Searches and filters reports by user, content, reason, and status criteria. Supports full-text search and advanced filtering. Requires REPORT_VIEW permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SearchReportsRequest"}}} - } + ] } }, "/admin/reports/{report_id}": { "get": { - "operationId": "get_report", - "summary": "Get report details", + "operationId": "get_admin_report", + "summary": "Get report", "tags": ["Admin"], "responses": { "200": { @@ -5764,7 +4799,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Retrieves detailed information about a specific report including content, reporter, and reason. Requires REPORT_VIEW permission.", + "description": "Retrieves detailed information about a specific report including content, reporter, reason, and the message context captured when it was filed. Requires REPORT_VIEW permission.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -5775,17 +4810,15 @@ "description": "The report id" } ] - } - }, - "/admin/search/refresh-index": { - "post": { - "operationId": "refresh_search_index", - "summary": "Refresh search index", + }, + "patch": { + "operationId": "update_admin_report", + "summary": "Update report", "tags": ["Admin"], "responses": { "200": { "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RefreshSearchIndexResponse"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ResolveReportResponse"}}} }, "400": { "description": "Bad Request - The request was malformed or contained invalid data", @@ -5826,18 +4859,27 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Trigger full or partial search index rebuild. Creates background job to reindex guilds and users. Returns job ID for status tracking. Requires GUILD_LOOKUP permission.", + "description": "Moves a report to the resolved status with an optional public comment shown to the reporter. Marks the report as handled, notifies the reporter, and creates an audit log entry. Requires REPORT_RESOLVE permission.", "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "report_id", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The report id" + } + ], "requestBody": { "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RefreshSearchIndexRequest"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateReportRequest"}}} } } }, - "/admin/search/refresh-status": { - "post": { - "operationId": "get_search_index_refresh_status", - "summary": "Get search index refresh status", + "/admin/search/index-refreshes/{job_id}": { + "get": { + "operationId": "get_admin_search_index_refresh", + "summary": "Get search index refresh", "tags": ["Admin"], "responses": { "200": { @@ -5883,77 +4925,22 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Polls status of a search index refresh job. Returns completion percentage and current phase. Requires GUILD_LOOKUP permission.", + "description": "Reads the progress of a queued search index refresh. Returns the completion counts and current phase, or a not_found status once the record has expired. Requires GUILD_LOOKUP permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetIndexRefreshStatusRequest"}}} - } + "parameters": [ + {"name": "job_id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The job id"} + ] } }, - "/admin/suspicious-email-domains/add": { + "/admin/search/indexes/{index_name}/refreshes": { "post": { - "operationId": "add_suspicious_email_domain", - "summary": "Add suspicious email domain", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Flag an email domain as suspicious. Registration is not blocked, but new accounts using this domain are required to verify a phone number before they can act on the platform. The list itself is not exposed to users — they only see the verified-phone gate.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SuspiciousEmailDomainRequest"}}} - } - } - }, - "/admin/suspicious-email-domains/check": { - "post": { - "operationId": "check_suspicious_email_domain", - "summary": "Check suspicious email domain status", + "operationId": "create_admin_search_index_refresh", + "summary": "Refresh a search index", "tags": ["Admin"], "responses": { "200": { "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanCheckResponseSchema"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RefreshSearchIndexResponse"}}} }, "400": { "description": "Bad Request - The request was malformed or contained invalid data", @@ -5994,72 +4981,27 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Query whether an email domain is currently flagged as suspicious.", + "description": "Trigger a full or partial rebuild of the named search index. Creates a background job and returns its refresh ID for status tracking. The channel_messages and guild_members indexes are rebuilt one guild at a time and require guild_id, and favorite_memes requires user_id. Requires GUILD_LOOKUP permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SuspiciousEmailDomainRequest"}}} - } - } - }, - "/admin/suspicious-email-domains/remove": { - "post": { - "operationId": "remove_suspicious_email_domain", - "summary": "Remove suspicious email domain flag", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + "parameters": [ + { + "name": "index_name", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The index name" } - }, - "description": "Remove a domain from the suspicious list. New registrations from this domain will no longer be auto-required to verify a phone number on signup.", - "security": [{"adminApiKey": []}], + ], "requestBody": { "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SuspiciousEmailDomainRequest"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RefreshSearchIndexRequest"}}} } } }, - "/admin/system-dm/send": { + "/admin/system-dms": { "post": { - "operationId": "send_system_dm", - "summary": "Send system DM", + "operationId": "create_admin_system_dm", + "summary": "Send a system direct message", "tags": ["Admin"], "responses": { "200": { @@ -6105,7 +5047,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Queue a worker job that sends the same system DM content to each provided user ID. Progress is observable via the Jobs admin page (task_type=sendSystemDm). Requires SYSTEM_DM_SEND permission.", + "description": "Queue a worker job that delivers the same content to every listed user as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.", "security": [{"adminApiKey": []}], "requestBody": { "required": true, @@ -6113,10 +5055,10 @@ } } }, - "/admin/system/heap-snapshot": { + "/admin/system/heap-snapshots": { "post": { - "operationId": "take_heap_snapshot", - "summary": "Take a V8 heap snapshot", + "operationId": "create_admin_system_heap_snapshot", + "summary": "Create a V8 heap snapshot", "tags": ["Admin"], "responses": { "200": { @@ -6162,14 +5104,244 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Triggers a V8 heap snapshot of the current process and returns the snapshot file. Used for diagnosing memory leaks. Requires SYSTEM_HEAP_SNAPSHOT permission.", + "description": "Writes a V8 heap snapshot of the current process and returns the snapshot file. Used for diagnosing memory leaks. Requires SYSTEM_HEAP_SNAPSHOT permission.", "security": [{"adminApiKey": []}] } }, - "/admin/users/cancel-bulk-message-deletion": { - "post": { - "operationId": "admin_cancel_bulk_message_deletion", - "summary": "Cancel bulk message deletion", + "/admin/users": { + "get": { + "operationId": "list_admin_users", + "summary": "List users", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SearchUsersResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Lists and searches users. Exactly one selector is honoured, in this precedence order: user_id, resolve, email, last_active_ip, then the indexed q search. The resolve selector takes one exact identifier, which may be a username#discriminator tag, a user ID, an email address, or a Stripe subscription ID. The email and user_id selectors ignore limit and offset. Requires USER_LOOKUP permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Restrict the results to the users matching this free-text query" + } + }, + { + "name": "user_id", + "in": "query", + "required": false, + "schema": { + "oneOf": [ + {"$ref": "#/components/schemas/SnowflakeType"}, + {"type": "array", "items": {"$ref": "#/components/schemas/SnowflakeType"}, "maxItems": 100} + ], + "description": "Restrict the results to these users. Repeat the parameter to pass more than one." + } + }, + { + "name": "resolve", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Resolve one exact identifier: a username#discriminator tag, a user ID, an email address, or a Stripe subscription ID" + } + }, + { + "name": "email", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Restrict the results to the user with this exact email address" + } + }, + { + "name": "last_active_ip", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Restrict the results to the users whose last active IP address matches this one exactly" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Maximum number of users to return"} + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Number of users to skip before returning results"} + } + ] + } + }, + "/admin/users/@me": { + "get": { + "operationId": "get_current_admin_user", + "summary": "Get current admin", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUsersMeResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Return the admin the request was authenticated as, with the admin permissions, roles, and metadata of the account. Requires AUTHENTICATE permission.", + "security": [{"adminApiKey": []}] + } + }, + "/admin/users/{user_id}": { + "get": { + "operationId": "get_admin_user", + "summary": "Get user", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LookupUserResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Look up one detailed user profile by ID. Returns account status, permissions, and metadata. The email address, date of birth, and IP address are redacted without the matching view permissions. Requires USER_LOOKUP permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ] + } + }, + "/admin/users/{user_id}/acls": { + "put": { + "operationId": "set_admin_user_acls", + "summary": "Set user admin permissions", "tags": ["Admin"], "responses": { "200": { @@ -6215,18 +5387,221 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Cancel a scheduled bulk message deletion job for a user. Prevents deletion of user messages across guilds. Creates audit log entry. Requires USER_CANCEL_BULK_MESSAGE_DELETION permission.", + "description": "Replace the admin ACL permissions granted to a user. Controls admin capabilities and panel access. The permissions accepted are the ones listed by GET /admin/acls. Creates audit log entry. Requires ACL_SET_USER permission.", "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ], "requestBody": { "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CancelBulkMessageDeletionRequest"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserAclsRequest"}}} } } }, - "/admin/users/cancel-deletion": { + "/admin/users/{user_id}/applications": { + "get": { + "operationId": "list_admin_user_applications", + "summary": "List user applications", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListApplicationsResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Lists the OAuth2 applications and bots a user owns. Requires APPLICATION_LIST_BY_OWNER permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ] + } + }, + "/admin/users/{user_id}/archives": { "post": { - "operationId": "cancel_account_deletion", - "summary": "Cancel account deletion", + "operationId": "create_admin_user_archive", + "summary": "Create user archive", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminArchiveResponseSchema"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Initiates a data export for a user. Creates an archive containing all the user's data (messages, server memberships, preferences, etc.) for export or compliance purposes.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminArchiveCreateRequest"}}} + } + } + }, + "/admin/users/{user_id}/avatar-block": { + "post": { + "operationId": "ban_admin_user_avatar", + "summary": "Ban this user's current avatar", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanUserAvatarResponseSchema"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Reads the user's current avatar_hash, strips any animation prefix, and adds the 8-char hash to the avatar-hash blocklist. Returns the banned hash.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanUserAvatarRequest"}}} + } + } + }, + "/admin/users/{user_id}/ban": { + "put": { + "operationId": "ban_admin_user", + "summary": "Ban user", "tags": ["Admin"], "responses": { "200": { @@ -6272,18 +5647,229 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Cancel a scheduled account deletion. User account restoration prevents data loss. Creates audit log entry. Requires USER_DELETE permission.", + "description": "Apply temporary ban to user account for specified duration, or permanently with a duration of zero. Prevents login and guild operations. Automatically lifts after expiry. Creates audit log entry. Requires USER_TEMP_BAN permission.", "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ], "requestBody": { "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DisableMfaRequest"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserBanRequest"}}} + } + }, + "delete": { + "operationId": "unban_admin_user", + "summary": "Unban user", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Immediately remove temporary ban from user account. User can log in and access guilds again. Creates audit log entry. Requires USER_TEMP_BAN permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ] + } + }, + "/admin/users/{user_id}/bot-status": { + "put": { + "operationId": "set_admin_user_bot_status", + "summary": "Set user bot status", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Mark or unmark a user account as a bot. Controls bot badge visibility and API permissions. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserBotStatusRequest"}}} } } }, - "/admin/users/change-dob": { - "post": { - "operationId": "change_user_dob", - "summary": "Change user DOB", + "/admin/users/{user_id}/change-log": { + "get": { + "operationId": "list_admin_user_change_log", + "summary": "List user contact change log", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ListUserChangeLogResponseSchema"}} + } + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Retrieve the identity and contact change log history for a user. Shows all profile modifications, admin actions, and account changes with timestamps. Email values are redacted without USER_VIEW_EMAIL. Requires USER_VIEW_CONTACT_LOG permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Maximum number of entries to return"} + }, + { + "name": "page_token", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Pagination token for the next page of results"} + } + ] + } + }, + "/admin/users/{user_id}/date-of-birth": { + "patch": { + "operationId": "update_admin_user_date_of_birth", + "summary": "Change user date of birth", "tags": ["Admin"], "responses": { "200": { @@ -6331,15 +5917,231 @@ }, "description": "Update user date of birth. May affect age-restricted content access. Creates audit log entry. Requires USER_UPDATE_DOB permission.", "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ], "requestBody": { "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChangeDobRequest"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserDobUpdateRequest"}}} } } }, - "/admin/users/change-email": { - "post": { - "operationId": "change_user_email", + "/admin/users/{user_id}/deletion": { + "put": { + "operationId": "schedule_admin_user_deletion", + "summary": "Schedule user deletion", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Schedule user account for deletion after grace period. Account will be fully deleted with all content unless cancellation is executed. Creates audit log entry. Requires USER_DELETE permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserDeletionScheduleRequest"}}} + } + }, + "delete": { + "operationId": "cancel_admin_user_deletion", + "summary": "Cancel user deletion", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Cancel a scheduled account deletion. User account restoration prevents data loss. Creates audit log entry. Requires USER_DELETE permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ] + } + }, + "/admin/users/{user_id}/dm-channels": { + "get": { + "operationId": "list_admin_user_dm_channels", + "summary": "List user direct message channels", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserDmChannelListResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "List the historical one-to-one direct message channels of a user with cursor pagination, or the group direct message channels they are a recipient of when type is group_dm. Requires USER_LIST_DM_CHANNELS permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/AdminUserDmChannelType"} + }, + { + "name": "before", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} + }, + {"name": "after", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/SnowflakeType"}}, + { + "name": "limit", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Maximum number of DM channels to return"} + } + ] + } + }, + "/admin/users/{user_id}/email": { + "patch": { + "operationId": "update_admin_user_email", "summary": "Change user email", "tags": ["Admin"], "responses": { @@ -6388,75 +6190,25 @@ }, "description": "Change user email address. New email must be valid and unique. Marks email as verified. Creates audit log entry. Requires USER_UPDATE_EMAIL permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChangeEmailRequest"}}} - } - } - }, - "/admin/users/change-log": { - "post": { - "operationId": "get_user_change_log", - "summary": "Get user change log", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ListUserChangeLogResponseSchema"}} - } - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" } - }, - "description": "Retrieve complete change log history for a user. Shows all profile modifications, admin actions, and account changes with timestamps. Requires USER_LOOKUP permission.", - "security": [{"adminApiKey": []}], + ], "requestBody": { "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListUserChangeLogRequest"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserEmailUpdateRequest"}}} } } }, - "/admin/users/change-username": { - "post": { - "operationId": "change_user_username", - "summary": "Change user username", + "/admin/users/{user_id}/email-verification": { + "put": { + "operationId": "verify_admin_user_email", + "summary": "Verify user email", "tags": ["Admin"], "responses": { "200": { @@ -6502,18 +6254,626 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Change user username. New username must meet requirements and be unique. Creates audit log entry. Requires USER_UPDATE_USERNAME permission.", + "description": "Manually verify user email address without requiring confirmation link. Bypasses email verification requirement. Creates audit log entry. Requires USER_UPDATE_EMAIL permission.", "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ] + } + }, + "/admin/users/{user_id}/flags": { + "patch": { + "operationId": "update_admin_user_flags", + "summary": "Update user flags", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Add or remove user flags to control account features and restrictions. Flags determine verification status and special properties. Creates audit log entry. Requires USER_UPDATE_FLAGS permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ], "requestBody": { "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChangeUsernameRequest"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserFlagsUpdateRequest"}}} } } }, - "/admin/users/clear-fields": { + "/admin/users/{user_id}/guilds": { + "get": { + "operationId": "list_admin_user_guilds", + "summary": "List user communities", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListUserGuildsResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "List all guilds a user is a member of, optionally with approximate member and presence counts. Shows roles and join dates. Requires USER_LIST_GUILDS permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + }, + { + "name": "before", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/SnowflakeType"} + }, + {"name": "after", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/SnowflakeType"}}, + { + "name": "limit", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Maximum number of guilds to return"} + }, + { + "name": "with_counts", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Whether to resolve live member and presence counts from the gateway" + } + } + ] + } + }, + "/admin/users/{user_id}/message-deletion": { + "delete": { + "operationId": "cancel_admin_user_message_deletion", + "summary": "Cancel bulk message deletion", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Cancel a scheduled bulk message deletion job for a user. Prevents deletion of user messages across guilds. Creates audit log entry. Requires USER_CANCEL_BULK_MESSAGE_DELETION permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ] + } + }, + "/admin/users/{user_id}/message-shreds": { "post": { - "operationId": "clear_user_fields", - "summary": "Clear user fields", + "operationId": "shred_admin_user_messages", + "summary": "Shred user messages", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MessageShredResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Queues bulk shredding of the given messages of a user, with attachment deletion. Returns a job ID to track progress asynchronously. Used for large-scale content removal. Requires MESSAGE_SHRED permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserMessageShredRequest"}}} + } + } + }, + "/admin/users/{user_id}/messages": { + "delete": { + "operationId": "delete_admin_user_messages", + "summary": "Delete all user messages", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteAllUserMessagesResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Deletes all messages from a specific user across all channels. Permanent operation used for account suspension or policy violation. Pass dry_run=false to delete; the default counts without deleting. Requires MESSAGE_DELETE_ALL permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + }, + { + "name": "dry_run", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Count the messages that would be deleted without deleting anything (default true)" + } + } + ] + } + }, + "/admin/users/{user_id}/mfa": { + "delete": { + "operationId": "disable_admin_user_mfa", + "summary": "Disable user MFA", + "tags": ["Admin"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Disable two-factor authentication for user account. Removes all authenticators. Creates audit log entry. Requires USER_UPDATE_MFA permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ] + } + }, + "/admin/users/{user_id}/password-reset": { + "post": { + "operationId": "send_admin_user_password_reset", + "summary": "Send user password reset", + "tags": ["Admin"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Send password reset email to user with reset link. User must use link within expiry window. Creates audit log entry. Requires USER_UPDATE_EMAIL permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ] + } + }, + "/admin/users/{user_id}/phone-verification": { + "put": { + "operationId": "update_admin_user_phone_verification", + "summary": "Update user phone verification flag", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Set whether a user is treated as having completed phone verification. This is the only supported path for clearing the irreversible user-facing phone verification flag. Requires USER_UPDATE_PHONE permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/AdminUserPhoneVerificationRequest"}} + } + } + } + }, + "/admin/users/{user_id}/premium-flags": { + "patch": { + "operationId": "update_admin_user_premium_flags", + "summary": "Update user premium flags", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Add or remove premium-related flags on a user account (badge visibility, override, purchase block, etc). Creates audit log entry. Requires USER_UPDATE_FLAGS permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/AdminUserPremiumFlagsUpdateRequest"}} + } + } + } + }, + "/admin/users/{user_id}/profile-fields": { + "delete": { + "operationId": "clear_admin_user_profile_fields", + "summary": "Clear user profile fields", "tags": ["Admin"], "responses": { "200": { @@ -6561,355 +6921,24 @@ }, "description": "Clear or reset user profile fields such as bio, avatar, or status. Creates audit log entry. Requires USER_UPDATE_PROFILE permission.", "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ], "requestBody": { "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ClearUserFieldsRequest"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserClearFieldsRequest"}}} } } }, - "/admin/users/delete-webauthn-credential": { - "post": { - "operationId": "delete_user_webauthn_credential", - "summary": "Delete user WebAuthn credential", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Delete a specific WebAuthn credential (passkey/security key) from a user account. Creates audit log entry. Requires USER_UPDATE_MFA permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteWebAuthnCredentialRequest"}}} - } - } - }, - "/admin/users/disable-mfa": { - "post": { - "operationId": "disable_user_mfa", - "summary": "Disable user MFA", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Disable two-factor authentication for user account. Removes all authenticators. Creates audit log entry. Requires USER_UPDATE_MFA permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DisableMfaRequest"}}} - } - } - }, - "/admin/users/disable-suspicious": { - "post": { - "operationId": "disable_user_suspicious", - "summary": "Disable user for suspicious activity", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Disable user account due to suspicious activity or abuse. Account is locked pending review. User cannot access services. Creates audit log entry. Requires USER_DISABLE_SUSPICIOUS permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/DisableForSuspiciousActivityRequest"}} - } - } - } - }, - "/admin/users/list-dm-channels": { - "post": { - "operationId": "list_user_dm_channels", - "summary": "List user DM channels", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListUserDmChannelsResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "List historical one-to-one DM channels for a user with cursor pagination. Requires USER_LIST_DM_CHANNELS permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListUserDmChannelsRequest"}}} - } - } - }, - "/admin/users/list-group-dm-channels": { - "post": { - "operationId": "list_user_group_dm_channels", - "summary": "List user group DM channels", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ListUserGroupDmChannelsResponse"}} - } - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "List group DM channels for a user. Requires USER_LIST_DM_CHANNELS permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListUserGroupDmChannelsRequest"}}} - } - } - }, - "/admin/users/list-guilds": { - "post": { - "operationId": "list_user_guilds", - "summary": "List user guilds", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListUserGuildsResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "List all guilds a user is a member of. Shows roles and join dates. Requires USER_LIST_GUILDS permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListUserGuildsRequest"}}} - } - } - }, - "/admin/users/list-relationships": { - "post": { - "operationId": "admin_list_user_relationships", + "/admin/users/{user_id}/relationships": { + "get": { + "operationId": "list_admin_user_relationships", "summary": "List user relationships", "tags": ["Admin"], "responses": { @@ -6958,294 +6987,19 @@ }, "description": "List a user's friends, incoming and outgoing friend requests, and blocked users. Requires USER_LIST_RELATIONSHIPS permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListUserRelationshipsRequest"}}} - } - } - }, - "/admin/users/list-sessions": { - "post": { - "operationId": "list_user_sessions", - "summary": "List user sessions", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListUserSessionsResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" } - }, - "description": "List all active user sessions across devices. Shows device info, IP, last activity, and creation time. Requires USER_LIST_SESSIONS permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListUserSessionsRequest"}}} - } - } - }, - "/admin/users/list-webauthn-credentials": { - "post": { - "operationId": "list_user_webauthn_credentials", - "summary": "List user WebAuthn credentials", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WebAuthnCredentialListResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "List all WebAuthn credentials (passkeys/security keys) registered for a user. Returns credential names, creation dates, and last usage. Creates audit log entry. Requires USER_UPDATE_MFA permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListWebAuthnCredentialsRequest"}}} - } - } - }, - "/admin/users/lookup": { - "post": { - "operationId": "lookup_user", - "summary": "Lookup user", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LookupUserResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Look up detailed user profile by ID, username, email, or phone. Returns account status, permissions, and metadata. Requires USER_LOOKUP permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LookupUserRequest"}}} - } - } - }, - "/admin/users/me": { - "get": { - "operationId": "get_authenticated_admin_user", - "summary": "Get authenticated admin user", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUsersMeResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Get profile of currently authenticated admin user. Returns admin permissions, roles, and metadata. Requires AUTHENTICATE permission.", - "security": [{"adminApiKey": []}] - } - }, - "/admin/users/remove-relationship": { - "post": { - "operationId": "remove_user_relationship", - "summary": "Remove user relationship", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Remove a single relationship row for a user. For friend and outgoing_request, the mirror entry on the other user is also removed. Dispatches RELATIONSHIP_REMOVE gateway events. Requires USER_REMOVE_RELATIONSHIP permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RemoveUserRelationshipRequest"}}} - } - } - }, - "/admin/users/remove-relationships-by-category": { - "post": { - "operationId": "remove_user_relationships_by_category", - "summary": "Remove all of a user's relationships in a category", + ] + }, + "delete": { + "operationId": "clear_admin_user_relationships", + "summary": "Clear user relationships", "tags": ["Admin"], "responses": { "200": { @@ -7295,18 +7049,31 @@ }, "description": "Bulk-remove every relationship of the chosen category (friend, incoming_request, outgoing_request, blocked) for a user. Mirror entries on the other party are removed for friend, incoming_request, and outgoing_request. Requires USER_REMOVE_RELATIONSHIP permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/RemoveUserRelationshipsByCategoryRequest"}} + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + }, + { + "name": "category", + "in": "query", + "required": true, + "schema": { + "enum": ["friend", "incoming_request", "outgoing_request", "blocked"], + "type": "string", + "description": "Category of relationships the operation applies to" + } } - } + ] } }, - "/admin/users/resend-verification-email": { - "post": { - "operationId": "admin_resend_verification_email", - "summary": "Resend verification email", + "/admin/users/{user_id}/relationships/{target_user_id}": { + "delete": { + "operationId": "remove_admin_user_relationship", + "summary": "Remove user relationship", "tags": ["Admin"], "responses": { "204": {"description": "No Content"}, @@ -7349,23 +7116,45 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Resend the account verification email for a user. Creates audit log entry and honours email verification resend limits. Requires USER_UPDATE_EMAIL permission.", + "description": "Remove a single relationship row for a user. For friend and outgoing_request, the mirror entry on the other user is also removed. Dispatches RELATIONSHIP_REMOVE gateway events. Requires USER_REMOVE_RELATIONSHIP permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ResendVerificationEmailRequest"}}} - } + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + }, + { + "name": "target_user_id", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The target user id" + }, + { + "name": "category", + "in": "query", + "required": true, + "schema": { + "enum": ["friend", "incoming_request", "outgoing_request", "blocked"], + "type": "string", + "description": "Category of relationships the operation applies to" + } + } + ] } }, - "/admin/users/schedule-deletion": { - "post": { - "operationId": "schedule_account_deletion", - "summary": "Schedule account deletion", + "/admin/users/{user_id}/sessions": { + "get": { + "operationId": "list_admin_user_sessions", + "summary": "List user sessions", "tags": ["Admin"], "responses": { "200": { "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListUserSessionsResponse"}}} }, "400": { "description": "Bad Request - The request was malformed or contained invalid data", @@ -7406,413 +7195,20 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Schedule user account for deletion after grace period. Account will be fully deleted with all content unless cancellation is executed. Creates audit log entry. Requires USER_DELETE permission.", + "description": "List all active user sessions across devices. Shows device info, IP, last activity, and creation time. Requires USER_LIST_SESSIONS permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScheduleAccountDeletionRequest"}}} - } - } - }, - "/admin/users/search": { - "post": { - "operationId": "search_users", - "summary": "Search users", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SearchUsersResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" } - }, - "description": "Searches users by username, email, ID, last active IP, and other criteria. Supports full-text search and filtering by account status. Requires USER_LOOKUP permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SearchUsersRequest"}}} - } - } - }, - "/admin/users/send-password-reset": { - "post": { - "operationId": "send_password_reset", - "summary": "Send password reset", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Send password reset email to user with reset link. User must use link within expiry window. Creates audit log entry. Requires USER_UPDATE_EMAIL permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SendPasswordResetRequest"}}} - } - } - }, - "/admin/users/set-acls": { - "post": { - "operationId": "set_user_acls", - "summary": "Set user ACLs", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Grant or revoke admin ACL permissions to user. Controls admin capabilities and panel access. Creates audit log entry. Requires ACL_SET_USER permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SetUserAclsRequest"}}} - } - } - }, - "/admin/users/set-bot-status": { - "post": { - "operationId": "set_user_bot_status", - "summary": "Set user bot status", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Mark or unmark a user account as a bot. Controls bot badge visibility and API permissions. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SetUserBotStatusRequest"}}} - } - } - }, - "/admin/users/set-system-status": { - "post": { - "operationId": "set_user_system_status", - "summary": "Set user system status", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Mark or unmark a user as a system account. System accounts have special permissions for automated operations. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SetUserSystemStatusRequest"}}} - } - } - }, - "/admin/users/set-traits": { - "post": { - "operationId": "set_user_traits", - "summary": "Set user traits", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Set or update user trait attributes and profile metadata. Traits customize user display and features. Creates audit log entry. Requires USER_UPDATE_TRAITS permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SetUserTraitsRequest"}}} - } - } - }, - "/admin/users/temp-ban": { - "post": { - "operationId": "temp_ban_user", - "summary": "Temp ban user", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Apply temporary ban to user account for specified duration. Prevents login and guild operations. Automatically lifts after expiry. Creates audit log entry. Requires USER_TEMP_BAN permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TempBanUserRequest"}}} - } - } - }, - "/admin/users/terminate-sessions": { - "post": { - "operationId": "terminate_user_sessions", + ] + }, + "delete": { + "operationId": "terminate_admin_user_sessions", "summary": "Terminate user sessions", "tags": ["Admin"], "responses": { @@ -7861,16 +7257,21 @@ }, "description": "Terminate all active user sessions across devices. Forces user to re-authenticate on next connection. Creates audit log entry. Requires USER_UPDATE_FLAGS permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TerminateSessionsRequest"}}} - } + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ] } }, - "/admin/users/unban": { - "post": { - "operationId": "unban_user", - "summary": "Unban user", + "/admin/users/{user_id}/suspicious-activity-disablement": { + "put": { + "operationId": "disable_admin_user_suspicious", + "summary": "Disable user for suspicious activity", "tags": ["Admin"], "responses": { "200": { @@ -7916,188 +7317,28 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Immediately remove temporary ban from user account. User can log in and access guilds again. Creates audit log entry. Requires USER_TEMP_BAN permission.", + "description": "Disable user account due to suspicious activity or abuse. Account is locked pending review. User cannot access services. Creates audit log entry. Requires USER_DISABLE_SUSPICIOUS permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DisableMfaRequest"}}} - } - } - }, - "/admin/users/update-flags": { - "post": { - "operationId": "update_user_flags", - "summary": "Update user flags", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" } - }, - "description": "Add or remove user flags to control account features and restrictions. Flags determine verification status and special properties. Creates audit log entry. Requires USER_UPDATE_FLAGS permission.", - "security": [{"adminApiKey": []}], + ], "requestBody": { "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateUserFlagsRequest"}}} - } - } - }, - "/admin/users/update-has-verified-phone": { - "post": { - "operationId": "update_user_has_verified_phone", - "summary": "Update user phone verification flag", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/AdminUserSuspiciousDisableRequest"}} } - }, - "description": "Set whether a user is treated as having completed phone verification. This is the only supported path for clearing the irreversible user-facing phone verification flag.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateHasVerifiedPhoneRequest"}}} } } }, - "/admin/users/update-premium-flags": { - "post": { - "operationId": "update_user_premium_flags", - "summary": "Update user premium flags", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Add or remove premium-related flags on a user account (badge visibility, override, purchase block, etc). Creates audit log entry. Requires USER_UPDATE_FLAGS permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdatePremiumFlagsRequest"}}} - } - } - }, - "/admin/users/update-suspicious-activity-flags": { - "post": { - "operationId": "update_suspicious_activity_flags", + "/admin/users/{user_id}/suspicious-activity-flags": { + "put": { + "operationId": "update_admin_user_suspicious_activity_flags", "summary": "Update suspicious activity flags", "tags": ["Admin"], "responses": { @@ -8146,18 +7387,27 @@ }, "description": "Flag user as suspicious for account abuse, fraud, or policy violations. Enables enforcement actions and rate limiting. Creates audit log entry. Requires USER_UPDATE_SUSPICIOUS_ACTIVITY permission.", "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ], "requestBody": { "required": true, "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/UpdateSuspiciousActivityFlagsRequest"}} + "application/json": {"schema": {"$ref": "#/components/schemas/AdminUserSuspiciousActivityFlagsRequest"}} } } } }, - "/admin/users/verify-email": { - "post": { - "operationId": "verify_user_email", - "summary": "Verify user email", + "/admin/users/{user_id}/system-status": { + "put": { + "operationId": "set_admin_user_system_status", + "summary": "Set user system status", "tags": ["Admin"], "responses": { "200": { @@ -8203,23 +7453,32 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Manually verify user email address without requiring confirmation link. Bypasses email verification requirement. Creates audit log entry. Requires USER_UPDATE_EMAIL permission.", + "description": "Mark or unmark a user as a system account. System accounts have special permissions for automated operations. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.", "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ], "requestBody": { "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VerifyUserEmailRequest"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserSystemStatusRequest"}}} } } }, - "/admin/users/{user_id}/ban-avatar": { - "post": { - "operationId": "ban_user_avatar", - "summary": "Ban this user's current avatar", + "/admin/users/{user_id}/traits": { + "put": { + "operationId": "set_admin_user_traits", + "summary": "Set user traits", "tags": ["Admin"], "responses": { "200": { "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanUserAvatarResponseSchema"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} }, "400": { "description": "Bad Request - The request was malformed or contained invalid data", @@ -8260,7 +7519,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Reads the user's current avatar_hash, strips any animation prefix, and adds the 8-char hash to the avatar blocklist. Returns the banned hash.", + "description": "Set or update user trait attributes and profile metadata. Traits customize user display and features. Creates audit log entry. Requires USER_UPDATE_TRAITS permission.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -8273,13 +7532,325 @@ ], "requestBody": { "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanUserAvatarRequest"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserTraitsRequest"}}} } } }, - "/admin/voice/regions/create": { + "/admin/users/{user_id}/username": { + "patch": { + "operationId": "update_admin_user_username", + "summary": "Change user username", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Change user username. New username must meet requirements and be unique. Creates audit log entry. Requires USER_UPDATE_USERNAME permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserUsernameUpdateRequest"}}} + } + } + }, + "/admin/users/{user_id}/verification-email": { "post": { - "operationId": "create_voice_region", + "operationId": "resend_admin_user_verification_email", + "summary": "Resend user verification email", + "tags": ["Admin"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Resend the account verification email for a user. Creates audit log entry and honours email verification resend limits. Requires USER_UPDATE_EMAIL permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ] + } + }, + "/admin/users/{user_id}/webauthn-credentials": { + "get": { + "operationId": "list_admin_user_webauthn_credentials", + "summary": "List user WebAuthn credentials", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WebAuthnCredentialListResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "List all WebAuthn credentials (passkeys/security keys) registered for a user. Returns credential names, creation dates, and last usage. Creates audit log entry. Requires USER_UPDATE_MFA permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + } + ] + } + }, + "/admin/users/{user_id}/webauthn-credentials/{credential_id}": { + "delete": { + "operationId": "delete_admin_user_webauthn_credential", + "summary": "Delete user WebAuthn credential", + "tags": ["Admin"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Delete a specific WebAuthn credential (passkey/security key) from a user account. Creates audit log entry. Requires USER_UPDATE_MFA permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + }, + { + "name": "credential_id", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The credential id" + } + ] + } + }, + "/admin/voice/regions": { + "get": { + "operationId": "list_admin_voice_regions", + "summary": "List voice regions", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListVoiceRegionsResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Lists all configured voice server regions with status and server count. Shows region names, latency info, and availability. Requires VOICE_REGION_LIST permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "include_servers", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Whether to include voice servers in the response"} + } + ] + }, + "post": { + "operationId": "create_admin_voice_region", "summary": "Create voice region", "tags": ["Admin"], "responses": { @@ -8334,66 +7905,9 @@ } } }, - "/admin/voice/regions/delete": { - "post": { - "operationId": "delete_voice_region", - "summary": "Delete voice region", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteVoiceResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Deletes a voice region. Removes region from routing and reassigns active connections. Creates audit log entry. Requires VOICE_REGION_DELETE permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteVoiceRegionRequest"}}} - } - } - }, - "/admin/voice/regions/get": { - "post": { - "operationId": "get_voice_region", + "/admin/voice/regions/{region_id}": { + "get": { + "operationId": "get_admin_voice_region", "summary": "Get voice region", "tags": ["Admin"], "responses": { @@ -8442,72 +7956,24 @@ }, "description": "Gets detailed information about a voice region including assigned servers, capacity, and server details. Requires VOICE_REGION_LIST permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetVoiceRegionRequest"}}} - } - } - }, - "/admin/voice/regions/list": { - "post": { - "operationId": "list_voice_regions", - "summary": "List voice regions", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListVoiceRegionsResponse"}}} + "parameters": [ + { + "name": "region_id", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The region id" }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + { + "name": "include_servers", + "in": "query", + "required": false, + "schema": {"type": "string", "description": "Whether to include voice servers in the response"} } - }, - "description": "Lists all configured voice server regions with status and server count. Shows region names, latency info, and availability. Requires VOICE_REGION_LIST permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListVoiceRegionsRequest"}}} - } - } - }, - "/admin/voice/regions/update": { - "post": { - "operationId": "update_voice_region", + ] + }, + "patch": { + "operationId": "update_admin_voice_region", "summary": "Update voice region", "tags": ["Admin"], "responses": { @@ -8556,15 +8022,144 @@ }, "description": "Updates voice region settings such as latency thresholds or priority. Changes affect voice routing for new sessions. Creates audit log entry. Requires VOICE_REGION_UPDATE permission.", "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "region_id", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The region id" + } + ], "requestBody": { "required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateVoiceRegionRequest"}}} } + }, + "delete": { + "operationId": "delete_admin_voice_region", + "summary": "Delete voice region", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteVoiceResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Deletes a voice region. Removes region from routing and reassigns active connections. Creates audit log entry. Requires VOICE_REGION_DELETE permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "region_id", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The region id" + } + ] } }, - "/admin/voice/servers/create": { + "/admin/voice/regions/{region_id}/servers": { + "get": { + "operationId": "list_admin_voice_servers", + "summary": "List voice servers", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListVoiceServersResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Lists all voice servers in a region with connection counts and capacity. Shows server status, region assignment, and load information. Requires VOICE_SERVER_LIST permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "region_id", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The region id" + } + ] + }, "post": { - "operationId": "create_voice_server", + "operationId": "create_admin_voice_server", "summary": "Create voice server", "tags": ["Admin"], "responses": { @@ -8613,72 +8208,24 @@ }, "description": "Creates and provisions a new voice server instance in a region. Configures capacity, codecs, and encryption. Creates audit log entry. Requires VOICE_SERVER_CREATE permission.", "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "region_id", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The region id" + } + ], "requestBody": { "required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateVoiceServerRequest"}}} } } }, - "/admin/voice/servers/delete": { - "post": { - "operationId": "delete_voice_server", - "summary": "Delete voice server", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteVoiceResponse"}}} - }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Decommissions and removes a voice server instance. Disconnects active sessions and migrates to other servers. Creates audit log entry. Requires VOICE_SERVER_DELETE permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteVoiceServerRequest"}}} - } - } - }, - "/admin/voice/servers/get": { - "post": { - "operationId": "get_voice_server", + "/admin/voice/regions/{region_id}/servers/{server_id}": { + "get": { + "operationId": "get_admin_voice_server", "summary": "Get voice server", "tags": ["Admin"], "responses": { @@ -8727,72 +8274,25 @@ }, "description": "Gets detailed voice server information including active connections and configuration. Requires VOICE_SERVER_LIST permission.", "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetVoiceServerRequest"}}} - } - } - }, - "/admin/voice/servers/list": { - "post": { - "operationId": "list_voice_servers", - "summary": "List voice servers", - "tags": ["Admin"], - "responses": { - "200": { - "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListVoiceServersResponse"}}} + "parameters": [ + { + "name": "region_id", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The region id" }, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + { + "name": "server_id", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The server id" } - }, - "description": "Lists all voice servers with connection counts and capacity. Shows server status, region assignment, and load information. Supports filtering and pagination. Requires VOICE_SERVER_LIST permission.", - "security": [{"adminApiKey": []}], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListVoiceServersRequest"}}} - } - } - }, - "/admin/voice/servers/update": { - "post": { - "operationId": "update_voice_server", + ] + }, + "patch": { + "operationId": "update_admin_voice_server", "summary": "Update voice server", "tags": ["Admin"], "responses": { @@ -8841,35 +8341,109 @@ }, "description": "Updates voice server configuration including capacity, region assignment, and quality settings. Changes apply to new connections. Creates audit log entry. Requires VOICE_SERVER_UPDATE permission.", "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "region_id", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The region id" + }, + { + "name": "server_id", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The server id" + } + ], "requestBody": { "required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateVoiceServerRequest"}}} } + }, + "delete": { + "operationId": "delete_admin_voice_server", + "summary": "Delete voice server", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteVoiceResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Decommissions and removes a voice server instance. Disconnects active sessions and migrates to other servers. Creates audit log entry. Requires VOICE_SERVER_DELETE permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "region_id", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The region id" + }, + { + "name": "server_id", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "The server id" + } + ] } } }, "components": { "schemas": { - "CreateAdminApiKeyResponse": { + "AdminAclListResponse": { "type": "object", "properties": { - "key_id": {"type": "string", "description": "Unique identifier for the API key"}, - "key": {"type": "string", "description": "The generated API key secret (only shown once)"}, - "name": {"type": "string", "description": "Display name for the API key"}, - "created_at": {"type": "string", "description": "ISO 8601 timestamp when the key was created"}, - "expires_at": { - "description": "ISO 8601 timestamp when the key expires, or null if no expiration", - "nullable": true, - "type": "string" - }, "acls": { "type": "array", "items": {"type": "string"}, "maxItems": 112, - "description": "List of access control permissions for the key" + "description": "Every admin access control permission the admin API recognises" } }, - "required": ["key_id", "key", "name", "created_at", "expires_at", "acls"] + "required": ["acls"] }, "Error": { "type": "object", @@ -9163,6 +8737,27 @@ }, "required": ["path", "message"] }, + "CreateAdminApiKeyResponse": { + "type": "object", + "properties": { + "key_id": {"type": "string", "description": "Unique identifier for the API key"}, + "key": {"type": "string", "description": "The generated API key secret (only shown once)"}, + "name": {"type": "string", "description": "Display name for the API key"}, + "created_at": {"type": "string", "description": "ISO 8601 timestamp when the key was created"}, + "expires_at": { + "description": "ISO 8601 timestamp when the key expires, or null if no expiration", + "nullable": true, + "type": "string" + }, + "acls": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 112, + "description": "List of access control permissions for the key" + } + }, + "required": ["key_id", "key", "name", "created_at", "expires_at", "acls"] + }, "CreateAdminApiKeyRequest": { "type": "object", "properties": { @@ -9176,13 +8771,130 @@ }, "acls": { "type": "array", - "items": {"type": "string"}, + "items": {"$ref": "#/components/schemas/AdminAclType"}, "maxItems": 112, "description": "List of access control permissions for the key" } }, "required": ["name", "acls"] }, + "AdminAclType": { + "enum": [ + "*", + "acl:set:user", + "admin_api_key:manage", + "application:lookup", + "application:list:by_owner", + "application:transfer_ownership", + "archive:trigger:guild", + "archive:trigger:user", + "archive:view_all", + "asset:purge", + "audit_log:view", + "admin:authenticate", + "jobs:view", + "jobs:cancel", + "ban:email:add", + "ban:email:check", + "ban:email:remove", + "suspicious_email_domain:add", + "suspicious_email_domain:check", + "suspicious_email_domain:remove", + "ban:phrase:add", + "ban:phrase:check", + "ban:phrase:remove", + "ban:ip:add", + "ban:ip:check", + "ban:ip:remove", + "ban:url:add", + "ban:url:check", + "ban:url:remove", + "ban:url_domain:add", + "ban:url_domain:check", + "ban:url_domain:remove", + "ban:file_sha:add", + "ban:file_sha:check", + "ban:file_sha:remove", + "ban:avatar_hash:add", + "ban:avatar_hash:check", + "ban:avatar_hash:remove", + "ban:profile_substring:add", + "ban:profile_substring:check", + "ban:profile_substring:remove", + "bulk:add:guild_members", + "bulk:delete:users", + "bulk:delete:user_messages", + "bulk:update:guild_features", + "bulk:update:suspicious_activity", + "bulk:update:user_flags", + "csam:submit_ncmec", + "discovery:remove", + "discovery:review", + "gateway:memory_stats", + "gateway:reload_all", + "gift_codes:generate", + "guild:audit_log:view", + "guild:ban_member", + "guild:delete", + "guild:force_add_member", + "guild:kick_member", + "guild:list:members", + "guild:lookup", + "guild:reload", + "guild:shutdown", + "guild:transfer_ownership", + "guild:update:features", + "guild:update:name", + "guild:update:settings", + "guild:update:vanity", + "instance:config:update", + "instance:config:view", + "instance:limit_config:update", + "instance:limit_config:view", + "message:delete_all", + "message:delete", + "message:lookup", + "message:shred", + "report:resolve", + "report:view", + "report:view:reporter_pii", + "system_dm:send", + "system:heap_snapshot", + "user:cancel:bulk_message_deletion", + "user:delete", + "user:disable:suspicious", + "user:list:dm_channels", + "user:list:guilds", + "user:list:relationships", + "user:list:sessions", + "user:lookup", + "user:remove:relationship", + "user:view:contact_log", + "user:view:dob", + "user:view:email", + "user:view:ip", + "user:temp_ban", + "user:update:bot_status", + "user:update:dob", + "user:update:email", + "user:update:flags", + "user:update:mfa", + "user:update:phone", + "user:update:profile", + "user:update:suspicious_activity", + "user:update:traits", + "user:update:username", + "voice:region:create", + "voice:region:delete", + "voice:region:list", + "voice:region:update", + "voice:server:create", + "voice:server:delete", + "voice:server:list", + "voice:server:update" + ], + "type": "string" + }, "ListAdminApiKeyResponse": { "type": "object", "properties": { @@ -9213,12 +8925,30 @@ }, "required": ["key_id", "name", "created_at", "last_used_at", "expires_at", "created_by_user_id", "acls"] }, + "SnowflakeType": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "format": "snowflake"}, + "UpdateAdminApiKeyRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "New display name for the API key" + }, + "acls": { + "type": "array", + "items": {"$ref": "#/components/schemas/AdminAclType"}, + "maxItems": 112, + "description": "Replacement list of access control permissions for the key" + } + } + }, "DeleteApiKeyResponse": { "type": "object", "properties": {"success": {"type": "boolean", "enum": [true]}}, "required": ["success"] }, - "ListGuildApplicationsResponse": { + "ListApplicationsResponse": { "type": "object", "properties": { "applications": {"type": "array", "items": {"$ref": "#/components/schemas/ApplicationAdminResponse"}} @@ -9336,24 +9066,6 @@ "version" ] }, - "SnowflakeType": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "format": "snowflake"}, - "ListGuildApplicationsRequest": { - "type": "object", - "properties": {"guild_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["guild_id"] - }, - "ListUserApplicationsResponse": { - "type": "object", - "properties": { - "applications": {"type": "array", "items": {"$ref": "#/components/schemas/ApplicationAdminResponse"}} - }, - "required": ["applications"] - }, - "ListUserApplicationsRequest": { - "type": "object", - "properties": {"user_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["user_id"] - }, "LookupApplicationResponse": { "type": "object", "properties": { @@ -9361,23 +9073,22 @@ }, "required": ["application"] }, - "LookupApplicationRequest": { - "type": "object", - "properties": {"application_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["application_id"] - }, "ApplicationUpdateResponse": { "type": "object", "properties": {"application": {"$ref": "#/components/schemas/ApplicationAdminResponse"}}, "required": ["application"] }, "TransferApplicationOwnershipRequest": { + "type": "object", + "properties": {"new_owner_id": {"$ref": "#/components/schemas/SnowflakeType"}}, + "required": ["new_owner_id"] + }, + "ListArchivesResponseSchema": { "type": "object", "properties": { - "application_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "new_owner_id": {"$ref": "#/components/schemas/SnowflakeType"} + "archives": {"type": "array", "items": {"$ref": "#/components/schemas/AdminArchiveResponseSchema"}} }, - "required": ["application_id", "new_owner_id"] + "required": ["archives"] }, "AdminArchiveResponseSchema": { "type": "object", @@ -9418,54 +9129,6 @@ "expires_at" ] }, - "TriggerGuildArchiveRequest": { - "type": "object", - "properties": { - "guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "include_attachments": { - "type": "boolean", - "description": "Whether to include attachment binaries in the archive" - } - }, - "required": ["guild_id"] - }, - "ListArchivesResponseSchema": { - "type": "object", - "properties": { - "archives": {"type": "array", "items": {"$ref": "#/components/schemas/AdminArchiveResponseSchema"}} - }, - "required": ["archives"] - }, - "ListArchivesRequest": { - "type": "object", - "properties": { - "subject_type": { - "type": "string", - "enum": ["user", "guild", "all"], - "description": "Type of archives to list" - }, - "subject_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "requested_by": {"$ref": "#/components/schemas/SnowflakeType"}, - "limit": { - "type": "number", - "minimum": 1, - "maximum": 200, - "description": "Maximum number of archives to return" - }, - "include_expired": {"type": "boolean", "description": "Whether to include expired archives"} - } - }, - "TriggerUserArchiveRequest": { - "type": "object", - "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "include_attachments": { - "type": "boolean", - "description": "Whether to include attachment binaries in the archive" - } - }, - "required": ["user_id"] - }, "GetArchiveResponseSchema": { "type": "object", "properties": { @@ -9478,295 +9141,351 @@ "properties": {"downloadUrl": {"type": "string"}, "expiresAt": {"type": "string"}}, "required": ["downloadUrl", "expiresAt"] }, - "PurgeGuildAssetsResponseSchema": { - "type": "object", - "properties": { - "processed": {"type": "array", "items": {"$ref": "#/components/schemas/PurgeGuildAssetResultSchema"}}, - "errors": {"type": "array", "items": {"$ref": "#/components/schemas/PurgeGuildAssetErrorSchema"}} - }, - "required": ["processed", "errors"] - }, - "PurgeGuildAssetResultSchema": { - "type": "object", - "properties": { - "id": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "description": "Unique identifier of the asset"}, - "asset_type": { - "type": "string", - "enum": ["emoji", "sticker", "unknown"], - "description": "Type of guild asset" - }, - "found_in_db": {"type": "boolean", "description": "Whether the asset was found in the database"}, - "guild_id": { - "description": "ID of the guild the asset belongs to", - "nullable": true, - "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] - }, - "guild_nsfw_level": { - "description": "NSFW level of the guild the asset belongs to", - "nullable": true, - "allOf": [{"$ref": "#/components/schemas/NSFWLevel"}] - } - }, - "required": ["id", "asset_type", "found_in_db", "guild_id", "guild_nsfw_level"] - }, - "NSFWLevel": {"type": "integer", "format": "int32", "enum": [0, 3], "description": "The NSFW level of the guild"}, - "PurgeGuildAssetErrorSchema": { - "type": "object", - "properties": {"id": {"$ref": "#/components/schemas/SnowflakeType"}, "error": {"type": "string"}}, - "required": ["id", "error"] - }, - "PurgeGuildAssetsRequest": { - "type": "object", - "properties": { - "ids": { - "type": "array", - "items": {"type": "string"}, - "maxItems": 100, - "description": "List of asset IDs to purge" - } - }, - "required": ["ids"] - }, "AuditLogsListResponseSchema": { "type": "object", "properties": { - "logs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "log_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "admin_user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "admin_user": { - "nullable": true, - "type": "object", - "properties": { - "id": {"$ref": "#/components/schemas/SnowflakeType"}, - "username": {"type": "string"}, - "discriminator": {"type": "string"}, - "global_name": {"nullable": true, "type": "string"} - }, - "required": ["id", "username", "discriminator", "global_name"] - }, - "target_type": {"type": "string"}, - "target_id": { - "type": "string", - "description": "The ID of the affected entity (user, channel, role, invite code, etc.)" - }, - "target_user": { - "nullable": true, - "type": "object", - "properties": { - "id": {"$ref": "#/components/schemas/SnowflakeType"}, - "username": {"type": "string"}, - "discriminator": {"type": "string"}, - "global_name": {"nullable": true, "type": "string"} - }, - "required": ["id", "username", "discriminator", "global_name"] - }, - "target_guild": { - "nullable": true, - "type": "object", - "properties": {"id": {"$ref": "#/components/schemas/SnowflakeType"}, "name": {"type": "string"}}, - "required": ["id", "name"] - }, - "target_channel": { - "nullable": true, - "type": "object", - "properties": { - "id": {"$ref": "#/components/schemas/SnowflakeType"}, - "name": {"nullable": true, "type": "string"}, - "type": { - "type": "integer", - "format": "int32", - "enum": [0, 1, 2, 3, 4, 998, 999], - "description": "The type of the channel" - }, - "guild_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]} - }, - "required": ["id", "name", "type", "guild_id"] - }, - "related_users": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "id": {"$ref": "#/components/schemas/SnowflakeType"}, - "username": {"type": "string"}, - "discriminator": {"type": "string"}, - "global_name": {"nullable": true, "type": "string"} - }, - "required": ["id", "username", "discriminator", "global_name"] - }, - "patternProperties": { - "^(0|[1-9][0-9]*)$": { - "type": "object", - "properties": { - "id": {"$ref": "#/components/schemas/SnowflakeType"}, - "username": {"type": "string"}, - "discriminator": {"type": "string"}, - "global_name": {"nullable": true, "type": "string"} - }, - "required": ["id", "username", "discriminator", "global_name"] - } - } - }, - "related_guilds": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": {"id": {"$ref": "#/components/schemas/SnowflakeType"}, "name": {"type": "string"}}, - "required": ["id", "name"] - }, - "patternProperties": { - "^(0|[1-9][0-9]*)$": { - "type": "object", - "properties": {"id": {"$ref": "#/components/schemas/SnowflakeType"}, "name": {"type": "string"}}, - "required": ["id", "name"] - } - } - }, - "related_channels": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "id": {"$ref": "#/components/schemas/SnowflakeType"}, - "name": {"nullable": true, "type": "string"}, - "type": { - "type": "integer", - "format": "int32", - "enum": [0, 1, 2, 3, 4, 998, 999], - "description": "The type of the channel" - }, - "guild_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]} - }, - "required": ["id", "name", "type", "guild_id"] - }, - "patternProperties": { - "^(0|[1-9][0-9]*)$": { - "type": "object", - "properties": { - "id": {"$ref": "#/components/schemas/SnowflakeType"}, - "name": {"nullable": true, "type": "string"}, - "type": { - "type": "integer", - "format": "int32", - "enum": [0, 1, 2, 3, 4, 998, 999], - "description": "The type of the channel" - }, - "guild_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]} - }, - "required": ["id", "name", "type", "guild_id"] - } - } - }, - "action": {"type": "string"}, - "audit_log_reason": {"nullable": true, "type": "string"}, - "metadata": {"type": "object", "additionalProperties": {"type": "string"}}, - "created_at": {"type": "string"} - }, - "required": [ - "log_id", - "admin_user_id", - "admin_user", - "target_type", - "target_id", - "target_user", - "target_guild", - "target_channel", - "related_users", - "related_guilds", - "related_channels", - "action", - "audit_log_reason", - "metadata", - "created_at" - ] - } - }, + "logs": {"type": "array", "items": {"$ref": "#/components/schemas/AdminAuditLogResponseSchema"}}, "total": {"type": "number"} }, "required": ["logs", "total"] }, - "ListAuditLogsRequest": { + "AdminAuditLogResponseSchema": { "type": "object", "properties": { + "log_id": {"$ref": "#/components/schemas/SnowflakeType"}, "admin_user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "target_type": {"type": "string", "description": "Filter by target entity type"}, + "admin_user": { + "nullable": true, + "type": "object", + "properties": { + "id": {"$ref": "#/components/schemas/SnowflakeType"}, + "username": {"type": "string"}, + "discriminator": {"type": "string"}, + "global_name": {"nullable": true, "type": "string"} + }, + "required": ["id", "username", "discriminator", "global_name"] + }, + "target_type": {"type": "string"}, "target_id": { "type": "string", - "description": "Filter by target entity ID (user, channel, role, invite code, etc.)" + "description": "The ID of the affected entity (user, channel, role, invite code, etc.)" }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "format": "int32", - "description": "Maximum number of entries to return" + "target_user": { + "nullable": true, + "type": "object", + "properties": { + "id": {"$ref": "#/components/schemas/SnowflakeType"}, + "username": {"type": "string"}, + "discriminator": {"type": "string"}, + "global_name": {"nullable": true, "type": "string"} + }, + "required": ["id", "username", "discriminator", "global_name"] }, - "offset": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "format": "int53", - "description": "Number of entries to skip" - } - } - }, - "SearchAuditLogsRequest": { - "type": "object", - "properties": { - "query": {"type": "string", "description": "Search query string"}, - "admin_user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "target_type": {"type": "string", "description": "Filter by target entity type"}, - "target_id": { - "type": "string", - "description": "Filter by target entity ID (user, channel, role, invite code, etc.)" + "target_guild": { + "nullable": true, + "type": "object", + "properties": {"id": {"$ref": "#/components/schemas/SnowflakeType"}, "name": {"type": "string"}}, + "required": ["id", "name"] }, - "sort_by": { - "type": "string", - "enum": ["createdAt", "relevance"], - "description": "Field to sort audit logs by" + "target_channel": { + "nullable": true, + "type": "object", + "properties": { + "id": {"$ref": "#/components/schemas/SnowflakeType"}, + "name": {"nullable": true, "type": "string"}, + "type": { + "type": "integer", + "format": "int32", + "enum": [0, 1, 2, 3, 4, 998, 999], + "description": "The type of the channel" + }, + "guild_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]} + }, + "required": ["id", "name", "type", "guild_id"] }, - "sort_order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort order direction"}, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "format": "int32", - "description": "Maximum number of entries to return" + "related_users": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": {"$ref": "#/components/schemas/SnowflakeType"}, + "username": {"type": "string"}, + "discriminator": {"type": "string"}, + "global_name": {"nullable": true, "type": "string"} + }, + "required": ["id", "username", "discriminator", "global_name"] + }, + "patternProperties": { + "^(0|[1-9][0-9]*)$": { + "type": "object", + "properties": { + "id": {"$ref": "#/components/schemas/SnowflakeType"}, + "username": {"type": "string"}, + "discriminator": {"type": "string"}, + "global_name": {"nullable": true, "type": "string"} + }, + "required": ["id", "username", "discriminator", "global_name"] + } + } }, - "offset": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "format": "int53", - "description": "Number of entries to skip" - } - } - }, - "BanAvatarHashRequest": { - "type": "object", - "properties": { - "hashes": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 1000}, - "category": {"type": "string"}, - "severity": {"type": "integer", "minimum": 0, "maximum": 3, "format": "int32"}, - "source_url": {"type": "string"}, - "reason": {"type": "string"}, - "notes": {"type": "string"} + "related_guilds": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": {"id": {"$ref": "#/components/schemas/SnowflakeType"}, "name": {"type": "string"}}, + "required": ["id", "name"] + }, + "patternProperties": { + "^(0|[1-9][0-9]*)$": { + "type": "object", + "properties": {"id": {"$ref": "#/components/schemas/SnowflakeType"}, "name": {"type": "string"}}, + "required": ["id", "name"] + } + } + }, + "related_channels": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": {"$ref": "#/components/schemas/SnowflakeType"}, + "name": {"nullable": true, "type": "string"}, + "type": { + "type": "integer", + "format": "int32", + "enum": [0, 1, 2, 3, 4, 998, 999], + "description": "The type of the channel" + }, + "guild_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]} + }, + "required": ["id", "name", "type", "guild_id"] + }, + "patternProperties": { + "^(0|[1-9][0-9]*)$": { + "type": "object", + "properties": { + "id": {"$ref": "#/components/schemas/SnowflakeType"}, + "name": {"nullable": true, "type": "string"}, + "type": { + "type": "integer", + "format": "int32", + "enum": [0, 1, 2, 3, 4, 998, 999], + "description": "The type of the channel" + }, + "guild_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]} + }, + "required": ["id", "name", "type", "guild_id"] + } + } + }, + "action": {"type": "string"}, + "audit_log_reason": {"nullable": true, "type": "string"}, + "metadata": {"type": "object", "additionalProperties": {"type": "string"}}, + "created_at": {"type": "string"} }, - "required": ["hashes"] + "required": [ + "log_id", + "admin_user_id", + "admin_user", + "target_type", + "target_id", + "target_user", + "target_guild", + "target_channel", + "related_users", + "related_guilds", + "related_channels", + "action", + "audit_log_reason", + "metadata", + "created_at" + ] }, - "BanCheckResponseSchema": { + "AdminBlocklistTypeListResponse": { "type": "object", - "properties": {"banned": {"type": "boolean"}}, - "required": ["banned"] + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "list_type": {"$ref": "#/components/schemas/AdminBlocklistListType"}, + "description": { + "type": "string", + "description": "What the blocklist matches and how matching is performed" + }, + "value_field": { + "type": "string", + "description": "The request body field that carries the entry value when adding to this blocklist" + }, + "fields": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 8, + "description": "Fields entries of this blocklist accept beyond the value itself" + }, + "scoped": { + "type": "boolean", + "description": "Whether entries are scoped to a profile field and a scope must be supplied" + }, + "supports_bulk_create": { + "type": "boolean", + "description": "Whether PUT on the entry collection is accepted" + }, + "supports_bulk_delete": { + "type": "boolean", + "description": "Whether DELETE on the entry collection is accepted" + }, + "supports_update": {"type": "boolean", "description": "Whether PATCH on a single entry is accepted"} + }, + "required": [ + "list_type", + "description", + "value_field", + "fields", + "scoped", + "supports_bulk_create", + "supports_bulk_delete", + "supports_update" + ] + }, + "maxItems": 50, + "description": "Every blocklist exposed by this instance" + } + }, + "required": ["items"] }, - "CheckAvatarHashRequest": { + "AdminBlocklistListType": { + "enum": [ + "ip", + "email", + "email-domain-suspicious", + "phrase", + "url", + "url-domain", + "file-sha", + "avatar-hash", + "profile-substring" + ], + "type": "string", + "description": "The blocklist an entry belongs to" + }, + "AdminBlocklistEntryListResponse": { "type": "object", - "properties": {"hashes": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 1000}}, - "required": ["hashes"] + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "list_type": {"$ref": "#/components/schemas/AdminBlocklistListType"}, + "value": {"type": "string", "description": "The canonical stored value of the entry"}, + "scope": { + "description": "The profile field the entry is scoped to, or null when the blocklist is unscoped", + "nullable": true, + "type": "string" + }, + "category": { + "description": "The category slug stored alongside the entry, or null", + "nullable": true, + "type": "string" + }, + "severity": { + "description": "The stored severity, or null when the blocklist has no severity", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/Int32Type"}] + }, + "source_url": { + "description": "The upstream source the entry was imported from, or null", + "nullable": true, + "type": "string" + }, + "notes": { + "description": "The internal notes stored alongside the entry, or null", + "nullable": true, + "type": "string" + }, + "content_type": { + "description": "The MIME type hint stored alongside the entry, or null", + "nullable": true, + "type": "string" + }, + "match_subdomains": { + "description": "Whether subdomains are covered by the entry, or null when the blocklist has no such flag", + "nullable": true, + "type": "boolean" + }, + "reason": { + "description": "The stored reason for the entry, or null", + "nullable": true, + "type": "string" + }, + "expires_at": { + "description": "ISO 8601 timestamp when the entry expires, or null", + "nullable": true, + "type": "string" + }, + "created_at": { + "description": "ISO 8601 timestamp when the entry was added, or null", + "nullable": true, + "type": "string" + }, + "created_by_user_id": { + "description": "The admin who added the entry, or null when unknown", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] + } + }, + "required": [ + "list_type", + "value", + "scope", + "category", + "severity", + "source_url", + "notes", + "content_type", + "match_subdomains", + "reason", + "expires_at", + "created_at", + "created_by_user_id" + ] + }, + "maxItems": 200, + "description": "The blocklist entries in this page, ordered by value" + }, + "has_more": { + "type": "boolean", + "description": "Whether another page can be fetched with the next_after cursor" + }, + "next_after": { + "description": "Cursor to send as after on the next request, or null when this is the last page", + "nullable": true, + "type": "string" + } + }, + "required": ["items", "has_more", "next_after"] + }, + "Int32Type": {"type": "integer", "minimum": 0, "maximum": 2147483647, "format": "int32"}, + "AdminBlocklistEntryCreateRequest": { + "oneOf": [ + {"$ref": "#/components/schemas/BanIpRequest"}, + {"$ref": "#/components/schemas/BanEmailRequest"}, + {"$ref": "#/components/schemas/SuspiciousEmailDomainRequest"}, + {"$ref": "#/components/schemas/BanPhraseRequest"}, + {"$ref": "#/components/schemas/BanUrlRequest"}, + {"$ref": "#/components/schemas/BanUrlDomainRequest"}, + {"$ref": "#/components/schemas/BanFileShaRequest"}, + {"$ref": "#/components/schemas/BanAvatarHashRequest"}, + {"$ref": "#/components/schemas/BanProfileSubstringRequest"} + ], + "description": "The entry to add, in the shape the blocklist named by list_type accepts" + }, + "BanIpRequest": { + "type": "object", + "properties": {"ip": {"type": "string", "description": "IPv4/IPv6 address or CIDR range to ban"}}, + "required": ["ip"] }, "BanEmailRequest": { "type": "object", @@ -9774,50 +9493,18 @@ "required": ["email"] }, "EmailType": {"type": "string", "format": "email"}, - "BanFileShaRequest": { + "SuspiciousEmailDomainRequest": { "type": "object", "properties": { - "sha256_hex": {"type": "string", "description": "SHA-256 in hex"}, - "category": {"type": "string"}, - "severity": {"type": "integer", "minimum": 0, "maximum": 3, "format": "int32"}, - "content_type": {"type": "string", "description": "Optional MIME type hint for observability"}, - "source_url": {"type": "string"}, - "notes": {"type": "string"} - }, - "required": ["sha256_hex"] - }, - "BulkJobResponse": { - "type": "object", - "properties": {"job_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["job_id"] - }, - "BulkBanFileShasRequest": { - "type": "object", - "properties": { - "sha256_list": { - "type": "array", - "items": {"type": "string"}, - "minItems": 1, - "maxItems": 10000, - "description": "Array of SHA-256 hex strings to ban" + "domain": { + "type": "string", + "minLength": 1, + "maxLength": 253, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9\\-.]*\\.[a-zA-Z]{2,}$", + "description": "Email domain to flag as suspicious (e.g. mail.ru). Registrants from this domain will be required to verify a phone number." } }, - "required": ["sha256_list"] - }, - "CheckFileShaRequest": { - "type": "object", - "properties": {"sha256_hex": {"type": "string"}}, - "required": ["sha256_hex"] - }, - "UnbanFileShaRequest": { - "type": "object", - "properties": {"sha256_hex": {"type": "string"}}, - "required": ["sha256_hex"] - }, - "BanIpRequest": { - "type": "object", - "properties": {"ip": {"type": "string", "description": "IPv4/IPv6 address or CIDR range to ban"}}, - "required": ["ip"] + "required": ["domain"] }, "BanPhraseRequest": { "type": "object", @@ -9829,15 +9516,22 @@ }, "required": ["phrase"] }, - "BanProfileSubstringRequest": { + "BanUrlRequest": { "type": "object", "properties": { - "scope": {"enum": ["username", "global_name", "nickname", "bio", "pronouns"], "type": "string"}, - "substrings": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 1000}, - "reason": {"type": "string"}, - "notes": {"type": "string"} + "url": {"type": "string", "description": "Absolute URL to ban. Canonicalized before storage."}, + "category": {"type": "string", "description": "Category / source slug (defaults to \"manual\")"}, + "severity": { + "type": "integer", + "minimum": 0, + "maximum": 3, + "format": "int32", + "description": "Severity: 0 allow, 1 warn, 2 block, 3 block+report (default 2)" + }, + "source_url": {"type": "string", "description": "Upstream source URL if imported from a feed"}, + "notes": {"type": "string", "description": "Internal notes for audit trail"} }, - "required": ["scope", "substrings"] + "required": ["url"] }, "BanUrlDomainRequest": { "type": "object", @@ -9860,15 +9554,88 @@ }, "required": ["domain"] }, - "UnbanUrlDomainRequest": { + "BanFileShaRequest": { "type": "object", - "properties": {"domain": {"type": "string", "description": "Domain to unban"}}, - "required": ["domain"] + "properties": { + "sha256_hex": {"type": "string", "description": "SHA-256 in hex"}, + "category": {"type": "string"}, + "severity": {"type": "integer", "minimum": 0, "maximum": 3, "format": "int32"}, + "content_type": {"type": "string", "description": "Optional MIME type hint for observability"}, + "source_url": {"type": "string"}, + "notes": {"type": "string"} + }, + "required": ["sha256_hex"] }, - "BanUrlRequest": { + "BanAvatarHashRequest": { + "type": "object", + "properties": { + "hashes": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 1000}, + "category": {"type": "string"}, + "severity": {"type": "integer", "minimum": 0, "maximum": 3, "format": "int32"}, + "source_url": {"type": "string"}, + "reason": {"type": "string"}, + "notes": {"type": "string"} + }, + "required": ["hashes"] + }, + "BanProfileSubstringRequest": { + "type": "object", + "properties": { + "scope": {"enum": ["username", "global_name", "nickname", "bio", "pronouns"], "type": "string"}, + "substrings": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 1000}, + "reason": {"type": "string"}, + "notes": {"type": "string"} + }, + "required": ["scope", "substrings"] + }, + "BulkJobResponse": { + "type": "object", + "properties": {"job_id": {"$ref": "#/components/schemas/SnowflakeType"}}, + "required": ["job_id"] + }, + "BulkBanFileShasRequest": { + "type": "object", + "properties": { + "sha256_list": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "maxItems": 10000, + "description": "Array of SHA-256 hex strings to ban" + } + }, + "required": ["sha256_list"] + }, + "AdminBlocklistBulkDeleteRequest": { + "oneOf": [ + {"$ref": "#/components/schemas/CheckAvatarHashRequest"}, + {"$ref": "#/components/schemas/BanProfileSubstringRequest"} + ], + "description": "The entries to remove, in the shape the blocklist named by list_type accepts" + }, + "CheckAvatarHashRequest": { + "type": "object", + "properties": {"hashes": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 1000}}, + "required": ["hashes"] + }, + "BanCheckResponseSchema": { + "type": "object", + "properties": {"banned": {"type": "boolean"}}, + "required": ["banned"] + }, + "AdminBlocklistEntryUpdateRequest": { + "oneOf": [ + {"$ref": "#/components/schemas/AdminBlocklistUrlUpdateRequest"}, + {"$ref": "#/components/schemas/AdminBlocklistUrlDomainUpdateRequest"}, + {"$ref": "#/components/schemas/AdminBlocklistFileShaUpdateRequest"}, + {"$ref": "#/components/schemas/AdminBlocklistAvatarHashUpdateRequest"}, + {"$ref": "#/components/schemas/AdminBlocklistProfileSubstringUpdateRequest"} + ], + "description": "The stored fields to write, in the shape the blocklist named by list_type accepts" + }, + "AdminBlocklistUrlUpdateRequest": { "type": "object", "properties": { - "url": {"type": "string", "description": "Absolute URL to ban. Canonicalized before storage."}, "category": {"type": "string", "description": "Category / source slug (defaults to \"manual\")"}, "severity": { "type": "integer", @@ -9879,126 +9646,67 @@ }, "source_url": {"type": "string", "description": "Upstream source URL if imported from a feed"}, "notes": {"type": "string", "description": "Internal notes for audit trail"} - }, - "required": ["url"] + } }, - "CheckUrlBlocklistRequest": { - "type": "object", - "properties": {"url": {"type": "string", "description": "URL to check against the blocklist"}}, - "required": ["url"] - }, - "UnbanUrlRequest": { + "AdminBlocklistUrlDomainUpdateRequest": { "type": "object", "properties": { - "url": {"type": "string", "description": "URL to unban (must match the canonicalized form in storage)"} - }, - "required": ["url"] - }, - "BulkAddGuildMembersRequest": { - "type": "object", - "properties": { - "guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "user_ids": { - "type": "array", - "items": {"$ref": "#/components/schemas/SnowflakeType"}, - "maxItems": 1000, - "description": "List of user IDs to add as members" - } - }, - "required": ["guild_id", "user_ids"] - }, - "BulkDeleteUserMessagesRequest": { - "type": "object", - "properties": { - "user_ids": { - "type": "array", - "items": {"$ref": "#/components/schemas/SnowflakeType"}, - "maxItems": 1000, - "description": "List of user IDs whose messages will be deleted" - } - }, - "required": ["user_ids"] - }, - "BulkScheduleUserDeletionRequest": { - "type": "object", - "properties": { - "user_ids": { - "type": "array", - "items": {"$ref": "#/components/schemas/SnowflakeType"}, - "maxItems": 1000, - "description": "List of user IDs to schedule deletion for" + "match_subdomains": { + "type": "boolean", + "description": "If true, any subdomain rooted at this domain is also banned" }, - "reason_code": { + "category": {"type": "string", "description": "Category / source slug (defaults to \"manual\")"}, + "severity": { "type": "integer", "minimum": 0, - "maximum": 2147483647, + "maximum": 3, "format": "int32", - "description": "Code indicating the reason for deletion" + "description": "Severity: 0 allow, 1 warn, 2 block, 3 block+report (default 2)" }, - "public_reason": {"type": "string", "description": "Public-facing reason for the deletion"}, - "days_until_deletion": { - "type": "integer", - "minimum": 1, - "maximum": 365, - "format": "int32", - "description": "Number of days until the accounts are deleted" - } - }, - "required": ["user_ids", "reason_code"] + "source_url": {"type": "string", "description": "Upstream source URL if imported from a feed"}, + "notes": {"type": "string", "description": "Internal notes for audit trail"} + } }, - "BulkUpdateGuildFeaturesRequest": { + "AdminBlocklistFileShaUpdateRequest": { "type": "object", "properties": { - "guild_ids": { - "type": "array", - "items": {"$ref": "#/components/schemas/SnowflakeType"}, - "maxItems": 1000, - "description": "List of guild IDs to update" - }, - "add_features": { - "type": "array", - "items": {"$ref": "#/components/schemas/GuildFeatureSchema"}, - "maxItems": 100, - "description": "Guild features to add to all specified guilds" - }, - "remove_features": { - "type": "array", - "items": {"$ref": "#/components/schemas/GuildFeatureSchema"}, - "maxItems": 100, - "description": "Guild features to remove from all specified guilds" - } - }, - "required": ["guild_ids"] + "category": {"type": "string"}, + "severity": {"type": "integer", "minimum": 0, "maximum": 3, "format": "int32"}, + "content_type": {"type": "string", "description": "Optional MIME type hint for observability"}, + "source_url": {"type": "string"}, + "notes": {"type": "string"} + } }, - "GuildFeatureSchema": { - "type": "string", - "description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)" - }, - "BulkUpdateSuspiciousActivityFlagsRequest": { + "AdminBlocklistAvatarHashUpdateRequest": { "type": "object", "properties": { - "user_ids": { - "type": "array", - "items": {"$ref": "#/components/schemas/SnowflakeType"}, - "maxItems": 1000, - "description": "List of user IDs to update" - }, - "add_flags": { - "type": "array", - "items": {"type": "string"}, - "maxItems": 32, - "description": "Suspicious activity flag names to add to all specified users" - }, - "remove_flags": { - "type": "array", - "items": {"type": "string"}, - "maxItems": 32, - "description": "Suspicious activity flag names to remove from all specified users" - } - }, - "required": ["user_ids"] + "category": {"type": "string"}, + "severity": {"type": "integer", "minimum": 0, "maximum": 3, "format": "int32"}, + "source_url": {"type": "string"}, + "reason": {"type": "string"}, + "notes": {"type": "string"} + } }, - "BulkUpdateUserFlagsRequest": { + "AdminBlocklistProfileSubstringUpdateRequest": { + "type": "object", + "properties": { + "scope": {"enum": ["username", "global_name", "nickname", "bio", "pronouns"], "type": "string"}, + "reason": {"type": "string"}, + "notes": {"type": "string"} + }, + "required": ["scope"] + }, + "AdminBulkJobCreateRequest": { + "oneOf": [ + {"$ref": "#/components/schemas/UpdateUserFlagsAdminBulkJobCreateRequest"}, + {"$ref": "#/components/schemas/UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequest"}, + {"$ref": "#/components/schemas/UpdateGuildFeaturesAdminBulkJobCreateRequest"}, + {"$ref": "#/components/schemas/AddGuildMembersAdminBulkJobCreateRequest"}, + {"$ref": "#/components/schemas/ScheduleUserDeletionAdminBulkJobCreateRequest"}, + {"$ref": "#/components/schemas/DeleteUserMessagesAdminBulkJobCreateRequest"} + ] + }, + "UpdateUserFlagsAdminBulkJobCreateRequest": { "type": "object", "properties": { "user_ids": { @@ -10018,9 +9726,14 @@ "items": {"$ref": "#/components/schemas/UserFlags"}, "maxItems": 64, "description": "User flags to remove from all specified users" + }, + "task": { + "type": "string", + "enum": ["update_user_flags"], + "description": "Adds and removes account flags on every targeted user" } }, - "required": ["user_ids"] + "required": ["user_ids", "task"] }, "UserFlags": { "type": "string", @@ -10028,2108 +9741,137 @@ "pattern": "^[0-9]+$", "description": "A single user flag value to add or remove" }, - "CodesResponse": { - "type": "object", - "properties": {"codes": {"type": "array", "items": {"type": "string"}}}, - "required": ["codes"] - }, - "GenerateGiftCodesRequest": { + "UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequest": { "type": "object", "properties": { - "count": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "format": "int32", - "description": "Number of gift codes to generate" - }, - "duration_type": { - "type": "string", - "enum": ["days", "weeks", "months", "years"], - "description": "Duration unit for the generated gift codes" - }, - "duration_quantity": { - "type": "integer", - "minimum": 1, - "maximum": 3650, - "format": "int32", - "description": "Duration quantity for the selected unit. Lifetime gifts are not supported." - } - }, - "required": ["count", "duration_type", "duration_quantity"] - }, - "DiscoveryAdminPendingApplicationResponse": { - "type": "object", - "properties": { - "guild_id": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "description": "Guild ID"}, - "guild_name": {"type": "string", "description": "Guild name"}, - "guild_icon": {"description": "Guild icon hash", "nullable": true, "type": "string"}, - "guild_owner_id": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "description": "Guild owner user ID"}, - "guild_owner_username": {"description": "Guild owner username", "nullable": true, "type": "string"}, - "guild_owner_global_name": {"description": "Guild owner display name", "nullable": true, "type": "string"}, - "guild_owner_discriminator": {"description": "Guild owner discriminator", "nullable": true, "type": "string"}, - "guild_member_count": {"type": "number", "description": "Approximate member count"}, - "guild_nsfw_level": { - "description": "NSFW level of the guild", - "nullable": true, - "allOf": [{"$ref": "#/components/schemas/NSFWLevel"}] - }, - "guild_features": {"type": "array", "items": {"type": "string"}, "description": "Guild feature flags"}, - "description": {"type": "string", "description": "Discovery description"}, - "category_type": {"type": "number", "description": "Discovery category type"}, - "primary_language": {"description": "Primary community language", "nullable": true, "type": "string"}, - "custom_tags": {"type": "array", "items": {"type": "string"}, "description": "Custom discovery tags"}, - "applied_at": {"type": "string", "description": "Application timestamp"} - }, - "required": [ - "guild_id", - "guild_name", - "guild_icon", - "guild_owner_id", - "guild_owner_username", - "guild_owner_global_name", - "guild_owner_discriminator", - "guild_member_count", - "guild_nsfw_level", - "guild_features", - "description", - "category_type", - "primary_language", - "custom_tags", - "applied_at" - ] - }, - "DiscoveryApplicationResponse": { - "type": "object", - "properties": { - "guild_id": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "description": "Guild ID"}, - "guild_nsfw_level": { - "description": "NSFW level of the guild", - "nullable": true, - "allOf": [{"$ref": "#/components/schemas/NSFWLevel"}] - }, - "status": {"type": "string", "description": "Application status"}, - "description": {"type": "string", "description": "Discovery description"}, - "category_type": {"type": "number", "description": "Discovery category type"}, - "primary_language": {"description": "Primary community language", "nullable": true, "type": "string"}, - "custom_tags": {"type": "array", "items": {"type": "string"}, "description": "Custom discovery tags"}, - "applied_at": {"type": "string", "description": "Application timestamp"}, - "reviewed_at": {"description": "Review timestamp", "nullable": true, "type": "string"}, - "review_reason": {"description": "Review reason (approval/rejection)", "nullable": true, "type": "string"}, - "removed_at": {"description": "Removal timestamp", "nullable": true, "type": "string"}, - "removal_reason": {"description": "Removal reason", "nullable": true, "type": "string"} - }, - "required": ["guild_id", "status", "description", "category_type", "custom_tags", "applied_at"] - }, - "DiscoveryAdminReviewRequest": { - "type": "object", - "properties": {"reason": {"type": "string", "maxLength": 500, "description": "Review reason"}} - }, - "DiscoveryAdminRejectRequest": { - "type": "object", - "properties": { - "reason": {"type": "string", "minLength": 1, "maxLength": 500, "description": "Rejection reason"} - }, - "required": ["reason"] - }, - "DiscoveryAdminRemoveRequest": { - "type": "object", - "properties": {"reason": {"type": "string", "minLength": 1, "maxLength": 500, "description": "Removal reason"}}, - "required": ["reason"] - }, - "DiscoveryAdminListedGuildResponse": { - "type": "object", - "properties": { - "guild_id": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "description": "Guild ID"}, - "guild_name": {"type": "string", "description": "Guild name"}, - "guild_icon": {"description": "Guild icon hash", "nullable": true, "type": "string"}, - "guild_owner_id": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "description": "Guild owner user ID"}, - "guild_owner_username": {"description": "Guild owner username", "nullable": true, "type": "string"}, - "guild_owner_global_name": {"description": "Guild owner display name", "nullable": true, "type": "string"}, - "guild_owner_discriminator": {"description": "Guild owner discriminator", "nullable": true, "type": "string"}, - "guild_member_count": {"type": "number", "description": "Approximate member count"}, - "guild_nsfw_level": { - "description": "NSFW level of the guild", - "nullable": true, - "allOf": [{"$ref": "#/components/schemas/NSFWLevel"}] - }, - "guild_features": {"type": "array", "items": {"type": "string"}, "description": "Guild feature flags"}, - "description": {"type": "string", "description": "Discovery description"}, - "category_type": {"type": "number", "description": "Discovery category type"}, - "primary_language": {"description": "Primary community language", "nullable": true, "type": "string"}, - "custom_tags": {"type": "array", "items": {"type": "string"}, "description": "Custom discovery tags"}, - "applied_at": {"type": "string", "description": "Application timestamp"}, - "approved_at": {"description": "Approval timestamp", "nullable": true, "type": "string"} - }, - "required": [ - "guild_id", - "guild_name", - "guild_icon", - "guild_owner_id", - "guild_owner_username", - "guild_owner_global_name", - "guild_owner_discriminator", - "guild_member_count", - "guild_nsfw_level", - "guild_features", - "description", - "category_type", - "primary_language", - "custom_tags", - "applied_at", - "approved_at" - ] - }, - "GuildMemoryStatsResponse": { - "type": "object", - "properties": { - "guilds": { + "user_ids": { "type": "array", - "items": { - "type": "object", - "properties": { - "node_id": {"type": "string"}, - "guild_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, - "guild_name": {"type": "string"}, - "guild_icon": {"nullable": true, "type": "string"}, - "nsfw_level": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/NSFWLevel"}]}, - "memory": {"$ref": "#/components/schemas/Int64StringType"}, - "member_count": {"$ref": "#/components/schemas/Int32Type"}, - "session_count": {"$ref": "#/components/schemas/Int32Type"}, - "presence_count": {"$ref": "#/components/schemas/Int32Type"} - }, - "required": [ - "node_id", - "guild_id", - "guild_name", - "guild_icon", - "nsfw_level", - "memory", - "member_count", - "session_count", - "presence_count" - ] - }, - "maxItems": 1000 + "items": {"$ref": "#/components/schemas/SnowflakeType"}, + "maxItems": 1000, + "description": "List of user IDs to update" + }, + "add_flags": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 32, + "description": "Suspicious activity flag names to add to all specified users" + }, + "remove_flags": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 32, + "description": "Suspicious activity flag names to remove from all specified users" + }, + "task": { + "type": "string", + "enum": ["update_suspicious_activity_flags"], + "description": "Adds and removes verification requirements on every targeted user" } }, - "required": ["guilds"] + "required": ["user_ids", "task"] }, - "Int64StringType": {"type": "string", "format": "int64", "pattern": "^-?[0-9]+$"}, - "Int32Type": {"type": "integer", "minimum": 0, "maximum": 2147483647, "format": "int32"}, - "GetProcessMemoryStatsRequest": { - "type": "object", - "properties": {"limit": {"type": "integer", "minimum": 100, "maximum": 1000, "format": "int32"}} - }, - "ReloadAllGuildsResponse": { - "type": "object", - "properties": {"count": {"$ref": "#/components/schemas/Int32Type"}}, - "required": ["count"] - }, - "ReloadGuildsRequest": { + "UpdateGuildFeaturesAdminBulkJobCreateRequest": { "type": "object", "properties": { "guild_ids": { "type": "array", "items": {"$ref": "#/components/schemas/SnowflakeType"}, "maxItems": 1000, - "description": "List of guild IDs to reload" - } - }, - "required": ["guild_ids"] - }, - "NodeStatsResponse": { - "type": "object", - "properties": { - "status": {"type": "string"}, - "sessions": {"$ref": "#/components/schemas/Int32Type"}, - "guilds": {"$ref": "#/components/schemas/Int32Type"}, - "presences": {"$ref": "#/components/schemas/Int32Type"}, - "calls": {"$ref": "#/components/schemas/Int32Type"}, - "memory": { - "type": "object", - "properties": { - "total": {"$ref": "#/components/schemas/Int64StringType"}, - "processes": {"$ref": "#/components/schemas/Int64StringType"}, - "system": {"$ref": "#/components/schemas/Int64StringType"} - }, - "required": ["total", "processes", "system"] + "description": "List of guild IDs to update" }, - "process_count": {"$ref": "#/components/schemas/Int32Type"}, - "process_limit": {"$ref": "#/components/schemas/Int32Type"}, - "uptime_seconds": {"$ref": "#/components/schemas/Int32Type"}, - "node_count": {"$ref": "#/components/schemas/Int32Type"}, - "nodes": { - "type": "array", - "items": { - "type": "object", - "properties": { - "node_id": {"type": "string"}, - "status": {"type": "string"}, - "sessions": {"$ref": "#/components/schemas/Int32Type"}, - "guilds": {"$ref": "#/components/schemas/Int32Type"}, - "presences": {"$ref": "#/components/schemas/Int32Type"}, - "calls": {"$ref": "#/components/schemas/Int32Type"}, - "memory": { - "type": "object", - "properties": { - "total": {"$ref": "#/components/schemas/Int64StringType"}, - "processes": {"$ref": "#/components/schemas/Int64StringType"}, - "system": {"$ref": "#/components/schemas/Int64StringType"} - }, - "required": ["total", "processes", "system"] - }, - "process_count": {"$ref": "#/components/schemas/Int32Type"}, - "process_limit": {"$ref": "#/components/schemas/Int32Type"}, - "uptime_seconds": {"$ref": "#/components/schemas/Int32Type"} - }, - "required": [ - "node_id", - "status", - "sessions", - "guilds", - "presences", - "calls", - "memory", - "process_count", - "process_limit", - "uptime_seconds" - ] - }, - "maxItems": 1000 - } - }, - "required": [ - "status", - "sessions", - "guilds", - "presences", - "calls", - "memory", - "process_count", - "process_limit", - "uptime_seconds", - "node_count", - "nodes" - ] - }, - "GatewayVoiceStateCountsResponse": { - "type": "object", - "properties": { - "total_voice_states": {"$ref": "#/components/schemas/Int32Type"}, - "regions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "region_id": {"type": "string"}, - "voice_state_count": {"$ref": "#/components/schemas/Int32Type"} - }, - "required": ["region_id", "voice_state_count"] - }, - "maxItems": 1000 - }, - "servers": { - "type": "array", - "items": { - "type": "object", - "properties": { - "server_id": {"type": "string"}, - "voice_state_count": {"$ref": "#/components/schemas/Int32Type"} - }, - "required": ["server_id", "voice_state_count"] - }, - "maxItems": 5000 - } - }, - "required": ["total_voice_states", "regions", "servers"] - }, - "ListGuildAuditLogsResponse": { - "type": "object", - "properties": { - "audit_log_entries": { - "type": "array", - "items": {"$ref": "#/components/schemas/GuildAuditLogEntryResponse"}, - "description": "Array of audit log entries" - }, - "users": { - "type": "array", - "items": {"$ref": "#/components/schemas/UserPartialResponse"}, - "description": "Users referenced in the audit log entries" - }, - "webhooks": { - "type": "array", - "items": {"$ref": "#/components/schemas/AuditLogWebhookResponse"}, - "description": "Webhooks referenced in the audit log entries" - } - }, - "required": ["audit_log_entries", "users", "webhooks"] - }, - "GuildAuditLogEntryResponse": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^(0|[1-9][0-9]*)$", - "description": "The unique identifier for this audit log entry" - }, - "action_type": {"$ref": "#/components/schemas/AuditLogActionType"}, - "user_id": { - "description": "The user ID of the user who performed the action", - "nullable": true, - "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] - }, - "target_id": { - "description": "The ID of the affected entity (user, channel, role, invite code, etc.)", - "nullable": true, - "type": "string" - }, - "reason": {"type": "string", "description": "The reason provided for the action"}, - "options": { - "type": "object", - "properties": { - "channel_id": {"type": "string", "description": "Channel ID for relevant actions"}, - "count": {"type": "number", "description": "Count of items affected"}, - "delete_member_days": { - "type": "string", - "description": "Number of days of messages to delete on member ban" - }, - "id": {"type": "string", "description": "ID of the affected entity"}, - "integration_type": {"type": "number", "description": "Type of integration"}, - "message_id": {"type": "string", "description": "Message ID for relevant actions"}, - "members_removed": {"type": "number", "description": "Number of members removed"}, - "role_name": {"type": "string", "description": "Name of the role"}, - "type": {"type": "number", "description": "Type identifier"}, - "inviter_id": {"type": "string", "description": "ID of the user who created the invite"}, - "max_age": {"type": "number", "description": "Maximum age of the invite in seconds"}, - "max_uses": {"type": "number", "description": "Maximum number of uses for the invite"}, - "temporary": {"type": "boolean", "description": "Whether the invite grants temporary membership"}, - "uses": {"type": "number", "description": "Number of times the invite has been used"} - }, - "description": "Additional options depending on action type" - }, - "changes": { - "type": "array", - "items": {"$ref": "#/components/schemas/AuditLogChangeSchema"}, - "description": "Changes made to the target" - } - }, - "required": ["id", "action_type"] - }, - "AuditLogActionType": { - "type": "integer", - "format": "int32", - "enum": [ - 1, 10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, 26, 27, 28, 30, 31, 32, 40, 41, 42, 50, 51, 52, 60, 61, 62, - 90, 91, 92, 72, 73, 74, 75 - ], - "description": "The type of action that occurred" - }, - "AuditLogChangeSchema": { - "type": "object", - "properties": { - "key": {"type": "string", "description": "The field that changed"}, - "old_value": { - "description": "Value before the change", - "nullable": true, - "oneOf": [ - {"type": "string"}, - {"type": "number"}, - {"type": "boolean"}, - {"type": "array", "items": {"type": "string"}}, - {"type": "array", "items": {"type": "number"}}, - { - "type": "object", - "properties": { - "added": {"type": "array", "items": {"type": "string"}}, - "removed": {"type": "array", "items": {"type": "string"}} - }, - "required": ["added", "removed"] - } - ] - }, - "new_value": { - "description": "Value after the change", - "nullable": true, - "oneOf": [ - {"type": "string"}, - {"type": "number"}, - {"type": "boolean"}, - {"type": "array", "items": {"type": "string"}}, - {"type": "array", "items": {"type": "number"}}, - { - "type": "object", - "properties": { - "added": {"type": "array", "items": {"type": "string"}}, - "removed": {"type": "array", "items": {"type": "string"}} - }, - "required": ["added", "removed"] - } - ] - } - }, - "required": ["key"] - }, - "UserPartialResponse": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^(0|[1-9][0-9]*)$", - "description": "The unique identifier (snowflake) for this user" - }, - "username": {"type": "string", "description": "The username of the user, not unique across the platform"}, - "discriminator": {"type": "string", "description": "The four-digit discriminator tag of the user"}, - "global_name": {"description": "The display name of the user, if set", "nullable": true, "type": "string"}, - "avatar": {"description": "The hash of the user avatar image", "nullable": true, "type": "string"}, - "avatar_color": { - "description": "The dominant avatar color of the user as an integer", - "nullable": true, - "allOf": [{"$ref": "#/components/schemas/Int32Type"}] - }, - "bot": {"type": "boolean", "description": "Whether the user is a bot account"}, - "system": {"type": "boolean", "description": "Whether the user is an official system user"}, - "flags": {"$ref": "#/components/schemas/PublicUserFlags"}, - "mention_flags": { - "$ref": "#/components/schemas/MentionReplyPreferences", - "description": "The user's account-wide reply mention preference" - } - }, - "required": ["id", "username", "discriminator", "global_name", "avatar", "avatar_color", "flags"] - }, - "PublicUserFlags": { - "type": "integer", - "format": "int32", - "minimum": 0, - "maximum": 2147483647, - "description": "The public flags on the user account" - }, - "MentionReplyPreferences": { - "type": "integer", - "format": "int32", - "enum": [0, 1, 2], - "description": "Reply mention preference" - }, - "AuditLogWebhookResponse": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^(0|[1-9][0-9]*)$", - "description": "The unique identifier for this webhook" - }, - "type": {"$ref": "#/components/schemas/WebhookType"}, - "guild_id": { - "description": "The guild ID this webhook belongs to", - "nullable": true, - "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] - }, - "channel_id": { - "description": "The channel ID this webhook posts to", - "nullable": true, - "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] - }, - "name": {"type": "string", "description": "The name of the webhook"}, - "avatar_hash": {"description": "The hash of the webhook avatar", "nullable": true, "type": "string"} - }, - "required": ["id", "type", "name"] - }, - "WebhookType": {"type": "integer", "format": "int32", "enum": [1, 2], "description": "The type of webhook"}, - "ListGuildAuditLogsRequest": { - "type": "object", - "properties": { - "guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "limit": {"$ref": "#/components/schemas/Int32Type"}, - "before": {"$ref": "#/components/schemas/SnowflakeType"}, - "after": {"$ref": "#/components/schemas/SnowflakeType"}, - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "action_type": {"$ref": "#/components/schemas/AuditLogActionType"} - }, - "required": ["guild_id"] - }, - "BanGuildMemberRequest": { - "type": "object", - "properties": { - "delete_message_days": { - "type": "integer", - "minimum": 0, - "maximum": 7, - "format": "int32", - "description": "Number of days of messages to delete from the banned user (0-7). Deprecated in favor of delete_message_seconds." - }, - "delete_message_seconds": { - "type": "integer", - "minimum": 0, - "maximum": 604800, - "format": "int32", - "description": "Number of seconds of messages to delete for the banned user (0-604800, default 0)" - }, - "reason": {"description": "The reason for the ban (max 512 characters)", "nullable": true, "type": "string"}, - "ban_duration_seconds": { - "type": "integer", - "format": "int53", - "description": "Duration of the ban in seconds (0 for permanent, or between 60 and 63072000 seconds for a temporary ban)" - }, - "guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "user_id": {"$ref": "#/components/schemas/SnowflakeType"} - }, - "required": ["guild_id", "user_id"] - }, - "ClearGuildFieldsRequest": { - "type": "object", - "properties": { - "guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "fields": { - "type": "array", - "items": { - "type": "string", - "enum": ["icon", "banner", "splash", "embed_splash"], - "description": "Guild image field that can be cleared" - }, - "maxItems": 10, - "description": "List of guild image fields to clear" - } - }, - "required": ["guild_id", "fields"] - }, - "SuccessResponse": { - "type": "object", - "properties": { - "success": {"type": "boolean", "enum": [true], "description": "Whether the operation succeeded"} - }, - "required": ["success"] - }, - "DeleteGuildRequest": { - "type": "object", - "properties": {"guild_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["guild_id"] - }, - "ForceAddUserToGuildRequest": { - "type": "object", - "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "guild_id": {"$ref": "#/components/schemas/SnowflakeType"} - }, - "required": ["user_id", "guild_id"] - }, - "KickGuildMemberRequest": { - "type": "object", - "properties": { - "guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "user_id": {"$ref": "#/components/schemas/SnowflakeType"} - }, - "required": ["guild_id", "user_id"] - }, - "ListGuildMembersResponse": { - "type": "object", - "properties": { - "members": {"type": "array", "items": {"$ref": "#/components/schemas/GuildMemberResponse"}}, - "total": {"$ref": "#/components/schemas/Int32Type"}, - "limit": {"$ref": "#/components/schemas/Int32Type"}, - "offset": {"$ref": "#/components/schemas/Int32Type"} - }, - "required": ["members", "total", "limit", "offset"] - }, - "GuildMemberResponse": { - "type": "object", - "properties": { - "user": {"$ref": "#/components/schemas/UserPartialResponse"}, - "nick": {"description": "The nickname of the member in this guild", "nullable": true, "type": "string"}, - "avatar": {"description": "The hash of the member guild-specific avatar", "nullable": true, "type": "string"}, - "banner": {"description": "The hash of the member guild-specific banner", "nullable": true, "type": "string"}, - "accent_color": { - "description": "The accent colour of the member guild profile as an integer", - "nullable": true, - "allOf": [{"$ref": "#/components/schemas/Int32Type"}] - }, - "roles": { - "type": "array", - "items": {"type": "string"}, - "maxItems": 250, - "description": "Array of role IDs the member has" - }, - "joined_at": { - "type": "string", - "format": "date-time", - "description": "ISO8601 timestamp of when the user joined the guild" - }, - "mute": {"type": "boolean", "description": "Whether the member is muted in voice channels"}, - "deaf": {"type": "boolean", "description": "Whether the member is deafened in voice channels"}, - "communication_disabled_until": { - "description": "ISO8601 timestamp until which the member is timed out", - "nullable": true, - "type": "string", - "format": "date-time" - }, - "profile_flags": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/GuildMemberProfileFlags"}]}, - "mention_flags": { - "nullable": true, - "allOf": [ - { - "$ref": "#/components/schemas/MentionReplyPreferences", - "description": "Per-guild reply mention preference override; NO_PREFERENCE means inherit the user-level mention_flags." - } - ] - } - }, - "required": ["user", "roles", "joined_at", "mute", "deaf"] - }, - "GuildMemberProfileFlags": { - "type": "integer", - "format": "int32", - "minimum": 0, - "maximum": 2147483647, - "description": "Member profile flags" - }, - "ListGuildMembersRequest": { - "type": "object", - "properties": { - "guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "limit": {"type": "integer", "minimum": 1, "maximum": 200, "format": "int32"}, - "offset": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"} - }, - "required": ["guild_id"] - }, - "LookupGuildResponse": { - "type": "object", - "properties": { - "guild": { - "nullable": true, - "type": "object", - "properties": { - "id": {"$ref": "#/components/schemas/SnowflakeType"}, - "owner_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "owner_username": {"nullable": true, "type": "string"}, - "owner_global_name": {"nullable": true, "type": "string"}, - "owner_discriminator": {"nullable": true, "type": "string"}, - "name": {"type": "string"}, - "vanity_url_code": {"nullable": true, "type": "string"}, - "icon": {"nullable": true, "type": "string"}, - "banner": {"nullable": true, "type": "string"}, - "splash": {"nullable": true, "type": "string"}, - "embed_splash": {"nullable": true, "type": "string"}, - "features": {"type": "array", "items": {"type": "string"}, "maxItems": 100}, - "verification_level": {"$ref": "#/components/schemas/GuildVerificationLevel"}, - "mfa_level": {"$ref": "#/components/schemas/GuildMFALevel"}, - "nsfw_level": {"$ref": "#/components/schemas/NSFWLevel"}, - "nsfw": {"type": "boolean"}, - "content_warning_level": {"$ref": "#/components/schemas/ContentWarningLevel"}, - "content_warning_text": {"nullable": true, "type": "string"}, - "explicit_content_filter": {"$ref": "#/components/schemas/GuildExplicitContentFilter"}, - "default_message_notifications": {"$ref": "#/components/schemas/DefaultMessageNotifications"}, - "afk_channel_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, - "afk_timeout": {"$ref": "#/components/schemas/Int32Type"}, - "system_channel_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, - "system_channel_flags": {"$ref": "#/components/schemas/SystemChannelFlags"}, - "rules_channel_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, - "disabled_operations": {"$ref": "#/components/schemas/Int32Type"}, - "member_count": {"$ref": "#/components/schemas/Int32Type"}, - "channels": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"$ref": "#/components/schemas/SnowflakeType"}, - "name": {"nullable": true, "type": "string"}, - "type": { - "type": "integer", - "format": "int32", - "enum": [0, 1, 2, 3, 4, 998, 999], - "description": "The type of the channel" - }, - "position": {"$ref": "#/components/schemas/Int32Type"}, - "parent_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, - "nsfw": {"nullable": true, "type": "boolean"}, - "nsfw_override": {"nullable": true, "type": "boolean"}, - "content_warning_level": {"$ref": "#/components/schemas/ContentWarningLevel"}, - "content_warning_text": {"nullable": true, "type": "string"}, - "url": {"nullable": true, "type": "string"} - }, - "required": ["id", "name", "type", "position", "parent_id", "nsfw", "url"] - }, - "maxItems": 500 - }, - "roles": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"$ref": "#/components/schemas/SnowflakeType"}, - "name": {"type": "string"}, - "color": {"$ref": "#/components/schemas/Int32Type"}, - "position": {"$ref": "#/components/schemas/Int32Type"}, - "permissions": { - "type": "string", - "format": "int64", - "pattern": "^[0-9]+$", - "description": "The role permissions bitfield" - }, - "hoist": {"type": "boolean"}, - "mentionable": {"type": "boolean"} - }, - "required": ["id", "name", "color", "position", "permissions", "hoist", "mentionable"] - }, - "maxItems": 250 - } - }, - "required": [ - "id", - "owner_id", - "owner_username", - "owner_global_name", - "owner_discriminator", - "name", - "vanity_url_code", - "icon", - "banner", - "splash", - "embed_splash", - "features", - "verification_level", - "mfa_level", - "nsfw_level", - "explicit_content_filter", - "default_message_notifications", - "afk_channel_id", - "afk_timeout", - "system_channel_id", - "system_channel_flags", - "rules_channel_id", - "disabled_operations", - "member_count", - "channels", - "roles" - ] - } - }, - "required": ["guild"] - }, - "GuildVerificationLevel": { - "type": "integer", - "format": "int32", - "enum": [0, 1, 2, 3, 4], - "description": "Required verification level for members" - }, - "GuildMFALevel": { - "type": "integer", - "format": "int32", - "enum": [0, 1], - "description": "Required MFA level for moderation actions" - }, - "ContentWarningLevel": { - "type": "integer", - "format": "int32", - "enum": [0, 1], - "description": "The content warning level for a guild, category, or channel" - }, - "GuildExplicitContentFilter": { - "type": "integer", - "format": "int32", - "enum": [0, 1, 2], - "description": "Level of content filtering for explicit media" - }, - "DefaultMessageNotifications": { - "type": "integer", - "format": "int32", - "enum": [0, 1], - "description": "Default notification level for new members" - }, - "SystemChannelFlags": { - "type": "integer", - "format": "int32", - "minimum": 0, - "maximum": 2147483647, - "description": "System channel message flags" - }, - "LookupGuildRequest": { - "type": "object", - "properties": {"guild_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["guild_id"] - }, - "ReloadGuildRequest": { - "type": "object", - "properties": {"guild_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["guild_id"] - }, - "SearchGuildsResponse": { - "type": "object", - "properties": { - "guilds": {"type": "array", "items": {"$ref": "#/components/schemas/GuildAdminResponse"}}, - "total": {"type": "number"} - }, - "required": ["guilds", "total"] - }, - "GuildAdminResponse": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^(0|[1-9][0-9]*)$", - "description": "The unique identifier for this guild" - }, - "name": {"type": "string", "description": "The name of the guild"}, - "features": { - "type": "array", - "items": {"$ref": "#/components/schemas/GuildFeatureSchema"}, - "maxItems": 100, - "description": "Array of guild feature flags" - }, - "owner_id": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "description": "The ID of the guild owner"}, - "owner_username": {"description": "The username of the guild owner", "nullable": true, "type": "string"}, - "owner_global_name": { - "description": "The display name of the guild owner, if set", - "nullable": true, - "type": "string" - }, - "owner_discriminator": { - "description": "The discriminator of the guild owner", - "nullable": true, - "type": "string" - }, - "icon": {"description": "The hash of the guild icon", "nullable": true, "type": "string"}, - "banner": {"description": "The hash of the guild banner", "nullable": true, "type": "string"}, - "member_count": { - "type": "integer", - "minimum": 0, - "maximum": 2147483647, - "format": "int32", - "description": "The number of members in the guild" - }, - "nsfw_level": {"$ref": "#/components/schemas/NSFWLevel"}, - "nsfw": {"type": "boolean", "description": "Whether the guild is flagged as adult content"}, - "content_warning_level": {"$ref": "#/components/schemas/ContentWarningLevel"}, - "content_warning_text": { - "description": "Custom content warning text shown before entry", - "nullable": true, - "type": "string" - }, - "approximate_member_count": {"$ref": "#/components/schemas/Int32Type"}, - "approximate_presence_count": {"$ref": "#/components/schemas/Int32Type"} - }, - "required": [ - "id", - "name", - "features", - "owner_id", - "owner_username", - "owner_global_name", - "owner_discriminator", - "icon", - "banner", - "member_count" - ] - }, - "SearchGuildsRequest": { - "type": "object", - "properties": { - "query": {"type": "string"}, - "limit": {"type": "integer", "minimum": 1, "maximum": 200, "format": "int32"}, - "offset": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"} - } - }, - "ShutdownGuildRequest": { - "type": "object", - "properties": {"guild_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["guild_id"] - }, - "GuildUpdateResponse": { - "type": "object", - "properties": { - "guild": { - "type": "object", - "properties": { - "id": {"$ref": "#/components/schemas/SnowflakeType"}, - "name": {"type": "string"}, - "features": {"type": "array", "items": {"type": "string"}, "maxItems": 100}, - "owner_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "icon": {"nullable": true, "type": "string"}, - "banner": {"nullable": true, "type": "string"}, - "member_count": {"$ref": "#/components/schemas/Int32Type"}, - "nsfw_level": {"$ref": "#/components/schemas/NSFWLevel"}, - "nsfw": {"type": "boolean"}, - "content_warning_level": {"$ref": "#/components/schemas/ContentWarningLevel"}, - "content_warning_text": {"nullable": true, "type": "string"} - }, - "required": ["id", "name", "features", "owner_id", "icon", "banner", "member_count"] - } - }, - "required": ["guild"] - }, - "TransferGuildOwnershipRequest": { - "type": "object", - "properties": { - "guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "new_owner_id": {"$ref": "#/components/schemas/SnowflakeType"} - }, - "required": ["guild_id", "new_owner_id"] - }, - "UpdateGuildFeaturesRequest": { - "type": "object", - "properties": { - "guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, "add_features": { "type": "array", "items": {"$ref": "#/components/schemas/GuildFeatureSchema"}, "maxItems": 100, - "description": "Guild features to add" + "description": "Guild features to add to all specified guilds" }, "remove_features": { "type": "array", "items": {"$ref": "#/components/schemas/GuildFeatureSchema"}, "maxItems": 100, - "description": "Guild features to remove" + "description": "Guild features to remove from all specified guilds" + }, + "task": { + "type": "string", + "enum": ["update_guild_features"], + "description": "Adds and removes features on every targeted guild" } }, - "required": ["guild_id"] + "required": ["guild_ids", "task"] }, - "UpdateGuildNameRequest": { + "GuildFeatureSchema": { + "type": "string", + "description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)" + }, + "AddGuildMembersAdminBulkJobCreateRequest": { "type": "object", "properties": { "guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "name": {"type": "string", "description": "New name for the guild"} - }, - "required": ["guild_id", "name"] - }, - "UpdateGuildSettingsRequest": { - "type": "object", - "properties": { - "guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "verification_level": { - "$ref": "#/components/schemas/GuildVerificationLevel", - "description": "Required verification level for guild members" - }, - "mfa_level": { - "$ref": "#/components/schemas/GuildMFALevel", - "description": "Required MFA level for moderators" - }, - "nsfw_level": {"$ref": "#/components/schemas/NSFWLevel", "description": "NSFW content level for the guild"}, - "nsfw": {"type": "boolean", "description": "Whether the guild is flagged as adult content"}, - "content_warning_level": {"$ref": "#/components/schemas/ContentWarningLevel"}, - "content_warning_text": { - "description": "Custom content warning text shown before entry", - "nullable": true, - "type": "string" - }, - "explicit_content_filter": { - "$ref": "#/components/schemas/GuildExplicitContentFilter", - "description": "Explicit content filter level" - }, - "default_message_notifications": { - "$ref": "#/components/schemas/DefaultMessageNotifications", - "description": "Default notification setting for new members" - }, - "disabled_operations": {"$ref": "#/components/schemas/GuildOperations"} - }, - "required": ["guild_id"] - }, - "GuildOperations": { - "type": "integer", - "format": "int32", - "minimum": 0, - "maximum": 2147483647, - "description": "Bitmask of disabled guild operations" - }, - "UpdateGuildVanityRequest": { - "type": "object", - "properties": { - "guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "vanity_url_code": { - "description": "New vanity URL code, or null to remove", - "nullable": true, - "type": "string" - } - }, - "required": ["guild_id", "vanity_url_code"] - }, - "ListGuildEmojisResponse": { - "type": "object", - "properties": { - "guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "emojis": { + "user_ids": { "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"$ref": "#/components/schemas/SnowflakeType"}, - "name": {"type": "string"}, - "animated": {"type": "boolean"}, - "creator_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "media_url": {"type": "string"} - }, - "required": ["id", "name", "animated", "creator_id", "media_url"] - }, - "maxItems": 500 + "items": {"$ref": "#/components/schemas/SnowflakeType"}, + "maxItems": 1000, + "description": "List of user IDs to add as members" + }, + "task": { + "type": "string", + "enum": ["add_guild_members"], + "description": "Adds every targeted user to one guild" } }, - "required": ["guild_id", "emojis"] + "required": ["guild_id", "user_ids", "task"] }, - "ListGuildStickersResponse": { + "ScheduleUserDeletionAdminBulkJobCreateRequest": { "type": "object", "properties": { - "guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "stickers": { + "user_ids": { "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"$ref": "#/components/schemas/SnowflakeType"}, - "name": {"type": "string"}, - "animated": {"type": "boolean"}, - "creator_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "media_url": {"type": "string"} - }, - "required": ["id", "name", "animated", "creator_id", "media_url"] - }, - "maxItems": 500 - } - }, - "required": ["guild_id", "stickers"] - }, - "InstanceConfigResponse": { - "type": "object", - "properties": { - "sso": { - "type": "object", - "properties": { - "enabled": {"type": "boolean"}, - "enforced": {"type": "boolean"}, - "display_name": {"nullable": true, "type": "string"}, - "issuer": {"nullable": true, "type": "string"}, - "authorization_url": {"nullable": true, "type": "string"}, - "token_url": {"nullable": true, "type": "string"}, - "userinfo_url": {"nullable": true, "type": "string"}, - "jwks_url": {"nullable": true, "type": "string"}, - "client_id": {"nullable": true, "type": "string"}, - "client_secret_set": {"type": "boolean"}, - "scope": {"nullable": true, "type": "string"}, - "allowed_domains": {"type": "array", "items": {"type": "string"}, "maxItems": 100}, - "auto_provision": {"type": "boolean"}, - "redirect_uri": {"nullable": true, "type": "string"} - }, - "required": [ - "enabled", - "enforced", - "display_name", - "issuer", - "authorization_url", - "token_url", - "userinfo_url", - "jwks_url", - "client_id", - "client_secret_set", - "scope", - "allowed_domains", - "auto_provision", - "redirect_uri" - ] + "items": {"$ref": "#/components/schemas/SnowflakeType"}, + "maxItems": 1000, + "description": "List of user IDs to schedule deletion for" }, - "gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigSchema"}, - "registration": { - "type": "object", - "properties": { - "mode": {"type": "string", "enum": ["open", "approval", "closed"], "description": "Registration mode"}, - "admin_registration_urls_enabled": {"type": "boolean"}, - "urls": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"type": "string"}, - "label": {"nullable": true, "type": "string"}, - "created_by_user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "created_at": {"type": "string", "format": "date-time"}, - "expires_at": {"nullable": true, "type": "string", "format": "date-time"}, - "max_uses": { - "nullable": true, - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991, - "format": "int53" - }, - "use_count": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"}, - "revoked_at": {"nullable": true, "type": "string", "format": "date-time"}, - "approval_required": {"type": "boolean"}, - "last_used_at": {"nullable": true, "type": "string", "format": "date-time"}, - "last_used_by_user_id": { - "nullable": true, - "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] - } - }, - "required": [ - "id", - "label", - "created_by_user_id", - "created_at", - "expires_at", - "max_uses", - "use_count", - "revoked_at", - "approval_required", - "last_used_at", - "last_used_by_user_id" - ] - } - }, - "pending_registrations": { - "type": "array", - "items": { - "type": "object", - "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "username": {"type": "string"}, - "discriminator": {"type": "integer", "minimum": 0, "maximum": 9999, "format": "int32"}, - "global_name": {"nullable": true, "type": "string"}, - "email": {"nullable": true, "type": "string"}, - "requested_at": {"type": "string", "format": "date-time"}, - "registration_url_id": {"nullable": true, "type": "string"}, - "client_ip": {"nullable": true, "type": "string"} - }, - "required": [ - "user_id", - "username", - "discriminator", - "global_name", - "email", - "requested_at", - "registration_url_id", - "client_ip" - ] - } - } - }, - "required": ["mode", "admin_registration_urls_enabled", "urls", "pending_registrations"] + "reason_code": { + "$ref": "#/components/schemas/DeletionReasonCode", + "description": "Code indicating the reason for deletion" }, - "self_hosted": {"type": "boolean"}, - "app_public": { - "type": "object", - "properties": { - "branding": { - "type": "object", - "properties": { - "product_name": {"type": "string"}, - "icon_url": {"nullable": true, "type": "string"}, - "symbol_url": {"nullable": true, "type": "string"}, - "logo_url": {"nullable": true, "type": "string"}, - "wordmark_url": {"nullable": true, "type": "string"}, - "favicon_url": {"nullable": true, "type": "string"}, - "theme_color": {"nullable": true, "type": "string"} - }, - "required": [ - "product_name", - "icon_url", - "symbol_url", - "logo_url", - "wordmark_url", - "favicon_url", - "theme_color" - ] - }, - "setup": { - "type": "object", - "properties": {"configured": {"type": "boolean"}}, - "required": ["configured"] - }, - "legal": { - "type": "object", - "properties": { - "terms_url": {"nullable": true, "type": "string"}, - "privacy_url": {"nullable": true, "type": "string"} - }, - "required": ["terms_url", "privacy_url"] - }, - "registration": { - "type": "object", - "properties": {"collect_date_of_birth": {"type": "boolean"}}, - "required": ["collect_date_of_birth"] - } - }, - "required": ["branding", "setup", "legal", "registration"] - }, - "policy": { - "type": "object", - "properties": { - "single_community_enabled": {"type": "boolean"}, - "single_community_guild_id": {"nullable": true, "type": "string"}, - "direct_messages_disabled": {"type": "boolean"}, - "direct_messages_locked": {"type": "boolean"}, - "premium_mode": {"enum": ["mirror", "everyone"], "type": "string"}, - "services": { - "type": "object", - "properties": { - "gif_enabled": {"nullable": true, "type": "boolean"}, - "youtube_enabled": {"nullable": true, "type": "boolean"}, - "bluesky_enabled": {"nullable": true, "type": "boolean"} - }, - "required": ["gif_enabled", "youtube_enabled", "bluesky_enabled"] - }, - "services_resolved": { - "type": "object", - "properties": { - "gif_enabled": {"type": "boolean"}, - "youtube_enabled": {"type": "boolean"}, - "bluesky_enabled": {"type": "boolean"} - }, - "required": ["gif_enabled", "youtube_enabled", "bluesky_enabled"] - }, - "services_available": { - "type": "object", - "properties": { - "gif": {"type": "boolean"}, - "youtube": {"type": "boolean"}, - "bluesky": {"type": "boolean"} - }, - "required": ["gif", "youtube", "bluesky"] - }, - "deferred_phone_gate": { - "type": "object", - "properties": { - "enabled": {"type": "boolean"}, - "window_hours": {"type": "number"}, - "member_threshold": {"type": "number"} - }, - "required": ["enabled", "window_hours", "member_threshold"] - } - }, - "required": [ - "single_community_enabled", - "single_community_guild_id", - "direct_messages_disabled", - "direct_messages_locked", - "premium_mode", - "services", - "services_resolved", - "services_available", - "deferred_phone_gate" - ] - }, - "integrations": { - "type": "object", - "properties": { - "gif": { - "type": "object", - "properties": {"klipy_api_key_set": {"type": "boolean"}, "effective_available": {"type": "boolean"}}, - "required": ["klipy_api_key_set", "effective_available"] - }, - "youtube": { - "type": "object", - "properties": {"api_key_set": {"type": "boolean"}, "effective_available": {"type": "boolean"}}, - "required": ["api_key_set", "effective_available"] - }, - "captcha": { - "type": "object", - "properties": { - "provider": {"nullable": true, "enum": ["hcaptcha", "turnstile", "none"], "type": "string"}, - "effective_provider": {"enum": ["hcaptcha", "turnstile", "none"], "type": "string"}, - "hcaptcha_site_key": {"nullable": true, "type": "string"}, - "hcaptcha_secret_key_set": {"type": "boolean"}, - "turnstile_site_key": {"nullable": true, "type": "string"}, - "turnstile_secret_key_set": {"type": "boolean"}, - "effective_enabled": {"type": "boolean"} - }, - "required": [ - "provider", - "effective_provider", - "hcaptcha_site_key", - "hcaptcha_secret_key_set", - "turnstile_site_key", - "turnstile_secret_key_set", - "effective_enabled" - ] - }, - "email": { - "type": "object", - "properties": { - "enabled": {"nullable": true, "type": "boolean"}, - "effective_enabled": {"type": "boolean"}, - "provider": {"nullable": true, "enum": ["smtp", "none"], "type": "string"}, - "effective_provider": {"enum": ["smtp", "none"], "type": "string"}, - "from_email": {"nullable": true, "type": "string"}, - "from_name": {"nullable": true, "type": "string"}, - "smtp": { - "type": "object", - "properties": { - "host": {"nullable": true, "type": "string"}, - "port": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 65535, "format": "int32"}, - "username": {"nullable": true, "type": "string"}, - "password_set": {"type": "boolean"}, - "secure": {"nullable": true, "type": "boolean"} - }, - "required": ["host", "port", "username", "password_set", "secure"] - }, - "disable_new_ip_authorization": {"type": "boolean"}, - "effective_disable_new_ip_authorization": {"type": "boolean"} - }, - "required": [ - "enabled", - "effective_enabled", - "provider", - "effective_provider", - "from_email", - "from_name", - "smtp", - "disable_new_ip_authorization", - "effective_disable_new_ip_authorization" - ] - }, - "bluesky": { - "type": "object", - "properties": { - "enabled": {"nullable": true, "type": "boolean"}, - "effective_enabled": {"type": "boolean"}, - "client_name": {"nullable": true, "type": "string"}, - "client_uri": {"nullable": true, "type": "string"}, - "logo_uri": {"nullable": true, "type": "string"}, - "tos_uri": {"nullable": true, "type": "string"}, - "policy_uri": {"nullable": true, "type": "string"}, - "key_count": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"} - }, - "required": [ - "enabled", - "effective_enabled", - "client_name", - "client_uri", - "logo_uri", - "tos_uri", - "policy_uri", - "key_count" - ] - } - }, - "required": ["gif", "youtube", "captcha", "email", "bluesky"] - }, - "media": { - "type": "object", - "properties": { - "attachment_decay": { - "type": "object", - "properties": { - "enabled": {"nullable": true, "type": "boolean"}, - "min_size_mb": {"nullable": true, "type": "number", "minimum": 0, "exclusiveMinimum": true}, - "max_size_mb": {"nullable": true, "type": "number", "minimum": 0, "exclusiveMinimum": true}, - "max_eligible_size_mb": {"nullable": true, "type": "number", "minimum": 0, "exclusiveMinimum": true}, - "min_lifetime_days": { - "nullable": true, - "type": "integer", - "maximum": 9007199254740991, - "format": "int53", - "minimum": 0, - "exclusiveMinimum": true - }, - "max_lifetime_days": { - "nullable": true, - "type": "integer", - "maximum": 9007199254740991, - "format": "int53", - "minimum": 0, - "exclusiveMinimum": true - }, - "curve": {"nullable": true, "type": "number", "minimum": 0, "maximum": 1}, - "renew_threshold_days": { - "nullable": true, - "type": "integer", - "maximum": 9007199254740991, - "format": "int53", - "minimum": 0, - "exclusiveMinimum": true - }, - "renew_window_days": { - "nullable": true, - "type": "integer", - "maximum": 9007199254740991, - "format": "int53", - "minimum": 0, - "exclusiveMinimum": true - }, - "effective": { - "type": "object", - "properties": { - "enabled": {"type": "boolean"}, - "min_size_mb": {"type": "number", "minimum": 0, "exclusiveMinimum": true}, - "max_size_mb": {"type": "number", "minimum": 0, "exclusiveMinimum": true}, - "max_eligible_size_mb": {"type": "number", "minimum": 0, "exclusiveMinimum": true}, - "min_lifetime_days": { - "type": "integer", - "maximum": 9007199254740991, - "format": "int53", - "minimum": 0, - "exclusiveMinimum": true - }, - "max_lifetime_days": { - "type": "integer", - "maximum": 9007199254740991, - "format": "int53", - "minimum": 0, - "exclusiveMinimum": true - }, - "curve": {"type": "number", "minimum": 0, "maximum": 1}, - "renew_threshold_days": { - "type": "integer", - "maximum": 9007199254740991, - "format": "int53", - "minimum": 0, - "exclusiveMinimum": true - }, - "renew_window_days": { - "type": "integer", - "maximum": 9007199254740991, - "format": "int53", - "minimum": 0, - "exclusiveMinimum": true - } - }, - "required": [ - "enabled", - "min_size_mb", - "max_size_mb", - "max_eligible_size_mb", - "min_lifetime_days", - "max_lifetime_days", - "curve", - "renew_threshold_days", - "renew_window_days" - ] - } - }, - "required": [ - "enabled", - "min_size_mb", - "max_size_mb", - "max_eligible_size_mb", - "min_lifetime_days", - "max_lifetime_days", - "curve", - "renew_threshold_days", - "renew_window_days", - "effective" - ] - } - }, - "required": ["attachment_decay"] - } - }, - "required": [ - "sso", - "gateway_rollout", - "registration", - "self_hosted", - "app_public", - "policy", - "integrations", - "media" - ] - }, - "GatewayRolloutConfigSchema": { - "type": "object", - "properties": { - "session_rollout_percentage": {"type": "number", "minimum": 0, "maximum": 100}, - "session_rollout_mode": {"enum": ["modulo", "random"], "type": "string"}, - "guild_rollout_percentage": {"type": "number", "minimum": 0, "maximum": 100}, - "rpc_request_timeout_ms": {"type": "integer", "minimum": 1000, "maximum": 60000, "format": "int32"}, - "max_concurrent_session_starts": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"}, - "max_concurrent_guild_starts": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"}, - "gateway_dispatch_relay_shards": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"}, - "gateway_dispatch_relay_max_queue": {"type": "integer", "minimum": 0, "maximum": 1000000, "format": "int32"}, - "voice_e2ee_scope": {"enum": ["guild_feature_only", "platform_wide"], "type": "string"}, - "voice_reconciliation_v3_percentage": {"type": "number", "minimum": 0, "maximum": 100}, - "voice_reconciliation_v3_interval_ms": { - "type": "integer", - "minimum": 500, - "maximum": 60000, - "format": "int32" - } - } - }, - "BrandingAssetUploadRequest": { - "type": "object", - "properties": { - "kind": {"enum": ["icon", "symbol", "logo", "wordmark", "favicon"], "type": "string"}, - "image": {"nullable": true, "type": "string", "maxLength": 16000000} - }, - "required": ["kind"] - }, - "InstanceEmailSmtpTestResponse": { - "type": "object", - "properties": {"ok": {"type": "boolean"}, "error": {"nullable": true, "type": "string"}}, - "required": ["ok", "error"] - }, - "InstanceEmailSmtpTestRequest": { - "type": "object", - "properties": { - "host": {"type": "string", "minLength": 1, "maxLength": 255}, - "port": {"type": "integer", "minimum": 1, "maximum": 65535, "format": "int32"}, - "username": {"type": "string", "minLength": 1, "maxLength": 320}, - "password": {"type": "string", "minLength": 1, "maxLength": 4096}, - "secure": {"type": "boolean"} - }, - "required": ["host", "port", "username", "password"] - }, - "PendingRegistrationActionRequest": { - "type": "object", - "properties": {"user_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["user_id"] - }, - "CreateRegistrationUrlResponse": { - "type": "object", - "properties": { - "registration_url": { - "type": "object", - "properties": { - "id": {"type": "string"}, - "label": {"nullable": true, "type": "string"}, - "created_by_user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "created_at": {"type": "string", "format": "date-time"}, - "expires_at": {"nullable": true, "type": "string", "format": "date-time"}, - "max_uses": { - "nullable": true, - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991, - "format": "int53" - }, - "use_count": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"}, - "revoked_at": {"nullable": true, "type": "string", "format": "date-time"}, - "approval_required": {"type": "boolean"}, - "last_used_at": {"nullable": true, "type": "string", "format": "date-time"}, - "last_used_by_user_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]} - }, - "required": [ - "id", - "label", - "created_by_user_id", - "created_at", - "expires_at", - "max_uses", - "use_count", - "revoked_at", - "approval_required", - "last_used_at", - "last_used_by_user_id" - ] - }, - "code": {"type": "string"}, - "url": {"type": "string"} - }, - "required": ["registration_url", "code", "url"] - }, - "CreateRegistrationUrlRequest": { - "type": "object", - "properties": { - "label": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 120}, - "expires_at": {"nullable": true, "type": "string", "format": "date-time"}, - "max_uses": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 1000000, "format": "int32"}, - "approval_required": {"type": "boolean"} - } - }, - "RegistrationUrlActionRequest": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}, - "InstanceConfigUpdateRequest": { - "type": "object", - "properties": { - "gateway_rollout": { - "nullable": true, - "allOf": [{"$ref": "#/components/schemas/GatewayRolloutConfigUpdateRequest"}] - }, - "registration": { - "nullable": true, - "type": "object", - "properties": { - "mode": {"type": "string", "enum": ["open", "approval", "closed"], "description": "Registration mode"}, - "admin_registration_urls_enabled": {"type": "boolean"} - } - }, - "sso": { - "nullable": true, - "type": "object", - "properties": { - "enabled": {"type": "boolean"}, - "enforced": {"type": "boolean"}, - "display_name": {"nullable": true, "type": "string"}, - "issuer": {"nullable": true, "type": "string"}, - "authorization_url": {"nullable": true, "type": "string"}, - "token_url": {"nullable": true, "type": "string"}, - "userinfo_url": {"nullable": true, "type": "string"}, - "jwks_url": {"nullable": true, "type": "string"}, - "client_id": {"nullable": true, "type": "string"}, - "client_secret": {"nullable": true, "type": "string"}, - "scope": {"nullable": true, "type": "string"}, - "allowed_domains": {"type": "array", "items": {"type": "string"}, "maxItems": 100}, - "auto_provision": {"type": "boolean"} - } - }, - "app_public": { - "nullable": true, - "type": "object", - "properties": { - "branding": { - "nullable": true, - "type": "object", - "properties": { - "product_name": {"type": "string", "minLength": 1, "maxLength": 80}, - "icon_url": {"nullable": true, "type": "string", "maxLength": 2048}, - "symbol_url": {"nullable": true, "type": "string", "maxLength": 2048}, - "logo_url": {"nullable": true, "type": "string", "maxLength": 2048}, - "wordmark_url": {"nullable": true, "type": "string", "maxLength": 2048}, - "favicon_url": {"nullable": true, "type": "string", "maxLength": 2048}, - "theme_color": {"nullable": true, "type": "string", "maxLength": 64} - } - }, - "setup": {"nullable": true, "type": "object", "properties": {"configured": {"type": "boolean"}}}, - "legal": { - "nullable": true, - "type": "object", - "properties": { - "terms_url": {"nullable": true, "type": "string", "maxLength": 2048}, - "privacy_url": {"nullable": true, "type": "string", "maxLength": 2048} - } - }, - "registration": { - "nullable": true, - "type": "object", - "properties": {"collect_date_of_birth": {"type": "boolean"}} - } - } - }, - "integrations": { - "nullable": true, - "type": "object", - "properties": { - "gif": { - "nullable": true, - "type": "object", - "properties": {"klipy_api_key": {"nullable": true, "type": "string", "maxLength": 4096}} - }, - "youtube": { - "nullable": true, - "type": "object", - "properties": {"api_key": {"nullable": true, "type": "string", "maxLength": 4096}} - }, - "captcha": { - "nullable": true, - "type": "object", - "properties": { - "provider": {"nullable": true, "enum": ["hcaptcha", "turnstile", "none"], "type": "string"}, - "hcaptcha_site_key": {"nullable": true, "type": "string", "maxLength": 4096}, - "hcaptcha_secret_key": {"nullable": true, "type": "string", "maxLength": 4096}, - "turnstile_site_key": {"nullable": true, "type": "string", "maxLength": 4096}, - "turnstile_secret_key": {"nullable": true, "type": "string", "maxLength": 4096} - } - }, - "email": { - "nullable": true, - "type": "object", - "properties": { - "enabled": {"nullable": true, "type": "boolean"}, - "provider": {"nullable": true, "enum": ["smtp", "none"], "type": "string"}, - "from_email": {"nullable": true, "type": "string", "maxLength": 320}, - "from_name": {"nullable": true, "type": "string", "maxLength": 120}, - "smtp": { - "nullable": true, - "type": "object", - "properties": { - "host": {"nullable": true, "type": "string", "maxLength": 255}, - "port": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 65535, "format": "int32"}, - "username": {"nullable": true, "type": "string", "maxLength": 320}, - "password": {"nullable": true, "type": "string", "maxLength": 4096}, - "secure": {"nullable": true, "type": "boolean"} - } - }, - "disable_new_ip_authorization": {"nullable": true, "type": "boolean"} - } - }, - "bluesky": { - "nullable": true, - "type": "object", - "properties": { - "enabled": {"nullable": true, "type": "boolean"}, - "client_name": {"nullable": true, "type": "string", "maxLength": 120}, - "client_uri": {"nullable": true, "type": "string", "maxLength": 2048}, - "logo_uri": {"nullable": true, "type": "string", "maxLength": 2048}, - "tos_uri": {"nullable": true, "type": "string", "maxLength": 2048}, - "policy_uri": {"nullable": true, "type": "string", "maxLength": 2048}, - "keys": { - "type": "array", - "items": { - "type": "object", - "properties": { - "kid": {"type": "string", "minLength": 1, "maxLength": 255}, - "private_key": {"nullable": true, "type": "string", "maxLength": 10000} - }, - "required": ["kid"] - }, - "maxItems": 8 - } - } - } - } - }, - "media": { - "nullable": true, - "type": "object", - "properties": { - "attachment_decay": { - "nullable": true, - "type": "object", - "properties": { - "enabled": {"nullable": true, "type": "boolean"}, - "min_size_mb": {"nullable": true, "type": "number", "minimum": 0, "exclusiveMinimum": true}, - "max_size_mb": {"nullable": true, "type": "number", "minimum": 0, "exclusiveMinimum": true}, - "max_eligible_size_mb": {"nullable": true, "type": "number", "minimum": 0, "exclusiveMinimum": true}, - "min_lifetime_days": { - "nullable": true, - "type": "integer", - "maximum": 9007199254740991, - "format": "int53", - "minimum": 0, - "exclusiveMinimum": true - }, - "max_lifetime_days": { - "nullable": true, - "type": "integer", - "maximum": 9007199254740991, - "format": "int53", - "minimum": 0, - "exclusiveMinimum": true - }, - "curve": {"nullable": true, "type": "number", "minimum": 0, "maximum": 1}, - "renew_threshold_days": { - "nullable": true, - "type": "integer", - "maximum": 9007199254740991, - "format": "int53", - "minimum": 0, - "exclusiveMinimum": true - }, - "renew_window_days": { - "nullable": true, - "type": "integer", - "maximum": 9007199254740991, - "format": "int53", - "minimum": 0, - "exclusiveMinimum": true - } - } - } - } - }, - "policy": { - "nullable": true, - "type": "object", - "properties": { - "single_community_enabled": {"type": "boolean"}, - "single_community_name": {"type": "string", "minLength": 1, "maxLength": 100}, - "direct_messages_disabled": {"type": "boolean"}, - "premium_mode": {"enum": ["mirror", "everyone"], "type": "string"}, - "services": { - "nullable": true, - "type": "object", - "properties": { - "gif_enabled": {"nullable": true, "type": "boolean"}, - "youtube_enabled": {"nullable": true, "type": "boolean"}, - "bluesky_enabled": {"nullable": true, "type": "boolean"} - } - }, - "deferred_phone_gate": { - "nullable": true, - "type": "object", - "properties": { - "enabled": {"type": "boolean"}, - "window_hours": {"type": "number", "maximum": 8760, "minimum": 0, "exclusiveMinimum": true}, - "member_threshold": { - "type": "integer", - "maximum": 1000000, - "format": "int32", - "minimum": 0, - "exclusiveMinimum": true - } - } - } - } - } - } - }, - "GatewayRolloutConfigUpdateRequest": { - "type": "object", - "properties": { - "session_rollout_percentage": {"type": "number", "minimum": 0, "maximum": 100}, - "session_rollout_mode": {"enum": ["modulo", "random"], "type": "string"}, - "guild_rollout_percentage": {"type": "number", "minimum": 0, "maximum": 100}, - "rpc_request_timeout_ms": {"type": "integer", "minimum": 1000, "maximum": 60000, "format": "int32"}, - "max_concurrent_session_starts": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"}, - "max_concurrent_guild_starts": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"}, - "gateway_dispatch_relay_shards": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"}, - "gateway_dispatch_relay_max_queue": {"type": "integer", "minimum": 0, "maximum": 1000000, "format": "int32"}, - "voice_e2ee_scope": {"enum": ["guild_feature_only", "platform_wide"], "type": "string"}, - "voice_reconciliation_v3_percentage": {"type": "number", "minimum": 0, "maximum": 100}, - "voice_reconciliation_v3_interval_ms": { - "type": "integer", - "minimum": 500, - "maximum": 60000, - "format": "int32" - } - } - }, - "ActiveJobsResponseSchema": { - "type": "object", - "properties": {"jobs": {"type": "array", "items": {"$ref": "#/components/schemas/JobLedgerEntrySchema"}}}, - "required": ["jobs"] - }, - "JobLedgerEntrySchema": { - "type": "object", - "properties": { - "job_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "task_type": {"type": "string"}, - "status": {"enum": ["queued", "running", "succeeded", "failed", "cancelled", "deadletter"], "type": "string"}, - "progress_current": {"nullable": true, "type": "number"}, - "progress_total": {"nullable": true, "type": "number"}, - "progress_message": {"nullable": true, "type": "string"}, - "error_message": {"nullable": true, "type": "string"}, - "created_at": {"type": "string", "description": "ISO 8601"}, - "started_at": {"nullable": true, "type": "string"}, - "completed_at": {"nullable": true, "type": "string"}, - "requested_by_user_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, - "audit_log_reason": {"nullable": true, "type": "string"}, - "jet_stream_lane": {"nullable": true, "type": "string"}, - "jet_stream_seq": {"nullable": true, "type": "string"}, - "attempts": {"type": "integer", "format": "int53"}, - "max_attempts": {"type": "integer", "format": "int53"}, - "run_at": {"nullable": true, "type": "string"}, - "cancel_requested": {"type": "boolean"}, - "context_link": {"nullable": true, "type": "string"}, - "payload": {"description": "JSON-encoded original payload", "nullable": true, "type": "string"}, - "result": {"description": "JSON-encoded result, if any", "nullable": true, "type": "string"} - }, - "required": [ - "job_id", - "task_type", - "status", - "progress_current", - "progress_total", - "progress_message", - "error_message", - "created_at", - "started_at", - "completed_at", - "requested_by_user_id", - "audit_log_reason", - "jet_stream_lane", - "jet_stream_seq", - "attempts", - "max_attempts", - "run_at", - "cancel_requested", - "context_link", - "payload", - "result" - ] - }, - "CancelJobResponseSchema": { - "type": "object", - "properties": { - "cancelled": { - "type": "boolean", - "description": "True if a cancel request was recorded; false if the job was already terminal." - } - }, - "required": ["cancelled"] - }, - "CancelJobRequest": { - "type": "object", - "properties": {"job_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["job_id"] - }, - "GetJobResponseSchema": { - "type": "object", - "properties": {"job": {"$ref": "#/components/schemas/JobLedgerEntrySchema"}}, - "required": ["job"] - }, - "GetJobRequest": { - "type": "object", - "properties": {"job_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["job_id"] - }, - "ListJobsResponseSchema": { - "type": "object", - "properties": { - "jobs": {"type": "array", "items": {"$ref": "#/components/schemas/JobLedgerEntrySchema"}}, - "next_cursor": { - "nullable": true, - "type": "object", - "properties": { - "bucket_day": {"type": "string"}, - "created_at": {"type": "string"}, - "job_id": {"$ref": "#/components/schemas/SnowflakeType"} - }, - "required": ["bucket_day", "created_at", "job_id"] - } - }, - "required": ["jobs", "next_cursor"] - }, - "ListJobsRequest": { - "type": "object", - "properties": { - "limit": {"type": "integer", "minimum": 1, "maximum": 200, "format": "int32", "description": "Page size"}, - "cursor": { - "type": "object", - "properties": { - "bucket_day": {"type": "string"}, - "created_at": {"type": "string"}, - "job_id": {"$ref": "#/components/schemas/SnowflakeType"} - }, - "required": ["bucket_day", "created_at", "job_id"], - "description": "Cursor returned by a previous page" - }, - "max_lookback_days": { + "public_reason": {"type": "string", "description": "Public-facing reason for the deletion"}, + "days_until_deletion": { "type": "integer", "minimum": 1, - "maximum": 60, + "maximum": 365, "format": "int32", - "description": "How many days back to scan" + "description": "Number of days until the accounts are deleted" }, - "status": { - "enum": ["queued", "running", "succeeded", "failed", "cancelled", "deadletter"], + "task": { "type": "string", - "description": "Filter by job status" - }, - "task_type": {"type": "string", "description": "Filter by task type"}, - "requested_by_user_id": {"$ref": "#/components/schemas/SnowflakeType"} - } - }, - "LimitConfigGetResponse": { - "type": "object", - "properties": { - "limit_config": { - "type": "object", - "properties": { - "traitDefinitions": {"type": "array", "items": {"type": "string"}}, - "rules": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"type": "string", "minLength": 1, "description": "Unique rule identifier"}, - "filters": { - "type": "object", - "properties": { - "traits": { - "type": "array", - "items": {"type": "string"}, - "description": "Trait filters that must match for the rule to apply" - }, - "guildFeatures": { - "type": "array", - "items": {"type": "string"}, - "description": "Guild feature flags required for the rule to apply" - } - }, - "description": "Optional filters that scope the rule" - }, - "limits": { - "type": "object", - "additionalProperties": {"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}, - "description": "Per-limit key values" - }, - "modifiedFields": {"type": "array", "items": {"type": "string"}} - }, - "required": ["id", "limits"] - } - } - }, - "required": ["traitDefinitions", "rules"] - }, - "limit_config_json": {"type": "string"}, - "self_hosted": {"type": "boolean"}, - "defaults": { - "type": "object", - "additionalProperties": {"type": "object", "additionalProperties": {"type": "number"}} - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "key": {"type": "string"}, - "label": {"type": "string"}, - "description": {"type": "string"}, - "category": {"type": "string"}, - "scope": {"type": "string"}, - "isToggle": {"type": "boolean"}, - "unit": {"enum": ["bytes", "count"], "type": "string"}, - "min": {"type": "number"}, - "max": {"type": "number"} - }, - "required": ["key", "label", "description", "category", "scope", "isToggle"] - } - }, - "categories": {"type": "object", "additionalProperties": {"type": "string"}}, - "limit_keys": {"type": "array", "items": {"type": "string"}}, - "bounds": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": {"min": {"type": "number"}, "max": {"type": "number"}}, - "required": ["min", "max"] - } + "enum": ["schedule_user_deletion"], + "description": "Schedules account deletion for every targeted user" } }, - "required": [ - "limit_config", - "limit_config_json", - "self_hosted", - "defaults", - "metadata", - "categories", - "limit_keys" - ] + "required": ["user_ids", "reason_code", "task"] }, - "NonNegativeSafeIntegerType": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"}, - "LimitConfigUpdateRequest": { + "DeletionReasonCode": { + "type": "integer", + "format": "int32", + "enum": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22], + "description": "Reason the account was scheduled for deletion" + }, + "DeleteUserMessagesAdminBulkJobCreateRequest": { "type": "object", "properties": { - "limit_config": { - "type": "object", - "properties": { - "traitDefinitions": { - "type": "array", - "items": {"type": "string"}, - "description": "Trait definitions used by rules" - }, - "rules": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"type": "string", "minLength": 1, "description": "Unique rule identifier"}, - "filters": { - "type": "object", - "properties": { - "traits": { - "type": "array", - "items": {"type": "string"}, - "description": "Trait filters that must match for the rule to apply" - }, - "guildFeatures": { - "type": "array", - "items": {"type": "string"}, - "description": "Guild feature flags required for the rule to apply" - } - }, - "description": "Optional filters that scope the rule" - }, - "limits": { - "type": "object", - "additionalProperties": {"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}, - "description": "Per-limit key values" - } - }, - "required": ["id", "limits"] - }, - "description": "Limit rules" - } - }, - "required": ["rules"], - "description": "New limit configuration snapshot" + "user_ids": { + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeType"}, + "maxItems": 1000, + "description": "List of user IDs whose messages will be deleted" + }, + "task": { + "type": "string", + "enum": ["delete_user_messages"], + "description": "Deletes every message authored by each targeted user, across all channels" } }, - "required": ["limit_config"] + "required": ["user_ids", "task"] }, "BrowseChannelResponse": { "type": "object", @@ -12228,6 +9970,14 @@ "attachments" ] }, + "ContentWarningLevel": { + "type": "integer", + "format": "int32", + "enum": [0, 1], + "description": "The content warning level for a guild, category, or channel" + }, + "NSFWLevel": {"type": "integer", "format": "int32", "enum": [0, 3], "description": "The NSFW level of the guild"}, + "NonNegativeSafeIntegerType": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"}, "MessageResponseSchema": { "type": "object", "properties": { @@ -12544,6 +10294,46 @@ "mention_roles" ] }, + "UserPartialResponse": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "description": "The unique identifier (snowflake) for this user" + }, + "username": {"type": "string", "description": "The username of the user, not unique across the platform"}, + "discriminator": {"type": "string", "description": "The four-digit discriminator tag of the user"}, + "global_name": {"description": "The display name of the user, if set", "nullable": true, "type": "string"}, + "avatar": {"description": "The hash of the user avatar image", "nullable": true, "type": "string"}, + "avatar_color": { + "description": "The dominant avatar color of the user as an integer", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/Int32Type"}] + }, + "bot": {"type": "boolean", "description": "Whether the user is a bot account"}, + "system": {"type": "boolean", "description": "Whether the user is an official system user"}, + "flags": {"$ref": "#/components/schemas/PublicUserFlags"}, + "mention_flags": { + "$ref": "#/components/schemas/MentionReplyPreferences", + "description": "The user's account-wide reply mention preference" + } + }, + "required": ["id", "username", "discriminator", "global_name", "avatar", "avatar_color", "flags"] + }, + "PublicUserFlags": { + "type": "integer", + "format": "int32", + "minimum": 0, + "maximum": 2147483647, + "description": "The public flags on the user account" + }, + "MentionReplyPreferences": { + "type": "integer", + "format": "int32", + "enum": [0, 1, 2], + "description": "Reply mention preference" + }, "MessageFlags": { "type": "integer", "format": "int32", @@ -13003,45 +10793,6 @@ }, "required": ["timestamp", "type", "flags"] }, - "BrowseChannelRequest": { - "type": "object", - "properties": { - "channel_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "before": {"$ref": "#/components/schemas/SnowflakeType"}, - "after": {"$ref": "#/components/schemas/SnowflakeType"}, - "limit": {"type": "integer", "minimum": 1, "maximum": 100, "format": "int32"} - }, - "required": ["channel_id"] - }, - "DeleteMessageResponse": { - "type": "object", - "properties": {"success": {"type": "boolean", "enum": [true]}}, - "required": ["success"] - }, - "DeleteMessageRequest": { - "type": "object", - "properties": { - "channel_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "message_id": {"$ref": "#/components/schemas/SnowflakeType"} - }, - "required": ["channel_id", "message_id"] - }, - "DeleteAllUserMessagesResponse": { - "type": "object", - "properties": { - "success": {"type": "boolean", "enum": [true]}, - "dry_run": {"type": "boolean"}, - "channel_count": {"$ref": "#/components/schemas/Int32Type"}, - "message_count": {"$ref": "#/components/schemas/Int32Type"}, - "job_id": {"type": "string"} - }, - "required": ["success", "dry_run", "channel_count", "message_count"] - }, - "DeleteAllUserMessagesRequest": { - "type": "object", - "properties": {"user_id": {"$ref": "#/components/schemas/SnowflakeType"}, "dry_run": {"type": "boolean"}}, - "required": ["user_id"] - }, "LookupMessageResponse": { "type": "object", "properties": { @@ -13055,24 +10806,2071 @@ }, "required": ["messages", "message_id"] }, - "LookupMessageRequest": { + "DeleteMessageResponse": { "type": "object", - "properties": { - "channel_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "message_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "context_limit": {"type": "integer", "minimum": 1, "maximum": 100, "format": "int32"} - }, - "required": ["channel_id", "message_id"] + "properties": {"success": {"type": "boolean", "enum": [true]}}, + "required": ["success"] }, - "LookupMessageByAttachmentRequest": { + "DiscoveryAdminPendingApplicationResponse": { "type": "object", "properties": { - "channel_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "attachment_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "filename": {"type": "string"}, - "context_limit": {"type": "integer", "minimum": 1, "maximum": 100, "format": "int32"} + "guild_id": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "description": "Guild ID"}, + "guild_name": {"type": "string", "description": "Guild name"}, + "guild_icon": {"description": "Guild icon hash", "nullable": true, "type": "string"}, + "guild_owner_id": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "description": "Guild owner user ID"}, + "guild_owner_username": {"description": "Guild owner username", "nullable": true, "type": "string"}, + "guild_owner_global_name": {"description": "Guild owner display name", "nullable": true, "type": "string"}, + "guild_owner_discriminator": {"description": "Guild owner discriminator", "nullable": true, "type": "string"}, + "guild_member_count": {"type": "number", "description": "Approximate member count"}, + "guild_nsfw_level": { + "description": "NSFW level of the guild", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/NSFWLevel"}] + }, + "guild_features": {"type": "array", "items": {"type": "string"}, "description": "Guild feature flags"}, + "description": {"type": "string", "description": "Discovery description"}, + "category_type": {"type": "number", "description": "Discovery category type"}, + "primary_language": {"description": "Primary community language", "nullable": true, "type": "string"}, + "custom_tags": {"type": "array", "items": {"type": "string"}, "description": "Custom discovery tags"}, + "applied_at": {"type": "string", "description": "Application timestamp"} }, - "required": ["channel_id", "attachment_id", "filename"] + "required": [ + "guild_id", + "guild_name", + "guild_icon", + "guild_owner_id", + "guild_owner_username", + "guild_owner_global_name", + "guild_owner_discriminator", + "guild_member_count", + "guild_nsfw_level", + "guild_features", + "description", + "category_type", + "primary_language", + "custom_tags", + "applied_at" + ] + }, + "DiscoveryApplicationResponse": { + "type": "object", + "properties": { + "guild_id": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "description": "Guild ID"}, + "guild_nsfw_level": { + "description": "NSFW level of the guild", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/NSFWLevel"}] + }, + "status": {"type": "string", "description": "Application status"}, + "description": {"type": "string", "description": "Discovery description"}, + "category_type": {"type": "number", "description": "Discovery category type"}, + "primary_language": {"description": "Primary community language", "nullable": true, "type": "string"}, + "custom_tags": {"type": "array", "items": {"type": "string"}, "description": "Custom discovery tags"}, + "applied_at": {"type": "string", "description": "Application timestamp"}, + "reviewed_at": {"description": "Review timestamp", "nullable": true, "type": "string"}, + "review_reason": {"description": "Review reason (approval/rejection)", "nullable": true, "type": "string"}, + "removed_at": {"description": "Removal timestamp", "nullable": true, "type": "string"}, + "removal_reason": {"description": "Removal reason", "nullable": true, "type": "string"} + }, + "required": ["guild_id", "status", "description", "category_type", "custom_tags", "applied_at"] + }, + "DiscoveryAdminApplicationUpdateRequest": { + "oneOf": [ + {"$ref": "#/components/schemas/ApprovedDiscoveryAdminApplicationUpdateRequest"}, + {"$ref": "#/components/schemas/RejectedDiscoveryAdminApplicationUpdateRequest"} + ] + }, + "ApprovedDiscoveryAdminApplicationUpdateRequest": { + "type": "object", + "properties": { + "reason": {"type": "string", "maxLength": 500, "description": "Review reason"}, + "status": {"type": "string", "enum": ["approved"], "description": "Approve the pending application"} + }, + "required": ["status"] + }, + "RejectedDiscoveryAdminApplicationUpdateRequest": { + "type": "object", + "properties": { + "reason": {"type": "string", "minLength": 1, "maxLength": 500, "description": "Rejection reason"}, + "status": {"type": "string", "enum": ["rejected"], "description": "Reject the pending application"} + }, + "required": ["reason", "status"] + }, + "DiscoveryCategoryListResponse": { + "type": "array", + "items": {"$ref": "#/components/schemas/DiscoveryCategoryResponse"} + }, + "DiscoveryCategoryResponse": { + "type": "object", + "properties": { + "id": {"type": "number", "description": "Category ID"}, + "name": {"type": "string", "description": "Category display name"} + }, + "required": ["id", "name"] + }, + "DiscoveryAdminListedGuildResponse": { + "type": "object", + "properties": { + "guild_id": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "description": "Guild ID"}, + "guild_name": {"type": "string", "description": "Guild name"}, + "guild_icon": {"description": "Guild icon hash", "nullable": true, "type": "string"}, + "guild_owner_id": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "description": "Guild owner user ID"}, + "guild_owner_username": {"description": "Guild owner username", "nullable": true, "type": "string"}, + "guild_owner_global_name": {"description": "Guild owner display name", "nullable": true, "type": "string"}, + "guild_owner_discriminator": {"description": "Guild owner discriminator", "nullable": true, "type": "string"}, + "guild_member_count": {"type": "number", "description": "Approximate member count"}, + "guild_nsfw_level": { + "description": "NSFW level of the guild", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/NSFWLevel"}] + }, + "guild_features": {"type": "array", "items": {"type": "string"}, "description": "Guild feature flags"}, + "description": {"type": "string", "description": "Discovery description"}, + "category_type": {"type": "number", "description": "Discovery category type"}, + "primary_language": {"description": "Primary community language", "nullable": true, "type": "string"}, + "custom_tags": {"type": "array", "items": {"type": "string"}, "description": "Custom discovery tags"}, + "applied_at": {"type": "string", "description": "Application timestamp"}, + "approved_at": {"description": "Approval timestamp", "nullable": true, "type": "string"} + }, + "required": [ + "guild_id", + "guild_name", + "guild_icon", + "guild_owner_id", + "guild_owner_username", + "guild_owner_global_name", + "guild_owner_discriminator", + "guild_member_count", + "guild_nsfw_level", + "guild_features", + "description", + "category_type", + "primary_language", + "custom_tags", + "applied_at", + "approved_at" + ] + }, + "DiscoveryAdminListingBulkCategoryResponse": { + "type": "object", + "properties": { + "updated": {"type": "number", "description": "Number of listings moved"}, + "failed_guild_ids": { + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "Guilds that could not be moved" + } + }, + "required": ["updated", "failed_guild_ids"] + }, + "DiscoveryAdminListingBulkCategoryRequest": { + "type": "object", + "properties": { + "guild_ids": { + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeType"}, + "minItems": 1, + "maxItems": 100, + "description": "Listed guilds to move" + }, + "category_type": { + "type": "integer", + "minimum": 0, + "maximum": 8, + "format": "int32", + "description": "Discovery category to file every named guild under" + } + }, + "required": ["guild_ids", "category_type"] + }, + "DiscoveryApplicationPatchRequest": { + "type": "object", + "properties": { + "description": { + "type": "string", + "minLength": 10, + "maxLength": 300, + "description": "Updated description for discovery listing" + }, + "category_type": { + "type": "integer", + "minimum": 0, + "maximum": 8, + "format": "int32", + "description": "Updated discovery category type" + }, + "primary_language": {"type": "string", "description": "Primary community language (BCP-47 code)"}, + "custom_tags": { + "type": "array", + "items": {"type": "string", "minLength": 2, "maxLength": 30}, + "maxItems": 10, + "description": "Up to 10 custom discovery tags" + } + } + }, + "DiscoveryAdminRemoveRequest": { + "type": "object", + "properties": {"reason": {"type": "string", "minLength": 1, "maxLength": 500, "description": "Removal reason"}}, + "required": ["reason"] + }, + "GuildMemoryStatsResponse": { + "type": "object", + "properties": { + "guilds": { + "type": "array", + "items": { + "type": "object", + "properties": { + "node_id": {"type": "string"}, + "guild_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, + "guild_name": {"type": "string"}, + "guild_icon": {"nullable": true, "type": "string"}, + "nsfw_level": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/NSFWLevel"}]}, + "memory": {"$ref": "#/components/schemas/Int64StringType"}, + "member_count": {"$ref": "#/components/schemas/Int32Type"}, + "session_count": {"$ref": "#/components/schemas/Int32Type"}, + "presence_count": {"$ref": "#/components/schemas/Int32Type"} + }, + "required": [ + "node_id", + "guild_id", + "guild_name", + "guild_icon", + "nsfw_level", + "memory", + "member_count", + "session_count", + "presence_count" + ] + }, + "maxItems": 1000 + } + }, + "required": ["guilds"] + }, + "Int64StringType": {"type": "string", "format": "int64", "pattern": "^-?[0-9]+$"}, + "ReloadAllGuildsResponse": { + "type": "object", + "properties": {"count": {"$ref": "#/components/schemas/Int32Type"}}, + "required": ["count"] + }, + "ReloadGuildsRequest": { + "type": "object", + "properties": { + "guild_ids": { + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeType"}, + "maxItems": 1000, + "description": "List of guild IDs to reload" + } + }, + "required": ["guild_ids"] + }, + "NodeStatsResponse": { + "type": "object", + "properties": { + "status": {"type": "string"}, + "sessions": {"$ref": "#/components/schemas/Int32Type"}, + "guilds": {"$ref": "#/components/schemas/Int32Type"}, + "presences": {"$ref": "#/components/schemas/Int32Type"}, + "calls": {"$ref": "#/components/schemas/Int32Type"}, + "memory": { + "type": "object", + "properties": { + "total": {"$ref": "#/components/schemas/Int64StringType"}, + "processes": {"$ref": "#/components/schemas/Int64StringType"}, + "system": {"$ref": "#/components/schemas/Int64StringType"} + }, + "required": ["total", "processes", "system"] + }, + "process_count": {"$ref": "#/components/schemas/Int32Type"}, + "process_limit": {"$ref": "#/components/schemas/Int32Type"}, + "uptime_seconds": {"$ref": "#/components/schemas/Int32Type"}, + "node_count": {"$ref": "#/components/schemas/Int32Type"}, + "nodes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "node_id": {"type": "string"}, + "status": {"type": "string"}, + "sessions": {"$ref": "#/components/schemas/Int32Type"}, + "guilds": {"$ref": "#/components/schemas/Int32Type"}, + "presences": {"$ref": "#/components/schemas/Int32Type"}, + "calls": {"$ref": "#/components/schemas/Int32Type"}, + "memory": { + "type": "object", + "properties": { + "total": {"$ref": "#/components/schemas/Int64StringType"}, + "processes": {"$ref": "#/components/schemas/Int64StringType"}, + "system": {"$ref": "#/components/schemas/Int64StringType"} + }, + "required": ["total", "processes", "system"] + }, + "process_count": {"$ref": "#/components/schemas/Int32Type"}, + "process_limit": {"$ref": "#/components/schemas/Int32Type"}, + "uptime_seconds": {"$ref": "#/components/schemas/Int32Type"} + }, + "required": [ + "node_id", + "status", + "sessions", + "guilds", + "presences", + "calls", + "memory", + "process_count", + "process_limit", + "uptime_seconds" + ] + }, + "maxItems": 1000 + } + }, + "required": [ + "status", + "sessions", + "guilds", + "presences", + "calls", + "memory", + "process_count", + "process_limit", + "uptime_seconds", + "node_count", + "nodes" + ] + }, + "GatewayVoiceStateCountsResponse": { + "type": "object", + "properties": { + "total_voice_states": {"$ref": "#/components/schemas/Int32Type"}, + "regions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "region_id": {"type": "string"}, + "voice_state_count": {"$ref": "#/components/schemas/Int32Type"} + }, + "required": ["region_id", "voice_state_count"] + }, + "maxItems": 1000 + }, + "servers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "server_id": {"type": "string"}, + "voice_state_count": {"$ref": "#/components/schemas/Int32Type"} + }, + "required": ["server_id", "voice_state_count"] + }, + "maxItems": 5000 + } + }, + "required": ["total_voice_states", "regions", "servers"] + }, + "CodesResponse": { + "type": "object", + "properties": {"codes": {"type": "array", "items": {"type": "string"}}}, + "required": ["codes"] + }, + "GenerateGiftCodesRequest": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "format": "int32", + "description": "Number of gift codes to generate" + }, + "duration_type": { + "type": "string", + "enum": ["days", "weeks", "months", "years"], + "description": "Duration unit for the generated gift codes" + }, + "duration_quantity": { + "type": "integer", + "minimum": 1, + "maximum": 3650, + "format": "int32", + "description": "Duration quantity for the selected unit. Lifetime gifts are not supported." + } + }, + "required": ["count", "duration_type", "duration_quantity"] + }, + "SearchGuildsResponse": { + "type": "object", + "properties": { + "guilds": {"type": "array", "items": {"$ref": "#/components/schemas/GuildAdminResponse"}}, + "total": {"type": "number"} + }, + "required": ["guilds", "total"] + }, + "GuildAdminResponse": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "description": "The unique identifier for this guild" + }, + "name": {"type": "string", "description": "The name of the guild"}, + "features": { + "type": "array", + "items": {"$ref": "#/components/schemas/GuildFeatureSchema"}, + "maxItems": 100, + "description": "Array of guild feature flags" + }, + "owner_id": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "description": "The ID of the guild owner"}, + "owner_username": {"description": "The username of the guild owner", "nullable": true, "type": "string"}, + "owner_global_name": { + "description": "The display name of the guild owner, if set", + "nullable": true, + "type": "string" + }, + "owner_discriminator": { + "description": "The discriminator of the guild owner", + "nullable": true, + "type": "string" + }, + "icon": {"description": "The hash of the guild icon", "nullable": true, "type": "string"}, + "banner": {"description": "The hash of the guild banner", "nullable": true, "type": "string"}, + "member_count": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647, + "format": "int32", + "description": "The number of members in the guild" + }, + "nsfw_level": {"$ref": "#/components/schemas/NSFWLevel"}, + "nsfw": {"type": "boolean", "description": "Whether the guild is flagged as adult content"}, + "content_warning_level": {"$ref": "#/components/schemas/ContentWarningLevel"}, + "content_warning_text": { + "description": "Custom content warning text shown before entry", + "nullable": true, + "type": "string" + }, + "approximate_member_count": {"$ref": "#/components/schemas/Int32Type"}, + "approximate_presence_count": {"$ref": "#/components/schemas/Int32Type"} + }, + "required": [ + "id", + "name", + "features", + "owner_id", + "owner_username", + "owner_global_name", + "owner_discriminator", + "icon", + "banner", + "member_count" + ] + }, + "LookupGuildResponse": { + "type": "object", + "properties": { + "guild": { + "nullable": true, + "type": "object", + "properties": { + "id": {"$ref": "#/components/schemas/SnowflakeType"}, + "owner_id": {"$ref": "#/components/schemas/SnowflakeType"}, + "owner_username": {"nullable": true, "type": "string"}, + "owner_global_name": {"nullable": true, "type": "string"}, + "owner_discriminator": {"nullable": true, "type": "string"}, + "name": {"type": "string"}, + "vanity_url_code": {"nullable": true, "type": "string"}, + "icon": {"nullable": true, "type": "string"}, + "banner": {"nullable": true, "type": "string"}, + "splash": {"nullable": true, "type": "string"}, + "embed_splash": {"nullable": true, "type": "string"}, + "features": {"type": "array", "items": {"type": "string"}, "maxItems": 100}, + "verification_level": {"$ref": "#/components/schemas/GuildVerificationLevel"}, + "mfa_level": {"$ref": "#/components/schemas/GuildMFALevel"}, + "nsfw_level": {"$ref": "#/components/schemas/NSFWLevel"}, + "nsfw": {"type": "boolean"}, + "content_warning_level": {"$ref": "#/components/schemas/ContentWarningLevel"}, + "content_warning_text": {"nullable": true, "type": "string"}, + "explicit_content_filter": {"$ref": "#/components/schemas/GuildExplicitContentFilter"}, + "default_message_notifications": {"$ref": "#/components/schemas/DefaultMessageNotifications"}, + "afk_channel_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, + "afk_timeout": {"$ref": "#/components/schemas/Int32Type"}, + "system_channel_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, + "system_channel_flags": {"$ref": "#/components/schemas/SystemChannelFlags"}, + "rules_channel_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, + "disabled_operations": {"$ref": "#/components/schemas/Int32Type"}, + "member_count": {"$ref": "#/components/schemas/Int32Type"}, + "channels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"$ref": "#/components/schemas/SnowflakeType"}, + "name": {"nullable": true, "type": "string"}, + "type": { + "type": "integer", + "format": "int32", + "enum": [0, 1, 2, 3, 4, 998, 999], + "description": "The type of the channel" + }, + "position": {"$ref": "#/components/schemas/Int32Type"}, + "parent_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, + "nsfw": {"nullable": true, "type": "boolean"}, + "nsfw_override": {"nullable": true, "type": "boolean"}, + "content_warning_level": {"$ref": "#/components/schemas/ContentWarningLevel"}, + "content_warning_text": {"nullable": true, "type": "string"}, + "url": {"nullable": true, "type": "string"} + }, + "required": ["id", "name", "type", "position", "parent_id", "nsfw", "url"] + } + }, + "roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"$ref": "#/components/schemas/SnowflakeType"}, + "name": {"type": "string"}, + "color": {"$ref": "#/components/schemas/Int32Type"}, + "position": {"$ref": "#/components/schemas/Int32Type"}, + "permissions": { + "type": "string", + "format": "int64", + "pattern": "^[0-9]+$", + "description": "The role permissions bitfield" + }, + "hoist": {"type": "boolean"}, + "mentionable": {"type": "boolean"} + }, + "required": ["id", "name", "color", "position", "permissions", "hoist", "mentionable"] + } + } + }, + "required": [ + "id", + "owner_id", + "owner_username", + "owner_global_name", + "owner_discriminator", + "name", + "vanity_url_code", + "icon", + "banner", + "splash", + "embed_splash", + "features", + "verification_level", + "mfa_level", + "nsfw_level", + "explicit_content_filter", + "default_message_notifications", + "afk_channel_id", + "afk_timeout", + "system_channel_id", + "system_channel_flags", + "rules_channel_id", + "disabled_operations", + "member_count", + "channels", + "roles" + ] + } + }, + "required": ["guild"] + }, + "GuildVerificationLevel": { + "type": "integer", + "format": "int32", + "enum": [0, 1, 2, 3, 4], + "description": "Required verification level for members" + }, + "GuildMFALevel": { + "type": "integer", + "format": "int32", + "enum": [0, 1], + "description": "Required MFA level for moderation actions" + }, + "GuildExplicitContentFilter": { + "type": "integer", + "format": "int32", + "enum": [0, 1, 2], + "description": "Level of content filtering for explicit media" + }, + "DefaultMessageNotifications": { + "type": "integer", + "format": "int32", + "enum": [0, 1], + "description": "Default notification level for new members" + }, + "SystemChannelFlags": { + "type": "integer", + "format": "int32", + "minimum": 0, + "maximum": 2147483647, + "description": "System channel message flags" + }, + "GuildUpdateResponse": { + "type": "object", + "properties": { + "guild": { + "type": "object", + "properties": { + "id": {"$ref": "#/components/schemas/SnowflakeType"}, + "name": {"type": "string"}, + "features": {"type": "array", "items": {"type": "string"}, "maxItems": 100}, + "owner_id": {"$ref": "#/components/schemas/SnowflakeType"}, + "icon": {"nullable": true, "type": "string"}, + "banner": {"nullable": true, "type": "string"}, + "member_count": {"$ref": "#/components/schemas/Int32Type"}, + "nsfw_level": {"$ref": "#/components/schemas/NSFWLevel"}, + "nsfw": {"type": "boolean"}, + "content_warning_level": {"$ref": "#/components/schemas/ContentWarningLevel"}, + "content_warning_text": {"nullable": true, "type": "string"} + }, + "required": ["id", "name", "features", "owner_id", "icon", "banner", "member_count"] + } + }, + "required": ["guild"] + }, + "UpdateGuildRequest": { + "type": "object", + "properties": { + "verification_level": { + "$ref": "#/components/schemas/GuildVerificationLevel", + "description": "Required verification level for guild members" + }, + "mfa_level": { + "$ref": "#/components/schemas/GuildMFALevel", + "description": "Required MFA level for moderators" + }, + "nsfw_level": {"$ref": "#/components/schemas/NSFWLevel", "description": "NSFW content level for the guild"}, + "nsfw": {"type": "boolean", "description": "Whether the guild is flagged as adult content"}, + "content_warning_level": {"$ref": "#/components/schemas/ContentWarningLevel"}, + "content_warning_text": { + "description": "Custom content warning text shown before entry", + "nullable": true, + "type": "string" + }, + "explicit_content_filter": { + "$ref": "#/components/schemas/GuildExplicitContentFilter", + "description": "Explicit content filter level" + }, + "default_message_notifications": { + "$ref": "#/components/schemas/DefaultMessageNotifications", + "description": "Default notification setting for new members" + }, + "disabled_operations": {"$ref": "#/components/schemas/GuildOperations"}, + "name": {"type": "string", "description": "New name for the guild"}, + "vanity_url_code": { + "description": "New vanity URL code, or null to remove", + "nullable": true, + "type": "string" + }, + "new_owner_id": {"$ref": "#/components/schemas/SnowflakeType"}, + "add_features": { + "type": "array", + "items": {"$ref": "#/components/schemas/GuildFeatureSchema"}, + "maxItems": 100 + }, + "remove_features": { + "type": "array", + "items": {"$ref": "#/components/schemas/GuildFeatureSchema"}, + "maxItems": 100 + }, + "fields": { + "type": "array", + "items": { + "type": "string", + "enum": ["icon", "banner", "splash", "embed_splash"], + "description": "Guild image field that can be cleared" + }, + "maxItems": 10, + "description": "List of guild image fields to clear" + } + } + }, + "GuildOperations": { + "type": "integer", + "format": "int32", + "minimum": 0, + "maximum": 2147483647, + "description": "Bitmask of disabled guild operations" + }, + "SuccessResponse": { + "type": "object", + "properties": { + "success": {"type": "boolean", "enum": [true], "description": "Whether the operation succeeded"} + }, + "required": ["success"] + }, + "AdminArchiveCreateRequest": { + "type": "object", + "properties": { + "include_attachments": { + "type": "boolean", + "description": "Whether to include attachment binaries in the archive" + } + } + }, + "PurgeGuildAssetsResponseSchema": { + "type": "object", + "properties": { + "processed": {"type": "array", "items": {"$ref": "#/components/schemas/PurgeGuildAssetResultSchema"}}, + "errors": {"type": "array", "items": {"$ref": "#/components/schemas/PurgeGuildAssetErrorSchema"}} + }, + "required": ["processed", "errors"] + }, + "PurgeGuildAssetResultSchema": { + "type": "object", + "properties": { + "id": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "description": "Unique identifier of the asset"}, + "asset_type": { + "type": "string", + "enum": ["emoji", "sticker", "unknown"], + "description": "Type of guild asset" + }, + "found_in_db": {"type": "boolean", "description": "Whether the asset was found in the database"}, + "guild_id": { + "description": "ID of the guild the asset belongs to", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] + }, + "guild_nsfw_level": { + "description": "NSFW level of the guild the asset belongs to", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/NSFWLevel"}] + } + }, + "required": ["id", "asset_type", "found_in_db", "guild_id", "guild_nsfw_level"] + }, + "PurgeGuildAssetErrorSchema": { + "type": "object", + "properties": {"id": {"$ref": "#/components/schemas/SnowflakeType"}, "error": {"type": "string"}}, + "required": ["id", "error"] + }, + "PurgeGuildAssetsRequest": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 100, + "description": "List of asset IDs to purge" + } + }, + "required": ["ids"] + }, + "ListGuildAuditLogsResponse": { + "type": "object", + "properties": { + "audit_log_entries": { + "type": "array", + "items": {"$ref": "#/components/schemas/GuildAuditLogEntryResponse"}, + "description": "Array of audit log entries" + }, + "users": { + "type": "array", + "items": {"$ref": "#/components/schemas/UserPartialResponse"}, + "description": "Users referenced in the audit log entries" + }, + "webhooks": { + "type": "array", + "items": {"$ref": "#/components/schemas/AuditLogWebhookResponse"}, + "description": "Webhooks referenced in the audit log entries" + } + }, + "required": ["audit_log_entries", "users", "webhooks"] + }, + "GuildAuditLogEntryResponse": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "description": "The unique identifier for this audit log entry" + }, + "action_type": {"$ref": "#/components/schemas/AuditLogActionType"}, + "user_id": { + "description": "The user ID of the user who performed the action", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] + }, + "target_id": { + "description": "The ID of the affected entity (user, channel, role, invite code, etc.)", + "nullable": true, + "type": "string" + }, + "reason": {"type": "string", "description": "The reason provided for the action"}, + "options": { + "type": "object", + "properties": { + "channel_id": {"type": "string", "description": "Channel ID for relevant actions"}, + "count": {"type": "number", "description": "Count of items affected"}, + "delete_member_days": { + "type": "string", + "description": "Number of days of messages to delete on member ban" + }, + "id": {"type": "string", "description": "ID of the affected entity"}, + "integration_type": {"type": "number", "description": "Type of integration"}, + "message_id": {"type": "string", "description": "Message ID for relevant actions"}, + "members_removed": {"type": "number", "description": "Number of members removed"}, + "role_name": {"type": "string", "description": "Name of the role"}, + "type": {"type": "number", "description": "Type identifier"}, + "inviter_id": {"type": "string", "description": "ID of the user who created the invite"}, + "max_age": {"type": "number", "description": "Maximum age of the invite in seconds"}, + "max_uses": {"type": "number", "description": "Maximum number of uses for the invite"}, + "temporary": {"type": "boolean", "description": "Whether the invite grants temporary membership"}, + "uses": {"type": "number", "description": "Number of times the invite has been used"} + }, + "description": "Additional options depending on action type" + }, + "changes": { + "type": "array", + "items": {"$ref": "#/components/schemas/AuditLogChangeSchema"}, + "description": "Changes made to the target" + } + }, + "required": ["id", "action_type"] + }, + "AuditLogActionType": { + "type": "integer", + "format": "int32", + "enum": [ + 1, 10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, 26, 27, 28, 30, 31, 32, 40, 41, 42, 50, 51, 52, 60, 61, 62, + 90, 91, 92, 72, 73, 74, 75 + ], + "description": "The type of action that occurred" + }, + "AuditLogChangeSchema": { + "type": "object", + "properties": { + "key": {"type": "string", "description": "The field that changed"}, + "old_value": { + "description": "Value before the change", + "nullable": true, + "oneOf": [ + {"type": "string"}, + {"type": "number"}, + {"type": "boolean"}, + {"type": "array", "items": {"type": "string"}}, + {"type": "array", "items": {"type": "number"}}, + { + "type": "object", + "properties": { + "added": {"type": "array", "items": {"type": "string"}}, + "removed": {"type": "array", "items": {"type": "string"}} + }, + "required": ["added", "removed"] + } + ] + }, + "new_value": { + "description": "Value after the change", + "nullable": true, + "oneOf": [ + {"type": "string"}, + {"type": "number"}, + {"type": "boolean"}, + {"type": "array", "items": {"type": "string"}}, + {"type": "array", "items": {"type": "number"}}, + { + "type": "object", + "properties": { + "added": {"type": "array", "items": {"type": "string"}}, + "removed": {"type": "array", "items": {"type": "string"}} + }, + "required": ["added", "removed"] + } + ] + } + }, + "required": ["key"] + }, + "AuditLogWebhookResponse": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$", + "description": "The unique identifier for this webhook" + }, + "type": {"$ref": "#/components/schemas/WebhookType"}, + "guild_id": { + "description": "The guild ID this webhook belongs to", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] + }, + "channel_id": { + "description": "The channel ID this webhook posts to", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] + }, + "name": {"type": "string", "description": "The name of the webhook"}, + "avatar_hash": {"description": "The hash of the webhook avatar", "nullable": true, "type": "string"} + }, + "required": ["id", "type", "name"] + }, + "WebhookType": {"type": "integer", "format": "int32", "enum": [1, 2], "description": "The type of webhook"}, + "BanGuildMemberBody": { + "type": "object", + "properties": { + "delete_message_days": { + "type": "integer", + "minimum": 0, + "maximum": 7, + "format": "int32", + "description": "Number of days of messages to delete from the banned user (0-7). Deprecated in favor of delete_message_seconds." + }, + "delete_message_seconds": { + "type": "integer", + "minimum": 0, + "maximum": 604800, + "format": "int32", + "description": "Number of seconds of messages to delete for the banned user (0-604800, default 0)" + }, + "reason": {"description": "The reason for the ban (max 512 characters)", "nullable": true, "type": "string"}, + "ban_duration_seconds": { + "type": "integer", + "format": "int53", + "description": "Duration of the ban in seconds (0 for permanent, or between 60 and 63072000 seconds for a temporary ban)" + } + } + }, + "ListGuildEmojisResponse": { + "type": "object", + "properties": { + "guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, + "emojis": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"$ref": "#/components/schemas/SnowflakeType"}, + "name": {"type": "string"}, + "animated": {"type": "boolean"}, + "creator_id": {"$ref": "#/components/schemas/SnowflakeType"}, + "media_url": {"type": "string"} + }, + "required": ["id", "name", "animated", "creator_id", "media_url"] + } + } + }, + "required": ["guild_id", "emojis"] + }, + "ListGuildMembersResponse": { + "type": "object", + "properties": { + "members": {"type": "array", "items": {"$ref": "#/components/schemas/GuildMemberResponse"}}, + "total": {"$ref": "#/components/schemas/Int32Type"}, + "limit": {"$ref": "#/components/schemas/Int32Type"}, + "offset": {"$ref": "#/components/schemas/Int32Type"} + }, + "required": ["members", "total", "limit", "offset"] + }, + "GuildMemberResponse": { + "type": "object", + "properties": { + "user": {"$ref": "#/components/schemas/UserPartialResponse"}, + "nick": {"description": "The nickname of the member in this guild", "nullable": true, "type": "string"}, + "avatar": {"description": "The hash of the member guild-specific avatar", "nullable": true, "type": "string"}, + "banner": {"description": "The hash of the member guild-specific banner", "nullable": true, "type": "string"}, + "accent_color": { + "description": "The accent colour of the member guild profile as an integer", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/Int32Type"}] + }, + "roles": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 250, + "description": "Array of role IDs the member has" + }, + "joined_at": { + "type": "string", + "format": "date-time", + "description": "ISO8601 timestamp of when the user joined the guild" + }, + "mute": {"type": "boolean", "description": "Whether the member is muted in voice channels"}, + "deaf": {"type": "boolean", "description": "Whether the member is deafened in voice channels"}, + "communication_disabled_until": { + "description": "ISO8601 timestamp until which the member is timed out", + "nullable": true, + "type": "string", + "format": "date-time" + }, + "profile_flags": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/GuildMemberProfileFlags"}]}, + "mention_flags": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/MentionReplyPreferences", + "description": "Per-guild reply mention preference override; NO_PREFERENCE means inherit the user-level mention_flags." + } + ] + } + }, + "required": ["user", "roles", "joined_at", "mute", "deaf"] + }, + "GuildMemberProfileFlags": { + "type": "integer", + "format": "int32", + "minimum": 0, + "maximum": 2147483647, + "description": "Member profile flags" + }, + "ListGuildStickersResponse": { + "type": "object", + "properties": { + "guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, + "stickers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"$ref": "#/components/schemas/SnowflakeType"}, + "name": {"type": "string"}, + "animated": {"type": "boolean"}, + "creator_id": {"$ref": "#/components/schemas/SnowflakeType"}, + "media_url": {"type": "string"} + }, + "required": ["id", "name", "animated", "creator_id", "media_url"] + } + } + }, + "required": ["guild_id", "stickers"] + }, + "InstanceConfigResponse": { + "type": "object", + "properties": { + "sso": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "enforced": {"type": "boolean"}, + "display_name": {"nullable": true, "type": "string"}, + "issuer": {"nullable": true, "type": "string"}, + "authorization_url": {"nullable": true, "type": "string"}, + "token_url": {"nullable": true, "type": "string"}, + "userinfo_url": {"nullable": true, "type": "string"}, + "jwks_url": {"nullable": true, "type": "string"}, + "client_id": {"nullable": true, "type": "string"}, + "client_secret_set": {"type": "boolean"}, + "scope": {"nullable": true, "type": "string"}, + "allowed_domains": {"type": "array", "items": {"type": "string"}, "maxItems": 100}, + "auto_provision": {"type": "boolean"}, + "redirect_uri": {"nullable": true, "type": "string"} + }, + "required": [ + "enabled", + "enforced", + "display_name", + "issuer", + "authorization_url", + "token_url", + "userinfo_url", + "jwks_url", + "client_id", + "client_secret_set", + "scope", + "allowed_domains", + "auto_provision", + "redirect_uri" + ] + }, + "gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigSchema"}, + "registration": { + "type": "object", + "properties": { + "mode": {"type": "string", "enum": ["open", "approval", "closed"], "description": "Registration mode"}, + "admin_registration_urls_enabled": {"type": "boolean"}, + "urls": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "label": {"nullable": true, "type": "string"}, + "created_by_user_id": {"$ref": "#/components/schemas/SnowflakeType"}, + "created_at": {"type": "string", "format": "date-time"}, + "expires_at": {"nullable": true, "type": "string", "format": "date-time"}, + "max_uses": { + "nullable": true, + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "format": "int53" + }, + "use_count": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"}, + "revoked_at": {"nullable": true, "type": "string", "format": "date-time"}, + "approval_required": {"type": "boolean"}, + "last_used_at": {"nullable": true, "type": "string", "format": "date-time"}, + "last_used_by_user_id": { + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] + } + }, + "required": [ + "id", + "label", + "created_by_user_id", + "created_at", + "expires_at", + "max_uses", + "use_count", + "revoked_at", + "approval_required", + "last_used_at", + "last_used_by_user_id" + ] + } + }, + "pending_registrations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, + "username": {"type": "string"}, + "discriminator": {"type": "integer", "minimum": 0, "maximum": 9999, "format": "int32"}, + "global_name": {"nullable": true, "type": "string"}, + "email": {"nullable": true, "type": "string"}, + "requested_at": {"type": "string", "format": "date-time"}, + "registration_url_id": {"nullable": true, "type": "string"}, + "client_ip": {"nullable": true, "type": "string"} + }, + "required": [ + "user_id", + "username", + "discriminator", + "global_name", + "email", + "requested_at", + "registration_url_id", + "client_ip" + ] + } + } + }, + "required": ["mode", "admin_registration_urls_enabled", "urls", "pending_registrations"] + }, + "self_hosted": {"type": "boolean"}, + "app_public": { + "type": "object", + "properties": { + "branding": { + "type": "object", + "properties": { + "product_name": {"type": "string"}, + "icon_url": {"nullable": true, "type": "string"}, + "symbol_url": {"nullable": true, "type": "string"}, + "logo_url": {"nullable": true, "type": "string"}, + "wordmark_url": {"nullable": true, "type": "string"}, + "favicon_url": {"nullable": true, "type": "string"}, + "theme_color": {"nullable": true, "type": "string"} + }, + "required": [ + "product_name", + "icon_url", + "symbol_url", + "logo_url", + "wordmark_url", + "favicon_url", + "theme_color" + ] + }, + "setup": { + "type": "object", + "properties": {"configured": {"type": "boolean"}}, + "required": ["configured"] + }, + "legal": { + "type": "object", + "properties": { + "terms_url": {"nullable": true, "type": "string"}, + "privacy_url": {"nullable": true, "type": "string"} + }, + "required": ["terms_url", "privacy_url"] + }, + "registration": { + "type": "object", + "properties": {"collect_date_of_birth": {"type": "boolean"}}, + "required": ["collect_date_of_birth"] + } + }, + "required": ["branding", "setup", "legal", "registration"] + }, + "policy": { + "type": "object", + "properties": { + "single_community_enabled": {"type": "boolean"}, + "single_community_guild_id": {"nullable": true, "type": "string"}, + "direct_messages_disabled": {"type": "boolean"}, + "direct_messages_locked": {"type": "boolean"}, + "premium_mode": {"enum": ["mirror", "everyone"], "type": "string"}, + "services": { + "type": "object", + "properties": { + "gif_enabled": {"nullable": true, "type": "boolean"}, + "youtube_enabled": {"nullable": true, "type": "boolean"}, + "bluesky_enabled": {"nullable": true, "type": "boolean"} + }, + "required": ["gif_enabled", "youtube_enabled", "bluesky_enabled"] + }, + "services_resolved": { + "type": "object", + "properties": { + "gif_enabled": {"type": "boolean"}, + "youtube_enabled": {"type": "boolean"}, + "bluesky_enabled": {"type": "boolean"} + }, + "required": ["gif_enabled", "youtube_enabled", "bluesky_enabled"] + }, + "services_available": { + "type": "object", + "properties": { + "gif": {"type": "boolean"}, + "youtube": {"type": "boolean"}, + "bluesky": {"type": "boolean"} + }, + "required": ["gif", "youtube", "bluesky"] + }, + "deferred_phone_gate": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "window_hours": {"type": "number"}, + "member_threshold": {"type": "number"} + }, + "required": ["enabled", "window_hours", "member_threshold"] + } + }, + "required": [ + "single_community_enabled", + "single_community_guild_id", + "direct_messages_disabled", + "direct_messages_locked", + "premium_mode", + "services", + "services_resolved", + "services_available", + "deferred_phone_gate" + ] + }, + "integrations": { + "type": "object", + "properties": { + "gif": { + "type": "object", + "properties": {"klipy_api_key_set": {"type": "boolean"}, "effective_available": {"type": "boolean"}}, + "required": ["klipy_api_key_set", "effective_available"] + }, + "youtube": { + "type": "object", + "properties": {"api_key_set": {"type": "boolean"}, "effective_available": {"type": "boolean"}}, + "required": ["api_key_set", "effective_available"] + }, + "captcha": { + "type": "object", + "properties": { + "provider": {"nullable": true, "enum": ["hcaptcha", "turnstile", "none"], "type": "string"}, + "effective_provider": {"enum": ["hcaptcha", "turnstile", "none"], "type": "string"}, + "hcaptcha_site_key": {"nullable": true, "type": "string"}, + "hcaptcha_secret_key_set": {"type": "boolean"}, + "turnstile_site_key": {"nullable": true, "type": "string"}, + "turnstile_secret_key_set": {"type": "boolean"}, + "effective_enabled": {"type": "boolean"} + }, + "required": [ + "provider", + "effective_provider", + "hcaptcha_site_key", + "hcaptcha_secret_key_set", + "turnstile_site_key", + "turnstile_secret_key_set", + "effective_enabled" + ] + }, + "email": { + "type": "object", + "properties": { + "enabled": {"nullable": true, "type": "boolean"}, + "effective_enabled": {"type": "boolean"}, + "provider": {"nullable": true, "enum": ["smtp", "none"], "type": "string"}, + "effective_provider": {"enum": ["smtp", "none"], "type": "string"}, + "from_email": {"nullable": true, "type": "string"}, + "from_name": {"nullable": true, "type": "string"}, + "smtp": { + "type": "object", + "properties": { + "host": {"nullable": true, "type": "string"}, + "port": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 65535, "format": "int32"}, + "username": {"nullable": true, "type": "string"}, + "password_set": {"type": "boolean"}, + "secure": {"nullable": true, "type": "boolean"} + }, + "required": ["host", "port", "username", "password_set", "secure"] + }, + "disable_new_ip_authorization": {"type": "boolean"}, + "effective_disable_new_ip_authorization": {"type": "boolean"} + }, + "required": [ + "enabled", + "effective_enabled", + "provider", + "effective_provider", + "from_email", + "from_name", + "smtp", + "disable_new_ip_authorization", + "effective_disable_new_ip_authorization" + ] + }, + "bluesky": { + "type": "object", + "properties": { + "enabled": {"nullable": true, "type": "boolean"}, + "effective_enabled": {"type": "boolean"}, + "client_name": {"nullable": true, "type": "string"}, + "client_uri": {"nullable": true, "type": "string"}, + "logo_uri": {"nullable": true, "type": "string"}, + "tos_uri": {"nullable": true, "type": "string"}, + "policy_uri": {"nullable": true, "type": "string"}, + "key_count": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"} + }, + "required": [ + "enabled", + "effective_enabled", + "client_name", + "client_uri", + "logo_uri", + "tos_uri", + "policy_uri", + "key_count" + ] + } + }, + "required": ["gif", "youtube", "captcha", "email", "bluesky"] + }, + "media": { + "type": "object", + "properties": { + "attachment_decay": { + "type": "object", + "properties": { + "enabled": {"nullable": true, "type": "boolean"}, + "min_size_mb": {"nullable": true, "type": "number", "minimum": 0, "exclusiveMinimum": true}, + "max_size_mb": {"nullable": true, "type": "number", "minimum": 0, "exclusiveMinimum": true}, + "max_eligible_size_mb": {"nullable": true, "type": "number", "minimum": 0, "exclusiveMinimum": true}, + "min_lifetime_days": { + "nullable": true, + "type": "integer", + "maximum": 9007199254740991, + "format": "int53", + "minimum": 0, + "exclusiveMinimum": true + }, + "max_lifetime_days": { + "nullable": true, + "type": "integer", + "maximum": 9007199254740991, + "format": "int53", + "minimum": 0, + "exclusiveMinimum": true + }, + "curve": {"nullable": true, "type": "number", "minimum": 0, "maximum": 1}, + "renew_threshold_days": { + "nullable": true, + "type": "integer", + "maximum": 9007199254740991, + "format": "int53", + "minimum": 0, + "exclusiveMinimum": true + }, + "renew_window_days": { + "nullable": true, + "type": "integer", + "maximum": 9007199254740991, + "format": "int53", + "minimum": 0, + "exclusiveMinimum": true + }, + "effective": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "min_size_mb": {"type": "number", "minimum": 0, "exclusiveMinimum": true}, + "max_size_mb": {"type": "number", "minimum": 0, "exclusiveMinimum": true}, + "max_eligible_size_mb": {"type": "number", "minimum": 0, "exclusiveMinimum": true}, + "min_lifetime_days": { + "type": "integer", + "maximum": 9007199254740991, + "format": "int53", + "minimum": 0, + "exclusiveMinimum": true + }, + "max_lifetime_days": { + "type": "integer", + "maximum": 9007199254740991, + "format": "int53", + "minimum": 0, + "exclusiveMinimum": true + }, + "curve": {"type": "number", "minimum": 0, "maximum": 1}, + "renew_threshold_days": { + "type": "integer", + "maximum": 9007199254740991, + "format": "int53", + "minimum": 0, + "exclusiveMinimum": true + }, + "renew_window_days": { + "type": "integer", + "maximum": 9007199254740991, + "format": "int53", + "minimum": 0, + "exclusiveMinimum": true + } + }, + "required": [ + "enabled", + "min_size_mb", + "max_size_mb", + "max_eligible_size_mb", + "min_lifetime_days", + "max_lifetime_days", + "curve", + "renew_threshold_days", + "renew_window_days" + ] + } + }, + "required": [ + "enabled", + "min_size_mb", + "max_size_mb", + "max_eligible_size_mb", + "min_lifetime_days", + "max_lifetime_days", + "curve", + "renew_threshold_days", + "renew_window_days", + "effective" + ] + } + }, + "required": ["attachment_decay"] + } + }, + "required": [ + "sso", + "gateway_rollout", + "registration", + "self_hosted", + "app_public", + "policy", + "integrations", + "media" + ] + }, + "GatewayRolloutConfigSchema": { + "type": "object", + "properties": { + "session_rollout_percentage": {"type": "number", "minimum": 0, "maximum": 100}, + "session_rollout_mode": {"enum": ["modulo", "random"], "type": "string"}, + "guild_rollout_percentage": {"type": "number", "minimum": 0, "maximum": 100}, + "rpc_request_timeout_ms": {"type": "integer", "minimum": 1000, "maximum": 60000, "format": "int32"}, + "max_concurrent_session_starts": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"}, + "max_concurrent_guild_starts": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"}, + "gateway_dispatch_relay_shards": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"}, + "gateway_dispatch_relay_max_queue": {"type": "integer", "minimum": 0, "maximum": 1000000, "format": "int32"}, + "voice_e2ee_scope": {"enum": ["guild_feature_only", "platform_wide"], "type": "string"}, + "voice_reconciliation_v3_percentage": {"type": "number", "minimum": 0, "maximum": 100}, + "voice_reconciliation_v3_interval_ms": { + "type": "integer", + "minimum": 500, + "maximum": 60000, + "format": "int32" + } + } + }, + "InstanceConfigUpdateRequest": { + "type": "object", + "properties": { + "gateway_rollout": { + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/GatewayRolloutConfigUpdateRequest"}] + }, + "registration": { + "nullable": true, + "type": "object", + "properties": { + "mode": {"type": "string", "enum": ["open", "approval", "closed"], "description": "Registration mode"}, + "admin_registration_urls_enabled": {"type": "boolean"} + } + }, + "sso": { + "nullable": true, + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "enforced": {"type": "boolean"}, + "display_name": {"nullable": true, "type": "string"}, + "issuer": {"nullable": true, "type": "string"}, + "authorization_url": {"nullable": true, "type": "string"}, + "token_url": {"nullable": true, "type": "string"}, + "userinfo_url": {"nullable": true, "type": "string"}, + "jwks_url": {"nullable": true, "type": "string"}, + "client_id": {"nullable": true, "type": "string"}, + "client_secret": {"nullable": true, "type": "string"}, + "scope": {"nullable": true, "type": "string"}, + "allowed_domains": {"type": "array", "items": {"type": "string"}, "maxItems": 100}, + "auto_provision": {"type": "boolean"} + } + }, + "app_public": { + "nullable": true, + "type": "object", + "properties": { + "branding": { + "nullable": true, + "type": "object", + "properties": { + "product_name": {"type": "string", "minLength": 1, "maxLength": 80}, + "icon_url": {"nullable": true, "type": "string", "maxLength": 2048}, + "symbol_url": {"nullable": true, "type": "string", "maxLength": 2048}, + "logo_url": {"nullable": true, "type": "string", "maxLength": 2048}, + "wordmark_url": {"nullable": true, "type": "string", "maxLength": 2048}, + "favicon_url": {"nullable": true, "type": "string", "maxLength": 2048}, + "theme_color": {"nullable": true, "type": "string", "maxLength": 64} + } + }, + "setup": {"nullable": true, "type": "object", "properties": {"configured": {"type": "boolean"}}}, + "legal": { + "nullable": true, + "type": "object", + "properties": { + "terms_url": {"nullable": true, "type": "string", "maxLength": 2048}, + "privacy_url": {"nullable": true, "type": "string", "maxLength": 2048} + } + }, + "registration": { + "nullable": true, + "type": "object", + "properties": {"collect_date_of_birth": {"type": "boolean"}} + } + } + }, + "integrations": { + "nullable": true, + "type": "object", + "properties": { + "gif": { + "nullable": true, + "type": "object", + "properties": {"klipy_api_key": {"nullable": true, "type": "string", "maxLength": 4096}} + }, + "youtube": { + "nullable": true, + "type": "object", + "properties": {"api_key": {"nullable": true, "type": "string", "maxLength": 4096}} + }, + "captcha": { + "nullable": true, + "type": "object", + "properties": { + "provider": {"nullable": true, "enum": ["hcaptcha", "turnstile", "none"], "type": "string"}, + "hcaptcha_site_key": {"nullable": true, "type": "string", "maxLength": 4096}, + "hcaptcha_secret_key": {"nullable": true, "type": "string", "maxLength": 4096}, + "turnstile_site_key": {"nullable": true, "type": "string", "maxLength": 4096}, + "turnstile_secret_key": {"nullable": true, "type": "string", "maxLength": 4096} + } + }, + "email": { + "nullable": true, + "type": "object", + "properties": { + "enabled": {"nullable": true, "type": "boolean"}, + "provider": {"nullable": true, "enum": ["smtp", "none"], "type": "string"}, + "from_email": {"nullable": true, "type": "string", "maxLength": 320}, + "from_name": {"nullable": true, "type": "string", "maxLength": 120}, + "smtp": { + "nullable": true, + "type": "object", + "properties": { + "host": {"nullable": true, "type": "string", "maxLength": 255}, + "port": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 65535, "format": "int32"}, + "username": {"nullable": true, "type": "string", "maxLength": 320}, + "password": {"nullable": true, "type": "string", "maxLength": 4096}, + "secure": {"nullable": true, "type": "boolean"} + } + }, + "disable_new_ip_authorization": {"nullable": true, "type": "boolean"} + } + }, + "bluesky": { + "nullable": true, + "type": "object", + "properties": { + "enabled": {"nullable": true, "type": "boolean"}, + "client_name": {"nullable": true, "type": "string", "maxLength": 120}, + "client_uri": {"nullable": true, "type": "string", "maxLength": 2048}, + "logo_uri": {"nullable": true, "type": "string", "maxLength": 2048}, + "tos_uri": {"nullable": true, "type": "string", "maxLength": 2048}, + "policy_uri": {"nullable": true, "type": "string", "maxLength": 2048}, + "keys": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kid": {"type": "string", "minLength": 1, "maxLength": 255}, + "private_key": {"nullable": true, "type": "string", "maxLength": 10000} + }, + "required": ["kid"] + }, + "maxItems": 8 + } + } + } + } + }, + "media": { + "nullable": true, + "type": "object", + "properties": { + "attachment_decay": { + "nullable": true, + "type": "object", + "properties": { + "enabled": {"nullable": true, "type": "boolean"}, + "min_size_mb": {"nullable": true, "type": "number", "minimum": 0, "exclusiveMinimum": true}, + "max_size_mb": {"nullable": true, "type": "number", "minimum": 0, "exclusiveMinimum": true}, + "max_eligible_size_mb": {"nullable": true, "type": "number", "minimum": 0, "exclusiveMinimum": true}, + "min_lifetime_days": { + "nullable": true, + "type": "integer", + "maximum": 9007199254740991, + "format": "int53", + "minimum": 0, + "exclusiveMinimum": true + }, + "max_lifetime_days": { + "nullable": true, + "type": "integer", + "maximum": 9007199254740991, + "format": "int53", + "minimum": 0, + "exclusiveMinimum": true + }, + "curve": {"nullable": true, "type": "number", "minimum": 0, "maximum": 1}, + "renew_threshold_days": { + "nullable": true, + "type": "integer", + "maximum": 9007199254740991, + "format": "int53", + "minimum": 0, + "exclusiveMinimum": true + }, + "renew_window_days": { + "nullable": true, + "type": "integer", + "maximum": 9007199254740991, + "format": "int53", + "minimum": 0, + "exclusiveMinimum": true + } + } + } + } + }, + "policy": { + "nullable": true, + "type": "object", + "properties": { + "single_community_enabled": {"type": "boolean"}, + "single_community_name": {"type": "string", "minLength": 1, "maxLength": 100}, + "direct_messages_disabled": {"type": "boolean"}, + "direct_messages_locked": {"type": "boolean", "enum": [false]}, + "premium_mode": {"enum": ["mirror", "everyone"], "type": "string"}, + "services": { + "nullable": true, + "type": "object", + "properties": { + "gif_enabled": {"nullable": true, "type": "boolean"}, + "youtube_enabled": {"nullable": true, "type": "boolean"}, + "bluesky_enabled": {"nullable": true, "type": "boolean"} + } + }, + "deferred_phone_gate": { + "nullable": true, + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "window_hours": {"type": "number", "maximum": 8760, "minimum": 0, "exclusiveMinimum": true}, + "member_threshold": { + "type": "integer", + "maximum": 1000000, + "format": "int32", + "minimum": 0, + "exclusiveMinimum": true + } + } + } + } + } + } + }, + "GatewayRolloutConfigUpdateRequest": { + "type": "object", + "properties": { + "session_rollout_percentage": {"type": "number", "minimum": 0, "maximum": 100}, + "session_rollout_mode": {"enum": ["modulo", "random"], "type": "string"}, + "guild_rollout_percentage": {"type": "number", "minimum": 0, "maximum": 100}, + "rpc_request_timeout_ms": {"type": "integer", "minimum": 1000, "maximum": 60000, "format": "int32"}, + "max_concurrent_session_starts": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"}, + "max_concurrent_guild_starts": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"}, + "gateway_dispatch_relay_shards": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"}, + "gateway_dispatch_relay_max_queue": {"type": "integer", "minimum": 0, "maximum": 1000000, "format": "int32"}, + "voice_e2ee_scope": {"enum": ["guild_feature_only", "platform_wide"], "type": "string"}, + "voice_reconciliation_v3_percentage": {"type": "number", "minimum": 0, "maximum": 100}, + "voice_reconciliation_v3_interval_ms": { + "type": "integer", + "minimum": 500, + "maximum": 60000, + "format": "int32" + } + } + }, + "BrandingAssetUploadRequest": { + "type": "object", + "properties": { + "kind": {"enum": ["icon", "symbol", "logo", "wordmark", "favicon"], "type": "string"}, + "image": {"nullable": true, "type": "string", "maxLength": 16000000} + }, + "required": ["kind"] + }, + "InstanceEmailSmtpTestResponse": { + "type": "object", + "properties": {"ok": {"type": "boolean"}, "error": {"nullable": true, "type": "string"}}, + "required": ["ok", "error"] + }, + "InstanceEmailSmtpTestRequest": { + "type": "object", + "properties": { + "host": {"type": "string", "minLength": 1, "maxLength": 255}, + "port": {"type": "integer", "minimum": 1, "maximum": 65535, "format": "int32"}, + "username": {"type": "string", "minLength": 1, "maxLength": 320}, + "password": {"type": "string", "minLength": 1, "maxLength": 4096}, + "secure": {"type": "boolean"} + }, + "required": ["host", "port", "username", "password"] + }, + "PendingRegistrationActionRequest": { + "type": "object", + "properties": { + "status": {"type": "string", "enum": ["approved", "rejected"], "description": "Pending registration decision"} + }, + "required": ["status"] + }, + "CreateRegistrationUrlResponse": { + "type": "object", + "properties": { + "registration_url": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "label": {"nullable": true, "type": "string"}, + "created_by_user_id": {"$ref": "#/components/schemas/SnowflakeType"}, + "created_at": {"type": "string", "format": "date-time"}, + "expires_at": {"nullable": true, "type": "string", "format": "date-time"}, + "max_uses": { + "nullable": true, + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "format": "int53" + }, + "use_count": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"}, + "revoked_at": {"nullable": true, "type": "string", "format": "date-time"}, + "approval_required": {"type": "boolean"}, + "last_used_at": {"nullable": true, "type": "string", "format": "date-time"}, + "last_used_by_user_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]} + }, + "required": [ + "id", + "label", + "created_by_user_id", + "created_at", + "expires_at", + "max_uses", + "use_count", + "revoked_at", + "approval_required", + "last_used_at", + "last_used_by_user_id" + ] + }, + "code": {"type": "string"}, + "url": {"type": "string"} + }, + "required": ["registration_url", "code", "url"] + }, + "CreateRegistrationUrlRequest": { + "type": "object", + "properties": { + "label": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 120}, + "expires_at": {"nullable": true, "type": "string", "format": "date-time"}, + "max_uses": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 1000000, "format": "int32"}, + "approval_required": {"type": "boolean"} + } + }, + "ListJobsResponseSchema": { + "type": "object", + "properties": { + "jobs": {"type": "array", "items": {"$ref": "#/components/schemas/JobLedgerEntrySchema"}}, + "next_cursor": { + "nullable": true, + "type": "object", + "properties": { + "bucket_day": {"type": "string"}, + "created_at": {"type": "string"}, + "job_id": {"$ref": "#/components/schemas/SnowflakeType"} + }, + "required": ["bucket_day", "created_at", "job_id"] + } + }, + "required": ["jobs", "next_cursor"] + }, + "JobLedgerEntrySchema": { + "type": "object", + "properties": { + "job_id": {"$ref": "#/components/schemas/SnowflakeType"}, + "task_type": {"type": "string"}, + "status": {"enum": ["queued", "running", "succeeded", "cancelled", "deadletter"], "type": "string"}, + "progress_current": {"nullable": true, "type": "number"}, + "progress_total": {"nullable": true, "type": "number"}, + "progress_message": {"nullable": true, "type": "string"}, + "error_message": {"nullable": true, "type": "string"}, + "created_at": {"type": "string", "description": "ISO 8601"}, + "started_at": {"nullable": true, "type": "string"}, + "completed_at": {"nullable": true, "type": "string"}, + "requested_by_user_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, + "audit_log_reason": {"nullable": true, "type": "string"}, + "jet_stream_lane": {"nullable": true, "type": "string"}, + "jet_stream_seq": {"nullable": true, "type": "string"}, + "attempts": {"type": "integer", "format": "int53"}, + "max_attempts": {"type": "integer", "format": "int53"}, + "run_at": {"nullable": true, "type": "string"}, + "cancel_requested": {"type": "boolean"}, + "context_link": {"nullable": true, "type": "string"}, + "payload": {"description": "JSON-encoded original payload", "nullable": true, "type": "string"}, + "result": {"description": "JSON-encoded result, if any", "nullable": true, "type": "string"} + }, + "required": [ + "job_id", + "task_type", + "status", + "progress_current", + "progress_total", + "progress_message", + "error_message", + "created_at", + "started_at", + "completed_at", + "requested_by_user_id", + "audit_log_reason", + "jet_stream_lane", + "jet_stream_seq", + "attempts", + "max_attempts", + "run_at", + "cancel_requested", + "context_link", + "payload", + "result" + ] + }, + "ActiveJobsResponseSchema": { + "type": "object", + "properties": {"jobs": {"type": "array", "items": {"$ref": "#/components/schemas/JobLedgerEntrySchema"}}}, + "required": ["jobs"] + }, + "GetJobResponseSchema": { + "type": "object", + "properties": {"job": {"$ref": "#/components/schemas/JobLedgerEntrySchema"}}, + "required": ["job"] + }, + "CancelJobResponseSchema": { + "type": "object", + "properties": { + "cancelled": { + "type": "boolean", + "description": "True if a cancel request was recorded; false if the job was already terminal." + } + }, + "required": ["cancelled"] + }, + "LimitConfigGetResponse": { + "type": "object", + "properties": { + "limit_config": { + "type": "object", + "properties": { + "traitDefinitions": {"type": "array", "items": {"type": "string"}}, + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1, "description": "Unique rule identifier"}, + "filters": { + "type": "object", + "properties": { + "traits": { + "type": "array", + "items": {"type": "string"}, + "description": "Trait filters that must match for the rule to apply" + }, + "guildFeatures": { + "type": "array", + "items": {"type": "string"}, + "description": "Guild feature flags required for the rule to apply" + } + }, + "description": "Optional filters that scope the rule" + }, + "limits": { + "type": "object", + "additionalProperties": {"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}, + "description": "Per-limit key values" + }, + "modifiedFields": {"type": "array", "items": {"type": "string"}} + }, + "required": ["id", "limits"] + } + } + }, + "required": ["traitDefinitions", "rules"] + }, + "limit_config_json": {"type": "string"}, + "self_hosted": {"type": "boolean"}, + "defaults": { + "type": "object", + "additionalProperties": {"type": "object", "additionalProperties": {"type": "number"}} + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "key": {"type": "string"}, + "label": {"type": "string"}, + "description": {"type": "string"}, + "category": {"type": "string"}, + "scope": {"type": "string"}, + "isToggle": {"type": "boolean"}, + "unit": {"enum": ["bytes", "count"], "type": "string"}, + "min": {"type": "number"}, + "max": {"type": "number"} + }, + "required": ["key", "label", "description", "category", "scope", "isToggle"] + } + }, + "categories": {"type": "object", "additionalProperties": {"type": "string"}}, + "limit_keys": {"type": "array", "items": {"type": "string"}}, + "bounds": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": {"min": {"type": "number"}, "max": {"type": "number"}}, + "required": ["min", "max"] + } + } + }, + "required": [ + "limit_config", + "limit_config_json", + "self_hosted", + "defaults", + "metadata", + "categories", + "limit_keys" + ] + }, + "LimitConfigUpdateRequest": { + "type": "object", + "properties": { + "limit_config": { + "type": "object", + "properties": { + "traitDefinitions": { + "type": "array", + "items": {"type": "string"}, + "description": "Trait definitions used by rules" + }, + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1, "description": "Unique rule identifier"}, + "filters": { + "type": "object", + "properties": { + "traits": { + "type": "array", + "items": {"type": "string"}, + "description": "Trait filters that must match for the rule to apply" + }, + "guildFeatures": { + "type": "array", + "items": {"type": "string"}, + "description": "Guild feature flags required for the rule to apply" + } + }, + "description": "Optional filters that scope the rule" + }, + "limits": { + "type": "object", + "additionalProperties": {"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}, + "description": "Per-limit key values" + } + }, + "required": ["id", "limits"] + }, + "description": "Limit rules" + } + }, + "required": ["rules"], + "description": "New limit configuration snapshot" + } + }, + "required": ["limit_config"] + }, + "AdminMessageSearchResponse": { + "oneOf": [ + { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": {"$ref": "#/components/schemas/AdminMessageSchema"}, + "maxItems": 100 + }, + "message_responses": { + "type": "array", + "items": {"$ref": "#/components/schemas/MessageResponseSchema"}, + "maxItems": 100 + }, + "total": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"} + }, + "required": ["messages", "total"] + }, + {"$ref": "#/components/schemas/LookupMessageResponse"} + ] }, "NcmecAttachmentSubmitResultResponse": { "type": "object", @@ -13096,57 +12894,6 @@ }, "required": ["channel_id", "message_id", "attachment_id", "filename", "reporter_full_name", "confirmed_viewed"] }, - "SearchChannelMessagesResponse": { - "type": "object", - "properties": { - "messages": {"type": "array", "items": {"$ref": "#/components/schemas/AdminMessageSchema"}, "maxItems": 100}, - "message_responses": { - "type": "array", - "items": {"$ref": "#/components/schemas/MessageResponseSchema"}, - "maxItems": 100 - }, - "total": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"} - }, - "required": ["messages", "total"] - }, - "SearchChannelMessagesRequest": { - "type": "object", - "properties": { - "channel_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "query": {"type": "string", "minLength": 1, "maxLength": 200}, - "limit": {"type": "integer", "minimum": 1, "maximum": 100, "format": "int32"} - }, - "required": ["channel_id", "query"] - }, - "MessageShredResponse": { - "type": "object", - "properties": { - "success": {"type": "boolean", "enum": [true]}, - "job_id": {"type": "string"}, - "requested": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"} - }, - "required": ["success", "job_id"] - }, - "MessageShredRequest": { - "type": "object", - "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "entries": { - "type": "array", - "items": { - "type": "object", - "properties": { - "channel_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "message_id": {"$ref": "#/components/schemas/SnowflakeType"} - }, - "required": ["channel_id", "message_id"] - }, - "minItems": 1, - "maxItems": 1000 - } - }, - "required": ["user_id", "entries"] - }, "MessageShredStatusResponse": { "oneOf": [ { @@ -13175,17 +12922,26 @@ } ] }, - "MessageShredStatusRequest": { - "type": "object", - "properties": {"job_id": {"type": "string"}}, - "required": ["job_id"] - }, - "ListReportsResponse": { - "type": "object", - "properties": { - "reports": {"type": "array", "items": {"$ref": "#/components/schemas/ReportAdminResponseSchema"}} - }, - "required": ["reports"] + "AdminReportListResponse": { + "oneOf": [ + { + "type": "object", + "properties": { + "reports": {"type": "array", "items": {"$ref": "#/components/schemas/ReportAdminResponseSchema"}}, + "total": {"type": "number"}, + "offset": {"type": "number"}, + "limit": {"type": "number"} + }, + "required": ["reports", "total", "offset", "limit"] + }, + { + "type": "object", + "properties": { + "reports": {"type": "array", "items": {"$ref": "#/components/schemas/ReportAdminResponseSchema"}} + }, + "required": ["reports"] + } + ] }, "ReportAdminResponseSchema": { "type": "object", @@ -13370,26 +13126,6 @@ "enum": [0, 1, 2], "description": "The type of entity being reported" }, - "ListReportsRequest": { - "type": "object", - "properties": { - "status": {"$ref": "#/components/schemas/ReportStatus"}, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "format": "int32", - "description": "Maximum number of reports to return" - }, - "offset": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "format": "int53", - "description": "Number of reports to skip" - } - } - }, "ResolveReportResponse": { "type": "object", "properties": { @@ -13400,85 +13136,13 @@ }, "required": ["report_id", "status", "resolved_at", "public_comment"] }, - "ResolveReportRequest": { + "UpdateReportRequest": { "type": "object", "properties": { - "report_id": {"$ref": "#/components/schemas/SnowflakeType"}, + "status": {"type": "string", "enum": ["resolved"], "description": "The status to move the report to"}, "public_comment": {"type": "string", "description": "Public comment to include with the resolution"} }, - "required": ["report_id"] - }, - "SearchReportsResponse": { - "type": "object", - "properties": { - "reports": {"type": "array", "items": {"$ref": "#/components/schemas/ReportAdminResponseSchema"}}, - "total": {"type": "number"}, - "offset": {"type": "number"}, - "limit": {"type": "number"} - }, - "required": ["reports", "total", "offset", "limit"] - }, - "SearchReportsRequest": { - "type": "object", - "properties": { - "query": {"type": "string", "description": "Search query string"}, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "format": "int32", - "description": "Maximum number of entries to return" - }, - "offset": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "format": "int53", - "description": "Number of entries to skip" - }, - "reporter_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "status": {"$ref": "#/components/schemas/ReportStatus"}, - "report_type": {"$ref": "#/components/schemas/ReportType"}, - "category": {"type": "string", "description": "Filter by report category"}, - "reported_user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "reported_guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "reported_channel_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "guild_context_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "resolved_by_admin_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "sort_by": { - "type": "string", - "enum": ["createdAt", "reportedAt", "resolvedAt"], - "description": "Field to sort reports by" - }, - "sort_order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort order direction"} - } - }, - "RefreshSearchIndexResponse": { - "type": "object", - "properties": {"success": {"type": "boolean", "enum": [true]}, "job_id": {"type": "string"}}, - "required": ["success", "job_id"] - }, - "RefreshSearchIndexRequest": { - "type": "object", - "properties": { - "index_type": { - "type": "string", - "enum": [ - "guilds", - "users", - "reports", - "audit_logs", - "channel_messages", - "guild_members", - "favorite_memes", - "discovery" - ], - "description": "Type of search index to refresh" - }, - "guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "user_id": {"$ref": "#/components/schemas/SnowflakeType"} - }, - "required": ["index_type"] + "required": ["status"] }, "IndexRefreshStatusResponse": { "oneOf": [ @@ -13507,23 +13171,17 @@ } ] }, - "GetIndexRefreshStatusRequest": { + "RefreshSearchIndexResponse": { "type": "object", - "properties": {"job_id": {"type": "string", "description": "ID of the index refresh job to check"}}, - "required": ["job_id"] + "properties": {"success": {"type": "boolean", "enum": [true]}, "job_id": {"type": "string"}}, + "required": ["success", "job_id"] }, - "SuspiciousEmailDomainRequest": { + "RefreshSearchIndexRequest": { "type": "object", "properties": { - "domain": { - "type": "string", - "minLength": 1, - "maxLength": 253, - "pattern": "^[a-zA-Z0-9][a-zA-Z0-9\\-.]*\\.[a-zA-Z]{2,}$", - "description": "Email domain to flag as suspicious (e.g. mail.ru). Registrants from this domain will be required to verify a phone number." - } - }, - "required": ["domain"] + "guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, + "user_id": {"$ref": "#/components/schemas/SnowflakeType"} + } }, "SendSystemDmResponse": { "type": "object", @@ -13566,10 +13224,13 @@ }, "required": ["success", "filename", "size_bytes"] }, - "UserMutationResponse": { + "SearchUsersResponse": { "type": "object", - "properties": {"user": {"$ref": "#/components/schemas/UserAdminResponseSchema"}}, - "required": ["user"] + "properties": { + "users": {"type": "array", "items": {"$ref": "#/components/schemas/UserAdminResponseSchema"}}, + "total": {"type": "number"} + }, + "required": ["users", "total"] }, "UserAdminResponseSchema": { "type": "object", @@ -13671,33 +13332,62 @@ "format": "int32", "minimum": 0, "maximum": 2147483647, - "description": "Bitmask of suspicious activity flags that triggered the disable" + "description": "Bitmask of suspicious activity flags" }, - "CancelBulkMessageDeletionRequest": { + "AdminUsersMeResponse": { "type": "object", - "properties": {"user_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["user_id"] + "properties": {"user": {"$ref": "#/components/schemas/UserAdminResponseSchema"}}, + "required": ["user"] }, - "DisableMfaRequest": { + "LookupUserResponse": { "type": "object", - "properties": {"user_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["user_id"] + "properties": {"users": {"type": "array", "items": {"$ref": "#/components/schemas/UserAdminResponseSchema"}}}, + "required": ["users"] }, - "ChangeDobRequest": { + "UserMutationResponse": { + "type": "object", + "properties": {"user": {"$ref": "#/components/schemas/UserAdminResponseSchema"}}, + "required": ["user"] + }, + "AdminUserAclsRequest": { "type": "object", "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "date_of_birth": {"type": "string", "description": "New date of birth in YYYY-MM-DD format"} + "acls": { + "type": "array", + "items": {"$ref": "#/components/schemas/AdminAclType"}, + "maxItems": 112, + "description": "List of access control permissions to assign" + } }, - "required": ["user_id", "date_of_birth"] + "required": ["acls"] }, - "ChangeEmailRequest": { + "BanUserAvatarResponseSchema": { + "type": "object", + "properties": {"hash_short": {"type": "string"}}, + "required": ["hash_short"] + }, + "BanUserAvatarRequest": { + "type": "object", + "properties": {"reason": {"type": "string"}, "notes": {"type": "string"}} + }, + "AdminUserBanRequest": { "type": "object", "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "email": {"$ref": "#/components/schemas/EmailType"} + "duration_hours": { + "type": "integer", + "minimum": 0, + "maximum": 8760, + "format": "int32", + "description": "Duration of the ban in hours. Use 0 for a permanent ban (until manually unbanned)." + }, + "reason": {"type": "string", "description": "Reason for the temporary ban"} }, - "required": ["user_id", "email"] + "required": ["duration_hours"] + }, + "AdminUserBotStatusRequest": { + "type": "object", + "properties": {"bot": {"type": "boolean", "description": "Whether the user should be marked as a bot"}}, + "required": ["bot"] }, "ListUserChangeLogResponseSchema": { "type": "object", @@ -13723,36 +13413,248 @@ }, "required": ["entries", "next_page_token"] }, - "ListUserChangeLogRequest": { + "AdminUserDobUpdateRequest": { + "type": "object", + "properties": {"date_of_birth": {"type": "string", "description": "New date of birth in YYYY-MM-DD format"}}, + "required": ["date_of_birth"] + }, + "AdminUserDeletionScheduleRequest": { "type": "object", "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "limit": { + "reason_code": { + "$ref": "#/components/schemas/DeletionReasonCode", + "description": "Code indicating the reason for deletion" + }, + "public_reason": {"type": "string", "description": "Public-facing reason for the deletion"}, + "days_until_deletion": { "type": "integer", "minimum": 1, - "maximum": 200, + "maximum": 365, "format": "int32", - "description": "Maximum number of entries to return" + "description": "Number of days until the account is deleted" + } + }, + "required": ["reason_code"] + }, + "AdminUserDmChannelListResponse": { + "oneOf": [ + { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel_id": {"$ref": "#/components/schemas/SnowflakeType"}, + "channel_type": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}, + "channel_nsfw": {"nullable": true, "type": "boolean"}, + "guild_nsfw_level": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/NSFWLevel"}]}, + "recipient_ids": { + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeType"}, + "maxItems": 100 + }, + "recipients": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"$ref": "#/components/schemas/SnowflakeType"}, + "username": {"type": "string"}, + "discriminator": {"type": "string"}, + "global_name": {"nullable": true, "type": "string"}, + "avatar": {"nullable": true, "type": "string"} + }, + "required": ["id", "username", "discriminator", "global_name", "avatar"] + }, + "maxItems": 100 + }, + "last_message_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, + "is_open": {"type": "boolean"}, + "name": {"nullable": true, "type": "string"}, + "icon": {"nullable": true, "type": "string"}, + "owner_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]} + }, + "required": [ + "channel_id", + "channel_type", + "channel_nsfw", + "guild_nsfw_level", + "recipient_ids", + "recipients", + "last_message_id", + "is_open", + "name", + "icon", + "owner_id" + ] + }, + "maxItems": 200 + } + }, + "required": ["channels"] }, - "page_token": {"type": "string", "description": "Pagination token for the next page of results"} - }, - "required": ["user_id"] + { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel_id": {"$ref": "#/components/schemas/SnowflakeType"}, + "channel_type": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}, + "channel_nsfw": {"nullable": true, "type": "boolean"}, + "guild_nsfw_level": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/NSFWLevel"}]}, + "recipient_ids": { + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeType"}, + "maxItems": 100 + }, + "recipients": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"$ref": "#/components/schemas/SnowflakeType"}, + "username": {"type": "string"}, + "discriminator": {"type": "string"}, + "global_name": {"nullable": true, "type": "string"}, + "avatar": {"nullable": true, "type": "string"} + }, + "required": ["id", "username", "discriminator", "global_name", "avatar"] + }, + "maxItems": 100 + }, + "last_message_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, + "is_open": {"type": "boolean"}, + "name": {"nullable": true, "type": "string"}, + "icon": {"nullable": true, "type": "string"}, + "owner_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]} + }, + "required": [ + "channel_id", + "channel_type", + "channel_nsfw", + "guild_nsfw_level", + "recipient_ids", + "recipients", + "last_message_id", + "is_open", + "name", + "icon", + "owner_id" + ] + }, + "maxItems": 500 + } + }, + "required": ["channels"] + } + ] }, - "ChangeUsernameRequest": { + "AdminUserDmChannelType": { + "type": "string", + "enum": ["dm", "group_dm"], + "description": "Kind of direct message channel to list" + }, + "AdminUserEmailUpdateRequest": { + "type": "object", + "properties": {"email": {"$ref": "#/components/schemas/EmailType"}}, + "required": ["email"] + }, + "AdminUserFlagsUpdateRequest": { "type": "object", "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "username": {"$ref": "#/components/schemas/UsernameType"}, - "discriminator": {"$ref": "#/components/schemas/DiscriminatorType"} - }, - "required": ["user_id", "username"] + "add_flags": { + "type": "array", + "items": {"$ref": "#/components/schemas/UserFlags"}, + "maxItems": 64, + "description": "User flags to add" + }, + "remove_flags": { + "type": "array", + "items": {"$ref": "#/components/schemas/UserFlags"}, + "maxItems": 64, + "description": "User flags to remove" + } + } }, - "UsernameType": {"type": "string", "minLength": 1, "maxLength": 32, "pattern": "^[a-zA-Z0-9_]+$"}, - "DiscriminatorType": {"type": "string", "pattern": "^\\d{1,4}$"}, - "ClearUserFieldsRequest": { + "ListUserGuildsResponse": { + "type": "object", + "properties": {"guilds": {"type": "array", "items": {"$ref": "#/components/schemas/GuildAdminResponse"}}}, + "required": ["guilds"] + }, + "MessageShredResponse": { + "type": "object", + "properties": { + "success": {"type": "boolean", "enum": [true]}, + "job_id": {"type": "string"}, + "requested": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"} + }, + "required": ["success", "job_id"] + }, + "AdminUserMessageShredRequest": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel_id": {"$ref": "#/components/schemas/SnowflakeType"}, + "message_id": {"$ref": "#/components/schemas/SnowflakeType"} + }, + "required": ["channel_id", "message_id"] + }, + "minItems": 1, + "maxItems": 1000 + } + }, + "required": ["entries"] + }, + "DeleteAllUserMessagesResponse": { + "type": "object", + "properties": { + "success": {"type": "boolean", "enum": [true]}, + "dry_run": {"type": "boolean"}, + "channel_count": {"$ref": "#/components/schemas/Int32Type"}, + "message_count": {"$ref": "#/components/schemas/Int32Type"}, + "job_id": {"type": "string"} + }, + "required": ["success", "dry_run", "channel_count", "message_count"] + }, + "AdminUserPhoneVerificationRequest": { + "type": "object", + "properties": { + "has_verified_phone": { + "type": "boolean", + "description": "Whether the user should be treated as having completed phone verification" + } + }, + "required": ["has_verified_phone"] + }, + "AdminUserPremiumFlagsUpdateRequest": { + "type": "object", + "properties": { + "add_flags": { + "type": "array", + "items": {"$ref": "#/components/schemas/PremiumFlags"}, + "maxItems": 64, + "description": "Premium flags to add" + }, + "remove_flags": { + "type": "array", + "items": {"$ref": "#/components/schemas/PremiumFlags"}, + "maxItems": 64, + "description": "Premium flags to remove" + } + } + }, + "AdminUserClearFieldsRequest": { "type": "object", "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, "fields": { "type": "array", "items": { @@ -13764,198 +13666,21 @@ "description": "List of profile fields to clear" } }, - "required": ["user_id", "fields"] - }, - "DeleteWebAuthnCredentialRequest": { - "type": "object", - "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "credential_id": {"type": "string", "description": "ID of the WebAuthn credential to delete"} - }, - "required": ["user_id", "credential_id"] - }, - "DisableForSuspiciousActivityRequest": { - "type": "object", - "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "flags": {"$ref": "#/components/schemas/SuspiciousActivityFlags"} - }, - "required": ["user_id", "flags"] - }, - "ListUserDmChannelsResponse": { - "type": "object", - "properties": { - "channels": { - "type": "array", - "items": { - "type": "object", - "properties": { - "channel_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "channel_type": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}, - "channel_nsfw": {"nullable": true, "type": "boolean"}, - "guild_nsfw_level": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/NSFWLevel"}]}, - "recipient_ids": { - "type": "array", - "items": {"$ref": "#/components/schemas/SnowflakeType"}, - "maxItems": 100 - }, - "recipients": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"$ref": "#/components/schemas/SnowflakeType"}, - "username": {"type": "string"}, - "discriminator": {"type": "string"}, - "global_name": {"nullable": true, "type": "string"}, - "avatar": {"nullable": true, "type": "string"} - }, - "required": ["id", "username", "discriminator", "global_name", "avatar"] - }, - "maxItems": 100 - }, - "last_message_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, - "is_open": {"type": "boolean"}, - "name": {"nullable": true, "type": "string"}, - "icon": {"nullable": true, "type": "string"}, - "owner_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]} - }, - "required": [ - "channel_id", - "channel_type", - "channel_nsfw", - "guild_nsfw_level", - "recipient_ids", - "recipients", - "last_message_id", - "is_open", - "name", - "icon", - "owner_id" - ] - }, - "maxItems": 200 - } - }, - "required": ["channels"] - }, - "ListUserDmChannelsRequest": { - "type": "object", - "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "before": {"$ref": "#/components/schemas/SnowflakeType"}, - "after": {"$ref": "#/components/schemas/SnowflakeType"}, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "format": "int32", - "description": "Maximum number of DM channels to return" - } - }, - "required": ["user_id"] - }, - "ListUserGroupDmChannelsResponse": { - "type": "object", - "properties": { - "channels": { - "type": "array", - "items": { - "type": "object", - "properties": { - "channel_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "channel_type": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}, - "channel_nsfw": {"nullable": true, "type": "boolean"}, - "guild_nsfw_level": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/NSFWLevel"}]}, - "recipient_ids": { - "type": "array", - "items": {"$ref": "#/components/schemas/SnowflakeType"}, - "maxItems": 100 - }, - "recipients": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"$ref": "#/components/schemas/SnowflakeType"}, - "username": {"type": "string"}, - "discriminator": {"type": "string"}, - "global_name": {"nullable": true, "type": "string"}, - "avatar": {"nullable": true, "type": "string"} - }, - "required": ["id", "username", "discriminator", "global_name", "avatar"] - }, - "maxItems": 100 - }, - "last_message_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, - "is_open": {"type": "boolean"}, - "name": {"nullable": true, "type": "string"}, - "icon": {"nullable": true, "type": "string"}, - "owner_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]} - }, - "required": [ - "channel_id", - "channel_type", - "channel_nsfw", - "guild_nsfw_level", - "recipient_ids", - "recipients", - "last_message_id", - "is_open", - "name", - "icon", - "owner_id" - ] - }, - "maxItems": 500 - } - }, - "required": ["channels"] - }, - "ListUserGroupDmChannelsRequest": { - "type": "object", - "properties": {"user_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["user_id"] - }, - "ListUserGuildsResponse": { - "type": "object", - "properties": {"guilds": {"type": "array", "items": {"$ref": "#/components/schemas/GuildAdminResponse"}}}, - "required": ["guilds"] - }, - "ListUserGuildsRequest": { - "type": "object", - "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "before": {"$ref": "#/components/schemas/SnowflakeType"}, - "after": {"$ref": "#/components/schemas/SnowflakeType"}, - "limit": {"type": "integer", "minimum": 1, "maximum": 200, "format": "int32"}, - "with_counts": {"type": "boolean"} - }, - "required": ["user_id"] + "required": ["fields"] }, "ListUserRelationshipsResponse": { "type": "object", "properties": { - "friends": { - "type": "array", - "items": {"$ref": "#/components/schemas/AdminRelationshipEntrySchema"}, - "maxItems": 10000 - }, + "friends": {"type": "array", "items": {"$ref": "#/components/schemas/AdminRelationshipEntrySchema"}}, "incoming_requests": { "type": "array", - "items": {"$ref": "#/components/schemas/AdminRelationshipEntrySchema"}, - "maxItems": 10000 + "items": {"$ref": "#/components/schemas/AdminRelationshipEntrySchema"} }, "outgoing_requests": { "type": "array", - "items": {"$ref": "#/components/schemas/AdminRelationshipEntrySchema"}, - "maxItems": 10000 + "items": {"$ref": "#/components/schemas/AdminRelationshipEntrySchema"} }, - "blocked": { - "type": "array", - "items": {"$ref": "#/components/schemas/AdminRelationshipEntrySchema"}, - "maxItems": 10000 - } + "blocked": {"type": "array", "items": {"$ref": "#/components/schemas/AdminRelationshipEntrySchema"}} }, "required": ["friends", "incoming_requests", "outgoing_requests", "blocked"] }, @@ -13985,10 +13710,10 @@ "enum": ["friend", "incoming_request", "outgoing_request", "blocked"], "type": "string" }, - "ListUserRelationshipsRequest": { + "RemoveUserRelationshipsResponse": { "type": "object", - "properties": {"user_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["user_id"] + "properties": {"removed_count": {"$ref": "#/components/schemas/Int32Type"}}, + "required": ["removed_count"] }, "ListUserSessionsResponse": { "type": "object", @@ -14043,11 +13768,50 @@ }, "required": ["sessions"] }, - "ListUserSessionsRequest": { + "TerminateSessionsResponse": { "type": "object", - "properties": {"user_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["user_id"] + "properties": {"terminated_count": {"$ref": "#/components/schemas/Int32Type"}}, + "required": ["terminated_count"] }, + "AdminUserSuspiciousDisableRequest": { + "type": "object", + "properties": {"flags": {"$ref": "#/components/schemas/SuspiciousActivityFlags"}}, + "required": ["flags"] + }, + "AdminUserSuspiciousActivityFlagsRequest": { + "type": "object", + "properties": {"flags": {"$ref": "#/components/schemas/SuspiciousActivityFlags"}}, + "required": ["flags"] + }, + "AdminUserSystemStatusRequest": { + "type": "object", + "properties": { + "system": {"type": "boolean", "description": "Whether the user should be marked as a system user"} + }, + "required": ["system"] + }, + "AdminUserTraitsRequest": { + "type": "object", + "properties": { + "traits": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 100, + "description": "List of traits to assign to the user" + } + }, + "required": ["traits"] + }, + "AdminUserUsernameUpdateRequest": { + "type": "object", + "properties": { + "username": {"$ref": "#/components/schemas/UsernameType"}, + "discriminator": {"$ref": "#/components/schemas/DiscriminatorType"} + }, + "required": ["username"] + }, + "UsernameType": {"type": "string", "minLength": 1, "maxLength": 32, "pattern": "^[a-zA-Z0-9_]+$"}, + "DiscriminatorType": {"type": "string", "pattern": "^\\d{1,4}$"}, "WebAuthnCredentialListResponse": { "type": "array", "items": {"$ref": "#/components/schemas/WebAuthnCredentialResponse"} @@ -14062,250 +13826,140 @@ }, "required": ["id", "name", "created_at", "last_used_at"] }, - "ListWebAuthnCredentialsRequest": { - "type": "object", - "properties": {"user_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["user_id"] - }, - "LookupUserResponse": { - "type": "object", - "properties": {"users": {"type": "array", "items": {"$ref": "#/components/schemas/UserAdminResponseSchema"}}}, - "required": ["users"] - }, - "LookupUserRequest": { - "oneOf": [ - {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}, - { - "type": "object", - "properties": { - "user_ids": {"type": "array", "items": {"$ref": "#/components/schemas/SnowflakeType"}, "maxItems": 100} - }, - "required": ["user_ids"] - } - ] - }, - "AdminUsersMeResponse": { - "type": "object", - "properties": {"user": {"$ref": "#/components/schemas/UserAdminResponseSchema"}}, - "required": ["user"] - }, - "RemoveUserRelationshipRequest": { + "ListVoiceRegionsResponse": { "type": "object", "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "target_user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "category": { - "enum": ["friend", "incoming_request", "outgoing_request", "blocked"], - "type": "string", - "description": "Which relationship to remove. Friend and outgoing_request also remove the mirror entry on the target user." - } - }, - "required": ["user_id", "target_user_id", "category"] - }, - "RemoveUserRelationshipsResponse": { - "type": "object", - "properties": {"removed_count": {"$ref": "#/components/schemas/Int32Type"}}, - "required": ["removed_count"] - }, - "RemoveUserRelationshipsByCategoryRequest": { - "type": "object", - "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "category": { - "enum": ["friend", "incoming_request", "outgoing_request", "blocked"], - "type": "string", - "description": "Category of relationships to remove for this user" - } - }, - "required": ["user_id", "category"] - }, - "ResendVerificationEmailRequest": { - "type": "object", - "properties": {"user_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["user_id"] - }, - "ScheduleAccountDeletionRequest": { - "type": "object", - "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "reason_code": { - "type": "integer", - "minimum": 0, - "maximum": 2147483647, - "format": "int32", - "description": "Code indicating the reason for deletion" - }, - "public_reason": {"type": "string", "description": "Public-facing reason for the deletion"}, - "days_until_deletion": { - "type": "integer", - "minimum": 1, - "maximum": 365, - "format": "int32", - "description": "Number of days until the account is deleted" - } - }, - "required": ["user_id", "reason_code"] - }, - "SearchUsersResponse": { - "type": "object", - "properties": { - "users": {"type": "array", "items": {"$ref": "#/components/schemas/UserAdminResponseSchema"}}, - "total": {"type": "number"} - }, - "required": ["users", "total"] - }, - "SearchUsersRequest": { - "type": "object", - "properties": { - "query": {"type": "string"}, - "email": {"type": "string"}, - "last_active_ip": {"type": "string"}, - "limit": {"type": "integer", "minimum": 1, "maximum": 200, "format": "int32"}, - "offset": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"} - } - }, - "SendPasswordResetRequest": { - "type": "object", - "properties": {"user_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["user_id"] - }, - "SetUserAclsRequest": { - "type": "object", - "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "acls": { + "regions": { "type": "array", - "items": {"type": "string"}, - "maxItems": 112, - "description": "List of access control permissions to assign" + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "description": "Unique identifier for the voice region"}, + "name": {"type": "string", "description": "Display name of the voice region"}, + "emoji": {"type": "string", "description": "Emoji representing the region"}, + "latitude": {"type": "number", "description": "Geographic latitude coordinate"}, + "longitude": {"type": "number", "description": "Geographic longitude coordinate"}, + "is_default": {"type": "boolean", "description": "Whether this is the default region"}, + "vip_only": {"type": "boolean", "description": "Whether this region is restricted to VIP users"}, + "required_guild_features": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 100, + "description": "Guild features required to use this region" + }, + "allowed_guild_ids": { + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeType"}, + "maxItems": 1000, + "description": "Guild IDs explicitly allowed to use this region" + }, + "allowed_user_ids": { + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeType"}, + "maxItems": 1000, + "description": "User IDs explicitly allowed to use this region" + }, + "created_at": { + "description": "ISO 8601 timestamp when the region was created", + "nullable": true, + "type": "string" + }, + "updated_at": { + "description": "ISO 8601 timestamp when the region was last updated", + "nullable": true, + "type": "string" + }, + "servers": { + "type": "array", + "items": {"$ref": "#/components/schemas/VoiceServerAdminResponse"}, + "description": "Voice servers in this region" + } + }, + "required": [ + "id", + "name", + "emoji", + "latitude", + "longitude", + "is_default", + "vip_only", + "required_guild_features", + "allowed_guild_ids", + "allowed_user_ids", + "created_at", + "updated_at" + ] + }, + "description": "List of voice regions" } }, - "required": ["user_id", "acls"] + "required": ["regions"] }, - "SetUserBotStatusRequest": { + "VoiceServerAdminResponse": { "type": "object", "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "bot": {"type": "boolean", "description": "Whether the user should be marked as a bot"} - }, - "required": ["user_id", "bot"] - }, - "SetUserSystemStatusRequest": { - "type": "object", - "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "system": {"type": "boolean", "description": "Whether the user should be marked as a system user"} - }, - "required": ["user_id", "system"] - }, - "SetUserTraitsRequest": { - "type": "object", - "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "traits": { + "region_id": {"type": "string", "description": "ID of the region this server belongs to"}, + "server_id": {"type": "string", "description": "Unique identifier for the voice server"}, + "endpoint": { + "type": "string", + "format": "uri", + "description": "Client signal WebSocket endpoint URL for the voice server" + }, + "latitude": { + "description": "Optional geographic latitude override for this server", + "nullable": true, + "type": "number" + }, + "longitude": { + "description": "Optional geographic longitude override for this server", + "nullable": true, + "type": "number" + }, + "is_active": {"type": "boolean", "description": "Whether the server is currently active"}, + "vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"}, + "required_guild_features": { "type": "array", "items": {"type": "string"}, "maxItems": 100, - "description": "List of traits to assign to the user" - } - }, - "required": ["user_id", "traits"] - }, - "TempBanUserRequest": { - "type": "object", - "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "duration_hours": { - "type": "integer", - "minimum": 0, - "maximum": 8760, - "format": "int32", - "description": "Duration of the ban in hours. Use 0 for a permanent ban (until manually unbanned)." + "description": "Guild features required to use this server" }, - "reason": {"type": "string", "description": "Reason for the temporary ban"} - }, - "required": ["user_id", "duration_hours"] - }, - "TerminateSessionsResponse": { - "type": "object", - "properties": {"terminated_count": {"$ref": "#/components/schemas/Int32Type"}}, - "required": ["terminated_count"] - }, - "TerminateSessionsRequest": { - "type": "object", - "properties": {"user_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["user_id"] - }, - "UpdateUserFlagsRequest": { - "type": "object", - "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "add_flags": { + "allowed_guild_ids": { "type": "array", - "items": {"$ref": "#/components/schemas/UserFlags"}, - "maxItems": 64, - "description": "User flags to add" + "items": {"$ref": "#/components/schemas/SnowflakeType"}, + "maxItems": 1000, + "description": "Guild IDs explicitly allowed to use this server" }, - "remove_flags": { + "allowed_user_ids": { "type": "array", - "items": {"$ref": "#/components/schemas/UserFlags"}, - "maxItems": 64, - "description": "User flags to remove" - } - }, - "required": ["user_id"] - }, - "UpdateHasVerifiedPhoneRequest": { - "type": "object", - "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "has_verified_phone": { - "type": "boolean", - "description": "Whether the user should be treated as having completed phone verification" - } - }, - "required": ["user_id", "has_verified_phone"] - }, - "UpdatePremiumFlagsRequest": { - "type": "object", - "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "add_flags": { - "type": "array", - "items": {"$ref": "#/components/schemas/PremiumFlags"}, - "maxItems": 64, - "description": "Premium flags to add" + "items": {"$ref": "#/components/schemas/SnowflakeType"}, + "maxItems": 1000, + "description": "User IDs explicitly allowed to use this server" }, - "remove_flags": { - "type": "array", - "items": {"$ref": "#/components/schemas/PremiumFlags"}, - "maxItems": 64, - "description": "Premium flags to remove" + "created_at": { + "description": "ISO 8601 timestamp when the server was created", + "nullable": true, + "type": "string" + }, + "updated_at": { + "description": "ISO 8601 timestamp when the server was last updated", + "nullable": true, + "type": "string" } }, - "required": ["user_id"] - }, - "UpdateSuspiciousActivityFlagsRequest": { - "type": "object", - "properties": { - "user_id": {"$ref": "#/components/schemas/SnowflakeType"}, - "flags": {"$ref": "#/components/schemas/SuspiciousActivityFlags"} - }, - "required": ["user_id", "flags"] - }, - "VerifyUserEmailRequest": { - "type": "object", - "properties": {"user_id": {"$ref": "#/components/schemas/SnowflakeType"}}, - "required": ["user_id"] - }, - "BanUserAvatarResponseSchema": { - "type": "object", - "properties": {"hash_short": {"type": "string"}}, - "required": ["hash_short"] - }, - "BanUserAvatarRequest": { - "type": "object", - "properties": {"reason": {"type": "string"}, "notes": {"type": "string"}} + "required": [ + "region_id", + "server_id", + "endpoint", + "latitude", + "longitude", + "is_active", + "vip_only", + "required_guild_features", + "allowed_guild_ids", + "allowed_user_ids", + "created_at", + "updated_at" + ] }, "CreateVoiceRegionResponse": { "type": "object", @@ -14399,16 +14053,6 @@ }, "required": ["id", "name", "emoji", "latitude", "longitude"] }, - "DeleteVoiceResponse": { - "type": "object", - "properties": {"success": {"type": "boolean", "description": "Whether the deletion was successful"}}, - "required": ["success"] - }, - "DeleteVoiceRegionRequest": { - "type": "object", - "properties": {"id": {"type": "string", "description": "ID of the voice region to delete"}}, - "required": ["id"] - }, "GetVoiceRegionResponse": { "type": "object", "properties": { @@ -14476,155 +14120,6 @@ }, "required": ["region"] }, - "VoiceServerAdminResponse": { - "type": "object", - "properties": { - "region_id": {"type": "string", "description": "ID of the region this server belongs to"}, - "server_id": {"type": "string", "description": "Unique identifier for the voice server"}, - "endpoint": { - "type": "string", - "format": "uri", - "description": "Client signal WebSocket endpoint URL for the voice server" - }, - "latitude": { - "description": "Optional geographic latitude override for this server", - "nullable": true, - "type": "number" - }, - "longitude": { - "description": "Optional geographic longitude override for this server", - "nullable": true, - "type": "number" - }, - "is_active": {"type": "boolean", "description": "Whether the server is currently active"}, - "vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"}, - "required_guild_features": { - "type": "array", - "items": {"type": "string"}, - "maxItems": 100, - "description": "Guild features required to use this server" - }, - "allowed_guild_ids": { - "type": "array", - "items": {"$ref": "#/components/schemas/SnowflakeType"}, - "maxItems": 1000, - "description": "Guild IDs explicitly allowed to use this server" - }, - "allowed_user_ids": { - "type": "array", - "items": {"$ref": "#/components/schemas/SnowflakeType"}, - "maxItems": 1000, - "description": "User IDs explicitly allowed to use this server" - }, - "created_at": { - "description": "ISO 8601 timestamp when the server was created", - "nullable": true, - "type": "string" - }, - "updated_at": { - "description": "ISO 8601 timestamp when the server was last updated", - "nullable": true, - "type": "string" - } - }, - "required": [ - "region_id", - "server_id", - "endpoint", - "latitude", - "longitude", - "is_active", - "vip_only", - "required_guild_features", - "allowed_guild_ids", - "allowed_user_ids", - "created_at", - "updated_at" - ] - }, - "GetVoiceRegionRequest": { - "type": "object", - "properties": { - "id": {"type": "string", "description": "ID of the voice region to retrieve"}, - "include_servers": {"type": "boolean", "description": "Whether to include voice servers in the response"} - }, - "required": ["id"] - }, - "ListVoiceRegionsResponse": { - "type": "object", - "properties": { - "regions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"type": "string", "description": "Unique identifier for the voice region"}, - "name": {"type": "string", "description": "Display name of the voice region"}, - "emoji": {"type": "string", "description": "Emoji representing the region"}, - "latitude": {"type": "number", "description": "Geographic latitude coordinate"}, - "longitude": {"type": "number", "description": "Geographic longitude coordinate"}, - "is_default": {"type": "boolean", "description": "Whether this is the default region"}, - "vip_only": {"type": "boolean", "description": "Whether this region is restricted to VIP users"}, - "required_guild_features": { - "type": "array", - "items": {"type": "string"}, - "maxItems": 100, - "description": "Guild features required to use this region" - }, - "allowed_guild_ids": { - "type": "array", - "items": {"$ref": "#/components/schemas/SnowflakeType"}, - "maxItems": 1000, - "description": "Guild IDs explicitly allowed to use this region" - }, - "allowed_user_ids": { - "type": "array", - "items": {"$ref": "#/components/schemas/SnowflakeType"}, - "maxItems": 1000, - "description": "User IDs explicitly allowed to use this region" - }, - "created_at": { - "description": "ISO 8601 timestamp when the region was created", - "nullable": true, - "type": "string" - }, - "updated_at": { - "description": "ISO 8601 timestamp when the region was last updated", - "nullable": true, - "type": "string" - }, - "servers": { - "type": "array", - "items": {"$ref": "#/components/schemas/VoiceServerAdminResponse"}, - "description": "Voice servers in this region" - } - }, - "required": [ - "id", - "name", - "emoji", - "latitude", - "longitude", - "is_default", - "vip_only", - "required_guild_features", - "allowed_guild_ids", - "allowed_user_ids", - "created_at", - "updated_at" - ] - }, - "description": "List of voice regions" - } - }, - "required": ["regions"] - }, - "ListVoiceRegionsRequest": { - "type": "object", - "properties": { - "include_servers": {"type": "boolean", "description": "Whether to include voice servers in the response"} - } - }, "UpdateVoiceRegionResponse": { "type": "object", "properties": { @@ -14717,6 +14212,22 @@ }, "required": ["id"] }, + "DeleteVoiceResponse": { + "type": "object", + "properties": {"success": {"type": "boolean", "description": "Whether the deletion was successful"}}, + "required": ["success"] + }, + "ListVoiceServersResponse": { + "type": "object", + "properties": { + "servers": { + "type": "array", + "items": {"$ref": "#/components/schemas/VoiceServerAdminResponse"}, + "description": "List of voice servers" + } + }, + "required": ["servers"] + }, "CreateVoiceServerResponse": { "type": "object", "properties": { @@ -14835,14 +14346,6 @@ }, "required": ["region_id", "server_id", "endpoint", "api_key", "api_secret"] }, - "DeleteVoiceServerRequest": { - "type": "object", - "properties": { - "region_id": {"type": "string", "description": "ID of the region the server belongs to"}, - "server_id": {"type": "string", "description": "ID of the voice server to delete"} - }, - "required": ["region_id", "server_id"] - }, "GetVoiceServerResponse": { "type": "object", "properties": { @@ -14854,30 +14357,6 @@ }, "required": ["server"] }, - "GetVoiceServerRequest": { - "type": "object", - "properties": { - "region_id": {"type": "string", "description": "ID of the region the server belongs to"}, - "server_id": {"type": "string", "description": "ID of the voice server to retrieve"} - }, - "required": ["region_id", "server_id"] - }, - "ListVoiceServersResponse": { - "type": "object", - "properties": { - "servers": { - "type": "array", - "items": {"$ref": "#/components/schemas/VoiceServerAdminResponse"}, - "description": "List of voice servers" - } - }, - "required": ["servers"] - }, - "ListVoiceServersRequest": { - "type": "object", - "properties": {"region_id": {"type": "string", "description": "ID of the region to list servers for"}}, - "required": ["region_id"] - }, "UpdateVoiceServerResponse": { "type": "object", "properties": { diff --git a/fluxer_admin/src/acl.rs b/fluxer_admin/src/acl.rs index 09d53d179..80e132689 100644 --- a/fluxer_admin/src/acl.rs +++ b/fluxer_admin/src/acl.rs @@ -122,6 +122,7 @@ pub const ALL_ACLS: &[&str] = &[ ARCHIVE_TRIGGER_GUILD, ARCHIVE_TRIGGER_USER, ARCHIVE_VIEW_ALL, + ASSET_PURGE, AUDIT_LOG_VIEW, AUTHENTICATE, JOBS_VIEW, diff --git a/fluxer_admin/src/api/admin_api_keys.rs b/fluxer_admin/src/api/admin_api_keys.rs index f729bda15..cdb1a454c 100644 --- a/fluxer_admin/src/api/admin_api_keys.rs +++ b/fluxer_admin/src/api/admin_api_keys.rs @@ -12,7 +12,7 @@ impl AdminApiClient { acls: &[String], ) -> ApiResult { let body = generated_types::CreateAdminApiKeyRequest { - acls: acls.to_vec(), + acls: parse_acls(acls)?, expires_in_days: None, name: generated_types::CreateAdminApiKeyRequestName::try_from(name) .map_err(|e| ApiError::Parse(e.to_string()))?, @@ -35,10 +35,20 @@ impl AdminApiClient { } pub async fn revoke_api_key(&self, key_id: &str) -> ApiResult<()> { + let key_id = generated_types::SnowflakeType::from(key_id.to_owned()); self.generated() - .delete_admin_api_key(key_id) + .delete_admin_api_key(&key_id) .await .map_err(|e| self.generated_error(e))?; Ok(()) } } + +pub(super) fn parse_acls(acls: &[String]) -> ApiResult> { + acls.iter() + .map(|acl| { + generated_types::AdminAclType::try_from(acl.as_str()) + .map_err(|e| ApiError::Parse(e.to_string())) + }) + .collect() +} diff --git a/fluxer_admin/src/api/applications.rs b/fluxer_admin/src/api/applications.rs index fb649e487..5d8997231 100644 --- a/fluxer_admin/src/api/applications.rs +++ b/fluxer_admin/src/api/applications.rs @@ -4,35 +4,36 @@ use super::client::{AdminApiClient, ApiResult}; use super::types::{Application, ApplicationUpdateResponse, LookupApplicationResponse}; use serde::Serialize; -#[derive(Serialize)] -struct LookupApplicationRequest<'a> { - application_id: &'a str, -} - -#[derive(Serialize)] -struct ListUserApplicationsRequest<'a> { - user_id: &'a str, -} - #[derive(Serialize)] struct TransferApplicationOwnershipRequest<'a> { - application_id: &'a str, new_owner_id: &'a str, } impl AdminApiClient { pub async fn lookup_application(&self, application_id: &str) -> ApiResult> { - let body = LookupApplicationRequest { application_id }; - let resp: LookupApplicationResponse = - self.post_typed("/admin/applications/lookup", &body).await?; + let resp: LookupApplicationResponse = self + .get( + &format!( + "/admin/applications/{}", + urlencoding::encode(application_id) + ), + None, + ) + .await?; Ok(resp.application) } pub async fn list_user_applications(&self, user_id: &str) -> ApiResult> { - let body = ListUserApplicationsRequest { user_id }; - let resp: super::types::ListUserApplicationsResponse = self - .post_typed("/admin/applications/list-by-owner", &body) - .await?; + let query_params = [("owner_id", user_id)]; + let resp: super::types::ListUserApplicationsResponse = + self.get("/admin/applications", Some(&query_params)).await?; + Ok(resp.applications) + } + + pub async fn list_guild_applications(&self, guild_id: &str) -> ApiResult> { + let query_params = [("guild_id", guild_id)]; + let resp: super::types::ListUserApplicationsResponse = + self.get("/admin/applications", Some(&query_params)).await?; Ok(resp.applications) } @@ -41,11 +42,15 @@ impl AdminApiClient { application_id: &str, new_owner_id: &str, ) -> ApiResult { - let body = TransferApplicationOwnershipRequest { - application_id, - new_owner_id, - }; - self.post_typed("/admin/applications/transfer-ownership", &body) - .await + let body = TransferApplicationOwnershipRequest { new_owner_id }; + self.patch_typed_with_reason( + &format!( + "/admin/applications/{}", + urlencoding::encode(application_id) + ), + &body, + None, + ) + .await } } diff --git a/fluxer_admin/src/api/archives.rs b/fluxer_admin/src/api/archives.rs index 97b001755..939604a7f 100644 --- a/fluxer_admin/src/api/archives.rs +++ b/fluxer_admin/src/api/archives.rs @@ -2,7 +2,7 @@ use crate::api::generated::types as generated_types; -use super::client::{AdminApiClient, ApiError, ApiResult}; +use super::client::{AdminApiClient, ApiResult}; use super::types::{Archive, ArchiveDownloadUrlResponse, ListArchivesResponse}; impl AdminApiClient { @@ -11,13 +11,12 @@ impl AdminApiClient { user_id: &str, include_attachments: bool, ) -> ApiResult { - let body = generated_types::TriggerUserArchiveRequest { + let body = generated_types::AdminArchiveCreateRequest { include_attachments: include_attachments.then_some(true), - user_id: snowflake(user_id), }; let response = self .generated() - .trigger_user_archive(&body) + .create_admin_user_archive(&snowflake(user_id), &body) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -28,13 +27,12 @@ impl AdminApiClient { guild_id: &str, include_attachments: bool, ) -> ApiResult { - let body = generated_types::TriggerGuildArchiveRequest { - guild_id: snowflake(guild_id), + let body = generated_types::AdminArchiveCreateRequest { include_attachments: include_attachments.then_some(true), }; let response = self .generated() - .trigger_guild_archive(&body) + .create_admin_guild_archive(&snowflake(guild_id), &body) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -47,22 +45,16 @@ impl AdminApiClient { include_expired: bool, requested_by: Option<&str>, ) -> ApiResult { - let body = generated_types::ListArchivesRequest { - include_expired: Some(include_expired), - limit: None, - requested_by: requested_by.map(snowflake), - subject_id: subject_id.map(snowflake), - subject_type: Some( - generated_types::ListArchivesRequestSubjectType::try_from(subject_type) - .map_err(|e| ApiError::Parse(e.to_string()))?, + let query_params = [ + ("subject_type", subject_type), + ("subject_id", subject_id.unwrap_or_default()), + ("requested_by", requested_by.unwrap_or_default()), + ( + "include_expired", + if include_expired { "true" } else { "false" }, ), - }; - let response = self - .generated() - .list_archives(&body) - .await - .map_err(|e| self.generated_error(e))?; - self.generated_value(response.into_inner()) + ]; + self.get("/admin/archives", Some(&query_params)).await } pub async fn get_archive_download_url( @@ -73,7 +65,7 @@ impl AdminApiClient { ) -> ApiResult { let response = self .generated() - .get_archive_download_url(subject_type, subject_id, archive_id) + .get_admin_archive_download(subject_type, subject_id, archive_id) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) diff --git a/fluxer_admin/src/api/assets.rs b/fluxer_admin/src/api/assets.rs index b53c97409..955904d67 100644 --- a/fluxer_admin/src/api/assets.rs +++ b/fluxer_admin/src/api/assets.rs @@ -5,11 +5,18 @@ use crate::api::generated::types as generated_types; use super::client::{AdminApiClient, ApiResult}; impl AdminApiClient { - pub async fn purge_assets(&self, ids: &[String]) -> ApiResult { + pub async fn purge_assets( + &self, + guild_id: &str, + ids: &[String], + ) -> ApiResult { let body = generated_types::PurgeGuildAssetsRequest { ids: ids.to_vec() }; let response = self .generated() - .purge_guild_assets(&body) + .purge_admin_guild_assets( + &generated_types::SnowflakeType::from(guild_id.to_owned()), + &body, + ) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) diff --git a/fluxer_admin/src/api/audit.rs b/fluxer_admin/src/api/audit.rs index afd6b7f49..c595881ee 100644 --- a/fluxer_admin/src/api/audit.rs +++ b/fluxer_admin/src/api/audit.rs @@ -23,26 +23,47 @@ impl AdminApiClient { &self, params: &SearchAuditLogsParams, ) -> ApiResult { - let body = generated_types::SearchAuditLogsRequest { - admin_user_id: nonempty_string(params.admin_user_id.as_deref()) - .map(generated_types::SnowflakeType::from), - limit: Some( - crate::api::generated::nonzero_u32(params.limit, "limit") - .map_err(ApiError::Parse)?, + let sort_by = params + .sort_by + .as_deref() + .map(audit_sort_by) + .transpose()? + .map(|value| value.to_string()); + let sort_order = params + .sort_order + .as_deref() + .map(audit_sort_order) + .transpose()? + .map(|value| value.to_string()); + let limit = params.limit.to_string(); + let offset = params.offset.to_string(); + let query_params = [ + ( + "q", + nonempty_string(params.query.as_deref()).unwrap_or_default(), ), - offset: Some(i64::from(params.offset)), - query: nonempty_string(params.query.as_deref()), - sort_by: params.sort_by.as_deref().map(audit_sort_by).transpose()?, - sort_order: params - .sort_order - .as_deref() - .map(audit_sort_order) - .transpose()?, - target_id: nonempty_string(params.target_id.as_deref()), - target_type: nonempty_string(params.target_type.as_deref()), - }; - let body = serde_json::to_value(&body).map_err(|e| ApiError::Parse(e.to_string()))?; - self.post("/admin/audit-logs/search", Some(&body)).await + ( + "admin_user_id", + nonempty_string(params.admin_user_id.as_deref()).unwrap_or_default(), + ), + ( + "target_type", + nonempty_string(params.target_type.as_deref()).unwrap_or_default(), + ), + ( + "target_id", + nonempty_string(params.target_id.as_deref()).unwrap_or_default(), + ), + ("sort_by", sort_by.unwrap_or_default()), + ("sort_order", sort_order.unwrap_or_default()), + ("limit", limit), + ("offset", offset), + ]; + let query_params: Vec<(&str, &str)> = query_params + .iter() + .map(|(key, value)| (*key, value.as_str())) + .collect(); + self.get("/admin/audit-logs", Some(&query_params)).await } } @@ -58,7 +79,7 @@ fn audit_logs_response( } #[cfg(test)] -fn audit_log_entry(entry: generated_types::AuditLogsListResponseSchemaLogsItem) -> AuditLogEntry { +fn audit_log_entry(entry: generated_types::AdminAuditLogResponseSchema) -> AuditLogEntry { AuditLogEntry { log_id: String::from(entry.log_id), admin_user_id: String::from(entry.admin_user_id), @@ -78,17 +99,17 @@ fn audit_log_entry(entry: generated_types::AuditLogsListResponseSchemaLogsItem) } } -fn audit_sort_by(value: &str) -> ApiResult { +fn audit_sort_by(value: &str) -> ApiResult { let value = match value { "created_at" => "createdAt", value => value, }; - generated_types::SearchAuditLogsRequestSortBy::try_from(value) + generated_types::ListAdminAuditLogsSortBy::try_from(value) .map_err(|e| ApiError::Parse(e.to_string())) } -fn audit_sort_order(value: &str) -> ApiResult { - generated_types::SearchAuditLogsRequestSortOrder::try_from(value) +fn audit_sort_order(value: &str) -> ApiResult { + generated_types::ListAdminAuditLogsSortOrder::try_from(value) .map_err(|e| ApiError::Parse(e.to_string())) } diff --git a/fluxer_admin/src/api/bans.rs b/fluxer_admin/src/api/bans.rs index bf1cdbdcf..cd31db90b 100644 --- a/fluxer_admin/src/api/bans.rs +++ b/fluxer_admin/src/api/bans.rs @@ -7,209 +7,123 @@ use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult}; impl AdminApiClient { pub async fn ban_email(&self, email: &str) -> ApiResult<()> { - let body = generated_types::BanEmailRequest { - email: generated_types::EmailType::from(email.to_owned()), - }; - self.generated() - .add_email_ban(&body) - .await - .map_err(|e| self.generated_error(e))?; - Ok(()) + self.create_blocklist_entry( + "email", + generated_types::BanEmailRequest { + email: generated_types::EmailType::from(email.to_owned()), + } + .into(), + ) + .await } pub async fn unban_email(&self, email: &str) -> ApiResult<()> { - let body = generated_types::BanEmailRequest { - email: generated_types::EmailType::from(email.to_owned()), - }; - self.generated() - .remove_email_ban(&body) - .await - .map_err(|e| self.generated_error(e))?; - Ok(()) + self.delete_blocklist_entry("email", email, None).await } pub async fn check_email_ban(&self, email: &str) -> ApiResult { - let body = generated_types::BanEmailRequest { - email: generated_types::EmailType::from(email.to_owned()), - }; - let response = self - .generated() - .check_email_ban_status(&body) - .await - .map_err(|e| self.generated_error(e))?; - self.generated_value(response.into_inner()) + self.check_blocklist_entry("email", email, None).await } pub async fn ban_ip(&self, ip: &str) -> ApiResult<()> { - let body = generated_types::BanIpRequest { ip: ip.to_owned() }; - self.generated() - .add_ip_ban(&body) - .await - .map_err(|e| self.generated_error(e))?; - Ok(()) + self.create_blocklist_entry( + "ip", + generated_types::BanIpRequest { ip: ip.to_owned() }.into(), + ) + .await } pub async fn unban_ip(&self, ip: &str) -> ApiResult<()> { - let body = generated_types::BanIpRequest { ip: ip.to_owned() }; - self.generated() - .remove_ip_ban(&body) - .await - .map_err(|e| self.generated_error(e))?; - Ok(()) + self.delete_blocklist_entry("ip", ip, None).await } pub async fn check_ip_ban(&self, ip: &str) -> ApiResult { - let body = generated_types::BanIpRequest { ip: ip.to_owned() }; - let response = self - .generated() - .check_ip_ban_status(&body) - .await - .map_err(|e| self.generated_error(e))?; - self.generated_value(response.into_inner()) + self.check_blocklist_entry("ip", ip, None).await } pub async fn add_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> { - let body = suspicious_email_domain_request(domain)?; - self.generated() - .add_suspicious_email_domain(&body) - .await - .map_err(|e| self.generated_error(e))?; - Ok(()) + self.create_blocklist_entry( + SUSPICIOUS_EMAIL_DOMAIN_LIST, + suspicious_email_domain_request(domain)?.into(), + ) + .await } pub async fn remove_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> { - let body = suspicious_email_domain_request(domain)?; - self.generated() - .remove_suspicious_email_domain(&body) + self.delete_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None) .await - .map_err(|e| self.generated_error(e))?; - Ok(()) } pub async fn check_suspicious_email_domain(&self, domain: &str) -> ApiResult { - let body = suspicious_email_domain_request(domain)?; - let response = self - .generated() - .check_suspicious_email_domain(&body) + self.check_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None) .await - .map_err(|e| self.generated_error(e))?; - self.generated_value(response.into_inner()) } pub async fn ban_phrase(&self, phrase: &str) -> ApiResult<()> { - let body = generated_types::BanPhraseRequest { - phrase: phrase.to_owned(), - }; - self.generated() - .add_phrase_ban(&body) - .await - .map_err(|e| self.generated_error(e))?; - Ok(()) + self.create_blocklist_entry( + "phrase", + generated_types::BanPhraseRequest { + phrase: phrase.to_owned(), + } + .into(), + ) + .await } pub async fn unban_phrase(&self, phrase: &str) -> ApiResult<()> { - let body = generated_types::BanPhraseRequest { - phrase: phrase.to_owned(), - }; - self.generated() - .remove_phrase_ban(&body) - .await - .map_err(|e| self.generated_error(e))?; - Ok(()) + self.delete_blocklist_entry("phrase", phrase, None).await } pub async fn check_phrase_ban(&self, phrase: &str) -> ApiResult { - let body = generated_types::BanPhraseRequest { - phrase: phrase.to_owned(), - }; - let response = self - .generated() - .check_phrase_ban_status(&body) - .await - .map_err(|e| self.generated_error(e))?; - self.generated_value(response.into_inner()) + self.check_blocklist_entry("phrase", phrase, None).await } pub async fn ban_url(&self, url: &str) -> ApiResult<()> { - let body = generated_types::BanUrlRequest { - category: None, - notes: None, - severity: None, - source_url: None, - url: url.to_owned(), - }; - self.generated() - .add_url_ban(&body) - .await - .map_err(|e| self.generated_error(e))?; - Ok(()) + self.create_blocklist_entry( + "url", + generated_types::BanUrlRequest { + category: None, + notes: None, + severity: None, + source_url: None, + url: url.to_owned(), + } + .into(), + ) + .await } pub async fn unban_url(&self, url: &str) -> ApiResult<()> { - let body = generated_types::UnbanUrlRequest { - url: url.to_owned(), - }; - self.generated() - .remove_url_ban(&body) - .await - .map_err(|e| self.generated_error(e))?; - Ok(()) + self.delete_blocklist_entry("url", url, None).await } pub async fn check_url_ban(&self, url: &str) -> ApiResult { - let body = generated_types::CheckUrlBlocklistRequest { - url: url.to_owned(), - }; - let response = self - .generated() - .check_url_ban_status(&body) - .await - .map_err(|e| self.generated_error(e))?; - self.generated_value(response.into_inner()) + self.check_blocklist_entry("url", url, None).await } pub async fn ban_url_domain(&self, domain: &str, match_subdomains: bool) -> ApiResult<()> { - let body = generated_types::BanUrlDomainRequest { - category: None, - domain: domain.to_owned(), - match_subdomains: Some(match_subdomains), - notes: None, - severity: None, - source_url: None, - }; - self.generated() - .add_url_domain_ban(&body) - .await - .map_err(|e| self.generated_error(e))?; - Ok(()) + self.create_blocklist_entry( + "url-domain", + generated_types::BanUrlDomainRequest { + category: None, + domain: domain.to_owned(), + match_subdomains: Some(match_subdomains), + notes: None, + severity: None, + source_url: None, + } + .into(), + ) + .await } pub async fn unban_url_domain(&self, domain: &str) -> ApiResult<()> { - let body = generated_types::UnbanUrlDomainRequest { - domain: domain.to_owned(), - }; - self.generated() - .remove_url_domain_ban(&body) + self.delete_blocklist_entry("url-domain", domain, None) .await - .map_err(|e| self.generated_error(e))?; - Ok(()) } pub async fn check_url_domain_ban(&self, domain: &str) -> ApiResult { - let body = generated_types::BanUrlDomainRequest { - category: None, - domain: domain.to_owned(), - match_subdomains: None, - notes: None, - severity: None, - source_url: None, - }; - let response = self - .generated() - .check_url_domain_ban_status(&body) - .await - .map_err(|e| self.generated_error(e))?; - self.generated_value(response.into_inner()) + self.check_blocklist_entry("url-domain", domain, None).await } pub async fn ban_file_sha( @@ -217,16 +131,22 @@ impl AdminApiClient { sha256_hex: &str, audit_log_reason: Option<&str>, ) -> ApiResult<()> { - let body = generated_types::BanFileShaRequest { - category: None, - content_type: None, - notes: None, - severity: None, - sha256_hex: sha256_hex.to_owned(), - source_url: None, - }; - self.post_typed_with_reason::<(), _>("/admin/bans/file-sha/add", &body, audit_log_reason) - .await + let body = generated_types::AdminBlocklistEntryCreateRequest::from( + generated_types::BanFileShaRequest { + category: None, + content_type: None, + notes: None, + severity: None, + sha256_hex: sha256_hex.to_owned(), + source_url: None, + }, + ); + self.post_void_with_reason( + "/admin/blocklists/file-sha/entries", + Some(&serde_json::to_value(&body).map_err(|e| ApiError::Parse(e.to_string()))?), + audit_log_reason, + ) + .await } pub async fn unban_file_sha( @@ -234,23 +154,17 @@ impl AdminApiClient { sha256_hex: &str, audit_log_reason: Option<&str>, ) -> ApiResult<()> { - let body = generated_types::UnbanFileShaRequest { - sha256_hex: sha256_hex.to_owned(), - }; - self.post_typed_with_reason::<(), _>("/admin/bans/file-sha/remove", &body, audit_log_reason) - .await + self.delete_void_with_reason( + &blocklist_entry_path("file-sha", sha256_hex), + None, + audit_log_reason, + ) + .await } pub async fn check_file_sha_ban(&self, sha256_hex: &str) -> ApiResult { - let body = generated_types::CheckFileShaRequest { - sha256_hex: sha256_hex.to_owned(), - }; - let response = self - .generated() - .check_file_sha_ban_status(&body) + self.check_blocklist_entry("file-sha", sha256_hex, None) .await - .map_err(|e| self.generated_error(e))?; - self.generated_value(response.into_inner()) } pub async fn bulk_ban_file_shas( @@ -261,54 +175,45 @@ impl AdminApiClient { let body = generated_types::BulkBanFileShasRequest { sha256_list: sha256_list.to_vec(), }; - self.post_typed_with_reason("/admin/bans/file-sha/bulk-add", &body, audit_log_reason) - .await + self.put_typed_with_reason( + "/admin/blocklists/file-sha/entries", + &body, + audit_log_reason, + ) + .await } pub async fn ban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> { - let body = generated_types::BanAvatarHashRequest { - category: None, - hashes: vec![hash_short.to_owned()], - notes: None, - reason: None, - severity: None, - source_url: None, - }; - self.generated() - .add_avatar_hash_ban(&body) - .await - .map_err(|e| self.generated_error(e))?; - Ok(()) + self.create_blocklist_entry( + "avatar-hash", + generated_types::BanAvatarHashRequest { + category: None, + hashes: vec![hash_short.to_owned()], + notes: None, + reason: None, + severity: None, + source_url: None, + } + .into(), + ) + .await } pub async fn unban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> { - let body = generated_types::CheckAvatarHashRequest { - hashes: vec![hash_short.to_owned()], - }; - self.generated() - .remove_avatar_hash_ban(&body) + self.delete_blocklist_entry("avatar-hash", hash_short, None) .await - .map_err(|e| self.generated_error(e))?; - Ok(()) } pub async fn check_avatar_hash_ban(&self, hash_short: &str) -> ApiResult { - let body = generated_types::CheckAvatarHashRequest { - hashes: vec![hash_short.to_owned()], - }; - let response = self - .generated() - .check_avatar_hash_ban_status(&body) + self.check_blocklist_entry("avatar-hash", hash_short, None) .await - .map_err(|e| self.generated_error(e))?; - self.generated_value(response.into_inner()) } pub async fn ban_user_avatar(&self, user_id: &str) -> ApiResult { let body = generated_types::BanUserAvatarRequest::default(); let response = self .generated() - .ban_user_avatar( + .ban_admin_user_avatar( &generated_types::SnowflakeType::from(user_id.to_owned()), &body, ) @@ -318,21 +223,16 @@ impl AdminApiClient { } pub async fn ban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> { - let body = profile_substring_request(scope, substring)?; - self.generated() - .add_profile_substring_ban(&body) - .await - .map_err(|e| self.generated_error(e))?; - Ok(()) + self.create_blocklist_entry( + PROFILE_SUBSTRING_LIST, + profile_substring_request(scope, substring)?.into(), + ) + .await } pub async fn unban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> { - let body = profile_substring_request(scope, substring)?; - self.generated() - .remove_profile_substring_ban(&body) + self.delete_blocklist_entry(PROFILE_SUBSTRING_LIST, substring, Some(scope)) .await - .map_err(|e| self.generated_error(e))?; - Ok(()) } pub async fn check_profile_substring_ban( @@ -340,16 +240,76 @@ impl AdminApiClient { scope: &str, substring: &str, ) -> ApiResult { - let body = profile_substring_request(scope, substring)?; + self.check_blocklist_entry(PROFILE_SUBSTRING_LIST, substring, Some(scope)) + .await + } + + async fn create_blocklist_entry( + &self, + list_type: &str, + body: generated_types::AdminBlocklistEntryCreateRequest, + ) -> ApiResult<()> { + self.generated() + .create_admin_blocklist_entry(list_type, &body) + .await + .map_err(|e| self.generated_error(e))?; + Ok(()) + } + + async fn delete_blocklist_entry( + &self, + list_type: &str, + entry_value: &str, + scope: Option<&str>, + ) -> ApiResult<()> { + let scope = scope.map(blocklist_delete_scope).transpose()?; + self.generated() + .delete_admin_blocklist_entry(list_type, entry_value, scope) + .await + .map_err(|e| self.generated_error(e))?; + Ok(()) + } + + async fn check_blocklist_entry( + &self, + list_type: &str, + entry_value: &str, + scope: Option<&str>, + ) -> ApiResult { + let scope = scope.map(blocklist_get_scope).transpose()?; let response = self .generated() - .check_profile_substring_ban_status(&body) + .get_admin_blocklist_entry(list_type, entry_value, scope) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) } } +const SUSPICIOUS_EMAIL_DOMAIN_LIST: &str = "email-domain-suspicious"; + +const PROFILE_SUBSTRING_LIST: &str = "profile-substring"; + +fn blocklist_entry_path(list_type: &str, entry_value: &str) -> String { + format!( + "/admin/blocklists/{}/entries/{}", + urlencoding::encode(list_type), + urlencoding::encode(entry_value) + ) +} + +fn blocklist_get_scope(scope: &str) -> ApiResult { + generated_types::GetAdminBlocklistEntryScope::try_from(scope) + .map_err(|e| ApiError::Parse(e.to_string())) +} + +fn blocklist_delete_scope( + scope: &str, +) -> ApiResult { + generated_types::DeleteAdminBlocklistEntryScope::try_from(scope) + .map_err(|e| ApiError::Parse(e.to_string())) +} + fn suspicious_email_domain_request( domain: &str, ) -> ApiResult { diff --git a/fluxer_admin/src/api/bulk.rs b/fluxer_admin/src/api/bulk.rs index 58f743395..48c7458a8 100644 --- a/fluxer_admin/src/api/bulk.rs +++ b/fluxer_admin/src/api/bulk.rs @@ -13,12 +13,16 @@ impl AdminApiClient { remove_flags: &[String], audit_log_reason: Option<&str>, ) -> ApiResult { - let body = generated_types::BulkUpdateUserFlagsRequest { - add_flags: user_flags(add_flags), - remove_flags: user_flags(remove_flags), - user_ids: snowflakes(user_ids), - }; - self.post_typed_with_reason("/admin/bulk/update-user-flags", &body, audit_log_reason) + let body = generated_types::AdminBulkJobCreateRequest::from( + generated_types::UpdateUserFlagsAdminBulkJobCreateRequest { + add_flags: user_flags(add_flags), + remove_flags: user_flags(remove_flags), + task: + generated_types::UpdateUserFlagsAdminBulkJobCreateRequestTask::UpdateUserFlags, + user_ids: snowflakes(user_ids), + }, + ); + self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason) .await } @@ -29,17 +33,16 @@ impl AdminApiClient { remove_flags: &[String], audit_log_reason: Option<&str>, ) -> ApiResult { - let body = generated_types::BulkUpdateSuspiciousActivityFlagsRequest { - add_flags: add_flags.to_vec(), - remove_flags: remove_flags.to_vec(), - user_ids: snowflakes(user_ids), - }; - self.post_typed_with_reason( - "/admin/bulk/update-suspicious-activity-flags", - &body, - audit_log_reason, - ) - .await + let body = generated_types::AdminBulkJobCreateRequest::from( + generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequest { + add_flags: add_flags.to_vec(), + remove_flags: remove_flags.to_vec(), + task: generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequestTask::UpdateSuspiciousActivityFlags, + user_ids: snowflakes(user_ids), + }, + ); + self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason) + .await } pub async fn bulk_update_guild_features( @@ -49,12 +52,15 @@ impl AdminApiClient { remove_features: &[String], audit_log_reason: Option<&str>, ) -> ApiResult { - let body = generated_types::BulkUpdateGuildFeaturesRequest { - add_features: guild_features(add_features), - guild_ids: snowflakes(guild_ids), - remove_features: guild_features(remove_features), - }; - self.post_typed_with_reason("/admin/bulk/update-guild-features", &body, audit_log_reason) + let body = generated_types::AdminBulkJobCreateRequest::from( + generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequest { + add_features: guild_features(add_features), + guild_ids: snowflakes(guild_ids), + remove_features: guild_features(remove_features), + task: generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequestTask::UpdateGuildFeatures, + }, + ); + self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason) .await } @@ -64,11 +70,15 @@ impl AdminApiClient { user_ids: &[String], audit_log_reason: Option<&str>, ) -> ApiResult { - let body = generated_types::BulkAddGuildMembersRequest { - guild_id: snowflake(guild_id), - user_ids: snowflakes(user_ids), - }; - self.post_typed_with_reason("/admin/bulk/add-guild-members", &body, audit_log_reason) + let body = generated_types::AdminBulkJobCreateRequest::from( + generated_types::AddGuildMembersAdminBulkJobCreateRequest { + guild_id: snowflake(guild_id), + task: + generated_types::AddGuildMembersAdminBulkJobCreateRequestTask::AddGuildMembers, + user_ids: snowflakes(user_ids), + }, + ); + self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason) .await } @@ -77,10 +87,14 @@ impl AdminApiClient { user_ids: &[String], audit_log_reason: Option<&str>, ) -> ApiResult { - let body = generated_types::BulkDeleteUserMessagesRequest { - user_ids: snowflakes(user_ids), - }; - self.post_typed_with_reason("/admin/bulk/delete-user-messages", &body, audit_log_reason) + let body = generated_types::AdminBulkJobCreateRequest::from( + generated_types::DeleteUserMessagesAdminBulkJobCreateRequest { + task: + generated_types::DeleteUserMessagesAdminBulkJobCreateRequestTask::DeleteUserMessages, + user_ids: snowflakes(user_ids), + }, + ); + self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason) .await } @@ -92,21 +106,24 @@ impl AdminApiClient { public_reason: Option<&str>, audit_log_reason: Option<&str>, ) -> ApiResult { - let body = generated_types::BulkScheduleUserDeletionRequest { - days_until_deletion: Some( - crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion") - .map_err(ApiError::Parse)?, - ), - public_reason: public_reason.map(std::borrow::ToOwned::to_owned), - reason_code: i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?, - user_ids: snowflakes(user_ids), - }; - self.post_typed_with_reason( - "/admin/bulk/schedule-user-deletion", - &body, - audit_log_reason, - ) - .await + let body = generated_types::AdminBulkJobCreateRequest::from( + generated_types::ScheduleUserDeletionAdminBulkJobCreateRequest { + days_until_deletion: Some( + crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion") + .map_err(ApiError::Parse)?, + ), + public_reason: public_reason.map(std::borrow::ToOwned::to_owned), + reason_code: crate::api::generated::deletion_reason_code( + i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?, + "reason_code", + ) + .map_err(ApiError::Parse)?, + task: generated_types::ScheduleUserDeletionAdminBulkJobCreateRequestTask::ScheduleUserDeletion, + user_ids: snowflakes(user_ids), + }, + ); + self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason) + .await } } diff --git a/fluxer_admin/src/api/client.rs b/fluxer_admin/src/api/client.rs index f9d6ebfa7..a4b6885c2 100644 --- a/fluxer_admin/src/api/client.rs +++ b/fluxer_admin/src/api/client.rs @@ -188,17 +188,105 @@ impl AdminApiClient { path: &str, body: Option<&serde_json::Value>, ) -> ApiResult { - let builder = Self::with_json_body(self.request(Method::PATCH, path, None), body); - let response = Self::send_request(builder).await?; + self.patch_with_reason(path, body, None).await + } + + pub async fn patch_with_reason( + &self, + path: &str, + body: Option<&serde_json::Value>, + audit_log_reason: Option<&str>, + ) -> ApiResult { + let builder = + Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason); + let response = Self::send_request(Self::with_json_body(builder, body)).await?; self.parse_response(response).await } - pub async fn delete_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> { - let builder = Self::with_json_body(self.request(Method::DELETE, path, None), body); - let response = Self::send_request(builder).await?; + pub async fn patch_typed_with_reason( + &self, + path: &str, + body: &B, + audit_log_reason: Option<&str>, + ) -> ApiResult + where + T: DeserializeOwned, + B: Serialize + ?Sized, + { + let builder = + Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason); + let response = Self::send_request(builder.json(body)).await?; + self.parse_response(response).await + } + + pub async fn put_with_reason( + &self, + path: &str, + body: Option<&serde_json::Value>, + audit_log_reason: Option<&str>, + ) -> ApiResult { + let builder = + Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason); + let response = Self::send_request(Self::with_json_body(builder, body)).await?; + self.parse_response(response).await + } + + pub async fn put_typed_with_reason( + &self, + path: &str, + body: &B, + audit_log_reason: Option<&str>, + ) -> ApiResult + where + T: DeserializeOwned, + B: Serialize + ?Sized, + { + let builder = + Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason); + let response = Self::send_request(builder.json(body)).await?; + self.parse_response(response).await + } + + pub async fn put_void_with_reason( + &self, + path: &str, + body: Option<&serde_json::Value>, + audit_log_reason: Option<&str>, + ) -> ApiResult<()> { + let builder = + Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason); + let response = Self::send_request(Self::with_json_body(builder, body)).await?; Self::parse_void_response(response).await } + pub async fn delete_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> { + self.delete_void_with_reason(path, body, None).await + } + + pub async fn delete_void_with_reason( + &self, + path: &str, + body: Option<&serde_json::Value>, + audit_log_reason: Option<&str>, + ) -> ApiResult<()> { + let builder = + Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason); + let response = Self::send_request(Self::with_json_body(builder, body)).await?; + Self::parse_void_response(response).await + } + + pub async fn delete_with_reason( + &self, + path: &str, + body: Option<&serde_json::Value>, + audit_log_reason: Option<&str>, + ) -> ApiResult { + let builder = + Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason); + let response = Self::send_request(Self::with_json_body(builder, body)).await?; + self.parse_response(response).await + } + async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> { if response.status().is_success() { Ok(()) diff --git a/fluxer_admin/src/api/codes.rs b/fluxer_admin/src/api/codes.rs index ae7fa0d5f..bdbb45f16 100644 --- a/fluxer_admin/src/api/codes.rs +++ b/fluxer_admin/src/api/codes.rs @@ -26,7 +26,7 @@ impl AdminApiClient { }; let response = self .generated() - .generate_gift_codes(&body) + .create_admin_gift_codes(&body) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) diff --git a/fluxer_admin/src/api/discovery.rs b/fluxer_admin/src/api/discovery.rs index 211893765..23a02dc44 100644 --- a/fluxer_admin/src/api/discovery.rs +++ b/fluxer_admin/src/api/discovery.rs @@ -13,7 +13,7 @@ impl AdminApiClient { ) -> ApiResult> { let response = self .generated() - .list_pending_discovery_applications() + .list_admin_discovery_applications() .await .map_err(|e| self.generated_error(e))?; response @@ -26,7 +26,7 @@ impl AdminApiClient { pub async fn list_discovery_listed_guilds(&self) -> ApiResult> { let response = self .generated() - .list_discovery_listed_guilds() + .list_admin_discovery_listings() .await .map_err(|e| self.generated_error(e))?; response @@ -42,15 +42,18 @@ impl AdminApiClient { reason: Option<&str>, ) -> ApiResult { let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned()); - let body = generated_types::DiscoveryAdminReviewRequest { - reason: reason - .map(generated_types::DiscoveryAdminReviewRequestReason::try_from) - .transpose() - .map_err(|e| ApiError::Parse(e.to_string()))?, - }; + let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from( + generated_types::ApprovedDiscoveryAdminApplicationUpdateRequest { + reason: reason + .map(generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestReason::try_from) + .transpose() + .map_err(|e| ApiError::Parse(e.to_string()))?, + status: generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestStatus::Approved, + }, + ); let response = self .generated() - .approve_discovery_application(&guild_id, &body) + .update_admin_discovery_application(&guild_id, &body) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -62,13 +65,20 @@ impl AdminApiClient { reason: &str, ) -> ApiResult { let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned()); - let body = generated_types::DiscoveryAdminRejectRequest { - reason: generated_types::DiscoveryAdminRejectRequestReason::try_from(reason) - .map_err(|e| ApiError::Parse(e.to_string()))?, - }; + let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from( + generated_types::RejectedDiscoveryAdminApplicationUpdateRequest { + reason: + generated_types::RejectedDiscoveryAdminApplicationUpdateRequestReason::try_from( + reason, + ) + .map_err(|e| ApiError::Parse(e.to_string()))?, + status: + generated_types::RejectedDiscoveryAdminApplicationUpdateRequestStatus::Rejected, + }, + ); let response = self .generated() - .reject_discovery_application(&guild_id, &body) + .update_admin_discovery_application(&guild_id, &body) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -86,7 +96,7 @@ impl AdminApiClient { }; let response = self .generated() - .remove_from_discovery(&guild_id, &body) + .delete_admin_discovery_listing(&guild_id, &body) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) diff --git a/fluxer_admin/src/api/generated.rs b/fluxer_admin/src/api/generated.rs index 22fa1e819..d3fe5853f 100644 --- a/fluxer_admin/src/api/generated.rs +++ b/fluxer_admin/src/api/generated.rs @@ -32,6 +32,14 @@ pub(crate) fn nonzero_u32(value: u32, field: &str) -> Result Result { + types::DeletionReasonCode::try_from(value) + .map_err(|_| format!("{field} is not a deletion reason code: {value}")) +} + #[cfg(test)] mod tests { use super::{number_to_u64, types::*}; diff --git a/fluxer_admin/src/api/guild_assets.rs b/fluxer_admin/src/api/guild_assets.rs index 2e8e83114..fb8465676 100644 --- a/fluxer_admin/src/api/guild_assets.rs +++ b/fluxer_admin/src/api/guild_assets.rs @@ -10,7 +10,7 @@ impl AdminApiClient { let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned()); let response = self .generated() - .admin_list_guild_emojis(&guild_id) + .list_admin_guild_emojis(&guild_id) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -23,7 +23,7 @@ impl AdminApiClient { let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned()); let response = self .generated() - .admin_list_guild_stickers(&guild_id) + .list_admin_guild_stickers(&guild_id) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) diff --git a/fluxer_admin/src/api/guilds.rs b/fluxer_admin/src/api/guilds.rs index d98443a81..42af60881 100644 --- a/fluxer_admin/src/api/guilds.rs +++ b/fluxer_admin/src/api/guilds.rs @@ -17,28 +17,20 @@ impl AdminApiClient { limit: u32, offset: u32, ) -> ApiResult { - let body = generated_types::SearchGuildsRequest { - limit: Some( - crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?, - ), - offset: Some(i64::from(offset)), - query: Some(query.to_owned()), - }; + let limit = limit.to_string(); + let offset = offset.to_string(); let response = self .generated() - .search_guilds(&body) + .list_admin_guilds(Some(limit.as_str()), Some(offset.as_str()), Some(query)) .await .map_err(|e| self.generated_error(e))?; search_guilds_response(response.into_inner()) } pub async fn get_guild_by_id(&self, guild_id: &str) -> ApiResult { - let body = generated_types::LookupGuildRequest { - guild_id: snowflake(guild_id), - }; let response = self .generated() - .lookup_guild(&body) + .get_admin_guild(&snowflake(guild_id)) .await .map_err(|e| self.generated_error(e))?; let resp: LookupGuildResponse = self.generated_value(response.into_inner())?; @@ -51,12 +43,9 @@ impl AdminApiClient { } pub async fn lookup_guild(&self, guild_id: &str) -> ApiResult> { - let body = generated_types::LookupGuildRequest { - guild_id: snowflake(guild_id), - }; let response = self .generated() - .lookup_guild(&body) + .get_admin_guild(&snowflake(guild_id)) .await .map_err(|e| self.generated_error(e))?; let resp: LookupGuildResponse = self.generated_value(response.into_inner())?; @@ -69,26 +58,23 @@ impl AdminApiClient { add_features: &[String], remove_features: &[String], ) -> ApiResult { - let body = generated_types::UpdateGuildFeaturesRequest { + let body = generated_types::UpdateGuildRequest { add_features: guild_features(add_features), - guild_id: snowflake(guild_id), remove_features: guild_features(remove_features), + ..Default::default() }; let response = self .generated() - .update_guild_features(&body) + .update_admin_guild(&snowflake(guild_id), &body) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) } pub async fn delete_guild(&self, guild_id: &str) -> ApiResult { - let body = generated_types::DeleteGuildRequest { - guild_id: snowflake(guild_id), - }; let response = self .generated() - .admin_delete_guild(&body) + .delete_admin_guild(&snowflake(guild_id)) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -99,13 +85,13 @@ impl AdminApiClient { guild_id: &str, new_owner_id: &str, ) -> ApiResult { - let body = generated_types::TransferGuildOwnershipRequest { - guild_id: snowflake(guild_id), - new_owner_id: snowflake(new_owner_id), + let body = generated_types::UpdateGuildRequest { + new_owner_id: Some(snowflake(new_owner_id)), + ..Default::default() }; let response = self .generated() - .admin_transfer_guild_ownership(&body) + .update_admin_guild(&snowflake(guild_id), &body) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -117,44 +103,32 @@ impl AdminApiClient { limit: u32, offset: u32, ) -> ApiResult { - let body = generated_types::ListGuildMembersRequest { - guild_id: snowflake(guild_id), - limit: Some( - crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?, - ), - offset: Some(i64::from(offset)), - }; + let limit = limit.to_string(); + let offset = offset.to_string(); let response = self .generated() - .admin_list_guild_members(&body) + .list_admin_guild_members( + &snowflake(guild_id), + Some(limit.as_str()), + Some(offset.as_str()), + ) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) } pub async fn ban_guild_member(&self, guild_id: &str, user_id: &str) -> ApiResult<()> { - let body = generated_types::BanGuildMemberRequest { - ban_duration_seconds: None, - delete_message_days: None, - delete_message_seconds: None, - guild_id: snowflake(guild_id), - reason: None, - user_id: snowflake(user_id), - }; + let body = generated_types::BanGuildMemberBody::default(); self.generated() - .admin_ban_guild_member(&body) + .ban_admin_guild_member(&snowflake(guild_id), &snowflake(user_id), &body) .await .map_err(|e| self.generated_error(e))?; Ok(()) } pub async fn kick_guild_member(&self, guild_id: &str, user_id: &str) -> ApiResult<()> { - let body = generated_types::KickGuildMemberRequest { - guild_id: snowflake(guild_id), - user_id: snowflake(user_id), - }; self.generated() - .kick_guild_member(&body) + .kick_admin_guild_member(&snowflake(guild_id), &snowflake(user_id)) .await .map_err(|e| self.generated_error(e))?; Ok(()) @@ -166,21 +140,22 @@ impl AdminApiClient { limit: Option, before: Option<&str>, ) -> ApiResult { - let body = generated_types::ListGuildAuditLogsRequest { - action_type: None, - after: None, - before: before.map(snowflake), - guild_id: snowflake(guild_id), - limit: limit - .map(i32::try_from) - .transpose() - .map_err(|e| ApiError::Parse(e.to_string()))? - .map(generated_types::Int32Type::from), - user_id: None, - }; + let before = before.map(snowflake); + let limit = limit + .map(i32::try_from) + .transpose() + .map_err(|e| ApiError::Parse(e.to_string()))? + .map(generated_types::Int32Type::from); let response = self .generated() - .list_guild_audit_logs_admin(&body) + .list_admin_guild_audit_logs( + &snowflake(guild_id), + None, + None, + before.as_ref(), + limit.as_ref(), + None, + ) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -189,15 +164,15 @@ impl AdminApiClient { pub async fn clear_guild_fields(&self, guild_id: &str, fields: &[String]) -> ApiResult<()> { let fields = fields .iter() - .map(generated_types::ClearGuildFieldsRequestFieldsItem::try_from) + .map(|field| generated_types::UpdateGuildRequestFieldsItem::try_from(field.as_str())) .collect::, _>>() .map_err(|e| ApiError::Parse(e.to_string()))?; - let body = generated_types::ClearGuildFieldsRequest { + let body = generated_types::UpdateGuildRequest { fields, - guild_id: snowflake(guild_id), + ..Default::default() }; self.generated() - .clear_guild_fields(&body) + .update_admin_guild(&snowflake(guild_id), &body) .await .map_err(|e| self.generated_error(e))?; Ok(()) @@ -208,10 +183,10 @@ impl AdminApiClient { guild_id: &str, settings: &serde_json::Value, ) -> ApiResult { - let body = guild_settings_request(guild_id, settings)?; + let body = guild_settings_request(settings)?; let response = self .generated() - .update_guild_settings(&body) + .update_admin_guild(&snowflake(guild_id), &body) .await .map_err(|e| self.generated_error(e))?; guild_update_response(response.into_inner()) @@ -222,13 +197,13 @@ impl AdminApiClient { guild_id: &str, name: &str, ) -> ApiResult { - let body = generated_types::UpdateGuildNameRequest { - guild_id: snowflake(guild_id), - name: name.to_owned(), + let body = generated_types::UpdateGuildRequest { + name: Some(name.to_owned()), + ..Default::default() }; let response = self .generated() - .update_guild_name(&body) + .update_admin_guild(&snowflake(guild_id), &body) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -239,37 +214,27 @@ impl AdminApiClient { guild_id: &str, vanity: Option<&str>, ) -> ApiResult { - let body = generated_types::UpdateGuildVanityRequest { - guild_id: snowflake(guild_id), - vanity_url_code: vanity.map(std::borrow::ToOwned::to_owned), - }; - let response = self - .generated() - .update_guild_vanity(&body) - .await - .map_err(|e| self.generated_error(e))?; - self.generated_value(response.into_inner()) + let body = serde_json::json!({"vanity_url_code": vanity}); + self.patch( + &format!("/admin/guilds/{}", urlencoding::encode(guild_id)), + Some(&body), + ) + .await } pub async fn reload_guild(&self, guild_id: &str) -> ApiResult { - let body = generated_types::ReloadGuildRequest { - guild_id: snowflake(guild_id), - }; let response = self .generated() - .reload_guild(&body) + .create_admin_guild_reload(&snowflake(guild_id)) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) } pub async fn shutdown_guild(&self, guild_id: &str) -> ApiResult { - let body = generated_types::ShutdownGuildRequest { - guild_id: snowflake(guild_id), - }; let response = self .generated() - .shutdown_guild(&body) + .create_admin_guild_shutdown(&snowflake(guild_id)) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -280,13 +245,9 @@ impl AdminApiClient { user_id: &str, guild_id: &str, ) -> ApiResult { - let body = generated_types::ForceAddUserToGuildRequest { - guild_id: snowflake(guild_id), - user_id: snowflake(user_id), - }; let response = self .generated() - .force_add_user_to_guild(&body) + .add_admin_guild_member(&snowflake(guild_id), &snowflake(user_id)) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -298,39 +259,23 @@ impl AdminApiClient { limit: u32, offset: u32, ) -> ApiResult { - let body = generated_types::SearchReportsRequest { - category: None, - guild_context_id: None, - limit: Some( - crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?, - ), - offset: Some(i64::from(offset)), - query: None, - report_type: None, - reported_channel_id: None, - reported_guild_id: Some(snowflake(guild_id)), - reported_user_id: None, - reporter_id: None, - resolved_by_admin_id: None, - sort_by: None, - sort_order: None, - status: None, - }; - let response = self - .generated() - .search_reports(&body) - .await - .map_err(|e| self.generated_error(e))?; - let response = response.into_inner(); - Ok(SearchReportsResponse { - reports: self.generated_value(response.reports)?, - total: crate::api::generated::number_to_u64(response.total, "total") - .map_err(ApiError::Parse)?, - offset: crate::api::generated::number_to_u64(response.offset, "offset") - .map_err(ApiError::Parse)?, - limit: crate::api::generated::number_to_u64(response.limit, "limit") - .map_err(ApiError::Parse)?, - }) + self.search_reports( + None, + None, + None, + None, + None, + None, + Some(guild_id), + None, + None, + None, + None, + None, + limit, + offset, + ) + .await } } @@ -417,22 +362,21 @@ fn guild_update_response( } fn guild_settings_request( - guild_id: &str, settings: &serde_json::Value, -) -> ApiResult { +) -> ApiResult { let patch = serde_json::from_value::(settings.clone()) .map_err(|e| ApiError::Parse(e.to_string()))?; - Ok(generated_types::UpdateGuildSettingsRequest { + Ok(generated_types::UpdateGuildRequest { content_warning_level: patch.content_warning_level, content_warning_text: patch.content_warning_text, default_message_notifications: patch.default_message_notifications, disabled_operations: patch.disabled_operations, explicit_content_filter: patch.explicit_content_filter, - guild_id: snowflake(guild_id), mfa_level: patch.mfa_level, nsfw: patch.nsfw, nsfw_level: patch.nsfw_level, verification_level: patch.verification_level, + ..Default::default() }) } @@ -459,9 +403,8 @@ mod tests { "nsfw": true, "verification_level": 2, }); - let request = guild_settings_request("123", &settings).unwrap(); + let request = guild_settings_request(&settings).unwrap(); let json = serde_json::to_value(request).unwrap(); - assert_eq!(json["guild_id"], "123"); assert_eq!(json["disabled_operations"], 5); assert_eq!(json["nsfw"], true); assert_eq!(json["verification_level"], 2); diff --git a/fluxer_admin/src/api/instance_config.rs b/fluxer_admin/src/api/instance_config.rs index 3891b4e1e..6bab25873 100644 --- a/fluxer_admin/src/api/instance_config.rs +++ b/fluxer_admin/src/api/instance_config.rs @@ -4,19 +4,18 @@ use super::client::{AdminApiClient, ApiResult}; use super::types::{ CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceConfigResponse, InstanceConfigUpdateRequest, InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse, - PendingRegistrationActionRequest, RegistrationUrlActionRequest, }; impl AdminApiClient { pub async fn get_instance_config(&self) -> ApiResult { - self.post("/admin/instance-config/get", None).await + self.get("/admin/instance/config", None).await } pub async fn update_instance_config( &self, update: &InstanceConfigUpdateRequest, ) -> ApiResult { - self.post_typed("/admin/instance-config/update", update) + self.patch_typed_with_reason("/admin/instance/config", update, None) .await } @@ -24,7 +23,7 @@ impl AdminApiClient { &self, request: &InstanceEmailSmtpTestRequest, ) -> ApiResult { - self.post_typed("/admin/instance-config/integrations/smtp/test", request) + self.post_typed("/admin/instance/config/smtp-tests", request) .await } @@ -32,40 +31,49 @@ impl AdminApiClient { &self, request: &CreateRegistrationUrlRequest, ) -> ApiResult { - self.post_typed("/admin/instance-config/registration-urls/create", request) + self.post_typed("/admin/instance/registration-urls", request) .await } pub async fn revoke_registration_url(&self, id: &str) -> ApiResult { - let request = RegistrationUrlActionRequest { id: id.to_owned() }; - self.post_typed("/admin/instance-config/registration-urls/revoke", &request) - .await + self.delete_with_reason( + &format!( + "/admin/instance/registration-urls/{}", + urlencoding::encode(id) + ), + None, + None, + ) + .await } pub async fn approve_pending_registration( &self, user_id: &str, ) -> ApiResult { - let request = PendingRegistrationActionRequest { - user_id: user_id.to_owned(), - }; - self.post_typed( - "/admin/instance-config/pending-registrations/approve", - &request, - ) - .await + self.decide_pending_registration(user_id, "approved").await } pub async fn reject_pending_registration( &self, user_id: &str, ) -> ApiResult { - let request = PendingRegistrationActionRequest { - user_id: user_id.to_owned(), - }; - self.post_typed( - "/admin/instance-config/pending-registrations/reject", - &request, + self.decide_pending_registration(user_id, "rejected").await + } + + async fn decide_pending_registration( + &self, + user_id: &str, + status: &str, + ) -> ApiResult { + let body = serde_json::json!({"status": status}); + self.patch_with_reason( + &format!( + "/admin/instance/pending-registrations/{}", + urlencoding::encode(user_id) + ), + Some(&body), + None, ) .await } diff --git a/fluxer_admin/src/api/jobs.rs b/fluxer_admin/src/api/jobs.rs index c32cb7ce1..6f9caf1af 100644 --- a/fluxer_admin/src/api/jobs.rs +++ b/fluxer_admin/src/api/jobs.rs @@ -1,8 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::api::generated::types as generated_types; - -use super::client::{AdminApiClient, ApiError, ApiResult}; +use super::client::{AdminApiClient, ApiResult}; use super::types::{ActiveJobsResponse, CancelJobResponse, GetJobResponse, ListJobsResponse}; pub struct ListJobsParams { @@ -16,51 +14,32 @@ pub struct ListJobsParams { impl AdminApiClient { pub async fn list_jobs(&self, params: &ListJobsParams) -> ApiResult { - let cursor = params - .cursor - .clone() - .map(serde_json::from_value::) - .transpose() - .map_err(|e| ApiError::Parse(e.to_string()))?; - let status = params - .status - .as_deref() - .map(generated_types::ListJobsRequestStatus::try_from) - .transpose() - .map_err(|e| ApiError::Parse(e.to_string()))?; - let body = generated_types::ListJobsRequest { - cursor, - limit: Some( - crate::api::generated::nonzero_u32(params.limit, "limit") - .map_err(ApiError::Parse)?, + let cursor = params.cursor.as_ref(); + let cursor_bucket_day = cursor_field(cursor, "bucket_day"); + let cursor_created_at = cursor_field(cursor, "created_at"); + let cursor_job_id = cursor_field(cursor, "job_id"); + let limit = params.limit.to_string(); + let max_lookback_days = params.max_lookback_days.to_string(); + let query_params = [ + ("limit", limit.as_str()), + ("cursor_bucket_day", cursor_bucket_day.as_str()), + ("cursor_created_at", cursor_created_at.as_str()), + ("cursor_job_id", cursor_job_id.as_str()), + ("max_lookback_days", max_lookback_days.as_str()), + ("status", params.status.as_deref().unwrap_or_default()), + ("task_type", params.task_type.as_deref().unwrap_or_default()), + ( + "requested_by_user_id", + params.requested_by_user_id.as_deref().unwrap_or_default(), ), - max_lookback_days: Some( - crate::api::generated::nonzero_u32(params.max_lookback_days, "max_lookback_days") - .map_err(ApiError::Parse)?, - ), - requested_by_user_id: params - .requested_by_user_id - .as_ref() - .cloned() - .map(generated_types::SnowflakeType::from), - status, - task_type: params.task_type.clone(), - }; - let response = self - .generated() - .list_jobs(&body) - .await - .map_err(|e| self.generated_error(e))?; - self.generated_value(response.into_inner()) + ]; + self.get("/admin/jobs", Some(&query_params)).await } pub async fn get_job(&self, job_id: &str) -> ApiResult { - let body = generated_types::GetJobRequest { - job_id: generated_types::SnowflakeType::from(job_id.to_owned()), - }; let response = self .generated() - .get_job(&body) + .get_admin_job(job_id) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -71,19 +50,28 @@ impl AdminApiClient { job_id: &str, audit_log_reason: Option<&str>, ) -> ApiResult { - let body = generated_types::CancelJobRequest { - job_id: generated_types::SnowflakeType::from(job_id.to_owned()), - }; - self.post_typed_with_reason("/admin/jobs/cancel", &body, audit_log_reason) - .await + self.put_with_reason( + &format!("/admin/jobs/{}/cancellation", urlencoding::encode(job_id)), + None, + audit_log_reason, + ) + .await } pub async fn list_active_jobs(&self) -> ApiResult { let response = self .generated() - .list_active_jobs() + .list_admin_active_jobs() .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) } } + +fn cursor_field(cursor: Option<&serde_json::Value>, field: &str) -> String { + cursor + .and_then(|cursor| cursor.get(field)) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned() +} diff --git a/fluxer_admin/src/api/limit_config.rs b/fluxer_admin/src/api/limit_config.rs index 66f547647..3bbdf13f6 100644 --- a/fluxer_admin/src/api/limit_config.rs +++ b/fluxer_admin/src/api/limit_config.rs @@ -5,14 +5,14 @@ use super::types::{LimitConfigResponse, LimitConfigUpdateRequest}; impl AdminApiClient { pub async fn get_limit_config(&self) -> ApiResult { - self.post("/admin/limit-config/get", Some(&serde_json::json!({}))) - .await + self.get("/admin/limit-config", None).await } pub async fn update_limit_config( &self, request: &LimitConfigUpdateRequest, ) -> ApiResult { - self.post_typed("/admin/limit-config/update", request).await + self.put_typed_with_reason("/admin/limit-config", request, None) + .await } } diff --git a/fluxer_admin/src/api/messages.rs b/fluxer_admin/src/api/messages.rs index e031c7d1d..8f0d9b2fa 100644 --- a/fluxer_admin/src/api/messages.rs +++ b/fluxer_admin/src/api/messages.rs @@ -16,12 +16,16 @@ impl AdminApiClient { message_id: &str, audit_log_reason: Option<&str>, ) -> ApiResult<()> { - let body = generated_types::DeleteMessageRequest { - channel_id: snowflake(channel_id), - message_id: snowflake(message_id), - }; let _: serde_json::Value = self - .post_typed_with_reason("/admin/messages/delete", &body, audit_log_reason) + .delete_with_reason( + &format!( + "/admin/channels/{}/messages/{}", + urlencoding::encode(channel_id), + urlencoding::encode(message_id) + ), + None, + audit_log_reason, + ) .await?; Ok(()) } @@ -50,7 +54,7 @@ impl AdminApiClient { }; let response = self .generated() - .report_message_attachment_to_ncmec(&body) + .create_admin_ncmec_report(&body) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -62,17 +66,14 @@ impl AdminApiClient { message_id: &str, context_limit: u32, ) -> ApiResult { - let body = generated_types::LookupMessageRequest { - channel_id: snowflake(channel_id), - context_limit: Some( - crate::api::generated::nonzero_u32(context_limit, "context_limit") - .map_err(ApiError::Parse)?, - ), - message_id: snowflake(message_id), - }; + let context_limit = context_limit.to_string(); let response = self .generated() - .lookup_message(&body) + .get_admin_message( + &snowflake(channel_id), + &snowflake(message_id), + Some(context_limit.as_str()), + ) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -86,16 +87,13 @@ impl AdminApiClient { let entries = entries .iter() .cloned() - .map(serde_json::from_value::) + .map(serde_json::from_value::) .collect::, _>>() .map_err(|e| ApiError::Parse(e.to_string()))?; - let body = generated_types::MessageShredRequest { - entries, - user_id: snowflake(user_id), - }; + let body = generated_types::AdminUserMessageShredRequest { entries }; let response = self .generated() - .queue_message_shred(&body) + .shred_admin_user_messages(&snowflake(user_id), &body) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -106,13 +104,10 @@ impl AdminApiClient { user_id: &str, dry_run: bool, ) -> ApiResult { - let body = generated_types::DeleteAllUserMessagesRequest { - dry_run: Some(dry_run), - user_id: snowflake(user_id), - }; + let dry_run = if dry_run { "true" } else { "false" }; let response = self .generated() - .delete_all_user_messages(&body) + .delete_admin_user_messages(&snowflake(user_id), Some(dry_run)) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -122,12 +117,9 @@ impl AdminApiClient { &self, job_id: &str, ) -> ApiResult { - let body = generated_types::MessageShredStatusRequest { - job_id: job_id.to_owned(), - }; let response = self .generated() - .get_message_shred_status(&body) + .get_admin_message_shred(job_id) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -140,18 +132,18 @@ impl AdminApiClient { filename: &str, context_limit: u32, ) -> ApiResult { - let body = generated_types::LookupMessageByAttachmentRequest { - attachment_id: snowflake(attachment_id), - channel_id: snowflake(channel_id), - context_limit: Some( - crate::api::generated::nonzero_u32(context_limit, "context_limit") - .map_err(ApiError::Parse)?, - ), - filename: filename.to_owned(), - }; + let context_limit = context_limit.to_string(); let response = self .generated() - .lookup_message_by_attachment(&body) + .search_admin_messages( + Some(&snowflake(attachment_id)), + &snowflake(channel_id), + Some(context_limit.as_str()), + Some(filename), + None, + None, + None, + ) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -164,18 +156,17 @@ impl AdminApiClient { after: Option<&str>, limit: Option, ) -> ApiResult { - let body = generated_types::BrowseChannelRequest { - after: after.map(snowflake), - before: before.map(snowflake), - channel_id: snowflake(channel_id), - limit: limit - .map(|value| crate::api::generated::nonzero_u32(value, "limit")) - .transpose() - .map_err(ApiError::Parse)?, - }; + let after = after.map(snowflake); + let before = before.map(snowflake); + let limit = limit.map(|value| value.to_string()); let response = self .generated() - .browse_channel_messages(&body) + .list_admin_channel_messages( + &snowflake(channel_id), + after.as_ref(), + before.as_ref(), + limit.as_deref(), + ) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -187,18 +178,18 @@ impl AdminApiClient { query: &str, limit: Option, ) -> ApiResult { - let body = generated_types::SearchChannelMessagesRequest { - channel_id: snowflake(channel_id), - limit: limit - .map(|value| crate::api::generated::nonzero_u32(value, "limit")) - .transpose() - .map_err(ApiError::Parse)?, - query: generated_types::SearchChannelMessagesRequestQuery::try_from(query) - .map_err(|e| ApiError::Parse(e.to_string()))?, - }; + let limit = limit.map(|value| value.to_string()); let response = self .generated() - .search_channel_messages(&body) + .search_admin_messages( + None, + &snowflake(channel_id), + None, + None, + limit.as_deref(), + None, + Some(query), + ) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) diff --git a/fluxer_admin/src/api/reports.rs b/fluxer_admin/src/api/reports.rs index 4c40139ad..4b107a51d 100644 --- a/fluxer_admin/src/api/reports.rs +++ b/fluxer_admin/src/api/reports.rs @@ -1,7 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::api::generated::types as generated_types; - use super::client::{AdminApiClient, ApiError, ApiResult}; use super::types::{ ListReportsResponse, ReportEntry, ResolveReportResponse, SearchReportsResponse, @@ -14,28 +12,21 @@ impl AdminApiClient { limit: u32, offset: Option, ) -> ApiResult { - let body = generated_types::ListReportsRequest { - limit: Some( - crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?, - ), - offset: offset.map(i64::from), - status: status - .map(generated_types::ReportStatus::try_from) - .transpose() - .map_err(|e| ApiError::Parse(e.to_string()))?, - }; - let response = self - .generated() - .list_reports(&body) - .await - .map_err(|e| self.generated_error(e))?; - self.generated_value(response.into_inner()) + let status = status.map(report_status).transpose()?.unwrap_or_default(); + let limit = limit.to_string(); + let offset = offset.map(|value| value.to_string()).unwrap_or_default(); + let query_params = [ + ("status", status), + ("limit", limit.as_str()), + ("offset", offset.as_str()), + ]; + self.get("/admin/reports", Some(&query_params)).await } pub async fn get_report(&self, report_id: &str) -> ApiResult { let response = self .generated() - .get_report(report_id) + .get_admin_report(report_id) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -47,12 +38,16 @@ impl AdminApiClient { public_comment: Option<&str>, audit_log_reason: Option<&str>, ) -> ApiResult { - let body = generated_types::ResolveReportRequest { - public_comment: public_comment.map(std::borrow::ToOwned::to_owned), - report_id: generated_types::SnowflakeType::from(report_id.to_owned()), - }; - self.post_typed_with_reason("/admin/reports/resolve", &body, audit_log_reason) - .await + let mut body = serde_json::json!({"status": "resolved"}); + if let Some(public_comment) = public_comment { + body["public_comment"] = serde_json::Value::from(public_comment); + } + self.patch_with_reason( + &format!("/admin/reports/{}", urlencoding::encode(report_id)), + Some(&body), + audit_log_reason, + ) + .await } #[allow(clippy::too_many_arguments)] @@ -73,48 +68,37 @@ impl AdminApiClient { limit: u32, offset: u32, ) -> ApiResult { - let body = generated_types::SearchReportsRequest { - category: nonempty_string(category), - guild_context_id: nonempty_snowflake(guild_context_id), - limit: Some( - crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?, + let status = status.map(report_status).transpose()?.unwrap_or_default(); + let report_type = report_type + .map(report_type_name) + .transpose()? + .unwrap_or_default(); + let sort_by = sort_by.map(report_sort_by).transpose()?.unwrap_or_default(); + let limit = limit.to_string(); + let offset = offset.to_string(); + let query_params = [ + ("q", query.unwrap_or_default()), + ("status", status), + ("report_type", report_type), + ("category", category.unwrap_or_default()), + ("reporter_id", reporter_id.unwrap_or_default()), + ("reported_user_id", reported_user_id.unwrap_or_default()), + ("reported_guild_id", reported_guild_id.unwrap_or_default()), + ( + "reported_channel_id", + reported_channel_id.unwrap_or_default(), ), - offset: Some(i64::from(offset)), - query: nonempty_string(query), - report_type: report_type - .map(generated_types::ReportType::try_from) - .transpose() - .map_err(|e| ApiError::Parse(e.to_string()))?, - reported_channel_id: nonempty_snowflake(reported_channel_id), - reported_guild_id: nonempty_snowflake(reported_guild_id), - reported_user_id: nonempty_snowflake(reported_user_id), - reporter_id: nonempty_snowflake(reporter_id), - resolved_by_admin_id: nonempty_snowflake(resolved_by_admin_id), - sort_by: sort_by - .map(generated_types::SearchReportsRequestSortBy::try_from) - .transpose() - .map_err(|e| ApiError::Parse(e.to_string()))?, - sort_order: sort_order - .map(generated_types::SearchReportsRequestSortOrder::try_from) - .transpose() - .map_err(|e| ApiError::Parse(e.to_string()))?, - status: status - .map(generated_types::ReportStatus::try_from) - .transpose() - .map_err(|e| ApiError::Parse(e.to_string()))?, - }; - let response = self - .generated() - .search_reports(&body) - .await - .map_err(|e| self.generated_error(e))?; - let response = response.into_inner(); - Ok(SearchReportsResponse { - reports: self.generated_value(response.reports)?, - total: response.total as u64, - offset: response.offset as u64, - limit: response.limit as u64, - }) + ("guild_context_id", guild_context_id.unwrap_or_default()), + ( + "resolved_by_admin_id", + resolved_by_admin_id.unwrap_or_default(), + ), + ("sort_by", sort_by), + ("sort_order", sort_order.unwrap_or_default()), + ("limit", limit.as_str()), + ("offset", offset.as_str()), + ]; + self.get("/admin/reports", Some(&query_params)).await } pub async fn search_reports_by_reporter( @@ -168,12 +152,30 @@ impl AdminApiClient { } } -fn nonempty_string(value: Option<&str>) -> Option { - value - .filter(|value| !value.is_empty()) - .map(std::borrow::ToOwned::to_owned) +fn report_status(value: i32) -> ApiResult<&'static str> { + match value { + 0 => Ok("pending"), + 1 => Ok("resolved"), + other => Err(ApiError::Parse(format!("unknown report status: {other}"))), + } } -fn nonempty_snowflake(value: Option<&str>) -> Option { - nonempty_string(value).map(generated_types::SnowflakeType::from) +fn report_type_name(value: i32) -> ApiResult<&'static str> { + match value { + 0 => Ok("message"), + 1 => Ok("user"), + 2 => Ok("guild"), + other => Err(ApiError::Parse(format!("unknown report type: {other}"))), + } +} + +fn report_sort_by(value: &str) -> ApiResult<&'static str> { + match value { + "created_at" | "createdAt" => Ok("created_at"), + "reported_at" | "reportedAt" => Ok("reported_at"), + "resolved_at" | "resolvedAt" => Ok("resolved_at"), + other => Err(ApiError::Parse(format!( + "unknown report sort field: {other}" + ))), + } } diff --git a/fluxer_admin/src/api/search.rs b/fluxer_admin/src/api/search.rs index 3368e41e3..c914e513e 100644 --- a/fluxer_admin/src/api/search.rs +++ b/fluxer_admin/src/api/search.rs @@ -2,7 +2,7 @@ use crate::api::generated::types as generated_types; -use super::client::{AdminApiClient, ApiError, ApiResult}; +use super::client::{AdminApiClient, ApiResult}; use super::types::{IndexRefreshStatusResponse, RefreshSearchIndexResponse}; impl AdminApiClient { @@ -13,13 +13,11 @@ impl AdminApiClient { ) -> ApiResult { let body = generated_types::RefreshSearchIndexRequest { guild_id: guild_id.map(|id| generated_types::SnowflakeType::from(id.to_owned())), - index_type: generated_types::RefreshSearchIndexRequestIndexType::try_from(index_type) - .map_err(|e| ApiError::Parse(e.to_string()))?, user_id: None, }; let response = self .generated() - .refresh_search_index(&body) + .create_admin_search_index_refresh(index_type, &body) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -29,12 +27,9 @@ impl AdminApiClient { &self, job_id: &str, ) -> ApiResult { - let body = generated_types::GetIndexRefreshStatusRequest { - job_id: job_id.to_owned(), - }; let response = self .generated() - .get_search_index_refresh_status(&body) + .get_admin_search_index_refresh(job_id) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) diff --git a/fluxer_admin/src/api/system.rs b/fluxer_admin/src/api/system.rs index 7fa1689c0..fff1f0ac5 100644 --- a/fluxer_admin/src/api/system.rs +++ b/fluxer_admin/src/api/system.rs @@ -2,7 +2,7 @@ use crate::api::generated::types as generated_types; -use super::client::{AdminApiClient, ApiError, ApiResult}; +use super::client::{AdminApiClient, ApiResult}; use super::types::{ GatewayVoiceStateCountsResponse, GuildMemoryStatsResponse, NodeStatsResponse, ReloadAllGuildsResponse, @@ -10,12 +10,10 @@ use super::types::{ impl AdminApiClient { pub async fn get_guild_memory_stats(&self, limit: u32) -> ApiResult { - let body = generated_types::GetProcessMemoryStatsRequest { - limit: Some(i32::try_from(limit).map_err(|e| ApiError::Parse(e.to_string()))?), - }; + let limit = limit.to_string(); let response = self .generated() - .get_guild_memory_statistics(&body) + .get_admin_gateway_memory_stats(Some(limit.as_str())) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -34,7 +32,7 @@ impl AdminApiClient { }; let response = self .generated() - .reload_all_specified_guilds(&body) + .create_admin_gateway_reload(&body) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -43,7 +41,7 @@ impl AdminApiClient { pub async fn get_node_stats(&self) -> ApiResult { let response = self .generated() - .get_gateway_node_statistics() + .get_admin_gateway_stats() .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -54,7 +52,7 @@ impl AdminApiClient { ) -> ApiResult { let response = self .generated() - .get_gateway_voice_state_counts() + .get_admin_gateway_voice_state_counts() .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) diff --git a/fluxer_admin/src/api/system_dm.rs b/fluxer_admin/src/api/system_dm.rs index 8336903ce..204a79e5e 100644 --- a/fluxer_admin/src/api/system_dm.rs +++ b/fluxer_admin/src/api/system_dm.rs @@ -22,7 +22,7 @@ impl AdminApiClient { }; let response = self .generated() - .send_system_dm(&body) + .create_admin_system_dm(&body) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) diff --git a/fluxer_admin/src/api/types/instance_config.rs b/fluxer_admin/src/api/types/instance_config.rs index aebe32e0a..1d3efc1d3 100644 --- a/fluxer_admin/src/api/types/instance_config.rs +++ b/fluxer_admin/src/api/types/instance_config.rs @@ -826,13 +826,3 @@ pub struct CreateRegistrationUrlResponse { pub code: String, pub url: String, } - -#[derive(Clone, Debug, Serialize)] -pub struct RegistrationUrlActionRequest { - pub id: String, -} - -#[derive(Clone, Debug, Serialize)] -pub struct PendingRegistrationActionRequest { - pub user_id: String, -} diff --git a/fluxer_admin/src/api/users.rs b/fluxer_admin/src/api/users.rs index 44d3b04c7..44ec778dc 100644 --- a/fluxer_admin/src/api/users.rs +++ b/fluxer_admin/src/api/users.rs @@ -17,18 +17,19 @@ impl AdminApiClient { limit: u32, offset: u32, ) -> ApiResult { - let body = generated_types::SearchUsersRequest { - email: nonempty_string(email), - last_active_ip: nonempty_string(last_active_ip), - limit: Some( - crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?, - ), - offset: Some(i64::from(offset)), - query: nonempty_string(query), - }; + let limit = limit.to_string(); + let offset = offset.to_string(); let response = self .generated() - .search_users(&body) + .list_admin_users( + nonempty(email), + nonempty(last_active_ip), + Some(limit.as_str()), + Some(offset.as_str()), + nonempty(query), + None, + None, + ) .await .map_err(|e| self.generated_error(e))?; let response = response.into_inner(); @@ -39,10 +40,9 @@ impl AdminApiClient { } pub async fn lookup_user(&self, query: &str) -> ApiResult> { - let body = generated_types::LookupUserRequest::Query(query.to_owned()); let response = self .generated() - .lookup_user(&body) + .list_admin_users(None, None, None, None, None, Some(query), None) .await .map_err(|e| self.generated_error(e))?; let resp: LookupUserResponse = self.generated_value(response.into_inner())?; @@ -53,27 +53,18 @@ impl AdminApiClient { if user_ids.is_empty() { return Ok(vec![]); } - let body = generated_types::LookupUserRequest::UserIds( - user_ids - .iter() - .cloned() - .map(generated_types::SnowflakeType::from) - .collect(), - ); - let response = self - .generated() - .lookup_user(&body) - .await - .map_err(|e| self.generated_error(e))?; - let resp: LookupUserResponse = self.generated_value(response.into_inner())?; + let query_params: Vec<(&str, &str)> = user_ids + .iter() + .map(|user_id| ("user_id", user_id.as_str())) + .collect(); + let resp: LookupUserResponse = self.get("/admin/users", Some(&query_params)).await?; Ok(resp.users) } pub async fn get_user_by_id(&self, user_id: &str) -> ApiResult { - let body = generated_types::LookupUserRequest::Query(user_id.to_owned()); let response = self .generated() - .lookup_user(&body) + .get_admin_user(&snowflake(user_id)) .await .map_err(|e| self.generated_error(e))?; let resp: LookupUserResponse = self.generated_value(response.into_inner())?; @@ -89,7 +80,7 @@ impl AdminApiClient { pub async fn get_current_admin(&self) -> ApiResult { let response = self .generated() - .get_authenticated_admin_user() + .get_current_admin_user() .await .map_err(|e| self.generated_error(e))?; let resp: AdminUserMeResponse = self.generated_value(response.into_inner())?; @@ -102,14 +93,13 @@ impl AdminApiClient { add_flags: &[String], remove_flags: &[String], ) -> ApiResult { - let body = generated_types::UpdateUserFlagsRequest { + let body = generated_types::AdminUserFlagsUpdateRequest { add_flags: user_flags(add_flags), remove_flags: user_flags(remove_flags), - user_id: snowflake(user_id), }; let response = self .generated() - .update_user_flags(&body) + .update_admin_user_flags(&snowflake(user_id), &body) .await .map_err(|e| self.generated_error(e))?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?; @@ -124,19 +114,19 @@ impl AdminApiClient { after: Option<&str>, with_counts: Option, ) -> ApiResult> { - let body = generated_types::ListUserGuildsRequest { - after: after.map(|id| generated_types::SnowflakeType::from(id.to_owned())), - before: before.map(|id| generated_types::SnowflakeType::from(id.to_owned())), - limit: Some( - crate::api::generated::nonzero_u32(limit.unwrap_or(200), "limit") - .map_err(ApiError::Parse)?, - ), - user_id: generated_types::SnowflakeType::from(user_id.to_owned()), - with_counts: Some(with_counts.unwrap_or(true)), - }; + let after = after.map(snowflake); + let before = before.map(snowflake); + let limit = limit.unwrap_or(200).to_string(); + let with_counts = bool_param(with_counts.unwrap_or(true)); let response = self .generated() - .list_user_guilds(&body) + .list_admin_user_guilds( + &snowflake(user_id), + after.as_ref(), + before.as_ref(), + Some(limit.as_str()), + Some(with_counts), + ) .await .map_err(|e| self.generated_error(e))?; let resp: ListUserGuildsResponse = self.generated_value(response.into_inner())?; @@ -147,12 +137,9 @@ impl AdminApiClient { &self, user_id: &str, ) -> ApiResult { - let body = generated_types::ListUserSessionsRequest { - user_id: generated_types::SnowflakeType::from(user_id.to_owned()), - }; let response = self .generated() - .list_user_sessions(&body) + .list_admin_user_sessions(&snowflake(user_id)) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -162,12 +149,9 @@ impl AdminApiClient { &self, user_id: &str, ) -> ApiResult { - let body = generated_types::TerminateSessionsRequest { - user_id: snowflake(user_id), - }; let response = self .generated() - .terminate_user_sessions(&body) + .terminate_admin_user_sessions(&snowflake(user_id)) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -177,12 +161,9 @@ impl AdminApiClient { &self, user_id: &str, ) -> ApiResult { - let body = generated_types::ListUserRelationshipsRequest { - user_id: generated_types::SnowflakeType::from(user_id.to_owned()), - }; let response = self .generated() - .admin_list_user_relationships(&body) + .list_admin_user_relationships(&snowflake(user_id)) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -195,18 +176,18 @@ impl AdminApiClient { after: Option<&str>, limit: Option, ) -> ApiResult { - let body = generated_types::ListUserDmChannelsRequest { - after: after.map(|id| generated_types::SnowflakeType::from(id.to_owned())), - before: before.map(|id| generated_types::SnowflakeType::from(id.to_owned())), - limit: Some( - crate::api::generated::nonzero_u32(limit.unwrap_or(50), "limit") - .map_err(ApiError::Parse)?, - ), - user_id: generated_types::SnowflakeType::from(user_id.to_owned()), - }; + let after = after.map(snowflake); + let before = before.map(snowflake); + let limit = limit.unwrap_or(50).to_string(); let response = self .generated() - .list_user_dm_channels(&body) + .list_admin_user_dm_channels( + &snowflake(user_id), + after.as_ref(), + before.as_ref(), + Some(limit.as_str()), + Some(generated_types::AdminUserDmChannelType::Dm), + ) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -216,12 +197,15 @@ impl AdminApiClient { &self, user_id: &str, ) -> ApiResult { - let body = generated_types::ListUserGroupDmChannelsRequest { - user_id: generated_types::SnowflakeType::from(user_id.to_owned()), - }; let response = self .generated() - .list_user_group_dm_channels(&body) + .list_admin_user_dm_channels( + &snowflake(user_id), + None, + None, + None, + Some(generated_types::AdminUserDmChannelType::GroupDm), + ) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -233,14 +217,13 @@ impl AdminApiClient { add_flags: &[i32], remove_flags: &[i32], ) -> ApiResult { - let body = generated_types::UpdatePremiumFlagsRequest { + let body = generated_types::AdminUserPremiumFlagsUpdateRequest { add_flags: premium_flags(add_flags), remove_flags: premium_flags(remove_flags), - user_id: snowflake(user_id), }; let response = self .generated() - .update_user_premium_flags(&body) + .update_admin_user_premium_flags(&snowflake(user_id), &body) .await .map_err(|e| self.generated_error(e))?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?; @@ -248,13 +231,12 @@ impl AdminApiClient { } pub async fn update_suspicious_flags(&self, user_id: &str, flags: i32) -> ApiResult { - let body = generated_types::UpdateSuspiciousActivityFlagsRequest { + let body = generated_types::AdminUserSuspiciousActivityFlagsRequest { flags: generated_types::SuspiciousActivityFlags::from(flags), - user_id: snowflake(user_id), }; let response = self .generated() - .update_suspicious_activity_flags(&body) + .update_admin_user_suspicious_activity_flags(&snowflake(user_id), &body) .await .map_err(|e| self.generated_error(e))?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?; @@ -262,13 +244,12 @@ impl AdminApiClient { } pub async fn set_user_acls(&self, user_id: &str, acls: &[String]) -> ApiResult { - let body = generated_types::SetUserAclsRequest { - acls: acls.to_vec(), - user_id: snowflake(user_id), + let body = generated_types::AdminUserAclsRequest { + acls: super::admin_api_keys::parse_acls(acls)?, }; let response = self .generated() - .set_user_acls(&body) + .set_admin_user_acls(&snowflake(user_id), &body) .await .map_err(|e| self.generated_error(e))?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?; @@ -276,13 +257,12 @@ impl AdminApiClient { } pub async fn set_user_traits(&self, user_id: &str, traits: &[String]) -> ApiResult { - let body = generated_types::SetUserTraitsRequest { + let body = generated_types::AdminUserTraitsRequest { traits: traits.to_vec(), - user_id: snowflake(user_id), }; let response = self .generated() - .set_user_traits(&body) + .set_admin_user_traits(&snowflake(user_id), &body) .await .map_err(|e| self.generated_error(e))?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?; @@ -290,34 +270,25 @@ impl AdminApiClient { } pub async fn disable_mfa(&self, user_id: &str) -> ApiResult<()> { - let body = generated_types::DisableMfaRequest { - user_id: snowflake(user_id), - }; self.generated() - .disable_user_mfa(&body) + .disable_admin_user_mfa(&snowflake(user_id)) .await .map_err(|e| self.generated_error(e))?; Ok(()) } pub async fn resend_verification_email(&self, user_id: &str) -> ApiResult<()> { - let body = generated_types::ResendVerificationEmailRequest { - user_id: snowflake(user_id), - }; self.generated() - .admin_resend_verification_email(&body) + .resend_admin_user_verification_email(&snowflake(user_id)) .await .map_err(|e| self.generated_error(e))?; Ok(()) } pub async fn verify_email(&self, user_id: &str) -> ApiResult { - let body = generated_types::VerifyUserEmailRequest { - user_id: snowflake(user_id), - }; let response = self .generated() - .verify_user_email(&body) + .verify_admin_user_email(&snowflake(user_id)) .await .map_err(|e| self.generated_error(e))?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?; @@ -329,13 +300,10 @@ impl AdminApiClient { user_id: &str, has_verified_phone: bool, ) -> ApiResult { - let body = generated_types::UpdateHasVerifiedPhoneRequest { - has_verified_phone, - user_id: snowflake(user_id), - }; + let body = generated_types::AdminUserPhoneVerificationRequest { has_verified_phone }; let response = self .generated() - .update_user_has_verified_phone(&body) + .update_admin_user_phone_verification(&snowflake(user_id), &body) .await .map_err(|e| self.generated_error(e))?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?; @@ -349,16 +317,15 @@ impl AdminApiClient { ) -> ApiResult { let fields = fields .iter() - .map(generated_types::ClearUserFieldsRequestFieldsItem::try_from) + .map(|field| { + generated_types::AdminUserClearFieldsRequestFieldsItem::try_from(field.as_str()) + }) .collect::, _>>() .map_err(|e| ApiError::Parse(e.to_string()))?; - let body = generated_types::ClearUserFieldsRequest { - fields, - user_id: snowflake(user_id), - }; + let body = generated_types::AdminUserClearFieldsRequest { fields }; let response = self .generated() - .clear_user_fields(&body) + .clear_admin_user_profile_fields(&snowflake(user_id), &body) .await .map_err(|e| self.generated_error(e))?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?; @@ -366,13 +333,10 @@ impl AdminApiClient { } pub async fn set_bot_status(&self, user_id: &str, is_bot: bool) -> ApiResult { - let body = generated_types::SetUserBotStatusRequest { - bot: is_bot, - user_id: snowflake(user_id), - }; + let body = generated_types::AdminUserBotStatusRequest { bot: is_bot }; let response = self .generated() - .set_user_bot_status(&body) + .set_admin_user_bot_status(&snowflake(user_id), &body) .await .map_err(|e| self.generated_error(e))?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?; @@ -380,13 +344,10 @@ impl AdminApiClient { } pub async fn set_system_status(&self, user_id: &str, is_system: bool) -> ApiResult { - let body = generated_types::SetUserSystemStatusRequest { - system: is_system, - user_id: snowflake(user_id), - }; + let body = generated_types::AdminUserSystemStatusRequest { system: is_system }; let response = self .generated() - .set_user_system_status(&body) + .set_admin_user_system_status(&snowflake(user_id), &body) .await .map_err(|e| self.generated_error(e))?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?; @@ -399,18 +360,17 @@ impl AdminApiClient { username: &str, discriminator: Option<&str>, ) -> ApiResult { - let body = generated_types::ChangeUsernameRequest { + let body = generated_types::AdminUserUsernameUpdateRequest { discriminator: discriminator .map(generated_types::DiscriminatorType::try_from) .transpose() .map_err(|e| ApiError::Parse(e.to_string()))?, - user_id: snowflake(user_id), username: generated_types::UsernameType::try_from(username) .map_err(|e| ApiError::Parse(e.to_string()))?, }; let response = self .generated() - .change_user_username(&body) + .update_admin_user_username(&snowflake(user_id), &body) .await .map_err(|e| self.generated_error(e))?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?; @@ -418,13 +378,12 @@ impl AdminApiClient { } pub async fn change_email(&self, user_id: &str, email: &str) -> ApiResult { - let body = generated_types::ChangeEmailRequest { + let body = generated_types::AdminUserEmailUpdateRequest { email: generated_types::EmailType::from(email.to_owned()), - user_id: snowflake(user_id), }; let response = self .generated() - .change_user_email(&body) + .update_admin_user_email(&snowflake(user_id), &body) .await .map_err(|e| self.generated_error(e))?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?; @@ -438,25 +397,25 @@ impl AdminApiClient { reason: Option<&str>, private_reason: Option<&str>, ) -> ApiResult { - let body = generated_types::TempBanUserRequest { + let body = generated_types::AdminUserBanRequest { duration_hours: i32::try_from(duration_hours) .map_err(|e| ApiError::Parse(e.to_string()))?, reason: reason.map(std::borrow::ToOwned::to_owned), - user_id: snowflake(user_id), }; let resp: UserMutationResponse = self - .post_typed_with_reason("/admin/users/temp-ban", &body, private_reason) + .put_typed_with_reason( + &format!("/admin/users/{}/ban", urlencoding::encode(user_id)), + &body, + private_reason, + ) .await?; Ok(resp.user) } pub async fn unban_user(&self, user_id: &str) -> ApiResult { - let body = generated_types::DisableMfaRequest { - user_id: snowflake(user_id), - }; let response = self .generated() - .unban_user(&body) + .unban_admin_user(&snowflake(user_id)) .await .map_err(|e| self.generated_error(e))?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?; @@ -470,18 +429,18 @@ impl AdminApiClient { public_reason: Option<&str>, days_until_deletion: u32, ) -> ApiResult { - let body = generated_types::ScheduleAccountDeletionRequest { + let body = generated_types::AdminUserDeletionScheduleRequest { days_until_deletion: Some( crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion") .map_err(ApiError::Parse)?, ), public_reason: public_reason.map(std::borrow::ToOwned::to_owned), - reason_code, - user_id: snowflake(user_id), + reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code") + .map_err(ApiError::Parse)?, }; let response = self .generated() - .schedule_account_deletion(&body) + .schedule_admin_user_deletion(&snowflake(user_id), &body) .await .map_err(|e| self.generated_error(e))?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?; @@ -489,12 +448,9 @@ impl AdminApiClient { } pub async fn cancel_deletion(&self, user_id: &str) -> ApiResult { - let body = generated_types::DisableMfaRequest { - user_id: snowflake(user_id), - }; let response = self .generated() - .cancel_account_deletion(&body) + .cancel_admin_user_deletion(&snowflake(user_id)) .await .map_err(|e| self.generated_error(e))?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?; @@ -502,13 +458,12 @@ impl AdminApiClient { } pub async fn change_dob(&self, user_id: &str, dob: &str) -> ApiResult { - let body = generated_types::ChangeDobRequest { + let body = generated_types::AdminUserDobUpdateRequest { date_of_birth: dob.to_owned(), - user_id: snowflake(user_id), }; let response = self .generated() - .change_user_dob(&body) + .update_admin_user_date_of_birth(&snowflake(user_id), &body) .await .map_err(|e| self.generated_error(e))?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?; @@ -516,11 +471,8 @@ impl AdminApiClient { } pub async fn send_password_reset(&self, user_id: &str) -> ApiResult<()> { - let body = generated_types::SendPasswordResetRequest { - user_id: snowflake(user_id), - }; self.generated() - .send_password_reset(&body) + .send_admin_user_password_reset(&snowflake(user_id)) .await .map_err(|e| self.generated_error(e))?; Ok(()) @@ -532,14 +484,10 @@ impl AdminApiClient { target_id: &str, category: &str, ) -> ApiResult<()> { - let body = generated_types::RemoveUserRelationshipRequest { - category: generated_types::RemoveUserRelationshipRequestCategory::try_from(category) - .map_err(|e| ApiError::Parse(e.to_string()))?, - target_user_id: snowflake(target_id), - user_id: snowflake(user_id), - }; + let category = generated_types::RemoveAdminUserRelationshipCategory::try_from(category) + .map_err(|e| ApiError::Parse(e.to_string()))?; self.generated() - .remove_user_relationship(&body) + .remove_admin_user_relationship(&snowflake(user_id), target_id, category) .await .map_err(|e| self.generated_error(e))?; Ok(()) @@ -550,16 +498,11 @@ impl AdminApiClient { user_id: &str, category: &str, ) -> ApiResult { - let body = generated_types::RemoveUserRelationshipsByCategoryRequest { - category: generated_types::RemoveUserRelationshipsByCategoryRequestCategory::try_from( - category, - ) - .map_err(|e| ApiError::Parse(e.to_string()))?, - user_id: snowflake(user_id), - }; + let category = generated_types::ClearAdminUserRelationshipsCategory::try_from(category) + .map_err(|e| ApiError::Parse(e.to_string()))?; let response = self .generated() - .remove_user_relationships_by_category(&body) + .clear_admin_user_relationships(&snowflake(user_id), category) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -570,12 +513,8 @@ impl AdminApiClient { user_id: &str, credential_id: &str, ) -> ApiResult<()> { - let body = generated_types::DeleteWebAuthnCredentialRequest { - credential_id: credential_id.to_owned(), - user_id: snowflake(user_id), - }; self.generated() - .delete_user_webauthn_credential(&body) + .delete_admin_user_webauthn_credential(&snowflake(user_id), credential_id) .await .map_err(|e| self.generated_error(e))?; Ok(()) @@ -586,17 +525,10 @@ impl AdminApiClient { user_id: &str, limit: Option, ) -> ApiResult { - let body = generated_types::ListUserChangeLogRequest { - limit: Some( - crate::api::generated::nonzero_u32(limit.unwrap_or(50), "limit") - .map_err(ApiError::Parse)?, - ), - page_token: None, - user_id: generated_types::SnowflakeType::from(user_id.to_owned()), - }; + let limit = limit.unwrap_or(50).to_string(); let response = self .generated() - .get_user_change_log(&body) + .list_admin_user_change_log(&snowflake(user_id), Some(limit.as_str()), None) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -606,24 +538,18 @@ impl AdminApiClient { &self, user_id: &str, ) -> ApiResult { - let body = generated_types::ListWebAuthnCredentialsRequest { - user_id: generated_types::SnowflakeType::from(user_id.to_owned()), - }; let response = self .generated() - .list_user_webauthn_credentials(&body) + .list_admin_user_webauthn_credentials(&snowflake(user_id)) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) } pub async fn cancel_bulk_message_deletion(&self, user_id: &str) -> ApiResult { - let body = generated_types::CancelBulkMessageDeletionRequest { - user_id: snowflake(user_id), - }; let response = self .generated() - .admin_cancel_bulk_message_deletion(&body) + .cancel_admin_user_message_deletion(&snowflake(user_id)) .await .map_err(|e| self.generated_error(e))?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?; @@ -631,10 +557,12 @@ impl AdminApiClient { } } -fn nonempty_string(value: Option<&str>) -> Option { - value - .filter(|value| !value.is_empty()) - .map(std::borrow::ToOwned::to_owned) +fn nonempty(value: Option<&str>) -> Option<&str> { + value.filter(|value| !value.is_empty()) +} + +fn bool_param(value: bool) -> &'static str { + if value { "true" } else { "false" } } fn snowflake(value: &str) -> generated_types::SnowflakeType { diff --git a/fluxer_admin/src/api/voice.rs b/fluxer_admin/src/api/voice.rs index 777d1d8c6..d71db376d 100644 --- a/fluxer_admin/src/api/voice.rs +++ b/fluxer_admin/src/api/voice.rs @@ -14,12 +14,9 @@ impl AdminApiClient { &self, include_servers: bool, ) -> ApiResult { - let body = generated_types::ListVoiceRegionsRequest { - include_servers: Some(include_servers), - }; let response = self .generated() - .list_voice_regions(&body) + .list_admin_voice_regions(Some(bool_param(include_servers))) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -30,13 +27,9 @@ impl AdminApiClient { id: &str, include_servers: bool, ) -> ApiResult { - let body = generated_types::GetVoiceRegionRequest { - id: id.to_owned(), - include_servers: Some(include_servers), - }; let response = self .generated() - .get_voice_region(&body) + .get_admin_voice_region(id, Some(bool_param(include_servers))) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -51,7 +44,7 @@ impl AdminApiClient { .map_err(|e| ApiError::Parse(e.to_string()))?; let response = self .generated() - .create_voice_region(&body) + .create_admin_voice_region(&body) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -61,34 +54,31 @@ impl AdminApiClient { &self, params: &serde_json::Value, ) -> ApiResult { + let region_id = required_field(params, "id")?; let body = serde_json::from_value::(params.clone()) .map_err(|e| ApiError::Parse(e.to_string()))?; let response = self .generated() - .update_voice_region(&body) + .update_admin_voice_region(®ion_id, &body) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) } pub async fn delete_voice_region(&self, id: &str) -> ApiResult { - let body = generated_types::DeleteVoiceRegionRequest { id: id.to_owned() }; let response = self .generated() - .delete_voice_region(&body) + .delete_admin_voice_region(id) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) } pub async fn list_voice_servers(&self, region_id: &str) -> ApiResult { - let body = generated_types::ListVoiceServersRequest { - region_id: region_id.to_owned(), - }; let response = self .generated() - .list_voice_servers(&body) + .list_admin_voice_servers(region_id) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -99,13 +89,9 @@ impl AdminApiClient { region_id: &str, server_id: &str, ) -> ApiResult { - let body = generated_types::GetVoiceServerRequest { - region_id: region_id.to_owned(), - server_id: server_id.to_owned(), - }; let response = self .generated() - .get_voice_server(&body) + .get_admin_voice_server(region_id, server_id) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -115,12 +101,13 @@ impl AdminApiClient { &self, params: &serde_json::Value, ) -> ApiResult { + let region_id = required_field(params, "region_id")?; let body = serde_json::from_value::(params.clone()) .map_err(|e| ApiError::Parse(e.to_string()))?; let response = self .generated() - .create_voice_server(&body) + .create_admin_voice_server(®ion_id, &body) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -130,12 +117,14 @@ impl AdminApiClient { &self, params: &serde_json::Value, ) -> ApiResult { + let region_id = required_field(params, "region_id")?; + let server_id = required_field(params, "server_id")?; let body = serde_json::from_value::(params.clone()) .map_err(|e| ApiError::Parse(e.to_string()))?; let response = self .generated() - .update_voice_server(&body) + .update_admin_voice_server(®ion_id, &server_id, &body) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) @@ -146,15 +135,23 @@ impl AdminApiClient { region_id: &str, server_id: &str, ) -> ApiResult { - let body = generated_types::DeleteVoiceServerRequest { - region_id: region_id.to_owned(), - server_id: server_id.to_owned(), - }; let response = self .generated() - .delete_voice_server(&body) + .delete_admin_voice_server(region_id, server_id) .await .map_err(|e| self.generated_error(e))?; self.generated_value(response.into_inner()) } } + +fn bool_param(value: bool) -> &'static str { + if value { "true" } else { "false" } +} + +fn required_field(params: &serde_json::Value, field: &str) -> ApiResult { + params + .get(field) + .and_then(serde_json::Value::as_str) + .map(std::borrow::ToOwned::to_owned) + .ok_or_else(|| ApiError::Parse(format!("{field} is required"))) +} diff --git a/fluxer_admin/src/middleware/auth.rs b/fluxer_admin/src/middleware/auth.rs index 83fb503eb..5146200a5 100644 --- a/fluxer_admin/src/middleware/auth.rs +++ b/fluxer_admin/src/middleware/auth.rs @@ -135,7 +135,7 @@ async fn fetch_admin_user( config: &crate::config::AdminConfig, session: &Session, ) -> AdminFetchResult { - let url = format!("{}/admin/users/me", config.api_endpoint); + let url = format!("{}/admin/users/@me", config.api_endpoint); let response = match crate::api::client::with_proxy_client_ip_header(http_client.get(&url), config) .header("Authorization", format!("Bearer {}", session.access_token)) diff --git a/fluxer_admin/src/routes/guild_tabs.rs b/fluxer_admin/src/routes/guild_tabs.rs index 9137cb372..86ecd5c77 100644 --- a/fluxer_admin/src/routes/guild_tabs.rs +++ b/fluxer_admin/src/routes/guild_tabs.rs @@ -158,7 +158,7 @@ pub async fn render( return None; } let apps = client - .list_user_applications(guild_id) + .list_guild_applications(guild_id) .await .map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild applications")) .unwrap_or_default(); diff --git a/fluxer_admin/src/routes/guilds.rs b/fluxer_admin/src/routes/guilds.rs index 187848f68..b1575ca41 100644 --- a/fluxer_admin/src/routes/guilds.rs +++ b/fluxer_admin/src/routes/guilds.rs @@ -415,7 +415,7 @@ async fn dispatch_guild_action( return FlashData::error("Emoji ID is required"); }; action_result( - client.purge_assets(&[emoji_id]).await, + client.purge_assets(guild_id, &[emoji_id]).await, "Emoji deleted", "Failed to delete emoji", ) @@ -425,7 +425,7 @@ async fn dispatch_guild_action( return FlashData::error("Sticker ID is required"); }; action_result( - client.purge_assets(&[sticker_id]).await, + client.purge_assets(guild_id, &[sticker_id]).await, "Sticker deleted", "Failed to delete sticker", ) diff --git a/fluxer_admin/src/routes/users.rs b/fluxer_admin/src/routes/users.rs index c4bc402f0..76a508e28 100644 --- a/fluxer_admin/src/routes/users.rs +++ b/fluxer_admin/src/routes/users.rs @@ -55,7 +55,6 @@ pub fn router() -> Router { .route("/users", get(users_list)) .route("/users/{user_id}", get(user_detail).post(user_detail_post)) .route("/users/{user_id}/tabs/{tab}", get(user_tab)) - .route("/users/{user_id}/peek", get(user_peek)) .route("/users/{user_id}/fragment", get(user_peek)) } diff --git a/fluxer_admin/src/templates/pages/jobs_list.rs b/fluxer_admin/src/templates/pages/jobs_list.rs index f4eb3493c..32e768b38 100644 --- a/fluxer_admin/src/templates/pages/jobs_list.rs +++ b/fluxer_admin/src/templates/pages/jobs_list.rs @@ -25,8 +25,8 @@ fn filter_bar(base: &str, p: &JobsListParams) -> Markup { div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4" { (select_input("status", "Status", &[ ("", "Any"), ("queued", "Queued"), ("running", "Running"), - ("succeeded", "Succeeded"), ("failed", "Failed"), - ("cancelled", "Cancelled"), ("deadletter", "Dead-letter"), + ("succeeded", "Succeeded"), ("cancelled", "Cancelled"), + ("deadletter", "Dead-letter"), ], p.status_filter)) div class="flex flex-col gap-2" { label for="task_type" class=(FORM_LABEL_CLASS) { "Task type" } diff --git a/fluxer_admin/tests/htmx_acceptance.rs b/fluxer_admin/tests/htmx_acceptance.rs index c983ec242..fbeddabd7 100644 --- a/fluxer_admin/tests/htmx_acceptance.rs +++ b/fluxer_admin/tests/htmx_acceptance.rs @@ -218,6 +218,16 @@ async fn user_fragment_alias_returns_drawer_fragment() { assert!(fragment.contains("SearchedUser"), "{fragment}"); } +#[tokio::test] +async fn user_peek_alias_is_gone() { + let app = setup().await; + + assert_eq!( + get_status(&app, "/users/1500000000000000001/peek").await, + StatusCode::NOT_FOUND + ); +} + #[tokio::test] async fn drawer_triggers_use_htmx_and_native_popover() { let app = setup().await; @@ -644,6 +654,23 @@ async fn get(app: &TestApp, uri: &str, headers: &[(&str, &str)]) -> String { get_with_headers(app, uri, headers).await.1 } +async fn get_status(app: &TestApp, uri: &str) -> StatusCode { + let response = app + .router + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri(uri) + .header(header::COOKIE, &app.session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + response.status() +} + async fn get_with_headers( app: &TestApp, uri: &str, @@ -752,8 +779,9 @@ async fn spawn_mock_api() -> String { } async fn mock_api(method: Method, uri: Uri) -> Response { - match (method, uri.path()) { - (Method::GET, "/admin/users/me") => json_response(json!({ "user": admin_user() })), + let path = uri.path().to_owned(); + match (method, path.as_str()) { + (Method::GET, "/admin/users/@me") => json_response(json!({ "user": admin_user() })), (Method::GET, "/admin/api-keys") => json_response(json!([])), (Method::POST, "/admin/api-keys") => json_response(json!({ "key_id": "1900000000000000001", @@ -763,60 +791,56 @@ async fn mock_api(method: Method, uri: Uri) -> Response { "expires_at": null, "acls": ["*"] })), - (Method::POST, "/admin/users/search") => { + (Method::GET, "/admin/users") => { json_response(json!({ "users": [searched_user()], "total": 1 })) } - (Method::POST, "/admin/users/lookup") => { + (Method::GET, "/admin/users/1500000000000000001") => { json_response(json!({ "users": [searched_user()] })) } - (Method::POST, "/admin/users/update-has-verified-phone") => { + (Method::PUT, "/admin/users/1500000000000000001/phone-verification") => { json_response(json!({ "user": searched_user() })) } - (Method::POST, "/admin/guilds/search") => { + (Method::GET, "/admin/guilds") => { json_response(json!({ "guilds": [searched_guild()], "total": 1 })) } - (Method::POST, "/admin/guilds/lookup") => { + (Method::GET, "/admin/guilds/1600000000000000001") => { json_response(json!({ "guild": searched_guild_detail() })) } - (Method::POST, "/admin/applications/lookup") => { - json_response(json!({ "application": searched_application() })) - } - (Method::POST, "/admin/applications/list-by-owner") => { + (Method::GET, "/admin/applications") => { json_response(json!({ "applications": [searched_application()] })) } - (Method::POST, "/admin/reports/search") => json_response( + (Method::GET, "/admin/reports") => json_response( json!({ "reports": [searched_report()], "total": 1, "offset": 0, "limit": 25 }), ), (Method::GET, "/admin/reports/1800000000000000001") => json_response(searched_report()), (Method::GET, "/admin/reports/1800000000000000002") => { json_response(searched_message_report()) } - (Method::POST, "/admin/reports/resolve") => json_response(json!({ + (Method::PATCH, "/admin/reports/1800000000000000001") => json_response(json!({ "report_id": "1800000000000000001", "status": 1, "resolved_at": "2026-05-26T12:03:00.000Z", "public_comment": "done" })), - (Method::POST, "/admin/jobs/list") => { + (Method::GET, "/admin/jobs") => { json_response(json!({ "jobs": [searched_job()], "next_cursor": null, "cursor": null })) } - (Method::POST, "/admin/jobs/get") => json_response(json!({ "job": searched_job() })), - (Method::POST, "/admin/instance-config/get") => json_response(instance_config()), - (Method::POST, "/admin/instance-config/registration-urls/create") => json_response(json!({ + (Method::GET, "/admin/jobs/1900000000000000001") => { + json_response(json!({ "job": searched_job() })) + } + (Method::GET, "/admin/instance/config") => json_response(instance_config()), + (Method::POST, "/admin/instance/registration-urls") => json_response(json!({ "registration_url": registration_url_fixture(), "code": "11111111-1111-4111-8111-111111111111", "url": "https://app.example.test/register?registration_url=11111111-1111-4111-8111-111111111111" })), - (Method::POST, "/admin/instance-config/registration-urls/revoke") => { + (Method::DELETE, path) if path.starts_with("/admin/instance/registration-urls/") => { json_response(instance_config_without_registration_urls()) } - (Method::POST, "/admin/instance-config/pending-registrations/approve") => { + (Method::PATCH, path) if path.starts_with("/admin/instance/pending-registrations/") => { json_response(instance_config_without_pending_registrations()) } - (Method::POST, "/admin/instance-config/pending-registrations/reject") => { - json_response(instance_config_without_pending_registrations()) - } - (Method::POST, "/admin/limit-config/get") => json_response(limit_config()), + (Method::GET, "/admin/limit-config") => json_response(limit_config()), _ => (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" }))).into_response(), } } diff --git a/fluxer_admin/tests/parity/fixtures/api_routes.json b/fluxer_admin/tests/parity/fixtures/api_routes.json index aada723e3..211c605ce 100644 --- a/fluxer_admin/tests/parity/fixtures/api_routes.json +++ b/fluxer_admin/tests/parity/fixtures/api_routes.json @@ -2,7 +2,7 @@ "routes": [ { "method": "GET", - "path": "/admin/users/me", + "path": "/admin/users/@me", "body_file": "admin_user_me.json" }, { @@ -21,28 +21,28 @@ "body": "{}" }, { - "method": "POST", - "path": "/admin/users/search", + "method": "GET", + "path": "/admin/users", "body_file": "search_users.json" }, { - "method": "POST", - "path": "/admin/users/lookup", + "method": "GET", + "path": "/admin/users/1508576042312688531", "body_file": "lookup_user.json" }, { - "method": "POST", - "path": "/admin/guilds/search", + "method": "GET", + "path": "/admin/guilds", "body_file": "search_guilds.json" }, { - "method": "POST", - "path": "/admin/guilds/lookup", + "method": "GET", + "path": "/admin/guilds/1600000000000000001", "body_file": "lookup_guild.json" }, { - "method": "POST", - "path": "/admin/reports/search", + "method": "GET", + "path": "/admin/reports", "body_file": "search_reports.json" }, { diff --git a/fluxer_api/pkgs/worker/src/contracts/WorkerTypes.ts b/fluxer_api/pkgs/worker/src/contracts/WorkerTypes.ts index 069bcb647..378a53417 100644 --- a/fluxer_api/pkgs/worker/src/contracts/WorkerTypes.ts +++ b/fluxer_api/pkgs/worker/src/contracts/WorkerTypes.ts @@ -62,4 +62,5 @@ export interface WorkerJobOptions { requestedByUserId?: bigint | undefined; auditLogReason?: string | undefined; skipLedger?: boolean | undefined; + requireLedger?: boolean | undefined; } diff --git a/fluxer_api/src/api/admin/AdminRepository.ts b/fluxer_api/src/api/admin/AdminRepository.ts index 8df71ce85..52e95591d 100644 --- a/fluxer_api/src/api/admin/AdminRepository.ts +++ b/fluxer_api/src/api/admin/AdminRepository.ts @@ -42,9 +42,11 @@ const LOAD_ALL_BANNED_IPS_QUERY = BannedIps.select(); const IS_EMAIL_BANNED_QUERY = BannedEmails.select({ where: BannedEmails.where.eq('email_lower'), }); +const LOAD_ALL_BANNED_EMAILS_QUERY = BannedEmails.select(); const IS_EMAIL_DOMAIN_SUSPICIOUS_QUERY = SuspiciousEmailDomains.select({ where: SuspiciousEmailDomains.where.eq('domain'), }); +const LOAD_ALL_SUSPICIOUS_EMAIL_DOMAINS_QUERY = SuspiciousEmailDomains.select(); const IS_EMAIL_DOMAIN_DISPOSABLE_QUERY = DisposableEmailDomains.select({ where: DisposableEmailDomains.where.eq('domain'), }); @@ -246,6 +248,13 @@ export class AdminRepository implements IAdminRepository { await deleteOneOrMany(BannedEmails.deleteByPk({email_lower: emailLower})); } + async loadAllBannedEmails(): Promise> { + const rows = await fetchMany<{ + email_lower: string; + }>(LOAD_ALL_BANNED_EMAILS_QUERY.bind({})); + return rows.map((row) => row.email_lower); + } + async isEmailDomainSuspicious(domain: string): Promise { const domainLower = domain.toLowerCase(); if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false; @@ -265,6 +274,13 @@ export class AdminRepository implements IAdminRepository { await deleteOneOrMany(SuspiciousEmailDomains.deleteByPk({domain: domainLower})); } + async loadAllSuspiciousEmailDomains(): Promise> { + const rows = await fetchMany<{ + domain: string; + }>(LOAD_ALL_SUSPICIOUS_EMAIL_DOMAINS_QUERY.bind({})); + return rows.map((row) => row.domain); + } + async isEmailDomainDisposable(domain: string): Promise { const domainLower = domain.toLowerCase(); if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false; diff --git a/fluxer_api/src/api/admin/AdminService.ts b/fluxer_api/src/api/admin/AdminService.ts index ca41c9b05..bae2dee81 100644 --- a/fluxer_api/src/api/admin/AdminService.ts +++ b/fluxer_api/src/api/admin/AdminService.ts @@ -188,10 +188,14 @@ export class AdminService { adminUserId: UserID, auditLogReason: string | null, ): Promise { - await this.apiContext.services.worker.addJob('sendSystemDm', { - content: data.content, - user_ids: data.userIds, - }); + await this.apiContext.services.worker.addJob( + 'sendSystemDm', + { + content: data.content, + user_ids: data.userIds, + }, + {requireLedger: true}, + ); const metadata = new Map([ ['recipient_count', data.userIds.length.toString()], ['content_length', data.content.length.toString()], diff --git a/fluxer_api/src/api/admin/IAdminRepository.ts b/fluxer_api/src/api/admin/IAdminRepository.ts index 45d55742a..2067bec2e 100644 --- a/fluxer_api/src/api/admin/IAdminRepository.ts +++ b/fluxer_api/src/api/admin/IAdminRepository.ts @@ -57,12 +57,16 @@ export abstract class IAdminRepository { abstract unbanEmail(email: string): Promise; + abstract loadAllBannedEmails(): Promise>; + abstract isEmailDomainSuspicious(domain: string): Promise; abstract addSuspiciousEmailDomain(domain: string): Promise; abstract removeSuspiciousEmailDomain(domain: string): Promise; + abstract loadAllSuspiciousEmailDomains(): Promise>; + abstract isEmailDomainDisposable(domain: string): Promise; abstract addDisposableEmailDomain(domain: string): Promise; diff --git a/fluxer_api/src/api/admin/controllers/AdminApiKeyAdminController.ts b/fluxer_api/src/api/admin/controllers/AdminApiKeyAdminController.ts index 19a929a13..0cf8b047e 100644 --- a/fluxer_api/src/api/admin/controllers/AdminApiKeyAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/AdminApiKeyAdminController.ts @@ -8,6 +8,7 @@ import { DeleteApiKeyResponse, ListAdminApiKeyResponse, type ListAdminApiKeyResponse as ListAdminApiKeyResponseType, + UpdateAdminApiKeyRequest, } from '@fluxer/schema/src/domains/admin/AdminSchemas'; import {KeyIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; import {z} from 'zod'; @@ -17,6 +18,19 @@ import {OpenAPI} from '../../middleware/ResponseTypeMiddleware'; import {RateLimitConfigs} from '../../RateLimitConfig'; import type {HonoApp} from '../../types/HonoEnv'; import {Validator} from '../../Validator'; +import type {AdminApiKeyView} from '../services/AdminApiKeyService'; + +function toApiKeyResponse(key: AdminApiKeyView): ListAdminApiKeyResponseType { + return { + key_id: key.keyId, + name: key.name, + created_at: key.createdAt.toISOString(), + last_used_at: key.lastUsedAt?.toISOString() ?? null, + expires_at: key.expiresAt?.toISOString() ?? null, + created_by_user_id: String(key.createdById), + acls: Array.from(key.acls), + }; +} export function AdminApiKeyAdminController(app: HonoApp) { app.post( @@ -53,6 +67,7 @@ export function AdminApiKeyAdminController(app: HonoApp) { ); app.get( '/admin/api-keys', + RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.ADMIN_API_KEY_MANAGE), OpenAPI({ operationId: 'list_admin_api_keys', @@ -68,26 +83,66 @@ export function AdminApiKeyAdminController(app: HonoApp) { const adminApiKeyService = ctx.get('adminApiKeyService'); const user = ctx.get('user'); const keys = await adminApiKeyService.listKeys(user.id); - const response: Array = keys.map((key) => ({ - key_id: key.keyId, - name: key.name, - created_at: key.createdAt.toISOString(), - last_used_at: key.lastUsedAt?.toISOString() ?? null, - expires_at: key.expiresAt?.toISOString() ?? null, - created_by_user_id: String(key.createdById), - acls: Array.from(key.acls), - })); + const response: Array = keys.map(toApiKeyResponse); return ctx.json(response); }, ); + app.get( + '/admin/api-keys/:key_id', + RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), + requireAdminACL(AdminACLs.ADMIN_API_KEY_MANAGE), + Validator('param', KeyIdParam), + OpenAPI({ + operationId: 'get_admin_api_key', + summary: 'Get admin API key', + responseSchema: ListAdminApiKeyResponse, + statusCode: 200, + security: ['adminApiKey'], + tags: ['Admin'], + description: + 'Retrieves a single API key created by the authenticated admin. Returns metadata including creation time, last used time, and assigned permissions. The actual key material is never returned.', + }), + async (ctx) => { + const adminApiKeyService = ctx.get('adminApiKeyService'); + const user = ctx.get('user'); + const keyId = ctx.req.valid('param').key_id; + const key = await adminApiKeyService.getKey(keyId, user.id); + return ctx.json(toApiKeyResponse(key)); + }, + ); + app.patch( + '/admin/api-keys/:key_id', + RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), + requireAdminACL(AdminACLs.ADMIN_API_KEY_MANAGE), + Validator('param', KeyIdParam), + Validator('json', UpdateAdminApiKeyRequest), + OpenAPI({ + operationId: 'update_admin_api_key', + summary: 'Update admin API key', + responseSchema: ListAdminApiKeyResponse, + statusCode: 200, + security: ['adminApiKey'], + tags: ['Admin'], + description: + 'Renames an API key or replaces the access control lists (ACLs) it carries. The key may only carry permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.', + }), + async (ctx) => { + const adminApiKeyService = ctx.get('adminApiKeyService'); + const user = ctx.get('user'); + const adminUserAcls = ctx.get('adminUserAcls'); + const keyId = ctx.req.valid('param').key_id; + const key = await adminApiKeyService.updateKey(keyId, user.id, ctx.req.valid('json'), adminUserAcls); + return ctx.json(toApiKeyResponse(key)); + }, + ); app.delete( - '/admin/api-keys/:keyId', + '/admin/api-keys/:key_id', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.ADMIN_API_KEY_MANAGE), Validator('param', KeyIdParam), OpenAPI({ operationId: 'delete_admin_api_key', - summary: 'Delete admin API key', + summary: 'Revoke admin API key', responseSchema: DeleteApiKeyResponse, statusCode: 200, security: ['adminApiKey'], @@ -98,7 +153,7 @@ export function AdminApiKeyAdminController(app: HonoApp) { async (ctx) => { const adminApiKeyService = ctx.get('adminApiKeyService'); const user = ctx.get('user'); - const keyId = ctx.req.valid('param').keyId; + const keyId = ctx.req.valid('param').key_id; await adminApiKeyService.revokeKey(keyId, user.id); return ctx.json({success: true}, 200); }, diff --git a/fluxer_api/src/api/admin/controllers/ApplicationAdminController.ts b/fluxer_api/src/api/admin/controllers/ApplicationAdminController.ts index 16670b9ac..cb5bf527c 100644 --- a/fluxer_api/src/api/admin/controllers/ApplicationAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/ApplicationAdminController.ts @@ -1,16 +1,18 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; +import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError'; import { + AdminApplicationIdParam, ApplicationUpdateResponse, - ListGuildApplicationsRequest, - ListGuildApplicationsResponse, - ListUserApplicationsRequest, - ListUserApplicationsResponse, - LookupApplicationRequest, + ListApplicationsQuery, + ListApplicationsResponse, LookupApplicationResponse, TransferApplicationOwnershipRequest, } from '@fluxer/schema/src/domains/admin/AdminApplicationSchemas'; +import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; +import {createApplicationID, createGuildID, createUserID} from '../../BrandedTypes'; import {requireAdminACL, requireAnyAdminACL} from '../../middleware/AdminMiddleware'; import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware'; import {OpenAPI} from '../../middleware/ResponseTypeMiddleware'; @@ -18,15 +20,73 @@ import {RateLimitConfigs} from '../../RateLimitConfig'; import type {HonoApp} from '../../types/HonoEnv'; import {Validator} from '../../Validator'; +function requireRequestAdminACL(granted: ReadonlySet, required: string): void { + if (!granted.has(required) && !granted.has(AdminACLs.WILDCARD)) { + throw new MissingACLError(required); + } +} + export function ApplicationAdminController(app: HonoApp) { - app.post( - '/admin/applications/lookup', + app.get( + '/admin/applications', + RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), + requireAnyAdminACL([AdminACLs.APPLICATION_LOOKUP, AdminACLs.APPLICATION_LIST_BY_OWNER]), + Validator('query', ListApplicationsQuery), + OpenAPI({ + operationId: 'list_admin_applications', + summary: 'List applications', + description: + 'Lists OAuth2 applications and bots. Pass owner_id to list the applications a user owns, or guild_id to list the applications whose bot users are members of a guild. Exactly one of the two is required. owner_id requires APPLICATION_LIST_BY_OWNER permission, guild_id requires APPLICATION_LOOKUP permission.', + responseSchema: ListApplicationsResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const {owner_id: ownerId, guild_id: guildId} = ctx.req.valid('query'); + if (guildId != null && ownerId != null) { + throw InputValidationError.create('guild_id', 'Only one of owner_id and guild_id may be supplied'); + } + if (guildId != null) { + requireRequestAdminACL(ctx.get('adminUserAcls'), AdminACLs.APPLICATION_LOOKUP); + return ctx.json(await adminService.applicationService.listGuildApplications(createGuildID(guildId))); + } + if (ownerId != null) { + requireRequestAdminACL(ctx.get('adminUserAcls'), AdminACLs.APPLICATION_LIST_BY_OWNER); + return ctx.json(await adminService.applicationService.listUserApplications(createUserID(ownerId))); + } + throw InputValidationError.create('owner_id', 'One of owner_id and guild_id is required'); + }, + ); + app.get( + '/admin/users/:user_id/applications', + RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), + requireAdminACL(AdminACLs.APPLICATION_LIST_BY_OWNER), + Validator('param', UserIdParam), + OpenAPI({ + operationId: 'list_admin_user_applications', + summary: 'List user applications', + description: 'Lists the OAuth2 applications and bots a user owns. Requires APPLICATION_LIST_BY_OWNER permission.', + responseSchema: ListApplicationsResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const userId = createUserID(ctx.req.valid('param').user_id); + return ctx.json(await adminService.applicationService.listUserApplications(userId)); + }, + ); + app.get( + '/admin/applications/:application_id', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.APPLICATION_LOOKUP), - Validator('json', LookupApplicationRequest), + Validator('param', AdminApplicationIdParam), OpenAPI({ - operationId: 'lookup_application', - summary: 'Look up application', + operationId: 'get_admin_application', + summary: 'Get application', description: 'Retrieves complete application details including ownership, bot user, OAuth2 redirect URIs, and credential status. Requires APPLICATION_LOOKUP permission.', responseSchema: LookupApplicationResponse, @@ -36,59 +96,21 @@ export function ApplicationAdminController(app: HonoApp) { }), async (ctx) => { const adminService = ctx.get('adminService'); - return ctx.json(await adminService.applicationService.lookupApplication(ctx.req.valid('json'))); + const applicationId = createApplicationID(ctx.req.valid('param').application_id); + return ctx.json(await adminService.applicationService.lookupApplication(applicationId)); }, ); - app.post( - '/admin/applications/list-by-owner', - RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), - requireAdminACL(AdminACLs.APPLICATION_LIST_BY_OWNER), - Validator('json', ListUserApplicationsRequest), - OpenAPI({ - operationId: 'admin_list_user_applications', - summary: 'List applications owned by a user', - description: - 'Lists all applications (OAuth2 clients and bots) owned by a specific user. Requires APPLICATION_LIST_BY_OWNER permission.', - responseSchema: ListUserApplicationsResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - return ctx.json(await adminService.applicationService.listUserApplications(ctx.req.valid('json'))); - }, - ); - app.post( - '/admin/applications/list-by-guild', - RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), - requireAnyAdminACL([AdminACLs.APPLICATION_LOOKUP, AdminACLs.APPLICATION_LIST_BY_OWNER]), - Validator('json', ListGuildApplicationsRequest), - OpenAPI({ - operationId: 'admin_list_guild_applications', - summary: 'List applications installed in a guild', - description: - 'Lists OAuth2 applications whose bot users are members of a guild. Requires APPLICATION_LOOKUP or APPLICATION_LIST_BY_OWNER permission.', - responseSchema: ListGuildApplicationsResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - return ctx.json(await adminService.applicationService.listGuildApplications(ctx.req.valid('json'))); - }, - ); - app.post( - '/admin/applications/transfer-ownership', + app.patch( + '/admin/applications/:application_id', RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY), requireAdminACL(AdminACLs.APPLICATION_TRANSFER_OWNERSHIP), + Validator('param', AdminApplicationIdParam), Validator('json', TransferApplicationOwnershipRequest), OpenAPI({ - operationId: 'transfer_application_ownership', - summary: 'Transfer application ownership', + operationId: 'update_admin_application', + summary: 'Update application', description: - 'Transfers application ownership to another user. Used when owner is inactive or for administrative recovery. Logged to audit log. Requires APPLICATION_TRANSFER_OWNERSHIP permission.', + 'Updates an application. Transfers ownership to the user given by new_owner_id, which is used when the owner is inactive or for administrative recovery. Logged to audit log. Requires APPLICATION_TRANSFER_OWNERSHIP permission.', responseSchema: ApplicationUpdateResponse, statusCode: 200, security: 'adminApiKey', @@ -98,8 +120,10 @@ export function ApplicationAdminController(app: HonoApp) { const adminService = ctx.get('adminService'); const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); + const applicationId = createApplicationID(ctx.req.valid('param').application_id); return ctx.json( await adminService.applicationService.transferApplicationOwnership( + applicationId, ctx.req.valid('json'), adminUserId, auditLogReason, diff --git a/fluxer_api/src/api/admin/controllers/ArchiveAdminController.ts b/fluxer_api/src/api/admin/controllers/ArchiveAdminController.ts index ff1e07eef..11a914e90 100644 --- a/fluxer_api/src/api/admin/controllers/ArchiveAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/ArchiveAdminController.ts @@ -3,15 +3,14 @@ import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError'; import { + AdminArchiveCreateRequest, AdminArchiveResponseSchema, DownloadUrlResponseSchema, GetArchiveResponseSchema, - ListArchivesRequest, + ListArchivesQuery, ListArchivesResponseSchema, - TriggerGuildArchiveRequest, - TriggerUserArchiveRequest, } from '@fluxer/schema/src/domains/admin/AdminSchemas'; -import {ArchivePathParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; +import {ArchivePathParam, GuildIdParam, UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; import {createGuildID, createUserID} from '../../BrandedTypes'; import {requireAdminACL, requireAnyAdminACL} from '../../middleware/AdminMiddleware'; import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware'; @@ -26,15 +25,21 @@ function canViewArchive(adminAcls: Set, subjectType: 'user' | 'guild'): return adminAcls.has(AdminACLs.ARCHIVE_TRIGGER_GUILD); } +function requireArchiveSubjectAccess(adminAcls: Set, subjectType: 'user' | 'guild'): void { + if (canViewArchive(adminAcls, subjectType) || adminAcls.has(AdminACLs.WILDCARD)) return; + throw new MissingACLError(subjectType === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD); +} + export function ArchiveAdminController(app: HonoApp) { app.post( - '/admin/archives/user', + '/admin/users/:user_id/archives', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.ARCHIVE_TRIGGER_USER), - Validator('json', TriggerUserArchiveRequest), + Validator('param', UserIdParam), + Validator('json', AdminArchiveCreateRequest), OpenAPI({ - operationId: 'trigger_user_archive', - summary: 'Trigger user archive', + operationId: 'create_admin_user_archive', + summary: 'Create user archive', responseSchema: AdminArchiveResponseSchema, statusCode: 200, security: ['adminApiKey'], @@ -45,23 +50,23 @@ export function ArchiveAdminController(app: HonoApp) { async (ctx) => { const adminArchiveService = ctx.get('adminArchiveService'); const adminUserId = ctx.get('adminUserId'); - const body = ctx.req.valid('json'); const result = await adminArchiveService.triggerUserArchive( - createUserID(body.user_id), + createUserID(ctx.req.valid('param').user_id), adminUserId, - body.include_attachments, + ctx.req.valid('json').include_attachments, ); return ctx.json(result, 200); }, ); app.post( - '/admin/archives/guild', + '/admin/guilds/:guild_id/archives', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.ARCHIVE_TRIGGER_GUILD), - Validator('json', TriggerGuildArchiveRequest), + Validator('param', GuildIdParam), + Validator('json', AdminArchiveCreateRequest), OpenAPI({ - operationId: 'trigger_guild_archive', - summary: 'Trigger guild archive', + operationId: 'create_admin_guild_archive', + summary: 'Create guild archive', responseSchema: AdminArchiveResponseSchema, statusCode: 200, security: ['adminApiKey'], @@ -72,22 +77,21 @@ export function ArchiveAdminController(app: HonoApp) { async (ctx) => { const adminArchiveService = ctx.get('adminArchiveService'); const adminUserId = ctx.get('adminUserId'); - const body = ctx.req.valid('json'); const result = await adminArchiveService.triggerGuildArchive( - createGuildID(body.guild_id), + createGuildID(ctx.req.valid('param').guild_id), adminUserId, - body.include_attachments, + ctx.req.valid('json').include_attachments, ); return ctx.json(result, 200); }, ); - app.post( - '/admin/archives/list', + app.get( + '/admin/archives', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAnyAdminACL([AdminACLs.ARCHIVE_VIEW_ALL, AdminACLs.ARCHIVE_TRIGGER_USER, AdminACLs.ARCHIVE_TRIGGER_GUILD]), - Validator('json', ListArchivesRequest), + Validator('query', ListArchivesQuery), OpenAPI({ - operationId: 'list_archives', + operationId: 'list_admin_archives', summary: 'List archives', responseSchema: ListArchivesResponseSchema, statusCode: 200, @@ -99,40 +103,34 @@ export function ArchiveAdminController(app: HonoApp) { async (ctx) => { const adminArchiveService = ctx.get('adminArchiveService'); const adminAcls = ctx.get('adminUserAcls'); - const body = ctx.req.valid('json') as ListArchivesRequest; + const query = ctx.req.valid('query'); if ( - body.subject_type === 'all' && + query.subject_type === 'all' && !adminAcls.has(AdminACLs.ARCHIVE_VIEW_ALL) && !adminAcls.has(AdminACLs.WILDCARD) ) { throw new MissingACLError(AdminACLs.ARCHIVE_VIEW_ALL); } - if ( - body.subject_type !== 'all' && - !canViewArchive(adminAcls, body.subject_type) && - !adminAcls.has(AdminACLs.WILDCARD) - ) { - throw new MissingACLError( - body.subject_type === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD, - ); + if (query.subject_type !== 'all') { + requireArchiveSubjectAccess(adminAcls, query.subject_type); } const result = await adminArchiveService.listArchives({ - subjectType: body.subject_type as 'user' | 'guild' | 'all', - subjectId: body.subject_id ?? undefined, - requestedBy: body.requested_by ?? undefined, - limit: body.limit, - includeExpired: body.include_expired, + subjectType: query.subject_type, + subjectId: query.subject_id ?? undefined, + requestedBy: query.requested_by ?? undefined, + limit: query.limit, + includeExpired: query.include_expired, }); return ctx.json({archives: result}, 200); }, ); app.get( - '/admin/archives/:subjectType/:subjectId/:archiveId', + '/admin/archives/:subject_type/:subject_id/:archive_id', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAnyAdminACL([AdminACLs.ARCHIVE_VIEW_ALL, AdminACLs.ARCHIVE_TRIGGER_USER, AdminACLs.ARCHIVE_TRIGGER_GUILD]), Validator('param', ArchivePathParam), OpenAPI({ - operationId: 'get_archive_details', + operationId: 'get_admin_archive', summary: 'Get archive details', responseSchema: GetArchiveResponseSchema, statusCode: 200, @@ -145,25 +143,18 @@ export function ArchiveAdminController(app: HonoApp) { const adminArchiveService = ctx.get('adminArchiveService'); const adminAcls = ctx.get('adminUserAcls'); const params = ctx.req.valid('param'); - const subjectType = params.subjectType; - if (!canViewArchive(adminAcls, subjectType) && !adminAcls.has(AdminACLs.WILDCARD)) { - throw new MissingACLError( - subjectType === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD, - ); - } - const subjectId = params.subjectId; - const archiveId = params.archiveId; - const archive = await adminArchiveService.getArchive(subjectType, subjectId, archiveId); + requireArchiveSubjectAccess(adminAcls, params.subject_type); + const archive = await adminArchiveService.getArchive(params.subject_type, params.subject_id, params.archive_id); return ctx.json({archive}, 200); }, ); app.get( - '/admin/archives/:subjectType/:subjectId/:archiveId/download', + '/admin/archives/:subject_type/:subject_id/:archive_id/download', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAnyAdminACL([AdminACLs.ARCHIVE_VIEW_ALL, AdminACLs.ARCHIVE_TRIGGER_USER, AdminACLs.ARCHIVE_TRIGGER_GUILD]), Validator('param', ArchivePathParam), OpenAPI({ - operationId: 'get_archive_download_url', + operationId: 'get_admin_archive_download', summary: 'Get archive download URL', responseSchema: DownloadUrlResponseSchema, statusCode: 200, @@ -176,15 +167,12 @@ export function ArchiveAdminController(app: HonoApp) { const adminArchiveService = ctx.get('adminArchiveService'); const adminAcls = ctx.get('adminUserAcls'); const params = ctx.req.valid('param'); - const subjectType = params.subjectType; - if (!canViewArchive(adminAcls, subjectType) && !adminAcls.has(AdminACLs.WILDCARD)) { - throw new MissingACLError( - subjectType === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD, - ); - } - const subjectId = params.subjectId; - const archiveId = params.archiveId; - const result = await adminArchiveService.getDownloadUrl(subjectType, subjectId, archiveId); + requireArchiveSubjectAccess(adminAcls, params.subject_type); + const result = await adminArchiveService.getDownloadUrl( + params.subject_type, + params.subject_id, + params.archive_id, + ); return ctx.json(result, 200); }, ); diff --git a/fluxer_api/src/api/admin/controllers/AssetAdminController.ts b/fluxer_api/src/api/admin/controllers/AssetAdminController.ts index f4a39f647..b12056c98 100644 --- a/fluxer_api/src/api/admin/controllers/AssetAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/AssetAdminController.ts @@ -2,6 +2,8 @@ import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; import {PurgeGuildAssetsRequest, PurgeGuildAssetsResponseSchema} from '@fluxer/schema/src/domains/admin/AdminSchemas'; +import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; +import {createGuildID} from '../../BrandedTypes'; import {requireAdminACL} from '../../middleware/AdminMiddleware'; import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware'; import {OpenAPI} from '../../middleware/ResponseTypeMiddleware'; @@ -10,20 +12,21 @@ import type {HonoApp} from '../../types/HonoEnv'; import {Validator} from '../../Validator'; export function AssetAdminController(app: HonoApp) { - app.post( - '/admin/assets/purge', + app.delete( + '/admin/guilds/:guild_id/assets', RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY), requireAdminACL(AdminACLs.ASSET_PURGE), + Validator('param', GuildIdParam), Validator('json', PurgeGuildAssetsRequest), OpenAPI({ - operationId: 'purge_guild_assets', + operationId: 'purge_admin_guild_assets', summary: 'Purge guild assets', responseSchema: PurgeGuildAssetsResponseSchema, statusCode: 200, security: ['adminApiKey'], tags: ['Admin'], description: - 'Delete and clean up all assets belonging to a guild, including icons, banners, and other media. This is a destructive operation used for cleanup during guild management or compliance actions.', + 'Delete and clean up emoji and sticker assets belonging to a guild, including their stored media. An ID owned by another guild is reported in errors and left untouched, and an ID with no record still queues its media for removal. This is a destructive operation used for cleanup during guild management or compliance actions.', }), async (ctx) => { const adminService = ctx.get('adminService'); @@ -32,6 +35,7 @@ export function AssetAdminController(app: HonoApp) { const data = ctx.req.valid('json'); return ctx.json( await adminService.assetPurgeService.purgeGuildAssets({ + guildId: createGuildID(ctx.req.valid('param').guild_id), ids: data.ids, adminUserId, auditLogReason, diff --git a/fluxer_api/src/api/admin/controllers/AuditLogAdminController.ts b/fluxer_api/src/api/admin/controllers/AuditLogAdminController.ts index 26c5fbaf3..113166485 100644 --- a/fluxer_api/src/api/admin/controllers/AuditLogAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/AuditLogAdminController.ts @@ -1,10 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError'; import { + AdminAuditLogResponseSchema, + AuditLogIdParam, AuditLogsListResponseSchema, - ListAuditLogsRequest, - SearchAuditLogsRequest, + ListAdminAuditLogsQuery, } from '@fluxer/schema/src/domains/admin/AdminSchemas'; import {requireAdminACL} from '../../middleware/AdminMiddleware'; import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware'; @@ -14,44 +17,66 @@ import type {HonoApp} from '../../types/HonoEnv'; import {Validator} from '../../Validator'; export function AuditLogAdminController(app: HonoApp) { - app.post( + app.get( '/admin/audit-logs', RateLimitMiddleware(RateLimitConfigs.ADMIN_AUDIT_LOG), requireAdminACL(AdminACLs.AUDIT_LOG_VIEW), - Validator('json', ListAuditLogsRequest), + Validator('query', ListAdminAuditLogsQuery), OpenAPI({ - operationId: 'list_audit_logs', - summary: 'List audit logs', + operationId: 'list_admin_audit_logs', + summary: 'List admin audit logs', responseSchema: AuditLogsListResponseSchema, statusCode: 200, security: ['adminApiKey'], tags: ['Admin'], description: - 'Retrieve a paginated list of audit logs with optional filtering by date range, action type, or actor. Used for tracking administrative operations and compliance auditing.', + 'Retrieve a paginated page of audit logs with optional filtering by acting admin, target type, or target ID. Passing q runs a full-text search across the audit log index instead of paging through the log in order, and sort_by with sort_order then order the matches. Used for tracking administrative operations, compliance auditing, and incident response.', }), async (ctx) => { const adminService = ctx.get('adminService'); - return ctx.json(await adminService.auditService.listAuditLogs(ctx.req.valid('json'))); + const {q, admin_user_id, target_type, target_id, sort_by, sort_order, limit, offset} = ctx.req.valid('query'); + if (q === undefined) { + return ctx.json( + await adminService.auditService.listAuditLogs({admin_user_id, target_type, target_id, limit, offset}), + ); + } + return ctx.json( + await adminService.auditService.searchAuditLogs({ + query: q, + admin_user_id, + target_type, + target_id, + sort_by, + sort_order, + limit, + offset, + }), + ); }, ); - app.post( - '/admin/audit-logs/search', + app.get( + '/admin/audit-logs/:log_id', RateLimitMiddleware(RateLimitConfigs.ADMIN_AUDIT_LOG), requireAdminACL(AdminACLs.AUDIT_LOG_VIEW), - Validator('json', SearchAuditLogsRequest), + Validator('param', AuditLogIdParam), OpenAPI({ - operationId: 'search_audit_logs', - summary: 'Search audit logs', - responseSchema: AuditLogsListResponseSchema, + operationId: 'get_admin_audit_log', + summary: 'Get admin audit log entry', + responseSchema: AdminAuditLogResponseSchema, statusCode: 200, security: ['adminApiKey'], tags: ['Admin'], description: - 'Perform a full-text search across audit logs for specific events or changes. Allows targeted queries for compliance investigations or incident response.', + 'Retrieve a single admin audit log entry by ID, with the same resolved user, guild, and channel summaries the listing returns. Used to inspect one administrative operation during compliance investigations or incident response.', }), async (ctx) => { const adminService = ctx.get('adminService'); - return ctx.json(await adminService.auditService.searchAuditLogs(ctx.req.valid('json'))); + const {log_id} = ctx.req.valid('param'); + const log = await adminService.auditService.getAuditLog(log_id); + if (!log) { + throw new NotFoundError({code: APIErrorCodes.NOT_FOUND}); + } + return ctx.json(log); }, ); } diff --git a/fluxer_api/src/api/admin/controllers/BanAdminController.ts b/fluxer_api/src/api/admin/controllers/BanAdminController.ts index 31b219680..907ca044a 100644 --- a/fluxer_api/src/api/admin/controllers/BanAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/BanAdminController.ts @@ -1,6 +1,26 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; +import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError'; +import { + AdminBlocklistAvatarHashUpdateRequest, + AdminBlocklistBulkDeleteRequest, + AdminBlocklistEntryCreateRequest, + AdminBlocklistEntryListQuery, + AdminBlocklistEntryListResponse, + AdminBlocklistEntryUpdateRequest, + AdminBlocklistFileShaUpdateRequest, + type AdminBlocklistListType, + AdminBlocklistProfileSubstringUpdateRequest, + AdminBlocklistScopeQuery, + AdminBlocklistTypeListResponse, + AdminBlocklistUrlDomainUpdateRequest, + AdminBlocklistUrlUpdateRequest, + BlocklistEntryParam, + BlocklistTypeParam, +} from '@fluxer/schema/src/domains/admin/AdminBlocklistSchemas'; import { BanAvatarHashRequest, BanCheckResponseSchema, @@ -16,654 +36,338 @@ import { BulkBanFileShasRequest, BulkJobResponse, CheckAvatarHashRequest, - CheckFileShaRequest, - CheckUrlBlocklistRequest, SuspiciousEmailDomainRequest, - UnbanFileShaRequest, - UnbanUrlDomainRequest, - UnbanUrlRequest, } from '@fluxer/schema/src/domains/admin/AdminSchemas'; -import {requireAdminACL} from '../../middleware/AdminMiddleware'; +import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; +import type {ZodTypeAny, z} from 'zod'; +import {requireAdminACL, requireAnyAdminACL} from '../../middleware/AdminMiddleware'; import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware'; import {OpenAPI} from '../../middleware/ResponseTypeMiddleware'; import {getWorkerService} from '../../middleware/ServiceRegistry'; import {RateLimitConfigs} from '../../RateLimitConfig'; import type {HonoApp} from '../../types/HonoEnv'; -import {Validator} from '../../Validator'; +import {requireRequestJsonBody} from '../../utils/RequestJsonBody'; +import {inputValidationErrorFromZodIssues, Validator} from '../../Validator'; + +type ProfileSubstringScope = BanProfileSubstringRequest['scope']; + +const BLOCKLIST_CATALOG = [ + { + list_type: 'ip' as const, + description: + 'IPv4/IPv6 addresses and CIDR ranges denied service. Applies to live connections and can be applied retroactively.', + value_field: 'ip', + fields: [], + scoped: false, + supports_bulk_create: false, + supports_bulk_delete: false, + supports_update: false, + }, + { + list_type: 'email' as const, + description: 'Email addresses that cannot be used to register or be set on an account.', + value_field: 'email', + fields: [], + scoped: false, + supports_bulk_create: false, + supports_bulk_delete: false, + supports_update: false, + }, + { + list_type: 'email-domain-suspicious' as const, + description: + 'Email domains flagged as suspicious. Registration is not blocked, but new accounts using the domain must verify a phone number before they can act on the platform. The list itself is not exposed to users.', + value_field: 'domain', + fields: [], + scoped: false, + supports_bulk_create: false, + supports_bulk_delete: false, + supports_update: false, + }, + { + list_type: 'phrase' as const, + description: + 'Phrases blocked in content. Matching is case-insensitive and normalizes common bypass tricks such as inserted whitespace, punctuation, invisible characters, and compatibility glyphs.', + value_field: 'phrase', + fields: [], + scoped: false, + supports_bulk_create: false, + supports_bulk_delete: false, + supports_update: false, + }, + { + list_type: 'url' as const, + description: 'Absolute http(s) URLs blocked from being posted. Values are canonicalized before storage.', + value_field: 'url', + fields: ['category', 'severity', 'source_url', 'notes'], + scoped: false, + supports_bulk_create: false, + supports_bulk_delete: false, + supports_update: true, + }, + { + list_type: 'url-domain' as const, + description: 'Domains blocked from being linked, optionally covering every subdomain rooted at the domain.', + value_field: 'domain', + fields: ['match_subdomains', 'category', 'severity', 'source_url', 'notes'], + scoped: false, + supports_bulk_create: false, + supports_bulk_delete: false, + supports_update: true, + }, + { + list_type: 'file-sha' as const, + description: 'SHA-256 hashes of files rejected on upload.', + value_field: 'sha256_hex', + fields: ['category', 'severity', 'content_type', 'source_url', 'notes'], + scoped: false, + supports_bulk_create: true, + supports_bulk_delete: false, + supports_update: true, + }, + { + list_type: 'avatar-hash' as const, + description: '8-char MD5-prefix avatar hashes rejected on upload.', + value_field: 'hashes', + fields: ['category', 'severity', 'source_url', 'reason', 'notes'], + scoped: false, + supports_bulk_create: false, + supports_bulk_delete: true, + supports_update: true, + }, + { + list_type: 'profile-substring' as const, + description: + 'Substrings blocked within one profile field. Matching reuses the phrase blocklist normalization, so a scope must accompany every operation.', + value_field: 'substrings', + fields: ['scope', 'reason', 'notes'], + scoped: true, + supports_bulk_create: false, + supports_bulk_delete: true, + supports_update: true, + }, +]; + +const BLOCKLIST_TYPE_ACLS: Record = { + ip: {add: AdminACLs.BAN_IP_ADD, check: AdminACLs.BAN_IP_CHECK, remove: AdminACLs.BAN_IP_REMOVE}, + email: {add: AdminACLs.BAN_EMAIL_ADD, check: AdminACLs.BAN_EMAIL_CHECK, remove: AdminACLs.BAN_EMAIL_REMOVE}, + 'email-domain-suspicious': { + add: AdminACLs.SUSPICIOUS_EMAIL_DOMAIN_ADD, + check: AdminACLs.SUSPICIOUS_EMAIL_DOMAIN_CHECK, + remove: AdminACLs.SUSPICIOUS_EMAIL_DOMAIN_REMOVE, + }, + phrase: {add: AdminACLs.BAN_PHRASE_ADD, check: AdminACLs.BAN_PHRASE_CHECK, remove: AdminACLs.BAN_PHRASE_REMOVE}, + url: {add: AdminACLs.BAN_URL_ADD, check: AdminACLs.BAN_URL_CHECK, remove: AdminACLs.BAN_URL_REMOVE}, + 'url-domain': { + add: AdminACLs.BAN_URL_DOMAIN_ADD, + check: AdminACLs.BAN_URL_DOMAIN_CHECK, + remove: AdminACLs.BAN_URL_DOMAIN_REMOVE, + }, + 'file-sha': { + add: AdminACLs.BAN_FILE_SHA_ADD, + check: AdminACLs.BAN_FILE_SHA_CHECK, + remove: AdminACLs.BAN_FILE_SHA_REMOVE, + }, + 'avatar-hash': { + add: AdminACLs.BAN_AVATAR_HASH_ADD, + check: AdminACLs.BAN_AVATAR_HASH_CHECK, + remove: AdminACLs.BAN_AVATAR_HASH_REMOVE, + }, + 'profile-substring': { + add: AdminACLs.BAN_PROFILE_SUBSTRING_ADD, + check: AdminACLs.BAN_PROFILE_SUBSTRING_CHECK, + remove: AdminACLs.BAN_PROFILE_SUBSTRING_REMOVE, + }, +}; + +type BlocklistVerb = 'add' | 'check' | 'remove'; + +const BLOCKLIST_ACLS_BY_VERB: Record> = { + add: Object.values(BLOCKLIST_TYPE_ACLS).map((acls) => acls.add), + check: Object.values(BLOCKLIST_TYPE_ACLS).map((acls) => acls.check), + remove: Object.values(BLOCKLIST_TYPE_ACLS).map((acls) => acls.remove), +}; + +function requireBlocklistACL(adminAcls: Set, listType: AdminBlocklistListType, verb: BlocklistVerb): void { + const requiredAcl = BLOCKLIST_TYPE_ACLS[listType][verb]; + if (!adminAcls.has(requiredAcl) && !adminAcls.has(AdminACLs.WILDCARD)) { + throw new MissingACLError(requiredAcl); + } +} + +function unsupportedForBlocklist(): never { + throw new BadRequestError({ + code: APIErrorCodes.INVALID_FORM_BODY, + message: 'This blocklist does not support this operation', + }); +} + +function assertBlocklistScopeAllowed(listType: AdminBlocklistListType, scope: ProfileSubstringScope | undefined): void { + if (listType !== 'profile-substring' && scope != null) { + throw new BadRequestError({ + code: APIErrorCodes.INVALID_FORM_BODY, + message: 'This blocklist does not accept a scope', + }); + } +} + +function requireProfileSubstringScope(scope: ProfileSubstringScope | undefined): ProfileSubstringScope { + if (scope == null) { + throw new BadRequestError({ + code: APIErrorCodes.INVALID_FORM_BODY, + message: 'The profile-substring blocklist requires a scope', + }); + } + return scope; +} + +async function parseBlocklistBody(schema: T, value: unknown): Promise> { + const result = await schema.safeParseAsync(value); + if (!result.success) { + throw inputValidationErrorFromZodIssues(result.error.issues); + } + return result.data; +} export function BanAdminController(app: HonoApp) { - app.post( - '/admin/bans/ip/add', + app.get( + '/admin/blocklists', RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_IP_ADD), - Validator('json', BanIpRequest), + requireAnyAdminACL(BLOCKLIST_ACLS_BY_VERB.check), OpenAPI({ - operationId: 'add_ip_ban', - summary: 'Add IP ban', - responseSchema: null, - statusCode: 204, - security: ['adminApiKey'], - tags: ['Admin'], - description: - 'Ban one or more IP addresses from accessing the platform. Users connecting from banned IPs will be denied service. Can be applied retroactively.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.banManagementService.banIp(ctx.req.valid('json'), adminUserId, auditLogReason); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/bans/ip/remove', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_IP_REMOVE), - Validator('json', BanIpRequest), - OpenAPI({ - operationId: 'remove_ip_ban', - summary: 'Remove IP ban', - responseSchema: null, - statusCode: 204, - security: ['adminApiKey'], - tags: ['Admin'], - description: - 'Lift a previously applied IP ban, allowing traffic from those addresses again. Used for appeals or when bans were applied in error.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.banManagementService.unbanIp(ctx.req.valid('json'), adminUserId, auditLogReason); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/bans/ip/check', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_IP_CHECK), - Validator('json', BanIpRequest), - OpenAPI({ - operationId: 'check_ip_ban_status', - summary: 'Check IP ban status', - responseSchema: BanCheckResponseSchema, + operationId: 'list_admin_blocklist_types', + summary: 'List blocklists', + responseSchema: AdminBlocklistTypeListResponse, statusCode: 200, security: ['adminApiKey'], tags: ['Admin'], description: - 'Query whether one or more IP addresses are currently banned. Returns the ban status and any associated metadata like reason or expiration.', + 'List every blocklist this instance maintains, the request field that carries an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.', }), async (ctx) => { - const adminService = ctx.get('adminService'); - return ctx.json(await adminService.banManagementService.checkIpBan(ctx.req.valid('json'))); + return ctx.json({items: BLOCKLIST_CATALOG}); }, ); - app.post( - '/admin/bans/email/add', + app.get( + '/admin/blocklists/:list_type/entries', RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_EMAIL_ADD), - Validator('json', BanEmailRequest), + requireAnyAdminACL(BLOCKLIST_ACLS_BY_VERB.check), + Validator('param', BlocklistTypeParam), + Validator('query', AdminBlocklistEntryListQuery), OpenAPI({ - operationId: 'add_email_ban', - summary: 'Add email ban', - responseSchema: null, - statusCode: 204, - security: ['adminApiKey'], - tags: ['Admin'], - description: - 'Ban one or more email addresses from registering or creating accounts. Users attempting to use banned emails will be blocked.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.banManagementService.banEmail(ctx.req.valid('json'), adminUserId, auditLogReason); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/bans/email/remove', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_EMAIL_REMOVE), - Validator('json', BanEmailRequest), - OpenAPI({ - operationId: 'remove_email_ban', - summary: 'Remove email ban', - responseSchema: null, - statusCode: 204, - security: ['adminApiKey'], - tags: ['Admin'], - description: - 'Lift a previously applied email ban, allowing the address to be used for new registrations. Used for appeals or error correction.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.banManagementService.unbanEmail(ctx.req.valid('json'), adminUserId, auditLogReason); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/bans/email/check', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_EMAIL_CHECK), - Validator('json', BanEmailRequest), - OpenAPI({ - operationId: 'check_email_ban_status', - summary: 'Check email ban status', - responseSchema: BanCheckResponseSchema, + operationId: 'list_admin_blocklist_entries', + summary: 'List blocklist entries', + responseSchema: AdminBlocklistEntryListResponse, statusCode: 200, security: ['adminApiKey'], tags: ['Admin'], description: - 'Query whether one or more email addresses are currently banned from registration. Returns the ban status and metadata.', + 'Page through the entries of a blocklist, ordered by value. Pass the next_after cursor of the previous page as after to fetch the next page. The profile-substring blocklist requires a scope.', }), async (ctx) => { const adminService = ctx.get('adminService'); - return ctx.json(await adminService.banManagementService.checkEmailBan(ctx.req.valid('json'))); - }, - ); - app.post( - '/admin/suspicious-email-domains/add', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.SUSPICIOUS_EMAIL_DOMAIN_ADD), - Validator('json', SuspiciousEmailDomainRequest), - OpenAPI({ - operationId: 'add_suspicious_email_domain', - summary: 'Add suspicious email domain', - responseSchema: null, - statusCode: 204, - security: ['adminApiKey'], - tags: ['Admin'], - description: - 'Flag an email domain as suspicious. Registration is not blocked, but new accounts using this domain are required to verify a phone number before they can act on the platform. The list itself is not exposed to users — they only see the verified-phone gate.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.banManagementService.addSuspiciousEmailDomain( - ctx.req.valid('json'), - adminUserId, - auditLogReason, - ); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/suspicious-email-domains/remove', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.SUSPICIOUS_EMAIL_DOMAIN_REMOVE), - Validator('json', SuspiciousEmailDomainRequest), - OpenAPI({ - operationId: 'remove_suspicious_email_domain', - summary: 'Remove suspicious email domain flag', - responseSchema: null, - statusCode: 204, - security: ['adminApiKey'], - tags: ['Admin'], - description: - 'Remove a domain from the suspicious list. New registrations from this domain will no longer be auto-required to verify a phone number on signup.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.banManagementService.removeSuspiciousEmailDomain( - ctx.req.valid('json'), - adminUserId, - auditLogReason, - ); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/suspicious-email-domains/check', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.SUSPICIOUS_EMAIL_DOMAIN_CHECK), - Validator('json', SuspiciousEmailDomainRequest), - OpenAPI({ - operationId: 'check_suspicious_email_domain', - summary: 'Check suspicious email domain status', - responseSchema: BanCheckResponseSchema, - statusCode: 200, - security: ['adminApiKey'], - tags: ['Admin'], - description: 'Query whether an email domain is currently flagged as suspicious.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - return ctx.json(await adminService.banManagementService.checkSuspiciousEmailDomain(ctx.req.valid('json'))); - }, - ); - app.post( - '/admin/bans/phrase/add', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_PHRASE_ADD), - Validator('json', BanPhraseRequest), - OpenAPI({ - operationId: 'add_phrase_ban', - summary: 'Add phrase ban', - responseSchema: null, - statusCode: 204, - security: ['adminApiKey'], - tags: ['Admin'], - description: - 'Ban a phrase. Matching is case-insensitive and also normalizes common bypass tricks such as inserted whitespace, punctuation, invisible characters, and compatibility glyphs.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.banManagementService.banPhrase(ctx.req.valid('json'), adminUserId, auditLogReason); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/bans/phrase/remove', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_PHRASE_REMOVE), - Validator('json', BanPhraseRequest), - OpenAPI({ - operationId: 'remove_phrase_ban', - summary: 'Remove phrase ban', - responseSchema: null, - statusCode: 204, - security: ['adminApiKey'], - tags: ['Admin'], - description: 'Lift a previously applied phrase ban, allowing messages containing that phrase again.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.banManagementService.unbanPhrase(ctx.req.valid('json'), adminUserId, auditLogReason); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/bans/phrase/check', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_PHRASE_CHECK), - Validator('json', BanPhraseRequest), - OpenAPI({ - operationId: 'check_phrase_ban_status', - summary: 'Check phrase ban status', - responseSchema: BanCheckResponseSchema, - statusCode: 200, - security: ['adminApiKey'], - tags: ['Admin'], - description: 'Query whether a phrase is currently banned.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - return ctx.json(await adminService.banManagementService.checkPhraseBan(ctx.req.valid('json'))); - }, - ); - app.post( - '/admin/bans/url/add', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_URL_ADD), - Validator('json', BanUrlRequest), - OpenAPI({ - operationId: 'add_url_ban', - summary: 'Add URL ban', - responseSchema: null, - statusCode: 204, - security: ['adminApiKey'], - tags: ['Admin'], - description: - 'Ban one or more URLs from being posted on the platform. Messages containing banned URLs will be blocked.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.banManagementService.banUrl(ctx.req.valid('json'), adminUserId, auditLogReason); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/bans/url/remove', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_URL_REMOVE), - Validator('json', UnbanUrlRequest), - OpenAPI({ - operationId: 'remove_url_ban', - summary: 'Remove URL ban', - responseSchema: null, - statusCode: 204, - security: ['adminApiKey'], - tags: ['Admin'], - description: 'Lift a previously applied URL ban, allowing the URL to be posted again.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.banManagementService.unbanUrl(ctx.req.valid('json'), adminUserId, auditLogReason); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/bans/url/check', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_URL_CHECK), - Validator('json', CheckUrlBlocklistRequest), - OpenAPI({ - operationId: 'check_url_ban_status', - summary: 'Check URL ban status', - responseSchema: BanCheckResponseSchema, - statusCode: 200, - security: ['adminApiKey'], - tags: ['Admin'], - description: 'Query whether one or more URLs are currently banned from being posted.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - return ctx.json(await adminService.banManagementService.checkUrlBan(ctx.req.valid('json'))); - }, - ); - app.post( - '/admin/bans/url-domain/add', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_URL_DOMAIN_ADD), - Validator('json', BanUrlDomainRequest), - OpenAPI({ - operationId: 'add_url_domain_ban', - summary: 'Add URL domain ban', - responseSchema: null, - statusCode: 204, - security: ['adminApiKey'], - tags: ['Admin'], - description: - 'Ban an entire URL domain from being linked on the platform. All URLs under the banned domain will be blocked.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.banManagementService.banUrlDomain(ctx.req.valid('json'), adminUserId, auditLogReason); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/bans/url-domain/remove', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_URL_DOMAIN_REMOVE), - Validator('json', UnbanUrlDomainRequest), - OpenAPI({ - operationId: 'remove_url_domain_ban', - summary: 'Remove URL domain ban', - responseSchema: null, - statusCode: 204, - security: ['adminApiKey'], - tags: ['Admin'], - description: 'Lift a previously applied URL domain ban, allowing links to that domain again.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.banManagementService.unbanUrlDomain(ctx.req.valid('json'), adminUserId, auditLogReason); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/bans/url-domain/check', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_URL_DOMAIN_CHECK), - Validator('json', BanUrlDomainRequest), - OpenAPI({ - operationId: 'check_url_domain_ban_status', - summary: 'Check URL domain ban status', - responseSchema: BanCheckResponseSchema, - statusCode: 200, - security: ['adminApiKey'], - tags: ['Admin'], - description: 'Query whether a URL domain is currently banned from being linked.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - return ctx.json(await adminService.banManagementService.checkUrlDomainBan(ctx.req.valid('json'))); - }, - ); - app.post( - '/admin/bans/file-sha/add', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_FILE_SHA_ADD), - Validator('json', BanFileShaRequest), - OpenAPI({ - operationId: 'add_file_sha_ban', - summary: 'Add file SHA ban', - responseSchema: null, - statusCode: 204, - security: ['adminApiKey'], - tags: ['Admin'], - description: 'Ban one or more files by SHA hash. Uploads matching the banned hash will be rejected.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.banManagementService.banFileSha(ctx.req.valid('json'), adminUserId, auditLogReason); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/bans/file-sha/remove', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_FILE_SHA_REMOVE), - Validator('json', UnbanFileShaRequest), - OpenAPI({ - operationId: 'remove_file_sha_ban', - summary: 'Remove file SHA ban', - responseSchema: null, - statusCode: 204, - security: ['adminApiKey'], - tags: ['Admin'], - description: 'Lift a previously applied file SHA ban, allowing uploads of that file again.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.banManagementService.unbanFileSha(ctx.req.valid('json'), adminUserId, auditLogReason); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/bans/file-sha/check', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_FILE_SHA_CHECK), - Validator('json', CheckFileShaRequest), - OpenAPI({ - operationId: 'check_file_sha_ban_status', - summary: 'Check file SHA ban status', - responseSchema: BanCheckResponseSchema, - statusCode: 200, - security: ['adminApiKey'], - tags: ['Admin'], - description: 'Query whether one or more file SHA hashes are currently banned.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - return ctx.json(await adminService.banManagementService.checkFileShaBan(ctx.req.valid('json'))); - }, - ); - app.post( - '/admin/bans/avatar-hash/add', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_AVATAR_HASH_ADD), - Validator('json', BanAvatarHashRequest), - OpenAPI({ - operationId: 'add_avatar_hash_ban', - summary: 'Add avatar hash ban', - responseSchema: null, - statusCode: 204, - security: ['adminApiKey'], - tags: ['Admin'], - description: - 'Ban one or more 8-char MD5-prefix avatar hashes. Avatars matching the banned hash will be rejected on upload.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.banManagementService.banAvatarHash(ctx.req.valid('json'), adminUserId, auditLogReason); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/bans/avatar-hash/remove', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_AVATAR_HASH_REMOVE), - Validator('json', CheckAvatarHashRequest), - OpenAPI({ - operationId: 'remove_avatar_hash_ban', - summary: 'Remove avatar hash ban', - responseSchema: null, - statusCode: 204, - security: ['adminApiKey'], - tags: ['Admin'], - description: 'Lift a previously applied avatar-hash ban.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.banManagementService.unbanAvatarHash(ctx.req.valid('json'), adminUserId, auditLogReason); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/bans/avatar-hash/check', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_AVATAR_HASH_CHECK), - Validator('json', CheckAvatarHashRequest), - OpenAPI({ - operationId: 'check_avatar_hash_ban_status', - summary: 'Check avatar hash ban status', - responseSchema: BanCheckResponseSchema, - statusCode: 200, - security: ['adminApiKey'], - tags: ['Admin'], - description: 'Query whether any of the provided avatar hashes are banned.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - return ctx.json(await adminService.banManagementService.checkAvatarHashBan(ctx.req.valid('json'))); - }, - ); - app.post( - '/admin/users/:user_id/ban-avatar', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_AVATAR_HASH_ADD), - Validator('json', BanUserAvatarRequest), - OpenAPI({ - operationId: 'ban_user_avatar', - summary: "Ban this user's current avatar", - responseSchema: BanUserAvatarResponseSchema, - statusCode: 200, - security: ['adminApiKey'], - tags: ['Admin'], - description: - "Reads the user's current avatar_hash, strips any animation prefix, and adds the 8-char hash to the avatar blocklist. Returns the banned hash.", - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - const userId = ctx.req.param('user_id'); - const body = ctx.req.valid('json'); + const {list_type: listType} = ctx.req.valid('param'); + requireBlocklistACL(ctx.get('adminUserAcls'), listType, 'check'); + const {limit, after, scope} = ctx.req.valid('query'); + assertBlocklistScopeAllowed(listType, scope); return ctx.json( - await adminService.banManagementService.banUserAvatar({user_id: userId, ...body}, adminUserId, auditLogReason), + await adminService.banManagementService.listBlocklistEntries({ + listType, + limit, + after: after ?? null, + scope: listType === 'profile-substring' ? requireProfileSubstringScope(scope) : null, + }), ); }, ); app.post( - '/admin/bans/profile-substring/add', + '/admin/blocklists/:list_type/entries', RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_PROFILE_SUBSTRING_ADD), - Validator('json', BanProfileSubstringRequest), + requireAnyAdminACL(BLOCKLIST_ACLS_BY_VERB.add), + Validator('param', BlocklistTypeParam), OpenAPI({ - operationId: 'add_profile_substring_ban', - summary: 'Add profile-substring ban', + operationId: 'create_admin_blocklist_entry', + summary: 'Add blocklist entry', responseSchema: null, statusCode: 204, security: ['adminApiKey'], tags: ['Admin'], + requestSchema: AdminBlocklistEntryCreateRequest, description: - 'Ban a substring within a specific profile field (username, global_name, nickname, bio, or pronouns). Matching reuses the phrase blocklist normalization (whitespace, punctuation, zero-width, lookalikes).', + 'Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list, or that IPInfo reports as a high blast-radius carrier NAT, is refused with 400 IP_BAN_DECLINED and recorded in the audit log.', }), async (ctx) => { const adminService = ctx.get('adminService'); const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); - await adminService.banManagementService.banProfileSubstring(ctx.req.valid('json'), adminUserId, auditLogReason); + const {list_type: listType} = ctx.req.valid('param'); + requireBlocklistACL(ctx.get('adminUserAcls'), listType, 'add'); + const bans = adminService.banManagementService; + const raw = await requireRequestJsonBody(ctx.req); + switch (listType) { + case 'ip': + await bans.banIp(await parseBlocklistBody(BanIpRequest, raw), adminUserId, auditLogReason); + break; + case 'email': + await bans.banEmail(await parseBlocklistBody(BanEmailRequest, raw), adminUserId, auditLogReason); + break; + case 'email-domain-suspicious': + await bans.addSuspiciousEmailDomain( + await parseBlocklistBody(SuspiciousEmailDomainRequest, raw), + adminUserId, + auditLogReason, + ); + break; + case 'phrase': + await bans.banPhrase(await parseBlocklistBody(BanPhraseRequest, raw), adminUserId, auditLogReason); + break; + case 'url': + await bans.banUrl(await parseBlocklistBody(BanUrlRequest, raw), adminUserId, auditLogReason); + break; + case 'url-domain': + await bans.banUrlDomain(await parseBlocklistBody(BanUrlDomainRequest, raw), adminUserId, auditLogReason); + break; + case 'file-sha': + await bans.banFileSha(await parseBlocklistBody(BanFileShaRequest, raw), adminUserId, auditLogReason); + break; + case 'avatar-hash': + await bans.banAvatarHash(await parseBlocklistBody(BanAvatarHashRequest, raw), adminUserId, auditLogReason); + break; + case 'profile-substring': + await bans.banProfileSubstring( + await parseBlocklistBody(BanProfileSubstringRequest, raw), + adminUserId, + auditLogReason, + ); + break; + } return ctx.body(null, 204); }, ); - app.post( - '/admin/bans/profile-substring/remove', + app.put( + '/admin/blocklists/:list_type/entries', RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_PROFILE_SUBSTRING_REMOVE), - Validator('json', BanProfileSubstringRequest), - OpenAPI({ - operationId: 'remove_profile_substring_ban', - summary: 'Remove profile-substring ban', - responseSchema: null, - statusCode: 204, - security: ['adminApiKey'], - tags: ['Admin'], - description: 'Lift a previously applied profile-substring ban.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.banManagementService.unbanProfileSubstring(ctx.req.valid('json'), adminUserId, auditLogReason); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/bans/profile-substring/check', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_PROFILE_SUBSTRING_CHECK), - Validator('json', BanProfileSubstringRequest), - OpenAPI({ - operationId: 'check_profile_substring_ban_status', - summary: 'Check profile-substring ban status', - responseSchema: BanCheckResponseSchema, - statusCode: 200, - security: ['adminApiKey'], - tags: ['Admin'], - description: 'Query whether any of the provided substrings are banned for the given scope.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - return ctx.json(await adminService.banManagementService.checkProfileSubstringBan(ctx.req.valid('json'))); - }, - ); - app.post( - '/admin/bans/file-sha/bulk-add', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), - requireAdminACL(AdminACLs.BAN_FILE_SHA_ADD), + requireAnyAdminACL(BLOCKLIST_ACLS_BY_VERB.add), + Validator('param', BlocklistTypeParam), Validator('json', BulkBanFileShasRequest), OpenAPI({ - operationId: 'bulk_ban_file_shas', - summary: 'Bulk-ban file SHAs as a background job', + operationId: 'bulk_create_admin_blocklist_entries', + summary: 'Bulk-add blocklist entries', responseSchema: BulkJobResponse, statusCode: 200, security: ['adminApiKey'], tags: ['Admin'], description: - 'Enqueue a background job that bans many file SHAs at once. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.', + 'Enqueue a background job that adds many entries to a blocklist at once. Returns a job_id immediately; observe progress at /admin/jobs/:job_id. Only the file-sha blocklist accepts this operation, reported as supports_bulk_create by GET /admin/blocklists.', }), async (ctx) => { const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); + const {list_type: listType} = ctx.req.valid('param'); + requireBlocklistACL(ctx.get('adminUserAcls'), listType, 'add'); + if (listType !== 'file-sha') { + unsupportedForBlocklist(); + } const body = ctx.req.valid('json'); const workerService = getWorkerService(); const jobId = await workerService.addJob( @@ -673,9 +377,248 @@ export function BanAdminController(app: HonoApp) { admin_user_id: adminUserId.toString(), audit_log_reason: auditLogReason, }, - {requestedByUserId: adminUserId, ...(auditLogReason && {auditLogReason})}, + {requestedByUserId: adminUserId, requireLedger: true, ...(auditLogReason && {auditLogReason})}, ); return ctx.json({job_id: jobId.toString()}); }, ); + app.delete( + '/admin/blocklists/:list_type/entries', + RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), + requireAnyAdminACL(BLOCKLIST_ACLS_BY_VERB.remove), + Validator('param', BlocklistTypeParam), + OpenAPI({ + operationId: 'bulk_delete_admin_blocklist_entries', + summary: 'Bulk-remove blocklist entries', + responseSchema: null, + statusCode: 204, + security: ['adminApiKey'], + tags: ['Admin'], + requestSchema: AdminBlocklistBulkDeleteRequest, + description: + 'Remove several entries from a blocklist in one request. The request body is the shape the blocklist named by list_type accepts. Only the avatar-hash and profile-substring blocklists accept this operation, reported as supports_bulk_delete by GET /admin/blocklists.', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserId = ctx.get('adminUserId'); + const auditLogReason = ctx.get('auditLogReason'); + const {list_type: listType} = ctx.req.valid('param'); + requireBlocklistACL(ctx.get('adminUserAcls'), listType, 'remove'); + const bans = adminService.banManagementService; + const raw = await requireRequestJsonBody(ctx.req); + switch (listType) { + case 'avatar-hash': + await bans.unbanAvatarHash( + await parseBlocklistBody(CheckAvatarHashRequest, raw), + adminUserId, + auditLogReason, + ); + break; + case 'profile-substring': + await bans.unbanProfileSubstring( + await parseBlocklistBody(BanProfileSubstringRequest, raw), + adminUserId, + auditLogReason, + ); + break; + default: + unsupportedForBlocklist(); + } + return ctx.body(null, 204); + }, + ); + app.get( + '/admin/blocklists/:list_type/entries/:entry_value', + RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), + requireAnyAdminACL(BLOCKLIST_ACLS_BY_VERB.check), + Validator('param', BlocklistEntryParam), + Validator('query', AdminBlocklistScopeQuery), + OpenAPI({ + operationId: 'get_admin_blocklist_entry', + summary: 'Check blocklist entry', + responseSchema: BanCheckResponseSchema, + statusCode: 200, + security: ['adminApiKey'], + tags: ['Admin'], + description: + 'Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a URL can match a banned domain. The profile-substring blocklist requires a scope.', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const {list_type: listType, entry_value: entryValue} = ctx.req.valid('param'); + requireBlocklistACL(ctx.get('adminUserAcls'), listType, 'check'); + const {scope} = ctx.req.valid('query'); + assertBlocklistScopeAllowed(listType, scope); + const bans = adminService.banManagementService; + switch (listType) { + case 'ip': + return ctx.json(await bans.checkIpBan({ip: entryValue})); + case 'email': + return ctx.json(await bans.checkEmailBan({email: entryValue})); + case 'email-domain-suspicious': + return ctx.json(await bans.checkSuspiciousEmailDomain({domain: entryValue})); + case 'phrase': + return ctx.json(await bans.checkPhraseBan({phrase: entryValue})); + case 'url': + return ctx.json(await bans.checkUrlBan({url: entryValue})); + case 'url-domain': + return ctx.json(await bans.checkUrlDomainBan({domain: entryValue})); + case 'file-sha': + return ctx.json(await bans.checkFileShaBan({sha256_hex: entryValue})); + case 'avatar-hash': + return ctx.json(await bans.checkAvatarHashBan({hashes: [entryValue]})); + case 'profile-substring': + return ctx.json( + await bans.checkProfileSubstringBan({ + scope: requireProfileSubstringScope(scope), + substrings: [entryValue], + }), + ); + } + }, + ); + app.patch( + '/admin/blocklists/:list_type/entries/:entry_value', + RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), + requireAnyAdminACL(BLOCKLIST_ACLS_BY_VERB.add), + Validator('param', BlocklistEntryParam), + OpenAPI({ + operationId: 'update_admin_blocklist_entry', + summary: 'Update blocklist entry', + responseSchema: null, + statusCode: 204, + security: ['adminApiKey'], + tags: ['Admin'], + requestSchema: AdminBlocklistEntryUpdateRequest, + description: + 'Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries carry fields accept this operation, reported as supports_update by GET /admin/blocklists.', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserId = ctx.get('adminUserId'); + const auditLogReason = ctx.get('auditLogReason'); + const {list_type: listType, entry_value: entryValue} = ctx.req.valid('param'); + requireBlocklistACL(ctx.get('adminUserAcls'), listType, 'add'); + const bans = adminService.banManagementService; + const raw = await requireRequestJsonBody(ctx.req); + switch (listType) { + case 'url': { + const body = await parseBlocklistBody(AdminBlocklistUrlUpdateRequest, raw); + await bans.banUrl({url: entryValue, ...body}, adminUserId, auditLogReason); + break; + } + case 'url-domain': { + const body = await parseBlocklistBody(AdminBlocklistUrlDomainUpdateRequest, raw); + await bans.banUrlDomain({domain: entryValue, ...body}, adminUserId, auditLogReason); + break; + } + case 'file-sha': { + const body = await parseBlocklistBody(AdminBlocklistFileShaUpdateRequest, raw); + await bans.banFileSha({sha256_hex: entryValue, ...body}, adminUserId, auditLogReason); + break; + } + case 'avatar-hash': { + const body = await parseBlocklistBody(AdminBlocklistAvatarHashUpdateRequest, raw); + await bans.banAvatarHash({hashes: [entryValue], ...body}, adminUserId, auditLogReason); + break; + } + case 'profile-substring': { + const body = await parseBlocklistBody(AdminBlocklistProfileSubstringUpdateRequest, raw); + await bans.banProfileSubstring({substrings: [entryValue], ...body}, adminUserId, auditLogReason); + break; + } + default: + unsupportedForBlocklist(); + } + return ctx.body(null, 204); + }, + ); + app.delete( + '/admin/blocklists/:list_type/entries/:entry_value', + RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), + requireAnyAdminACL(BLOCKLIST_ACLS_BY_VERB.remove), + Validator('param', BlocklistEntryParam), + Validator('query', AdminBlocklistScopeQuery), + OpenAPI({ + operationId: 'delete_admin_blocklist_entry', + summary: 'Remove blocklist entry', + responseSchema: null, + statusCode: 204, + security: ['adminApiKey'], + tags: ['Admin'], + description: + 'Remove an entry from a blocklist. The value is percent-encoded in the path and is canonicalized the same way it was on add, so an IP covered only by a broader CIDR entry cannot be removed through the narrower address. The profile-substring blocklist requires a scope.', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserId = ctx.get('adminUserId'); + const auditLogReason = ctx.get('auditLogReason'); + const {list_type: listType, entry_value: entryValue} = ctx.req.valid('param'); + requireBlocklistACL(ctx.get('adminUserAcls'), listType, 'remove'); + const {scope} = ctx.req.valid('query'); + assertBlocklistScopeAllowed(listType, scope); + const bans = adminService.banManagementService; + switch (listType) { + case 'ip': + await bans.unbanIp({ip: entryValue}, adminUserId, auditLogReason); + break; + case 'email': + await bans.unbanEmail({email: entryValue}, adminUserId, auditLogReason); + break; + case 'email-domain-suspicious': + await bans.removeSuspiciousEmailDomain({domain: entryValue}, adminUserId, auditLogReason); + break; + case 'phrase': + await bans.unbanPhrase({phrase: entryValue}, adminUserId, auditLogReason); + break; + case 'url': + await bans.unbanUrl({url: entryValue}, adminUserId, auditLogReason); + break; + case 'url-domain': + await bans.unbanUrlDomain({domain: entryValue}, adminUserId, auditLogReason); + break; + case 'file-sha': + await bans.unbanFileSha({sha256_hex: entryValue}, adminUserId, auditLogReason); + break; + case 'avatar-hash': + await bans.unbanAvatarHash({hashes: [entryValue]}, adminUserId, auditLogReason); + break; + case 'profile-substring': + await bans.unbanProfileSubstring( + {scope: requireProfileSubstringScope(scope), substrings: [entryValue]}, + adminUserId, + auditLogReason, + ); + break; + } + return ctx.body(null, 204); + }, + ); + app.post( + '/admin/users/:user_id/avatar-block', + RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION), + requireAdminACL(AdminACLs.BAN_AVATAR_HASH_ADD), + Validator('param', UserIdParam), + Validator('json', BanUserAvatarRequest), + OpenAPI({ + operationId: 'ban_admin_user_avatar', + summary: "Ban this user's current avatar", + responseSchema: BanUserAvatarResponseSchema, + statusCode: 200, + security: ['adminApiKey'], + tags: ['Admin'], + description: + "Reads the user's current avatar_hash, strips any animation prefix, and adds the 8-char hash to the avatar-hash blocklist. Returns the banned hash.", + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserId = ctx.get('adminUserId'); + const auditLogReason = ctx.get('auditLogReason'); + const userId = ctx.req.valid('param').user_id.toString(); + const body = ctx.req.valid('json'); + return ctx.json( + await adminService.banManagementService.banUserAvatar({user_id: userId, ...body}, adminUserId, auditLogReason), + ); + }, + ); } diff --git a/fluxer_api/src/api/admin/controllers/BulkAdminController.ts b/fluxer_api/src/api/admin/controllers/BulkAdminController.ts index c34fafc2c..24903bb17 100644 --- a/fluxer_api/src/api/admin/controllers/BulkAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/BulkAdminController.ts @@ -1,18 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; -import { - BulkAddGuildMembersRequest, - BulkUpdateGuildFeaturesRequest, -} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas'; -import {BulkDeleteUserMessagesRequest} from '@fluxer/schema/src/domains/admin/AdminMessageSchemas'; +import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError'; +import {AdminBulkJobCreateRequest, AdminBulkTaskType} from '@fluxer/schema/src/domains/admin/AdminBulkSchemas'; import {BulkJobResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas'; -import { - BulkScheduleUserDeletionRequest, - BulkUpdateSuspiciousActivityFlagsRequest, - BulkUpdateUserFlagsRequest, -} from '@fluxer/schema/src/domains/admin/AdminUserSchemas'; -import {requireAdminACL} from '../../middleware/AdminMiddleware'; +import type {UserID} from '../../BrandedTypes'; +import {requireAnyAdminACL} from '../../middleware/AdminMiddleware'; import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware'; import {OpenAPI} from '../../middleware/ResponseTypeMiddleware'; import {getWorkerService} from '../../middleware/ServiceRegistry'; @@ -20,27 +13,25 @@ import {RateLimitConfigs} from '../../RateLimitConfig'; import type {HonoApp} from '../../types/HonoEnv'; import {Validator} from '../../Validator'; -export function BulkAdminController(app: HonoApp) { - app.post( - '/admin/bulk/update-user-flags', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION), - requireAdminACL(AdminACLs.BULK_UPDATE_USER_FLAGS), - Validator('json', BulkUpdateUserFlagsRequest), - OpenAPI({ - operationId: 'bulk_update_user_flags', - summary: 'Bulk update user flags', - description: - 'Enqueue a background job that modifies user flags (e.g., verified, bot, system) for multiple users. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.', - responseSchema: BulkJobResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - const body = ctx.req.valid('json'); - const jobId = await getWorkerService().addJob( +const BULK_TASK_ACLS: Record = { + [AdminBulkTaskType.UPDATE_USER_FLAGS]: AdminACLs.BULK_UPDATE_USER_FLAGS, + [AdminBulkTaskType.UPDATE_SUSPICIOUS_ACTIVITY_FLAGS]: AdminACLs.BULK_UPDATE_SUSPICIOUS_ACTIVITY, + [AdminBulkTaskType.UPDATE_GUILD_FEATURES]: AdminACLs.BULK_UPDATE_GUILD_FEATURES, + [AdminBulkTaskType.ADD_GUILD_MEMBERS]: AdminACLs.BULK_ADD_GUILD_MEMBERS, + [AdminBulkTaskType.SCHEDULE_USER_DELETION]: AdminACLs.BULK_DELETE_USERS, + [AdminBulkTaskType.DELETE_USER_MESSAGES]: AdminACLs.BULK_DELETE_USER_MESSAGES, +}; + +async function queueBulkJob( + body: AdminBulkJobCreateRequest, + adminUserId: UserID, + auditLogReason: string | null, +): Promise { + const workerService = getWorkerService(); + const options = {requestedByUserId: adminUserId, requireLedger: true, ...(auditLogReason && {auditLogReason})}; + switch (body.task) { + case AdminBulkTaskType.UPDATE_USER_FLAGS: + return await workerService.addJob( 'bulkUpdateUserFlags', { user_ids: body.user_ids.map((id) => id.toString()), @@ -49,31 +40,10 @@ export function BulkAdminController(app: HonoApp) { admin_user_id: adminUserId.toString(), audit_log_reason: auditLogReason, }, - {requestedByUserId: adminUserId, ...(auditLogReason && {auditLogReason})}, + options, ); - return ctx.json({job_id: jobId.toString()}); - }, - ); - app.post( - '/admin/bulk/update-suspicious-activity-flags', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION), - requireAdminACL(AdminACLs.BULK_UPDATE_SUSPICIOUS_ACTIVITY), - Validator('json', BulkUpdateSuspiciousActivityFlagsRequest), - OpenAPI({ - operationId: 'bulk_update_suspicious_activity_flags', - summary: 'Bulk update suspicious activity flags', - description: - 'Enqueue a background job that modifies suspicious activity flags for multiple users. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.', - responseSchema: BulkJobResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - const body = ctx.req.valid('json'); - const jobId = await getWorkerService().addJob( + case AdminBulkTaskType.UPDATE_SUSPICIOUS_ACTIVITY_FLAGS: + return await workerService.addJob( 'bulkUpdateSuspiciousActivityFlags', { user_ids: body.user_ids.map((id) => id.toString()), @@ -82,31 +52,10 @@ export function BulkAdminController(app: HonoApp) { admin_user_id: adminUserId.toString(), audit_log_reason: auditLogReason, }, - {requestedByUserId: adminUserId, ...(auditLogReason && {auditLogReason})}, + options, ); - return ctx.json({job_id: jobId.toString()}); - }, - ); - app.post( - '/admin/bulk/update-guild-features', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION), - requireAdminACL(AdminACLs.BULK_UPDATE_GUILD_FEATURES), - Validator('json', BulkUpdateGuildFeaturesRequest), - OpenAPI({ - operationId: 'bulk_update_guild_features', - summary: 'Bulk update guild features', - description: - 'Enqueue a background job that modifies guild features across multiple servers. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.', - responseSchema: BulkJobResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - const body = ctx.req.valid('json'); - const jobId = await getWorkerService().addJob( + case AdminBulkTaskType.UPDATE_GUILD_FEATURES: + return await workerService.addJob( 'bulkUpdateGuildFeatures', { guild_ids: body.guild_ids.map((id) => id.toString()), @@ -115,31 +64,10 @@ export function BulkAdminController(app: HonoApp) { admin_user_id: adminUserId.toString(), audit_log_reason: auditLogReason, }, - {requestedByUserId: adminUserId, ...(auditLogReason && {auditLogReason})}, + options, ); - return ctx.json({job_id: jobId.toString()}); - }, - ); - app.post( - '/admin/bulk/add-guild-members', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION), - requireAdminACL(AdminACLs.BULK_ADD_GUILD_MEMBERS), - Validator('json', BulkAddGuildMembersRequest), - OpenAPI({ - operationId: 'bulk_add_guild_members', - summary: 'Bulk add guild members', - description: - 'Enqueue a background job that adds multiple users to a guild. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.', - responseSchema: BulkJobResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - const body = ctx.req.valid('json'); - const jobId = await getWorkerService().addJob( + case AdminBulkTaskType.ADD_GUILD_MEMBERS: + return await workerService.addJob( 'bulkAddGuildMembers', { guild_id: body.guild_id.toString(), @@ -147,31 +75,10 @@ export function BulkAdminController(app: HonoApp) { admin_user_id: adminUserId.toString(), audit_log_reason: auditLogReason, }, - {requestedByUserId: adminUserId, ...(auditLogReason && {auditLogReason})}, + options, ); - return ctx.json({job_id: jobId.toString()}); - }, - ); - app.post( - '/admin/bulk/schedule-user-deletion', - RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION), - requireAdminACL(AdminACLs.BULK_DELETE_USERS), - Validator('json', BulkScheduleUserDeletionRequest), - OpenAPI({ - operationId: 'schedule_bulk_user_deletion', - summary: 'Schedule bulk user deletion', - description: - 'Enqueue a background job that schedules account deletions for multiple users. Returns a job_id immediately; observe progress at /admin/jobs/:job_id. Note: the worker version skips Stripe refunds, session termination, and identifier banning — apply those separately for high-risk accounts.', - responseSchema: BulkJobResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - const body = ctx.req.valid('json'); - const jobId = await getWorkerService().addJob( + case AdminBulkTaskType.SCHEDULE_USER_DELETION: + return await workerService.addJob( 'bulkScheduleUserDeletion', { user_ids: body.user_ids.map((id) => id.toString()), @@ -181,21 +88,39 @@ export function BulkAdminController(app: HonoApp) { admin_user_id: adminUserId.toString(), audit_log_reason: auditLogReason, }, - {requestedByUserId: adminUserId, ...(auditLogReason && {auditLogReason})}, + options, ); - return ctx.json({job_id: jobId.toString()}); - }, - ); + case AdminBulkTaskType.DELETE_USER_MESSAGES: + return await workerService.addJob( + 'bulkDeleteMessagesForUsers', + { + user_ids: body.user_ids.map((id) => id.toString()), + admin_user_id: adminUserId.toString(), + audit_log_reason: auditLogReason, + }, + options, + ); + } +} + +export function BulkAdminController(app: HonoApp) { app.post( - '/admin/bulk/delete-user-messages', + '/admin/bulk-jobs', RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION), - requireAdminACL(AdminACLs.BULK_DELETE_USER_MESSAGES), - Validator('json', BulkDeleteUserMessagesRequest), + Validator('json', AdminBulkJobCreateRequest), + requireAnyAdminACL([ + AdminACLs.BULK_UPDATE_USER_FLAGS, + AdminACLs.BULK_UPDATE_SUSPICIOUS_ACTIVITY, + AdminACLs.BULK_UPDATE_GUILD_FEATURES, + AdminACLs.BULK_ADD_GUILD_MEMBERS, + AdminACLs.BULK_DELETE_USERS, + AdminACLs.BULK_DELETE_USER_MESSAGES, + ]), OpenAPI({ - operationId: 'bulk_delete_user_messages', - summary: 'Bulk delete user messages', + operationId: 'create_admin_bulk_job', + summary: 'Queue a bulk job', description: - 'Enqueue a background job that deletes every message authored by each of the given users across all channels. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.', + 'Enqueue one background administrative job. The `task` discriminator selects both the body variant and the ACL evaluated for the request: `update_user_flags` needs bulk:update:user_flags, `update_suspicious_activity_flags` needs bulk:update:suspicious_activity, `update_guild_features` needs bulk:update:guild_features, `add_guild_members` needs bulk:add:guild_members, `schedule_user_deletion` needs bulk:delete:users, and `delete_user_messages` needs bulk:delete:user_messages. Returns a job_id immediately; observe progress at /admin/jobs/:job_id. Note: the schedule_user_deletion worker skips Stripe refunds, session termination, and identifier banning — apply those separately for high-risk accounts.', responseSchema: BulkJobResponse, statusCode: 200, security: 'adminApiKey', @@ -203,17 +128,14 @@ export function BulkAdminController(app: HonoApp) { }), async (ctx) => { const adminUserId = ctx.get('adminUserId'); + const adminAcls = ctx.get('adminUserAcls'); const auditLogReason = ctx.get('auditLogReason'); const body = ctx.req.valid('json'); - const jobId = await getWorkerService().addJob( - 'bulkDeleteMessagesForUsers', - { - user_ids: body.user_ids.map((id) => id.toString()), - admin_user_id: adminUserId.toString(), - audit_log_reason: auditLogReason, - }, - {requestedByUserId: adminUserId, ...(auditLogReason && {auditLogReason})}, - ); + const requiredAcl = BULK_TASK_ACLS[body.task]; + if (!adminAcls.has(requiredAcl) && !adminAcls.has(AdminACLs.WILDCARD)) { + throw new MissingACLError(requiredAcl); + } + const jobId = await queueBulkJob(body, adminUserId, auditLogReason); return ctx.json({job_id: jobId.toString()}); }, ); diff --git a/fluxer_api/src/api/admin/controllers/CodesAdminController.ts b/fluxer_api/src/api/admin/controllers/CodesAdminController.ts index 871575931..83ae64540 100644 --- a/fluxer_api/src/api/admin/controllers/CodesAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/CodesAdminController.ts @@ -17,15 +17,15 @@ function trimTrailingSlash(value: string): string { export function CodesAdminController(app: HonoApp) { app.post( - '/admin/codes/gift', + '/admin/gift-codes', RateLimitMiddleware(RateLimitConfigs.ADMIN_CODE_GENERATION), requireAdminACL(AdminACLs.GIFT_CODES_GENERATE), Validator('json', GenerateGiftCodesRequest), OpenAPI({ - operationId: 'generate_gift_codes', - summary: 'Generate gift codes', + operationId: 'create_admin_gift_codes', + summary: 'Issue gift codes', description: - 'Create one-use Plutonium gift codes with an explicit positive duration. Lifetime gifts are not supported.', + 'Create one-use Plutonium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. Not available on self-hosted instances. Requires GIFT_CODES_GENERATE permission.', responseSchema: CodesResponse, statusCode: 200, security: 'adminApiKey', diff --git a/fluxer_api/src/api/admin/controllers/DiscoveryAdminController.ts b/fluxer_api/src/api/admin/controllers/DiscoveryAdminController.ts index 86099801e..48e877f34 100644 --- a/fluxer_api/src/api/admin/controllers/DiscoveryAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/DiscoveryAdminController.ts @@ -1,15 +1,20 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; -import {DiscoveryApplicationStatus} from '@fluxer/constants/src/DiscoveryConstants'; +import {DiscoveryApplicationStatus, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants'; import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; import { + DiscoveryAdminApplicationUpdateRequest, + DiscoveryAdminCategoryListingQuery, DiscoveryAdminListedGuildResponse, + DiscoveryAdminListingBulkCategoryRequest, + DiscoveryAdminListingBulkCategoryResponse, DiscoveryAdminPendingApplicationResponse, - DiscoveryAdminRejectRequest, DiscoveryAdminRemoveRequest, - DiscoveryAdminReviewRequest, + DiscoveryApplicationPatchRequest, DiscoveryApplicationResponse, + DiscoveryCategoryIdParam, + DiscoveryCategoryListResponse, } from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas'; import {z} from 'zod'; import {createGuildID} from '../../BrandedTypes'; @@ -41,8 +46,6 @@ function mapRowToApplicationResponse(row: GuildDiscoveryRow) { }; } -const ADMIN_LIST_HARD_CAP = 1000; - interface GuildEnrichment { name: string; icon: string | null; @@ -131,8 +134,8 @@ export function DiscoveryAdminController(app: HonoApp) { RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_LIST), requireAdminACL(AdminACLs.DISCOVERY_REVIEW), OpenAPI({ - operationId: 'list_pending_discovery_applications', - summary: 'List all pending discovery applications', + operationId: 'list_admin_discovery_applications', + summary: 'List discovery applications', description: 'Returns every pending discovery application, enriched with guild metadata. No pagination. Requires DISCOVERY_REVIEW permission.', responseSchema: z.array(DiscoveryAdminPendingApplicationResponse), @@ -144,21 +147,106 @@ export function DiscoveryAdminController(app: HonoApp) { const discoveryService = ctx.get('discoveryService'); const guildService = ctx.get('guildService'); const userRepository = ctx.get('userRepository'); - const rows = await discoveryService.listByStatus({ - status: DiscoveryApplicationStatus.PENDING, - limit: ADMIN_LIST_HARD_CAP, - }); + const rows = await discoveryService.listByStatus({status: DiscoveryApplicationStatus.PENDING}); const enrichment = await enrichGuilds(rows, guildService, userRepository); return ctx.json(rows.map((row) => mapPendingResponse(row, enrichment.get(row.guild_id.toString())))); }, ); + app.patch( + '/admin/discovery/applications/:guild_id', + RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_ACTION), + requireAdminACL(AdminACLs.DISCOVERY_REVIEW), + Validator('param', GuildIdParam), + Validator('json', DiscoveryAdminApplicationUpdateRequest), + OpenAPI({ + operationId: 'update_admin_discovery_application', + summary: 'Review discovery application', + description: 'Approve or reject a pending discovery application. Requires DISCOVERY_REVIEW permission.', + responseSchema: DiscoveryApplicationResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + }), + async (ctx) => { + const {guild_id} = ctx.req.valid('param'); + const guildId = createGuildID(guild_id); + const data = ctx.req.valid('json'); + const adminUserId = ctx.get('adminUserId'); + const discoveryService = ctx.get('discoveryService'); + const row = + data.status === DiscoveryApplicationStatus.APPROVED + ? await discoveryService.approve({guildId, adminUserId, reason: data.reason}) + : await discoveryService.reject({guildId, adminUserId, reason: data.reason}); + return ctx.json(mapRowToApplicationResponse(row)); + }, + ); app.get( - '/admin/discovery/listed', + '/admin/discovery/categories', RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_LIST), requireAdminACL(AdminACLs.DISCOVERY_REVIEW), OpenAPI({ - operationId: 'list_discovery_listed_guilds', - summary: 'List all guilds currently listed in discovery', + operationId: 'list_admin_discovery_categories', + summary: 'List discovery categories', + description: + 'Returns every discovery category a listing can be filed under. Requires DISCOVERY_REVIEW permission.', + responseSchema: DiscoveryCategoryListResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + }), + async (ctx) => { + return ctx.json( + Object.entries(DiscoveryCategoryLabels).map(([id, name]) => ({ + id: Number(id), + name, + })), + ); + }, + ); + app.get( + '/admin/discovery/categories/:category_id/listings', + RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_LIST), + requireAdminACL(AdminACLs.DISCOVERY_REVIEW), + Validator('param', DiscoveryCategoryIdParam), + Validator('query', DiscoveryAdminCategoryListingQuery), + OpenAPI({ + operationId: 'list_admin_discovery_category_listings', + summary: 'List guilds in a discovery category', + description: + 'Returns an offset page of the guilds listed under one discovery category, most members first, enriched with guild metadata. Requires DISCOVERY_REVIEW permission.', + responseSchema: z.array(DiscoveryAdminListedGuildResponse), + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + }), + async (ctx) => { + const {category_id} = ctx.req.valid('param'); + const {limit, offset} = ctx.req.valid('query'); + const discoveryService = ctx.get('discoveryService'); + const guildService = ctx.get('guildService'); + const userRepository = ctx.get('userRepository'); + const rows = await discoveryService.listByStatus({status: DiscoveryApplicationStatus.APPROVED}); + const inCategory = rows.filter((row) => row.category_type === category_id); + const enrichment = await enrichGuilds(inCategory, guildService, userRepository); + const sorted = [...inCategory].sort( + (left, right) => + (enrichment.get(right.guild_id.toString())?.member_count ?? 0) - + (enrichment.get(left.guild_id.toString())?.member_count ?? 0), + ); + return ctx.json( + sorted + .slice(offset, offset + limit) + .map((row) => mapListedResponse(row, enrichment.get(row.guild_id.toString()))), + ); + }, + ); + app.get( + '/admin/discovery/listings', + RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_LIST), + requireAdminACL(AdminACLs.DISCOVERY_REVIEW), + OpenAPI({ + operationId: 'list_admin_discovery_listings', + summary: 'List discovery listings', description: 'Returns every approved/listed discovery guild, enriched with guild metadata. No pagination. Requires DISCOVERY_REVIEW permission.', responseSchema: z.array(DiscoveryAdminListedGuildResponse), @@ -170,24 +258,59 @@ export function DiscoveryAdminController(app: HonoApp) { const discoveryService = ctx.get('discoveryService'); const guildService = ctx.get('guildService'); const userRepository = ctx.get('userRepository'); - const rows = await discoveryService.listByStatus({ - status: DiscoveryApplicationStatus.APPROVED, - limit: ADMIN_LIST_HARD_CAP, - }); + const rows = await discoveryService.listByStatus({status: DiscoveryApplicationStatus.APPROVED}); const enrichment = await enrichGuilds(rows, guildService, userRepository); return ctx.json(rows.map((row) => mapListedResponse(row, enrichment.get(row.guild_id.toString())))); }, ); - app.post( - '/admin/discovery/applications/:guild_id/approve', + app.patch( + '/admin/discovery/listings', + RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_ACTION), + requireAdminACL(AdminACLs.DISCOVERY_REVIEW), + Validator('json', DiscoveryAdminListingBulkCategoryRequest), + OpenAPI({ + operationId: 'bulk_update_admin_discovery_listing_category', + summary: 'Move discovery listings to a category', + description: + 'Files every named discovery listing under one category. Every guild is attempted and the ones that could not be moved are reported. Requires DISCOVERY_REVIEW permission.', + responseSchema: DiscoveryAdminListingBulkCategoryResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + }), + async (ctx) => { + const data = ctx.req.valid('json'); + const adminUserId = ctx.get('adminUserId'); + const discoveryService = ctx.get('discoveryService'); + const guildIds = [...new Set(data.guild_ids)]; + const failed: Array = []; + let updated = 0; + for (const rawGuildId of guildIds) { + try { + await discoveryService.editApplication({ + guildId: createGuildID(rawGuildId), + userId: adminUserId, + data: {category_type: data.category_type}, + }); + updated += 1; + } catch { + failed.push(rawGuildId.toString()); + } + } + return ctx.json({updated, failed_guild_ids: failed}); + }, + ); + app.patch( + '/admin/discovery/listings/:guild_id', RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_ACTION), requireAdminACL(AdminACLs.DISCOVERY_REVIEW), Validator('param', GuildIdParam), - Validator('json', DiscoveryAdminReviewRequest), + Validator('json', DiscoveryApplicationPatchRequest), OpenAPI({ - operationId: 'approve_discovery_application', - summary: 'Approve discovery application', - description: 'Approve a pending discovery application. Requires DISCOVERY_REVIEW permission.', + operationId: 'update_admin_discovery_listing', + summary: 'Update discovery listing', + description: + 'Edit the description, category, language, or tags of a discovery listing without delisting the guild. Requires DISCOVERY_REVIEW permission.', responseSchema: DiscoveryApplicationResponse, statusCode: 200, security: 'adminApiKey', @@ -199,44 +322,19 @@ export function DiscoveryAdminController(app: HonoApp) { const data = ctx.req.valid('json'); const adminUserId = ctx.get('adminUserId'); const discoveryService = ctx.get('discoveryService'); - const row = await discoveryService.approve({guildId, adminUserId, reason: data.reason}); + const row = await discoveryService.editApplication({guildId, userId: adminUserId, data}); return ctx.json(mapRowToApplicationResponse(row)); }, ); - app.post( - '/admin/discovery/applications/:guild_id/reject', - RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_ACTION), - requireAdminACL(AdminACLs.DISCOVERY_REVIEW), - Validator('param', GuildIdParam), - Validator('json', DiscoveryAdminRejectRequest), - OpenAPI({ - operationId: 'reject_discovery_application', - summary: 'Reject discovery application', - description: 'Reject a pending discovery application. Requires DISCOVERY_REVIEW permission.', - responseSchema: DiscoveryApplicationResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const {guild_id} = ctx.req.valid('param'); - const guildId = createGuildID(guild_id); - const data = ctx.req.valid('json'); - const adminUserId = ctx.get('adminUserId'); - const discoveryService = ctx.get('discoveryService'); - const row = await discoveryService.reject({guildId, adminUserId, reason: data.reason}); - return ctx.json(mapRowToApplicationResponse(row)); - }, - ); - app.post( - '/admin/discovery/guilds/:guild_id/remove', + app.delete( + '/admin/discovery/listings/:guild_id', RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_ACTION), requireAdminACL(AdminACLs.DISCOVERY_REMOVE), Validator('param', GuildIdParam), Validator('json', DiscoveryAdminRemoveRequest), OpenAPI({ - operationId: 'remove_from_discovery', - summary: 'Remove guild from discovery', + operationId: 'delete_admin_discovery_listing', + summary: 'Remove discovery listing', description: 'Remove an approved guild from discovery. Requires DISCOVERY_REMOVE permission.', responseSchema: DiscoveryApplicationResponse, statusCode: 200, diff --git a/fluxer_api/src/api/admin/controllers/GatewayAdminController.ts b/fluxer_api/src/api/admin/controllers/GatewayAdminController.ts index 82c5b92fd..c9ac34122 100644 --- a/fluxer_api/src/api/admin/controllers/GatewayAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/GatewayAdminController.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; -import {GetProcessMemoryStatsRequest} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas'; +import {GetProcessMemoryStatsQuery} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas'; import { GatewayVoiceStateCountsResponse, GuildMemoryStatsResponse, @@ -18,54 +18,12 @@ import type {HonoApp} from '../../types/HonoEnv'; import {Validator} from '../../Validator'; export function GatewayAdminController(app: HonoApp) { - app.post( - '/admin/gateway/memory-stats', - RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), - requireAdminACL(AdminACLs.GATEWAY_MEMORY_STATS), - Validator('json', GetProcessMemoryStatsRequest), - OpenAPI({ - operationId: 'get_guild_memory_statistics', - summary: 'Get guild memory statistics', - description: 'Returns heap and resident memory usage per guild. Requires GATEWAY_MEMORY_STATS permission.', - responseSchema: GuildMemoryStatsResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const body = ctx.req.valid('json'); - return ctx.json(await adminService.guildServiceAggregate.managementService.getGuildMemoryStats(body.limit)); - }, - ); - app.post( - '/admin/gateway/reload-all', - RateLimitMiddleware(RateLimitConfigs.ADMIN_GATEWAY_RELOAD), - requireAdminACL(AdminACLs.GATEWAY_RELOAD_ALL), - Validator('json', ReloadGuildsRequest), - OpenAPI({ - operationId: 'reload_all_specified_guilds', - summary: 'Reload specified guilds', - description: - 'Reconnects to the database and re-syncs guild state. Used for recovery after data inconsistencies. Requires GATEWAY_RELOAD_ALL permission.', - responseSchema: ReloadAllGuildsResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const body = ctx.req.valid('json'); - const guildIds = body.guild_ids.map((id) => createGuildID(id)); - return ctx.json(await adminService.guildServiceAggregate.managementService.reloadAllGuilds(guildIds)); - }, - ); app.get( '/admin/gateway/stats', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.GATEWAY_MEMORY_STATS), OpenAPI({ - operationId: 'get_gateway_node_statistics', + operationId: 'get_admin_gateway_stats', summary: 'Get gateway node statistics', description: 'Returns uptime, process memory, and guild count. Used to monitor gateway health and performance. Requires GATEWAY_MEMORY_STATS permission.', @@ -79,12 +37,32 @@ export function GatewayAdminController(app: HonoApp) { return ctx.json(await adminService.guildServiceAggregate.managementService.getNodeStats()); }, ); + app.get( + '/admin/gateway/memory-stats', + RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), + requireAdminACL(AdminACLs.GATEWAY_MEMORY_STATS), + Validator('query', GetProcessMemoryStatsQuery), + OpenAPI({ + operationId: 'get_admin_gateway_memory_stats', + summary: 'Get guild memory statistics', + description: 'Returns heap and resident memory usage per guild. Requires GATEWAY_MEMORY_STATS permission.', + responseSchema: GuildMemoryStatsResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const {limit} = ctx.req.valid('query'); + return ctx.json(await adminService.guildServiceAggregate.managementService.getGuildMemoryStats(limit)); + }, + ); app.get( '/admin/gateway/voice-state-counts', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.GATEWAY_MEMORY_STATS), OpenAPI({ - operationId: 'get_gateway_voice_state_counts', + operationId: 'get_admin_gateway_voice_state_counts', summary: 'Get gateway voice state counts', description: 'Returns active voice state counts grouped by voice region and voice server. Requires GATEWAY_MEMORY_STATS permission.', @@ -98,4 +76,26 @@ export function GatewayAdminController(app: HonoApp) { return ctx.json(await adminService.guildServiceAggregate.managementService.getVoiceStateCounts()); }, ); + app.post( + '/admin/gateway/reloads', + RateLimitMiddleware(RateLimitConfigs.ADMIN_GATEWAY_RELOAD), + requireAdminACL(AdminACLs.GATEWAY_RELOAD_ALL), + Validator('json', ReloadGuildsRequest), + OpenAPI({ + operationId: 'create_admin_gateway_reload', + summary: 'Reload gateway guilds', + description: + 'Reconnects to the database and re-syncs guild state. Used for recovery after data inconsistencies. Requires GATEWAY_RELOAD_ALL permission.', + responseSchema: ReloadAllGuildsResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const body = ctx.req.valid('json'); + const guildIds = body.guild_ids.map((id) => createGuildID(id)); + return ctx.json(await adminService.guildServiceAggregate.managementService.reloadAllGuilds(guildIds)); + }, + ); } diff --git a/fluxer_api/src/api/admin/controllers/GuildAdminController.ts b/fluxer_api/src/api/admin/controllers/GuildAdminController.ts index f816f91e3..4aa6f4c11 100644 --- a/fluxer_api/src/api/admin/controllers/GuildAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/GuildAdminController.ts @@ -1,23 +1,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError'; +import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError'; import { - BanGuildMemberRequest, - ClearGuildFieldsRequest, - DeleteGuildRequest, - ForceAddUserToGuildRequest, - KickGuildMemberRequest, - ListGuildAuditLogsRequest, + BanGuildMemberBody, ListGuildAuditLogsResponse, - ListGuildMembersRequest, - LookupGuildRequest, - ReloadGuildRequest, - ShutdownGuildRequest, - TransferGuildOwnershipRequest, - UpdateGuildFeaturesRequest, - UpdateGuildNameRequest, - UpdateGuildSettingsRequest, - UpdateGuildVanityRequest, + ListGuildMembersQuery, + ListGuildsQuery, + UpdateGuildRequest, } from '@fluxer/schema/src/domains/admin/AdminGuildSchemas'; import { GuildUpdateResponse, @@ -25,29 +16,114 @@ import { ListGuildMembersResponse, ListGuildStickersResponse, LookupGuildResponse, + SearchGuildsResponse, SuccessResponse, } from '@fluxer/schema/src/domains/admin/AdminSchemas'; -import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; +import {GuildIdParam, GuildIdUserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; +import {GuildAuditLogListQuery} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas'; import {createGuildID} from '../../BrandedTypes'; -import {requireAdminACL} from '../../middleware/AdminMiddleware'; +import {requireAdminACL, requireAnyAdminACL} from '../../middleware/AdminMiddleware'; import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware'; import {OpenAPI} from '../../middleware/ResponseTypeMiddleware'; -import {RateLimitConfigs} from '../../RateLimitConfig'; import {AdminRateLimitConfigs} from '../../rate_limit_configs/AdminRateLimitConfig'; import type {HonoApp} from '../../types/HonoEnv'; import {Validator} from '../../Validator'; +const GUILD_UPDATE_ACLS = [ + AdminACLs.GUILD_UPDATE_NAME, + AdminACLs.GUILD_UPDATE_SETTINGS, + AdminACLs.GUILD_UPDATE_FEATURES, + AdminACLs.GUILD_UPDATE_VANITY, + AdminACLs.GUILD_TRANSFER_OWNERSHIP, +]; + +function hasGuildSettingsUpdate(body: UpdateGuildRequest): boolean { + return ( + body.verification_level !== undefined || + body.mfa_level !== undefined || + body.nsfw_level !== undefined || + body.nsfw !== undefined || + body.content_warning_level !== undefined || + body.content_warning_text !== undefined || + body.explicit_content_filter !== undefined || + body.default_message_notifications !== undefined || + body.disabled_operations !== undefined + ); +} + +function hasGuildFeatureUpdate(body: UpdateGuildRequest): boolean { + return body.add_features !== undefined || body.remove_features !== undefined; +} + +function selectGuildUpdateACLs(body: UpdateGuildRequest): Array { + const required: Array = []; + if (body.name !== undefined) { + required.push(AdminACLs.GUILD_UPDATE_NAME); + } + if (body.fields !== undefined || hasGuildSettingsUpdate(body)) { + required.push(AdminACLs.GUILD_UPDATE_SETTINGS); + } + if (hasGuildFeatureUpdate(body)) { + required.push(AdminACLs.GUILD_UPDATE_FEATURES); + } + if (body.vanity_url_code !== undefined) { + required.push(AdminACLs.GUILD_UPDATE_VANITY); + } + if (body.new_owner_id !== undefined) { + required.push(AdminACLs.GUILD_TRANSFER_OWNERSHIP); + } + return required.length > 0 ? required : [AdminACLs.WILDCARD]; +} + +function requireAllAdminACLs(granted: ReadonlySet, required: ReadonlyArray): void { + if (granted.has(AdminACLs.WILDCARD)) { + return; + } + for (const acl of required) { + if (!granted.has(acl)) { + throw new MissingACLError(acl); + } + } +} + export function GuildAdminController(app: HonoApp) { - app.post( - '/admin/guilds/lookup', - RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), + app.get( + '/admin/guilds', + RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.GUILD_LOOKUP), - Validator('json', LookupGuildRequest), + Validator('query', ListGuildsQuery), OpenAPI({ - operationId: 'lookup_guild', - summary: 'Look up guild', + operationId: 'list_admin_guilds', + summary: 'List guilds', description: - 'Retrieves complete guild details including metadata, settings, and statistics. Look up by guild ID or vanity slug. Requires GUILD_LOOKUP permission.', + 'Searches guilds by name, ID, and other criteria. Supports full-text search and pagination through limit and offset. Requires GUILD_LOOKUP permission.', + responseSchema: SearchGuildsResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const query = ctx.req.valid('query'); + return ctx.json( + await adminService.searchService.searchGuilds({ + query: query.q, + limit: query.limit, + offset: query.offset, + }), + ); + }, + ); + app.get( + '/admin/guilds/:guild_id', + RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP), + requireAdminACL(AdminACLs.GUILD_LOOKUP), + Validator('param', GuildIdParam), + OpenAPI({ + operationId: 'get_admin_guild', + summary: 'Get guild', + description: + 'Retrieves complete guild details including metadata, settings, channels, roles, and statistics. Requires GUILD_LOOKUP permission.', responseSchema: LookupGuildResponse, statusCode: 200, security: 'adminApiKey', @@ -55,16 +131,138 @@ export function GuildAdminController(app: HonoApp) { }), async (ctx) => { const adminService = ctx.get('adminService'); - return ctx.json(await adminService.guildServiceAggregate.lookupService.lookupGuild(ctx.req.valid('json'))); + return ctx.json( + await adminService.guildServiceAggregate.lookupService.lookupGuild({ + guild_id: ctx.req.valid('param').guild_id, + }), + ); }, ); - app.post( - '/admin/guilds/list-members', - RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), - requireAdminACL(AdminACLs.GUILD_LIST_MEMBERS), - Validator('json', ListGuildMembersRequest), + app.patch( + '/admin/guilds/:guild_id', + RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY), + requireAnyAdminACL(GUILD_UPDATE_ACLS), + Validator('param', GuildIdParam), + Validator('json', UpdateGuildRequest), OpenAPI({ - operationId: 'admin_list_guild_members', + operationId: 'update_admin_guild', + summary: 'Update guild', + description: + 'Partially updates a guild. The permissions required are selected by the fields present in the body and are evaluated with all-of semantics: name requires GUILD_UPDATE_NAME, vanity_url_code requires GUILD_UPDATE_VANITY, new_owner_id requires GUILD_TRANSFER_OWNERSHIP, add_features and remove_features require GUILD_UPDATE_FEATURES, and fields together with every other setting requires GUILD_UPDATE_SETTINGS. A body carrying no field requires the wildcard permission. Every applied change is logged to the audit log.', + responseSchema: GuildUpdateResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserId = ctx.get('adminUserId'); + const auditLogReason = ctx.get('auditLogReason'); + const guildIdRaw = ctx.req.valid('param').guild_id; + const body = ctx.req.valid('json'); + requireAllAdminACLs(ctx.get('adminUserAcls'), selectGuildUpdateACLs(body)); + const {updateService, vanityService, lookupService} = adminService.guildServiceAggregate; + if (body.fields !== undefined) { + await updateService.clearGuildFields({guild_id: guildIdRaw, fields: body.fields}, adminUserId, auditLogReason); + } + if (hasGuildSettingsUpdate(body)) { + await updateService.updateGuildSettings( + { + guild_id: guildIdRaw, + verification_level: body.verification_level, + mfa_level: body.mfa_level, + nsfw_level: body.nsfw_level, + nsfw: body.nsfw, + content_warning_level: body.content_warning_level, + content_warning_text: body.content_warning_text, + explicit_content_filter: body.explicit_content_filter, + default_message_notifications: body.default_message_notifications, + disabled_operations: body.disabled_operations, + }, + adminUserId, + auditLogReason, + ); + } + if (hasGuildFeatureUpdate(body)) { + await updateService.updateGuildFeatures({ + guildId: createGuildID(guildIdRaw), + addFeatures: body.add_features ?? [], + removeFeatures: body.remove_features ?? [], + adminUserId, + auditLogReason, + }); + } + if (body.name !== undefined) { + await updateService.updateGuildName({guild_id: guildIdRaw, name: body.name}, adminUserId, auditLogReason); + } + if (body.vanity_url_code !== undefined) { + await vanityService.updateGuildVanity( + {guild_id: guildIdRaw, vanity_url_code: body.vanity_url_code}, + adminUserId, + auditLogReason, + ); + } + if (body.new_owner_id !== undefined) { + await updateService.transferGuildOwnership( + {guild_id: guildIdRaw, new_owner_id: body.new_owner_id}, + adminUserId, + auditLogReason, + ); + } + const {guild} = await lookupService.lookupGuild({guild_id: guildIdRaw}); + if (!guild) { + throw new UnknownGuildError(); + } + return ctx.json({ + guild: { + id: guild.id, + name: guild.name, + features: guild.features, + owner_id: guild.owner_id, + icon: guild.icon, + banner: guild.banner, + member_count: guild.member_count, + nsfw_level: guild.nsfw_level, + }, + }); + }, + ); + app.delete( + '/admin/guilds/:guild_id', + RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY), + requireAdminACL(AdminACLs.GUILD_DELETE), + Validator('param', GuildIdParam), + OpenAPI({ + operationId: 'delete_admin_guild', + summary: 'Delete guild', + description: + 'Permanently deletes a guild. Deletes all channels, messages, and settings. Irreversible operation with no recovery window. Logged to audit log. Requires GUILD_DELETE permission.', + responseSchema: SuccessResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserId = ctx.get('adminUserId'); + const auditLogReason = ctx.get('auditLogReason'); + return ctx.json( + await adminService.guildServiceAggregate.managementService.deleteGuild( + ctx.req.valid('param').guild_id, + adminUserId, + auditLogReason, + ), + ); + }, + ); + app.get( + '/admin/guilds/:guild_id/members', + RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP), + requireAdminACL(AdminACLs.GUILD_LIST_MEMBERS), + Validator('param', GuildIdParam), + Validator('query', ListGuildMembersQuery), + OpenAPI({ + operationId: 'list_admin_guild_members', summary: 'List guild members', description: 'Lists all guild members with pagination. Returns member IDs, join dates, and roles. Requires GUILD_LIST_MEMBERS permission.', @@ -75,7 +273,102 @@ export function GuildAdminController(app: HonoApp) { }), async (ctx) => { const adminService = ctx.get('adminService'); - return ctx.json(await adminService.guildServiceAggregate.lookupService.listGuildMembers(ctx.req.valid('json'))); + const query = ctx.req.valid('query'); + return ctx.json( + await adminService.guildServiceAggregate.lookupService.listGuildMembers({ + guild_id: ctx.req.valid('param').guild_id, + limit: query.limit, + offset: query.offset, + }), + ); + }, + ); + app.put( + '/admin/guilds/:guild_id/members/:user_id', + RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY), + requireAdminACL(AdminACLs.GUILD_FORCE_ADD_MEMBER), + Validator('param', GuildIdUserIdParam), + OpenAPI({ + operationId: 'add_admin_guild_member', + summary: 'Add guild member', + description: + 'Forcefully adds a user to a guild. Bypasses normal invite flow for administrative account recovery. Logged to audit log. Requires GUILD_FORCE_ADD_MEMBER permission.', + responseSchema: SuccessResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserId = ctx.get('adminUserId'); + const auditLogReason = ctx.get('auditLogReason'); + const requestCache = ctx.get('requestCache'); + const params = ctx.req.valid('param'); + return ctx.json( + await adminService.guildServiceAggregate.membershipService.forceAddUserToGuild({ + data: {guild_id: params.guild_id, user_id: params.user_id}, + requestCache, + adminUserId, + auditLogReason, + }), + ); + }, + ); + app.delete( + '/admin/guilds/:guild_id/members/:user_id', + RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY), + requireAdminACL(AdminACLs.GUILD_KICK_MEMBER), + Validator('param', GuildIdUserIdParam), + OpenAPI({ + operationId: 'kick_admin_guild_member', + summary: 'Remove guild member', + description: + 'Temporarily removes a user from a guild. User can rejoin. Logged to audit log. Requires GUILD_KICK_MEMBER permission.', + responseSchema: null, + statusCode: 204, + security: 'adminApiKey', + tags: 'Admin', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserId = ctx.get('adminUserId'); + const auditLogReason = ctx.get('auditLogReason'); + const params = ctx.req.valid('param'); + await adminService.guildServiceAggregate.membershipService.kickMember( + {guild_id: params.guild_id, user_id: params.user_id}, + adminUserId, + auditLogReason, + ); + return ctx.body(null, 204); + }, + ); + app.put( + '/admin/guilds/:guild_id/bans/:user_id', + RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY), + requireAdminACL(AdminACLs.GUILD_BAN_MEMBER), + Validator('param', GuildIdUserIdParam), + Validator('json', BanGuildMemberBody), + OpenAPI({ + operationId: 'ban_admin_guild_member', + summary: 'Ban guild member', + description: + 'Bans a user from a guild, optionally deleting their recent messages. Prevents the user from joining until the ban expires or is removed. Logged to audit log. Requires GUILD_BAN_MEMBER permission.', + responseSchema: null, + statusCode: 204, + security: 'adminApiKey', + tags: 'Admin', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserId = ctx.get('adminUserId'); + const auditLogReason = ctx.get('auditLogReason'); + const params = ctx.req.valid('param'); + await adminService.guildServiceAggregate.membershipService.banMember( + {...ctx.req.valid('json'), guild_id: params.guild_id, user_id: params.user_id}, + adminUserId, + auditLogReason, + ); + return ctx.body(null, 204); }, ); app.get( @@ -84,7 +377,7 @@ export function GuildAdminController(app: HonoApp) { requireAdminACL(AdminACLs.ASSET_PURGE), Validator('param', GuildIdParam), OpenAPI({ - operationId: 'admin_list_guild_emojis', + operationId: 'list_admin_guild_emojis', summary: 'List guild emojis', description: 'Lists all custom emojis in a guild. Returns ID, name, and creation date. Used for asset inventory and purge operations. Requires ASSET_PURGE permission.', @@ -105,7 +398,7 @@ export function GuildAdminController(app: HonoApp) { requireAdminACL(AdminACLs.ASSET_PURGE), Validator('param', GuildIdParam), OpenAPI({ - operationId: 'admin_list_guild_stickers', + operationId: 'list_admin_guild_stickers', summary: 'List guild stickers', description: 'Lists all stickers in a guild. Returns ID, name, and asset information. Used for asset inventory and purge operations. Requires ASSET_PURGE permission.', @@ -120,13 +413,14 @@ export function GuildAdminController(app: HonoApp) { return ctx.json(await adminService.guildServiceAggregate.lookupService.listGuildStickers(guildId)); }, ); - app.post( - '/admin/guilds/audit-logs', + app.get( + '/admin/guilds/:guild_id/audit-logs', RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.GUILD_AUDIT_LOG_VIEW), - Validator('json', ListGuildAuditLogsRequest), + Validator('param', GuildIdParam), + Validator('query', GuildAuditLogListQuery), OpenAPI({ - operationId: 'list_guild_audit_logs_admin', + operationId: 'list_admin_guild_audit_logs', summary: 'List guild audit logs', description: 'Returns in-app guild audit log entries for a guild without requiring VIEW_AUDIT_LOG membership permission. Supports pagination via before/after log IDs and filtering by user_id or action_type. Requires GUILD_AUDIT_LOG_VIEW permission.', @@ -137,271 +431,26 @@ export function GuildAdminController(app: HonoApp) { }), async (ctx) => { const adminService = ctx.get('adminService'); - return ctx.json(await adminService.guildServiceAggregate.listGuildAuditLogs(ctx.req.valid('json'))); - }, - ); - app.post( - '/admin/guilds/clear-fields', - RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY), - requireAdminACL(AdminACLs.GUILD_UPDATE_SETTINGS), - Validator('json', ClearGuildFieldsRequest), - OpenAPI({ - operationId: 'clear_guild_fields', - summary: 'Clear guild fields', - description: - 'Clears specified optional guild fields such as icon, banner, or description. Logged to audit log. Requires GUILD_UPDATE_SETTINGS permission.', - responseSchema: null, - statusCode: 204, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.guildServiceAggregate.updateService.clearGuildFields( - ctx.req.valid('json'), - adminUserId, - auditLogReason, - ); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/guilds/update-features', - RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY), - requireAdminACL(AdminACLs.GUILD_UPDATE_FEATURES), - Validator('json', UpdateGuildFeaturesRequest), - OpenAPI({ - operationId: 'update_guild_features', - summary: 'Update guild features', - description: - 'Enables or disables guild feature flags. Modifies verification levels and community settings. Changes are logged to audit log. Requires GUILD_UPDATE_FEATURES permission.', - responseSchema: GuildUpdateResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - const body = ctx.req.valid('json'); - const guildId = createGuildID(body.guild_id); + const query = ctx.req.valid('query'); return ctx.json( - await adminService.guildServiceAggregate.updateService.updateGuildFeatures({ - guildId, - addFeatures: body.add_features, - removeFeatures: body.remove_features, - adminUserId, - auditLogReason, + await adminService.guildServiceAggregate.listGuildAuditLogs({ + guild_id: ctx.req.valid('param').guild_id, + limit: query.limit, + before: query.before, + after: query.after, + user_id: query.user_id, + action_type: query.action_type, }), ); }, ); app.post( - '/admin/guilds/update-name', - RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY), - requireAdminACL(AdminACLs.GUILD_UPDATE_NAME), - Validator('json', UpdateGuildNameRequest), - OpenAPI({ - operationId: 'update_guild_name', - summary: 'Update guild name', - description: - 'Changes a guild name. Used for removing inappropriate names or correcting display issues. Logged to audit log. Requires GUILD_UPDATE_NAME permission.', - responseSchema: GuildUpdateResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - return ctx.json( - await adminService.guildServiceAggregate.updateService.updateGuildName( - ctx.req.valid('json'), - adminUserId, - auditLogReason, - ), - ); - }, - ); - app.post( - '/admin/guilds/update-settings', - RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY), - requireAdminACL(AdminACLs.GUILD_UPDATE_SETTINGS), - Validator('json', UpdateGuildSettingsRequest), - OpenAPI({ - operationId: 'update_guild_settings', - summary: 'Update guild settings', - description: - 'Modifies guild configuration including description, region, language and other settings. Logged to audit log. Requires GUILD_UPDATE_SETTINGS permission.', - responseSchema: GuildUpdateResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - return ctx.json( - await adminService.guildServiceAggregate.updateService.updateGuildSettings( - ctx.req.valid('json'), - adminUserId, - auditLogReason, - ), - ); - }, - ); - app.post( - '/admin/guilds/transfer-ownership', - RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY), - requireAdminACL(AdminACLs.GUILD_TRANSFER_OWNERSHIP), - Validator('json', TransferGuildOwnershipRequest), - OpenAPI({ - operationId: 'admin_transfer_guild_ownership', - summary: 'Transfer guild ownership', - description: - 'Transfers guild ownership to another user. Used when owner is inactive or for administrative recovery. Logged to audit log. Requires GUILD_TRANSFER_OWNERSHIP permission.', - responseSchema: GuildUpdateResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - return ctx.json( - await adminService.guildServiceAggregate.updateService.transferGuildOwnership( - ctx.req.valid('json'), - adminUserId, - auditLogReason, - ), - ); - }, - ); - app.post( - '/admin/guilds/update-vanity', - RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY), - requireAdminACL(AdminACLs.GUILD_UPDATE_VANITY), - Validator('json', UpdateGuildVanityRequest), - OpenAPI({ - operationId: 'update_guild_vanity', - summary: 'Update guild vanity', - description: - 'Updates a guild vanity URL slug. Sets custom short URL and prevents duplicate slugs. Logged to audit log. Requires GUILD_UPDATE_VANITY permission.', - responseSchema: GuildUpdateResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - return ctx.json( - await adminService.guildServiceAggregate.vanityService.updateGuildVanity( - ctx.req.valid('json'), - adminUserId, - auditLogReason, - ), - ); - }, - ); - app.post( - '/admin/guilds/force-add-user', - RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY), - requireAdminACL(AdminACLs.GUILD_FORCE_ADD_MEMBER), - Validator('json', ForceAddUserToGuildRequest), - OpenAPI({ - operationId: 'force_add_user_to_guild', - summary: 'Force add user to guild', - description: - 'Forcefully adds a user to a guild. Bypasses normal invite flow for administrative account recovery. Logged to audit log. Requires GUILD_FORCE_ADD_MEMBER permission.', - responseSchema: SuccessResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - const requestCache = ctx.get('requestCache'); - return ctx.json( - await adminService.guildServiceAggregate.membershipService.forceAddUserToGuild({ - data: ctx.req.valid('json'), - requestCache, - adminUserId, - auditLogReason, - }), - ); - }, - ); - app.post( - '/admin/guilds/ban-member', - RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY), - requireAdminACL(AdminACLs.GUILD_BAN_MEMBER), - Validator('json', BanGuildMemberRequest), - OpenAPI({ - operationId: 'admin_ban_guild_member', - summary: 'Ban guild member', - description: - 'Permanently bans a user from a guild. Prevents user from joining. Logged to audit log. Requires GUILD_BAN_MEMBER permission.', - responseSchema: null, - statusCode: 204, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.guildServiceAggregate.membershipService.banMember( - ctx.req.valid('json'), - adminUserId, - auditLogReason, - ); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/guilds/kick-member', - RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY), - requireAdminACL(AdminACLs.GUILD_KICK_MEMBER), - Validator('json', KickGuildMemberRequest), - OpenAPI({ - operationId: 'kick_guild_member', - summary: 'Kick guild member', - description: - 'Temporarily removes a user from a guild. User can rejoin. Logged to audit log. Requires GUILD_KICK_MEMBER permission.', - responseSchema: null, - statusCode: 204, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.guildServiceAggregate.membershipService.kickMember( - ctx.req.valid('json'), - adminUserId, - auditLogReason, - ); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/guilds/reload', - RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY), + '/admin/guilds/:guild_id/reloads', + RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY), requireAdminACL(AdminACLs.GUILD_RELOAD), - Validator('json', ReloadGuildRequest), + Validator('param', GuildIdParam), OpenAPI({ - operationId: 'reload_guild', + operationId: 'create_admin_guild_reload', summary: 'Reload guild', description: 'Reloads a single guild state from database. Used to recover from corruption or sync issues. Logged to audit log. Requires GUILD_RELOAD permission.', @@ -414,10 +463,9 @@ export function GuildAdminController(app: HonoApp) { const adminService = ctx.get('adminService'); const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); - const body = ctx.req.valid('json'); return ctx.json( await adminService.guildServiceAggregate.managementService.reloadGuild( - body.guild_id, + ctx.req.valid('param').guild_id, adminUserId, auditLogReason, ), @@ -425,13 +473,13 @@ export function GuildAdminController(app: HonoApp) { }, ); app.post( - '/admin/guilds/shutdown', - RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY), + '/admin/guilds/:guild_id/shutdowns', + RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY), requireAdminACL(AdminACLs.GUILD_SHUTDOWN), - Validator('json', ShutdownGuildRequest), + Validator('param', GuildIdParam), OpenAPI({ - operationId: 'shutdown_guild', - summary: 'Shutdown guild', + operationId: 'create_admin_guild_shutdown', + summary: 'Shut down guild', description: 'Shuts down and unloads a guild from the gateway. Guild data remains in database. Used for emergency resource cleanup. Logged to audit log. Requires GUILD_SHUTDOWN permission.', responseSchema: SuccessResponse, @@ -443,39 +491,9 @@ export function GuildAdminController(app: HonoApp) { const adminService = ctx.get('adminService'); const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); - const body = ctx.req.valid('json'); return ctx.json( await adminService.guildServiceAggregate.managementService.shutdownGuild( - body.guild_id, - adminUserId, - auditLogReason, - ), - ); - }, - ); - app.post( - '/admin/guilds/delete', - RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY), - requireAdminACL(AdminACLs.GUILD_DELETE), - Validator('json', DeleteGuildRequest), - OpenAPI({ - operationId: 'admin_delete_guild', - summary: 'Delete guild', - description: - 'Permanently deletes a guild. Deletes all channels, messages, and settings. Irreversible operation. Logged to audit log. Requires GUILD_DELETE permission.', - responseSchema: SuccessResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - const body = ctx.req.valid('json'); - return ctx.json( - await adminService.guildServiceAggregate.managementService.deleteGuild( - body.guild_id, + ctx.req.valid('param').guild_id, adminUserId, auditLogReason, ), diff --git a/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts b/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts index f0504f020..7fed9e303 100644 --- a/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts @@ -11,9 +11,10 @@ import { InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse, PendingRegistrationActionRequest, - RegistrationUrlActionRequest, + RegistrationUrlIdParam, } from '@fluxer/schema/src/domains/admin/AdminSchemas'; import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas'; +import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider'; import type {Context} from 'hono'; import {createMiddleware} from 'hono/factory'; @@ -166,12 +167,12 @@ async function grantSetupCompleterAdminACL(ctx: Context): Promise export function InstanceConfigAdminController(app: HonoApp) { const instanceConfigRepository = getInstanceConfigRepository(); - app.post( - '/admin/instance-config/get', + app.get( + '/admin/instance/config', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireSetupSessionOrAdminACL(AdminACLs.INSTANCE_CONFIG_VIEW), OpenAPI({ - operationId: 'get_instance_config', + operationId: 'get_admin_instance_config', summary: 'Get instance configuration', description: 'Retrieves instance-wide configuration including webhooks and SSO configuration. Requires INSTANCE_CONFIG_VIEW permission.', @@ -184,13 +185,13 @@ export function InstanceConfigAdminController(app: HonoApp) { return ctx.json(await buildInstanceConfigResponse()); }, ); - app.post( - '/admin/instance-config/update', + app.patch( + '/admin/instance/config', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireSetupSessionOrAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE), Validator('json', InstanceConfigUpdateRequest), OpenAPI({ - operationId: 'update_instance_config', + operationId: 'update_admin_instance_config', summary: 'Update instance configuration', description: 'Updates instance configuration settings including webhook URLs and SSO parameters. Changes apply immediately. Requires INSTANCE_CONFIG_UPDATE permission.', @@ -379,13 +380,13 @@ export function InstanceConfigAdminController(app: HonoApp) { }, ); app.post( - '/admin/instance-config/branding-asset', + '/admin/instance/config/branding-assets', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireSetupSessionOrAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE), Validator('json', BrandingAssetUploadRequest), OpenAPI({ - operationId: 'upload_instance_branding_asset', - summary: 'Upload or clear an instance branding asset', + operationId: 'create_admin_instance_branding_asset', + summary: 'Upload an instance branding asset', description: 'Uploads a branding image served by the media proxy and stores its URL, or clears it when no image is provided. Requires INSTANCE_CONFIG_UPDATE permission.', responseSchema: InstanceConfigResponse, @@ -409,13 +410,13 @@ export function InstanceConfigAdminController(app: HonoApp) { }, ); app.post( - '/admin/instance-config/integrations/smtp/test', + '/admin/instance/config/smtp-tests', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireSetupSessionOrAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE), Validator('json', InstanceEmailSmtpTestRequest), OpenAPI({ - operationId: 'test_instance_smtp_config', - summary: 'Validate SMTP configuration', + operationId: 'create_admin_instance_smtp_test', + summary: 'Run an SMTP configuration test', description: 'Validates that an SMTP configuration can authenticate and accept a connection. Requires INSTANCE_CONFIG_UPDATE permission.', responseSchema: InstanceEmailSmtpTestResponse, @@ -444,12 +445,12 @@ export function InstanceConfigAdminController(app: HonoApp) { }, ); app.post( - '/admin/instance-config/registration-urls/create', + '/admin/instance/registration-urls', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE), Validator('json', CreateRegistrationUrlRequest), OpenAPI({ - operationId: 'create_registration_url', + operationId: 'create_admin_registration_url', summary: 'Create an admin-issued registration URL', description: 'Creates a one-time-display registration URL that can be sent manually by an administrator. Requires INSTANCE_CONFIG_UPDATE permission.', @@ -474,13 +475,13 @@ export function InstanceConfigAdminController(app: HonoApp) { }); }, ); - app.post( - '/admin/instance-config/registration-urls/revoke', + app.delete( + '/admin/instance/registration-urls/:registration_url_id', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE), - Validator('json', RegistrationUrlActionRequest), + Validator('param', RegistrationUrlIdParam), OpenAPI({ - operationId: 'revoke_registration_url', + operationId: 'revoke_admin_registration_url', summary: 'Revoke an admin-issued registration URL', description: 'Revokes an admin-issued registration URL so it can no longer be used. Requires INSTANCE_CONFIG_UPDATE permission.', @@ -490,50 +491,30 @@ export function InstanceConfigAdminController(app: HonoApp) { tags: 'Admin', }), async (ctx) => { - await instanceConfigRepository.revokeRegistrationUrl(ctx.req.valid('json').id); + await instanceConfigRepository.revokeRegistrationUrl(ctx.req.valid('param').registration_url_id); return ctx.json(await buildInstanceConfigResponse()); }, ); - app.post( - '/admin/instance-config/pending-registrations/approve', + app.patch( + '/admin/instance/pending-registrations/:user_id', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE), + Validator('param', UserIdParam), Validator('json', PendingRegistrationActionRequest), OpenAPI({ - operationId: 'approve_pending_registration', - summary: 'Approve a pending registration', + operationId: 'update_admin_pending_registration', + summary: 'Approve or reject a pending registration', description: - 'Approves a registration waiting for manual review by removing its pending registration trait. Requires INSTANCE_CONFIG_UPDATE permission.', + 'Decides a registration waiting for manual review. Approving removes its pending registration trait, rejecting also prevents the account from logging in. Requires INSTANCE_CONFIG_UPDATE permission.', responseSchema: InstanceConfigResponse, statusCode: 200, security: 'adminApiKey', tags: 'Admin', }), async (ctx) => { - const userId = ctx.req.valid('json').user_id; - await updatePendingRegistrationUser(ctx, userId, 'approve'); - await instanceConfigRepository.removePendingRegistration(userId); - return ctx.json(await buildInstanceConfigResponse()); - }, - ); - app.post( - '/admin/instance-config/pending-registrations/reject', - RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), - requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE), - Validator('json', PendingRegistrationActionRequest), - OpenAPI({ - operationId: 'reject_pending_registration', - summary: 'Reject a pending registration', - description: - 'Rejects a registration waiting for manual review and prevents the account from logging in. Requires INSTANCE_CONFIG_UPDATE permission.', - responseSchema: InstanceConfigResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const userId = ctx.req.valid('json').user_id; - await updatePendingRegistrationUser(ctx, userId, 'reject'); + const userId = ctx.req.valid('param').user_id.toString(); + const decision = ctx.req.valid('json').status === 'approved' ? 'approve' : 'reject'; + await updatePendingRegistrationUser(ctx, userId, decision); await instanceConfigRepository.removePendingRegistration(userId); return ctx.json(await buildInstanceConfigResponse()); }, @@ -570,11 +551,15 @@ async function applyInstancePolicyUpdate( patch.single_community_enabled = false; } } + const unlockDirectMessages = policy.direct_messages_locked === false; + if (unlockDirectMessages && current.direct_messages_locked) { + patch.direct_messages_locked = false; + } if ( policy.direct_messages_disabled !== undefined && policy.direct_messages_disabled !== current.direct_messages_disabled ) { - if (current.direct_messages_locked) { + if (current.direct_messages_locked && !unlockDirectMessages) { throw new InstancePolicyTransitionNotAllowedError(); } patch.direct_messages_disabled = policy.direct_messages_disabled; diff --git a/fluxer_api/src/api/admin/controllers/JobsAdminController.ts b/fluxer_api/src/api/admin/controllers/JobsAdminController.ts index 2f0a1d9d3..be602e83b 100644 --- a/fluxer_api/src/api/admin/controllers/JobsAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/JobsAdminController.ts @@ -3,13 +3,13 @@ import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; import { ActiveJobsResponseSchema, - CancelJobRequest, CancelJobResponseSchema, - GetJobRequest, GetJobResponseSchema, - ListJobsRequest, + ListJobsQuery, + type ListJobsRequest, ListJobsResponseSchema, } from '@fluxer/schema/src/domains/admin/JobsSchemas'; +import {JobIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; import {requireAdminACL} from '../../middleware/AdminMiddleware'; import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware'; import {OpenAPI} from '../../middleware/ResponseTypeMiddleware'; @@ -17,34 +17,72 @@ import {RateLimitConfigs} from '../../RateLimitConfig'; import type {HonoApp} from '../../types/HonoEnv'; import {Validator} from '../../Validator'; +function toListJobsRequest(query: ListJobsQuery): ListJobsRequest { + return { + limit: query.limit, + max_lookback_days: query.max_lookback_days, + ...(query.cursor_bucket_day !== undefined && + query.cursor_created_at !== undefined && + query.cursor_job_id !== undefined && { + cursor: { + bucket_day: query.cursor_bucket_day, + created_at: query.cursor_created_at, + job_id: query.cursor_job_id, + }, + }), + ...(query.status !== undefined && {status: query.status}), + ...(query.task_type !== undefined && {task_type: query.task_type}), + ...(query.requested_by_user_id !== undefined && {requested_by_user_id: query.requested_by_user_id}), + }; +} + export function JobsAdminController(app: HonoApp) { - app.post( - '/admin/jobs/list', + app.get( + '/admin/jobs', RateLimitMiddleware(RateLimitConfigs.ADMIN_JOBS_VIEW), requireAdminACL(AdminACLs.JOBS_VIEW), - Validator('json', ListJobsRequest), + Validator('query', ListJobsQuery), OpenAPI({ - operationId: 'list_jobs', + operationId: 'list_admin_jobs', summary: 'List jobs', responseSchema: ListJobsResponseSchema, statusCode: 200, security: ['adminApiKey'], tags: ['Admin'], description: - 'Paginated, filterable list of background jobs from the human-facing ledger. Walks back through day-buckets and applies status / task-type / requester filters in-process.', + 'Paginated, filterable list of background jobs from the human-facing ledger. Walks back through day-buckets and applies status / task-type / requester filters in-process. The three cursor query parameters come from the previous page `next_cursor` and must be supplied together.', }), async (ctx) => { const adminService = ctx.get('adminService'); - return ctx.json(await adminService.jobAdminService.listJobs(ctx.req.valid('json'))); + return ctx.json(await adminService.jobAdminService.listJobs(toListJobsRequest(ctx.req.valid('query')))); }, ); - app.post( - '/admin/jobs/get', + app.get( + '/admin/jobs/active', RateLimitMiddleware(RateLimitConfigs.ADMIN_JOBS_VIEW), requireAdminACL(AdminACLs.JOBS_VIEW), - Validator('json', GetJobRequest), OpenAPI({ - operationId: 'get_job', + operationId: 'list_admin_active_jobs', + summary: 'List active jobs', + responseSchema: ActiveJobsResponseSchema, + statusCode: 200, + security: ['adminApiKey'], + tags: ['Admin'], + description: + 'Polling endpoint for the Jobs page. Returns only currently-active jobs (queued or running) from their own index, so the UI can refresh progress without scanning historical day-buckets.', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + return ctx.json(await adminService.jobAdminService.listActiveJobs()); + }, + ); + app.get( + '/admin/jobs/:job_id', + RateLimitMiddleware(RateLimitConfigs.ADMIN_JOBS_VIEW), + requireAdminACL(AdminACLs.JOBS_VIEW), + Validator('param', JobIdParam), + OpenAPI({ + operationId: 'get_admin_job', summary: 'Get job detail', responseSchema: GetJobResponseSchema, statusCode: 200, @@ -54,18 +92,18 @@ export function JobsAdminController(app: HonoApp) { }), async (ctx) => { const adminService = ctx.get('adminService'); - const result = await adminService.jobAdminService.getJob(ctx.req.valid('json').job_id); + const result = await adminService.jobAdminService.getJob(ctx.req.valid('param').job_id); if (!result) return ctx.json({error: 'job_not_found'}, 404); return ctx.json(result); }, ); - app.post( - '/admin/jobs/cancel', + app.put( + '/admin/jobs/:job_id/cancellation', RateLimitMiddleware(RateLimitConfigs.ADMIN_JOBS_VIEW), requireAdminACL(AdminACLs.JOBS_CANCEL), - Validator('json', CancelJobRequest), + Validator('param', JobIdParam), OpenAPI({ - operationId: 'cancel_job', + operationId: 'create_admin_job_cancellation', summary: 'Request cancellation of a running job', responseSchema: CancelJobResponseSchema, statusCode: 200, @@ -76,26 +114,7 @@ export function JobsAdminController(app: HonoApp) { }), async (ctx) => { const adminService = ctx.get('adminService'); - return ctx.json(await adminService.jobAdminService.cancelJob(ctx.req.valid('json').job_id)); - }, - ); - app.post( - '/admin/jobs/active', - RateLimitMiddleware(RateLimitConfigs.ADMIN_JOBS_VIEW), - requireAdminACL(AdminACLs.JOBS_VIEW), - OpenAPI({ - operationId: 'list_active_jobs', - summary: 'List active (queued + running) jobs', - responseSchema: ActiveJobsResponseSchema, - statusCode: 200, - security: ['adminApiKey'], - tags: ['Admin'], - description: - 'Polling endpoint for the Jobs page. Returns only currently-active jobs (queued or running) so the UI can refresh progress without scanning historical data.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - return ctx.json(await adminService.jobAdminService.listActiveJobs()); + return ctx.json(await adminService.jobAdminService.cancelJob(ctx.req.valid('param').job_id)); }, ); } diff --git a/fluxer_api/src/api/admin/controllers/LimitConfigAdminController.ts b/fluxer_api/src/api/admin/controllers/LimitConfigAdminController.ts index b283dc8b3..a611e9bda 100644 --- a/fluxer_api/src/api/admin/controllers/LimitConfigAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/LimitConfigAdminController.ts @@ -91,12 +91,12 @@ function findModifiedLimits( } export function LimitConfigAdminController(app: HonoApp) { - app.post( - '/admin/limit-config/get', + app.get( + '/admin/limit-config', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW), OpenAPI({ - operationId: 'get_limit_config', + operationId: 'get_admin_limit_config', summary: 'Get limit configuration', description: 'Retrieves rate limit configuration including message limits, upload limits, and request throttles. Shows defaults, metadata, and any modifications from defaults. Requires INSTANCE_LIMIT_CONFIG_VIEW permission.', @@ -111,16 +111,16 @@ export function LimitConfigAdminController(app: HonoApp) { return ctx.json(formatConfig(snapshot)); }, ); - app.post( - '/admin/limit-config/update', + app.put( + '/admin/limit-config', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE), Validator('json', LimitConfigUpdateRequest), OpenAPI({ - operationId: 'update_limit_config', - summary: 'Update limit configuration', + operationId: 'replace_admin_limit_config', + summary: 'Replace limit configuration', description: - 'Updates rate limit configuration including message throughput, upload sizes, and request throttles. Changes apply immediately to all new operations. Requires INSTANCE_LIMIT_CONFIG_UPDATE permission.', + 'Replaces the stored limit configuration, which covers message throughput, upload sizes, and request throttles, with the supplied document. Changes apply immediately to all new operations. Requires INSTANCE_LIMIT_CONFIG_UPDATE permission.', responseSchema: LimitConfigGetResponse, statusCode: 200, security: 'adminApiKey', diff --git a/fluxer_api/src/api/admin/controllers/MessageAdminController.ts b/fluxer_api/src/api/admin/controllers/MessageAdminController.ts index c49307c20..b7570397f 100644 --- a/fluxer_api/src/api/admin/controllers/MessageAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/MessageAdminController.ts @@ -1,21 +1,21 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; +import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import { - BrowseChannelRequest, + AdminChannelMessageListQuery, + AdminMessageSearchQuery, + AdminMessageSearchResponse, BrowseChannelResponse, - SearchChannelMessagesRequest, - SearchChannelMessagesResponse, } from '@fluxer/schema/src/domains/admin/AdminMessageBrowseSchemas'; import { - DeleteAllUserMessagesRequest, + AdminMessageDetailQuery, + AdminUserMessageDeleteQuery, + AdminUserMessageShredRequest, DeleteAllUserMessagesResponse, - DeleteMessageRequest, - LookupMessageByAttachmentRequest, - LookupMessageRequest, - MessageShredRequest, + MessageShredJobIdParam, MessageShredResponse, - MessageShredStatusRequest, ReportAttachmentToNcmecRequest, } from '@fluxer/schema/src/domains/admin/AdminMessageSchemas'; import { @@ -24,6 +24,11 @@ import { MessageShredStatusResponse, NcmecAttachmentSubmitResultResponse, } from '@fluxer/schema/src/domains/admin/AdminSchemas'; +import { + ChannelIdMessageIdParam, + ChannelIdParam, + UserIdParam, +} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; import {createAttachmentID, createChannelID, createMessageID, createReportID} from '../../BrandedTypes'; import {requireAdminACL} from '../../middleware/AdminMiddleware'; import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware'; @@ -33,48 +38,57 @@ import type {HonoApp} from '../../types/HonoEnv'; import {Validator} from '../../Validator'; export function MessageAdminController(app: HonoApp) { - app.post( - '/admin/messages/lookup', + app.get( + '/admin/messages', RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION), requireAdminACL(AdminACLs.MESSAGE_LOOKUP), - Validator('json', LookupMessageRequest), + Validator('query', AdminMessageSearchQuery), OpenAPI({ - operationId: 'lookup_message', - summary: 'Look up message details', + operationId: 'search_admin_messages', + summary: 'Search messages', description: - 'Retrieves complete message details including content, attachments, edits, and metadata. Look up by message ID and channel. Requires MESSAGE_LOOKUP permission.', - responseSchema: LookupMessageResponse, + 'Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message carrying that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.', + responseSchema: AdminMessageSearchResponse, statusCode: 200, security: 'adminApiKey', tags: 'Admin', }), async (ctx) => { const adminService = ctx.get('adminService'); - return ctx.json(await adminService.messageService.lookupMessage(ctx.req.valid('json'))); + const query = ctx.req.valid('query'); + if (query.message_id != null) { + return ctx.json( + await adminService.messageService.lookupMessage({ + channel_id: query.channel_id, + message_id: query.message_id, + context_limit: query.context_limit, + }), + ); + } + if (query.attachment_id != null) { + if (query.filename == null) { + throw InputValidationError.fromCode('filename', ValidationErrorCodes.INVALID_FORMAT); + } + return ctx.json( + await adminService.messageService.lookupMessageByAttachment({ + channel_id: query.channel_id, + attachment_id: query.attachment_id, + filename: query.filename, + context_limit: query.context_limit, + }), + ); + } + return ctx.json( + await adminService.messageService.searchChannelMessages({ + channel_id: query.channel_id, + query: query.q ?? '', + limit: query.limit, + }), + ); }, ); app.post( - '/admin/messages/lookup-by-attachment', - RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION), - requireAdminACL(AdminACLs.MESSAGE_LOOKUP), - Validator('json', LookupMessageByAttachmentRequest), - OpenAPI({ - operationId: 'lookup_message_by_attachment', - summary: 'Look up message by attachment', - description: - 'Finds and retrieves message containing a specific attachment by ID. Used to locate messages with sensitive or illegal content. Requires MESSAGE_LOOKUP permission.', - responseSchema: LookupMessageResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - return ctx.json(await adminService.messageService.lookupMessageByAttachment(ctx.req.valid('json'))); - }, - ); - app.post( - '/admin/messages/report-to-ncmec', + '/admin/messages/ncmec-reports', RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION), requireAdminACL(AdminACLs.CSAM_SUBMIT_NCMEC), requireAdminACL(AdminACLs.MESSAGE_DELETE), @@ -82,8 +96,8 @@ export function MessageAdminController(app: HonoApp) { requireAdminACL(AdminACLs.ARCHIVE_TRIGGER_USER), Validator('json', ReportAttachmentToNcmecRequest), OpenAPI({ - operationId: 'report_message_attachment_to_ncmec', - summary: 'Report an image attachment to NCMEC', + operationId: 'create_admin_ncmec_report', + summary: 'Report an attachment to NCMEC', description: 'Submits a specific image attachment to NCMEC, creates an audit log entry, silently disables the user, triggers one archive for the user, and schedules content deletion after the archive completes.', responseSchema: NcmecAttachmentSubmitResultResponse, @@ -107,16 +121,96 @@ export function MessageAdminController(app: HonoApp) { return ctx.json(result); }, ); - app.post( - '/admin/messages/delete', + app.get( + '/admin/messages/shreds/:job_id', + RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION), + requireAdminACL(AdminACLs.MESSAGE_SHRED), + Validator('param', MessageShredJobIdParam), + OpenAPI({ + operationId: 'get_admin_message_shred', + summary: 'Get message shred job', + description: + 'Returns the progress of a queued message shred job, including whether it is complete. Requires MESSAGE_SHRED permission.', + responseSchema: MessageShredStatusResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const {job_id} = ctx.req.valid('param'); + return ctx.json(await adminService.messageShredService.getMessageShredStatus(job_id.toString())); + }, + ); + app.get( + '/admin/channels/:channel_id/messages', + RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION), + requireAdminACL(AdminACLs.MESSAGE_LOOKUP), + Validator('param', ChannelIdParam), + Validator('query', AdminChannelMessageListQuery), + OpenAPI({ + operationId: 'list_admin_channel_messages', + summary: 'List channel messages', + description: + 'Pages through the messages of a channel, newest first, with cursor-based pagination. Requires MESSAGE_LOOKUP permission.', + responseSchema: BrowseChannelResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const {channel_id} = ctx.req.valid('param'); + const {limit, before, after} = ctx.req.valid('query'); + return ctx.json( + await adminService.messageService.browseChannel({ + channel_id, + before, + after, + limit, + }), + ); + }, + ); + app.get( + '/admin/channels/:channel_id/messages/:message_id', + RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION), + requireAdminACL(AdminACLs.MESSAGE_LOOKUP), + Validator('param', ChannelIdMessageIdParam), + Validator('query', AdminMessageDetailQuery), + OpenAPI({ + operationId: 'get_admin_message', + summary: 'Get message', + description: + 'Retrieves complete message details including content, attachments, edits, and metadata, together with the messages surrounding it. Requires MESSAGE_LOOKUP permission.', + responseSchema: LookupMessageResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const {channel_id, message_id} = ctx.req.valid('param'); + const {context_limit} = ctx.req.valid('query'); + return ctx.json( + await adminService.messageService.lookupMessage({ + channel_id, + message_id, + context_limit, + }), + ); + }, + ); + app.delete( + '/admin/channels/:channel_id/messages/:message_id', RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION), requireAdminACL(AdminACLs.MESSAGE_DELETE), - Validator('json', DeleteMessageRequest), + Validator('param', ChannelIdMessageIdParam), OpenAPI({ - operationId: 'admin_delete_message', - summary: 'Delete single message', + operationId: 'delete_admin_message', + summary: 'Delete message', description: - 'Deletes a single message permanently. Used for removing inappropriate or harmful content. Logged to audit log. Requires MESSAGE_DELETE permission.', + 'Deletes a single message permanently and purges its attachments. Used for removing inappropriate or harmful content. Logged to audit log. Requires MESSAGE_DELETE permission.', responseSchema: DeleteMessageResponse, statusCode: 200, security: 'adminApiKey', @@ -126,21 +220,23 @@ export function MessageAdminController(app: HonoApp) { const adminService = ctx.get('adminService'); const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); + const {channel_id, message_id} = ctx.req.valid('param'); return ctx.json( - await adminService.messageService.deleteMessage(ctx.req.valid('json'), adminUserId, auditLogReason), + await adminService.messageService.deleteMessage({channel_id, message_id}, adminUserId, auditLogReason), ); }, ); app.post( - '/admin/messages/shred', + '/admin/users/:user_id/message-shreds', RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION), requireAdminACL(AdminACLs.MESSAGE_SHRED), - Validator('json', MessageShredRequest), + Validator('param', UserIdParam), + Validator('json', AdminUserMessageShredRequest), OpenAPI({ - operationId: 'queue_message_shred', - summary: 'Queue message shred operation', + operationId: 'shred_admin_user_messages', + summary: 'Shred user messages', description: - 'Queues bulk message shredding with attachment deletion. Returns job ID to track progress asynchronously. Used for large-scale content removal. Requires MESSAGE_SHRED permission.', + 'Queues bulk shredding of the given messages of a user, with attachment deletion. Returns a job ID to track progress asynchronously. Used for large-scale content removal. Requires MESSAGE_SHRED permission.', responseSchema: MessageShredResponse, statusCode: 200, security: 'adminApiKey', @@ -150,21 +246,24 @@ export function MessageAdminController(app: HonoApp) { const adminService = ctx.get('adminService'); const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); + const {user_id} = ctx.req.valid('param'); + const {entries} = ctx.req.valid('json'); return ctx.json( - await adminService.messageShredService.queueMessageShred(ctx.req.valid('json'), adminUserId, auditLogReason), + await adminService.messageShredService.queueMessageShred({user_id, entries}, adminUserId, auditLogReason), ); }, ); - app.post( - '/admin/messages/delete-all', + app.delete( + '/admin/users/:user_id/messages', RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION), requireAdminACL(AdminACLs.MESSAGE_DELETE_ALL), - Validator('json', DeleteAllUserMessagesRequest), + Validator('param', UserIdParam), + Validator('query', AdminUserMessageDeleteQuery), OpenAPI({ - operationId: 'delete_all_user_messages', + operationId: 'delete_admin_user_messages', summary: 'Delete all user messages', description: - 'Deletes all messages from a specific user across all channels. Permanent operation used for account suspension or policy violation. Requires MESSAGE_DELETE_ALL permission.', + 'Deletes all messages from a specific user across all channels. Permanent operation used for account suspension or policy violation. Pass dry_run=false to delete; the default counts without deleting. Requires MESSAGE_DELETE_ALL permission.', responseSchema: DeleteAllUserMessagesResponse, statusCode: 200, security: 'adminApiKey', @@ -174,73 +273,15 @@ export function MessageAdminController(app: HonoApp) { const adminService = ctx.get('adminService'); const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); + const {user_id} = ctx.req.valid('param'); + const {dry_run} = ctx.req.valid('query'); return ctx.json( await adminService.messageDeletionService.deleteAllUserMessages( - ctx.req.valid('json'), + {user_id, dry_run}, adminUserId, auditLogReason, ), ); }, ); - app.post( - '/admin/messages/shred-status', - RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION), - requireAdminACL(AdminACLs.MESSAGE_SHRED), - Validator('json', MessageShredStatusRequest), - OpenAPI({ - operationId: 'get_message_shred_status', - summary: 'Get message shred status', - description: - 'Polls status of a queued message shred operation. Returns progress percentage and whether the job is complete. Requires MESSAGE_SHRED permission.', - responseSchema: MessageShredStatusResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const body = ctx.req.valid('json'); - return ctx.json(await adminService.messageShredService.getMessageShredStatus(body.job_id)); - }, - ); - app.post( - '/admin/messages/browse', - RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION), - requireAdminACL(AdminACLs.MESSAGE_LOOKUP), - Validator('json', BrowseChannelRequest), - OpenAPI({ - operationId: 'browse_channel_messages', - summary: 'Browse channel messages', - description: - 'Browses messages in a channel with cursor-based pagination. Returns messages in reverse chronological order. Requires MESSAGE_LOOKUP permission.', - responseSchema: BrowseChannelResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - return ctx.json(await adminService.messageService.browseChannel(ctx.req.valid('json'))); - }, - ); - app.post( - '/admin/messages/search', - RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION), - requireAdminACL(AdminACLs.MESSAGE_LOOKUP), - Validator('json', SearchChannelMessagesRequest), - OpenAPI({ - operationId: 'search_channel_messages', - summary: 'Search channel messages', - description: 'Searches messages within a channel by content. Requires MESSAGE_LOOKUP permission.', - responseSchema: SearchChannelMessagesResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - return ctx.json(await adminService.messageService.searchChannelMessages(ctx.req.valid('json'))); - }, - ); } diff --git a/fluxer_api/src/api/admin/controllers/ReportAdminController.ts b/fluxer_api/src/api/admin/controllers/ReportAdminController.ts index 4a8095bf8..b47b36a46 100644 --- a/fluxer_api/src/api/admin/controllers/ReportAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/ReportAdminController.ts @@ -2,13 +2,12 @@ import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; import { - ListReportsRequest, - ListReportsResponse, + AdminReportListResponse, + ListReportsQuery, ReportAdminResponseSchema, - ResolveReportRequest, ResolveReportResponse, - SearchReportsRequest, - SearchReportsResponse, + type SearchReportsRequest, + UpdateReportRequest, } from '@fluxer/schema/src/domains/admin/AdminSchemas'; import {ReportIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; import {createReportID} from '../../BrandedTypes'; @@ -19,18 +18,68 @@ import {RateLimitConfigs} from '../../RateLimitConfig'; import type {HonoApp} from '../../types/HonoEnv'; import {Validator} from '../../Validator'; +const REPORT_STATUS_BY_FILTER = { + pending: 0, + resolved: 1, +} as const; + +const REPORT_TYPE_BY_FILTER = { + message: 0, + user: 1, + guild: 2, +} as const; + +const REPORT_SORT_FIELD_BY_QUERY = { + created_at: 'createdAt', + reported_at: 'reportedAt', + resolved_at: 'resolvedAt', +} as const; + +function usesReportSearchIndex(query: ListReportsQuery): boolean { + return ( + query.q !== undefined || + query.report_type !== undefined || + query.category !== undefined || + query.reporter_id !== undefined || + query.reported_user_id !== undefined || + query.reported_guild_id !== undefined || + query.reported_channel_id !== undefined || + query.guild_context_id !== undefined || + query.resolved_by_admin_id !== undefined + ); +} + +function toSearchReportsRequest(query: ListReportsQuery): SearchReportsRequest { + return { + query: query.q, + limit: query.limit, + offset: query.offset, + reporter_id: query.reporter_id, + status: query.status === undefined ? undefined : REPORT_STATUS_BY_FILTER[query.status], + report_type: query.report_type === undefined ? undefined : REPORT_TYPE_BY_FILTER[query.report_type], + category: query.category, + reported_user_id: query.reported_user_id, + reported_guild_id: query.reported_guild_id, + reported_channel_id: query.reported_channel_id, + guild_context_id: query.guild_context_id, + resolved_by_admin_id: query.resolved_by_admin_id, + sort_by: REPORT_SORT_FIELD_BY_QUERY[query.sort_by], + sort_order: query.sort_order, + }; +} + export function ReportAdminController(app: HonoApp) { - app.post( - '/admin/reports/list', + app.get( + '/admin/reports', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.REPORT_VIEW), - Validator('json', ListReportsRequest), + Validator('query', ListReportsQuery), OpenAPI({ - operationId: 'list_reports', + operationId: 'list_admin_reports', summary: 'List reports', description: - 'Lists user and content reports with optional status filtering and pagination. Requires REPORT_VIEW permission.', - responseSchema: ListReportsResponse, + 'Lists user and content reports with pagination. Filtering by status alone reads them straight from the database; supplying a free-text query or any of the entity, category and resolver filters searches the report index instead and adds the total, offset and limit of the page to the response. Reporter contact details are redacted unless the caller also holds REPORT_VIEW_REPORTER_PII. Requires REPORT_VIEW permission.', + responseSchema: AdminReportListResponse, statusCode: 200, security: 'adminApiKey', tags: 'Admin', @@ -38,8 +87,16 @@ export function ReportAdminController(app: HonoApp) { async (ctx) => { const adminService = ctx.get('adminService'); const adminUserAcls = ctx.get('adminUserAcls'); - const {status, limit, offset} = ctx.req.valid('json'); - return ctx.json(await adminService.reportServiceAggregate.listReports(status ?? 0, adminUserAcls, limit, offset)); + const query = ctx.req.valid('query'); + if (usesReportSearchIndex(query)) { + return ctx.json( + await adminService.reportServiceAggregate.searchReports(toSearchReportsRequest(query), adminUserAcls), + ); + } + const status = query.status === undefined ? 0 : REPORT_STATUS_BY_FILTER[query.status]; + return ctx.json( + await adminService.reportServiceAggregate.listReports(status, adminUserAcls, query.limit, query.offset), + ); }, ); app.get( @@ -48,10 +105,10 @@ export function ReportAdminController(app: HonoApp) { requireAdminACL(AdminACLs.REPORT_VIEW), Validator('param', ReportIdParam), OpenAPI({ - operationId: 'get_report', - summary: 'Get report details', + operationId: 'get_admin_report', + summary: 'Get report', description: - 'Retrieves detailed information about a specific report including content, reporter, and reason. Requires REPORT_VIEW permission.', + 'Retrieves detailed information about a specific report including content, reporter, reason, and the message context captured when it was filed. Requires REPORT_VIEW permission.', responseSchema: ReportAdminResponseSchema, statusCode: 200, security: 'adminApiKey', @@ -65,16 +122,17 @@ export function ReportAdminController(app: HonoApp) { return ctx.json(report); }, ); - app.post( - '/admin/reports/resolve', + app.patch( + '/admin/reports/:report_id', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.REPORT_RESOLVE), - Validator('json', ResolveReportRequest), + Validator('param', ReportIdParam), + Validator('json', UpdateReportRequest), OpenAPI({ - operationId: 'resolve_report', - summary: 'Resolve report', + operationId: 'update_admin_report', + summary: 'Update report', description: - 'Closes and resolves a report with optional public comment. Marks report as handled and creates audit log entry. Requires REPORT_RESOLVE permission.', + 'Moves a report to the resolved status with an optional public comment shown to the reporter. Marks the report as handled, notifies the reporter, and creates an audit log entry. Requires REPORT_RESOLVE permission.', responseSchema: ResolveReportResponse, statusCode: 200, security: 'adminApiKey', @@ -84,7 +142,8 @@ export function ReportAdminController(app: HonoApp) { const adminService = ctx.get('adminService'); const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); - const {report_id, public_comment} = ctx.req.valid('json'); + const {report_id} = ctx.req.valid('param'); + const {public_comment} = ctx.req.valid('json'); return ctx.json( await adminService.reportServiceAggregate.resolveReport( createReportID(report_id), @@ -95,26 +154,4 @@ export function ReportAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/reports/search', - RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), - requireAdminACL(AdminACLs.REPORT_VIEW), - Validator('json', SearchReportsRequest), - OpenAPI({ - operationId: 'search_reports', - summary: 'Search reports', - description: - 'Searches and filters reports by user, content, reason, and status criteria. Supports full-text search and advanced filtering. Requires REPORT_VIEW permission.', - responseSchema: SearchReportsResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserAcls = ctx.get('adminUserAcls'); - const body = ctx.req.valid('json'); - return ctx.json(await adminService.reportServiceAggregate.searchReports(body, adminUserAcls)); - }, - ); } diff --git a/fluxer_api/src/api/admin/controllers/SearchAdminController.ts b/fluxer_api/src/api/admin/controllers/SearchAdminController.ts index 141d8a5ae..0c2505895 100644 --- a/fluxer_api/src/api/admin/controllers/SearchAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/SearchAdminController.ts @@ -1,16 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; -import {SearchGuildsRequest} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas'; import { - GetIndexRefreshStatusRequest, IndexRefreshStatusResponse, RefreshSearchIndexRequest, RefreshSearchIndexResponse, - SearchGuildsResponse, - SearchUsersResponse, + SearchIndexNameParam, + SearchIndexRefreshIdParam, } from '@fluxer/schema/src/domains/admin/AdminSchemas'; -import {SearchUsersRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas'; import {requireAdminACL} from '../../middleware/AdminMiddleware'; import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware'; import {OpenAPI} from '../../middleware/ResponseTypeMiddleware'; @@ -20,58 +17,16 @@ import {Validator} from '../../Validator'; export function SearchAdminController(app: HonoApp) { app.post( - '/admin/guilds/search', - RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), - requireAdminACL(AdminACLs.GUILD_LOOKUP), - Validator('json', SearchGuildsRequest), - OpenAPI({ - operationId: 'search_guilds', - summary: 'Search guilds', - description: - 'Searches guilds by name, ID, and other criteria. Supports full-text search and filtering. Requires GUILD_LOOKUP permission.', - responseSchema: SearchGuildsResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const body = ctx.req.valid('json'); - return ctx.json(await adminService.searchService.searchGuilds(body)); - }, - ); - app.post( - '/admin/users/search', - RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), - requireAdminACL(AdminACLs.USER_LOOKUP), - Validator('json', SearchUsersRequest), - OpenAPI({ - operationId: 'search_users', - summary: 'Search users', - description: - 'Searches users by username, email, ID, last active IP, and other criteria. Supports full-text search and filtering by account status. Requires USER_LOOKUP permission.', - responseSchema: SearchUsersResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserAcls = ctx.get('adminUserAcls'); - const body = ctx.req.valid('json'); - return ctx.json(await adminService.searchService.searchUsers(body, adminUserAcls)); - }, - ); - app.post( - '/admin/search/refresh-index', + '/admin/search/indexes/:index_name/refreshes', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.GUILD_LOOKUP), + Validator('param', SearchIndexNameParam), Validator('json', RefreshSearchIndexRequest), OpenAPI({ - operationId: 'refresh_search_index', - summary: 'Refresh search index', + operationId: 'create_admin_search_index_refresh', + summary: 'Refresh a search index', description: - 'Trigger full or partial search index rebuild. Creates background job to reindex guilds and users. Returns job ID for status tracking. Requires GUILD_LOOKUP permission.', + 'Trigger a full or partial rebuild of the named search index. Creates a background job and returns its refresh ID for status tracking. The channel_messages and guild_members indexes are rebuilt one guild at a time and require guild_id, and favorite_memes requires user_id. Requires GUILD_LOOKUP permission.', responseSchema: RefreshSearchIndexResponse, statusCode: 200, security: 'adminApiKey', @@ -81,20 +36,27 @@ export function SearchAdminController(app: HonoApp) { const adminService = ctx.get('adminService'); const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); - const body = ctx.req.valid('json'); - return ctx.json(await adminService.searchService.refreshSearchIndex(body, adminUserId, auditLogReason)); + const {index_name} = ctx.req.valid('param'); + const {guild_id, user_id} = ctx.req.valid('json'); + return ctx.json( + await adminService.searchService.refreshSearchIndex( + {index_type: index_name, guild_id, user_id}, + adminUserId, + auditLogReason, + ), + ); }, ); - app.post( - '/admin/search/refresh-status', + app.get( + '/admin/search/index-refreshes/:job_id', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.GUILD_LOOKUP), - Validator('json', GetIndexRefreshStatusRequest), + Validator('param', SearchIndexRefreshIdParam), OpenAPI({ - operationId: 'get_search_index_refresh_status', - summary: 'Get search index refresh status', + operationId: 'get_admin_search_index_refresh', + summary: 'Get search index refresh', description: - 'Polls status of a search index refresh job. Returns completion percentage and current phase. Requires GUILD_LOOKUP permission.', + 'Reads the progress of a queued search index refresh. Returns the completion counts and current phase, or a not_found status once the record has expired. Requires GUILD_LOOKUP permission.', responseSchema: IndexRefreshStatusResponse, statusCode: 200, security: 'adminApiKey', @@ -102,8 +64,8 @@ export function SearchAdminController(app: HonoApp) { }), async (ctx) => { const adminService = ctx.get('adminService'); - const body = ctx.req.valid('json'); - return ctx.json(await adminService.searchService.getIndexRefreshStatus(body.job_id)); + const {job_id} = ctx.req.valid('param'); + return ctx.json(await adminService.searchService.getIndexRefreshStatus(job_id)); }, ); } diff --git a/fluxer_api/src/api/admin/controllers/SystemAdminController.ts b/fluxer_api/src/api/admin/controllers/SystemAdminController.ts index 4424a4599..cee0f9df0 100644 --- a/fluxer_api/src/api/admin/controllers/SystemAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/SystemAdminController.ts @@ -14,14 +14,14 @@ import type {HonoApp} from '../../types/HonoEnv'; export function SystemAdminController(app: HonoApp) { app.post( - '/admin/system/heap-snapshot', + '/admin/system/heap-snapshots', RateLimitMiddleware(RateLimitConfigs.ADMIN_SYSTEM_HEAP_SNAPSHOT), requireAdminACL(AdminACLs.SYSTEM_HEAP_SNAPSHOT), OpenAPI({ - operationId: 'take_heap_snapshot', - summary: 'Take a V8 heap snapshot', + operationId: 'create_admin_system_heap_snapshot', + summary: 'Create a V8 heap snapshot', description: - 'Triggers a V8 heap snapshot of the current process and returns the snapshot file. Used for diagnosing memory leaks. Requires SYSTEM_HEAP_SNAPSHOT permission.', + 'Writes a V8 heap snapshot of the current process and returns the snapshot file. Used for diagnosing memory leaks. Requires SYSTEM_HEAP_SNAPSHOT permission.', responseSchema: HeapSnapshotResponse, statusCode: 200, security: 'adminApiKey', diff --git a/fluxer_api/src/api/admin/controllers/SystemDmAdminController.ts b/fluxer_api/src/api/admin/controllers/SystemDmAdminController.ts index d04e03d93..b730d1fe7 100644 --- a/fluxer_api/src/api/admin/controllers/SystemDmAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/SystemDmAdminController.ts @@ -11,19 +11,19 @@ import {Validator} from '../../Validator'; export function SystemDmAdminController(app: HonoApp) { app.post( - '/admin/system-dm/send', + '/admin/system-dms', RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION), requireAdminACL(AdminACLs.SYSTEM_DM_SEND), Validator('json', SendSystemDmRequest), OpenAPI({ - operationId: 'send_system_dm', - summary: 'Send system DM', + operationId: 'create_admin_system_dm', + summary: 'Send a system direct message', responseSchema: SendSystemDmResponse, statusCode: 200, security: 'adminApiKey', tags: 'Admin', description: - 'Queue a worker job that sends the same system DM content to each provided user ID. Progress is observable via the Jobs admin page (task_type=sendSystemDm). Requires SYSTEM_DM_SEND permission.', + 'Queue a worker job that delivers the same content to every listed user as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.', }), async (ctx) => { const adminService = ctx.get('adminService'); diff --git a/fluxer_api/src/api/admin/controllers/UserAdminController.ts b/fluxer_api/src/api/admin/controllers/UserAdminController.ts index 4eab233ad..b794322b7 100644 --- a/fluxer_api/src/api/admin/controllers/UserAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/UserAdminController.ts @@ -1,73 +1,93 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; -import {ListUserGuildsRequest, ListUserGuildsResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas'; +import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError'; +import {ListUserGuildsResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas'; +import {SearchUsersResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas'; import { + AdminAclListResponse, + AdminUserAclsRequest, + AdminUserBanRequest, + AdminUserBotStatusRequest, + AdminUserChangeLogQuery, + AdminUserClearFieldsRequest, + AdminUserDeletionScheduleRequest, + AdminUserDmChannelListQuery, + AdminUserDmChannelListResponse, + AdminUserDobUpdateRequest, + AdminUserEmailUpdateRequest, + AdminUserFlagsUpdateRequest, + AdminUserGuildListQuery, + AdminUserListQuery, + AdminUserPhoneVerificationRequest, + AdminUserPremiumFlagsUpdateRequest, + AdminUserRelationshipCategoryQuery, + AdminUserRelationshipParam, + AdminUserSuspiciousActivityFlagsRequest, + AdminUserSuspiciousDisableRequest, + AdminUserSystemStatusRequest, AdminUsersMeResponse, - CancelBulkMessageDeletionRequest, - ChangeDobRequest, - ChangeEmailRequest, - ChangeUsernameRequest, - ClearUserFieldsRequest, - DeleteWebAuthnCredentialRequest, - DisableForSuspiciousActivityRequest, - DisableMfaRequest, - ListUserChangeLogRequest, + AdminUserTraitsRequest, + AdminUserUsernameUpdateRequest, + AdminUserWebAuthnCredentialParam, ListUserChangeLogResponseSchema, - ListUserDmChannelsRequest, - ListUserDmChannelsResponse, - ListUserGroupDmChannelsRequest, - ListUserGroupDmChannelsResponse, - ListUserRelationshipsRequest, ListUserRelationshipsResponse, - ListUserSessionsRequest, ListUserSessionsResponse, - ListWebAuthnCredentialsRequest, LookupUserRequest, LookupUserResponse, - RemoveUserRelationshipRequest, - RemoveUserRelationshipsByCategoryRequest, RemoveUserRelationshipsResponse, - ResendVerificationEmailRequest, - ScheduleAccountDeletionRequest, - SendPasswordResetRequest, - SetUserAclsRequest, - SetUserBotStatusRequest, - SetUserSystemStatusRequest, - SetUserTraitsRequest, - TempBanUserRequest, - TerminateSessionsRequest, TerminateSessionsResponse, - UpdateHasVerifiedPhoneRequest, - UpdatePremiumFlagsRequest, - UpdateSuspiciousActivityFlagsRequest, - UpdateUserFlagsRequest, UserMutationResponse, - VerifyUserEmailRequest, } from '@fluxer/schema/src/domains/admin/AdminUserSchemas'; import {WebAuthnCredentialListResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas'; +import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; import {createUserID} from '../../BrandedTypes'; import {requireAdminACL} from '../../middleware/AdminMiddleware'; import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware'; import {OpenAPI} from '../../middleware/ResponseTypeMiddleware'; import {RateLimitConfigs} from '../../RateLimitConfig'; import type {HonoApp} from '../../types/HonoEnv'; -import {Validator} from '../../Validator'; +import {inputValidationErrorFromZodIssues, Validator} from '../../Validator'; import {mapUserToAdminResponse} from '../models/UserTypes'; +function requireSelectorACL(granted: ReadonlySet, acl: string): void { + if (!granted.has(acl) && !granted.has(AdminACLs.WILDCARD)) { + throw new MissingACLError(acl); + } +} + export function UserAdminController(app: HonoApp) { app.get( - '/admin/users/me', + '/admin/acls', + RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.AUTHENTICATE), OpenAPI({ - operationId: 'get_authenticated_admin_user', - summary: 'Get authenticated admin user', + operationId: 'list_admin_acls', + summary: 'List admin permissions', + responseSchema: AdminAclListResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + description: + 'Returns every access control permission the admin API recognises. This is the registry admin accounts and admin API keys draw their permissions from. Requires AUTHENTICATE permission.', + }), + async (ctx) => { + return ctx.json({acls: Object.values(AdminACLs)}); + }, + ); + app.get( + '/admin/users/@me', + RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), + requireAdminACL(AdminACLs.AUTHENTICATE), + OpenAPI({ + operationId: 'get_current_admin_user', + summary: 'Get current admin', responseSchema: AdminUsersMeResponse, statusCode: 200, security: 'adminApiKey', tags: 'Admin', description: - 'Get profile of currently authenticated admin user. Returns admin permissions, roles, and metadata. Requires AUTHENTICATE permission.', + 'Return the admin the request was authenticated as, with the admin permissions, roles, and metadata of the account. Requires AUTHENTICATE permission.', }), async (ctx) => { const adminUser = ctx.get('user'); @@ -77,116 +97,303 @@ export function UserAdminController(app: HonoApp) { }); }, ); - app.post( - '/admin/users/lookup', + app.get( + '/admin/users', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.USER_LOOKUP), - Validator('json', LookupUserRequest), + Validator('query', AdminUserListQuery), OpenAPI({ - operationId: 'lookup_user', - summary: 'Lookup user', + operationId: 'list_admin_users', + summary: 'List users', + responseSchema: SearchUsersResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + description: + 'Lists and searches users. Exactly one selector is honoured, in this precedence order: user_id, resolve, email, last_active_ip, then the indexed q search. The resolve selector takes one exact identifier, which may be a username#discriminator tag, a user ID, an email address, or a Stripe subscription ID. The email and user_id selectors ignore limit and offset. Requires USER_LOOKUP permission.', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserAcls = ctx.get('adminUserAcls'); + const query = ctx.req.valid('query'); + const userIds = + query.user_id === undefined ? undefined : Array.isArray(query.user_id) ? query.user_id : [query.user_id]; + if (userIds || query.resolve !== undefined) { + if (!userIds && query.resolve?.includes('@')) { + requireSelectorACL(adminUserAcls, AdminACLs.USER_VIEW_EMAIL); + } + const parsed = LookupUserRequest.safeParse(userIds ? {user_ids: userIds} : {query: query.resolve}); + if (!parsed.success) { + throw inputValidationErrorFromZodIssues(parsed.error.issues); + } + const {users} = await adminService.userService.lookupService.lookupUser(parsed.data, adminUserAcls); + return ctx.json({users, total: users.length}); + } + if (query.email?.trim()) { + requireSelectorACL(adminUserAcls, AdminACLs.USER_VIEW_EMAIL); + } else if (query.last_active_ip?.trim()) { + requireSelectorACL(adminUserAcls, AdminACLs.USER_VIEW_IP); + } + return ctx.json( + await adminService.searchService.searchUsers( + { + query: query.q, + email: query.email, + last_active_ip: query.last_active_ip, + limit: query.limit, + offset: query.offset, + }, + adminUserAcls, + ), + ); + }, + ); + app.get( + '/admin/users/:user_id', + RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), + requireAdminACL(AdminACLs.USER_LOOKUP), + Validator('param', UserIdParam), + OpenAPI({ + operationId: 'get_admin_user', + summary: 'Get user', responseSchema: LookupUserResponse, statusCode: 200, security: 'adminApiKey', tags: 'Admin', description: - 'Look up detailed user profile by ID, username, email, or phone. Returns account status, permissions, and metadata. Requires USER_LOOKUP permission.', + 'Look up one detailed user profile by ID. Returns account status, permissions, and metadata. The email address, date of birth, and IP address are redacted without the matching view permissions. Requires USER_LOOKUP permission.', }), async (ctx) => { const adminService = ctx.get('adminService'); const adminUserAcls = ctx.get('adminUserAcls'); - return ctx.json(await adminService.userService.lookupService.lookupUser(ctx.req.valid('json'), adminUserAcls)); + const {user_id: userId} = ctx.req.valid('param'); + return ctx.json(await adminService.userService.lookupService.lookupUser({user_ids: [userId]}, adminUserAcls)); }, ); - app.post( - '/admin/users/list-guilds', + app.get( + '/admin/users/:user_id/guilds', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.USER_LIST_GUILDS), - Validator('json', ListUserGuildsRequest), + Validator('param', UserIdParam), + Validator('query', AdminUserGuildListQuery), OpenAPI({ - operationId: 'list_user_guilds', - summary: 'List user guilds', + operationId: 'list_admin_user_guilds', + summary: 'List user communities', responseSchema: ListUserGuildsResponse, statusCode: 200, security: 'adminApiKey', tags: 'Admin', description: - 'List all guilds a user is a member of. Shows roles and join dates. Requires USER_LIST_GUILDS permission.', + 'List all guilds a user is a member of, optionally with approximate member and presence counts. Shows roles and join dates. Requires USER_LIST_GUILDS permission.', }), async (ctx) => { const adminService = ctx.get('adminService'); - return ctx.json(await adminService.guildServiceAggregate.lookupService.listUserGuilds(ctx.req.valid('json'))); + const {user_id: userId} = ctx.req.valid('param'); + const query = ctx.req.valid('query'); + return ctx.json( + await adminService.guildServiceAggregate.lookupService.listUserGuilds({user_id: userId, ...query}), + ); }, ); - app.post( - '/admin/users/list-dm-channels', + app.get( + '/admin/users/:user_id/dm-channels', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.USER_LIST_DM_CHANNELS), - Validator('json', ListUserDmChannelsRequest), + Validator('param', UserIdParam), + Validator('query', AdminUserDmChannelListQuery), OpenAPI({ - operationId: 'list_user_dm_channels', - summary: 'List user DM channels', - responseSchema: ListUserDmChannelsResponse, + operationId: 'list_admin_user_dm_channels', + summary: 'List user direct message channels', + responseSchema: AdminUserDmChannelListResponse, statusCode: 200, security: 'adminApiKey', tags: 'Admin', description: - 'List historical one-to-one DM channels for a user with cursor pagination. Requires USER_LIST_DM_CHANNELS permission.', + 'List the historical one-to-one direct message channels of a user with cursor pagination, or the group direct message channels they are a recipient of when type is group_dm. Requires USER_LIST_DM_CHANNELS permission.', }), async (ctx) => { const adminService = ctx.get('adminService'); - return ctx.json(await adminService.userService.listUserDmChannels(ctx.req.valid('json'))); + const {user_id: userId} = ctx.req.valid('param'); + const {type, ...pagination} = ctx.req.valid('query'); + if (type === 'group_dm') { + return ctx.json(await adminService.userService.listUserGroupDmChannels({user_id: userId})); + } + return ctx.json(await adminService.userService.listUserDmChannels({user_id: userId, ...pagination})); }, ); - app.post( - '/admin/users/list-group-dm-channels', + app.get( + '/admin/users/:user_id/change-log', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), - requireAdminACL(AdminACLs.USER_LIST_DM_CHANNELS), - Validator('json', ListUserGroupDmChannelsRequest), + requireAdminACL(AdminACLs.USER_VIEW_CONTACT_LOG), + Validator('param', UserIdParam), + Validator('query', AdminUserChangeLogQuery), OpenAPI({ - operationId: 'list_user_group_dm_channels', - summary: 'List user group DM channels', - responseSchema: ListUserGroupDmChannelsResponse, + operationId: 'list_admin_user_change_log', + summary: 'List user contact change log', + responseSchema: ListUserChangeLogResponseSchema, statusCode: 200, security: 'adminApiKey', tags: 'Admin', - description: 'List group DM channels for a user. Requires USER_LIST_DM_CHANNELS permission.', + description: + 'Retrieve the identity and contact change log history for a user. Shows all profile modifications, admin actions, and account changes with timestamps. Email values are redacted without USER_VIEW_EMAIL. Requires USER_VIEW_CONTACT_LOG permission.', }), async (ctx) => { const adminService = ctx.get('adminService'); - return ctx.json(await adminService.userService.listUserGroupDmChannels(ctx.req.valid('json'))); + const adminUserAcls = ctx.get('adminUserAcls'); + const {user_id: userId} = ctx.req.valid('param'); + const query = ctx.req.valid('query'); + return ctx.json(await adminService.userService.listUserChangeLog({user_id: userId, ...query}, adminUserAcls)); }, ); - app.post( - '/admin/users/disable-mfa', + app.get( + '/admin/users/:user_id/relationships', + RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), + requireAdminACL(AdminACLs.USER_LIST_RELATIONSHIPS), + Validator('param', UserIdParam), + OpenAPI({ + operationId: 'list_admin_user_relationships', + summary: 'List user relationships', + responseSchema: ListUserRelationshipsResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + description: + "List a user's friends, incoming and outgoing friend requests, and blocked users. Requires USER_LIST_RELATIONSHIPS permission.", + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const {user_id: userId} = ctx.req.valid('param'); + return ctx.json(await adminService.relationshipService.listRelationships({user_id: userId})); + }, + ); + app.delete( + '/admin/users/:user_id/relationships', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), - requireAdminACL(AdminACLs.USER_UPDATE_MFA), - Validator('json', DisableMfaRequest), + requireAdminACL(AdminACLs.USER_REMOVE_RELATIONSHIP), + Validator('param', UserIdParam), + Validator('query', AdminUserRelationshipCategoryQuery), OpenAPI({ - operationId: 'disable_user_mfa', - summary: 'Disable user MFA', - responseSchema: null, - statusCode: 204, + operationId: 'clear_admin_user_relationships', + summary: 'Clear user relationships', + responseSchema: RemoveUserRelationshipsResponse, + statusCode: 200, security: 'adminApiKey', tags: 'Admin', description: - 'Disable two-factor authentication for user account. Removes all authenticators. Creates audit log entry. Requires USER_UPDATE_MFA permission.', + 'Bulk-remove every relationship of the chosen category (friend, incoming_request, outgoing_request, blocked) for a user. Mirror entries on the other party are removed for friend, incoming_request, and outgoing_request. Requires USER_REMOVE_RELATIONSHIP permission.', }), async (ctx) => { const adminService = ctx.get('adminService'); const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); - await adminService.userService.securityService.disableMfa(ctx.req.valid('json'), adminUserId, auditLogReason); + const {user_id: userId} = ctx.req.valid('param'); + const {category} = ctx.req.valid('query'); + return ctx.json( + await adminService.relationshipService.removeRelationshipsByCategory( + {user_id: userId, category}, + adminUserId, + auditLogReason, + ), + ); + }, + ); + app.delete( + '/admin/users/:user_id/relationships/:target_user_id', + RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), + requireAdminACL(AdminACLs.USER_REMOVE_RELATIONSHIP), + Validator('param', AdminUserRelationshipParam), + Validator('query', AdminUserRelationshipCategoryQuery), + OpenAPI({ + operationId: 'remove_admin_user_relationship', + summary: 'Remove user relationship', + responseSchema: null, + statusCode: 204, + security: 'adminApiKey', + tags: 'Admin', + description: + 'Remove a single relationship row for a user. For friend and outgoing_request, the mirror entry on the other user is also removed. Dispatches RELATIONSHIP_REMOVE gateway events. Requires USER_REMOVE_RELATIONSHIP permission.', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserId = ctx.get('adminUserId'); + const auditLogReason = ctx.get('auditLogReason'); + const {user_id: userId, target_user_id: targetUserId} = ctx.req.valid('param'); + const {category} = ctx.req.valid('query'); + await adminService.relationshipService.removeRelationship( + {user_id: userId, target_user_id: targetUserId, category}, + adminUserId, + auditLogReason, + ); return ctx.body(null, 204); }, ); - app.post( - '/admin/users/list-webauthn-credentials', + app.get( + '/admin/users/:user_id/sessions', + RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), + requireAdminACL(AdminACLs.USER_LIST_SESSIONS), + Validator('param', UserIdParam), + OpenAPI({ + operationId: 'list_admin_user_sessions', + summary: 'List user sessions', + responseSchema: ListUserSessionsResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + description: + 'List all active user sessions across devices. Shows device info, IP, last activity, and creation time. Requires USER_LIST_SESSIONS permission.', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserId = ctx.get('adminUserId'); + const auditLogReason = ctx.get('auditLogReason'); + const adminUserAcls = ctx.get('adminUserAcls'); + const {user_id: userId} = ctx.req.valid('param'); + return ctx.json( + await adminService.userService.securityService.listUserSessions( + userId, + adminUserId, + auditLogReason, + adminUserAcls, + ), + ); + }, + ); + app.delete( + '/admin/users/:user_id/sessions', + RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), + requireAdminACL(AdminACLs.USER_UPDATE_FLAGS), + Validator('param', UserIdParam), + OpenAPI({ + operationId: 'terminate_admin_user_sessions', + summary: 'Terminate user sessions', + responseSchema: TerminateSessionsResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + description: + 'Terminate all active user sessions across devices. Forces user to re-authenticate on next connection. Creates audit log entry. Requires USER_UPDATE_FLAGS permission.', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserId = ctx.get('adminUserId'); + const auditLogReason = ctx.get('auditLogReason'); + const {user_id: userId} = ctx.req.valid('param'); + return ctx.json( + await adminService.userService.securityService.terminateSessions( + {user_id: userId}, + adminUserId, + auditLogReason, + ), + ); + }, + ); + app.get( + '/admin/users/:user_id/webauthn-credentials', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.USER_UPDATE_MFA), - Validator('json', ListWebAuthnCredentialsRequest), + Validator('param', UserIdParam), OpenAPI({ - operationId: 'list_user_webauthn_credentials', + operationId: 'list_admin_user_webauthn_credentials', summary: 'List user WebAuthn credentials', responseSchema: WebAuthnCredentialListResponse, statusCode: 200, @@ -199,22 +406,23 @@ export function UserAdminController(app: HonoApp) { const adminService = ctx.get('adminService'); const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); + const {user_id: userId} = ctx.req.valid('param'); return ctx.json( await adminService.userService.securityService.listWebAuthnCredentials( - ctx.req.valid('json'), + {user_id: userId}, adminUserId, auditLogReason, ), ); }, ); - app.post( - '/admin/users/delete-webauthn-credential', + app.delete( + '/admin/users/:user_id/webauthn-credentials/:credential_id', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.USER_UPDATE_MFA), - Validator('json', DeleteWebAuthnCredentialRequest), + Validator('param', AdminUserWebAuthnCredentialParam), OpenAPI({ - operationId: 'delete_user_webauthn_credential', + operationId: 'delete_admin_user_webauthn_credential', summary: 'Delete user WebAuthn credential', responseSchema: null, statusCode: 204, @@ -227,22 +435,48 @@ export function UserAdminController(app: HonoApp) { const adminService = ctx.get('adminService'); const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); + const {user_id: userId, credential_id: credentialId} = ctx.req.valid('param'); await adminService.userService.securityService.deleteWebAuthnCredential( - ctx.req.valid('json'), + {user_id: userId, credential_id: credentialId}, adminUserId, auditLogReason, ); return ctx.body(null, 204); }, ); - app.post( - '/admin/users/clear-fields', + app.delete( + '/admin/users/:user_id/mfa', + RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), + requireAdminACL(AdminACLs.USER_UPDATE_MFA), + Validator('param', UserIdParam), + OpenAPI({ + operationId: 'disable_admin_user_mfa', + summary: 'Disable user MFA', + responseSchema: null, + statusCode: 204, + security: 'adminApiKey', + tags: 'Admin', + description: + 'Disable two-factor authentication for user account. Removes all authenticators. Creates audit log entry. Requires USER_UPDATE_MFA permission.', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserId = ctx.get('adminUserId'); + const auditLogReason = ctx.get('auditLogReason'); + const {user_id: userId} = ctx.req.valid('param'); + await adminService.userService.securityService.disableMfa({user_id: userId}, adminUserId, auditLogReason); + return ctx.body(null, 204); + }, + ); + app.delete( + '/admin/users/:user_id/profile-fields', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.USER_UPDATE_PROFILE), - Validator('json', ClearUserFieldsRequest), + Validator('param', UserIdParam), + Validator('json', AdminUserClearFieldsRequest), OpenAPI({ - operationId: 'clear_user_fields', - summary: 'Clear user fields', + operationId: 'clear_admin_user_profile_fields', + summary: 'Clear user profile fields', responseSchema: UserMutationResponse, statusCode: 200, security: 'adminApiKey', @@ -255,9 +489,10 @@ export function UserAdminController(app: HonoApp) { const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); const adminUserAcls = ctx.get('adminUserAcls'); + const {user_id: userId} = ctx.req.valid('param'); return ctx.json( await adminService.userService.profileService.clearUserFields( - ctx.req.valid('json'), + {user_id: userId, ...ctx.req.valid('json')}, adminUserId, auditLogReason, adminUserAcls, @@ -265,13 +500,14 @@ export function UserAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/users/set-bot-status', + app.put( + '/admin/users/:user_id/bot-status', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS), - Validator('json', SetUserBotStatusRequest), + Validator('param', UserIdParam), + Validator('json', AdminUserBotStatusRequest), OpenAPI({ - operationId: 'set_user_bot_status', + operationId: 'set_admin_user_bot_status', summary: 'Set user bot status', responseSchema: UserMutationResponse, statusCode: 200, @@ -285,9 +521,10 @@ export function UserAdminController(app: HonoApp) { const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); const adminUserAcls = ctx.get('adminUserAcls'); + const {user_id: userId} = ctx.req.valid('param'); return ctx.json( await adminService.userService.profileService.setUserBotStatus( - ctx.req.valid('json'), + {user_id: userId, ...ctx.req.valid('json')}, adminUserId, auditLogReason, adminUserAcls, @@ -295,13 +532,14 @@ export function UserAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/users/set-system-status', + app.put( + '/admin/users/:user_id/system-status', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS), - Validator('json', SetUserSystemStatusRequest), + Validator('param', UserIdParam), + Validator('json', AdminUserSystemStatusRequest), OpenAPI({ - operationId: 'set_user_system_status', + operationId: 'set_admin_user_system_status', summary: 'Set user system status', responseSchema: UserMutationResponse, statusCode: 200, @@ -315,9 +553,10 @@ export function UserAdminController(app: HonoApp) { const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); const adminUserAcls = ctx.get('adminUserAcls'); + const {user_id: userId} = ctx.req.valid('param'); return ctx.json( await adminService.userService.profileService.setUserSystemStatus( - ctx.req.valid('json'), + {user_id: userId, ...ctx.req.valid('json')}, adminUserId, auditLogReason, adminUserAcls, @@ -325,97 +564,14 @@ export function UserAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/users/verify-email', - RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), - requireAdminACL(AdminACLs.USER_UPDATE_EMAIL), - Validator('json', VerifyUserEmailRequest), - OpenAPI({ - operationId: 'verify_user_email', - summary: 'Verify user email', - responseSchema: UserMutationResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - description: - 'Manually verify user email address without requiring confirmation link. Bypasses email verification requirement. Creates audit log entry. Requires USER_UPDATE_EMAIL permission.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - const adminUserAcls = ctx.get('adminUserAcls'); - return ctx.json( - await adminService.userService.profileService.verifyUserEmail( - ctx.req.valid('json'), - adminUserId, - auditLogReason, - adminUserAcls, - ), - ); - }, - ); - app.post( - '/admin/users/resend-verification-email', - RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), - requireAdminACL(AdminACLs.USER_UPDATE_EMAIL), - Validator('json', ResendVerificationEmailRequest), - OpenAPI({ - operationId: 'admin_resend_verification_email', - summary: 'Resend verification email', - responseSchema: null, - statusCode: 204, - security: 'adminApiKey', - tags: 'Admin', - description: - 'Resend the account verification email for a user. Creates audit log entry and honours email verification resend limits. Requires USER_UPDATE_EMAIL permission.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.userService.securityService.resendVerificationEmail( - ctx.req.valid('json'), - adminUserId, - auditLogReason, - ); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/users/send-password-reset', - RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), - requireAdminACL(AdminACLs.USER_UPDATE_EMAIL), - Validator('json', SendPasswordResetRequest), - OpenAPI({ - operationId: 'send_password_reset', - summary: 'Send password reset', - responseSchema: null, - statusCode: 204, - security: 'adminApiKey', - tags: 'Admin', - description: - 'Send password reset email to user with reset link. User must use link within expiry window. Creates audit log entry. Requires USER_UPDATE_EMAIL permission.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.userService.securityService.sendPasswordReset( - ctx.req.valid('json'), - adminUserId, - auditLogReason, - ); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/users/change-username', + app.patch( + '/admin/users/:user_id/username', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.USER_UPDATE_USERNAME), - Validator('json', ChangeUsernameRequest), + Validator('param', UserIdParam), + Validator('json', AdminUserUsernameUpdateRequest), OpenAPI({ - operationId: 'change_user_username', + operationId: 'update_admin_user_username', summary: 'Change user username', responseSchema: UserMutationResponse, statusCode: 200, @@ -429,9 +585,10 @@ export function UserAdminController(app: HonoApp) { const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); const adminUserAcls = ctx.get('adminUserAcls'); + const {user_id: userId} = ctx.req.valid('param'); return ctx.json( await adminService.userService.profileService.changeUsername( - ctx.req.valid('json'), + {user_id: userId, ...ctx.req.valid('json')}, adminUserId, auditLogReason, adminUserAcls, @@ -439,13 +596,14 @@ export function UserAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/users/change-email', + app.patch( + '/admin/users/:user_id/email', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.USER_UPDATE_EMAIL), - Validator('json', ChangeEmailRequest), + Validator('param', UserIdParam), + Validator('json', AdminUserEmailUpdateRequest), OpenAPI({ - operationId: 'change_user_email', + operationId: 'update_admin_user_email', summary: 'Change user email', responseSchema: UserMutationResponse, statusCode: 200, @@ -459,9 +617,10 @@ export function UserAdminController(app: HonoApp) { const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); const adminUserAcls = ctx.get('adminUserAcls'); + const {user_id: userId} = ctx.req.valid('param'); return ctx.json( await adminService.userService.profileService.changeEmail( - ctx.req.valid('json'), + {user_id: userId, ...ctx.req.valid('json')}, adminUserId, auditLogReason, adminUserAcls, @@ -469,57 +628,30 @@ export function UserAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/users/terminate-sessions', + app.put( + '/admin/users/:user_id/email-verification', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), - requireAdminACL(AdminACLs.USER_UPDATE_FLAGS), - Validator('json', TerminateSessionsRequest), + requireAdminACL(AdminACLs.USER_UPDATE_EMAIL), + Validator('param', UserIdParam), OpenAPI({ - operationId: 'terminate_user_sessions', - summary: 'Terminate user sessions', - responseSchema: TerminateSessionsResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - description: - 'Terminate all active user sessions across devices. Forces user to re-authenticate on next connection. Creates audit log entry. Requires USER_UPDATE_FLAGS permission.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - return ctx.json( - await adminService.userService.securityService.terminateSessions( - ctx.req.valid('json'), - adminUserId, - auditLogReason, - ), - ); - }, - ); - app.post( - '/admin/users/temp-ban', - RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), - requireAdminACL(AdminACLs.USER_TEMP_BAN), - Validator('json', TempBanUserRequest), - OpenAPI({ - operationId: 'temp_ban_user', - summary: 'Temp ban user', + operationId: 'verify_admin_user_email', + summary: 'Verify user email', responseSchema: UserMutationResponse, statusCode: 200, security: 'adminApiKey', tags: 'Admin', description: - 'Apply temporary ban to user account for specified duration. Prevents login and guild operations. Automatically lifts after expiry. Creates audit log entry. Requires USER_TEMP_BAN permission.', + 'Manually verify user email address without requiring confirmation link. Bypasses email verification requirement. Creates audit log entry. Requires USER_UPDATE_EMAIL permission.', }), async (ctx) => { const adminService = ctx.get('adminService'); const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); const adminUserAcls = ctx.get('adminUserAcls'); + const {user_id: userId} = ctx.req.valid('param'); return ctx.json( - await adminService.userService.banService.tempBanUser( - ctx.req.valid('json'), + await adminService.userService.profileService.verifyUserEmail( + {user_id: userId}, adminUserId, auditLogReason, adminUserAcls, @@ -528,12 +660,96 @@ export function UserAdminController(app: HonoApp) { }, ); app.post( - '/admin/users/unban', + '/admin/users/:user_id/verification-email', + RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), + requireAdminACL(AdminACLs.USER_UPDATE_EMAIL), + Validator('param', UserIdParam), + OpenAPI({ + operationId: 'resend_admin_user_verification_email', + summary: 'Resend user verification email', + responseSchema: null, + statusCode: 204, + security: 'adminApiKey', + tags: 'Admin', + description: + 'Resend the account verification email for a user. Creates audit log entry and honours email verification resend limits. Requires USER_UPDATE_EMAIL permission.', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserId = ctx.get('adminUserId'); + const auditLogReason = ctx.get('auditLogReason'); + const {user_id: userId} = ctx.req.valid('param'); + await adminService.userService.securityService.resendVerificationEmail( + {user_id: userId}, + adminUserId, + auditLogReason, + ); + return ctx.body(null, 204); + }, + ); + app.post( + '/admin/users/:user_id/password-reset', + RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), + requireAdminACL(AdminACLs.USER_UPDATE_EMAIL), + Validator('param', UserIdParam), + OpenAPI({ + operationId: 'send_admin_user_password_reset', + summary: 'Send user password reset', + responseSchema: null, + statusCode: 204, + security: 'adminApiKey', + tags: 'Admin', + description: + 'Send password reset email to user with reset link. User must use link within expiry window. Creates audit log entry. Requires USER_UPDATE_EMAIL permission.', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserId = ctx.get('adminUserId'); + const auditLogReason = ctx.get('auditLogReason'); + const {user_id: userId} = ctx.req.valid('param'); + await adminService.userService.securityService.sendPasswordReset({user_id: userId}, adminUserId, auditLogReason); + return ctx.body(null, 204); + }, + ); + app.put( + '/admin/users/:user_id/ban', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.USER_TEMP_BAN), - Validator('json', DisableMfaRequest), + Validator('param', UserIdParam), + Validator('json', AdminUserBanRequest), OpenAPI({ - operationId: 'unban_user', + operationId: 'ban_admin_user', + summary: 'Ban user', + responseSchema: UserMutationResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + description: + 'Apply temporary ban to user account for specified duration, or permanently with a duration of zero. Prevents login and guild operations. Automatically lifts after expiry. Creates audit log entry. Requires USER_TEMP_BAN permission.', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserId = ctx.get('adminUserId'); + const auditLogReason = ctx.get('auditLogReason'); + const adminUserAcls = ctx.get('adminUserAcls'); + const {user_id: userId} = ctx.req.valid('param'); + return ctx.json( + await adminService.userService.banService.tempBanUser( + {user_id: userId, ...ctx.req.valid('json')}, + adminUserId, + auditLogReason, + adminUserAcls, + ), + ); + }, + ); + app.delete( + '/admin/users/:user_id/ban', + RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), + requireAdminACL(AdminACLs.USER_TEMP_BAN), + Validator('param', UserIdParam), + OpenAPI({ + operationId: 'unban_admin_user', summary: 'Unban user', responseSchema: UserMutationResponse, statusCode: 200, @@ -547,9 +763,10 @@ export function UserAdminController(app: HonoApp) { const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); const adminUserAcls = ctx.get('adminUserAcls'); + const {user_id: userId} = ctx.req.valid('param'); return ctx.json( await adminService.userService.banService.unbanUser( - ctx.req.valid('json'), + {user_id: userId}, adminUserId, auditLogReason, adminUserAcls, @@ -557,14 +774,15 @@ export function UserAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/users/schedule-deletion', + app.put( + '/admin/users/:user_id/deletion', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.USER_DELETE), - Validator('json', ScheduleAccountDeletionRequest), + Validator('param', UserIdParam), + Validator('json', AdminUserDeletionScheduleRequest), OpenAPI({ - operationId: 'schedule_account_deletion', - summary: 'Schedule account deletion', + operationId: 'schedule_admin_user_deletion', + summary: 'Schedule user deletion', responseSchema: UserMutationResponse, statusCode: 200, security: 'adminApiKey', @@ -577,9 +795,10 @@ export function UserAdminController(app: HonoApp) { const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); const adminUserAcls = ctx.get('adminUserAcls'); + const {user_id: userId} = ctx.req.valid('param'); return ctx.json( await adminService.userService.deletionService.scheduleAccountDeletion( - ctx.req.valid('json'), + {user_id: userId, ...ctx.req.valid('json')}, adminUserId, auditLogReason, adminUserAcls, @@ -587,14 +806,14 @@ export function UserAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/users/cancel-deletion', + app.delete( + '/admin/users/:user_id/deletion', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.USER_DELETE), - Validator('json', DisableMfaRequest), + Validator('param', UserIdParam), OpenAPI({ - operationId: 'cancel_account_deletion', - summary: 'Cancel account deletion', + operationId: 'cancel_admin_user_deletion', + summary: 'Cancel user deletion', responseSchema: UserMutationResponse, statusCode: 200, security: 'adminApiKey', @@ -607,9 +826,10 @@ export function UserAdminController(app: HonoApp) { const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); const adminUserAcls = ctx.get('adminUserAcls'); + const {user_id: userId} = ctx.req.valid('param'); return ctx.json( await adminService.userService.deletionService.cancelAccountDeletion( - ctx.req.valid('json'), + {user_id: userId}, adminUserId, auditLogReason, adminUserAcls, @@ -617,13 +837,13 @@ export function UserAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/users/cancel-bulk-message-deletion', + app.delete( + '/admin/users/:user_id/message-deletion', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.USER_CANCEL_BULK_MESSAGE_DELETION), - Validator('json', CancelBulkMessageDeletionRequest), + Validator('param', UserIdParam), OpenAPI({ - operationId: 'admin_cancel_bulk_message_deletion', + operationId: 'cancel_admin_user_message_deletion', summary: 'Cancel bulk message deletion', responseSchema: UserMutationResponse, statusCode: 200, @@ -637,9 +857,10 @@ export function UserAdminController(app: HonoApp) { const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); const adminUserAcls = ctx.get('adminUserAcls'); + const {user_id: userId} = ctx.req.valid('param'); return ctx.json( await adminService.userService.cancelBulkMessageDeletion( - ctx.req.valid('json'), + {user_id: userId}, adminUserId, auditLogReason, adminUserAcls, @@ -647,29 +868,31 @@ export function UserAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/users/set-acls', + app.put( + '/admin/users/:user_id/acls', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.ACL_SET_USER), - Validator('json', SetUserAclsRequest), + Validator('param', UserIdParam), + Validator('json', AdminUserAclsRequest), OpenAPI({ - operationId: 'set_user_acls', - summary: 'Set user ACLs', + operationId: 'set_admin_user_acls', + summary: 'Set user admin permissions', responseSchema: UserMutationResponse, statusCode: 200, security: 'adminApiKey', tags: 'Admin', description: - 'Grant or revoke admin ACL permissions to user. Controls admin capabilities and panel access. Creates audit log entry. Requires ACL_SET_USER permission.', + 'Replace the admin ACL permissions granted to a user. Controls admin capabilities and panel access. The permissions accepted are the ones listed by GET /admin/acls. Creates audit log entry. Requires ACL_SET_USER permission.', }), async (ctx) => { const adminService = ctx.get('adminService'); const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); const adminUserAcls = ctx.get('adminUserAcls'); + const {user_id: userId} = ctx.req.valid('param'); return ctx.json( await adminService.userService.securityService.setUserAcls( - ctx.req.valid('json'), + {user_id: userId, ...ctx.req.valid('json')}, adminUserId, auditLogReason, adminUserAcls, @@ -677,13 +900,14 @@ export function UserAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/users/set-traits', + app.put( + '/admin/users/:user_id/traits', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.USER_UPDATE_TRAITS), - Validator('json', SetUserTraitsRequest), + Validator('param', UserIdParam), + Validator('json', AdminUserTraitsRequest), OpenAPI({ - operationId: 'set_user_traits', + operationId: 'set_admin_user_traits', summary: 'Set user traits', responseSchema: UserMutationResponse, statusCode: 200, @@ -697,9 +921,10 @@ export function UserAdminController(app: HonoApp) { const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); const adminUserAcls = ctx.get('adminUserAcls'); + const {user_id: userId} = ctx.req.valid('param'); return ctx.json( await adminService.userService.securityService.setUserTraits( - ctx.req.valid('json'), + {user_id: userId, ...ctx.req.valid('json')}, adminUserId, auditLogReason, adminUserAcls, @@ -707,13 +932,14 @@ export function UserAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/users/update-flags', + app.patch( + '/admin/users/:user_id/flags', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.USER_UPDATE_FLAGS), - Validator('json', UpdateUserFlagsRequest), + Validator('param', UserIdParam), + Validator('json', AdminUserFlagsUpdateRequest), OpenAPI({ - operationId: 'update_user_flags', + operationId: 'update_admin_user_flags', summary: 'Update user flags', responseSchema: UserMutationResponse, statusCode: 200, @@ -728,7 +954,7 @@ export function UserAdminController(app: HonoApp) { const auditLogReason = ctx.get('auditLogReason'); const adminUserAcls = ctx.get('adminUserAcls'); const body = ctx.req.valid('json'); - const userId = createUserID(body.user_id); + const userId = createUserID(ctx.req.valid('param').user_id); const addFlags = body.add_flags.map((flag) => BigInt(flag)); const removeFlags = body.remove_flags.map((flag) => BigInt(flag)); return ctx.json( @@ -742,13 +968,14 @@ export function UserAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/users/update-premium-flags', + app.patch( + '/admin/users/:user_id/premium-flags', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.USER_UPDATE_FLAGS), - Validator('json', UpdatePremiumFlagsRequest), + Validator('param', UserIdParam), + Validator('json', AdminUserPremiumFlagsUpdateRequest), OpenAPI({ - operationId: 'update_user_premium_flags', + operationId: 'update_admin_user_premium_flags', summary: 'Update user premium flags', responseSchema: UserMutationResponse, statusCode: 200, @@ -763,13 +990,11 @@ export function UserAdminController(app: HonoApp) { const auditLogReason = ctx.get('auditLogReason'); const adminUserAcls = ctx.get('adminUserAcls'); const body = ctx.req.valid('json'); - const userId = createUserID(body.user_id); - const addFlags = body.add_flags; - const removeFlags = body.remove_flags; + const userId = createUserID(ctx.req.valid('param').user_id); return ctx.json( await adminService.userService.securityService.updatePremiumFlags({ userId, - data: {addFlags, removeFlags}, + data: {addFlags: body.add_flags, removeFlags: body.remove_flags}, adminUserId, auditLogReason, acls: adminUserAcls, @@ -777,29 +1002,31 @@ export function UserAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/users/update-has-verified-phone', + app.put( + '/admin/users/:user_id/phone-verification', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.USER_UPDATE_PHONE), - Validator('json', UpdateHasVerifiedPhoneRequest), + Validator('param', UserIdParam), + Validator('json', AdminUserPhoneVerificationRequest), OpenAPI({ - operationId: 'update_user_has_verified_phone', + operationId: 'update_admin_user_phone_verification', summary: 'Update user phone verification flag', responseSchema: UserMutationResponse, statusCode: 200, security: 'adminApiKey', tags: 'Admin', description: - 'Set whether a user is treated as having completed phone verification. This is the only supported path for clearing the irreversible user-facing phone verification flag.', + 'Set whether a user is treated as having completed phone verification. This is the only supported path for clearing the irreversible user-facing phone verification flag. Requires USER_UPDATE_PHONE permission.', }), async (ctx) => { const adminService = ctx.get('adminService'); const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); const adminUserAcls = ctx.get('adminUserAcls'); + const {user_id: userId} = ctx.req.valid('param'); return ctx.json( await adminService.userService.securityService.updateHasVerifiedPhone( - ctx.req.valid('json'), + {user_id: userId, ...ctx.req.valid('json')}, adminUserId, auditLogReason, adminUserAcls, @@ -807,14 +1034,15 @@ export function UserAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/users/change-dob', + app.patch( + '/admin/users/:user_id/date-of-birth', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.USER_UPDATE_DOB), - Validator('json', ChangeDobRequest), + Validator('param', UserIdParam), + Validator('json', AdminUserDobUpdateRequest), OpenAPI({ - operationId: 'change_user_dob', - summary: 'Change user DOB', + operationId: 'update_admin_user_date_of_birth', + summary: 'Change user date of birth', responseSchema: UserMutationResponse, statusCode: 200, security: 'adminApiKey', @@ -827,9 +1055,10 @@ export function UserAdminController(app: HonoApp) { const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); const adminUserAcls = ctx.get('adminUserAcls'); + const {user_id: userId} = ctx.req.valid('param'); return ctx.json( await adminService.userService.profileService.changeDob( - ctx.req.valid('json'), + {user_id: userId, ...ctx.req.valid('json')}, adminUserId, auditLogReason, adminUserAcls, @@ -837,13 +1066,14 @@ export function UserAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/users/update-suspicious-activity-flags', + app.put( + '/admin/users/:user_id/suspicious-activity-flags', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.USER_UPDATE_SUSPICIOUS_ACTIVITY), - Validator('json', UpdateSuspiciousActivityFlagsRequest), + Validator('param', UserIdParam), + Validator('json', AdminUserSuspiciousActivityFlagsRequest), OpenAPI({ - operationId: 'update_suspicious_activity_flags', + operationId: 'update_admin_user_suspicious_activity_flags', summary: 'Update suspicious activity flags', responseSchema: UserMutationResponse, statusCode: 200, @@ -857,9 +1087,10 @@ export function UserAdminController(app: HonoApp) { const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); const adminUserAcls = ctx.get('adminUserAcls'); + const {user_id: userId} = ctx.req.valid('param'); return ctx.json( await adminService.userService.securityService.updateSuspiciousActivityFlags( - ctx.req.valid('json'), + {user_id: userId, ...ctx.req.valid('json')}, adminUserId, auditLogReason, adminUserAcls, @@ -867,13 +1098,14 @@ export function UserAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/users/disable-suspicious', + app.put( + '/admin/users/:user_id/suspicious-activity-disablement', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), requireAdminACL(AdminACLs.USER_DISABLE_SUSPICIOUS), - Validator('json', DisableForSuspiciousActivityRequest), + Validator('param', UserIdParam), + Validator('json', AdminUserSuspiciousDisableRequest), OpenAPI({ - operationId: 'disable_user_suspicious', + operationId: 'disable_admin_user_suspicious', summary: 'Disable user for suspicious activity', responseSchema: UserMutationResponse, statusCode: 200, @@ -887,9 +1119,10 @@ export function UserAdminController(app: HonoApp) { const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); const adminUserAcls = ctx.get('adminUserAcls'); + const {user_id: userId} = ctx.req.valid('param'); return ctx.json( await adminService.userService.securityService.disableForSuspiciousActivity( - ctx.req.valid('json'), + {user_id: userId, ...ctx.req.valid('json')}, adminUserId, auditLogReason, adminUserAcls, @@ -897,127 +1130,4 @@ export function UserAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/users/list-sessions', - RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), - requireAdminACL(AdminACLs.USER_LIST_SESSIONS), - Validator('json', ListUserSessionsRequest), - OpenAPI({ - operationId: 'list_user_sessions', - summary: 'List user sessions', - responseSchema: ListUserSessionsResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - description: - 'List all active user sessions across devices. Shows device info, IP, last activity, and creation time. Requires USER_LIST_SESSIONS permission.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - const adminUserAcls = ctx.get('adminUserAcls'); - const body = ctx.req.valid('json'); - return ctx.json( - await adminService.userService.securityService.listUserSessions( - body.user_id, - adminUserId, - auditLogReason, - adminUserAcls, - ), - ); - }, - ); - app.post( - '/admin/users/change-log', - RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), - requireAdminACL(AdminACLs.USER_LOOKUP), - Validator('json', ListUserChangeLogRequest), - OpenAPI({ - operationId: 'get_user_change_log', - summary: 'Get user change log', - responseSchema: ListUserChangeLogResponseSchema, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - description: - 'Retrieve complete change log history for a user. Shows all profile modifications, admin actions, and account changes with timestamps. Requires USER_LOOKUP permission.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserAcls = ctx.get('adminUserAcls'); - return ctx.json(await adminService.userService.listUserChangeLog(ctx.req.valid('json'), adminUserAcls)); - }, - ); - app.post( - '/admin/users/list-relationships', - RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), - requireAdminACL(AdminACLs.USER_LIST_RELATIONSHIPS), - Validator('json', ListUserRelationshipsRequest), - OpenAPI({ - operationId: 'admin_list_user_relationships', - summary: 'List user relationships', - responseSchema: ListUserRelationshipsResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - description: - "List a user's friends, incoming and outgoing friend requests, and blocked users. Requires USER_LIST_RELATIONSHIPS permission.", - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - return ctx.json(await adminService.relationshipService.listRelationships(ctx.req.valid('json'))); - }, - ); - app.post( - '/admin/users/remove-relationship', - RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), - requireAdminACL(AdminACLs.USER_REMOVE_RELATIONSHIP), - Validator('json', RemoveUserRelationshipRequest), - OpenAPI({ - operationId: 'remove_user_relationship', - summary: 'Remove user relationship', - responseSchema: null, - statusCode: 204, - security: 'adminApiKey', - tags: 'Admin', - description: - 'Remove a single relationship row for a user. For friend and outgoing_request, the mirror entry on the other user is also removed. Dispatches RELATIONSHIP_REMOVE gateway events. Requires USER_REMOVE_RELATIONSHIP permission.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - await adminService.relationshipService.removeRelationship(ctx.req.valid('json'), adminUserId, auditLogReason); - return ctx.body(null, 204); - }, - ); - app.post( - '/admin/users/remove-relationships-by-category', - RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), - requireAdminACL(AdminACLs.USER_REMOVE_RELATIONSHIP), - Validator('json', RemoveUserRelationshipsByCategoryRequest), - OpenAPI({ - operationId: 'remove_user_relationships_by_category', - summary: "Remove all of a user's relationships in a category", - responseSchema: RemoveUserRelationshipsResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - description: - 'Bulk-remove every relationship of the chosen category (friend, incoming_request, outgoing_request, blocked) for a user. Mirror entries on the other party are removed for friend, incoming_request, and outgoing_request. Requires USER_REMOVE_RELATIONSHIP permission.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - const adminUserId = ctx.get('adminUserId'); - const auditLogReason = ctx.get('auditLogReason'); - return ctx.json( - await adminService.relationshipService.removeRelationshipsByCategory( - ctx.req.valid('json'), - adminUserId, - auditLogReason, - ), - ); - }, - ); } diff --git a/fluxer_api/src/api/admin/controllers/VoiceAdminController.ts b/fluxer_api/src/api/admin/controllers/VoiceAdminController.ts index 0a89b6a19..489586823 100644 --- a/fluxer_api/src/api/admin/controllers/VoiceAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/VoiceAdminController.ts @@ -6,37 +6,40 @@ import { CreateVoiceRegionResponse, CreateVoiceServerRequest, CreateVoiceServerResponse, - DeleteVoiceRegionRequest, DeleteVoiceResponse, - DeleteVoiceServerRequest, - GetVoiceRegionRequest, + GetVoiceRegionQuery, GetVoiceRegionResponse, - GetVoiceServerRequest, GetVoiceServerResponse, - ListVoiceRegionsRequest, + ListVoiceRegionsQuery, ListVoiceRegionsResponse, - ListVoiceServersRequest, ListVoiceServersResponse, UpdateVoiceRegionRequest, UpdateVoiceRegionResponse, UpdateVoiceServerRequest, UpdateVoiceServerResponse, + VoiceRegionIdParam, + VoiceServerIdParam, } from '@fluxer/schema/src/domains/admin/AdminVoiceSchemas'; +import type {Context} from 'hono'; import {requireAdminACL} from '../../middleware/AdminMiddleware'; import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware'; import {OpenAPI} from '../../middleware/ResponseTypeMiddleware'; import {RateLimitConfigs} from '../../RateLimitConfig'; -import type {HonoApp} from '../../types/HonoEnv'; +import type {HonoApp, HonoEnv} from '../../types/HonoEnv'; import {Validator} from '../../Validator'; +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + export function VoiceAdminController(app: HonoApp) { - app.post( - '/admin/voice/regions/list', + app.get( + '/admin/voice/regions', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.VOICE_REGION_LIST), - Validator('json', ListVoiceRegionsRequest), + Validator('query', ListVoiceRegionsQuery), OpenAPI({ - operationId: 'list_voice_regions', + operationId: 'list_admin_voice_regions', summary: 'List voice regions', responseSchema: ListVoiceRegionsResponse, statusCode: 200, @@ -47,36 +50,16 @@ export function VoiceAdminController(app: HonoApp) { }), async (ctx) => { const adminService = ctx.get('adminService'); - return ctx.json(await adminService.voiceService.listVoiceRegions(ctx.req.valid('json'))); + return ctx.json(await adminService.voiceService.listVoiceRegions(ctx.req.valid('query'))); }, ); app.post( - '/admin/voice/regions/get', - RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), - requireAdminACL(AdminACLs.VOICE_REGION_LIST), - Validator('json', GetVoiceRegionRequest), - OpenAPI({ - operationId: 'get_voice_region', - summary: 'Get voice region', - responseSchema: GetVoiceRegionResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - description: - 'Gets detailed information about a voice region including assigned servers, capacity, and server details. Requires VOICE_REGION_LIST permission.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - return ctx.json(await adminService.voiceService.getVoiceRegion(ctx.req.valid('json'))); - }, - ); - app.post( - '/admin/voice/regions/create', + '/admin/voice/regions', RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY), requireAdminACL(AdminACLs.VOICE_REGION_CREATE), Validator('json', CreateVoiceRegionRequest), OpenAPI({ - operationId: 'create_voice_region', + operationId: 'create_admin_voice_region', summary: 'Create voice region', responseSchema: CreateVoiceRegionResponse, statusCode: 200, @@ -94,13 +77,45 @@ export function VoiceAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/voice/regions/update', + app.get( + '/admin/voice/regions/:region_id', + RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), + requireAdminACL(AdminACLs.VOICE_REGION_LIST), + Validator('param', VoiceRegionIdParam), + Validator('query', GetVoiceRegionQuery), + OpenAPI({ + operationId: 'get_admin_voice_region', + summary: 'Get voice region', + responseSchema: GetVoiceRegionResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + description: + 'Gets detailed information about a voice region including assigned servers, capacity, and server details. Requires VOICE_REGION_LIST permission.', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + return ctx.json( + await adminService.voiceService.getVoiceRegion({ + id: ctx.req.valid('param').region_id, + include_servers: ctx.req.valid('query').include_servers, + }), + ); + }, + ); + app.patch( + '/admin/voice/regions/:region_id', RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY), requireAdminACL(AdminACLs.VOICE_REGION_UPDATE), - Validator('json', UpdateVoiceRegionRequest), + Validator('param', VoiceRegionIdParam), + Validator('json', UpdateVoiceRegionRequest, { + pre: (value: unknown, ctx: Context) => ({ + ...(isPlainObject(value) ? value : {}), + id: ctx.req.param('region_id'), + }), + }), OpenAPI({ - operationId: 'update_voice_region', + operationId: 'update_admin_voice_region', summary: 'Update voice region', responseSchema: UpdateVoiceRegionResponse, statusCode: 200, @@ -118,13 +133,13 @@ export function VoiceAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/voice/regions/delete', + app.delete( + '/admin/voice/regions/:region_id', RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY), requireAdminACL(AdminACLs.VOICE_REGION_DELETE), - Validator('json', DeleteVoiceRegionRequest), + Validator('param', VoiceRegionIdParam), OpenAPI({ - operationId: 'delete_voice_region', + operationId: 'delete_admin_voice_region', summary: 'Delete voice region', responseSchema: DeleteVoiceResponse, statusCode: 200, @@ -138,57 +153,47 @@ export function VoiceAdminController(app: HonoApp) { const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); return ctx.json( - await adminService.voiceService.deleteVoiceRegion(ctx.req.valid('json'), adminUserId, auditLogReason), + await adminService.voiceService.deleteVoiceRegion( + {id: ctx.req.valid('param').region_id}, + adminUserId, + auditLogReason, + ), ); }, ); - app.post( - '/admin/voice/servers/list', + app.get( + '/admin/voice/regions/:region_id/servers', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.VOICE_SERVER_LIST), - Validator('json', ListVoiceServersRequest), + Validator('param', VoiceRegionIdParam), OpenAPI({ - operationId: 'list_voice_servers', + operationId: 'list_admin_voice_servers', summary: 'List voice servers', responseSchema: ListVoiceServersResponse, statusCode: 200, security: 'adminApiKey', tags: 'Admin', description: - 'Lists all voice servers with connection counts and capacity. Shows server status, region assignment, and load information. Supports filtering and pagination. Requires VOICE_SERVER_LIST permission.', + 'Lists all voice servers in a region with connection counts and capacity. Shows server status, region assignment, and load information. Requires VOICE_SERVER_LIST permission.', }), async (ctx) => { const adminService = ctx.get('adminService'); - return ctx.json(await adminService.voiceService.listVoiceServers(ctx.req.valid('json'))); + return ctx.json(await adminService.voiceService.listVoiceServers({region_id: ctx.req.valid('param').region_id})); }, ); app.post( - '/admin/voice/servers/get', - RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), - requireAdminACL(AdminACLs.VOICE_SERVER_LIST), - Validator('json', GetVoiceServerRequest), - OpenAPI({ - operationId: 'get_voice_server', - summary: 'Get voice server', - responseSchema: GetVoiceServerResponse, - statusCode: 200, - security: 'adminApiKey', - tags: 'Admin', - description: - 'Gets detailed voice server information including active connections and configuration. Requires VOICE_SERVER_LIST permission.', - }), - async (ctx) => { - const adminService = ctx.get('adminService'); - return ctx.json(await adminService.voiceService.getVoiceServer(ctx.req.valid('json'))); - }, - ); - app.post( - '/admin/voice/servers/create', + '/admin/voice/regions/:region_id/servers', RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY), requireAdminACL(AdminACLs.VOICE_SERVER_CREATE), - Validator('json', CreateVoiceServerRequest), + Validator('param', VoiceRegionIdParam), + Validator('json', CreateVoiceServerRequest, { + pre: (value: unknown, ctx: Context) => ({ + ...(isPlainObject(value) ? value : {}), + region_id: ctx.req.param('region_id'), + }), + }), OpenAPI({ - operationId: 'create_voice_server', + operationId: 'create_admin_voice_server', summary: 'Create voice server', responseSchema: CreateVoiceServerResponse, statusCode: 200, @@ -206,13 +211,41 @@ export function VoiceAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/voice/servers/update', + app.get( + '/admin/voice/regions/:region_id/servers/:server_id', + RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), + requireAdminACL(AdminACLs.VOICE_SERVER_LIST), + Validator('param', VoiceServerIdParam), + OpenAPI({ + operationId: 'get_admin_voice_server', + summary: 'Get voice server', + responseSchema: GetVoiceServerResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + description: + 'Gets detailed voice server information including active connections and configuration. Requires VOICE_SERVER_LIST permission.', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const {region_id, server_id} = ctx.req.valid('param'); + return ctx.json(await adminService.voiceService.getVoiceServer({region_id, server_id})); + }, + ); + app.patch( + '/admin/voice/regions/:region_id/servers/:server_id', RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY), requireAdminACL(AdminACLs.VOICE_SERVER_UPDATE), - Validator('json', UpdateVoiceServerRequest), + Validator('param', VoiceServerIdParam), + Validator('json', UpdateVoiceServerRequest, { + pre: (value: unknown, ctx: Context) => ({ + ...(isPlainObject(value) ? value : {}), + region_id: ctx.req.param('region_id'), + server_id: ctx.req.param('server_id'), + }), + }), OpenAPI({ - operationId: 'update_voice_server', + operationId: 'update_admin_voice_server', summary: 'Update voice server', responseSchema: UpdateVoiceServerResponse, statusCode: 200, @@ -230,13 +263,13 @@ export function VoiceAdminController(app: HonoApp) { ); }, ); - app.post( - '/admin/voice/servers/delete', + app.delete( + '/admin/voice/regions/:region_id/servers/:server_id', RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY), requireAdminACL(AdminACLs.VOICE_SERVER_DELETE), - Validator('json', DeleteVoiceServerRequest), + Validator('param', VoiceServerIdParam), OpenAPI({ - operationId: 'delete_voice_server', + operationId: 'delete_admin_voice_server', summary: 'Delete voice server', responseSchema: DeleteVoiceResponse, statusCode: 200, @@ -249,8 +282,9 @@ export function VoiceAdminController(app: HonoApp) { const adminService = ctx.get('adminService'); const adminUserId = ctx.get('adminUserId'); const auditLogReason = ctx.get('auditLogReason'); + const {region_id, server_id} = ctx.req.valid('param'); return ctx.json( - await adminService.voiceService.deleteVoiceServer(ctx.req.valid('json'), adminUserId, auditLogReason), + await adminService.voiceService.deleteVoiceServer({region_id, server_id}, adminUserId, auditLogReason), ); }, ); diff --git a/fluxer_api/src/api/admin/repositories/AdminApiKeyRepository.ts b/fluxer_api/src/api/admin/repositories/AdminApiKeyRepository.ts index f6a69ffef..bdab0e892 100644 --- a/fluxer_api/src/api/admin/repositories/AdminApiKeyRepository.ts +++ b/fluxer_api/src/api/admin/repositories/AdminApiKeyRepository.ts @@ -7,7 +7,7 @@ import type {AdminApiKeyRow} from '../../database/types/AdminAuthTypes'; import {AdminApiKey} from '../../models/AdminApiKey'; import {AdminApiKeys, AdminApiKeysByCreator} from '../../Tables'; import {hashPassword} from '../../utils/PasswordUtils'; -import type {CreateAdminApiKeyData, IAdminApiKeyRepository} from './IAdminApiKeyRepository'; +import type {CreateAdminApiKeyData, IAdminApiKeyRepository, UpdateAdminApiKeyData} from './IAdminApiKeyRepository'; function computeTtlSeconds(expiresAt: Date): number { const diffSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000); @@ -79,6 +79,38 @@ export class AdminApiKeyRepository implements IAdminApiKeyRepository { return new AdminApiKey(row); } + async update(apiKey: AdminApiKey, data: UpdateAdminApiKeyData): Promise { + const row = apiKey.toRow(); + const updatedRow: AdminApiKeyRow = { + ...row, + name: data.name ?? row.name, + acls: data.acls ?? row.acls, + }; + const patch = { + name: Db.set(updatedRow.name), + acls: Db.set(updatedRow.acls ?? new Set()), + }; + const batch = new BatchBuilder(); + if (apiKey.expiresAt) { + const ttlSeconds = computeTtlSeconds(apiKey.expiresAt); + batch.addPrepared(AdminApiKeys.patchByPkWithTtl({key_id: apiKey.keyId}, patch, ttlSeconds)); + batch.addPrepared( + AdminApiKeysByCreator.patchByPkWithTtl( + {created_by_user_id: apiKey.createdById, key_id: apiKey.keyId}, + patch, + ttlSeconds, + ), + ); + } else { + batch.addPrepared(AdminApiKeys.patchByPk({key_id: apiKey.keyId}, patch)); + batch.addPrepared( + AdminApiKeysByCreator.patchByPk({created_by_user_id: apiKey.createdById, key_id: apiKey.keyId}, patch), + ); + } + await batch.execute(); + return new AdminApiKey(updatedRow); + } + async listByCreator(createdBy: UserID): Promise> { const query = AdminApiKeysByCreator.select({ where: AdminApiKeysByCreator.where.eq('created_by_user_id'), diff --git a/fluxer_api/src/api/admin/repositories/AdminArchiveRepository.ts b/fluxer_api/src/api/admin/repositories/AdminArchiveRepository.ts index 5c5a7440d..a6efa244e 100644 --- a/fluxer_api/src/api/admin/repositories/AdminArchiveRepository.ts +++ b/fluxer_api/src/api/admin/repositories/AdminArchiveRepository.ts @@ -86,6 +86,8 @@ export class AdminArchiveRepository { }, { started_at: Db.set(new Date()), + failed_at: Db.clear(), + error_message: Db.clear(), progress_percent: Db.set(0), progress_step: Db.set(progressStep), }, @@ -101,6 +103,8 @@ export class AdminArchiveRepository { }, { started_at: Db.set(new Date()), + failed_at: Db.clear(), + error_message: Db.clear(), progress_percent: Db.set(0), progress_step: Db.set(progressStep), }, @@ -116,6 +120,8 @@ export class AdminArchiveRepository { }, { started_at: Db.set(new Date()), + failed_at: Db.clear(), + error_message: Db.clear(), progress_percent: Db.set(0), progress_step: Db.set(progressStep), }, diff --git a/fluxer_api/src/api/admin/repositories/IAdminApiKeyRepository.ts b/fluxer_api/src/api/admin/repositories/IAdminApiKeyRepository.ts index 1ba849fa2..ea61a6143 100644 --- a/fluxer_api/src/api/admin/repositories/IAdminApiKeyRepository.ts +++ b/fluxer_api/src/api/admin/repositories/IAdminApiKeyRepository.ts @@ -9,9 +9,15 @@ export interface CreateAdminApiKeyData { acls: Set; } +export interface UpdateAdminApiKeyData { + name?: string; + acls?: Set; +} + export interface IAdminApiKeyRepository { create(data: CreateAdminApiKeyData, createdBy: UserID, keyId: bigint, rawKey: string): Promise; findById(keyId: bigint): Promise; + update(apiKey: AdminApiKey, data: UpdateAdminApiKeyData): Promise; listByCreator(createdBy: UserID): Promise>; updateLastUsed(keyId: bigint, expiresAt: Date | null): Promise; revoke(keyId: bigint, createdBy: UserID): Promise; diff --git a/fluxer_api/src/api/admin/services/AdminApiKeyService.ts b/fluxer_api/src/api/admin/services/AdminApiKeyService.ts index da1bef895..2a512f6e4 100644 --- a/fluxer_api/src/api/admin/services/AdminApiKeyService.ts +++ b/fluxer_api/src/api/admin/services/AdminApiKeyService.ts @@ -4,10 +4,11 @@ import {randomInt} from 'node:crypto'; import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; import {AdminApiKeyNotFoundError} from '@fluxer/errors/src/domains/admin/AdminApiKeyNotFoundError'; import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError'; -import type {CreateAdminApiKeyRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas'; +import type {CreateAdminApiKeyRequest, UpdateAdminApiKeyRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas'; import {ms} from 'itty-time'; import type {UserID} from '../../BrandedTypes'; import type {ISnowflakeService} from '../../infrastructure/ISnowflakeService'; +import type {AdminApiKey} from '../../models/AdminApiKey'; import {verifyPassword} from '../../utils/PasswordUtils'; import type {IAdminApiKeyRepository} from '../repositories/IAdminApiKeyRepository'; @@ -26,6 +27,16 @@ interface CreateApiKeyResult { }; } +export interface AdminApiKeyView { + keyId: string; + name: string; + createdAt: Date; + lastUsedAt: Date | null; + expiresAt: Date | null; + createdById: UserID; + acls: Set; +} + export class AdminApiKeyService { constructor( private readonly adminApiKeyRepository: IAdminApiKeyRepository, @@ -65,12 +76,7 @@ export class AdminApiKeyService { const keyId = await this.snowflakeService.generate(); const rawKey = this.generateRawKey(keyId); const expiresAt = request.expires_in_days ? new Date(Date.now() + request.expires_in_days * ms('1 day')) : null; - if (creatorAcls) { - const invalidACLs = request.acls.filter((acl) => !creatorAcls.has(acl) && !creatorAcls.has(AdminACLs.WILDCARD)); - if (invalidACLs.length > 0) { - throw new MissingACLError(invalidACLs[0]); - } - } + this.assertGrantableAcls(request.acls, creatorAcls); const aclsSet = new Set(request.acls); const apiKey = await this.adminApiKeyRepository.create( { @@ -120,41 +126,68 @@ export class AdminApiKeyService { }; } - async listKeys(createdBy: UserID): Promise< - Array<{ - keyId: string; - name: string; - createdAt: Date; - lastUsedAt: Date | null; - expiresAt: Date | null; - createdById: UserID; - acls: Set; - }> - > { + async listKeys(createdBy: UserID): Promise> { const apiKeys = await this.adminApiKeyRepository.listByCreator(createdBy); - return apiKeys.map((key) => ({ - keyId: key.keyId.toString(), - name: key.name, - createdAt: key.createdAt, - lastUsedAt: key.lastUsedAt, - expiresAt: key.expiresAt, - createdById: key.createdById, - acls: key.acls ?? new Set(), - })); + return apiKeys.map((key) => this.toView(key)); } - async revokeKey(keyId: string, createdBy: UserID): Promise { - if (!/^\d+$/.test(keyId)) { - throw new AdminApiKeyNotFoundError(); + async getKey(keyId: bigint, createdBy: UserID): Promise { + const apiKey = await this.findOwnedKey(keyId, createdBy); + return this.toView(apiKey); + } + + async updateKey( + keyId: bigint, + createdBy: UserID, + request: UpdateAdminApiKeyRequest, + creatorAcls?: Set, + ): Promise { + const apiKey = await this.findOwnedKey(keyId, createdBy); + if (request.acls) { + this.assertGrantableAcls(request.acls, creatorAcls); } - const keyIdBigInt = BigInt(keyId); - const apiKey = await this.adminApiKeyRepository.findById(keyIdBigInt); + const updated = await this.adminApiKeyRepository.update(apiKey, { + name: request.name, + acls: request.acls ? new Set(request.acls) : undefined, + }); + return this.toView(updated); + } + + async revokeKey(keyId: bigint, createdBy: UserID): Promise { + const apiKey = await this.findOwnedKey(keyId, createdBy); + await this.adminApiKeyRepository.revoke(apiKey.keyId, createdBy); + } + + private async findOwnedKey(keyId: bigint, createdBy: UserID): Promise { + const apiKey = await this.adminApiKeyRepository.findById(keyId); if (!apiKey) { throw new AdminApiKeyNotFoundError(); } if (apiKey.createdById !== createdBy) { throw new AdminApiKeyNotFoundError(); } - await this.adminApiKeyRepository.revoke(keyIdBigInt, createdBy); + return apiKey; + } + + private assertGrantableAcls(acls: ReadonlyArray, creatorAcls?: Set): void { + if (!creatorAcls) { + return; + } + const invalidACLs = acls.filter((acl) => !creatorAcls.has(acl) && !creatorAcls.has(AdminACLs.WILDCARD)); + if (invalidACLs.length > 0) { + throw new MissingACLError(invalidACLs[0]); + } + } + + private toView(apiKey: AdminApiKey): AdminApiKeyView { + return { + keyId: apiKey.keyId.toString(), + name: apiKey.name, + createdAt: apiKey.createdAt, + lastUsedAt: apiKey.lastUsedAt, + expiresAt: apiKey.expiresAt, + createdById: apiKey.createdById, + acls: apiKey.acls ?? new Set(), + }; } } diff --git a/fluxer_api/src/api/admin/services/AdminApplicationService.ts b/fluxer_api/src/api/admin/services/AdminApplicationService.ts index 1bb9cb03f..7387eee3d 100644 --- a/fluxer_api/src/api/admin/services/AdminApplicationService.ts +++ b/fluxer_api/src/api/admin/services/AdminApplicationService.ts @@ -5,16 +5,12 @@ import {UnknownApplicationError} from '@fluxer/errors/src/domains/oauth/UnknownA import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError'; import type { ApplicationAdminResponse, - ListGuildApplicationsRequest, - ListGuildApplicationsResponse, - ListUserApplicationsRequest, - ListUserApplicationsResponse, - LookupApplicationRequest, + ListApplicationsResponse, LookupApplicationResponse, TransferApplicationOwnershipRequest, } from '@fluxer/schema/src/domains/admin/AdminApplicationSchemas'; import type {ApiContext} from '../../ApiContext'; -import {createApplicationID, createGuildID, createUserID, type UserID} from '../../BrandedTypes'; +import {type ApplicationID, createApplicationID, createUserID, type GuildID, type UserID} from '../../BrandedTypes'; import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate'; import type {Application} from '../../models/Application'; import type {IApplicationRepository} from '../../oauth/repositories/IApplicationRepository'; @@ -36,9 +32,8 @@ interface UserDisplay { export class AdminApplicationService { constructor(private readonly deps: AdminApplicationServiceDeps) {} - async lookupApplication(data: LookupApplicationRequest): Promise { + async lookupApplication(applicationId: ApplicationID): Promise { const {applicationRepository} = this.deps; - const applicationId = createApplicationID(data.application_id); const application = await applicationRepository.getApplication(applicationId); if (!application) { return {application: null}; @@ -49,10 +44,9 @@ export class AdminApplicationService { }; } - async listUserApplications(data: ListUserApplicationsRequest): Promise { + async listUserApplications(ownerUserId: UserID): Promise { const {applicationRepository} = this.deps; const {users: userRepository} = this.deps.apiContext.services; - const ownerUserId = createUserID(data.user_id); const owner = await userRepository.findUnique(ownerUserId); if (!owner) { throw new UnknownUserError(); @@ -75,9 +69,8 @@ export class AdminApplicationService { }; } - async listGuildApplications(data: ListGuildApplicationsRequest): Promise { + async listGuildApplications(guildId: GuildID): Promise { const {applicationRepository, guildRepository} = this.deps; - const guildId = createGuildID(data.guild_id); const guild = await guildRepository.findUnique(guildId); if (!guild) { throw new UnknownGuildError(); @@ -100,13 +93,13 @@ export class AdminApplicationService { } async transferApplicationOwnership( + applicationId: ApplicationID, data: TransferApplicationOwnershipRequest, adminUserId: UserID, auditLogReason: string | null, ) { const {applicationRepository, auditService} = this.deps; const {users: userRepository} = this.deps.apiContext.services; - const applicationId = createApplicationID(data.application_id); const application = await applicationRepository.getApplication(applicationId); if (!application) { throw new UnknownApplicationError(); diff --git a/fluxer_api/src/api/admin/services/AdminArchiveService.ts b/fluxer_api/src/api/admin/services/AdminArchiveService.ts index 9e55ac0fc..a3730a48b 100644 --- a/fluxer_api/src/api/admin/services/AdminArchiveService.ts +++ b/fluxer_api/src/api/admin/services/AdminArchiveService.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError'; import {HarvestExpiredError} from '@fluxer/errors/src/domains/moderation/HarvestExpiredError'; import {HarvestFailedError} from '@fluxer/errors/src/domains/moderation/HarvestFailedError'; @@ -130,7 +131,10 @@ export class AdminArchiveService { async listArchives(params: ListArchivesParams): Promise> { const {subjectType = 'all', subjectId, requestedBy, limit = 50, includeExpired = false} = params; if (subjectId !== undefined && subjectType === 'all') { - throw new Error('subject_type must be specified when subject_id is provided'); + throw InputValidationError.create( + 'subject_type', + 'subject_type must name user or guild when subject_id is supplied', + ); } if (subjectId !== undefined) { const archives = await this.adminArchiveRepository.listBySubject( @@ -175,12 +179,12 @@ export class AdminArchiveService { if (!archive) { throw new UnknownHarvestError(); } - if (!archive.completedAt || !archive.storageKey) { - throw new HarvestNotReadyError(); - } if (archive.failedAt) { throw new HarvestFailedError(); } + if (!archive.completedAt || !archive.storageKey) { + throw new HarvestNotReadyError(); + } if (archive.expiresAt && archive.expiresAt < new Date()) { throw new HarvestExpiredError(); } diff --git a/fluxer_api/src/api/admin/services/AdminAssetPurgeService.ts b/fluxer_api/src/api/admin/services/AdminAssetPurgeService.ts index 00d76192d..2d9e7dbae 100644 --- a/fluxer_api/src/api/admin/services/AdminAssetPurgeService.ts +++ b/fluxer_api/src/api/admin/services/AdminAssetPurgeService.ts @@ -29,11 +29,12 @@ export class AdminAssetPurgeService { } async purgeGuildAssets(args: { + guildId: GuildID; ids: Array; adminUserId: UserID; auditLogReason: string | null; }): Promise { - const {ids, adminUserId, auditLogReason} = args; + const {guildId, ids, adminUserId, auditLogReason} = args; const processed: Array = []; const errors: Array = []; const seen = new Set(); @@ -51,7 +52,11 @@ export class AdminAssetPurgeService { continue; } try { - const result = await this.processAssetId(numericId, trimmedId, adminUserId, auditLogReason); + const result = await this.processAssetId(guildId, numericId, trimmedId, adminUserId, auditLogReason); + if (result === null) { + errors.push({id: trimmedId, error: 'Asset belongs to another guild'}); + continue; + } processed.push(result); } catch (error) { const message = error instanceof Error && error.message !== '' ? error.message : 'Failed to purge asset'; @@ -62,15 +67,19 @@ export class AdminAssetPurgeService { } private async processAssetId( + guildId: GuildID, numericId: bigint, idString: string, adminUserId: UserID, auditLogReason: string | null, - ): Promise { + ): Promise { const {guildRepository} = this.deps; const emojiId = createEmojiID(numericId); const emoji = await guildRepository.getEmojiById(emojiId); if (emoji) { + if (emoji.guildId !== guildId) { + return null; + } await guildRepository.deleteEmoji(emoji.guildId, emojiId); await this.dispatchGuildEmojisUpdate(emoji.guildId); await this.assetPurger.purgeEmoji(idString); @@ -97,6 +106,9 @@ export class AdminAssetPurgeService { const stickerId = createStickerID(numericId); const sticker = await guildRepository.getStickerById(stickerId); if (sticker) { + if (sticker.guildId !== guildId) { + return null; + } await guildRepository.deleteSticker(sticker.guildId, stickerId); await this.dispatchGuildStickersUpdate(sticker.guildId); await this.assetPurger.purgeSticker(idString); diff --git a/fluxer_api/src/api/admin/services/AdminAuditService.ts b/fluxer_api/src/api/admin/services/AdminAuditService.ts index 88b5221b0..e354a552c 100644 --- a/fluxer_api/src/api/admin/services/AdminAuditService.ts +++ b/fluxer_api/src/api/admin/services/AdminAuditService.ts @@ -55,6 +55,15 @@ export class AdminAuditService { } } + async getAuditLog(logId: bigint): Promise { + const log = await this.adminRepository.getAuditLog(logId); + if (!log) { + return null; + } + const [response] = await this.toResponses([log]); + return response; + } + async listAuditLogs(data: { admin_user_id?: bigint; target_type?: string; diff --git a/fluxer_api/src/api/admin/services/AdminBanManagementService.ts b/fluxer_api/src/api/admin/services/AdminBanManagementService.ts index 5a137978c..50b040425 100644 --- a/fluxer_api/src/api/admin/services/AdminBanManagementService.ts +++ b/fluxer_api/src/api/admin/services/AdminBanManagementService.ts @@ -1,9 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; +import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError'; import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError'; +import type {AdminBlocklistListType} from '@fluxer/schema/src/domains/admin/AdminBlocklistSchemas'; import type {IpInfoLookupResult, IpInfoService} from '@pkgs/geoip/src/IpInfoService'; import type {ApiContext} from '../../ApiContext'; import {createUserID, type UserID} from '../../BrandedTypes'; @@ -47,6 +50,59 @@ interface AdminBanManagementServiceDeps { suspiciousIpRepository: ISuspiciousIpRepository; } +interface AdminBlocklistEntry { + list_type: AdminBlocklistListType; + value: string; + scope: string | null; + category: string | null; + severity: number | null; + source_url: string | null; + notes: string | null; + content_type: string | null; + match_subdomains: boolean | null; + reason: string | null; + expires_at: string | null; + created_at: string | null; + created_by_user_id: string | null; +} + +interface AdminBlocklistEntryPage { + items: Array; + has_more: boolean; + next_after: string | null; +} + +function createBlocklistEntry( + listType: AdminBlocklistListType, + value: string, + overrides: Partial> = {}, +): AdminBlocklistEntry { + return { + list_type: listType, + value, + scope: null, + category: null, + severity: null, + source_url: null, + notes: null, + content_type: null, + match_subdomains: null, + reason: null, + expires_at: null, + created_at: null, + created_by_user_id: null, + ...overrides, + }; +} + +function toIsoString(value: Date | null | undefined): string | null { + return value ? value.toISOString() : null; +} + +function toSnowflakeString(value: bigint | null | undefined): string | null { + return value == null ? null : value.toString(); +} + interface AdminBlocklistAuditParams { adminUserId: UserID; auditLogReason: string | null; @@ -400,7 +456,7 @@ export class AdminBanManagementService { const {adminRepository} = this.deps; const {cache: cacheService} = this.deps.apiContext.services; const canonical = canonicalizeUrl(data.url); - if (!canonical) throw new Error('URL could not be canonicalized'); + if (!canonical) throw InputValidationError.fromCode('url', ValidationErrorCodes.INVALID_URL_FORMAT); await adminRepository.banUrl({ url_canonical: canonical, category: data.category ?? ContentBlocklistCategory.MANUAL, @@ -434,7 +490,7 @@ export class AdminBanManagementService { const {adminRepository} = this.deps; const {cache: cacheService} = this.deps.apiContext.services; const canonical = canonicalizeUrl(data.url); - if (!canonical) throw new Error('URL could not be canonicalized'); + if (!canonical) throw InputValidationError.fromCode('url', ValidationErrorCodes.INVALID_URL_FORMAT); await adminRepository.unbanUrl(canonical); urlBlocklistCache.removeExactUrl(canonical); await cacheService.publish(BANNED_URLS_REFRESH_CHANNEL, 'refresh'); @@ -769,6 +825,124 @@ export class AdminBanManagementService { return {banned: false}; } + async listBlocklistEntries(params: { + listType: AdminBlocklistListType; + limit: number; + after: string | null; + scope: BannedProfileSubstringScope | null; + }): Promise { + const entries = await this.loadBlocklistEntries(params.listType, params.scope); + entries.sort((left, right) => (left.value < right.value ? -1 : left.value > right.value ? 1 : 0)); + const after = params.after; + const remaining = after == null ? entries : entries.filter((entry) => entry.value > after); + const page = remaining.slice(0, params.limit); + const hasMore = remaining.length > page.length; + const lastEntry = page.at(-1); + return { + items: page, + has_more: hasMore, + next_after: hasMore && lastEntry ? lastEntry.value : null, + }; + } + + private async loadBlocklistEntries( + listType: AdminBlocklistListType, + scope: BannedProfileSubstringScope | null, + ): Promise> { + const {adminRepository} = this.deps; + switch (listType) { + case 'ip': { + const rows = await adminRepository.loadAllBannedIpEntries(); + return rows.map((row) => + createBlocklistEntry(listType, row.ip, { + reason: row.reason, + expires_at: toIsoString(row.expiresAt), + created_at: toIsoString(row.createdAt), + }), + ); + } + case 'email': { + const rows = await adminRepository.loadAllBannedEmails(); + return rows.map((value) => createBlocklistEntry(listType, value)); + } + case 'email-domain-suspicious': { + const rows = await adminRepository.loadAllSuspiciousEmailDomains(); + return rows.map((value) => createBlocklistEntry(listType, value)); + } + case 'phrase': { + const rows = await adminRepository.loadAllBannedPhrases(); + return rows.map((value) => createBlocklistEntry(listType, value)); + } + case 'url': { + const rows = await adminRepository.loadAllBannedUrls(); + return rows.map((row) => + createBlocklistEntry(listType, row.url_canonical, { + category: row.category, + severity: row.severity, + source_url: row.source_url, + notes: row.notes, + created_at: toIsoString(row.added_at), + created_by_user_id: toSnowflakeString(row.added_by), + }), + ); + } + case 'url-domain': { + const rows = await adminRepository.loadAllBannedUrlDomains(); + return rows.map((row) => + createBlocklistEntry(listType, row.domain, { + category: row.category, + severity: row.severity, + source_url: row.source_url, + notes: row.notes, + match_subdomains: row.match_subdomains, + created_at: toIsoString(row.added_at), + created_by_user_id: toSnowflakeString(row.added_by), + }), + ); + } + case 'file-sha': { + const rows = await adminRepository.loadAllBannedFileShas(); + return rows.map((row) => + createBlocklistEntry(listType, row.sha256_hex, { + category: row.category, + severity: row.severity, + source_url: row.source_url, + notes: row.notes, + content_type: row.content_type, + created_at: toIsoString(row.added_at), + created_by_user_id: toSnowflakeString(row.added_by), + }), + ); + } + case 'avatar-hash': { + const rows = await adminRepository.loadAllBannedAvatarHashes(); + return rows.map((row) => + createBlocklistEntry(listType, row.hash_short, { + category: row.category, + severity: row.severity, + source_url: row.source_url, + notes: row.notes, + created_at: toIsoString(row.added_at), + created_by_user_id: toSnowflakeString(row.added_by), + }), + ); + } + case 'profile-substring': { + const rows = await adminRepository.loadAllBannedProfileSubstrings(); + return rows + .filter((row) => scope == null || row.scope === scope) + .map((row) => + createBlocklistEntry(listType, row.substring, { + scope: row.scope, + notes: row.notes, + created_at: toIsoString(row.added_at), + created_by_user_id: toSnowflakeString(row.added_by), + }), + ); + } + } + } + private async createBlocklistAuditLog({ adminUserId, auditLogReason, diff --git a/fluxer_api/src/api/admin/services/AdminReportService.ts b/fluxer_api/src/api/admin/services/AdminReportService.ts index ba3b69eb0..941b36230 100644 --- a/fluxer_api/src/api/admin/services/AdminReportService.ts +++ b/fluxer_api/src/api/admin/services/AdminReportService.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError'; import type {SearchReportsRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas'; import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import {getEmailTemplate} from '@pkgs/email/src/email_i18n/EmailI18n'; @@ -200,7 +201,7 @@ export class AdminReportService { async searchReports(data: SearchReportsRequest, acls: ReadonlySet) { const reportSearchService = getReportSearchService(); if (!reportSearchService) { - throw new Error('Search is not enabled'); + throw new FeatureTemporarilyDisabledError(); } const filters: Record = {}; if (data.reporter_id !== undefined) { diff --git a/fluxer_api/src/api/admin/services/AdminSearchService.ts b/fluxer_api/src/api/admin/services/AdminSearchService.ts index 5e60a52a4..6c9340e8e 100644 --- a/fluxer_api/src/api/admin/services/AdminSearchService.ts +++ b/fluxer_api/src/api/admin/services/AdminSearchService.ts @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError'; +import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes'; import type {ApiContext} from '../../ApiContext'; import {createGuildID, createUserID, type UserID} from '../../BrandedTypes'; @@ -44,8 +46,7 @@ export class AdminSearchService { ); const guildSearchService = getGuildSearchService(); if (!guildSearchService) { - Logger.error('[AdminSearchService] searchGuilds - Search service not enabled'); - throw new Error('Search is not enabled'); + throw new FeatureTemporarilyDisabledError(); } const query = data.query?.trim() || ''; const isIdQuery = /^\d+$/.test(query); @@ -125,7 +126,7 @@ export class AdminSearchService { } const userSearchService = getUserSearchService(); if (!userSearchService) { - throw new Error('Search is not enabled'); + throw new FeatureTemporarilyDisabledError(); } const query = data.query?.trim() || ''; const isIdQuery = /^\d+$/.test(query); @@ -179,25 +180,26 @@ export class AdminSearchService { }; if (data.index_type === 'channel_messages') { if (!data.guild_id) { - throw new Error('guild_id is required for the channel_messages index type'); + throw InputValidationError.create('guild_id', 'guild_id is required for the channel_messages index type'); } payload.guild_id = data.guild_id.toString(); } if (data.index_type === 'guild_members') { if (!data.guild_id) { - throw new Error('guild_id is required for the guild_members index type'); + throw InputValidationError.create('guild_id', 'guild_id is required for the guild_members index type'); } payload.guild_id = data.guild_id.toString(); } if (data.index_type === 'favorite_memes') { if (!data.user_id) { - throw new Error('user_id is required for favorite_memes index type'); + throw InputValidationError.create('user_id', 'user_id is required for the favorite_memes index type'); } payload.user_id = data.user_id.toString(); } await workerService.addJob('refreshSearchIndex', payload, { jobKey: `refreshSearchIndex_${data.index_type}_${jobId}`, maxAttempts: 1, + requireLedger: true, }); Logger.debug({index_type: data.index_type, job_id: jobId}, 'Queued search index refresh job'); const metadata = new Map([ diff --git a/fluxer_api/src/api/admin/services/AdminVoiceService.ts b/fluxer_api/src/api/admin/services/AdminVoiceService.ts index 05bbb14af..e2de2b94e 100644 --- a/fluxer_api/src/api/admin/services/AdminVoiceService.ts +++ b/fluxer_api/src/api/admin/services/AdminVoiceService.ts @@ -226,6 +226,10 @@ export class AdminVoiceService { async createVoiceServer(data: CreateVoiceServerRequest, adminUserId: UserID, auditLogReason: string | null) { const {voiceRepository} = this.deps; + const region = await voiceRepository.getRegion(data.region_id); + if (!region) { + throw new UnknownVoiceRegionError(); + } const server = await voiceRepository.createServer({ regionId: data.region_id, serverId: data.server_id, diff --git a/fluxer_api/src/api/admin/tests/AdminApiKeyACLValidation.test.ts b/fluxer_api/src/api/admin/tests/AdminApiKeyACLValidation.test.ts index d67755013..dcd16ff17 100644 --- a/fluxer_api/src/api/admin/tests/AdminApiKeyACLValidation.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminApiKeyACLValidation.test.ts @@ -113,6 +113,32 @@ describe('Admin API Key ACL Validation', () => { .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); + test('rejects an ACL outside the registry with 400', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['*']); + await createBuilder(harness, `${admin.token}`) + .post('/admin/api-keys') + .body({ + name: 'Typo Key', + acls: ['user:veiw'], + }) + .expect(HTTP_STATUS.BAD_REQUEST) + .executeWithResponse(); + const keys = await listAdminApiKeys(harness, admin.token); + expect(keys).toHaveLength(0); + }); + test('update rejects an ACL outside the registry with 400 and leaves the stored set unchanged', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['*']); + const apiKey = await createAdminApiKey(harness, admin, 'Typo Update Key', ['audit_log:view'], null); + await createBuilder(harness, `${admin.token}`) + .patch(`/admin/api-keys/${apiKey.keyId}`) + .body({acls: ['user:veiw']}) + .expect(HTTP_STATUS.BAD_REQUEST) + .executeWithResponse(); + const keys = await listAdminApiKeys(harness, admin.token); + expect(keys[0]!.acls).toEqual(['audit_log:view']); + }); test('empty ACL list is valid', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']); diff --git a/fluxer_api/src/api/admin/tests/AdminApiKeyAuthentication.test.ts b/fluxer_api/src/api/admin/tests/AdminApiKeyAuthentication.test.ts index bd97c78d8..b46950562 100644 --- a/fluxer_api/src/api/admin/tests/AdminApiKeyAuthentication.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminApiKeyAuthentication.test.ts @@ -83,21 +83,12 @@ describe('Admin API Key Authentication', () => { 'guild:lookup', ]); const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Auth Test Key'); - await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) - .expect(HTTP_STATUS.OK) - .execute(); + await createBuilder(harness, apiKey.token).get(`/admin/users/${admin.userId}`).expect(HTTP_STATUS.OK).execute(); }); test('invalid key is rejected', async () => { await createTestAccount(harness); await createBuilder(harness, 'Admin invalid_key_12345') - .post('/admin/users/lookup') - .body({ - user_ids: ['123456789'], - }) + .get('/admin/users/123456789') .expect(HTTP_STATUS.UNAUTHORIZED) .execute(); }); @@ -113,10 +104,7 @@ describe('Admin API Key Authentication', () => { const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Revoke Test Key'); await revokeAdminApiKey(harness, admin.token, apiKey.keyId); await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.UNAUTHORIZED) .execute(); }); @@ -131,10 +119,7 @@ describe('Admin API Key Authentication', () => { ]); const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Prefix Test Key'); await createBuilder(harness, `Bearer ${apiKey.key}`) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.UNAUTHORIZED) .execute(); }); @@ -149,10 +134,7 @@ describe('Admin API Key Authentication', () => { ]); const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'No Prefix Test Key'); await createBuilder(harness, apiKey.key) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.UNAUTHORIZED) .execute(); }); @@ -167,22 +149,13 @@ describe('Admin API Key Authentication', () => { ]); const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Case Test Key'); await createBuilder(harness, `admin ${apiKey.key}`) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.UNAUTHORIZED) .execute(); }); test('empty key is rejected', async () => { await createTestAccount(harness); - await createBuilder(harness, 'Admin ') - .post('/admin/users/lookup') - .body({ - user_ids: ['123456789'], - }) - .expect(HTTP_STATUS.UNAUTHORIZED) - .execute(); + await createBuilder(harness, 'Admin ').get('/admin/users/123456789').expect(HTTP_STATUS.UNAUTHORIZED).execute(); }); test('cannot authenticate to user endpoints', async () => { const admin = await createTestAccount(harness); @@ -221,12 +194,7 @@ describe('Admin API Key Authentication', () => { const keys = await listAdminApiKeys(harness, admin.token); expect(keys).toHaveLength(1); expect(keys[0]!.last_used_at).toBeNull(); - await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) - .execute(); + await createBuilder(harness, apiKey.token).get(`/admin/users/${admin.userId}`).execute(); const keysAfter = await listAdminApiKeys(harness, admin.token); expect(keysAfter).toHaveLength(1); expect(keysAfter[0]!.last_used_at).not.toBeNull(); @@ -238,13 +206,7 @@ describe('Admin API Key Authentication', () => { const key2 = await createAdminApiKey(harness, admin, 'Key 2', ['admin:authenticate', 'user:lookup'], null); const key3 = await createAdminApiKey(harness, admin, 'Key 3', ['admin:authenticate', 'user:lookup'], null); for (const key of [key1, key2, key3]) { - await createBuilder(harness, key.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) - .expect(HTTP_STATUS.OK) - .execute(); + await createBuilder(harness, key.token).get(`/admin/users/${admin.userId}`).expect(HTTP_STATUS.OK).execute(); } const keys = await listAdminApiKeys(harness, admin.token); expect(keys).toHaveLength(3); @@ -255,12 +217,6 @@ describe('Admin API Key Authentication', () => { await setUserACLs(harness, admin1, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']); await setUserACLs(harness, admin2, ['admin:authenticate', 'user:lookup']); const key1 = await createAdminApiKey(harness, admin1, 'Admin 1 Key', ['admin:authenticate', 'user:lookup'], null); - await createBuilder(harness, key1.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin2.userId], - }) - .expect(HTTP_STATUS.OK) - .execute(); + await createBuilder(harness, key1.token).get(`/admin/users/${admin2.userId}`).expect(HTTP_STATUS.OK).execute(); }); }); diff --git a/fluxer_api/src/api/admin/tests/AdminApiKeyAuthorization.test.ts b/fluxer_api/src/api/admin/tests/AdminApiKeyAuthorization.test.ts index b56377f34..3f96f65bb 100644 --- a/fluxer_api/src/api/admin/tests/AdminApiKeyAuthorization.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminApiKeyAuthorization.test.ts @@ -16,20 +16,8 @@ describe('Admin API Key Authorization', () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view', 'user:lookup']); const apiKey = await createAdminApiKey(harness, admin, 'Test Key', ['audit_log:view', 'user:lookup'], null); - await createBuilder(harness, apiKey.token) - .post('/admin/audit-logs') - .body({ - limit: 10, - }) - .expect(HTTP_STATUS.OK) - .execute(); - await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) - .expect(HTTP_STATUS.OK) - .execute(); + await createBuilder(harness, apiKey.token).get('/admin/audit-logs?limit=10').expect(HTTP_STATUS.OK).execute(); + await createBuilder(harness, apiKey.token).get(`/admin/users/${admin.userId}`).expect(HTTP_STATUS.OK).execute(); }); test('key fails if owner loses admin access', async () => { const admin = await createTestAccount(harness); @@ -37,10 +25,7 @@ describe('Admin API Key Authorization', () => { const apiKey = await createAdminApiKey(harness, admin, 'Owner Check', ['user:lookup'], null); await setUserACLs(harness, admin, ['admin_api_key:manage', 'user:lookup']); await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS') .execute(); }); @@ -50,10 +35,7 @@ describe('Admin API Key Authorization', () => { const apiKey = await createAdminApiKey(harness, admin, 'Owner ACL Check', ['user:lookup'], null); await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']); await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL') .execute(); }); @@ -62,10 +44,7 @@ describe('Admin API Key Authorization', () => { await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view', 'user:lookup']); const apiKey = await createAdminApiKey(harness, admin, 'Limited Key', ['audit_log:view'], null); await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -73,13 +52,7 @@ describe('Admin API Key Authorization', () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['*']); const apiKey = await createAdminApiKey(harness, admin, 'Wildcard Key', ['*'], null); - await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) - .expect(HTTP_STATUS.OK) - .execute(); + await createBuilder(harness, apiKey.token).get(`/admin/users/${admin.userId}`).expect(HTTP_STATUS.OK).execute(); }); test('multiple keys with different ACLs work independently', async () => { const admin = await createTestAccount(harness); @@ -93,35 +66,17 @@ describe('Admin API Key Authorization', () => { const key1 = await createAdminApiKey(harness, admin, 'Audit Log Key', ['audit_log:view'], null); const key2 = await createAdminApiKey(harness, admin, 'Users Key', ['user:lookup'], null); const key3 = await createAdminApiKey(harness, admin, 'Guilds Key', ['guild:lookup'], null); - await createBuilder(harness, key1.token) - .post('/admin/audit-logs') - .body({ - limit: 10, - }) - .expect(HTTP_STATUS.OK) - .execute(); - await createBuilder(harness, key2.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) - .expect(HTTP_STATUS.OK) - .execute(); + await createBuilder(harness, key1.token).get('/admin/audit-logs?limit=10').expect(HTTP_STATUS.OK).execute(); + await createBuilder(harness, key2.token).get(`/admin/users/${admin.userId}`).expect(HTTP_STATUS.OK).execute(); const guildJson = await createBuilder<{ guild: unknown; }>(harness, key3.token) - .post('/admin/guilds/lookup') - .body({ - guild_id: '123', - }) + .get('/admin/guilds/123') .expect(HTTP_STATUS.OK) .execute(); expect(guildJson.guild).toBeNull(); await createBuilder(harness, key1.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL') .execute(); }); @@ -137,7 +92,7 @@ describe('Admin API Key Authorization', () => { const apiKey = await createAdminApiKey(harness, admin, 'List Test No ACL', [], null); await createBuilder(harness, apiKey.token).get('/admin/api-keys').expect(HTTP_STATUS.FORBIDDEN).execute(); }); - test('DELETE /admin/api-keys/:keyId requires admin_api_key:manage', async () => { + test('DELETE /admin/api-keys/:key_id requires admin_api_key:manage', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']); const keyToDelete = await createAdminApiKey(harness, admin, 'To Delete', [], null); @@ -148,7 +103,7 @@ describe('Admin API Key Authorization', () => { .expect(HTTP_STATUS.OK) .execute(); }); - test('DELETE /admin/api-keys/:keyId fails without admin_api_key:manage', async () => { + test('DELETE /admin/api-keys/:key_id fails without admin_api_key:manage', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']); const keyToDelete = await createAdminApiKey(harness, admin, 'To Delete 2', [], null); diff --git a/fluxer_api/src/api/admin/tests/AdminApiKeyLifecycle.test.ts b/fluxer_api/src/api/admin/tests/AdminApiKeyLifecycle.test.ts index 075b45ea9..c0ad6ac21 100644 --- a/fluxer_api/src/api/admin/tests/AdminApiKeyLifecycle.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminApiKeyLifecycle.test.ts @@ -235,6 +235,64 @@ describe('Admin API Key Lifecycle', () => { expect(keys).toHaveLength(1); expect('key' in keys[0]!).toBe(false); }); + test('get API key by id', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']); + const apiKey = await createAdminApiKey(harness, admin, 'Readable Key', ['audit_log:view'], null); + const key = await createBuilder>(harness, `${admin.token}`) + .get(`/admin/api-keys/${apiKey.keyId}`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(key.key_id).toBe(apiKey.keyId); + expect(key.name).toBe('Readable Key'); + expect(key.acls).toEqual(['audit_log:view']); + expect('key' in key).toBe(false); + }); + test('get non-existent API key', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']); + await createBuilder(harness, `${admin.token}`) + .get('/admin/api-keys/999999999999999999') + .expect(HTTP_STATUS.NOT_FOUND) + .executeWithResponse(); + }); + test('update API key name and acls', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view', 'guild:lookup']); + const apiKey = await createAdminApiKey(harness, admin, 'Old Name', ['audit_log:view'], null); + const updated = await createBuilder>(harness, `${admin.token}`) + .patch(`/admin/api-keys/${apiKey.keyId}`) + .body({name: 'New Name', acls: ['guild:lookup']}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(updated.name).toBe('New Name'); + expect(updated.acls).toEqual(['guild:lookup']); + const keys = await listAdminApiKeys(harness, admin.token); + expect(keys[0]!.name).toBe('New Name'); + expect(keys[0]!.acls).toEqual(['guild:lookup']); + }); + test('update API key leaves omitted fields unchanged', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']); + const apiKey = await createAdminApiKey(harness, admin, 'Stable Name', ['audit_log:view'], null); + const updated = await createBuilder>(harness, `${admin.token}`) + .patch(`/admin/api-keys/${apiKey.keyId}`) + .body({name: 'Renamed'}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(updated.name).toBe('Renamed'); + expect(updated.acls).toEqual(['audit_log:view']); + }); + test('update API key cannot grant acls the admin does not hold', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']); + const apiKey = await createAdminApiKey(harness, admin, 'Escalation Key', ['audit_log:view'], null); + await createBuilder(harness, `${admin.token}`) + .patch(`/admin/api-keys/${apiKey.keyId}`) + .body({acls: ['guild:delete']}) + .expect(HTTP_STATUS.FORBIDDEN) + .executeWithResponse(); + }); test('revoke API key', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, [ @@ -255,7 +313,7 @@ describe('Admin API Key Lifecycle', () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']); await createBuilder(harness, `${admin.token}`) - .delete('/admin/api-keys/nonexistent-key-id') + .delete('/admin/api-keys/999999999999999999') .body(null) .expect(HTTP_STATUS.NOT_FOUND) .executeWithResponse(); @@ -265,18 +323,12 @@ describe('Admin API Key Lifecycle', () => { await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']); const apiKey = await createAdminApiKey(harness, admin, 'Key to Test', ['user:lookup'], null); await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.OK) .executeWithResponse(); await revokeAdminApiKey(harness, admin.token, apiKey.keyId); await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.UNAUTHORIZED) .executeWithResponse(); }); diff --git a/fluxer_api/src/api/admin/tests/AdminApiKeyManagement.test.ts b/fluxer_api/src/api/admin/tests/AdminApiKeyManagement.test.ts index c80b496d6..c033835b4 100644 --- a/fluxer_api/src/api/admin/tests/AdminApiKeyManagement.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminApiKeyManagement.test.ts @@ -12,6 +12,20 @@ import { revokeAdminApiKey, } from './AdminTestUtils'; +interface ValidationErrorResponse { + errors: Array<{ + path: string; + code: string; + message: string; + }>; +} + +function expectInvalidKeyIdFormat(json: ValidationErrorResponse): void { + const keyIdError = json.errors.find((error) => error.path === 'key_id'); + expect(keyIdError).toBeDefined(); + expect(keyIdError?.code).toBe('INVALID_SNOWFLAKE_FORMAT'); +} + describe('Admin API Key Management', () => { let harness: ApiTestHarness; beforeEach(async () => { @@ -110,21 +124,12 @@ describe('Admin API Key Management', () => { 'guild:lookup', ]); const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Auth Test Key'); - await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) - .expect(HTTP_STATUS.OK) - .execute(); + await createBuilder(harness, apiKey.token).get(`/admin/users/${admin.userId}`).expect(HTTP_STATUS.OK).execute(); }); test('invalid API key is rejected', async () => { await createTestAccount(harness); await createBuilder(harness, 'Admin invalid_key_12345') - .post('/admin/users/lookup') - .body({ - user_ids: ['123456789'], - }) + .get('/admin/users/123456789') .expect(HTTP_STATUS.UNAUTHORIZED) .execute(); }); @@ -139,10 +144,7 @@ describe('Admin API Key Management', () => { ]); const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Prefix Test Key'); await createBuilder(harness, `Bearer ${apiKey.key}`) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.UNAUTHORIZED) .execute(); }); @@ -157,10 +159,7 @@ describe('Admin API Key Management', () => { ]); const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Case Test Key'); await createBuilder(harness, `admin ${apiKey.key}`) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.UNAUTHORIZED) .execute(); }); @@ -189,12 +188,7 @@ describe('Admin API Key Management', () => { const keysBefore = await listAdminApiKeys(harness, admin.token); expect(keysBefore).toHaveLength(1); expect(keysBefore[0]!.last_used_at).toBeNull(); - await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) - .execute(); + await createBuilder(harness, apiKey.token).get(`/admin/users/${admin.userId}`).execute(); const keysAfter = await listAdminApiKeys(harness, admin.token); expect(keysAfter).toHaveLength(1); expect(keysAfter[0]!.last_used_at).not.toBeNull(); @@ -210,13 +204,7 @@ describe('Admin API Key Management', () => { 'user:lookup', ]); const apiKey = await createAdminApiKey(harness, admin, 'Test Key', ['audit_log:view', 'user:lookup'], null); - await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) - .expect(HTTP_STATUS.OK) - .execute(); + await createBuilder(harness, apiKey.token).get(`/admin/users/${admin.userId}`).expect(HTTP_STATUS.OK).execute(); }); test('key cannot access endpoints without required ACLs', async () => { const admin = await createTestAccount(harness); @@ -228,10 +216,7 @@ describe('Admin API Key Management', () => { ]); const apiKey = await createAdminApiKey(harness, admin, 'Limited Key', ['audit_log:view'], null); await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -239,13 +224,7 @@ describe('Admin API Key Management', () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['*']); const apiKey = await createAdminApiKey(harness, admin, 'Wildcard Key', ['*'], null); - await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) - .expect(HTTP_STATUS.OK) - .execute(); + await createBuilder(harness, apiKey.token).get(`/admin/users/${admin.userId}`).expect(HTTP_STATUS.OK).execute(); }); test('multiple keys with different ACLs work independently', async () => { const admin = await createTestAccount(harness); @@ -258,18 +237,9 @@ describe('Admin API Key Management', () => { ]); const auditKey = await createAdminApiKey(harness, admin, 'Audit Log Key', ['audit_log:view'], null); const userKey = await createAdminApiKey(harness, admin, 'Users Key', ['user:lookup'], null); - await createBuilder(harness, userKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) - .expect(HTTP_STATUS.OK) - .execute(); + await createBuilder(harness, userKey.token).get(`/admin/users/${admin.userId}`).expect(HTTP_STATUS.OK).execute(); await createBuilder(harness, auditKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -335,19 +305,10 @@ describe('Admin API Key Management', () => { 'guild:lookup', ]); const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Revoke Auth Test'); - await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) - .expect(HTTP_STATUS.OK) - .execute(); + await createBuilder(harness, apiKey.token).get(`/admin/users/${admin.userId}`).expect(HTTP_STATUS.OK).execute(); await revokeAdminApiKey(harness, admin.token, apiKey.keyId); await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.UNAUTHORIZED) .execute(); }); @@ -361,11 +322,40 @@ describe('Admin API Key Management', () => { 'guild:lookup', ]); await createBuilder(harness, `${admin.token}`) - .delete('/admin/api-keys/nonexistent-id') + .delete('/admin/api-keys/999999999999999999') .body(null) .expect(HTTP_STATUS.NOT_FOUND) .execute(); }); + test('get rejects a non-snowflake key id', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']); + const {json} = await createBuilder(harness, `${admin.token}`) + .get('/admin/api-keys/nonexistent-id') + .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') + .executeWithResponse(); + expectInvalidKeyIdFormat(json); + }); + test('update rejects a non-snowflake key id', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']); + const {json} = await createBuilder(harness, `${admin.token}`) + .patch('/admin/api-keys/nonexistent-id') + .body({name: 'Renamed'}) + .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') + .executeWithResponse(); + expectInvalidKeyIdFormat(json); + }); + test('revocation rejects a non-snowflake key id', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']); + const {json} = await createBuilder(harness, `${admin.token}`) + .delete('/admin/api-keys/nonexistent-id') + .body(null) + .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') + .executeWithResponse(); + expectInvalidKeyIdFormat(json); + }); test('revocation requires admin_api_key:manage ACL', async () => { const admin1 = await createTestAccount(harness); const admin2 = await createTestAccount(harness); @@ -413,11 +403,8 @@ describe('Admin API Key Management', () => { const targetUser = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'acl:set:user']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/set-acls') - .body({ - user_id: targetUser.userId, - acls: ['admin:authenticate'], - }) + .put(`/admin/users/${targetUser.userId}/acls`) + .body({acls: ['admin:authenticate']}) .expect(HTTP_STATUS.OK) .execute(); }); @@ -426,11 +413,8 @@ describe('Admin API Key Management', () => { const targetUser = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/set-acls') - .body({ - user_id: targetUser.userId, - acls: ['admin:authenticate'], - }) + .put(`/admin/users/${targetUser.userId}/acls`) + .body({acls: ['admin:authenticate']}) .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -446,11 +430,8 @@ describe('Admin API Key Management', () => { null, ); await createBuilder(harness, apiKey.token) - .post('/admin/users/set-acls') - .body({ - user_id: targetUser.userId, - acls: ['admin:authenticate'], - }) + .put(`/admin/users/${targetUser.userId}/acls`) + .body({acls: ['admin:authenticate']}) .expect(HTTP_STATUS.OK) .execute(); }); @@ -460,11 +441,8 @@ describe('Admin API Key Management', () => { await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']); const apiKey = await createAdminApiKey(harness, admin, 'No ACL Setter Key', ['user:lookup'], null); await createBuilder(harness, apiKey.token) - .post('/admin/users/set-acls') - .body({ - user_id: targetUser.userId, - acls: ['admin:authenticate'], - }) + .put(`/admin/users/${targetUser.userId}/acls`) + .body({acls: ['admin:authenticate']}) .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -472,11 +450,8 @@ describe('Admin API Key Management', () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'acl:set:user']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/set-acls') - .body({ - user_id: '999999999999999999', - acls: ['admin:authenticate'], - }) + .put('/admin/users/999999999999999999/acls') + .body({acls: ['admin:authenticate']}) .expect(HTTP_STATUS.NOT_FOUND) .execute(); }); @@ -487,12 +462,8 @@ describe('Admin API Key Management', () => { const targetUser = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/schedule-deletion') - .body({ - user_id: targetUser.userId, - reason_code: 1, - days_until_deletion: 60, - }) + .put(`/admin/users/${targetUser.userId}/deletion`) + .body({reason_code: 1, days_until_deletion: 60}) .expect(HTTP_STATUS.OK) .execute(); }); @@ -501,12 +472,8 @@ describe('Admin API Key Management', () => { const targetUser = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/schedule-deletion') - .body({ - user_id: targetUser.userId, - reason_code: 1, - days_until_deletion: 60, - }) + .put(`/admin/users/${targetUser.userId}/deletion`) + .body({reason_code: 1, days_until_deletion: 60}) .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -515,12 +482,8 @@ describe('Admin API Key Management', () => { const targetUser = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/schedule-deletion') - .body({ - user_id: targetUser.userId, - reason_code: 0, - days_until_deletion: 1, - }) + .put(`/admin/users/${targetUser.userId}/deletion`) + .body({reason_code: 1, days_until_deletion: 1}) .expect(HTTP_STATUS.OK) .executeWithResponse(); }); @@ -529,12 +492,8 @@ describe('Admin API Key Management', () => { const targetUser = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/schedule-deletion') - .body({ - user_id: targetUser.userId, - reason_code: 1, - days_until_deletion: 1, - }) + .put(`/admin/users/${targetUser.userId}/deletion`) + .body({reason_code: 2, days_until_deletion: 1}) .expect(HTTP_STATUS.OK) .executeWithResponse(); }); @@ -544,12 +503,8 @@ describe('Admin API Key Management', () => { await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:delete']); const apiKey = await createAdminApiKey(harness, admin, 'Deletion Key', ['user:delete'], null); await createBuilder(harness, apiKey.token) - .post('/admin/users/schedule-deletion') - .body({ - user_id: targetUser.userId, - reason_code: 1, - days_until_deletion: 60, - }) + .put(`/admin/users/${targetUser.userId}/deletion`) + .body({reason_code: 1, days_until_deletion: 60}) .expect(HTTP_STATUS.OK) .execute(); }); @@ -559,12 +514,8 @@ describe('Admin API Key Management', () => { await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']); const apiKey = await createAdminApiKey(harness, admin, 'No Deletion Key', ['user:lookup'], null); await createBuilder(harness, apiKey.token) - .post('/admin/users/schedule-deletion') - .body({ - user_id: targetUser.userId, - reason_code: 1, - days_until_deletion: 60, - }) + .put(`/admin/users/${targetUser.userId}/deletion`) + .body({reason_code: 1, days_until_deletion: 60}) .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -572,12 +523,8 @@ describe('Admin API Key Management', () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/schedule-deletion') - .body({ - user_id: '999999999999999999', - reason_code: 1, - days_until_deletion: 60, - }) + .put('/admin/users/999999999999999999/deletion') + .body({reason_code: 1, days_until_deletion: 60}) .expect(HTTP_STATUS.NOT_FOUND) .execute(); }); diff --git a/fluxer_api/src/api/admin/tests/AdminApiKeyRevocation.test.ts b/fluxer_api/src/api/admin/tests/AdminApiKeyRevocation.test.ts index fff1dd5bc..9855f8136 100644 --- a/fluxer_api/src/api/admin/tests/AdminApiKeyRevocation.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminApiKeyRevocation.test.ts @@ -63,7 +63,7 @@ describe('Admin API Key Revocation', () => { 'guild:lookup', ]); await createBuilder(harness, `${admin.token}`) - .delete('/admin/api-keys/nonexistent-id') + .delete('/admin/api-keys/999999999999999999') .body(null) .expect(HTTP_STATUS.NOT_FOUND) .execute(); diff --git a/fluxer_api/src/api/admin/tests/AdminApplications.test.ts b/fluxer_api/src/api/admin/tests/AdminApplications.test.ts new file mode 100644 index 000000000..36376295d --- /dev/null +++ b/fluxer_api/src/api/admin/tests/AdminApplications.test.ts @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {ADMIN_OAUTH2_APPLICATION_ID} from '@fluxer/constants/src/Core'; +import type { + ApplicationUpdateResponse, + ListApplicationsResponse, + LookupApplicationResponse, +} from '@fluxer/schema/src/domains/admin/AdminApplicationSchemas'; +import {afterEach, beforeEach, describe, expect, test} from 'vitest'; +import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils'; +import {createOAuth2Application, createUniqueApplicationName} from '../../oauth/tests/OAuth2TestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; +import {HTTP_STATUS} from '../../test/TestConstants'; +import {createBuilder} from '../../test/TestRequestBuilder'; + +interface AuditLogsResponse { + logs: Array<{ + target_type: string; + target_id: string; + action: string; + }>; +} + +describe('Admin applications', () => { + let harness: ApiTestHarness; + + beforeEach(async () => { + harness = await createApiTestHarness(); + }); + + afterEach(async () => { + await harness.shutdown(); + }); + + test('gets an application by id', async () => { + const owner = await createTestAccount(harness); + const app = await createOAuth2Application(harness, owner.token, { + name: createUniqueApplicationName('Lookup App'), + redirect_uris: ['https://example.test/callback'], + }); + await setUserACLs(harness, owner, [AdminACLs.AUTHENTICATE, AdminACLs.APPLICATION_LOOKUP]); + + const response = await createBuilder(harness, `${owner.token}`) + .get(`/admin/applications/${app.application.id}`) + .expect(HTTP_STATUS.OK) + .execute(); + + expect(response.application).toMatchObject({ + id: app.application.id, + owner_user_id: owner.userId, + }); + }); + + test('lists applications owned by a user', async () => { + const owner = await createTestAccount(harness); + const app = await createOAuth2Application(harness, owner.token, { + name: createUniqueApplicationName('Owned App'), + redirect_uris: ['https://example.test/callback'], + }); + await setUserACLs(harness, owner, [AdminACLs.AUTHENTICATE, AdminACLs.APPLICATION_LIST_BY_OWNER]); + + const response = await createBuilder(harness, `${owner.token}`) + .get(`/admin/applications?owner_id=${owner.userId}`) + .expect(HTTP_STATUS.OK) + .execute(); + + expect(response.applications).toHaveLength(1); + expect(response.applications[0]).toMatchObject({id: app.application.id}); + }); + + test('rejects a list by owner from a lookup-only admin', async () => { + const owner = await createTestAccount(harness); + await createOAuth2Application(harness, owner.token, { + name: createUniqueApplicationName('Owned App'), + redirect_uris: ['https://example.test/callback'], + }); + await setUserACLs(harness, owner, [AdminACLs.AUTHENTICATE, AdminACLs.APPLICATION_LOOKUP]); + + await createBuilder(harness, `${owner.token}`) + .get(`/admin/applications?owner_id=${owner.userId}`) + .expect(HTTP_STATUS.FORBIDDEN) + .executeWithResponse(); + }); + + test('rejects a list without owner_id or guild_id', async () => { + const owner = await createTestAccount(harness); + await setUserACLs(harness, owner, [AdminACLs.AUTHENTICATE, AdminACLs.APPLICATION_LOOKUP]); + + await createBuilder(harness, `${owner.token}`) + .get('/admin/applications') + .expect(HTTP_STATUS.BAD_REQUEST) + .executeWithResponse(); + }); + + test('transfers application ownership', async () => { + const owner = await createTestAccount(harness); + const newOwner = await createTestAccount(harness); + const app = await createOAuth2Application(harness, owner.token, { + name: createUniqueApplicationName('Transferred App'), + redirect_uris: ['https://example.test/callback'], + }); + await setUserACLs(harness, owner, [AdminACLs.AUTHENTICATE, AdminACLs.APPLICATION_TRANSFER_OWNERSHIP]); + + const response = await createBuilder(harness, `${owner.token}`) + .patch(`/admin/applications/${app.application.id}`) + .body({new_owner_id: newOwner.userId}) + .expect(HTTP_STATUS.OK) + .execute(); + + expect(response.application.owner_user_id).toBe(newOwner.userId); + }); + + test('refuses to transfer the built-in admin application', async () => { + const owner = await createTestAccount(harness); + const newOwner = await createTestAccount(harness); + await setUserACLs(harness, owner, [ + AdminACLs.AUTHENTICATE, + AdminACLs.APPLICATION_TRANSFER_OWNERSHIP, + AdminACLs.AUDIT_LOG_VIEW, + ]); + + await createBuilder(harness, `${owner.token}`) + .patch(`/admin/applications/${ADMIN_OAUTH2_APPLICATION_ID}`) + .body({new_owner_id: newOwner.userId}) + .expect(HTTP_STATUS.FORBIDDEN, 'FORBIDDEN') + .execute(); + + const auditLogs = await createBuilder(harness, `${owner.token}`) + .get(`/admin/audit-logs?target_type=application&target_id=${ADMIN_OAUTH2_APPLICATION_ID}`) + .execute(); + + expect(auditLogs.logs).toEqual([]); + }); + + test('transfer requires the application update permission', async () => { + const owner = await createTestAccount(harness); + const newOwner = await createTestAccount(harness); + const app = await createOAuth2Application(harness, owner.token, { + name: createUniqueApplicationName('Guarded App'), + redirect_uris: ['https://example.test/callback'], + }); + await setUserACLs(harness, owner, [AdminACLs.AUTHENTICATE, AdminACLs.APPLICATION_LOOKUP]); + + await createBuilder(harness, `${owner.token}`) + .patch(`/admin/applications/${app.application.id}`) + .body({new_owner_id: newOwner.userId}) + .expect(HTTP_STATUS.FORBIDDEN) + .executeWithResponse(); + }); +}); diff --git a/fluxer_api/src/api/admin/tests/AdminArchiveRetryClearsFailure.test.ts b/fluxer_api/src/api/admin/tests/AdminArchiveRetryClearsFailure.test.ts new file mode 100644 index 000000000..a12d94099 --- /dev/null +++ b/fluxer_api/src/api/admin/tests/AdminArchiveRetryClearsFailure.test.ts @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {beforeEach, describe, expect, test} from 'vitest'; +import {createTestAccount, setUserACLs, type TestAccount} from '../../auth/tests/AuthTestUtils'; +import {createTestGuild} from '../../emoji/tests/EmojiTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; +import {HTTP_STATUS} from '../../test/TestConstants'; +import {createBuilder} from '../../test/TestRequestBuilder'; +import type {AdminArchive} from '../models/AdminArchiveModel'; +import {AdminArchiveRepository} from '../repositories/AdminArchiveRepository'; + +interface ArchiveResponse { + archive_id: string; + subject_id: string; + subject_type: string; + requested_by: string; + completed_at: string | null; + failed_at: string | null; + error_message: string | null; +} + +async function setAdminArchiveAcls(harness: ApiTestHarness, admin: TestAccount): Promise { + return await setUserACLs(harness, admin, ['admin:authenticate', 'archive:trigger:guild']); +} + +async function triggerGuildArchive( + harness: ApiTestHarness, + adminToken: string, + guildId: string, +): Promise { + return await createBuilder(harness, `${adminToken}`) + .post(`/admin/guilds/${guildId}/archives`) + .body({}) + .expect(HTTP_STATUS.OK) + .execute(); +} + +async function loadArchive(guildId: string, archiveId: string): Promise { + const archive = await new AdminArchiveRepository().findBySubjectAndArchiveId( + 'guild', + BigInt(guildId), + BigInt(archiveId), + ); + if (!archive) { + throw new Error(`Archive ${archiveId} not found`); + } + return archive; +} + +describe('Admin archive retry clears failure', () => { + let harness: ApiTestHarness; + beforeEach(async () => { + harness = await createApiTestHarness(); + }); + test('a retry clears the previous failure and a completed retry reports no failure', async () => { + const admin = await createTestAccount(harness); + const updatedAdmin = await setAdminArchiveAcls(harness, admin); + const owner = await createTestAccount(harness); + const guild = await createTestGuild(harness, owner.token); + const created = await triggerGuildArchive(harness, updatedAdmin.token, guild.id); + const repository = new AdminArchiveRepository(); + await repository.markAsFailed(await loadArchive(guild.id, created.archive_id), 'archive exploded'); + const failed = await loadArchive(guild.id, created.archive_id); + expect(failed.failedAt).not.toBeNull(); + expect(failed.errorMessage).toBe('archive exploded'); + await repository.markAsStarted(failed); + const retrying = await loadArchive(guild.id, created.archive_id); + expect(retrying.failedAt).toBeNull(); + expect(retrying.errorMessage).toBeNull(); + const validTime = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000); + await repository.markAsCompleted(retrying, `test/${created.archive_id}.zip`, 1024n, validTime); + const result = await createBuilder<{archive: ArchiveResponse | null}>(harness, `${updatedAdmin.token}`) + .get(`/admin/archives/guild/${guild.id}/${created.archive_id}`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(result.archive?.failed_at).toBeNull(); + expect(result.archive?.error_message).toBeNull(); + expect(result.archive?.completed_at).not.toBeNull(); + const download = await createBuilder<{downloadUrl: string; expiresAt: string}>(harness, `${updatedAdmin.token}`) + .get(`/admin/archives/guild/${guild.id}/${created.archive_id}/download`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(download.downloadUrl).not.toBe(''); + }); + test('download reports the failure rather than unreadiness when the latest attempt failed', async () => { + const admin = await createTestAccount(harness); + const updatedAdmin = await setAdminArchiveAcls(harness, admin); + const owner = await createTestAccount(harness); + const guild = await createTestGuild(harness, owner.token); + const created = await triggerGuildArchive(harness, updatedAdmin.token, guild.id); + const repository = new AdminArchiveRepository(); + await repository.markAsFailed(await loadArchive(guild.id, created.archive_id), 'archive exploded'); + await createBuilder(harness, `${updatedAdmin.token}`) + .get(`/admin/archives/guild/${guild.id}/${created.archive_id}/download`) + .expect(HTTP_STATUS.BAD_REQUEST, 'HARVEST_FAILED') + .execute(); + }); +}); diff --git a/fluxer_api/src/api/admin/tests/AdminArchivesList.test.ts b/fluxer_api/src/api/admin/tests/AdminArchivesList.test.ts index 8a59fcce6..0e73525ec 100644 --- a/fluxer_api/src/api/admin/tests/AdminArchivesList.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminArchivesList.test.ts @@ -28,8 +28,8 @@ async function triggerGuildArchive( guildId: string, ): Promise { return await createBuilder(harness, `${adminToken}`) - .post('/admin/archives/guild') - .body({guild_id: guildId}) + .post(`/admin/guilds/${guildId}/archives`) + .body({}) .expect(HTTP_STATUS.OK) .execute(); } @@ -40,13 +40,7 @@ async function listArchivesByRequester( requestedBy: string, ): Promise { return await createBuilder(harness, `${adminToken}`) - .post('/admin/archives/list') - .body({ - subject_type: 'guild', - requested_by: requestedBy, - include_expired: false, - limit: 50, - }) + .get(`/admin/archives?subject_type=guild&requested_by=${requestedBy}&include_expired=false&limit=50`) .expect(HTTP_STATUS.OK) .execute(); } @@ -82,4 +76,59 @@ describe('Admin archives list', () => { expect(resultTwo.archives.some((entry) => entry.archive_id === archiveTwo.archive_id)).toBe(true); expect(resultTwo.archives.some((entry) => entry.archive_id === archiveOne.archive_id)).toBe(false); }); + test('reads one archive by subject type, subject id and archive id', async () => { + const admin = await createTestAccount(harness); + const updatedAdmin = await setAdminArchiveAcls(harness, admin); + const owner = await createTestAccount(harness); + const guild = await createTestGuild(harness, owner.token); + const archive = await triggerGuildArchive(harness, updatedAdmin.token, guild.id); + const result = await createBuilder<{archive: ArchiveResponse | null}>(harness, `${updatedAdmin.token}`) + .get(`/admin/archives/guild/${guild.id}/${archive.archive_id}`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(result.archive?.archive_id).toBe(archive.archive_id); + }); + test('creates a user archive under the user resource', async () => { + const admin = await createTestAccount(harness); + const updatedAdmin = await setUserACLs(harness, admin, ['admin:authenticate', 'archive:trigger:user']); + const subject = await createTestAccount(harness); + const archive = await createBuilder(harness, `${updatedAdmin.token}`) + .post(`/admin/users/${subject.userId}/archives`) + .body({include_attachments: true}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(archive.subject_type).toBe('user'); + expect(archive.subject_id).toBe(subject.userId); + }); + test('a guild trigger ACL does not read user archives', async () => { + const admin = await createTestAccount(harness); + const updatedAdmin = await setAdminArchiveAcls(harness, admin); + await createBuilder(harness, `${updatedAdmin.token}`) + .get('/admin/archives/user/1/2') + .expect(HTTP_STATUS.FORBIDDEN) + .execute(); + }); + test('rejects a subject id supplied without a concrete subject type', async () => { + const admin = await createTestAccount(harness); + const updatedAdmin = await setUserACLs(harness, admin, ['admin:authenticate', 'archive:view_all']); + const response = await createBuilder<{ + code: string; + errors: Array<{ + path: string; + }>; + }>(harness, `${updatedAdmin.token}`) + .get('/admin/archives?subject_id=123') + .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') + .execute(); + expect(response.errors[0]?.path).toBe('subject_type'); + }); + test('accepts a subject id paired with a concrete subject type', async () => { + const admin = await createTestAccount(harness); + const updatedAdmin = await setUserACLs(harness, admin, ['admin:authenticate', 'archive:view_all']); + const result = await createBuilder(harness, `${updatedAdmin.token}`) + .get('/admin/archives?subject_type=user&subject_id=123') + .expect(HTTP_STATUS.OK) + .execute(); + expect(Array.isArray(result.archives)).toBe(true); + }); }); diff --git a/fluxer_api/src/api/admin/tests/AdminBlocklistUrl.test.ts b/fluxer_api/src/api/admin/tests/AdminBlocklistUrl.test.ts new file mode 100644 index 000000000..2985ed53c --- /dev/null +++ b/fluxer_api/src/api/admin/tests/AdminBlocklistUrl.test.ts @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; +import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; +import {createBuilder} from '../../test/TestRequestBuilder'; + +interface ValidationErrorResponse { + code: string; + message: string; + errors?: Array<{ + path: string; + code: string; + message: string; + }>; +} + +describe('Admin url blocklist canonicalization', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + }); + + afterAll(async () => { + await harness?.shutdown(); + }); + + async function createBlocklistAdminToken(): Promise { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'ban:url:add', 'ban:url:remove']); + return `${admin.token}`; + } + + it('rejects an uncanonicalizable url on add with INVALID_FORM_BODY', async () => { + const token = await createBlocklistAdminToken(); + const json = await createBuilder(harness, token) + .post('/admin/blocklists/url/entries') + .body({url: 'http://'}) + .expect(400, 'INVALID_FORM_BODY') + .execute(); + expect(json.errors?.[0].path).toBe('url'); + expect(json.errors?.[0].code).toBe('INVALID_URL_FORMAT'); + }); + + it('rejects an uncanonicalizable url on update with INVALID_FORM_BODY', async () => { + const token = await createBlocklistAdminToken(); + const json = await createBuilder(harness, token) + .patch('/admin/blocklists/url/entries/not-a-url') + .body({}) + .expect(400, 'INVALID_FORM_BODY') + .execute(); + expect(json.errors?.[0].path).toBe('url'); + expect(json.errors?.[0].code).toBe('INVALID_URL_FORMAT'); + }); + + it('rejects an uncanonicalizable url on remove with INVALID_FORM_BODY', async () => { + const token = await createBlocklistAdminToken(); + const json = await createBuilder(harness, token) + .delete('/admin/blocklists/url/entries/not-a-url') + .body(null) + .expect(400, 'INVALID_FORM_BODY') + .execute(); + expect(json.errors?.[0].path).toBe('url'); + expect(json.errors?.[0].code).toBe('INVALID_URL_FORMAT'); + }); + + it('still adds a url that canonicalizes', async () => { + const token = await createBlocklistAdminToken(); + await createBuilder(harness, token) + .post('/admin/blocklists/url/entries') + .body({url: 'https://Example.com/?utm_source=x'}) + .expect(204) + .execute(); + }); +}); diff --git a/fluxer_api/src/api/admin/tests/AdminDeletionQueue.test.ts b/fluxer_api/src/api/admin/tests/AdminDeletionQueue.test.ts index 4956095cf..24ace8531 100644 --- a/fluxer_api/src/api/admin/tests/AdminDeletionQueue.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminDeletionQueue.test.ts @@ -8,6 +8,7 @@ import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils'; import {setInjectedIpInfoService} from '../../middleware/ServiceMiddleware'; import {CassandraSuspiciousIpRepository} from '../../risk/SuspiciousIpRepository'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; +import {HTTP_STATUS} from '../../test/TestConstants'; import {createBuilder} from '../../test/TestRequestBuilder'; import {UserRepository} from '../../user/repositories/UserRepository'; @@ -92,13 +93,9 @@ describe('Admin Deletion Queue', () => { pending_deletion_at: string; }; }>(harness, `${admin.token}`) - .post('/admin/users/schedule-deletion') + .put(`/admin/users/${targetUser.userId}/deletion`) .header('x-forwarded-for', adminIp) - .body({ - user_id: targetUser.userId, - reason_code: 2, - days_until_deletion: 60, - }) + .body({reason_code: 2, days_until_deletion: 60}) .execute(); expect(await harness.kvProvider.zcard('deletion_queue')).toBe(1); const secondSchedule = await createBuilder<{ @@ -106,13 +103,9 @@ describe('Admin Deletion Queue', () => { pending_deletion_at: string; }; }>(harness, `${admin.token}`) - .post('/admin/users/schedule-deletion') + .put(`/admin/users/${targetUser.userId}/deletion`) .header('x-forwarded-for', adminIp) - .body({ - user_id: targetUser.userId, - reason_code: 2, - days_until_deletion: 62, - }) + .body({reason_code: 2, days_until_deletion: 62}) .execute(); const firstDate = firstSchedule.user.pending_deletion_at.slice(0, 10); const secondDate = secondSchedule.user.pending_deletion_at.slice(0, 10); @@ -139,13 +132,9 @@ describe('Admin Deletion Queue', () => { pending_deletion_at: string; }; }>(harness, `${admin.token}`) - .post('/admin/users/schedule-deletion') + .put(`/admin/users/${targetUser.userId}/deletion`) .header('x-forwarded-for', adminIp) - .body({ - user_id: targetUser.userId, - reason_code: 1, - days_until_deletion: 60, - }) + .body({reason_code: 1, days_until_deletion: 60}) .execute(); expect(await harness.kvProvider.zcard('deletion_queue')).toBe(1); await createBuilder<{ @@ -153,11 +142,8 @@ describe('Admin Deletion Queue', () => { pending_deletion_at: string | null; }; }>(harness, `${admin.token}`) - .post('/admin/users/cancel-deletion') + .delete(`/admin/users/${targetUser.userId}/deletion`) .header('x-forwarded-for', adminIp) - .body({ - user_id: targetUser.userId, - }) .execute(); expect(await harness.kvProvider.zcard('deletion_queue')).toBe(0); expect( @@ -173,21 +159,15 @@ describe('Admin Deletion Queue', () => { const targetUser = await createTestAccount(harness, {ipAddress: targetIp}); await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete', 'ban:ip:check', 'ban:email:check']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/schedule-deletion') + .put(`/admin/users/${targetUser.userId}/deletion`) .header('x-forwarded-for', adminIp) - .body({ - user_id: targetUser.userId, - reason_code: DeletionReasons.USER_REQUESTED, - days_until_deletion: 14, - }) + .body({reason_code: DeletionReasons.USER_REQUESTED, days_until_deletion: 14}) .execute(); const ipBan = await createBuilder<{banned: boolean}>(harness, `${admin.token}`) - .post('/admin/bans/ip/check') - .body({ip: targetIp}) + .get(`/admin/blocklists/ip/entries/${encodeURIComponent(targetIp)}`) .execute(); const emailBan = await createBuilder<{banned: boolean}>(harness, `${admin.token}`) - .post('/admin/bans/email/check') - .body({email: targetUser.email}) + .get(`/admin/blocklists/email/entries/${encodeURIComponent(targetUser.email)}`) .execute(); expect(ipBan.banned).toBe(false); expect(emailBan.banned).toBe(false); @@ -199,21 +179,15 @@ describe('Admin Deletion Queue', () => { const targetUser = await createTestAccount(harness, {ipAddress: targetIp}); await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete', 'ban:ip:check', 'ban:email:check']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/schedule-deletion') + .put(`/admin/users/${targetUser.userId}/deletion`) .header('x-forwarded-for', adminIp) - .body({ - user_id: targetUser.userId, - reason_code: DeletionReasons.SPAM, - days_until_deletion: 60, - }) + .body({reason_code: DeletionReasons.SPAM, days_until_deletion: 60}) .execute(); const ipBan = await createBuilder<{banned: boolean}>(harness, `${admin.token}`) - .post('/admin/bans/ip/check') - .body({ip: targetIp}) + .get(`/admin/blocklists/ip/entries/${encodeURIComponent(targetIp)}`) .execute(); const emailBan = await createBuilder<{banned: boolean}>(harness, `${admin.token}`) - .post('/admin/bans/email/check') - .body({email: targetUser.email}) + .get(`/admin/blocklists/email/entries/${encodeURIComponent(targetUser.email)}`) .execute(); const suspiciousIp = await new CassandraSuspiciousIpRepository().findActiveByIp(targetIp); expect(ipBan.banned).toBe(false); @@ -242,19 +216,14 @@ describe('Admin Deletion Queue', () => { }); }, }); - await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete', 'ban:ip:check']); + await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete', 'ban:ip:check', 'ban:email:check']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/schedule-deletion') + .put(`/admin/users/${targetUser.userId}/deletion`) .header('x-forwarded-for', adminIp) - .body({ - user_id: targetUser.userId, - reason_code: DeletionReasons.SPAM, - days_until_deletion: 60, - }) + .body({reason_code: DeletionReasons.SPAM, days_until_deletion: 60}) .execute(); const ipBan = await createBuilder<{banned: boolean}>(harness, `${admin.token}`) - .post('/admin/bans/ip/check') - .body({ip: targetIp}) + .get(`/admin/blocklists/ip/entries/${encodeURIComponent(targetIp)}`) .execute(); const suspiciousIp = await new CassandraSuspiciousIpRepository().findActiveByIp(targetIp); expect(ipBan.banned).toBe(false); @@ -289,22 +258,63 @@ describe('Admin Deletion Queue', () => { }); }, }); - await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete', 'ban:ip:check']); + await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete', 'ban:ip:check', 'ban:email:check']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/schedule-deletion') + .put(`/admin/users/${targetUser.userId}/deletion`) .header('x-forwarded-for', adminIp) - .body({ - user_id: targetUser.userId, - reason_code: DeletionReasons.SPAM, - days_until_deletion: 60, - }) + .body({reason_code: DeletionReasons.SPAM, days_until_deletion: 60}) .execute(); const ipBan = await createBuilder<{banned: boolean}>(harness, `${admin.token}`) - .post('/admin/bans/ip/check') - .body({ip: targetIp}) + .get(`/admin/blocklists/ip/entries/${encodeURIComponent(targetIp)}`) .execute(); const suspiciousIp = await new CassandraSuspiciousIpRepository().findActiveByIp(targetIp); expect(ipBan.banned).toBe(false); expect(suspiciousIp).toBeNull(); }); + test('rejects a scheduled deletion reason code outside the registry', async () => { + const admin = await createTestAccount(harness); + const targetUser = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']); + const {json} = await createBuilder<{ + code: string; + errors: Array<{ + path: string; + code: string; + }>; + }>(harness, `${admin.token}`) + .put(`/admin/users/${targetUser.userId}/deletion`) + .body({reason_code: 999, days_until_deletion: 60}) + .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') + .executeWithResponse(); + expect(json.errors.some((entry) => entry.path === 'reason_code')).toBe(true); + expect(await harness.kvProvider.zcard('deletion_queue')).toBe(0); + }); + test('accepts the highest scheduled deletion reason code of the registry', async () => { + const admin = await createTestAccount(harness); + const targetUser = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']); + await createBuilder(harness, `${admin.token}`) + .put(`/admin/users/${targetUser.userId}/deletion`) + .body({reason_code: DeletionReasons.IMPERSONATION_OR_FAKE_IDENTITY, days_until_deletion: 60}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(await harness.kvProvider.zcard('deletion_queue')).toBe(1); + }); + test('rejects a bulk schedule_user_deletion job with a reason code outside the registry', async () => { + const admin = await createTestAccount(harness); + const targetUser = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:delete:users']); + const {json} = await createBuilder<{ + code: string; + errors: Array<{ + path: string; + code: string; + }>; + }>(harness, `${admin.token}`) + .post('/admin/bulk-jobs') + .body({task: 'schedule_user_deletion', user_ids: [targetUser.userId], reason_code: 0}) + .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') + .executeWithResponse(); + expect(json.errors.some((entry) => entry.path === 'reason_code')).toBe(true); + }); }); diff --git a/fluxer_api/src/api/admin/tests/AdminEndpointsAuthorization.test.ts b/fluxer_api/src/api/admin/tests/AdminEndpointsAuthorization.test.ts index b46be06bb..ddc3701b7 100644 --- a/fluxer_api/src/api/admin/tests/AdminEndpointsAuthorization.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminEndpointsAuthorization.test.ts @@ -4,45 +4,100 @@ import {beforeEach, describe, test} from 'vitest'; import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; import {HTTP_STATUS} from '../../test/TestConstants'; -import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder'; +import {createBuilder, createBuilderWithoutAuth, type TestRequestBuilder} from '../../test/TestRequestBuilder'; -const adminEndpoints = [ - {method: 'POST', path: '/admin/reports/list', requiredACL: 'report:manage'}, - {method: 'GET', path: '/admin/reports/1', requiredACL: 'report:manage'}, - {method: 'POST', path: '/admin/reports/resolve', requiredACL: 'report:manage'}, - {method: 'POST', path: '/admin/bulk/update-user-flags', requiredACL: 'bulk:update'}, - {method: 'POST', path: '/admin/bulk/update-guild-features', requiredACL: 'bulk:update'}, - {method: 'POST', path: '/admin/bulk/add-guild-members', requiredACL: 'bulk:update'}, - {method: 'POST', path: '/admin/guilds/search', requiredACL: 'guild:lookup'}, - {method: 'POST', path: '/admin/users/search', requiredACL: 'user:lookup'}, - {method: 'POST', path: '/admin/messages/lookup', requiredACL: 'message:lookup'}, - {method: 'POST', path: '/admin/messages/delete', requiredACL: 'message:delete'}, - {method: 'POST', path: '/admin/gateway/memory-stats', requiredACL: 'gateway:manage'}, - {method: 'POST', path: '/admin/gateway/reload-all', requiredACL: 'gateway:manage'}, +function withMethod(builder: TestRequestBuilder, method: string, path: string, body: unknown = {}): TestRequestBuilder { + switch (method) { + case 'POST': + return builder.post(path).body(body); + case 'PATCH': + return builder.patch(path).body(body); + case 'PUT': + return builder.put(path).body(body); + case 'DELETE': + return builder.delete(path); + default: + return builder.get(path); + } +} + +interface AdminEndpointCase { + method: string; + path: string; + requiredACL: string; + body?: unknown; +} + +const adminEndpoints: Array = [ + {method: 'GET', path: '/admin/reports', requiredACL: 'report:view'}, + {method: 'GET', path: '/admin/reports/1', requiredACL: 'report:view'}, + {method: 'PATCH', path: '/admin/reports/1', requiredACL: 'report:resolve'}, + { + method: 'POST', + path: '/admin/bulk-jobs', + requiredACL: 'bulk:update:user_flags', + body: {task: 'update_user_flags', user_ids: ['1']}, + }, + { + method: 'POST', + path: '/admin/bulk-jobs', + requiredACL: 'bulk:update:guild_features', + body: {task: 'update_guild_features', guild_ids: ['1']}, + }, + { + method: 'POST', + path: '/admin/bulk-jobs', + requiredACL: 'bulk:add:guild_members', + body: {task: 'add_guild_members', guild_id: '1', user_ids: ['2']}, + }, + {method: 'GET', path: '/admin/guilds', requiredACL: 'guild:lookup'}, + {method: 'GET', path: '/admin/users', requiredACL: 'user:lookup'}, + {method: 'GET', path: '/admin/messages?channel_id=1', requiredACL: 'message:lookup'}, + {method: 'DELETE', path: '/admin/channels/1/messages/1', requiredACL: 'message:delete'}, + {method: 'GET', path: '/admin/gateway/memory-stats', requiredACL: 'gateway:manage'}, + {method: 'POST', path: '/admin/gateway/reloads', requiredACL: 'gateway:manage'}, {method: 'GET', path: '/admin/gateway/stats', requiredACL: 'gateway:manage'}, {method: 'GET', path: '/admin/gateway/voice-state-counts', requiredACL: 'gateway:manage'}, - {method: 'POST', path: '/admin/audit-logs', requiredACL: 'audit_log:view'}, - {method: 'POST', path: '/admin/audit-logs/search', requiredACL: 'audit_log:view'}, - {method: 'POST', path: '/admin/guilds/lookup', requiredACL: 'guild:lookup'}, - {method: 'POST', path: '/admin/guilds/list-members', requiredACL: 'guild:lookup'}, - {method: 'POST', path: '/admin/guilds/update-features', requiredACL: 'guild:update'}, - {method: 'POST', path: '/admin/guilds/update-name', requiredACL: 'guild:update'}, - {method: 'POST', path: '/admin/guilds/update-settings', requiredACL: 'guild:update'}, - {method: 'POST', path: '/admin/guilds/transfer-ownership', requiredACL: 'guild:update'}, - {method: 'POST', path: '/admin/guilds/update-vanity', requiredACL: 'guild:update'}, - {method: 'POST', path: '/admin/guilds/force-add-user', requiredACL: 'guild:update'}, - {method: 'POST', path: '/admin/guilds/reload', requiredACL: 'guild:update'}, - {method: 'POST', path: '/admin/guilds/shutdown', requiredACL: 'guild:update'}, - {method: 'POST', path: '/admin/users/lookup', requiredACL: 'user:lookup'}, - {method: 'POST', path: '/admin/users/list-guilds', requiredACL: 'user:lookup'}, - {method: 'POST', path: '/admin/users/list-dm-channels', requiredACL: 'user:list:dm_channels'}, - {method: 'POST', path: '/admin/users/disable-mfa', requiredACL: 'user:update'}, - {method: 'POST', path: '/admin/users/list-webauthn-credentials', requiredACL: 'user:update:mfa'}, - {method: 'POST', path: '/admin/users/delete-webauthn-credential', requiredACL: 'user:update:mfa'}, - {method: 'POST', path: '/admin/users/clear-fields', requiredACL: 'user:update'}, - {method: 'POST', path: '/admin/users/set-bot-status', requiredACL: 'user:update'}, - {method: 'POST', path: '/admin/users/set-acls', requiredACL: 'acl:set:user'}, - {method: 'POST', path: '/admin/users/schedule-deletion', requiredACL: 'user:delete'}, + {method: 'GET', path: '/admin/audit-logs', requiredACL: 'audit_log:view'}, + {method: 'GET', path: '/admin/audit-logs?q=example', requiredACL: 'audit_log:view'}, + {method: 'GET', path: '/admin/guilds/1', requiredACL: 'guild:lookup'}, + {method: 'GET', path: '/admin/guilds/1/members', requiredACL: 'guild:list:members'}, + {method: 'PATCH', path: '/admin/guilds/1', requiredACL: 'guild:update'}, + {method: 'DELETE', path: '/admin/guilds/1', requiredACL: 'guild:delete'}, + {method: 'PUT', path: '/admin/guilds/1/members/2', requiredACL: 'guild:force_add_member'}, + {method: 'DELETE', path: '/admin/guilds/1/members/2', requiredACL: 'guild:kick_member'}, + {method: 'PUT', path: '/admin/guilds/1/bans/2', requiredACL: 'guild:ban_member'}, + {method: 'GET', path: '/admin/guilds/1/audit-logs', requiredACL: 'guild:audit_log:view'}, + {method: 'DELETE', path: '/admin/guilds/1/assets', requiredACL: 'asset:purge'}, + {method: 'POST', path: '/admin/guilds/1/reloads', requiredACL: 'guild:reload'}, + {method: 'POST', path: '/admin/guilds/1/shutdowns', requiredACL: 'guild:shutdown'}, + {method: 'GET', path: '/admin/users/1', requiredACL: 'user:lookup'}, + {method: 'GET', path: '/admin/users/1/guilds', requiredACL: 'user:list:guilds'}, + {method: 'GET', path: '/admin/users/1/dm-channels', requiredACL: 'user:list:dm_channels'}, + {method: 'DELETE', path: '/admin/users/1/mfa', requiredACL: 'user:update:mfa'}, + {method: 'GET', path: '/admin/users/1/webauthn-credentials', requiredACL: 'user:update:mfa'}, + {method: 'DELETE', path: '/admin/users/1/webauthn-credentials/credential', requiredACL: 'user:update:mfa'}, + {method: 'DELETE', path: '/admin/users/1/profile-fields', requiredACL: 'user:update:profile'}, + {method: 'PUT', path: '/admin/users/1/bot-status', requiredACL: 'user:update:bot_status'}, + {method: 'PUT', path: '/admin/users/1/acls', requiredACL: 'acl:set:user'}, + {method: 'PUT', path: '/admin/users/1/deletion', requiredACL: 'user:delete'}, + {method: 'POST', path: '/admin/users/1/avatar-block', requiredACL: 'ban:avatar_hash:add'}, + {method: 'GET', path: '/admin/guilds/1/emojis', requiredACL: 'asset:purge'}, + {method: 'GET', path: '/admin/guilds/1/stickers', requiredACL: 'asset:purge'}, + {method: 'GET', path: '/admin/blocklists', requiredACL: 'ban:ip:check'}, + {method: 'GET', path: '/admin/blocklists/ip/entries', requiredACL: 'ban:ip:check'}, + {method: 'POST', path: '/admin/blocklists/ip/entries', requiredACL: 'ban:ip:add', body: {ip: '198.51.100.9'}}, + {method: 'GET', path: '/admin/discovery/applications', requiredACL: 'discovery:review'}, + {method: 'GET', path: '/admin/discovery/listings', requiredACL: 'discovery:review'}, + {method: 'PATCH', path: '/admin/discovery/listings/1', requiredACL: 'discovery:review'}, + {method: 'DELETE', path: '/admin/discovery/listings/1', requiredACL: 'discovery:remove'}, + {method: 'GET', path: '/admin/search/index-refreshes/1', requiredACL: 'guild:lookup'}, + { + method: 'POST', + path: '/admin/search/indexes/users/refreshes', + requiredACL: 'guild:lookup', + body: {}, + }, ]; describe('Admin Endpoints Authorization', () => { @@ -52,51 +107,34 @@ describe('Admin Endpoints Authorization', () => { }); test('admin endpoints require authentication', async () => { for (const endpoint of adminEndpoints.slice(0, 5)) { - if (endpoint.method === 'POST') { - await createBuilderWithoutAuth(harness).post(endpoint.path).expect(HTTP_STATUS.UNAUTHORIZED).execute(); - } else { - await createBuilderWithoutAuth(harness).get(endpoint.path).expect(HTTP_STATUS.UNAUTHORIZED).execute(); - } + await withMethod(createBuilderWithoutAuth(harness), endpoint.method, endpoint.path, endpoint.body) + .expect(HTTP_STATUS.UNAUTHORIZED) + .execute(); } }); test('admin endpoints require proper ACLs', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate']); for (const endpoint of adminEndpoints.slice(0, 10)) { - if (endpoint.method === 'POST') { - await createBuilder(harness, `${admin.token}`) - .post(endpoint.path) - .body({}) - .expect(HTTP_STATUS.FORBIDDEN) - .execute(); - } else { - await createBuilder(harness, `${admin.token}`).get(endpoint.path).expect(HTTP_STATUS.FORBIDDEN).execute(); - } + await withMethod(createBuilder(harness, `${admin.token}`), endpoint.method, endpoint.path, endpoint.body) + .expect(HTTP_STATUS.FORBIDDEN) + .execute(); } }); test('admin endpoints succeed with proper ACLs', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup', 'guild:lookup']); - const endpointsToTest = [ - {path: '/admin/users/lookup', body: {user_ids: ['123']}}, - {path: '/admin/guilds/lookup', body: {guild_id: '123'}}, - ]; - for (const endpoint of endpointsToTest) { - await createBuilder(harness, `${admin.token}`) - .post(endpoint.path) - .body(endpoint.body) - .expect(HTTP_STATUS.OK) - .execute(); + const endpointsToTest = ['/admin/users/123']; + for (const path of endpointsToTest) { + await createBuilder(harness, `${admin.token}`).get(path).expect(HTTP_STATUS.OK).execute(); } + await createBuilder(harness, `${admin.token}`).get('/admin/guilds/123').expect(HTTP_STATUS.OK).execute(); }); test('user lookup endpoint requires user:lookup ACL', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -104,10 +142,7 @@ describe('Admin Endpoints Authorization', () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']); await createBuilder(harness, `${admin.token}`) - .post('/admin/guilds/lookup') - .body({ - guild_ids: ['123456789'], - }) + .get('/admin/guilds/123456789') .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -115,10 +150,7 @@ describe('Admin Endpoints Authorization', () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']); await createBuilder(harness, `${admin.token}`) - .post('/admin/audit-logs') - .body({ - limit: 10, - }) + .get('/admin/audit-logs?limit=10') .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); diff --git a/fluxer_api/src/api/admin/tests/AdminGuildApplications.test.ts b/fluxer_api/src/api/admin/tests/AdminGuildApplications.test.ts index d01777f15..a985fc1d4 100644 --- a/fluxer_api/src/api/admin/tests/AdminGuildApplications.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminGuildApplications.test.ts @@ -2,7 +2,7 @@ import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; import {Permissions} from '@fluxer/constants/src/ChannelConstants'; -import type {ListGuildApplicationsResponse} from '@fluxer/schema/src/domains/admin/AdminApplicationSchemas'; +import type {ListApplicationsResponse} from '@fluxer/schema/src/domains/admin/AdminApplicationSchemas'; import {afterEach, beforeEach, describe, expect, test} from 'vitest'; import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils'; import {createGuild} from '../../channel/tests/ChannelTestUtils'; @@ -42,9 +42,8 @@ describe('Admin guild applications', () => { .execute(); await setUserACLs(harness, owner, [AdminACLs.AUTHENTICATE, AdminACLs.APPLICATION_LOOKUP]); - const response = await createBuilder(harness, `${owner.token}`) - .post('/admin/applications/list-by-guild') - .body({guild_id: guild.id}) + const response = await createBuilder(harness, `${owner.token}`) + .get(`/admin/applications?guild_id=${guild.id}`) .expect(HTTP_STATUS.OK) .execute(); diff --git a/fluxer_api/src/api/admin/tests/AdminGuildRoutes.test.ts b/fluxer_api/src/api/admin/tests/AdminGuildRoutes.test.ts new file mode 100644 index 000000000..233c653f6 --- /dev/null +++ b/fluxer_api/src/api/admin/tests/AdminGuildRoutes.test.ts @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {beforeEach, describe, expect, test} from 'vitest'; +import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils'; +import {createGuild} from '../../message/tests/MessageTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; +import {HTTP_STATUS} from '../../test/TestConstants'; +import {createBuilder} from '../../test/TestRequestBuilder'; + +interface AdminGuildDetail { + guild: { + id: string; + name: string; + owner_id: string; + features: Array; + } | null; +} + +interface AdminGuildUpdate { + guild: { + id: string; + name: string; + owner_id: string; + features: Array; + }; +} + +describe('Admin guild routes', () => { + let harness: ApiTestHarness; + beforeEach(async () => { + harness = await createApiTestHarness({search: 'enabled'}); + }); + test('GET /admin/guilds/{guild_id} returns the guild detail', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'guild:lookup']); + const guild = await createGuild(harness, admin.token, `Detail Guild ${Date.now()}`); + const result = await createBuilder(harness, `${admin.token}`) + .get(`/admin/guilds/${guild.id}`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(result.guild?.id).toBe(guild.id); + expect(result.guild?.owner_id).toBe(admin.userId); + }); + test('PATCH /admin/guilds/{guild_id} renames a guild with guild:update:name', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'guild:update:name']); + const guild = await createGuild(harness, admin.token, `Rename Guild ${Date.now()}`); + const renamed = `Renamed Guild ${Date.now()}`; + const result = await createBuilder(harness, `${admin.token}`) + .patch(`/admin/guilds/${guild.id}`) + .body({name: renamed}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(result.guild.name).toBe(renamed); + }); + test('PATCH /admin/guilds/{guild_id} applies every supplied field group in one call', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, [ + 'admin:authenticate', + 'guild:update:name', + 'guild:update:settings', + 'guild:update:features', + ]); + const guild = await createGuild(harness, admin.token, `Combined Guild ${Date.now()}`); + const renamed = `Combined Renamed ${Date.now()}`; + const result = await createBuilder(harness, `${admin.token}`) + .patch(`/admin/guilds/${guild.id}`) + .body({name: renamed, verification_level: 1, add_features: ['VERIFIED']}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(result.guild.name).toBe(renamed); + expect(result.guild.features).toContain('VERIFIED'); + }); + test('PATCH /admin/guilds/{guild_id} requires the ACL selected by every supplied field', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'guild:update:settings']); + const guild = await createGuild(harness, admin.token, `Partial ACL Guild ${Date.now()}`); + await createBuilder(harness, `${admin.token}`) + .patch(`/admin/guilds/${guild.id}`) + .body({name: 'Not Allowed', verification_level: 1}) + .expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL') + .execute(); + }); + test('PATCH /admin/guilds/{guild_id} rejects an empty patch without the wildcard ACL', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'guild:update:name']); + const guild = await createGuild(harness, admin.token, `Empty Patch Guild ${Date.now()}`); + await createBuilder(harness, `${admin.token}`) + .patch(`/admin/guilds/${guild.id}`) + .body({}) + .expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL') + .execute(); + }); + test('guild member add, ban and removal use the member and ban sub-resources', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, [ + 'admin:authenticate', + 'guild:lookup', + 'guild:list:members', + 'guild:force_add_member', + 'guild:kick_member', + 'guild:ban_member', + ]); + const target = await createTestAccount(harness); + const guild = await createGuild(harness, admin.token, `Membership Guild ${Date.now()}`); + await createBuilder(harness, `${admin.token}`) + .put(`/admin/guilds/${guild.id}/members/${target.userId}`) + .body(null) + .expect(HTTP_STATUS.OK) + .execute(); + await createBuilder(harness, `${admin.token}`) + .delete(`/admin/guilds/${guild.id}/members/${target.userId}`) + .body(null) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + await createBuilder(harness, `${admin.token}`) + .put(`/admin/guilds/${guild.id}/bans/${target.userId}`) + .body({}) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + }); + test('GET /admin/guilds/{guild_id}/members lists members', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'guild:list:members']); + const guild = await createGuild(harness, admin.token, `Member List Guild ${Date.now()}`); + const result = await createBuilder<{ + members: Array; + total: number; + limit: number; + offset: number; + }>(harness, `${admin.token}`) + .get(`/admin/guilds/${guild.id}/members?limit=10&offset=0`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(result.limit).toBe(10); + expect(result.offset).toBe(0); + }); + test('GET /admin/guilds/{guild_id}/members requires guild:list:members', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'guild:lookup']); + const guild = await createGuild(harness, admin.token, `Member ACL Guild ${Date.now()}`); + await createBuilder(harness, `${admin.token}`) + .get(`/admin/guilds/${guild.id}/members`) + .expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL') + .execute(); + }); + test('guild expression listings and asset purge live under the guild', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'asset:purge', 'asset:purge']); + const guild = await createGuild(harness, admin.token, `Expression Guild ${Date.now()}`); + const emojis = await createBuilder<{ + guild_id: string; + emojis: Array; + }>(harness, `${admin.token}`) + .get(`/admin/guilds/${guild.id}/emojis`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(emojis.guild_id).toBe(guild.id); + const stickers = await createBuilder<{ + guild_id: string; + stickers: Array; + }>(harness, `${admin.token}`) + .get(`/admin/guilds/${guild.id}/stickers`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(stickers.guild_id).toBe(guild.id); + const purge = await createBuilder<{ + processed: Array<{id: string; asset_type: string}>; + errors: Array; + }>(harness, `${admin.token}`) + .delete(`/admin/guilds/${guild.id}/assets`) + .body({ids: ['123456789012345678']}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(purge.processed).toHaveLength(1); + expect(purge.processed[0].asset_type).toBe('unknown'); + }); + test('GET /admin/guilds/{guild_id}/audit-logs returns the guild audit log', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'guild:audit_log:view']); + const guild = await createGuild(harness, admin.token, `Audit Guild ${Date.now()}`); + await createBuilder<{ + audit_log_entries: Array; + }>(harness, `${admin.token}`) + .get(`/admin/guilds/${guild.id}/audit-logs?limit=10`) + .expect(HTTP_STATUS.OK) + .execute(); + }); + test('guild reload and shutdown are collection sub-resources', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'guild:reload', 'guild:shutdown']); + const guild = await createGuild(harness, admin.token, `Lifecycle Guild ${Date.now()}`); + await createBuilder<{success: boolean}>(harness, `${admin.token}`) + .post(`/admin/guilds/${guild.id}/reloads`) + .body(null) + .expect(HTTP_STATUS.OK) + .execute(); + await createBuilder<{success: boolean}>(harness, `${admin.token}`) + .post(`/admin/guilds/${guild.id}/shutdowns`) + .body(null) + .expect(HTTP_STATUS.OK) + .execute(); + }); + test('DELETE /admin/guilds/{guild_id} deletes the guild', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'guild:lookup', 'guild:delete']); + const guild = await createGuild(harness, admin.token, `Doomed Guild ${Date.now()}`); + await createBuilder<{success: boolean}>(harness, `${admin.token}`) + .delete(`/admin/guilds/${guild.id}`) + .body(null) + .expect(HTTP_STATUS.OK) + .execute(); + const result = await createBuilder(harness, `${admin.token}`) + .get(`/admin/guilds/${guild.id}`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(result.guild).toBeNull(); + }); +}); diff --git a/fluxer_api/src/api/admin/tests/AdminJobsAndBulkJobs.test.ts b/fluxer_api/src/api/admin/tests/AdminJobsAndBulkJobs.test.ts new file mode 100644 index 000000000..13ba83099 --- /dev/null +++ b/fluxer_api/src/api/admin/tests/AdminJobsAndBulkJobs.test.ts @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {beforeEach, describe, expect, test} from 'vitest'; +import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; +import {HTTP_STATUS} from '../../test/TestConstants'; +import {createBuilder} from '../../test/TestRequestBuilder'; + +interface JobEntry { + job_id: string; + task_type: string; + status: string; +} + +interface ListJobsResponse { + jobs: Array; + next_cursor: {bucket_day: string; created_at: string; job_id: string} | null; +} + +interface ActiveJobsResponse { + jobs: Array; +} + +interface BulkJobResponse { + job_id: string; +} + +describe('Admin jobs and bulk jobs', () => { + let harness: ApiTestHarness; + beforeEach(async () => { + harness = await createApiTestHarness(); + }); + test('lists jobs with query filters and lists the active index separately', async () => { + const admin = await createTestAccount(harness); + const updated = await setUserACLs(harness, admin, ['admin:authenticate', 'jobs:view']); + const listed = await createBuilder(harness, `${updated.token}`) + .get('/admin/jobs?limit=10&max_lookback_days=1&status=queued') + .expect(HTTP_STATUS.OK) + .execute(); + expect(Array.isArray(listed.jobs)).toBe(true); + const active = await createBuilder(harness, `${updated.token}`) + .get('/admin/jobs/active') + .expect(HTTP_STATUS.OK) + .execute(); + expect(Array.isArray(active.jobs)).toBe(true); + }); + test('rejects a partially supplied list cursor', async () => { + const admin = await createTestAccount(harness); + const updated = await setUserACLs(harness, admin, ['admin:authenticate', 'jobs:view']); + await createBuilder(harness, `${updated.token}`) + .get('/admin/jobs?cursor_job_id=123') + .expect(HTTP_STATUS.BAD_REQUEST) + .execute(); + }); + test('rejects a list cursor whose bucket day is not a calendar date', async () => { + const admin = await createTestAccount(harness); + const updated = await setUserACLs(harness, admin, ['admin:authenticate', 'jobs:view']); + const response = await createBuilder<{ + code: string; + errors: Array<{ + path: string; + }>; + }>(harness, `${updated.token}`) + .get('/admin/jobs?cursor_bucket_day=nonsense&cursor_created_at=2026-01-01T00:00:00.000Z&cursor_job_id=123') + .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') + .execute(); + expect(response.errors[0]?.path).toBe('cursor_bucket_day'); + }); + test('rejects a list cursor whose creation time is not an ISO 8601 timestamp', async () => { + const admin = await createTestAccount(harness); + const updated = await setUserACLs(harness, admin, ['admin:authenticate', 'jobs:view']); + const response = await createBuilder<{ + code: string; + errors: Array<{ + path: string; + }>; + }>(harness, `${updated.token}`) + .get('/admin/jobs?cursor_bucket_day=2026-01-01&cursor_created_at=yesterday&cursor_job_id=123') + .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') + .execute(); + expect(response.errors[0]?.path).toBe('cursor_created_at'); + }); + test('queues a bulk job and exposes it through the job routes', async () => { + const admin = await createTestAccount(harness); + const updated = await setUserACLs(harness, admin, [ + 'admin:authenticate', + 'bulk:update:user_flags', + 'jobs:view', + 'jobs:cancel', + ]); + const target = await createTestAccount(harness); + const queued = await createBuilder(harness, `${updated.token}`) + .post('/admin/bulk-jobs') + .body({task: 'update_user_flags', user_ids: [target.userId], add_flags: [], remove_flags: []}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(queued.job_id).toBeTruthy(); + const cancellation = await createBuilder<{cancelled: boolean}>(harness, `${updated.token}`) + .put(`/admin/jobs/${queued.job_id}/cancellation`) + .body({}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(typeof cancellation.cancelled).toBe('boolean'); + }); + test('answers an unknown job identifier with job_not_found', async () => { + const admin = await createTestAccount(harness); + const updated = await setUserACLs(harness, admin, ['admin:authenticate', 'jobs:view']); + await createBuilder(harness, `${updated.token}`) + .get('/admin/jobs/123456789012345678') + .expect(HTTP_STATUS.NOT_FOUND) + .execute(); + }); + test('job cancellation requires jobs:cancel rather than jobs:view', async () => { + const admin = await createTestAccount(harness); + const updated = await setUserACLs(harness, admin, ['admin:authenticate', 'jobs:view']); + await createBuilder(harness, `${updated.token}`) + .put('/admin/jobs/123456789012345678/cancellation') + .body({}) + .expect(HTTP_STATUS.FORBIDDEN) + .execute(); + }); + test('holding one bulk ACL does not authorise another task', async () => { + const admin = await createTestAccount(harness); + const updated = await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:update:user_flags']); + await createBuilder(harness, `${updated.token}`) + .post('/admin/bulk-jobs') + .body({task: 'add_guild_members', guild_id: '1', user_ids: ['2']}) + .expect(HTTP_STATUS.FORBIDDEN) + .execute(); + }); + test('rejects a malformed bulk job body before evaluating any ACL', async () => { + const admin = await createTestAccount(harness); + const updated = await setUserACLs(harness, admin, ['admin:authenticate']); + await createBuilder(harness, `${updated.token}`) + .post('/admin/bulk-jobs') + .body({task: 'not_a_task'}) + .expect(HTTP_STATUS.BAD_REQUEST) + .execute(); + }); +}); diff --git a/fluxer_api/src/api/admin/tests/AdminLastActiveIpSearch.test.ts b/fluxer_api/src/api/admin/tests/AdminLastActiveIpSearch.test.ts index ce30f0dd3..88a7c8efb 100644 --- a/fluxer_api/src/api/admin/tests/AdminLastActiveIpSearch.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminLastActiveIpSearch.test.ts @@ -36,26 +36,26 @@ describe('Admin last active IP search', () => { }); test('finds a user by exact IPv4 last active IP', async () => { const admin = await createTestAccount(harness); - await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']); + await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup', 'user:view:ip']); const targetUser = await createTestAccount(harness); await setLastActiveIp(harness, targetUser.token, '198.51.100.91'); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/search') - .body({last_active_ip: '198.51.100.91', limit: 10, offset: 0}) + .get(`/admin/users?last_active_ip=${encodeURIComponent('198.51.100.91')}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(result.users.find((user) => user.id === targetUser.userId)).toBeDefined(); }); test('matches IPv6 last active addresses by /64 trust key', async () => { const admin = await createTestAccount(harness); - await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']); + await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup', 'user:view:ip']); const firstUser = await createTestAccount(harness); const secondUser = await createTestAccount(harness); await setLastActiveIp(harness, firstUser.token, '2a01:e0a:d10:95b0:8f54:410e:f290:1c66'); await setLastActiveIp(harness, secondUser.token, '2a01:e0a:d10:95b0:01e4:53a8:d0dd:7733'); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/search') - .body({last_active_ip: '2a01:e0a:d10:95b0:b53f:16d3:aff2:9b0f', limit: 10, offset: 0}) + .get( + `/admin/users?last_active_ip=${encodeURIComponent('2a01:e0a:d10:95b0:b53f:16d3:aff2:9b0f')}&limit=10&offset=0`, + ) .expect(HTTP_STATUS.OK) .execute(); expect(result.users.find((user) => user.id === firstUser.userId)).toBeDefined(); @@ -63,14 +63,13 @@ describe('Admin last active IP search', () => { }); test('keeps IPv4 last active searches exact', async () => { const admin = await createTestAccount(harness); - await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']); + await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup', 'user:view:ip']); const firstUser = await createTestAccount(harness); const secondUser = await createTestAccount(harness); await setLastActiveIp(harness, firstUser.token, '198.51.100.91'); await setLastActiveIp(harness, secondUser.token, '198.51.100.92'); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/search') - .body({last_active_ip: '198.51.100.91', limit: 10, offset: 0}) + .get(`/admin/users?last_active_ip=${encodeURIComponent('198.51.100.91')}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(result.users.find((user) => user.id === firstUser.userId)).toBeDefined(); diff --git a/fluxer_api/src/api/admin/tests/AdminOAuth2ApplicationRequirement.test.ts b/fluxer_api/src/api/admin/tests/AdminOAuth2ApplicationRequirement.test.ts index a3b7613d8..187a69643 100644 --- a/fluxer_api/src/api/admin/tests/AdminOAuth2ApplicationRequirement.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminOAuth2ApplicationRequirement.test.ts @@ -52,8 +52,7 @@ describe('Admin OAuth2 Application Requirement', () => { const oauth2Token = await createOAuth2Token(harness, admin.userId, ['identify', 'email']); await createBuilder(harness, `Bearer ${oauth2Token.token}`) - .post('/admin/users/lookup') - .body({user_ids: [admin.userId]}) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.FORBIDDEN, 'ACCESS_DENIED') .execute(); }); @@ -69,8 +68,7 @@ describe('Admin OAuth2 Application Requirement', () => { ]); await createBuilder(harness, `Bearer ${oauth2Token.token}`) - .post('/admin/guilds/lookup') - .body({guild_id: '123'}) + .get('/admin/guilds/123') .expect(HTTP_STATUS.FORBIDDEN, 'ACCESS_DENIED') .execute(); }); @@ -81,18 +79,15 @@ describe('Admin OAuth2 Application Requirement', () => { const oauth2Token = await createOAuth2Token(harness, admin.userId, ['identify', 'email']); await createBuilder(harness, `Bearer ${oauth2Token.token}`) - .post('/admin/users/lookup') - .body({user_ids: [admin.userId]}) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.FORBIDDEN, 'ACCESS_DENIED') .execute(); await createBuilder(harness, `Bearer ${oauth2Token.token}`) - .post('/admin/guilds/lookup') - .body({guild_id: '123'}) + .get('/admin/guilds/123') .expect(HTTP_STATUS.FORBIDDEN, 'ACCESS_DENIED') .execute(); await createBuilder(harness, `Bearer ${oauth2Token.token}`) - .post('/admin/audit-logs') - .body({limit: 10}) + .get('/admin/audit-logs?limit=10') .expect(HTTP_STATUS.FORBIDDEN, 'ACCESS_DENIED') .execute(); }); @@ -105,8 +100,7 @@ describe('Admin OAuth2 Application Requirement', () => { const oauth2Token = await createAdminOAuth2Token(harness, admin.userId); await createBuilder(harness, `Bearer ${oauth2Token.token}`) - .post('/admin/users/lookup') - .body({user_ids: [admin.userId]}) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.OK) .execute(); }); @@ -117,18 +111,15 @@ describe('Admin OAuth2 Application Requirement', () => { const oauth2Token = await createAdminOAuth2Token(harness, admin.userId); await createBuilder(harness, `Bearer ${oauth2Token.token}`) - .post('/admin/users/lookup') - .body({user_ids: [admin.userId]}) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.OK) .execute(); await createBuilder(harness, `Bearer ${oauth2Token.token}`) - .post('/admin/guilds/lookup') - .body({guild_id: '123'}) + .get('/admin/guilds/123') .expect(HTTP_STATUS.OK) .execute(); await createBuilder(harness, `Bearer ${oauth2Token.token}`) - .post('/admin/audit-logs') - .body({limit: 10}) + .get('/admin/audit-logs?limit=10') .expect(HTTP_STATUS.OK) .execute(); }); @@ -139,8 +130,7 @@ describe('Admin OAuth2 Application Requirement', () => { const oauth2Token = await createAdminOAuth2Token(harness, admin.userId); await createBuilder(harness, `Bearer ${oauth2Token.token}`) - .post('/admin/users/lookup') - .body({user_ids: [admin.userId]}) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL') .execute(); }); @@ -150,8 +140,7 @@ describe('Admin OAuth2 Application Requirement', () => { const oauth2Token = await createAdminOAuth2Token(harness, user.userId); await createBuilder(harness, `Bearer ${oauth2Token.token}`) - .post('/admin/users/lookup') - .body({user_ids: [user.userId]}) + .get(`/admin/users/${user.userId}`) .expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS') .execute(); }); @@ -163,8 +152,7 @@ describe('Admin OAuth2 Application Requirement', () => { await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/lookup') - .body({user_ids: [admin.userId]}) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.OK) .execute(); }); @@ -173,8 +161,7 @@ describe('Admin OAuth2 Application Requirement', () => { const user = await createTestAccount(harness); await createBuilder(harness, `${user.token}`) - .post('/admin/users/lookup') - .body({user_ids: [user.userId]}) + .get(`/admin/users/${user.userId}`) .expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS') .execute(); }); @@ -184,11 +171,7 @@ describe('Admin OAuth2 Application Requirement', () => { await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']); const apiKey = await createAdminApiKey(harness, admin, 'Test Key', ['user:lookup'], null); - await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({user_ids: [admin.userId]}) - .expect(HTTP_STATUS.OK) - .execute(); + await createBuilder(harness, apiKey.token).get(`/admin/users/${admin.userId}`).expect(HTTP_STATUS.OK).execute(); }); test('admin API key without required ACL cannot access endpoint', async () => { @@ -197,8 +180,7 @@ describe('Admin OAuth2 Application Requirement', () => { const apiKey = await createAdminApiKey(harness, admin, 'Limited Key', ['audit_log:view'], null); await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({user_ids: [admin.userId]}) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL') .execute(); }); diff --git a/fluxer_api/src/api/admin/tests/AdminRepositoryIpBans.test.ts b/fluxer_api/src/api/admin/tests/AdminRepositoryIpBans.test.ts index 00e984586..c3fc88448 100644 --- a/fluxer_api/src/api/admin/tests/AdminRepositoryIpBans.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminRepositoryIpBans.test.ts @@ -1,10 +1,16 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; +import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils'; import {upsertOne} from '../../database/CassandraQueryExecution'; import {BannedIps} from '../../Tables'; import type {ApiTestHarness} from '../../test/ApiTestHarness'; import {createApiTestHarness} from '../../test/ApiTestHarness'; +import {HTTP_STATUS} from '../../test/TestConstants'; +import {createBuilder} from '../../test/TestRequestBuilder'; import {AdminRepository} from '../AdminRepository'; describe('AdminRepository IP ban canonicalization', () => { @@ -75,3 +81,39 @@ describe('AdminRepository IP ban canonicalization', () => { expect(await storedIps()).toEqual([]); }); }); + +describe('Admin blocklist entry body parsing', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + }); + + afterAll(async () => { + await harness.shutdown(); + }); + + it('rejects a blocklist entry body that is not valid JSON', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.BAN_IP_ADD]); + const {json} = await createBuilder<{ + code: string; + errors: Array<{ + path: string; + code: string; + }>; + }>(harness, `${admin.token}`) + .post('/admin/blocklists/ip/entries') + .body('{not json') + .expect(HTTP_STATUS.BAD_REQUEST) + .executeWithResponse(); + expect(json.code).toBe(APIErrorCodes.INVALID_FORM_BODY); + const bodyError = json.errors.find((entry) => entry.path === 'body'); + expect(bodyError).toBeDefined(); + expect(bodyError?.code).toBe(ValidationErrorCodes.INVALID_FORMAT); + }); +}); diff --git a/fluxer_api/src/api/admin/tests/AdminSearchEndpoints.test.ts b/fluxer_api/src/api/admin/tests/AdminSearchEndpoints.test.ts index 9f3be9ae4..0879c43f6 100644 --- a/fluxer_api/src/api/admin/tests/AdminSearchEndpoints.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminSearchEndpoints.test.ts @@ -25,13 +25,12 @@ describe('Admin Search Endpoints', () => { afterEach(async () => { await harness.shutdown(); }); - describe('/admin/users/search', () => { + describe('GET /admin/users', () => { test('requires user:lookup ACL', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/search') - .body({query: 'test', limit: 10, offset: 0}) + .get(`/admin/users?q=${encodeURIComponent('test')}&limit=10&offset=0`) .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -42,8 +41,7 @@ describe('Admin Search Endpoints', () => { users: Array; total: number; }>(harness, `${admin.token}`) - .post('/admin/users/search') - .body({query: 'nonexistent-user-query-xyz', limit: 10, offset: 0}) + .get(`/admin/users?q=${encodeURIComponent('nonexistent-user-query-xyz')}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(result.users).toEqual([]); @@ -62,8 +60,7 @@ describe('Admin Search Endpoints', () => { }>; total: number; }>(harness, `${admin.token}`) - .post('/admin/users/search') - .body({query: targetUser.username, limit: 10, offset: 0}) + .get(`/admin/users?q=${encodeURIComponent(targetUser.username ?? '')}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(result.total).toBeGreaterThanOrEqual(1); @@ -78,15 +75,14 @@ describe('Admin Search Endpoints', () => { users: Array; total: number; }>(harness, `${admin.token}`) - .post('/admin/users/search') - .body({limit: 1, offset: 0}) + .get(`/admin/users?limit=1&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(result.users.length).toBeLessThanOrEqual(1); }); test('supports searching by last active IP', async () => { const admin = await createTestAccount(harness); - await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']); + await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup', 'user:view:ip']); const targetUser = await createTestAccount(harness); await setLastActiveIp(harness, targetUser.token, '198.51.100.91'); const result = await createBuilder<{ @@ -95,21 +91,19 @@ describe('Admin Search Endpoints', () => { }>; total: number; }>(harness, `${admin.token}`) - .post('/admin/users/search') - .body({last_active_ip: '198.51.100.91', limit: 10, offset: 0}) + .get(`/admin/users?last_active_ip=${encodeURIComponent('198.51.100.91')}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(result.total).toBeGreaterThanOrEqual(1); expect(result.users.find((user) => user.id === targetUser.userId)).toBeDefined(); }); }); - describe('/admin/users/list-dm-channels', () => { + describe('GET /admin/users/{user_id}/dm-channels', () => { test('requires user:list:dm_channels ACL', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/list-dm-channels') - .body({user_id: admin.userId, limit: 10}) + .get(`/admin/users/${admin.userId}/dm-channels?limit=10`) .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -135,8 +129,7 @@ describe('Admin Search Endpoints', () => { is_open: boolean; }>; }>(harness, `${admin.token}`) - .post('/admin/users/list-dm-channels') - .body({user_id: subjectUser.userId, limit: 2}) + .get(`/admin/users/${subjectUser.userId}/dm-channels?limit=2`) .expect(HTTP_STATUS.OK) .execute(); expect(firstPage.channels).toHaveLength(2); @@ -155,8 +148,7 @@ describe('Admin Search Endpoints', () => { is_open: boolean; }>; }>(harness, `${admin.token}`) - .post('/admin/users/list-dm-channels') - .body({user_id: subjectUser.userId, limit: 2, before: firstPage.channels[1]!.channel_id}) + .get(`/admin/users/${subjectUser.userId}/dm-channels?limit=2&before=${firstPage.channels[1]!.channel_id}`) .expect(HTTP_STATUS.OK) .execute(); expect(secondPage.channels).toHaveLength(1); @@ -171,8 +163,7 @@ describe('Admin Search Endpoints', () => { is_open: boolean; }>; }>(harness, `${admin.token}`) - .post('/admin/users/list-dm-channels') - .body({user_id: subjectUser.userId, limit: 2, after: secondPage.channels[0]!.channel_id}) + .get(`/admin/users/${subjectUser.userId}/dm-channels?limit=2&after=${secondPage.channels[0]!.channel_id}`) .expect(HTTP_STATUS.OK) .execute(); expect(previousPage.channels.map((channel) => channel.channel_id)).toEqual( @@ -184,19 +175,17 @@ describe('Admin Search Endpoints', () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'user:list:dm_channels']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/list-dm-channels') - .body({user_id: admin.userId, limit: 10, before: '1', after: '2'}) + .get(`/admin/users/${admin.userId}/dm-channels?limit=10&before=1&after=2`) .expect(HTTP_STATUS.BAD_REQUEST) .execute(); }); }); - describe('/admin/guilds/search', () => { + describe('GET /admin/guilds', () => { test('requires guild:lookup ACL', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate']); await createBuilder(harness, `${admin.token}`) - .post('/admin/guilds/search') - .body({query: 'test', limit: 10, offset: 0}) + .get('/admin/guilds?q=test&limit=10&offset=0') .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -207,8 +196,7 @@ describe('Admin Search Endpoints', () => { guilds: Array; total: number; }>(harness, `${admin.token}`) - .post('/admin/guilds/search') - .body({query: 'nonexistent-guild-query-xyz', limit: 10, offset: 0}) + .get('/admin/guilds?q=nonexistent-guild-query-xyz&limit=10&offset=0') .expect(HTTP_STATUS.OK) .execute(); expect(result.guilds).toEqual([]); @@ -226,8 +214,7 @@ describe('Admin Search Endpoints', () => { }>; total: number; }>(harness, `${admin.token}`) - .post('/admin/guilds/search') - .body({query: guildName, limit: 10, offset: 0}) + .get(`/admin/guilds?q=${encodeURIComponent(guildName)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(result.total).toBeGreaterThanOrEqual(1); @@ -236,38 +223,46 @@ describe('Admin Search Endpoints', () => { expect(foundGuild?.name).toBe(guildName); }); }); - describe('/admin/reports/search', () => { + describe('/admin/reports', () => { test('requires report:view ACL', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate']); await createBuilder(harness, `${admin.token}`) - .post('/admin/reports/search') - .body({limit: 10, offset: 0}) + .get('/admin/reports?q=example&limit=10&offset=0') .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); - test('returns report list response', async () => { + test('returns report list response when searching the report index', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'report:view']); const result = await createBuilder<{ reports: Array; total: number; }>(harness, `${admin.token}`) - .post('/admin/reports/search') - .body({limit: 10, offset: 0}) + .get('/admin/reports?q=example&limit=10&offset=0') .expect(HTTP_STATUS.OK) .execute(); expect(Array.isArray(result.reports)).toBe(true); expect(result.total).toBeGreaterThanOrEqual(result.reports.length); }); + test('returns report list response without search filters', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'report:view']); + const result = await createBuilder<{ + reports: Array; + }>(harness, `${admin.token}`) + .get('/admin/reports?status=pending&limit=10&offset=0') + .expect(HTTP_STATUS.OK) + .execute(); + expect(Array.isArray(result.reports)).toBe(true); + }); }); - describe('/admin/audit-logs/search', () => { + describe('/admin/audit-logs (search)', () => { test('requires audit_log:view ACL', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate']); await createBuilder(harness, `${admin.token}`) - .post('/admin/audit-logs/search') - .body({limit: 10, offset: 0}) + .get('/admin/audit-logs?q=set_acls&limit=10&offset=0') .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -278,8 +273,7 @@ describe('Admin Search Endpoints', () => { logs: Array; total: number; }>(harness, `${admin.token}`) - .post('/admin/audit-logs/search') - .body({limit: 10, offset: 0}) + .get('/admin/audit-logs?q=set_acls&limit=10&offset=0') .expect(HTTP_STATUS.OK) .execute(); expect(result).toHaveProperty('logs'); @@ -292,8 +286,8 @@ describe('Admin Search Endpoints', () => { await setUserACLs(harness, admin, ['admin:authenticate', 'audit_log:view', 'user:update_acls', 'acl:set:user']); const targetUser = await createTestAccount(harness); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/set-acls') - .body({user_id: targetUser.userId, acls: ['admin:authenticate']}) + .put(`/admin/users/${targetUser.userId}/acls`) + .body({acls: ['admin:authenticate']}) .expect(HTTP_STATUS.OK) .execute(); const result = await createBuilder<{ @@ -303,8 +297,7 @@ describe('Admin Search Endpoints', () => { }>; total: number; }>(harness, `${admin.token}`) - .post('/admin/audit-logs/search') - .body({admin_user_id: admin.userId, limit: 50, offset: 0}) + .get(`/admin/audit-logs?admin_user_id=${admin.userId}&limit=50&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(result.total).toBeGreaterThanOrEqual(1); @@ -317,8 +310,8 @@ describe('Admin Search Endpoints', () => { await setUserACLs(harness, admin, ['admin:authenticate', 'audit_log:view', 'user:update_acls', 'acl:set:user']); const targetUser = await createTestAccount(harness); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/set-acls') - .body({user_id: targetUser.userId, acls: ['admin:authenticate']}) + .put(`/admin/users/${targetUser.userId}/acls`) + .body({acls: ['admin:authenticate']}) .expect(HTTP_STATUS.OK) .execute(); const result = await createBuilder<{ @@ -328,8 +321,7 @@ describe('Admin Search Endpoints', () => { }>; total: number; }>(harness, `${admin.token}`) - .post('/admin/audit-logs/search') - .body({target_id: targetUser.userId, limit: 50, offset: 0}) + .get(`/admin/audit-logs?target_id=${targetUser.userId}&limit=50&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(result.total).toBeGreaterThanOrEqual(1); @@ -342,8 +334,8 @@ describe('Admin Search Endpoints', () => { await setUserACLs(harness, admin, ['admin:authenticate', 'audit_log:view', 'user:update_acls', 'acl:set:user']); const targetUser = await createTestAccount(harness); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/set-acls') - .body({user_id: targetUser.userId, acls: ['admin:authenticate']}) + .put(`/admin/users/${targetUser.userId}/acls`) + .body({acls: ['admin:authenticate']}) .header('X-Audit-Log-Reason', 'unique-test-reason-xyz') .expect(HTTP_STATUS.OK) .execute(); @@ -353,8 +345,7 @@ describe('Admin Search Endpoints', () => { }>; total: number; }>(harness, `${admin.token}`) - .post('/admin/audit-logs/search') - .body({query: 'set_acls', limit: 50, offset: 0}) + .get('/admin/audit-logs?q=set_acls&limit=50&offset=0') .expect(HTTP_STATUS.OK) .execute(); expect(result).toHaveProperty('logs'); @@ -369,8 +360,7 @@ describe('Admin Search Endpoints', () => { }>; total: number; }>(harness, `${admin.token}`) - .post('/admin/audit-logs/search') - .body({limit: 10, offset: 0, sort_by: 'createdAt', sort_order: 'desc'}) + .get('/admin/audit-logs?q=set_acls&limit=10&offset=0&sort_by=createdAt&sort_order=desc') .expect(HTTP_STATUS.OK) .execute(); const resultAsc = await createBuilder<{ @@ -379,21 +369,37 @@ describe('Admin Search Endpoints', () => { }>; total: number; }>(harness, `${admin.token}`) - .post('/admin/audit-logs/search') - .body({limit: 10, offset: 0, sort_by: 'createdAt', sort_order: 'asc'}) + .get('/admin/audit-logs?q=set_acls&limit=10&offset=0&sort_by=createdAt&sort_order=asc') .expect(HTTP_STATUS.OK) .execute(); expect(resultDesc).toHaveProperty('logs'); expect(resultAsc).toHaveProperty('logs'); }); }); + describe('/admin/audit-logs/{log_id}', () => { + test('requires audit_log:view ACL', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate']); + await createBuilder(harness, `${admin.token}`) + .get('/admin/audit-logs/999999999999999999') + .expect(HTTP_STATUS.FORBIDDEN) + .execute(); + }); + test('returns 404 for an unknown entry', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'audit_log:view']); + await createBuilder(harness, `${admin.token}`) + .get('/admin/audit-logs/999999999999999999') + .expect(HTTP_STATUS.NOT_FOUND) + .execute(); + }); + }); describe('/admin/audit-logs (list)', () => { test('requires audit_log:view ACL', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate']); await createBuilder(harness, `${admin.token}`) - .post('/admin/audit-logs') - .body({limit: 10, offset: 0}) + .get('/admin/audit-logs?limit=10&offset=0') .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -404,8 +410,7 @@ describe('Admin Search Endpoints', () => { logs: Array; total: number; }>(harness, `${admin.token}`) - .post('/admin/audit-logs') - .body({limit: 10, offset: 0}) + .get('/admin/audit-logs?limit=10&offset=0') .expect(HTTP_STATUS.OK) .execute(); expect(result).toHaveProperty('logs'); @@ -413,4 +418,93 @@ describe('Admin Search Endpoints', () => { expect(Array.isArray(result.logs)).toBe(true); }); }); + describe('POST /admin/search/indexes/{index_name}/refreshes', () => { + test('rejects a channel_messages refresh without guild_id', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'guild:lookup']); + const response = await createBuilder<{ + code: string; + errors: Array<{ + path: string; + }>; + }>(harness, `${admin.token}`) + .post('/admin/search/indexes/channel_messages/refreshes') + .body({}) + .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') + .execute(); + expect(response.errors[0]?.path).toBe('guild_id'); + }); + test('rejects a guild_members refresh without guild_id', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'guild:lookup']); + const response = await createBuilder<{ + code: string; + errors: Array<{ + path: string; + }>; + }>(harness, `${admin.token}`) + .post('/admin/search/indexes/guild_members/refreshes') + .body({}) + .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') + .execute(); + expect(response.errors[0]?.path).toBe('guild_id'); + }); + test('rejects a favorite_memes refresh without user_id', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'guild:lookup']); + const response = await createBuilder<{ + code: string; + errors: Array<{ + path: string; + }>; + }>(harness, `${admin.token}`) + .post('/admin/search/indexes/favorite_memes/refreshes') + .body({}) + .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') + .execute(); + expect(response.errors[0]?.path).toBe('user_id'); + }); + }); +}); + +describe('Admin Search Endpoints without a search backend', () => { + let harness: ApiTestHarness; + beforeEach(async () => { + harness = await createApiTestHarness({search: 'disabled'}); + }); + afterEach(async () => { + await harness.shutdown(); + }); + test('GET /admin/guilds answers 403 FEATURE_TEMPORARILY_DISABLED', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'guild:lookup']); + await createBuilder(harness, `${admin.token}`) + .get('/admin/guilds?q=test&limit=10&offset=0') + .expect(HTTP_STATUS.FORBIDDEN, 'FEATURE_TEMPORARILY_DISABLED') + .execute(); + }); + test('GET /admin/users with q answers 403 FEATURE_TEMPORARILY_DISABLED', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']); + await createBuilder(harness, `${admin.token}`) + .get('/admin/users?q=test&limit=10&offset=0') + .expect(HTTP_STATUS.FORBIDDEN, 'FEATURE_TEMPORARILY_DISABLED') + .execute(); + }); + test('GET /admin/reports answers 403 FEATURE_TEMPORARILY_DISABLED on the status branch', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'report:view']); + await createBuilder(harness, `${admin.token}`) + .get('/admin/reports?status=pending&limit=10&offset=0') + .expect(HTTP_STATUS.FORBIDDEN, 'FEATURE_TEMPORARILY_DISABLED') + .execute(); + }); + test('GET /admin/reports answers 403 FEATURE_TEMPORARILY_DISABLED on the search branch', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'report:view']); + await createBuilder(harness, `${admin.token}`) + .get('/admin/reports?q=example&limit=10&offset=0') + .expect(HTTP_STATUS.FORBIDDEN, 'FEATURE_TEMPORARILY_DISABLED') + .execute(); + }); }); diff --git a/fluxer_api/src/api/admin/tests/AdminSearchFieldCoverage.test.ts b/fluxer_api/src/api/admin/tests/AdminSearchFieldCoverage.test.ts index 376de5d38..d7a0c1757 100644 --- a/fluxer_api/src/api/admin/tests/AdminSearchFieldCoverage.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminSearchFieldCoverage.test.ts @@ -60,8 +60,7 @@ describe('Admin Search Field Coverage', () => { const targetUser = await createTestAccount(harness, {email: uniqueEmail}); await createTestAccount(harness, {email: `other-user-${Date.now()}@different.example`}); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/search') - .body({query: uniqueEmail, limit: 10, offset: 0}) + .get(`/admin/users?q=${encodeURIComponent(uniqueEmail)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(result.total).toBeGreaterThanOrEqual(1); @@ -77,8 +76,7 @@ describe('Admin Search Field Coverage', () => { const userA = await createTestAccount(harness, {email: emailA}); const userB = await createTestAccount(harness, {email: emailB}); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/search') - .body({query: emailA, limit: 10, offset: 0}) + .get(`/admin/users?q=${encodeURIComponent(emailA)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); const foundA = result.users.find((u) => u.id === userA.userId); @@ -92,8 +90,7 @@ describe('Admin Search Field Coverage', () => { const domain = `partialdomain${Date.now()}.example`; const user = await createTestAccount(harness, {email: `user@${domain}`}); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/search') - .body({query: domain, limit: 10, offset: 0}) + .get(`/admin/users?q=${encodeURIComponent(domain)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(result.total).toBeGreaterThanOrEqual(1); @@ -109,8 +106,7 @@ describe('Admin Search Field Coverage', () => { const targetUser = await createTestAccount(harness, {username: uniqueUsername}); await createTestAccount(harness, {username: `yother_${Date.now()}`}); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/search') - .body({query: uniqueUsername, limit: 10, offset: 0}) + .get(`/admin/users?q=${encodeURIComponent(uniqueUsername)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(result.total).toBeGreaterThanOrEqual(1); @@ -127,8 +123,7 @@ describe('Admin Search Field Coverage', () => { const userA = await createTestAccount(harness, {username: usernameA}); const userB = await createTestAccount(harness, {username: usernameB}); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/search') - .body({query: usernameA, limit: 10, offset: 0}) + .get(`/admin/users?q=${encodeURIComponent(usernameA)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); const foundA = result.users.find((u) => u.id === userA.userId); @@ -144,8 +139,7 @@ describe('Admin Search Field Coverage', () => { const targetUser = await createTestAccount(harness); await createTestAccount(harness); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/search') - .body({query: targetUser.userId, limit: 10, offset: 0}) + .get(`/admin/users?q=${encodeURIComponent(targetUser.userId)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(result.total).toBeGreaterThanOrEqual(1); @@ -158,8 +152,7 @@ describe('Admin Search Field Coverage', () => { await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']); const targetUser = await createTestAccount(harness); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/search') - .body({query: targetUser.userId, limit: 10, offset: 0}) + .get(`/admin/users?q=${encodeURIComponent(targetUser.userId)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(result.total).toBeGreaterThanOrEqual(1); @@ -171,8 +164,7 @@ describe('Admin Search Field Coverage', () => { await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']); const targetUser = await createTestAccount(harness); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/search') - .body({query: targetUser.userId, limit: 10, offset: 0}) + .get(`/admin/users?q=${encodeURIComponent(targetUser.userId)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); const matchingUsers = result.users.filter((u) => u.id === targetUser.userId); @@ -190,8 +182,7 @@ describe('Admin Search Field Coverage', () => { await setLastActiveIp(harness, targetUser.token, matchingIp); await setLastActiveIp(harness, otherUser.token, nonMatchingIp); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/search') - .body({last_active_ip: matchingIp, limit: 10, offset: 0}) + .get(`/admin/users?last_active_ip=${encodeURIComponent(matchingIp)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(result.total).toBeGreaterThanOrEqual(1); @@ -208,8 +199,7 @@ describe('Admin Search Field Coverage', () => { const targetUser = await createTestAccount(harness, {email, username}); await setContactInfo(harness, targetUser.userId, {has_verified_phone: true}); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/search') - .body({query: email, limit: 10, offset: 0}) + .get(`/admin/users?q=${encodeURIComponent(email)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); const found = result.users.find((u) => u.id === targetUser.userId); @@ -240,22 +230,19 @@ describe('Admin Search Field Coverage', () => { const userA = await createTestAccount(harness, {email: emailA, username: usernameA}); const userB = await createTestAccount(harness, {email: emailB, username: usernameB}); const searchByEmailA = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/search') - .body({query: emailA, limit: 10, offset: 0}) + .get(`/admin/users?q=${encodeURIComponent(emailA)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(searchByEmailA.users.find((u) => u.id === userA.userId)).toBeDefined(); expect(searchByEmailA.users.find((u) => u.id === userB.userId)).toBeUndefined(); const searchByUsernameB = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/search') - .body({query: usernameB, limit: 10, offset: 0}) + .get(`/admin/users?q=${encodeURIComponent(usernameB)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(searchByUsernameB.users.find((u) => u.id === userB.userId)).toBeDefined(); expect(searchByUsernameB.users.find((u) => u.id === userA.userId)).toBeUndefined(); const searchByIdB = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/search') - .body({query: userB.userId, limit: 10, offset: 0}) + .get(`/admin/users?q=${encodeURIComponent(userB.userId)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(searchByIdB.users.find((u) => u.id === userB.userId)).toBeDefined(); @@ -272,8 +259,7 @@ describe('Admin Search Field Coverage', () => { const guildA = await createGuild(harness, admin.token, nameA); const guildB = await createGuild(harness, admin.token, nameB); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/guilds/search') - .body({query: nameA, limit: 10, offset: 0}) + .get(`/admin/guilds?q=${encodeURIComponent(nameA)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(result.total).toBeGreaterThanOrEqual(1); @@ -292,8 +278,7 @@ describe('Admin Search Field Coverage', () => { const guildA = await createGuild(harness, admin.token, uniqueNameA); const guildB = await createGuild(harness, admin.token, uniqueNameB); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/guilds/search') - .body({query: uniqueNameA, limit: 10, offset: 0}) + .get(`/admin/guilds?q=${encodeURIComponent(uniqueNameA)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); const foundA = result.guilds.find((g) => g.id === guildA.id); @@ -309,8 +294,7 @@ describe('Admin Search Field Coverage', () => { const guildA = await createGuild(harness, admin.token, `ID Search A ${Date.now()}`); const guildB = await createGuild(harness, admin.token, `ID Search B ${Date.now()}`); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/guilds/search') - .body({query: guildA.id, limit: 10, offset: 0}) + .get(`/admin/guilds?q=${encodeURIComponent(guildA.id)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(result.total).toBeGreaterThanOrEqual(1); @@ -325,8 +309,7 @@ describe('Admin Search Field Coverage', () => { await setUserACLs(harness, admin, ['admin:authenticate', 'guild:lookup']); const guild = await createGuild(harness, admin.token, `Offset Guild ${Date.now()}`); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/guilds/search') - .body({query: guild.id, limit: 10, offset: 1}) + .get(`/admin/guilds?q=${encodeURIComponent(guild.id)}&limit=10&offset=1`) .expect(HTTP_STATUS.OK) .execute(); expect(result.guilds).toEqual([]); @@ -340,8 +323,7 @@ describe('Admin Search Field Coverage', () => { const guildName = `Field Check Guild ${Date.now()}`; const guild = await createGuild(harness, admin.token, guildName); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/guilds/search') - .body({query: guildName, limit: 10, offset: 0}) + .get(`/admin/guilds?q=${encodeURIComponent(guildName)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); const found = result.guilds.find((g) => g.id === guild.id); @@ -363,16 +345,14 @@ describe('Admin Search Field Coverage', () => { const guildByAdmin = await createGuild(harness, admin.token, `Admin Owned ${ts}`); const guildByOther = await createGuild(harness, otherOwner.token, `Other Owned ${ts}`); const resultAdmin = await createBuilder(harness, `${admin.token}`) - .post('/admin/guilds/search') - .body({query: `Admin Owned ${ts}`, limit: 10, offset: 0}) + .get(`/admin/guilds?q=${encodeURIComponent(`Admin Owned ${ts}`)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); const foundAdmin = resultAdmin.guilds.find((g) => g.id === guildByAdmin.id); expect(foundAdmin).toBeDefined(); expect(foundAdmin!.owner_id).toBe(admin.userId); const resultOther = await createBuilder(harness, `${admin.token}`) - .post('/admin/guilds/search') - .body({query: `Other Owned ${ts}`, limit: 10, offset: 0}) + .get(`/admin/guilds?q=${encodeURIComponent(`Other Owned ${ts}`)}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); const foundOther = resultOther.guilds.find((g) => g.id === guildByOther.id); @@ -386,8 +366,7 @@ describe('Admin Search Field Coverage', () => { await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']); await createTestAccount(harness); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/search') - .body({limit: 10, offset: 0}) + .get(`/admin/users?limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(result.total).toBeGreaterThanOrEqual(1); @@ -398,8 +377,7 @@ describe('Admin Search Field Coverage', () => { await setUserACLs(harness, admin, ['admin:authenticate', 'guild:lookup']); await createGuild(harness, admin.token, `Omitted Query Guild ${Date.now()}`); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/guilds/search') - .body({limit: 10, offset: 0}) + .get('/admin/guilds?limit=10&offset=0') .expect(HTTP_STATUS.OK) .execute(); expect(result.total).toBeGreaterThanOrEqual(1); @@ -409,8 +387,7 @@ describe('Admin Search Field Coverage', () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/search') - .body({query: 'zzz-impossible-match-query-xyzzy-99999', limit: 10, offset: 0}) + .get(`/admin/users?q=${encodeURIComponent('zzz-impossible-match-query-xyzzy-99999')}&limit=10&offset=0`) .expect(HTTP_STATUS.OK) .execute(); expect(result.users).toEqual([]); @@ -420,8 +397,7 @@ describe('Admin Search Field Coverage', () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'guild:lookup']); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/guilds/search') - .body({query: 'zzz-impossible-match-query-xyzzy-99999', limit: 10, offset: 0}) + .get('/admin/guilds?q=zzz-impossible-match-query-xyzzy-99999&limit=10&offset=0') .expect(HTTP_STATUS.OK) .execute(); expect(result.guilds).toEqual([]); diff --git a/fluxer_api/src/api/admin/tests/AdminSetUserAclsValidation.test.ts b/fluxer_api/src/api/admin/tests/AdminSetUserAclsValidation.test.ts new file mode 100644 index 000000000..0f739f5af --- /dev/null +++ b/fluxer_api/src/api/admin/tests/AdminSetUserAclsValidation.test.ts @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest'; +import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; +import {HTTP_STATUS} from '../../test/TestConstants'; +import {createBuilder} from '../../test/TestRequestBuilder'; + +interface AdminUserMutationResponse { + user: { + id: string; + acls: Array; + }; +} + +interface AdminUserLookupResponse { + users: Array<{ + id: string; + acls: Array; + }>; +} + +describe('Admin set user ACLs validation', () => { + let harness: ApiTestHarness; + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + beforeEach(async () => { + await harness.reset(); + }); + afterAll(async () => { + await harness?.shutdown(); + }); + test('stores a value of the registry', async () => { + const admin = await setUserACLs(harness, await createTestAccount(harness), [ + AdminACLs.AUTHENTICATE, + AdminACLs.ACL_SET_USER, + AdminACLs.USER_LOOKUP, + ]); + const target = await createTestAccount(harness); + const result = await createBuilder(harness, `${admin.token}`) + .put(`/admin/users/${target.userId}/acls`) + .body({acls: [AdminACLs.USER_LOOKUP]}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(result.user.acls).toEqual([AdminACLs.USER_LOOKUP]); + }); + test('rejects an ACL outside the registry with 400 and leaves the stored set unchanged', async () => { + const admin = await setUserACLs(harness, await createTestAccount(harness), [ + AdminACLs.AUTHENTICATE, + AdminACLs.ACL_SET_USER, + AdminACLs.USER_LOOKUP, + ]); + const target = await createTestAccount(harness); + await createBuilder(harness, `${admin.token}`) + .put(`/admin/users/${target.userId}/acls`) + .body({acls: [AdminACLs.USER_LOOKUP]}) + .expect(HTTP_STATUS.OK) + .execute(); + await createBuilder(harness, `${admin.token}`) + .put(`/admin/users/${target.userId}/acls`) + .body({acls: ['user:veiw']}) + .expect(HTTP_STATUS.BAD_REQUEST) + .executeWithResponse(); + const lookup = await createBuilder(harness, `${admin.token}`) + .get(`/admin/users/${target.userId}`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(lookup.users[0]!.acls).toEqual([AdminACLs.USER_LOOKUP]); + }); +}); diff --git a/fluxer_api/src/api/admin/tests/AdminUserChangeLogAndFlags.test.ts b/fluxer_api/src/api/admin/tests/AdminUserChangeLogAndFlags.test.ts index beb29a989..13ba4da26 100644 --- a/fluxer_api/src/api/admin/tests/AdminUserChangeLogAndFlags.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminUserChangeLogAndFlags.test.ts @@ -48,14 +48,13 @@ describe('Admin User Change Log and Suspicious Flags', () => { afterAll(async () => { await harness?.shutdown(); }); - describe('POST /admin/users/change-log', () => { + describe('GET /admin/users/{user_id}/change-log', () => { test('returns empty entries for user with no changes', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]); const target = await createTestAccount(harness); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/change-log') - .body({user_id: target.userId, limit: 50}) + .get(`/admin/users/${target.userId}/change-log?limit=50`) .expect(HTTP_STATUS.OK) .execute(); expect(result.entries).toBeInstanceOf(Array); @@ -71,8 +70,7 @@ describe('Admin User Change Log and Suspicious Flags', () => { .expect(HTTP_STATUS.OK) .execute(); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/change-log') - .body({user_id: target.userId, limit: 50}) + .get(`/admin/users/${target.userId}/change-log?limit=50`) .expect(HTTP_STATUS.OK) .execute(); expect(result.entries).toBeInstanceOf(Array); @@ -83,26 +81,15 @@ describe('Admin User Change Log and Suspicious Flags', () => { await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]); const target = await createTestAccount(harness); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/change-log') - .body({user_id: target.userId}) + .get(`/admin/users/${target.userId}/change-log`) .expect(HTTP_STATUS.OK) .execute(); }); - test('rejects missing user_id', async () => { - const admin = await createTestAccount(harness); - await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]); - await createBuilder(harness, `${admin.token}`) - .post('/admin/users/change-log') - .body({limit: 50}) - .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') - .execute(); - }); test('rejects invalid user_id format', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/change-log') - .body({user_id: 'not-a-snowflake', limit: 50}) + .get('/admin/users/not-a-snowflake/change-log?limit=50') .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') .execute(); }); @@ -111,8 +98,7 @@ describe('Admin User Change Log and Suspicious Flags', () => { await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]); const target = await createTestAccount(harness); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/change-log') - .body({user_id: target.userId, limit: 0}) + .get(`/admin/users/${target.userId}/change-log?limit=0`) .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') .execute(); }); @@ -121,42 +107,38 @@ describe('Admin User Change Log and Suspicious Flags', () => { await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]); const target = await createTestAccount(harness); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/change-log') - .body({user_id: target.userId, limit: 201}) + .get(`/admin/users/${target.userId}/change-log?limit=201`) .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') .execute(); }); - test('requires USER_LOOKUP ACL', async () => { + test('requires USER_VIEW_CONTACT_LOG ACL', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE]); const target = await createTestAccount(harness); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/change-log') - .body({user_id: target.userId, limit: 50}) + .get(`/admin/users/${target.userId}/change-log?limit=50`) .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); - test('returns empty entries when admin lacks USER_VIEW_CONTACT_LOG ACL', async () => { + test('rejects an admin holding USER_LOOKUP but not USER_VIEW_CONTACT_LOG', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]); const target = await createTestAccount(harness); - const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/change-log') - .body({user_id: target.userId, limit: 50}) - .expect(HTTP_STATUS.OK) + await createBuilder(harness, `${admin.token}`) + .get(`/admin/users/${target.userId}/change-log?limit=50`) + .expect(HTTP_STATUS.FORBIDDEN) .execute(); - expect(result.entries).toEqual([]); }); }); - describe('POST /admin/users/update-suspicious-activity-flags', () => { + describe('PUT /admin/users/{user_id}/suspicious-activity-flags', () => { test('sets suspicious activity flags', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]); const target = await createTestAccount(harness); const flags = SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL | SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE; const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/update-suspicious-activity-flags') - .body({user_id: target.userId, flags}) + .put(`/admin/users/${target.userId}/suspicious-activity-flags`) + .body({flags}) .expect(HTTP_STATUS.OK) .execute(); expect(result.user.suspicious_activity_flags).toBe(flags); @@ -166,22 +148,22 @@ describe('Admin User Change Log and Suspicious Flags', () => { await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]); const target = await createTestAccount(harness); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/update-suspicious-activity-flags') - .body({user_id: target.userId, flags: SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL}) + .put(`/admin/users/${target.userId}/suspicious-activity-flags`) + .body({flags: SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL}) .expect(HTTP_STATUS.OK) .execute(); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/update-suspicious-activity-flags') - .body({user_id: target.userId, flags: 0}) + .put(`/admin/users/${target.userId}/suspicious-activity-flags`) + .body({flags: 0}) .expect(HTTP_STATUS.OK) .execute(); expect(result.user.suspicious_activity_flags).toBe(0); }); - test('rejects missing user_id', async () => { + test('rejects invalid user_id format', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/update-suspicious-activity-flags') + .put('/admin/users/not-a-snowflake/suspicious-activity-flags') .body({flags: 1}) .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') .execute(); @@ -191,8 +173,8 @@ describe('Admin User Change Log and Suspicious Flags', () => { await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]); const target = await createTestAccount(harness); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/update-suspicious-activity-flags') - .body({user_id: target.userId}) + .put(`/admin/users/${target.userId}/suspicious-activity-flags`) + .body({}) .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') .execute(); }); @@ -201,8 +183,8 @@ describe('Admin User Change Log and Suspicious Flags', () => { await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]); const target = await createTestAccount(harness); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/update-suspicious-activity-flags') - .body({user_id: target.userId, flags: -1}) + .put(`/admin/users/${target.userId}/suspicious-activity-flags`) + .body({flags: -1}) .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') .execute(); }); @@ -210,8 +192,8 @@ describe('Admin User Change Log and Suspicious Flags', () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/update-suspicious-activity-flags') - .body({user_id: '999999999999999999', flags: 1}) + .put('/admin/users/999999999999999999/suspicious-activity-flags') + .body({flags: 1}) .expect(HTTP_STATUS.NOT_FOUND) .execute(); }); @@ -220,8 +202,8 @@ describe('Admin User Change Log and Suspicious Flags', () => { await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]); const target = await createTestAccount(harness); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/update-suspicious-activity-flags') - .body({user_id: target.userId, flags: 1}) + .put(`/admin/users/${target.userId}/suspicious-activity-flags`) + .body({flags: 1}) .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -230,38 +212,36 @@ describe('Admin User Change Log and Suspicious Flags', () => { await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]); const target = await createTestAccount(harness); const result1 = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/update-suspicious-activity-flags') - .body({user_id: target.userId, flags: SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL}) + .put(`/admin/users/${target.userId}/suspicious-activity-flags`) + .body({flags: SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL}) .expect(HTTP_STATUS.OK) .execute(); expect(result1.user.suspicious_activity_flags).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL); const combined = SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL | SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL; const result2 = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/update-suspicious-activity-flags') - .body({user_id: target.userId, flags: combined}) + .put(`/admin/users/${target.userId}/suspicious-activity-flags`) + .body({flags: combined}) .expect(HTTP_STATUS.OK) .execute(); expect(result2.user.suspicious_activity_flags).toBe(combined); }); }); - describe('POST /admin/users/disable-mfa', () => { + describe('DELETE /admin/users/{user_id}/mfa', () => { test('succeeds for user without MFA', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]); const target = await createTestAccount(harness); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/disable-mfa') - .body({user_id: target.userId}) + .delete(`/admin/users/${target.userId}/mfa`) .expect(HTTP_STATUS.NO_CONTENT) .execute(); }); - test('rejects missing user_id', async () => { + test('rejects invalid user_id format', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/disable-mfa') - .body({}) + .delete('/admin/users/not-a-snowflake/mfa') .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') .execute(); }); @@ -270,13 +250,12 @@ describe('Admin User Change Log and Suspicious Flags', () => { await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]); const target = await createTestAccount(harness); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/disable-mfa') - .body({user_id: target.userId}) + .delete(`/admin/users/${target.userId}/mfa`) .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); }); - describe('POST /admin/users/verify-email', () => { + describe('PUT /admin/users/{user_id}/email-verification', () => { test('verifying email clears email_bounced and only email-related suspicious flags', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]); @@ -291,8 +270,7 @@ describe('Admin User Change Log and Suspicious Flags', () => { .expect(HTTP_STATUS.OK) .execute(); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/verify-email') - .body({user_id: target.userId}) + .put(`/admin/users/${target.userId}/email-verification`) .expect(HTTP_STATUS.OK) .execute(); expect(result.user.email_verified).toBe(true); @@ -300,23 +278,21 @@ describe('Admin User Change Log and Suspicious Flags', () => { expect(result.user.suspicious_activity_flags).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE); }); }); - describe('POST /admin/users/resend-verification-email', () => { + describe('POST /admin/users/{user_id}/verification-email', () => { test('sends verification email for unverified user', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]); const target = await createTestAccount(harness); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/resend-verification-email') - .body({user_id: target.userId}) + .post(`/admin/users/${target.userId}/verification-email`) .expect(HTTP_STATUS.NO_CONTENT) .execute(); }); - test('rejects missing user_id', async () => { + test('rejects invalid user_id format', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/resend-verification-email') - .body({}) + .post('/admin/users/not-a-snowflake/verification-email') .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') .execute(); }); @@ -325,8 +301,7 @@ describe('Admin User Change Log and Suspicious Flags', () => { await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]); const target = await createTestAccount(harness); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/resend-verification-email') - .body({user_id: target.userId}) + .post(`/admin/users/${target.userId}/verification-email`) .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); diff --git a/fluxer_api/src/api/admin/tests/AdminUserDirectory.test.ts b/fluxer_api/src/api/admin/tests/AdminUserDirectory.test.ts new file mode 100644 index 000000000..4651122b0 --- /dev/null +++ b/fluxer_api/src/api/admin/tests/AdminUserDirectory.test.ts @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest'; +import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils'; +import {getUserActivityBuffer} from '../../middleware/ServiceSingletons'; +import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; +import {HTTP_STATUS} from '../../test/TestConstants'; +import {createBuilder} from '../../test/TestRequestBuilder'; + +interface AclListResponse { + acls: Array; +} + +interface UserListResponse { + users: Array<{ + id: string; + email: string | null; + last_active_ip: string | null; + }>; + total: number; +} + +async function setLastActiveIp(harness: ApiTestHarness, token: string, ip: string): Promise { + await createBuilder(harness, `${token}`) + .get('/users/@me') + .header('x-forwarded-for', ip) + .expect(HTTP_STATUS.OK) + .execute(); + await getUserActivityBuffer().drainAndFlush(); +} + +describe('Admin user directory', () => { + let harness: ApiTestHarness; + beforeAll(async () => { + harness = await createApiTestHarness({search: 'enabled'}); + }); + beforeEach(async () => { + await harness.reset(); + }); + afterAll(async () => { + await harness?.shutdown(); + }); + describe('GET /admin/acls', () => { + test('lists every recognised admin permission', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE]); + const result = await createBuilder(harness, `${admin.token}`) + .get('/admin/acls') + .expect(HTTP_STATUS.OK) + .execute(); + expect(result.acls).toContain(AdminACLs.USER_LOOKUP); + expect(result.acls).toContain(AdminACLs.WILDCARD); + expect(result.acls).toHaveLength(Object.keys(AdminACLs).length); + }); + test('requires an authenticated admin', async () => { + const user = await createTestAccount(harness); + await createBuilder(harness, `${user.token}`).get('/admin/acls').expect(HTTP_STATUS.FORBIDDEN).execute(); + }); + }); + describe('GET /admin/users', () => { + test('returns the users named by repeated user_id parameters', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]); + const first = await createTestAccount(harness); + const second = await createTestAccount(harness); + const result = await createBuilder(harness, `${admin.token}`) + .get(`/admin/users?user_id=${first.userId}&user_id=${second.userId}`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(result.users.map((user) => user.id).sort()).toEqual([first.userId, second.userId].sort()); + }); + test('returns the user matching a free-text query', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]); + const target = await createTestAccount(harness); + const result = await createBuilder(harness, `${admin.token}`) + .get(`/admin/users?q=${target.userId}`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(result.users.map((user) => user.id)).toContain(target.userId); + }); + test('lists users when no selector is named', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]); + const result = await createBuilder(harness, `${admin.token}`) + .get('/admin/users') + .expect(HTTP_STATUS.OK) + .execute(); + expect(result.users.length).toBeGreaterThan(0); + expect(result.total).toBeGreaterThan(0); + }); + test('requires USER_LOOKUP ACL', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE]); + await createBuilder(harness, `${admin.token}`) + .get(`/admin/users?user_id=${admin.userId}`) + .expect(HTTP_STATUS.FORBIDDEN) + .execute(); + }); + test('rejects the email selector without USER_VIEW_EMAIL ACL', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP, AdminACLs.USER_VIEW_IP]); + const target = await createTestAccount(harness); + await createBuilder(harness, `${admin.token}`) + .get(`/admin/users?email=${encodeURIComponent(target.email)}`) + .expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL') + .execute(); + }); + test('returns the account for the email selector with USER_VIEW_EMAIL ACL', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP, AdminACLs.USER_VIEW_EMAIL]); + const target = await createTestAccount(harness); + const result = await createBuilder(harness, `${admin.token}`) + .get(`/admin/users?email=${encodeURIComponent(target.email)}`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(result.users.map((user) => user.id)).toEqual([target.userId]); + expect(result.users[0]?.email).toBe(target.email); + }); + test('rejects the last_active_ip selector without USER_VIEW_IP ACL', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP, AdminACLs.USER_VIEW_EMAIL]); + await createBuilder(harness, `${admin.token}`) + .get(`/admin/users?last_active_ip=${encodeURIComponent('203.0.113.9')}`) + .expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL') + .execute(); + }); + test('returns the accounts for the last_active_ip selector with USER_VIEW_IP ACL', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP, AdminACLs.USER_VIEW_IP]); + const target = await createTestAccount(harness); + await setLastActiveIp(harness, target.token, '203.0.113.9'); + const result = await createBuilder(harness, `${admin.token}`) + .get(`/admin/users?last_active_ip=${encodeURIComponent('203.0.113.9')}`) + .expect(HTTP_STATUS.OK) + .execute(); + const found = result.users.find((user) => user.id === target.userId); + expect(found).toBeDefined(); + expect(found?.last_active_ip).toBe('203.0.113.9'); + }); + test('rejects a resolve value containing an at sign without USER_VIEW_EMAIL ACL', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP, AdminACLs.USER_VIEW_IP]); + const target = await createTestAccount(harness); + await createBuilder(harness, `${admin.token}`) + .get(`/admin/users?resolve=${encodeURIComponent(target.email)}`) + .expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL') + .execute(); + }); + test('resolves an email address with USER_VIEW_EMAIL ACL', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP, AdminACLs.USER_VIEW_EMAIL]); + const target = await createTestAccount(harness); + const result = await createBuilder(harness, `${admin.token}`) + .get(`/admin/users?resolve=${encodeURIComponent(target.email)}`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(result.users.map((user) => user.id)).toEqual([target.userId]); + expect(result.users[0]?.email).toBe(target.email); + }); + test('resolves a user ID without a PII ACL', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]); + const target = await createTestAccount(harness); + const result = await createBuilder(harness, `${admin.token}`) + .get(`/admin/users?resolve=${target.userId}`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(result.users.map((user) => user.id)).toEqual([target.userId]); + expect(result.users[0]?.email).toBeNull(); + }); + }); +}); diff --git a/fluxer_api/src/api/admin/tests/AdminUserWebAuthnCredentialDelete.test.ts b/fluxer_api/src/api/admin/tests/AdminUserWebAuthnCredentialDelete.test.ts index 4ec4a6cc2..c5360a4c7 100644 --- a/fluxer_api/src/api/admin/tests/AdminUserWebAuthnCredentialDelete.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminUserWebAuthnCredentialDelete.test.ts @@ -77,16 +77,11 @@ describe('Admin WebAuthn credential delete', () => { .expect(204) .execute(); const userBeforeDelete = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/lookup') - .body({user_ids: [target.userId]}) + .get(`/admin/users/${target.userId}`) .execute(); expect(userBeforeDelete.users[0]?.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/delete-webauthn-credential') - .body({ - user_id: target.userId, - credential_id: credentialsBeforeDelete[0]!.id, - }) + .delete(`/admin/users/${target.userId}/webauthn-credentials/${credentialsBeforeDelete[0]!.id}`) .expect(204) .execute(); const credentialsAfterDelete = await createBuilder>(harness, target.token) @@ -94,8 +89,7 @@ describe('Admin WebAuthn credential delete', () => { .execute(); expect(credentialsAfterDelete).toHaveLength(0); const userAfterDelete = await createBuilder(harness, `${admin.token}`) - .post('/admin/users/lookup') - .body({user_ids: [target.userId]}) + .get(`/admin/users/${target.userId}`) .execute(); expect(userAfterDelete.users[0]?.authenticator_types).toEqual([]); }); diff --git a/fluxer_api/src/api/admin/tests/DiscoveryAdminOperations.test.ts b/fluxer_api/src/api/admin/tests/DiscoveryAdminOperations.test.ts index 2e2e1bee5..dc5641fc4 100644 --- a/fluxer_api/src/api/admin/tests/DiscoveryAdminOperations.test.ts +++ b/fluxer_api/src/api/admin/tests/DiscoveryAdminOperations.test.ts @@ -1,17 +1,21 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; -import {DiscoveryCategories} from '@fluxer/constants/src/DiscoveryConstants'; +import {DiscoveryApplicationStatus, DiscoveryCategories} from '@fluxer/constants/src/DiscoveryConstants'; import {GuildFeatures} from '@fluxer/constants/src/GuildConstants'; import type { DiscoveryAdminListedGuildResponse, + DiscoveryAdminListingBulkCategoryResponse, DiscoveryAdminPendingApplicationResponse, DiscoveryApplicationResponse, + DiscoveryCategoryListResponse, } from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; import {afterEach, beforeEach, describe, expect, test} from 'vitest'; import type {z} from 'zod'; import {createTestAccount, setUserACLs, type TestAccount} from '../../auth/tests/AuthTestUtils'; +import {createGuildID} from '../../BrandedTypes'; +import {GuildDiscoveryRepository} from '../../guild/repositories/GuildDiscoveryRepository'; import {createGuild, getGuild} from '../../guild/tests/GuildTestUtils'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; import {HTTP_STATUS, TEST_IDS} from '../../test/TestConstants'; @@ -45,6 +49,30 @@ async function createGuildWithApplication( return {owner, guild, application}; } +const SEEDED_LISTING_BASE_GUILD_ID = 900000000000000000n; + +async function seedApprovedListings(count: number): Promise { + const discoveryRepository = new GuildDiscoveryRepository(); + for (let i = 0; i < count; i++) { + const appliedAt = new Date(1700000000000 + i); + await discoveryRepository.upsert({ + guild_id: createGuildID(SEEDED_LISTING_BASE_GUILD_ID + BigInt(i)), + status: DiscoveryApplicationStatus.APPROVED, + category_type: DiscoveryCategories.GAMING, + description: `Seeded discovery listing ${i}`, + primary_language: null, + custom_tags: [], + applied_at: appliedAt, + reviewed_at: appliedAt, + reviewed_by: null, + review_reason: null, + removed_at: null, + removed_by: null, + removal_reason: null, + }); + } +} + async function createAdminWithACLs(harness: ApiTestHarness, acls: Array): Promise { const admin = await createTestAccount(harness); return setUserACLs(harness, admin, ['admin:authenticate', ...acls]); @@ -63,8 +91,8 @@ describe('Discovery Admin Operations', () => { const {guild} = await createGuildWithApplication(harness, 'Approve Test Guild'); const admin = await createAdminWithACLs(harness, ['discovery:review']); const result = await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/approve`) - .body({reason: 'Meets all requirements'}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'approved', reason: 'Meets all requirements'}) .expect(HTTP_STATUS.OK) .execute(); expect(result.status).toBe('approved'); @@ -75,8 +103,8 @@ describe('Discovery Admin Operations', () => { const {guild} = await createGuildWithApplication(harness, 'No Reason Approve Guild'); const admin = await createAdminWithACLs(harness, ['discovery:review']); const result = await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/approve`) - .body({}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'approved'}) .expect(HTTP_STATUS.OK) .execute(); expect(result.status).toBe('approved'); @@ -86,8 +114,8 @@ describe('Discovery Admin Operations', () => { const {owner, guild} = await createGuildWithApplication(harness, 'Feature Add Guild'); const admin = await createAdminWithACLs(harness, ['discovery:review']); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/approve`) - .body({}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'approved'}) .expect(HTTP_STATUS.OK) .execute(); const guildData = await getGuild(harness, owner.token, guild.id); @@ -97,21 +125,21 @@ describe('Discovery Admin Operations', () => { const {guild} = await createGuildWithApplication(harness, 'Double Approve Guild'); const admin = await createAdminWithACLs(harness, ['discovery:review']); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/approve`) - .body({}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'approved'}) .expect(HTTP_STATUS.OK) .execute(); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/approve`) - .body({}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'approved'}) .expect(HTTP_STATUS.CONFLICT, APIErrorCodes.DISCOVERY_APPLICATION_ALREADY_REVIEWED) .execute(); }); test('should not allow approving non-existent application', async () => { const admin = await createAdminWithACLs(harness, ['discovery:review']); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${TEST_IDS.NONEXISTENT_GUILD}/approve`) - .body({}) + .patch(`/admin/discovery/applications/${TEST_IDS.NONEXISTENT_GUILD}`) + .body({status: 'approved'}) .expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND) .execute(); }); @@ -121,8 +149,8 @@ describe('Discovery Admin Operations', () => { const {guild} = await createGuildWithApplication(harness, 'Reject Test Guild'); const admin = await createAdminWithACLs(harness, ['discovery:review']); const result = await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/reject`) - .body({reason: 'Description is too vague'}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'rejected', reason: 'Description is too vague'}) .expect(HTTP_STATUS.OK) .execute(); expect(result.status).toBe('rejected'); @@ -133,8 +161,8 @@ describe('Discovery Admin Operations', () => { const {guild} = await createGuildWithApplication(harness, 'No Reason Reject Guild'); const admin = await createAdminWithACLs(harness, ['discovery:review']); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/reject`) - .body({}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'rejected'}) .expect(HTTP_STATUS.BAD_REQUEST) .execute(); }); @@ -142,13 +170,13 @@ describe('Discovery Admin Operations', () => { const {guild} = await createGuildWithApplication(harness, 'Double Reject Guild'); const admin = await createAdminWithACLs(harness, ['discovery:review']); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/reject`) - .body({reason: 'First rejection'}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'rejected', reason: 'First rejection'}) .expect(HTTP_STATUS.OK) .execute(); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/reject`) - .body({reason: 'Second rejection'}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'rejected', reason: 'Second rejection'}) .expect(HTTP_STATUS.CONFLICT, APIErrorCodes.DISCOVERY_APPLICATION_ALREADY_REVIEWED) .execute(); }); @@ -156,21 +184,21 @@ describe('Discovery Admin Operations', () => { const {guild} = await createGuildWithApplication(harness, 'Approved Then Reject Guild'); const admin = await createAdminWithACLs(harness, ['discovery:review']); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/approve`) - .body({}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'approved'}) .expect(HTTP_STATUS.OK) .execute(); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/reject`) - .body({reason: 'Changed my mind'}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'rejected', reason: 'Changed my mind'}) .expect(HTTP_STATUS.CONFLICT, APIErrorCodes.DISCOVERY_APPLICATION_ALREADY_REVIEWED) .execute(); }); test('should not allow rejecting non-existent application', async () => { const admin = await createAdminWithACLs(harness, ['discovery:review']); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${TEST_IDS.NONEXISTENT_GUILD}/reject`) - .body({reason: 'Does not exist'}) + .patch(`/admin/discovery/applications/${TEST_IDS.NONEXISTENT_GUILD}`) + .body({status: 'rejected', reason: 'Does not exist'}) .expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND) .execute(); }); @@ -178,14 +206,14 @@ describe('Discovery Admin Operations', () => { describe('remove', () => { test('should remove an approved guild from discovery', async () => { const {owner, guild} = await createGuildWithApplication(harness, 'Remove Test Guild'); - const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:remove']); + const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:remove', 'discovery:review']); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/approve`) - .body({}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'approved'}) .expect(HTTP_STATUS.OK) .execute(); const result = await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/guilds/${guild.id}/remove`) + .delete(`/admin/discovery/listings/${guild.id}`) .body({reason: 'Violated community guidelines'}) .expect(HTTP_STATUS.OK) .execute(); @@ -195,23 +223,23 @@ describe('Discovery Admin Operations', () => { }); test('should require reason for removal', async () => { const {guild} = await createGuildWithApplication(harness, 'No Reason Remove Guild'); - const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:remove']); + const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:remove', 'discovery:review']); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/approve`) - .body({}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'approved'}) .expect(HTTP_STATUS.OK) .execute(); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/guilds/${guild.id}/remove`) + .delete(`/admin/discovery/listings/${guild.id}`) .body({}) .expect(HTTP_STATUS.BAD_REQUEST) .execute(); }); test('should not allow removing a pending application', async () => { const {guild} = await createGuildWithApplication(harness, 'Remove Pending Guild'); - const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:remove']); + const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:remove', 'discovery:review']); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/guilds/${guild.id}/remove`) + .delete(`/admin/discovery/listings/${guild.id}`) .body({reason: 'Not approved yet'}) .expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE) .execute(); @@ -219,7 +247,7 @@ describe('Discovery Admin Operations', () => { test('should not allow removing non-existent application', async () => { const admin = await createAdminWithACLs(harness, ['discovery:remove']); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/guilds/${TEST_IDS.NONEXISTENT_GUILD}/remove`) + .delete(`/admin/discovery/listings/${TEST_IDS.NONEXISTENT_GUILD}`) .body({reason: 'Does not exist'}) .expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND) .execute(); @@ -229,7 +257,7 @@ describe('Discovery Admin Operations', () => { test('returns all pending applications enriched with guild metadata', async () => { const created = await createGuildWithApplication(harness, 'List Test Guild 1'); await createGuildWithApplication(harness, 'List Test Guild 2'); - const admin = await createAdminWithACLs(harness, ['discovery:review']); + const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:review']); const results = await createBuilder>>( harness, `${admin.token}`, @@ -246,10 +274,10 @@ describe('Discovery Admin Operations', () => { }); test('excludes applications that are no longer pending', async () => { const {guild} = await createGuildWithApplication(harness, 'Excluded From Pending'); - const admin = await createAdminWithACLs(harness, ['discovery:review']); + const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:review']); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/approve`) - .body({}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'approved'}) .expect(HTTP_STATUS.OK) .execute(); const results = await createBuilder>>( @@ -263,7 +291,7 @@ describe('Discovery Admin Operations', () => { }); test('returns empty list when no pending applications exist', async () => { await harness.reset(); - const admin = await createAdminWithACLs(harness, ['discovery:review']); + const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:review']); const results = await createBuilder>>( harness, `${admin.token}`, @@ -277,17 +305,17 @@ describe('Discovery Admin Operations', () => { describe('list listed guilds', () => { test('returns all approved discovery guilds with approval timestamps', async () => { const {guild} = await createGuildWithApplication(harness, 'Listed Guild A'); - const admin = await createAdminWithACLs(harness, ['discovery:review']); + const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:review']); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/approve`) - .body({}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'approved'}) .expect(HTTP_STATUS.OK) .execute(); const results = await createBuilder>>( harness, `${admin.token}`, ) - .get('/admin/discovery/listed') + .get('/admin/discovery/listings') .expect(HTTP_STATUS.OK) .execute(); const found = results.find((r) => r.guild_id === guild.id); @@ -295,17 +323,31 @@ describe('Discovery Admin Operations', () => { expect(found?.guild_name).toBe('Listed Guild A'); expect(found?.approved_at).not.toBeNull(); }); + test('returns every approved listing when there are more than a thousand', async () => { + await seedApprovedListings(1001); + const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:review']); + const results = await createBuilder>>( + harness, + `${admin.token}`, + ) + .get('/admin/discovery/listings') + .expect(HTTP_STATUS.OK) + .execute(); + expect(results).toHaveLength(1001); + const workerRows = await new GuildDiscoveryRepository().listByStatus(DiscoveryApplicationStatus.APPROVED); + expect(workerRows).toHaveLength(1001); + }); test('does not include pending or removed guilds', async () => { const {guild: pendingGuild} = await createGuildWithApplication(harness, 'Still Pending'); - const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:remove']); + const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:remove', 'discovery:review']); const {guild: removedGuild} = await createGuildWithApplication(harness, 'Will Be Removed'); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${removedGuild.id}/approve`) - .body({}) + .patch(`/admin/discovery/applications/${removedGuild.id}`) + .body({status: 'approved'}) .expect(HTTP_STATUS.OK) .execute(); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/guilds/${removedGuild.id}/remove`) + .delete(`/admin/discovery/listings/${removedGuild.id}`) .body({reason: 'cleanup'}) .expect(HTTP_STATUS.OK) .execute(); @@ -313,13 +355,165 @@ describe('Discovery Admin Operations', () => { harness, `${admin.token}`, ) - .get('/admin/discovery/listed') + .get('/admin/discovery/listings') .expect(HTTP_STATUS.OK) .execute(); expect(results.find((r) => r.guild_id === pendingGuild.id)).toBeUndefined(); expect(results.find((r) => r.guild_id === removedGuild.id)).toBeUndefined(); }); }); + describe('list categories', () => { + test('returns every discovery category a listing can be filed under', async () => { + const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:review']); + const results = await createBuilder>(harness, `${admin.token}`) + .get('/admin/discovery/categories') + .expect(HTTP_STATUS.OK) + .execute(); + expect(results.find((category) => category.id === DiscoveryCategories.GAMING)?.name).toBe('Gaming'); + expect(results.find((category) => category.id === DiscoveryCategories.OTHER)?.name).toBe('Other'); + }); + }); + describe('list category listings', () => { + test('returns the listings filed under one category', async () => { + const {guild} = await createGuildWithApplication(harness, 'Category Listing Guild'); + const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:review']); + await createBuilder(harness, `${admin.token}`) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'approved'}) + .expect(HTTP_STATUS.OK) + .execute(); + const results = await createBuilder>>( + harness, + `${admin.token}`, + ) + .get(`/admin/discovery/categories/${DiscoveryCategories.GAMING}/listings`) + .expect(HTTP_STATUS.OK) + .execute(); + const found = results.find((r) => r.guild_id === guild.id); + expect(found).toBeDefined(); + expect(found?.category_type).toBe(DiscoveryCategories.GAMING); + }); + test('excludes listings filed under another category', async () => { + const {guild} = await createGuildWithApplication(harness, 'Other Category Guild'); + const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:review']); + await createBuilder(harness, `${admin.token}`) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'approved'}) + .expect(HTTP_STATUS.OK) + .execute(); + const results = await createBuilder>>( + harness, + `${admin.token}`, + ) + .get(`/admin/discovery/categories/${DiscoveryCategories.MUSIC}/listings`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(results.find((r) => r.guild_id === guild.id)).toBeUndefined(); + }); + test('rejects an unserved category identifier', async () => { + const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:review']); + await createBuilder(harness, `${admin.token}`) + .get('/admin/discovery/categories/99/listings') + .expect(HTTP_STATUS.BAD_REQUEST) + .execute(); + }); + }); + describe('update listing', () => { + test('edits the listing copy of an approved guild', async () => { + const {guild} = await createGuildWithApplication(harness, 'Editable Listing Guild'); + const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:review']); + await createBuilder(harness, `${admin.token}`) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'approved'}) + .expect(HTTP_STATUS.OK) + .execute(); + const result = await createBuilder(harness, `${admin.token}`) + .patch(`/admin/discovery/listings/${guild.id}`) + .body({description: 'A corrected discovery description', category_type: DiscoveryCategories.MUSIC}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(result.description).toBe('A corrected discovery description'); + expect(result.category_type).toBe(DiscoveryCategories.MUSIC); + expect(result.status).toBe('approved'); + }); + test('rejects an unserved category identifier', async () => { + const {guild} = await createGuildWithApplication(harness, 'Bad Category Listing Guild'); + const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:review']); + await createBuilder(harness, `${admin.token}`) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'approved'}) + .expect(HTTP_STATUS.OK) + .execute(); + await createBuilder(harness, `${admin.token}`) + .patch(`/admin/discovery/listings/${guild.id}`) + .body({category_type: 99}) + .expect(HTTP_STATUS.BAD_REQUEST) + .execute(); + }); + test('should not allow updating a non-existent application', async () => { + const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:review']); + await createBuilder(harness, `${admin.token}`) + .patch(`/admin/discovery/listings/${TEST_IDS.NONEXISTENT_GUILD}`) + .body({description: 'A corrected discovery description'}) + .expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND) + .execute(); + }); + }); + describe('bulk move listings', () => { + test('moves every named listing to one category', async () => { + const first = await createGuildWithApplication(harness, 'Bulk Move Guild A'); + const second = await createGuildWithApplication(harness, 'Bulk Move Guild B'); + const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:review']); + for (const guildId of [first.guild.id, second.guild.id]) { + await createBuilder(harness, `${admin.token}`) + .patch(`/admin/discovery/applications/${guildId}`) + .body({status: 'approved'}) + .expect(HTTP_STATUS.OK) + .execute(); + } + const result = await createBuilder>( + harness, + `${admin.token}`, + ) + .patch('/admin/discovery/listings') + .body({guild_ids: [first.guild.id, second.guild.id], category_type: DiscoveryCategories.EDUCATION}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(result.updated).toBe(2); + expect(result.failed_guild_ids).toHaveLength(0); + const listings = await createBuilder>>( + harness, + `${admin.token}`, + ) + .get(`/admin/discovery/categories/${DiscoveryCategories.EDUCATION}/listings`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(listings.find((r) => r.guild_id === first.guild.id)).toBeDefined(); + expect(listings.find((r) => r.guild_id === second.guild.id)).toBeDefined(); + }); + test('reports the guilds that could not be moved', async () => { + const {guild} = await createGuildWithApplication(harness, 'Bulk Move Partial Guild'); + const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:review']); + await createBuilder(harness, `${admin.token}`) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'approved'}) + .expect(HTTP_STATUS.OK) + .execute(); + const result = await createBuilder>( + harness, + `${admin.token}`, + ) + .patch('/admin/discovery/listings') + .body({ + guild_ids: [guild.id, TEST_IDS.NONEXISTENT_GUILD], + category_type: DiscoveryCategories.SCIENCE_AND_TECHNOLOGY, + }) + .expect(HTTP_STATUS.OK) + .execute(); + expect(result.updated).toBe(1); + expect(result.failed_guild_ids).toEqual([TEST_IDS.NONEXISTENT_GUILD]); + }); + }); describe('ACL requirements', () => { test('should require DISCOVERY_REVIEW ACL to list applications', async () => { const admin = await createAdminWithACLs(harness, ['user:lookup']); @@ -328,7 +522,7 @@ describe('Discovery Admin Operations', () => { .expect(HTTP_STATUS.FORBIDDEN) .execute(); await createBuilder(harness, `${admin.token}`) - .get('/admin/discovery/listed') + .get('/admin/discovery/listings') .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -336,8 +530,8 @@ describe('Discovery Admin Operations', () => { const {guild} = await createGuildWithApplication(harness, 'ACL Approve Guild'); const admin = await createAdminWithACLs(harness, ['user:lookup']); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/approve`) - .body({}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'approved'}) .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -345,8 +539,8 @@ describe('Discovery Admin Operations', () => { const {guild} = await createGuildWithApplication(harness, 'ACL Reject Guild'); const admin = await createAdminWithACLs(harness, ['user:lookup']); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/reject`) - .body({reason: 'Not allowed'}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'rejected', reason: 'Not allowed'}) .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -354,33 +548,57 @@ describe('Discovery Admin Operations', () => { const {guild} = await createGuildWithApplication(harness, 'ACL Remove Guild'); const admin = await createAdminWithACLs(harness, ['discovery:review']); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/approve`) - .body({}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'approved'}) .expect(HTTP_STATUS.OK) .execute(); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/guilds/${guild.id}/remove`) + .delete(`/admin/discovery/listings/${guild.id}`) .body({reason: 'Not allowed to remove'}) .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); + test('should require DISCOVERY_REVIEW ACL to read categories and category listings', async () => { + const admin = await createAdminWithACLs(harness, ['user:lookup']); + await createBuilder(harness, `${admin.token}`) + .get('/admin/discovery/categories') + .expect(HTTP_STATUS.FORBIDDEN) + .execute(); + await createBuilder(harness, `${admin.token}`) + .get(`/admin/discovery/categories/${DiscoveryCategories.GAMING}/listings`) + .expect(HTTP_STATUS.FORBIDDEN) + .execute(); + }); + test('should require DISCOVERY_REVIEW ACL to edit and move listings', async () => { + const admin = await createAdminWithACLs(harness, ['user:lookup']); + await createBuilder(harness, `${admin.token}`) + .patch(`/admin/discovery/listings/${TEST_IDS.NONEXISTENT_GUILD}`) + .body({description: 'A corrected discovery description'}) + .expect(HTTP_STATUS.FORBIDDEN) + .execute(); + await createBuilder(harness, `${admin.token}`) + .patch('/admin/discovery/listings') + .body({guild_ids: [TEST_IDS.NONEXISTENT_GUILD], category_type: DiscoveryCategories.MUSIC}) + .expect(HTTP_STATUS.FORBIDDEN) + .execute(); + }); test('should require authentication for admin endpoints', async () => { await createBuilderWithoutAuth(harness) .get('/admin/discovery/applications') .expect(HTTP_STATUS.UNAUTHORIZED) .execute(); await createBuilderWithoutAuth(harness) - .post(`/admin/discovery/applications/${TEST_IDS.NONEXISTENT_GUILD}/approve`) - .body({}) + .patch(`/admin/discovery/applications/${TEST_IDS.NONEXISTENT_GUILD}`) + .body({status: 'approved'}) .expect(HTTP_STATUS.UNAUTHORIZED) .execute(); await createBuilderWithoutAuth(harness) - .post(`/admin/discovery/applications/${TEST_IDS.NONEXISTENT_GUILD}/reject`) - .body({reason: 'test'}) + .patch(`/admin/discovery/applications/${TEST_IDS.NONEXISTENT_GUILD}`) + .body({status: 'rejected', reason: 'test'}) .expect(HTTP_STATUS.UNAUTHORIZED) .execute(); await createBuilderWithoutAuth(harness) - .post(`/admin/discovery/guilds/${TEST_IDS.NONEXISTENT_GUILD}/remove`) + .delete(`/admin/discovery/listings/${TEST_IDS.NONEXISTENT_GUILD}`) .body({reason: 'test'}) .expect(HTTP_STATUS.UNAUTHORIZED) .execute(); diff --git a/fluxer_api/src/api/admin/tests/GatewayAdminController.test.ts b/fluxer_api/src/api/admin/tests/GatewayAdminController.test.ts new file mode 100644 index 000000000..9b0950983 --- /dev/null +++ b/fluxer_api/src/api/admin/tests/GatewayAdminController.test.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {afterEach, beforeEach, describe, test} from 'vitest'; +import {createTestAccount, setUserACLs, type TestAccount} from '../../auth/tests/AuthTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; +import {HTTP_STATUS} from '../../test/TestConstants'; +import {createBuilder} from '../../test/TestRequestBuilder'; + +async function createAdminWithAcls(harness: ApiTestHarness, acls: Array): Promise { + const account = await createTestAccount(harness); + return await setUserACLs(harness, account, [AdminACLs.AUTHENTICATE, ...acls]); +} + +describe('GatewayAdminController', () => { + let harness: ApiTestHarness; + beforeEach(async () => { + harness = await createApiTestHarness(); + }); + afterEach(async () => { + await harness?.shutdown(); + }); + test('rejects a guild memory statistics limit below the minimum', async () => { + const admin = await createAdminWithAcls(harness, [AdminACLs.GATEWAY_MEMORY_STATS]); + await createBuilder(harness, `${admin.token}`) + .get('/admin/gateway/memory-stats?limit=50') + .expect(HTTP_STATUS.BAD_REQUEST) + .execute(); + }); + test('requires the gateway memory stats acl for guild memory statistics', async () => { + const admin = await createAdminWithAcls(harness, []); + await createBuilder(harness, `${admin.token}`) + .get('/admin/gateway/memory-stats') + .expect(HTTP_STATUS.FORBIDDEN) + .execute(); + }); + test('requires the gateway reload acl for reloads', async () => { + const admin = await createAdminWithAcls(harness, [AdminACLs.GATEWAY_MEMORY_STATS]); + await createBuilder(harness, `${admin.token}`) + .post('/admin/gateway/reloads') + .body({guild_ids: []}) + .expect(HTTP_STATUS.FORBIDDEN) + .execute(); + }); +}); diff --git a/fluxer_api/src/api/admin/tests/InstanceConfigPendingRegistrationApproval.test.ts b/fluxer_api/src/api/admin/tests/InstanceConfigPendingRegistrationApproval.test.ts index 6cd7a3e34..d05c90c60 100644 --- a/fluxer_api/src/api/admin/tests/InstanceConfigPendingRegistrationApproval.test.ts +++ b/fluxer_api/src/api/admin/tests/InstanceConfigPendingRegistrationApproval.test.ts @@ -60,8 +60,8 @@ describe('pending registration approval and the stock community', () => { .execute(); await createBuilder(harness, admin.token) - .post('/admin/instance-config/pending-registrations/approve') - .body({user_id: pending.user_id}) + .patch(`/admin/instance/pending-registrations/${pending.user_id}`) + .body({status: 'approved'}) .expect(HTTP_STATUS.OK) .execute(); @@ -84,8 +84,8 @@ describe('pending registration approval and the stock community', () => { }); await createBuilder(harness, admin.token) - .post('/admin/instance-config/pending-registrations/approve') - .body({user_id: outsider.userId}) + .patch(`/admin/instance/pending-registrations/${outsider.userId}`) + .body({status: 'approved'}) .expect(HTTP_STATUS.OK) .execute(); diff --git a/fluxer_api/src/api/admin/tests/InstanceConfigPendingRegistrationAudit.test.ts b/fluxer_api/src/api/admin/tests/InstanceConfigPendingRegistrationAudit.test.ts index cfc6d1319..b5b44d054 100644 --- a/fluxer_api/src/api/admin/tests/InstanceConfigPendingRegistrationAudit.test.ts +++ b/fluxer_api/src/api/admin/tests/InstanceConfigPendingRegistrationAudit.test.ts @@ -40,9 +40,9 @@ describe('pending registration audit logs', () => { }); it.each([ - ['approve', 'approve_registration'], - ['reject', 'reject_registration'], - ] as const)('logs a pending registration %s decision', async (decision, action) => { + ['approved', 'approve_registration'], + ['rejected', 'reject_registration'], + ] as const)('logs a pending registration %s decision', async (status, action) => { const admin = await setUserACLs(harness, await createTestAccount(harness), [ AdminACLs.AUTHENTICATE, AdminACLs.INSTANCE_CONFIG_UPDATE, @@ -54,8 +54,8 @@ describe('pending registration audit logs', () => { const pending = await createBuilderWithoutAuth(harness) .post('/auth/register') .body({ - email: createUniqueEmail(decision), - username: createUniqueUsername(decision), + email: createUniqueEmail(status), + username: createUniqueUsername(status), global_name: 'The register man', password: 'approving-since-1999', date_of_birth: '2000-01-01', @@ -64,14 +64,13 @@ describe('pending registration audit logs', () => { .execute(); await createBuilder(harness, admin.token) - .post(`/admin/instance-config/pending-registrations/${decision}`) + .patch(`/admin/instance/pending-registrations/${pending.user_id}`) .header('X-Audit-Log-Reason', 'Registration review') - .body({user_id: pending.user_id}) + .body({status}) .execute(); const result = await createBuilder(harness, admin.token) - .post('/admin/audit-logs') - .body({target_type: 'user', target_id: pending.user_id}) + .get(`/admin/audit-logs?target_type=user&target_id=${pending.user_id}`) .execute(); expect(result.logs).toEqual([ diff --git a/fluxer_api/src/api/admin/tests/InstancePolicyDirectMessages.test.ts b/fluxer_api/src/api/admin/tests/InstancePolicyDirectMessages.test.ts new file mode 100644 index 000000000..407fb6845 --- /dev/null +++ b/fluxer_api/src/api/admin/tests/InstancePolicyDirectMessages.test.ts @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; +import type {TestAccount} from '../../auth/tests/AuthTestUtils'; +import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils'; +import type {ApiTestHarness} from '../../test/ApiTestHarness'; +import {createApiTestHarness} from '../../test/ApiTestHarness'; +import {HTTP_STATUS} from '../../test/TestConstants'; +import {createBuilder} from '../../test/TestRequestBuilder'; + +interface InstanceConfigPolicyResponse { + policy: { + direct_messages_disabled: boolean; + direct_messages_locked: boolean; + }; +} + +describe('instance policy direct messages lock', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + }); + + afterAll(async () => { + await harness.shutdown(); + }); + + const createAdmin = async (): Promise => + await setUserACLs(harness, await createTestAccount(harness), [ + AdminACLs.AUTHENTICATE, + AdminACLs.INSTANCE_CONFIG_VIEW, + AdminACLs.INSTANCE_CONFIG_UPDATE, + ]); + + const patchPolicy = (admin: TestAccount, policy: Record) => + createBuilder(harness, admin.token).patch('/admin/instance/config').body({policy}); + + const lockDirectMessages = async (admin: TestAccount): Promise => { + await patchPolicy(admin, {direct_messages_disabled: true}).execute(); + const reenabled = await patchPolicy(admin, {direct_messages_disabled: false}).execute(); + expect(reenabled.policy.direct_messages_locked).toBe(true); + }; + + it('disable then re-enable sets the lock', async () => { + const admin = await createAdmin(); + + const disabled = await patchPolicy(admin, {direct_messages_disabled: true}).execute(); + expect(disabled.policy.direct_messages_disabled).toBe(true); + expect(disabled.policy.direct_messages_locked).toBe(false); + + const reenabled = await patchPolicy(admin, {direct_messages_disabled: false}).execute(); + expect(reenabled.policy.direct_messages_disabled).toBe(false); + expect(reenabled.policy.direct_messages_locked).toBe(true); + }); + + it('a change after the lock fails with INSTANCE_POLICY_TRANSITION_NOT_ALLOWED', async () => { + const admin = await createAdmin(); + await lockDirectMessages(admin); + + await patchPolicy(admin, {direct_messages_disabled: true}) + .expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INSTANCE_POLICY_TRANSITION_NOT_ALLOWED) + .execute(); + + const current = await createBuilder(harness, admin.token) + .get('/admin/instance/config') + .execute(); + expect(current.policy.direct_messages_disabled).toBe(false); + expect(current.policy.direct_messages_locked).toBe(true); + }); + + it('direct_messages_locked false clears the lock and admits the change', async () => { + const admin = await createAdmin(); + await lockDirectMessages(admin); + + const unlocked = await patchPolicy(admin, { + direct_messages_locked: false, + direct_messages_disabled: true, + }).execute(); + expect(unlocked.policy.direct_messages_disabled).toBe(true); + expect(unlocked.policy.direct_messages_locked).toBe(false); + }); + + it('rejects a request that tries to set the lock', async () => { + const admin = await createAdmin(); + + await patchPolicy(admin, {direct_messages_locked: true}) + .expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY) + .execute(); + }); +}); diff --git a/fluxer_api/src/api/admin/tests/SecurityAccessControl.test.ts b/fluxer_api/src/api/admin/tests/SecurityAccessControl.test.ts index 2be76fd4f..c91b617e6 100644 --- a/fluxer_api/src/api/admin/tests/SecurityAccessControl.test.ts +++ b/fluxer_api/src/api/admin/tests/SecurityAccessControl.test.ts @@ -102,10 +102,7 @@ describe('Security Access Control', () => { test('admin endpoints require admin authentication', async () => { const regularUser = await createTestAccount(harness); await createBuilder(harness, `${regularUser.token}`) - .post('/admin/users/lookup') - .body({ - user_ids: [regularUser.userId], - }) + .get(`/admin/users/${regularUser.userId}`) .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -113,10 +110,7 @@ describe('Security Access Control', () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.FORBIDDEN) .execute(); }); @@ -124,10 +118,7 @@ describe('Security Access Control', () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.OK) .execute(); }); diff --git a/fluxer_api/src/api/admin/tests/VoiceAdminController.test.ts b/fluxer_api/src/api/admin/tests/VoiceAdminController.test.ts index 42efaf756..22b597b7f 100644 --- a/fluxer_api/src/api/admin/tests/VoiceAdminController.test.ts +++ b/fluxer_api/src/api/admin/tests/VoiceAdminController.test.ts @@ -1,10 +1,16 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; import type { CreateVoiceRegionResponse, CreateVoiceServerResponse, + DeleteVoiceResponse, + GetVoiceRegionResponse, + GetVoiceServerResponse, ListVoiceRegionsResponse, + ListVoiceServersResponse, + UpdateVoiceRegionResponse, UpdateVoiceServerResponse, } from '@fluxer/schema/src/domains/admin/AdminVoiceSchemas'; import {afterEach, beforeEach, describe, expect, test} from 'vitest'; @@ -38,7 +44,7 @@ async function createVoiceFixture( }, ): Promise { await createBuilder(harness, `${admin.token}`) - .post('/admin/voice/regions/create') + .post('/admin/voice/regions') .body({ id: params.regionId, name: `Region ${params.regionId}`, @@ -49,9 +55,8 @@ async function createVoiceFixture( .expect(HTTP_STATUS.OK) .execute(); await createBuilder(harness, `${admin.token}`) - .post('/admin/voice/servers/create') + .post(`/admin/voice/regions/${params.regionId}/servers`) .body({ - region_id: params.regionId, server_id: params.serverId, endpoint: params.endpoint, api_key: params.apiKey, @@ -91,8 +96,7 @@ describe('VoiceAdminController', () => { apiSecret: 'list-api-secret', }); const result = await createBuilder(harness, `${admin.token}`) - .post('/admin/voice/regions/list') - .body({include_servers: true}) + .get('/admin/voice/regions?include_servers=true') .expect(HTTP_STATUS.OK) .execute(); expect(result.regions).toHaveLength(1); @@ -101,6 +105,77 @@ describe('VoiceAdminController', () => { expect(result.regions[0]?.servers).toHaveLength(1); expect(result.regions[0]?.servers?.[0]?.server_id).toBe(fixture.serverId); }); + test('resolves and removes regions and servers through the nested routes', async () => { + const admin = await createAdminWithAcls(harness, [ + AdminACLs.VOICE_REGION_CREATE, + AdminACLs.VOICE_REGION_DELETE, + AdminACLs.VOICE_REGION_LIST, + AdminACLs.VOICE_REGION_UPDATE, + AdminACLs.VOICE_SERVER_CREATE, + AdminACLs.VOICE_SERVER_DELETE, + AdminACLs.VOICE_SERVER_LIST, + ]); + const fixture = await createVoiceFixture(harness, admin, { + regionId: 'voice-region-nested-routes', + serverId: 'voice-server-nested-routes', + endpoint: 'https://voice-nested.example.com/socket', + apiKey: 'nested-api-key', + apiSecret: 'nested-api-secret', + }); + const region = await createBuilder(harness, `${admin.token}`) + .get(`/admin/voice/regions/${fixture.regionId}`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(region.region?.id).toBe(fixture.regionId); + expect(region.region?.servers).toHaveLength(1); + const servers = await createBuilder(harness, `${admin.token}`) + .get(`/admin/voice/regions/${fixture.regionId}/servers`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(servers.servers).toHaveLength(1); + expect(servers.servers[0]?.server_id).toBe(fixture.serverId); + const server = await createBuilder(harness, `${admin.token}`) + .get(`/admin/voice/regions/${fixture.regionId}/servers/${fixture.serverId}`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(server.server?.endpoint).toBe('https://voice-nested.example.com/socket'); + const renamed = await createBuilder(harness, `${admin.token}`) + .patch(`/admin/voice/regions/${fixture.regionId}`) + .body({name: 'Renamed region'}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(renamed.region.id).toBe(fixture.regionId); + expect(renamed.region.name).toBe('Renamed region'); + const deletedServer = await createBuilder(harness, `${admin.token}`) + .delete(`/admin/voice/regions/${fixture.regionId}/servers/${fixture.serverId}`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(deletedServer.success).toBe(true); + expect(await voiceRepository.getServer(fixture.regionId, fixture.serverId)).toBeNull(); + const deletedRegion = await createBuilder(harness, `${admin.token}`) + .delete(`/admin/voice/regions/${fixture.regionId}`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(deletedRegion.success).toBe(true); + expect(await voiceRepository.getRegion(fixture.regionId)).toBeNull(); + }); + test('rejects voice server creation when no region carries the identifier', async () => { + const admin = await createAdminWithAcls(harness, [AdminACLs.VOICE_SERVER_CREATE]); + const regionId = 'voice-region-missing-for-server-create'; + const serverId = 'voice-server-missing-region'; + await createBuilder(harness, `${admin.token}`) + .post(`/admin/voice/regions/${regionId}/servers`) + .body({ + server_id: serverId, + endpoint: 'https://voice-orphan.example.com/socket', + api_key: 'orphan-api-key', + api_secret: 'orphan-api-secret', + }) + .expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_VOICE_REGION) + .execute(); + expect(await voiceRepository.getServer(regionId, serverId)).toBeNull(); + expect(await voiceRepository.listServers(regionId)).toHaveLength(0); + }); test('updates voice server credentials when api key and secret are provided', async () => { const admin = await createAdminWithAcls(harness, [ AdminACLs.VOICE_REGION_CREATE, @@ -115,10 +190,8 @@ describe('VoiceAdminController', () => { apiSecret: 'original-api-secret', }); await createBuilder(harness, `${admin.token}`) - .post('/admin/voice/servers/update') + .patch(`/admin/voice/regions/${fixture.regionId}/servers/${fixture.serverId}`) .body({ - region_id: fixture.regionId, - server_id: fixture.serverId, endpoint: 'https://voice-updated.example.com/socket', api_key: 'updated-api-key', api_secret: 'updated-api-secret', @@ -145,10 +218,8 @@ describe('VoiceAdminController', () => { apiSecret: 'partial-before-api-secret', }); await createBuilder(harness, `${admin.token}`) - .post('/admin/voice/servers/update') + .patch(`/admin/voice/regions/${fixture.regionId}/servers/${fixture.serverId}`) .body({ - region_id: fixture.regionId, - server_id: fixture.serverId, api_key: 'partial-after-api-key', }) .expect(HTTP_STATUS.OK) @@ -158,10 +229,8 @@ describe('VoiceAdminController', () => { expect(afterApiKeyUpdate?.apiKey).toBe('partial-after-api-key'); expect(afterApiKeyUpdate?.apiSecret).toBe(fixture.initialApiSecret); await createBuilder(harness, `${admin.token}`) - .post('/admin/voice/servers/update') + .patch(`/admin/voice/regions/${fixture.regionId}/servers/${fixture.serverId}`) .body({ - region_id: fixture.regionId, - server_id: fixture.serverId, api_secret: 'partial-after-api-secret', }) .expect(HTTP_STATUS.OK) @@ -185,10 +254,8 @@ describe('VoiceAdminController', () => { apiSecret: 'before-api-secret', }); await createBuilder(harness, `${admin.token}`) - .post('/admin/voice/servers/update') + .patch(`/admin/voice/regions/${fixture.regionId}/servers/${fixture.serverId}`) .body({ - region_id: fixture.regionId, - server_id: fixture.serverId, endpoint: 'https://voice-after.example.com/socket', }) .expect(HTTP_STATUS.OK) diff --git a/fluxer_api/src/api/auth/tests/AuthSessionCacheInvalidation.test.ts b/fluxer_api/src/api/auth/tests/AuthSessionCacheInvalidation.test.ts index 1896ac4f8..48baca9ee 100644 --- a/fluxer_api/src/api/auth/tests/AuthSessionCacheInvalidation.test.ts +++ b/fluxer_api/src/api/auth/tests/AuthSessionCacheInvalidation.test.ts @@ -95,8 +95,8 @@ describe('Auth session cache invalidation', () => { expect(await readCachedSession(harness, token)).not.toBeNull(); } await createBuilder(harness, admin.token) - .post('/admin/users/temp-ban') - .body({user_id: target.userId, duration_hours: 24, reason: 'cache invalidation coverage'}) + .put(`/admin/users/${target.userId}/ban`) + .body({duration_hours: 24, reason: 'cache invalidation coverage'}) .execute(); for (const token of [target.token, targetSecond.token]) { expect(await readCachedSession(harness, token)).toBeNull(); diff --git a/fluxer_api/src/api/auth/tests/AuthTestUtils.ts b/fluxer_api/src/api/auth/tests/AuthTestUtils.ts index aea9837f6..c6f906aec 100644 --- a/fluxer_api/src/api/auth/tests/AuthTestUtils.ts +++ b/fluxer_api/src/api/auth/tests/AuthTestUtils.ts @@ -336,12 +336,12 @@ export async function enableSso( redirect_uri: '', ...overrides, }; - await createBuilder(harness, token).post('/admin/instance-config/update').body({sso: ssoConfig}).execute(); + await createBuilder(harness, token).patch('/admin/instance/config').body({sso: ssoConfig}).execute(); } export async function disableSso(harness: ApiTestHarness, token: string): Promise { await createBuilder(harness, token) - .post('/admin/instance-config/update') + .patch('/admin/instance/config') .body({ sso: { enabled: false, diff --git a/fluxer_api/src/api/auth/tests/Registration.test.ts b/fluxer_api/src/api/auth/tests/Registration.test.ts index 5e6e7746f..f6525ce47 100644 --- a/fluxer_api/src/api/auth/tests/Registration.test.ts +++ b/fluxer_api/src/api/auth/tests/Registration.test.ts @@ -128,7 +128,7 @@ describe('Auth registration', () => { await instanceConfigRepository.markAdminBootstrapped(); const account = await registerUser(harness, bootstrapRegistrationBody('stalesetupmarker')); await expectUserACLs(account.user_id, []); - await createBuilder(harness, account.token).post('/admin/instance-config/get').body({}).execute(); + await createBuilder(harness, account.token).get('/admin/instance/config').execute(); }); }); it('repairs setup completer admin ACL when bootstrap marker is stale', async () => { @@ -140,12 +140,12 @@ describe('Auth registration', () => { await expectUserACLs(account.user_id, []); await createBuilder(harness, account.token) - .post('/admin/instance-config/update') + .patch('/admin/instance/config') .body({app_public: {setup: {configured: true}}}) .execute(); await expectUserACLs(account.user_id, [AdminACLs.WILDCARD]); - await createBuilder(harness, account.token).post('/admin/instance-config/get').body({}).execute(); + await createBuilder(harness, account.token).get('/admin/instance/config').execute(); }); }); it('allows emoji global name', async () => { diff --git a/fluxer_api/src/api/auth/tests/SsoFlow.test.ts b/fluxer_api/src/api/auth/tests/SsoFlow.test.ts index 590e05a23..2b8e11227 100644 --- a/fluxer_api/src/api/auth/tests/SsoFlow.test.ts +++ b/fluxer_api/src/api/auth/tests/SsoFlow.test.ts @@ -90,7 +90,7 @@ describe('Auth SSO flow', () => { }); it('rejects enforced SSO config that cannot resolve claims', async () => { await createBuilder(harness, admin.token) - .post('/admin/instance-config/update') + .patch('/admin/instance/config') .body({ sso: { enabled: true, @@ -458,7 +458,7 @@ describe('Auth SSO flow', () => { }); it('rejects invalid allowed domains during config update', async () => { await createBuilder(harness, admin.token) - .post('/admin/instance-config/update') + .patch('/admin/instance/config') .body({ sso: { enabled: true, @@ -477,7 +477,7 @@ describe('Auth SSO flow', () => { }); it('rejects unsafe provider URLs during config update', async () => { await createBuilder(harness, admin.token) - .post('/admin/instance-config/update') + .patch('/admin/instance/config') .body({ sso: { enabled: true, @@ -504,8 +504,7 @@ describe('Auth SSO flow', () => { allowed_domains: Array; }; }>(harness, admin.token) - .post('/admin/instance-config/get') - .body({}) + .get('/admin/instance/config') .execute(); expect(config.sso.allowed_domains).toEqual(['example.com', 'xn--bcher-kva.example']); }); @@ -715,7 +714,7 @@ describe('Auth SSO flow', () => { }); it('does not advertise enabled SSO when optional SSO cannot resolve claims', async () => { await createBuilder(harness, admin.token) - .post('/admin/instance-config/update') + .patch('/admin/instance/config') .body({ sso: { enabled: true, diff --git a/fluxer_api/src/api/database/types/JobLedgerTypes.ts b/fluxer_api/src/api/database/types/JobLedgerTypes.ts index 02d35abe7..41460d17b 100644 --- a/fluxer_api/src/api/database/types/JobLedgerTypes.ts +++ b/fluxer_api/src/api/database/types/JobLedgerTypes.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'deadletter'; +export type JobStatus = 'queued' | 'running' | 'succeeded' | 'cancelled' | 'deadletter'; export interface JobByIdRow { job_id: bigint; diff --git a/fluxer_api/src/api/guild/repositories/GuildDiscoveryRepository.ts b/fluxer_api/src/api/guild/repositories/GuildDiscoveryRepository.ts index 4b202d3e3..52c5a1a50 100644 --- a/fluxer_api/src/api/guild/repositories/GuildDiscoveryRepository.ts +++ b/fluxer_api/src/api/guild/repositories/GuildDiscoveryRepository.ts @@ -23,9 +23,9 @@ const FETCH_ALL_DISCOVERY_PAGINATED = (limit: number) => export abstract class IGuildDiscoveryRepository { abstract findByGuildId(guildId: GuildID): Promise; - abstract listByStatus(status: string, limit: number): Promise>; + abstract listByStatus(status: string): Promise>; - abstract listFullByStatus(status: string, limit: number): Promise>; + abstract listFullByStatus(status: string): Promise>; abstract listAllPaginated(limit: number, lastGuildId?: GuildID): Promise>; @@ -54,15 +54,14 @@ export class GuildDiscoveryRepository extends IGuildDiscoveryRepository { return row; } - async listByStatus(status: string, limit: number): Promise> { - const rows = await fetchMany(FETCH_DISCOVERY_BY_STATUS, { + async listByStatus(status: string): Promise> { + return fetchMany(FETCH_DISCOVERY_BY_STATUS, { status, }); - return rows.slice(0, limit); } - async listFullByStatus(status: string, limit: number): Promise> { - const indexRows = await this.listByStatus(status, limit); + async listFullByStatus(status: string): Promise> { + const indexRows = await this.listByStatus(status); const fullRows = await Promise.all(indexRows.map((indexRow) => this.findByGuildId(indexRow.guild_id))); return fullRows.filter((row): row is GuildDiscoveryRow => row !== null); } diff --git a/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts b/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts index 2d45d799d..50830bff6 100644 --- a/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts +++ b/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts @@ -81,7 +81,7 @@ export abstract class IGuildDiscoveryService { min_member_count: number; }>; - abstract listByStatus(params: {status: string; limit: number}): Promise>; + abstract listByStatus(params: {status: string}): Promise>; abstract searchDiscoverable(params: { query?: string; @@ -378,8 +378,8 @@ export class GuildDiscoveryService extends IGuildDiscoveryService { return updatedRow; } - async listByStatus(params: {status: string; limit: number}): Promise> { - return this.discoveryRepository.listFullByStatus(params.status, params.limit); + async listByStatus(params: {status: string}): Promise> { + return this.discoveryRepository.listFullByStatus(params.status); } async searchDiscoverable(params: { diff --git a/fluxer_api/src/api/guild/tests/DiscoveryApplicationLifecycle.test.ts b/fluxer_api/src/api/guild/tests/DiscoveryApplicationLifecycle.test.ts index d671a8088..c8ec8d596 100644 --- a/fluxer_api/src/api/guild/tests/DiscoveryApplicationLifecycle.test.ts +++ b/fluxer_api/src/api/guild/tests/DiscoveryApplicationLifecycle.test.ts @@ -52,8 +52,8 @@ async function adminApprove( reason?: string, ): Promise { return createBuilder(harness, `${adminToken}`) - .post(`/admin/discovery/applications/${guildId}/approve`) - .body({reason}) + .patch(`/admin/discovery/applications/${guildId}`) + .body({status: 'approved', reason}) .expect(HTTP_STATUS.OK) .execute(); } @@ -65,8 +65,8 @@ async function adminReject( reason: string, ): Promise { return createBuilder(harness, `${adminToken}`) - .post(`/admin/discovery/applications/${guildId}/reject`) - .body({reason}) + .patch(`/admin/discovery/applications/${guildId}`) + .body({status: 'rejected', reason}) .expect(HTTP_STATUS.OK) .execute(); } diff --git a/fluxer_api/src/api/guild/tests/DiscoveryApplicationValidation.test.ts b/fluxer_api/src/api/guild/tests/DiscoveryApplicationValidation.test.ts index f351b6abc..eef90ed2d 100644 --- a/fluxer_api/src/api/guild/tests/DiscoveryApplicationValidation.test.ts +++ b/fluxer_api/src/api/guild/tests/DiscoveryApplicationValidation.test.ts @@ -177,8 +177,8 @@ describe('Discovery Application Validation', () => { .expect(HTTP_STATUS.OK) .execute(); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/approve`) - .body({}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'approved'}) .expect(HTTP_STATUS.OK) .execute(); await createBuilder(harness, owner.token) @@ -307,8 +307,8 @@ describe('Discovery Application Validation', () => { .expect(HTTP_STATUS.OK) .execute(); await createBuilder(harness, `${admin.token}`) - .post(`/admin/discovery/applications/${guild.id}/reject`) - .body({reason: 'Not suitable'}) + .patch(`/admin/discovery/applications/${guild.id}`) + .body({status: 'rejected', reason: 'Not suitable'}) .expect(HTTP_STATUS.OK) .execute(); await createBuilder(harness, owner.token) diff --git a/fluxer_api/src/api/guild/tests/DiscoverySearchAndJoin.test.ts b/fluxer_api/src/api/guild/tests/DiscoverySearchAndJoin.test.ts index 9a674d0d2..c835f96e7 100644 --- a/fluxer_api/src/api/guild/tests/DiscoverySearchAndJoin.test.ts +++ b/fluxer_api/src/api/guild/tests/DiscoverySearchAndJoin.test.ts @@ -38,8 +38,8 @@ async function applyAndApprove( .expect(HTTP_STATUS.OK) .execute(); await createBuilder(harness, `${adminToken}`) - .post(`/admin/discovery/applications/${guildId}/approve`) - .body({}) + .patch(`/admin/discovery/applications/${guildId}`) + .body({status: 'approved'}) .expect(HTTP_STATUS.OK) .execute(); } diff --git a/fluxer_api/src/api/jobs/IJobLedgerRepository.ts b/fluxer_api/src/api/jobs/IJobLedgerRepository.ts index 2f7a568b1..081e5a293 100644 --- a/fluxer_api/src/api/jobs/IJobLedgerRepository.ts +++ b/fluxer_api/src/api/jobs/IJobLedgerRepository.ts @@ -40,8 +40,6 @@ export abstract class IJobLedgerRepository { abstract markSucceeded(jobId: bigint, result: Record | null): Promise; - abstract markFailed(jobId: bigint, errorMessage: string): Promise; - abstract markCancelled(jobId: bigint): Promise; abstract markDeadletter(jobId: bigint, errorMessage: string): Promise; @@ -50,6 +48,8 @@ export abstract class IJobLedgerRepository { abstract setContextLink(jobId: bigint, link: string): Promise; + abstract setJetStreamSeq(jobId: bigint, seq: string): Promise; + abstract requestCancel(jobId: bigint): Promise; abstract isCancelRequested(jobId: bigint): Promise; diff --git a/fluxer_api/src/api/jobs/JobLedgerRepository.test.ts b/fluxer_api/src/api/jobs/JobLedgerRepository.test.ts new file mode 100644 index 000000000..bf83af2c1 --- /dev/null +++ b/fluxer_api/src/api/jobs/JobLedgerRepository.test.ts @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {setCassandraQueryExecutorForTesting} from '../database/CassandraQueryExecution'; +import type {JobStatus} from '../database/types/JobLedgerTypes'; +import {InMemoryCassandraQueryExecutor} from '../test/InMemoryCassandraQueryExecutor'; +import {JobLedgerRepository} from './JobLedgerRepository'; + +let executor: InMemoryCassandraQueryExecutor; + +async function createJob(repository: JobLedgerRepository, jobId: bigint, taskType: string): Promise { + await repository.createJob({ + jobId, + taskType, + payload: {}, + requestedByUserId: null, + auditLogReason: null, + maxAttempts: 3, + runAt: null, + jetStreamLane: null, + jetStreamSeq: null, + }); +} + +async function listJobIdsByStatus(repository: JobLedgerRepository, status: JobStatus): Promise> { + const result = await repository.listJobs({limit: 50, cursor: null, filters: {status}, maxLookbackDays: 1}); + return result.jobs.map((job) => job.job_id); +} + +describe('JobLedgerRepository listJobs status filter', () => { + beforeEach(() => { + executor = new InMemoryCassandraQueryExecutor(); + setCassandraQueryExecutorForTesting(executor); + }); + + afterEach(() => { + executor.reset(); + setCassandraQueryExecutorForTesting(null); + }); + + it('matches the live status of a succeeded job rather than the creation-time bucket snapshot', async () => { + const repository = new JobLedgerRepository(); + await createJob(repository, 1n, 'syncDisposableEmailDomains'); + await repository.markSucceeded(1n, null); + + expect(await listJobIdsByStatus(repository, 'succeeded')).toEqual([1n]); + expect(await listJobIdsByStatus(repository, 'queued')).toEqual([]); + }); + + it('matches the live status of a dead-lettered job', async () => { + const repository = new JobLedgerRepository(); + await createJob(repository, 2n, 'syncDisposableEmailDomains'); + await repository.markDeadletter(2n, 'boom'); + + expect(await listJobIdsByStatus(repository, 'deadletter')).toEqual([2n]); + expect(await listJobIdsByStatus(repository, 'queued')).toEqual([]); + }); + + it('still returns a job that has not left the queue under status=queued', async () => { + const repository = new JobLedgerRepository(); + await createJob(repository, 3n, 'syncDisposableEmailDomains'); + + expect(await listJobIdsByStatus(repository, 'queued')).toEqual([3n]); + expect(await listJobIdsByStatus(repository, 'running')).toEqual([]); + }); + + it('keeps the other filters working alongside the status filter', async () => { + const repository = new JobLedgerRepository(); + await createJob(repository, 4n, 'syncDisposableEmailDomains'); + await createJob(repository, 5n, 'processExpiredPremium'); + await repository.markSucceeded(4n, null); + await repository.markSucceeded(5n, null); + + const result = await repository.listJobs({ + limit: 50, + cursor: null, + filters: {status: 'succeeded', taskType: 'processExpiredPremium'}, + maxLookbackDays: 1, + }); + expect(result.jobs.map((job) => job.job_id)).toEqual([5n]); + }); +}); + +describe('JobLedgerRepository listJobs pagination', () => { + beforeEach(() => { + executor = new InMemoryCassandraQueryExecutor(); + setCassandraQueryExecutorForTesting(executor); + }); + + afterEach(() => { + executor.reset(); + setCassandraQueryExecutorForTesting(null); + }); + + it('emits a cursor for a page that filled exactly on the bucket boundary', async () => { + const repository = new JobLedgerRepository(); + for (let index = 0; index < 3; index++) { + await createJob(repository, BigInt(index + 1), 'syncDisposableEmailDomains'); + } + + const result = await repository.listJobs({limit: 3, cursor: null, filters: {}, maxLookbackDays: 1}); + + expect(result.jobs).toHaveLength(3); + expect(result.nextCursor).not.toBeNull(); + }); + + it('emits no cursor for a page that did not fill', async () => { + const repository = new JobLedgerRepository(); + await createJob(repository, 1n, 'syncDisposableEmailDomains'); + + const result = await repository.listJobs({limit: 3, cursor: null, filters: {}, maxLookbackDays: 1}); + + expect(result.jobs).toHaveLength(1); + expect(result.nextCursor).toBeNull(); + }); + + it('returns every match of a task type filter that sits past the unfiltered page window', async () => { + const repository = new JobLedgerRepository(); + for (let index = 0; index < 60; index++) { + await createJob(repository, BigInt(index + 1), 'syncDisposableEmailDomains'); + } + for (let index = 0; index < 5; index++) { + await createJob(repository, BigInt(1_000 + index), 'processExpiredPremium'); + } + + const result = await repository.listJobs({ + limit: 50, + cursor: null, + filters: {taskType: 'processExpiredPremium'}, + maxLookbackDays: 1, + }); + + expect(result.jobs.map((job) => job.job_id)).toEqual([1_000n, 1_001n, 1_002n, 1_003n, 1_004n]); + }); +}); diff --git a/fluxer_api/src/api/jobs/JobLedgerRepository.ts b/fluxer_api/src/api/jobs/JobLedgerRepository.ts index 983533642..71edcffff 100644 --- a/fluxer_api/src/api/jobs/JobLedgerRepository.ts +++ b/fluxer_api/src/api/jobs/JobLedgerRepository.ts @@ -106,18 +106,6 @@ export class JobLedgerRepository extends IJobLedgerRepository { await deleteOneOrMany(JobsActive.deleteByPk({job_id: jobId})); } - async markFailed(jobId: bigint, errorMessage: string): Promise { - const completedAt = new Date(); - const status: JobStatus = 'failed'; - await upsertOne( - JobsById.patchByPk( - {job_id: jobId}, - {status: Db.set(status), completed_at: Db.set(completedAt), error_message: Db.set(errorMessage)}, - ), - ); - await deleteOneOrMany(JobsActive.deleteByPk({job_id: jobId})); - } - async markCancelled(jobId: bigint): Promise { const completedAt = new Date(); const status: JobStatus = 'cancelled'; @@ -154,6 +142,10 @@ export class JobLedgerRepository extends IJobLedgerRepository { await upsertOne(JobsById.patchByPk({job_id: jobId}, {context_link: Db.set(link)})); } + async setJetStreamSeq(jobId: bigint, seq: string): Promise { + await upsertOne(JobsById.patchByPk({job_id: jobId}, {jet_stream_seq: Db.set(seq)})); + } + async requestCancel(jobId: bigint): Promise { await upsertOne(JobsById.patchByPk({job_id: jobId}, {cancel_requested: Db.set(true)})); } @@ -179,6 +171,11 @@ export class JobLedgerRepository extends IJobLedgerRepository { }): Promise { const {limit, cursor, filters, maxLookbackDays} = opts; const startBucket = cursor ? new Date(`${cursor.bucketDay}T00:00:00Z`) : new Date(); + const hasFilters = Boolean( + filters.status || + filters.taskType || + (filters.requestedByUserId !== undefined && filters.requestedByUserId !== null), + ); const collected: Array = []; let nextCursor: ListJobsCursor | null = null; for (let dayOffset = 0; dayOffset <= maxLookbackDays && collected.length < limit; dayOffset++) { @@ -186,12 +183,13 @@ export class JobLedgerRepository extends IJobLedgerRepository { bucketDate.setUTCDate(bucketDate.getUTCDate() - dayOffset); const bucketDay = bucketDayFor(bucketDate); const remaining = limit - collected.length + 1; + const bucketLimit = hasFilters ? {} : {limit: remaining}; const useCursor = dayOffset === 0 && cursor !== null; let bucketRows: Array; if (useCursor && cursor) { const query = JobsByDayBucket.select({ where: [JobsByDayBucket.where.eq('bucket_day'), JobsByDayBucket.where.lt('created_at')], - limit: remaining, + ...bucketLimit, }); bucketRows = await fetchMany( query.bind({bucket_day: bucketDay, created_at: cursor.createdAt}), @@ -199,25 +197,30 @@ export class JobLedgerRepository extends IJobLedgerRepository { } else { const query = JobsByDayBucket.select({ where: JobsByDayBucket.where.eq('bucket_day'), - limit: remaining, + ...bucketLimit, }); bucketRows = await fetchMany(query.bind({bucket_day: bucketDay})); } for (const r of bucketRows) { - if (filters.status && r.status !== filters.status) continue; if (filters.taskType && r.task_type !== filters.taskType) continue; if (filters.requestedByUserId !== undefined && filters.requestedByUserId !== null) { if (r.requested_by_user_id !== filters.requestedByUserId) continue; } + const fullRow = await this.getJob(r.job_id); + if (!fullRow) continue; + if (filters.status && fullRow.status !== filters.status) continue; if (collected.length >= limit) { nextCursor = {bucketDay, createdAt: r.created_at, jobId: r.job_id}; break; } - const fullRow = await this.getJob(r.job_id); - if (fullRow) collected.push(fullRow); + collected.push(fullRow); } if (nextCursor) break; } + if (nextCursor === null && collected.length >= limit) { + const last = collected[collected.length - 1]; + nextCursor = {bucketDay: bucketDayFor(last.created_at), createdAt: last.created_at, jobId: last.job_id}; + } return {jobs: collected, nextCursor}; } diff --git a/fluxer_api/src/api/middleware/ContentFilterMiddleware.ts b/fluxer_api/src/api/middleware/ContentFilterMiddleware.ts index 84b229dc5..8ced3ed3c 100644 --- a/fluxer_api/src/api/middleware/ContentFilterMiddleware.ts +++ b/fluxer_api/src/api/middleware/ContentFilterMiddleware.ts @@ -72,13 +72,7 @@ const SKIP_FIELD_SUFFIXES = [ '_tokens', ] as const; const SKIP_CONTENT_FILTER_PATH_PARTS = [ - '/admin/audit-logs/search/', - '/admin/bans/phrase/', - '/admin/guilds/search/', - '/admin/messages/search/', - '/admin/reports/search/', - '/admin/users/lookup/', - '/admin/users/search/', + '/admin/blocklists/phrase/', '/auth/', '/oauth2/', '/reports/dsa/email/', diff --git a/fluxer_api/src/api/oauth/repositories/ApplicationRepository.ts b/fluxer_api/src/api/oauth/repositories/ApplicationRepository.ts index a65023664..9e2f6c480 100644 --- a/fluxer_api/src/api/oauth/repositories/ApplicationRepository.ts +++ b/fluxer_api/src/api/oauth/repositories/ApplicationRepository.ts @@ -1,6 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; import {ADMIN_OAUTH2_APPLICATION_ID} from '@fluxer/constants/src/Core'; +import {ForbiddenError} from '@fluxer/errors/src/domains/core/ForbiddenError'; import {type ApplicationID, createApplicationID, type UserID} from '../../BrandedTypes'; import {Config} from '../../Config'; import {SYSTEM_USER_ID} from '../../constants/Core'; @@ -93,7 +95,10 @@ export class ApplicationRepository implements IApplicationRepository { async upsertApplication(data: ApplicationRow, oldData?: ApplicationRow | null): Promise { const applicationId = data.application_id; if (applicationId === createApplicationID(ADMIN_OAUTH2_APPLICATION_ID)) { - throw new Error('Cannot modify the built-in admin OAuth2 application'); + throw new ForbiddenError({ + code: APIErrorCodes.FORBIDDEN, + message: 'Cannot modify the built-in admin OAuth2 application', + }); } const result = await executeVersionedUpdate( async () => fetchOne(SELECT_APPLICATION_CQL, {application_id: applicationId}), @@ -125,7 +130,10 @@ export class ApplicationRepository implements IApplicationRepository { async deleteApplication(applicationId: ApplicationID): Promise { if (applicationId === createApplicationID(ADMIN_OAUTH2_APPLICATION_ID)) { - throw new Error('Cannot delete the built-in admin OAuth2 application'); + throw new ForbiddenError({ + code: APIErrorCodes.FORBIDDEN, + message: 'Cannot delete the built-in admin OAuth2 application', + }); } const applicationRow = await fetchOne(SELECT_APPLICATION_CQL, {application_id: applicationId}); const application = applicationRow ? new Application(applicationRow) : null; diff --git a/fluxer_api/src/api/oauth/tests/OAuth2ScopeEnforcement.test.ts b/fluxer_api/src/api/oauth/tests/OAuth2ScopeEnforcement.test.ts index 1092b246f..458ec2ede 100644 --- a/fluxer_api/src/api/oauth/tests/OAuth2ScopeEnforcement.test.ts +++ b/fluxer_api/src/api/oauth/tests/OAuth2ScopeEnforcement.test.ts @@ -411,10 +411,7 @@ describe('OAuth2 Scope Enforcement', () => { ADMIN_OAUTH2_APPLICATION_ID.toString(), ); await createBuilder(harness, `Bearer ${oauth2Token.token}`) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.OK) .execute(); }); @@ -423,10 +420,7 @@ describe('OAuth2 Scope Enforcement', () => { await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']); const oauth2Token = await createOAuth2Token(harness, admin.userId, ['identify', 'email']); await createBuilder(harness, `Bearer ${oauth2Token.token}`) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.FORBIDDEN, 'ACCESS_DENIED') .execute(); }); @@ -434,10 +428,7 @@ describe('OAuth2 Scope Enforcement', () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']); await createBuilder(harness, `${admin.token}`) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.OK) .execute(); }); @@ -445,13 +436,7 @@ describe('OAuth2 Scope Enforcement', () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']); const apiKey = await createAdminApiKey(harness, admin, 'Test Key', ['user:lookup'], null); - await createBuilder(harness, apiKey.token) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) - .expect(HTTP_STATUS.OK) - .execute(); + await createBuilder(harness, apiKey.token).get(`/admin/users/${admin.userId}`).expect(HTTP_STATUS.OK).execute(); }); test('built-in admin OAuth2 token still requires proper user ACLs', async () => { const admin = await createTestAccount(harness); @@ -463,10 +448,7 @@ describe('OAuth2 Scope Enforcement', () => { ADMIN_OAUTH2_APPLICATION_ID.toString(), ); await createBuilder(harness, `Bearer ${oauth2Token.token}`) - .post('/admin/users/lookup') - .body({ - user_ids: [admin.userId], - }) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL') .execute(); }); @@ -479,10 +461,7 @@ describe('OAuth2 Scope Enforcement', () => { ADMIN_OAUTH2_APPLICATION_ID.toString(), ); await createBuilder(harness, `Bearer ${oauth2Token.token}`) - .post('/admin/users/lookup') - .body({ - user_ids: [user.userId], - }) + .get(`/admin/users/${user.userId}`) .expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS') .execute(); }); @@ -509,8 +488,7 @@ describe('OAuth2 Scope Enforcement', () => { code: string; message: string; }>(harness, `Bearer ${oauth2Token.token}`) - .post('/admin/users/lookup') - .body({user_ids: [admin.userId]}) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.FORBIDDEN, 'ACCESS_DENIED') .execute(); expect(json.code).toBe('ACCESS_DENIED'); @@ -528,8 +506,7 @@ describe('OAuth2 Scope Enforcement', () => { code: string; message: string; }>(harness, `Bearer ${oauth2Token.token}`) - .post('/admin/users/lookup') - .body({user_ids: [admin.userId]}) + .get(`/admin/users/${admin.userId}`) .expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL') .execute(); expect(json.code).toBe('MISSING_ACL'); diff --git a/fluxer_api/src/api/report/ReportService.ts b/fluxer_api/src/api/report/ReportService.ts index 56cb723fe..c49484a28 100644 --- a/fluxer_api/src/api/report/ReportService.ts +++ b/fluxer_api/src/api/report/ReportService.ts @@ -10,6 +10,7 @@ import {CannotReportOwnMessageError} from '@fluxer/errors/src/domains/channel/Ca import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError'; import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError'; import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError'; +import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError'; import {CannotReportGuildError} from '@fluxer/errors/src/domains/guild/CannotReportGuildError'; @@ -804,7 +805,7 @@ export class ReportService { async listMyReports(reporterId: UserID, limit?: number, offset?: number): Promise> { if (!this.reportSearchService) { - throw new Error('Search service not available'); + throw new FeatureTemporarilyDisabledError(); } const {hits} = await this.reportSearchService.listReportsByReporter(reporterId, limit, offset); const reportIds = hits.map((hit) => createReportID(BigInt(hit.id))); @@ -814,7 +815,7 @@ export class ReportService { async listReportsByStatus(status: number, limit?: number, offset?: number): Promise> { if (!this.reportSearchService) { - throw new Error('Search service not available'); + throw new FeatureTemporarilyDisabledError(); } const {hits} = await this.reportSearchService.listReportsByStatus(status, limit, offset); const reportIds = hits.map((hit) => createReportID(BigInt(hit.id))); diff --git a/fluxer_api/src/api/report/tests/ContentReporting.test.ts b/fluxer_api/src/api/report/tests/ContentReporting.test.ts index c64ac0cad..a2d879bd5 100644 --- a/fluxer_api/src/api/report/tests/ContentReporting.test.ts +++ b/fluxer_api/src/api/report/tests/ContentReporting.test.ts @@ -229,9 +229,9 @@ describe('Content Reporting', () => { resolved_at: string | null; public_comment: string | null; }>(harness, `${admin.token}`) - .post('/admin/reports/resolve') + .patch(`/admin/reports/${report.report_id}`) .body({ - report_id: report.report_id, + status: 'resolved', public_comment: publicComment, }) .expect(HTTP_STATUS.OK) diff --git a/fluxer_api/src/api/worker/WorkerService.ts b/fluxer_api/src/api/worker/WorkerService.ts index 24f1bdce8..5b0233904 100644 --- a/fluxer_api/src/api/worker/WorkerService.ts +++ b/fluxer_api/src/api/worker/WorkerService.ts @@ -27,8 +27,29 @@ export class WorkerService implements IWorkerService { ): Promise { const jobId = await this.snowflake.generate(); const skipLedger = options?.skipLedger === true; + const requireLedger = options?.requireLedger === true; const payloadRecord = payload as Record; - const enrichedPayload = skipLedger ? payloadRecord : {...payloadRecord, __jobId: jobId.toString()}; + let ledgerWritten = false; + if (!skipLedger) { + try { + await this.ledger.createJob({ + jobId, + taskType, + payload: payloadRecord, + requestedByUserId: options?.requestedByUserId ?? null, + auditLogReason: options?.auditLogReason ?? null, + maxAttempts: options?.maxAttempts ?? 5, + runAt: options?.runAt ?? null, + jetStreamLane: findLaneForTask(taskType), + jetStreamSeq: null, + }); + ledgerWritten = true; + } catch (ledgerErr) { + Logger.error({err: ledgerErr, jobId: jobId.toString(), taskType}, 'Failed to write ledger row for job'); + if (requireLedger) throw ledgerErr; + } + } + const enrichedPayload = ledgerWritten ? {...payloadRecord, __jobId: jobId.toString()} : payloadRecord; try { const seq = await this.queue.enqueue(taskType, enrichedPayload, { ...(options?.runAt !== undefined && {runAt: options.runAt}), @@ -36,27 +57,19 @@ export class WorkerService implements IWorkerService { ...(options?.priority !== undefined && {priority: options.priority}), ...(options?.jobKey !== undefined && {jobKey: options.jobKey}), }); - if (!skipLedger) { - const lane = findLaneForTask(taskType); - try { - await this.ledger.createJob({ - jobId, - taskType, - payload: payload as Record, - requestedByUserId: options?.requestedByUserId ?? null, - auditLogReason: options?.auditLogReason ?? null, - maxAttempts: options?.maxAttempts ?? 5, - runAt: options?.runAt ?? null, - jetStreamLane: lane, - jetStreamSeq: seq, - }); - } catch (ledgerErr) { - Logger.error({err: ledgerErr, jobId: jobId.toString(), taskType}, 'Failed to write ledger row for job'); - } + if (ledgerWritten) { + await this.ledger + .setJetStreamSeq(jobId, seq) + .catch((err) => Logger.warn({err, jobId: jobId.toString()}, 'Ledger setJetStreamSeq failed')); } Logger.debug({taskType, jobId: jobId.toString(), seq}, 'Job queued successfully'); return jobId; } catch (error) { + if (ledgerWritten) { + await this.ledger + .markDeadletter(jobId, error instanceof Error ? error.message : String(error)) + .catch((err) => Logger.warn({err, jobId: jobId.toString()}, 'Ledger markDeadletter failed')); + } if (error instanceof WorkerQueueOverflowError) { Logger.warn({taskType, jobId: jobId.toString()}, 'Jobs stream is at its limit, shedding job'); throw error; diff --git a/fluxer_api/src/api/worker/tasks/RefreshSearchIndex.ts b/fluxer_api/src/api/worker/tasks/RefreshSearchIndex.ts index 03fd2b890..410b065d8 100644 --- a/fluxer_api/src/api/worker/tasks/RefreshSearchIndex.ts +++ b/fluxer_api/src/api/worker/tasks/RefreshSearchIndex.ts @@ -257,7 +257,7 @@ const refreshDiscovery: IndexHandler = async (_payload, _helpers, kvClient, prog const {guildRepository} = getWorkerDependencies(); const searchService = requireSearchService(getGuildSearchService()); const discoveryRepository = new GuildDiscoveryRepository(); - const approvedRows = await discoveryRepository.listByStatus(DiscoveryApplicationStatus.APPROVED, 1000); + const approvedRows = await discoveryRepository.listByStatus(DiscoveryApplicationStatus.APPROVED); if (approvedRows.length === 0) { return 0; } diff --git a/fluxer_api/src/api/worker/tasks/SyncDiscoveryIndex.ts b/fluxer_api/src/api/worker/tasks/SyncDiscoveryIndex.ts index c941f683e..fb1beec6d 100644 --- a/fluxer_api/src/api/worker/tasks/SyncDiscoveryIndex.ts +++ b/fluxer_api/src/api/worker/tasks/SyncDiscoveryIndex.ts @@ -19,7 +19,7 @@ const syncDiscoveryIndex: WorkerTaskHandler = async (_payload, helpers) => { } const {guildRepository, gatewayService} = getWorkerDependencies(); const discoveryRepository = new GuildDiscoveryRepository(); - const approvedRows = await discoveryRepository.listByStatus(DiscoveryApplicationStatus.APPROVED, 1000); + const approvedRows = await discoveryRepository.listByStatus(DiscoveryApplicationStatus.APPROVED); if (approvedRows.length === 0) { helpers.logger.info('No discoverable guilds to sync'); return; diff --git a/fluxer_api/src/api/worker/tests/WorkerServiceLedger.test.ts b/fluxer_api/src/api/worker/tests/WorkerServiceLedger.test.ts new file mode 100644 index 000000000..14c39d8f5 --- /dev/null +++ b/fluxer_api/src/api/worker/tests/WorkerServiceLedger.test.ts @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {describe, expect, test} from 'vitest'; +import type {ISnowflakeService} from '../../infrastructure/ISnowflakeService'; +import type {CreateJobInput, IJobLedgerRepository} from '../../jobs/IJobLedgerRepository'; +import type {JetStreamWorkerQueue} from '../JetStreamWorkerQueue'; +import {WorkerService} from '../WorkerService'; + +const JOB_ID = 4242n; + +function createSnowflake(): ISnowflakeService { + return { + generate: async () => JOB_ID, + } as unknown as ISnowflakeService; +} + +function createHarness(options?: {createJobError?: Error; enqueueError?: Error}) { + const calls: Array = []; + const createdJobs: Array = []; + const enqueued: Array<{taskType: string; payload: Record}> = []; + const seqUpdates: Array<{jobId: bigint; seq: string}> = []; + const deadletters: Array<{jobId: bigint; errorMessage: string}> = []; + const ledger = { + createJob: async (input: CreateJobInput) => { + calls.push('createJob'); + if (options?.createJobError) throw options.createJobError; + createdJobs.push(input); + }, + setJetStreamSeq: async (jobId: bigint, seq: string) => { + calls.push('setJetStreamSeq'); + seqUpdates.push({jobId, seq}); + }, + markDeadletter: async (jobId: bigint, errorMessage: string) => { + calls.push('markDeadletter'); + deadletters.push({jobId, errorMessage}); + }, + } as unknown as IJobLedgerRepository; + const queue = { + enqueue: async (taskType: string, payload: Record) => { + calls.push('enqueue'); + if (options?.enqueueError) throw options.enqueueError; + enqueued.push({taskType, payload}); + return 'seq-9'; + }, + } as unknown as JetStreamWorkerQueue; + const service = new WorkerService(queue, createSnowflake(), ledger); + return {service, calls, createdJobs, enqueued, seqUpdates, deadletters}; +} + +describe('WorkerService ledger ordering', () => { + test('writes the ledger row before enqueueing and patches the sequence afterwards', async () => { + const harness = createHarness(); + + const jobId = await harness.service.addJob('bulkUpdateUserFlags', {user_ids: []}); + + expect(jobId).toBe(JOB_ID); + expect(harness.calls).toEqual(['createJob', 'enqueue', 'setJetStreamSeq']); + expect(harness.createdJobs[0]!.jetStreamSeq).toBeNull(); + expect(harness.seqUpdates).toEqual([{jobId: JOB_ID, seq: 'seq-9'}]); + expect(harness.enqueued[0]!.payload.__jobId).toBe(JOB_ID.toString()); + }); + + test('rejects without enqueueing when the ledger write fails and the caller requires it', async () => { + const harness = createHarness({createJobError: new Error('cassandra unavailable')}); + + await expect(harness.service.addJob('bulkUpdateUserFlags', {user_ids: []}, {requireLedger: true})).rejects.toThrow( + 'cassandra unavailable', + ); + expect(harness.calls).toEqual(['createJob']); + expect(harness.enqueued).toEqual([]); + }); + + test('still enqueues a row-less job when the ledger write fails and the caller tolerates it', async () => { + const harness = createHarness({createJobError: new Error('cassandra unavailable')}); + + const jobId = await harness.service.addJob('bulkUpdateUserFlags', {user_ids: []}); + + expect(jobId).toBe(JOB_ID); + expect(harness.calls).toEqual(['createJob', 'enqueue']); + expect(harness.enqueued[0]!.payload).not.toHaveProperty('__jobId'); + }); + + test('marks the ledger row terminal when the enqueue fails', async () => { + const harness = createHarness({enqueueError: new Error('stream unreachable')}); + + await expect(harness.service.addJob('bulkUpdateUserFlags', {user_ids: []})).rejects.toThrow('stream unreachable'); + expect(harness.calls).toEqual(['createJob', 'enqueue', 'markDeadletter']); + expect(harness.deadletters).toEqual([{jobId: JOB_ID, errorMessage: 'stream unreachable'}]); + }); + + test('never touches the ledger when the caller skips it', async () => { + const harness = createHarness(); + + await harness.service.addJob('handleMentions', {}, {skipLedger: true}); + + expect(harness.calls).toEqual(['enqueue']); + expect(harness.enqueued[0]!.payload).not.toHaveProperty('__jobId'); + }); +}); diff --git a/fluxer_app/src/features/app/components/setup/SetupWizardClient.ts b/fluxer_app/src/features/app/components/setup/SetupWizardClient.ts index a1b9ea8eb..82c0e4bf9 100644 --- a/fluxer_app/src/features/app/components/setup/SetupWizardClient.ts +++ b/fluxer_app/src/features/app/components/setup/SetupWizardClient.ts @@ -13,12 +13,12 @@ import type { export type SetupBrandingAssetKind = BrandingAssetUploadRequest['kind']; export async function fetchInstanceConfig(): Promise { - const response = await http.post(Endpoints.ADMIN_INSTANCE_CONFIG_GET); + const response = await http.get(Endpoints.ADMIN_INSTANCE_CONFIG); return response.body; } export async function updateInstanceConfig(body: InstanceConfigUpdateRequest): Promise { - const response = await http.post(Endpoints.ADMIN_INSTANCE_CONFIG_UPDATE, {body}); + const response = await http.patch(Endpoints.ADMIN_INSTANCE_CONFIG, {body}); return response.body; } @@ -27,11 +27,11 @@ export async function uploadBrandingAsset( image: string | null, ): Promise { const body: BrandingAssetUploadRequest = {kind, image}; - const response = await http.post(Endpoints.ADMIN_INSTANCE_CONFIG_BRANDING_ASSET, {body}); + const response = await http.post(Endpoints.ADMIN_INSTANCE_CONFIG_BRANDING_ASSETS, {body}); return response.body; } export async function testSmtpConfig(body: InstanceEmailSmtpTestRequest): Promise { - const response = await http.post(Endpoints.ADMIN_INSTANCE_CONFIG_SMTP_TEST, {body}); + const response = await http.post(Endpoints.ADMIN_INSTANCE_CONFIG_SMTP_TESTS, {body}); return response.body; } diff --git a/fluxer_app/src/features/app/constants/Endpoints.ts b/fluxer_app/src/features/app/constants/Endpoints.ts index 746d8a3b9..0a25a9b37 100644 --- a/fluxer_app/src/features/app/constants/Endpoints.ts +++ b/fluxer_app/src/features/app/constants/Endpoints.ts @@ -34,10 +34,9 @@ export const Endpoints = { }, AUTH_SSO_START: '/auth/sso/start', AUTH_SSO_COMPLETE: '/auth/sso/complete', - ADMIN_INSTANCE_CONFIG_GET: '/admin/instance-config/get', - ADMIN_INSTANCE_CONFIG_UPDATE: '/admin/instance-config/update', - ADMIN_INSTANCE_CONFIG_BRANDING_ASSET: '/admin/instance-config/branding-asset', - ADMIN_INSTANCE_CONFIG_SMTP_TEST: '/admin/instance-config/integrations/smtp/test', + ADMIN_INSTANCE_CONFIG: '/admin/instance/config', + ADMIN_INSTANCE_CONFIG_BRANDING_ASSETS: '/admin/instance/config/branding-assets', + ADMIN_INSTANCE_CONFIG_SMTP_TESTS: '/admin/instance/config/smtp-tests', SUDO_WEBAUTHN_OPTIONS: '/users/@me/sudo/webauthn/authentication-options', OAUTH_AUTHORIZE: '/oauth2/authorize', OAUTH_CONSENT: '/oauth2/authorize/consent', diff --git a/knip.json b/knip.json index a3b185317..32e70b567 100644 --- a/knip.json +++ b/knip.json @@ -23,6 +23,7 @@ "fluxer_desktop/src/main/Autostart.ts": ["exports"], "fluxer_desktop/src/main/LinuxDesktopEntry.ts": ["exports"], "fluxer_desktop/src/main/NotificationState.ts": ["exports", "types"], + "packages/schema/src/domains/admin/AdminUserSchemas.ts": ["exports"], "packages/schema/src/domains/geolocation/GeolocationSchemas.ts": ["exports"], "pnpm-workspace.yaml": ["catalog"] }, diff --git a/packages/openapi/src/registry/ParameterRegistry.ts b/packages/openapi/src/registry/ParameterRegistry.ts index a5446c981..88bf2f564 100644 --- a/packages/openapi/src/registry/ParameterRegistry.ts +++ b/packages/openapi/src/registry/ParameterRegistry.ts @@ -80,6 +80,13 @@ const COMMON_PATH_PARAMETERS: Record = { schema: SnowflakeTypeRef, description: 'The ID of the OAuth2 application', }, + key_id: { + name: 'key_id', + in: 'path', + required: true, + schema: SnowflakeTypeRef, + description: 'The ID of the key', + }, }; export function extractPathParameters(path: string): Array { const paramRegex = /:(\w+)/g; diff --git a/packages/schema/src/domains/admin/AdminAclType.ts b/packages/schema/src/domains/admin/AdminAclType.ts new file mode 100644 index 000000000..5d8cd1b61 --- /dev/null +++ b/packages/schema/src/domains/admin/AdminAclType.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {z} from 'zod'; + +type AdminACL = (typeof AdminACLs)[keyof typeof AdminACLs]; + +const ADMIN_ACL_VALUES = Object.values(AdminACLs) as [AdminACL, ...Array]; + +export const ADMIN_ACL_COUNT = ADMIN_ACL_VALUES.length; + +export const AdminAclType = z.enum(ADMIN_ACL_VALUES); + +export type AdminAclType = z.infer; diff --git a/packages/schema/src/domains/admin/AdminApplicationSchemas.ts b/packages/schema/src/domains/admin/AdminApplicationSchemas.ts index 074e991ce..6c8b04100 100644 --- a/packages/schema/src/domains/admin/AdminApplicationSchemas.ts +++ b/packages/schema/src/domains/admin/AdminApplicationSchemas.ts @@ -39,11 +39,20 @@ export const ApplicationAdminResponse = z.object({ export type ApplicationAdminResponse = z.infer; -export const LookupApplicationRequest = z.object({ - application_id: SnowflakeType.describe('ID of the application to look up'), +export const AdminApplicationIdParam = z.object({ + application_id: SnowflakeType.describe('The ID of the OAuth2 application'), }); -export type LookupApplicationRequest = z.infer; +export type AdminApplicationIdParam = z.infer; + +export const ListApplicationsQuery = z.object({ + owner_id: SnowflakeType.optional().describe('Restrict the results to the applications owned by this user'), + guild_id: SnowflakeType.optional().describe( + 'Restrict the results to the applications whose bot users are members of this guild', + ), +}); + +export type ListApplicationsQuery = z.infer; export const LookupApplicationResponse = z.object({ application: ApplicationAdminResponse.nullable(), @@ -51,32 +60,13 @@ export const LookupApplicationResponse = z.object({ export type LookupApplicationResponse = z.infer; -export const ListUserApplicationsRequest = z.object({ - user_id: SnowflakeType.describe('ID of the user whose applications to list'), -}); - -export type ListUserApplicationsRequest = z.infer; - -export const ListUserApplicationsResponse = z.object({ +export const ListApplicationsResponse = z.object({ applications: z.array(ApplicationAdminResponse), }); -export type ListUserApplicationsResponse = z.infer; - -export const ListGuildApplicationsRequest = z.object({ - guild_id: SnowflakeType.describe('ID of the guild whose installed bot applications to list'), -}); - -export type ListGuildApplicationsRequest = z.infer; - -export const ListGuildApplicationsResponse = z.object({ - applications: z.array(ApplicationAdminResponse), -}); - -export type ListGuildApplicationsResponse = z.infer; +export type ListApplicationsResponse = z.infer; export const TransferApplicationOwnershipRequest = z.object({ - application_id: SnowflakeType.describe('ID of the application to transfer'), new_owner_id: SnowflakeType.describe('ID of the user to transfer ownership to'), }); diff --git a/packages/schema/src/domains/admin/AdminBlocklistSchemas.ts b/packages/schema/src/domains/admin/AdminBlocklistSchemas.ts new file mode 100644 index 000000000..cb27128c7 --- /dev/null +++ b/packages/schema/src/domains/admin/AdminBlocklistSchemas.ts @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + BanAvatarHashRequest, + BanEmailRequest, + BanFileShaRequest, + BanIpRequest, + BanPhraseRequest, + BanProfileSubstringRequest, + BanUrlDomainRequest, + BanUrlRequest, + CheckAvatarHashRequest, + SuspiciousEmailDomainRequest, +} from '@fluxer/schema/src/domains/admin/AdminSchemas'; +import {createQueryIntegerType} from '@fluxer/schema/src/primitives/QueryValidators'; +import {createStringType, Int32Type, SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; +import {z} from 'zod'; + +export const AdminBlocklistListType = z + .enum([ + 'ip', + 'email', + 'email-domain-suspicious', + 'phrase', + 'url', + 'url-domain', + 'file-sha', + 'avatar-hash', + 'profile-substring', + ]) + .describe('The blocklist an entry belongs to'); + +export type AdminBlocklistListType = z.infer; + +const BlocklistScopeType = BanProfileSubstringRequest.shape.scope; + +export const BlocklistTypeParam = z.object({ + list_type: AdminBlocklistListType.describe('The blocklist to operate on'), +}); + +export type BlocklistTypeParam = z.infer; + +export const BlocklistEntryParam = z.object({ + list_type: AdminBlocklistListType.describe('The blocklist the entry belongs to'), + entry_value: createStringType(1, 2048).describe('The percent-encoded value of the blocklist entry'), +}); + +export type BlocklistEntryParam = z.infer; + +export const AdminBlocklistScopeQuery = z.object({ + scope: BlocklistScopeType.optional().describe( + 'Profile field the entry is scoped to. Required on the profile-substring blocklist and rejected on every other blocklist.', + ), +}); + +export type AdminBlocklistScopeQuery = z.infer; + +export const AdminBlocklistEntryListQuery = z.object({ + limit: createQueryIntegerType({minValue: 1, maxValue: 200, defaultValue: 50}).describe( + 'Maximum number of entries to return', + ), + after: createStringType(1, 2048) + .optional() + .describe('Return entries ordered after this value, taken from the next_after cursor of the previous page'), + scope: BlocklistScopeType.optional().describe( + 'Profile field to list. Required on the profile-substring blocklist and rejected on every other blocklist.', + ), +}); + +export type AdminBlocklistEntryListQuery = z.infer; + +const AdminBlocklistTypeResponse = z.object({ + list_type: AdminBlocklistListType, + description: z.string().describe('What the blocklist matches and how matching is performed'), + value_field: z.string().describe('The request body field that carries the entry value when adding to this blocklist'), + fields: z.array(z.string()).max(8).describe('Fields entries of this blocklist accept beyond the value itself'), + scoped: z.boolean().describe('Whether entries are scoped to a profile field and a scope must be supplied'), + supports_bulk_create: z.boolean().describe('Whether PUT on the entry collection is accepted'), + supports_bulk_delete: z.boolean().describe('Whether DELETE on the entry collection is accepted'), + supports_update: z.boolean().describe('Whether PATCH on a single entry is accepted'), +}); + +export const AdminBlocklistTypeListResponse = z.object({ + items: z.array(AdminBlocklistTypeResponse).max(50).describe('Every blocklist exposed by this instance'), +}); + +const AdminBlocklistEntryResponse = z.object({ + list_type: AdminBlocklistListType, + value: createStringType(1, 2048).describe('The canonical stored value of the entry'), + scope: z + .string() + .nullable() + .describe('The profile field the entry is scoped to, or null when the blocklist is unscoped'), + category: z.string().nullable().describe('The category slug stored alongside the entry, or null'), + severity: Int32Type.nullable().describe('The stored severity, or null when the blocklist has no severity'), + source_url: z.string().nullable().describe('The upstream source the entry was imported from, or null'), + notes: z.string().nullable().describe('The internal notes stored alongside the entry, or null'), + content_type: z.string().nullable().describe('The MIME type hint stored alongside the entry, or null'), + match_subdomains: z + .boolean() + .nullable() + .describe('Whether subdomains are covered by the entry, or null when the blocklist has no such flag'), + reason: z.string().nullable().describe('The stored reason for the entry, or null'), + expires_at: z.string().nullable().describe('ISO 8601 timestamp when the entry expires, or null'), + created_at: z.string().nullable().describe('ISO 8601 timestamp when the entry was added, or null'), + created_by_user_id: SnowflakeStringType.nullable().describe('The admin who added the entry, or null when unknown'), +}); + +export const AdminBlocklistEntryListResponse = z.object({ + items: z.array(AdminBlocklistEntryResponse).max(200).describe('The blocklist entries in this page, ordered by value'), + has_more: z.boolean().describe('Whether another page can be fetched with the next_after cursor'), + next_after: z + .string() + .nullable() + .describe('Cursor to send as after on the next request, or null when this is the last page'), +}); + +export const AdminBlocklistEntryCreateRequest = z + .union([ + BanIpRequest, + BanEmailRequest, + SuspiciousEmailDomainRequest, + BanPhraseRequest, + BanUrlRequest, + BanUrlDomainRequest, + BanFileShaRequest, + BanAvatarHashRequest, + BanProfileSubstringRequest, + ]) + .describe('The entry to add, in the shape the blocklist named by list_type accepts'); + +export const AdminBlocklistBulkDeleteRequest = z + .union([CheckAvatarHashRequest, BanProfileSubstringRequest]) + .describe('The entries to remove, in the shape the blocklist named by list_type accepts'); + +export const AdminBlocklistUrlUpdateRequest = BanUrlRequest.omit({url: true}); + +export const AdminBlocklistUrlDomainUpdateRequest = BanUrlDomainRequest.omit({domain: true}); + +export const AdminBlocklistFileShaUpdateRequest = BanFileShaRequest.omit({sha256_hex: true}); + +export const AdminBlocklistAvatarHashUpdateRequest = BanAvatarHashRequest.omit({hashes: true}); + +export const AdminBlocklistProfileSubstringUpdateRequest = BanProfileSubstringRequest.omit({substrings: true}); + +export const AdminBlocklistEntryUpdateRequest = z + .union([ + AdminBlocklistUrlUpdateRequest, + AdminBlocklistUrlDomainUpdateRequest, + AdminBlocklistFileShaUpdateRequest, + AdminBlocklistAvatarHashUpdateRequest, + AdminBlocklistProfileSubstringUpdateRequest, + ]) + .describe('The stored fields to write, in the shape the blocklist named by list_type accepts'); diff --git a/packages/schema/src/domains/admin/AdminBulkSchemas.ts b/packages/schema/src/domains/admin/AdminBulkSchemas.ts new file mode 100644 index 000000000..463dc6a58 --- /dev/null +++ b/packages/schema/src/domains/admin/AdminBulkSchemas.ts @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + BulkAddGuildMembersRequest, + BulkUpdateGuildFeaturesRequest, +} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas'; +import {BulkDeleteUserMessagesRequest} from '@fluxer/schema/src/domains/admin/AdminMessageSchemas'; +import { + BulkScheduleUserDeletionRequest, + BulkUpdateSuspiciousActivityFlagsRequest, + BulkUpdateUserFlagsRequest, +} from '@fluxer/schema/src/domains/admin/AdminUserSchemas'; +import {z} from 'zod'; + +export const AdminBulkTaskType = { + UPDATE_USER_FLAGS: 'update_user_flags', + UPDATE_SUSPICIOUS_ACTIVITY_FLAGS: 'update_suspicious_activity_flags', + UPDATE_GUILD_FEATURES: 'update_guild_features', + ADD_GUILD_MEMBERS: 'add_guild_members', + SCHEDULE_USER_DELETION: 'schedule_user_deletion', + DELETE_USER_MESSAGES: 'delete_user_messages', +} as const; + +export type AdminBulkTaskType = (typeof AdminBulkTaskType)[keyof typeof AdminBulkTaskType]; + +export const AdminBulkJobCreateRequest = z.discriminatedUnion('task', [ + BulkUpdateUserFlagsRequest.extend({ + task: z + .literal(AdminBulkTaskType.UPDATE_USER_FLAGS) + .describe('Adds and removes account flags on every targeted user'), + }), + BulkUpdateSuspiciousActivityFlagsRequest.extend({ + task: z + .literal(AdminBulkTaskType.UPDATE_SUSPICIOUS_ACTIVITY_FLAGS) + .describe('Adds and removes verification requirements on every targeted user'), + }), + BulkUpdateGuildFeaturesRequest.extend({ + task: z + .literal(AdminBulkTaskType.UPDATE_GUILD_FEATURES) + .describe('Adds and removes features on every targeted guild'), + }), + BulkAddGuildMembersRequest.extend({ + task: z.literal(AdminBulkTaskType.ADD_GUILD_MEMBERS).describe('Adds every targeted user to one guild'), + }), + BulkScheduleUserDeletionRequest.extend({ + task: z + .literal(AdminBulkTaskType.SCHEDULE_USER_DELETION) + .describe('Schedules account deletion for every targeted user'), + }), + BulkDeleteUserMessagesRequest.extend({ + task: z + .literal(AdminBulkTaskType.DELETE_USER_MESSAGES) + .describe('Deletes every message authored by each targeted user, across all channels'), + }), +]); + +export type AdminBulkJobCreateRequest = z.infer; diff --git a/packages/schema/src/domains/admin/AdminGuildSchemas.ts b/packages/schema/src/domains/admin/AdminGuildSchemas.ts index d4174d877..d48aa2de8 100644 --- a/packages/schema/src/domains/admin/AdminGuildSchemas.ts +++ b/packages/schema/src/domains/admin/AdminGuildSchemas.ts @@ -18,6 +18,7 @@ import { GuildVerificationLevelSchema, NSFWLevelSchema, } from '@fluxer/schema/src/primitives/GuildValidators'; +import {createQueryIntegerType} from '@fluxer/schema/src/primitives/QueryValidators'; import { createBitflagInt32Type, createNamedStringLiteralUnion, @@ -101,40 +102,20 @@ export const KickGuildMemberRequest = z.object({ export type KickGuildMemberRequest = z.infer; -export const SearchGuildsRequest = z.object({ - query: createStringType(1, 1024).optional(), - limit: z.number().int().min(1).max(200).default(50), - offset: z.number().int().min(0).default(0), +export const GetProcessMemoryStatsQuery = z.object({ + limit: createQueryIntegerType({defaultValue: 100, minValue: 100, maxValue: 1000}).describe( + 'Maximum number of guild processes to return (100-1000, default 100)', + ), }); -export type SearchGuildsRequest = z.infer; +export type GetProcessMemoryStatsQuery = z.infer; -export const ReloadGuildRequest = z.object({ - guild_id: SnowflakeType, -}); - -export type ReloadGuildRequest = z.infer; - -export const ShutdownGuildRequest = z.object({ - guild_id: SnowflakeType, -}); - -export type ShutdownGuildRequest = z.infer; - -export const GetProcessMemoryStatsRequest = z.object({ - limit: z.number().int().min(100).max(1000).default(100), -}); - -export type GetProcessMemoryStatsRequest = z.infer; - -export const UpdateGuildFeaturesRequest = z.object({ +const UpdateGuildFeaturesRequest = z.object({ guild_id: SnowflakeType.describe('ID of the guild to update'), add_features: z.array(GuildFeatureSchema).max(100).default([]).describe('Guild features to add'), remove_features: z.array(GuildFeatureSchema).max(100).default([]).describe('Guild features to remove'), }); -export type UpdateGuildFeaturesRequest = z.infer; - export const ForceAddUserToGuildRequest = z.object({ user_id: SnowflakeType.describe('ID of the user to add to the guild'), guild_id: SnowflakeType.describe('ID of the guild to add the user to'), @@ -158,12 +139,6 @@ export const ClearGuildFieldsRequest = z.object({ export type ClearGuildFieldsRequest = z.infer; -export const DeleteGuildRequest = z.object({ - guild_id: SnowflakeType.describe('ID of the guild to delete'), -}); - -export type DeleteGuildRequest = z.infer; - export const UpdateGuildVanityRequest = z.object({ guild_id: SnowflakeType.describe('ID of the guild to update'), vanity_url_code: VanityURLCodeType.nullable().describe('New vanity URL code, or null to remove'), @@ -254,3 +229,43 @@ export type ListGuildAuditLogsRequest = z.infer; + +export const ListGuildsQuery = z.object({ + q: createStringType(1, 1024) + .optional() + .describe('Free-text query matched against the guild name and vanity URL code'), + limit: createQueryIntegerType({defaultValue: 50, minValue: 1, maxValue: 200}).describe( + 'Maximum guilds to return (1-200, default 50)', + ), + offset: createQueryIntegerType({defaultValue: 0, minValue: 0, maxValue: 10000}).describe( + 'Guilds to skip before returning results (default 0)', + ), +}); + +export type ListGuildsQuery = z.infer; + +export const ListGuildMembersQuery = z.object({ + limit: createQueryIntegerType({defaultValue: 50, minValue: 1, maxValue: 200}).describe( + 'Maximum members to return (1-200, default 50)', + ), + offset: createQueryIntegerType({defaultValue: 0, minValue: 0, maxValue: 2147483647}).describe( + 'Members to skip before returning results (default 0)', + ), +}); + +export type ListGuildMembersQuery = z.infer; + +export const UpdateGuildRequest = UpdateGuildSettingsRequest.omit({guild_id: true}).extend({ + name: UpdateGuildNameRequest.shape.name.optional(), + vanity_url_code: UpdateGuildVanityRequest.shape.vanity_url_code.optional(), + new_owner_id: TransferGuildOwnershipRequest.shape.new_owner_id.optional(), + add_features: UpdateGuildFeaturesRequest.shape.add_features.removeDefault().optional(), + remove_features: UpdateGuildFeaturesRequest.shape.remove_features.removeDefault().optional(), + fields: ClearGuildFieldsRequest.shape.fields.optional(), +}); + +export type UpdateGuildRequest = z.infer; + +export const BanGuildMemberBody = BanGuildMemberRequest.omit({guild_id: true, user_id: true}); + +export type BanGuildMemberBody = z.infer; diff --git a/packages/schema/src/domains/admin/AdminMessageBrowseSchemas.ts b/packages/schema/src/domains/admin/AdminMessageBrowseSchemas.ts index 3b42a79cc..29c4105cc 100644 --- a/packages/schema/src/domains/admin/AdminMessageBrowseSchemas.ts +++ b/packages/schema/src/domains/admin/AdminMessageBrowseSchemas.ts @@ -1,8 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {AdminMessageSchema} from '@fluxer/schema/src/domains/admin/AdminSchemas'; +import {AdminMessageSchema, LookupMessageResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas'; import {MessageResponseSchema} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; -import {SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; +import {FilenameType} from '@fluxer/schema/src/primitives/FileValidators'; +import {createQueryIntegerType} from '@fluxer/schema/src/primitives/QueryValidators'; +import {createStringType, SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; import {z} from 'zod'; export const BrowseChannelRequest = z.object({ @@ -30,10 +32,42 @@ export const SearchChannelMessagesRequest = z.object({ export type SearchChannelMessagesRequest = z.infer; -export const SearchChannelMessagesResponse = z.object({ +const SearchChannelMessagesResponse = z.object({ messages: z.array(AdminMessageSchema).max(100), message_responses: z.array(MessageResponseSchema).max(100).optional(), total: z.number().int().min(0), }); -export type SearchChannelMessagesResponse = z.infer; +export const AdminChannelMessageListQuery = z.object({ + limit: createQueryIntegerType({defaultValue: 50, minValue: 1, maxValue: 100}).describe( + 'Number of messages to return (1-100, default 50)', + ), + before: SnowflakeType.optional().describe('Return messages older than this message ID'), + after: SnowflakeType.optional().describe('Return messages newer than this message ID'), +}); + +export type AdminChannelMessageListQuery = z.infer; + +export const AdminMessageSearchQuery = z.object({ + channel_id: SnowflakeType.describe('Return messages sent in this channel'), + q: createStringType(1, 200).optional().describe('Free-text query matched against message content'), + message_id: SnowflakeType.optional().describe( + 'Return the single message with this ID together with its surrounding context; ignores every other filter', + ), + attachment_id: SnowflakeType.optional().describe( + 'Return the single message carrying this attachment together with its surrounding context; requires filename', + ), + filename: FilenameType.optional().describe('The filename of the attachment named by attachment_id'), + context_limit: createQueryIntegerType({defaultValue: 50, minValue: 1, maxValue: 100}).describe( + 'How many messages surrounding a message resolved by message_id or attachment_id to return as context (1-100, default 50)', + ), + limit: createQueryIntegerType({defaultValue: 25, minValue: 1, maxValue: 100}).describe( + 'Maximum number of messages to return when searching (1-100, default 25)', + ), +}); + +export type AdminMessageSearchQuery = z.infer; + +export const AdminMessageSearchResponse = z.union([SearchChannelMessagesResponse, LookupMessageResponse]); + +export type AdminMessageSearchResponse = z.infer; diff --git a/packages/schema/src/domains/admin/AdminMessageSchemas.ts b/packages/schema/src/domains/admin/AdminMessageSchemas.ts index 50d0a7041..971c821e5 100644 --- a/packages/schema/src/domains/admin/AdminMessageSchemas.ts +++ b/packages/schema/src/domains/admin/AdminMessageSchemas.ts @@ -1,9 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {FilenameType} from '@fluxer/schema/src/primitives/FileValidators'; +import {createQueryIntegerType} from '@fluxer/schema/src/primitives/QueryValidators'; import {Int32Type, SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; import {z} from 'zod'; +const FALSE_QUERY_VALUES = ['false', 'False', '0']; + export const LookupMessageRequest = z.object({ channel_id: SnowflakeType, message_id: SnowflakeType, @@ -59,12 +62,6 @@ export const MessageShredResponse = z.object({ export type MessageShredResponse = z.infer; -export const MessageShredStatusRequest = z.object({ - job_id: z.string(), -}); - -export type MessageShredStatusRequest = z.infer; - export const DeleteAllUserMessagesRequest = z.object({ user_id: SnowflakeType, dry_run: z.boolean().default(true), @@ -87,3 +84,34 @@ export const BulkDeleteUserMessagesRequest = z.object({ }); export type BulkDeleteUserMessagesRequest = z.infer; +export const AdminMessageDetailQuery = z.object({ + context_limit: createQueryIntegerType({defaultValue: 50, minValue: 1, maxValue: 100}).describe( + 'How many messages surrounding the requested message to return as context (1-100, default 50)', + ), +}); + +export type AdminMessageDetailQuery = z.infer; + +export const AdminUserMessageShredRequest = z.object({ + entries: z.array(MessageShredEntryType).min(1).max(1000), +}); + +export type AdminUserMessageShredRequest = z.infer; + +export const AdminUserMessageDeleteQuery = z.object({ + dry_run: z + .string() + .trim() + .optional() + .default('true') + .transform((value) => !FALSE_QUERY_VALUES.includes(value)) + .describe('Count the messages that would be deleted without deleting anything (default true)'), +}); + +export type AdminUserMessageDeleteQuery = z.infer; + +export const MessageShredJobIdParam = z.object({ + job_id: SnowflakeType.describe('The ID of the message shred job'), +}); + +export type MessageShredJobIdParam = z.infer; diff --git a/packages/schema/src/domains/admin/AdminSchemas.ts b/packages/schema/src/domains/admin/AdminSchemas.ts index e4f1d96d5..819eb45b8 100644 --- a/packages/schema/src/domains/admin/AdminSchemas.ts +++ b/packages/schema/src/domains/admin/AdminSchemas.ts @@ -1,6 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; import { GIFT_CODE_DURATION_TYPE_DEFINITIONS, MAX_GIFT_CODES_PER_REQUEST, @@ -12,6 +11,7 @@ import { SystemChannelFlagsDescriptions, } from '@fluxer/constants/src/GuildConstants'; import {LIMIT_KEYS} from '@fluxer/constants/src/LimitConfigMetadata'; +import {ADMIN_ACL_COUNT, AdminAclType} from '@fluxer/schema/src/domains/admin/AdminAclType'; import {GuildAdminResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas'; import {UserAdminResponseSchema} from '@fluxer/schema/src/domains/admin/AdminUserSchemas'; import { @@ -30,6 +30,7 @@ import { NSFWLevelSchema, } from '@fluxer/schema/src/primitives/GuildValidators'; import {PermissionStringType} from '@fluxer/schema/src/primitives/PermissionValidators'; +import {createQueryIntegerType, QueryBooleanType} from '@fluxer/schema/src/primitives/QueryValidators'; import { createBitflagInt32Type, createInt32EnumType, @@ -45,8 +46,6 @@ import { import {EmailType} from '@fluxer/schema/src/primitives/UserValidators'; import {z} from 'zod'; -const ADMIN_ACL_COUNT = Object.keys(AdminACLs).length; - const ReportStatusSchema = withOpenApiType( createInt32EnumType( [ @@ -113,28 +112,26 @@ const SearchIndexTypeEnum = createNamedStringLiteralUnion( ], 'Type of search index to refresh', ); -export const ListAuditLogsRequest = z.object({ - admin_user_id: SnowflakeType.optional().describe('Filter by admin user who performed the action'), - target_type: createStringType(1, 64).optional().describe('Filter by target entity type'), - target_id: z.string().optional().describe('Filter by target entity ID (user, channel, role, invite code, etc.)'), - limit: z.number().int().min(1).max(200).default(50).describe('Maximum number of entries to return'), - offset: z.number().int().min(0).default(0).describe('Number of entries to skip'), +export const AuditLogIdParam = z.object({ + log_id: SnowflakeType.describe('The ID of the audit log entry'), }); -export type ListAuditLogsRequest = z.infer; +export type AuditLogIdParam = z.infer; -export const SearchAuditLogsRequest = z.object({ - query: createStringType(1, 1024).optional().describe('Search query string'), +export const ListAdminAuditLogsQuery = z.object({ + q: createStringType(1, 1024).optional().describe('Free-text query run against the audit log search index'), admin_user_id: SnowflakeType.optional().describe('Filter by admin user who performed the action'), target_type: createStringType(1, 64).optional().describe('Filter by target entity type'), target_id: z.string().optional().describe('Filter by target entity ID (user, channel, role, invite code, etc.)'), sort_by: AuditLogSortByEnum.default('createdAt'), sort_order: SortOrderEnum.default('desc'), - limit: z.number().int().min(1).max(200).default(50).describe('Maximum number of entries to return'), - offset: z.number().int().min(0).default(0).describe('Number of entries to skip'), + limit: createQueryIntegerType({defaultValue: 50, minValue: 1, maxValue: 200}).describe( + 'Maximum number of entries to return', + ), + offset: createQueryIntegerType({defaultValue: 0, minValue: 0}).describe('Number of entries to skip'), }); -export type SearchAuditLogsRequest = z.infer; +export type ListAdminAuditLogsQuery = z.infer; export const SearchReportsRequest = z.object({ query: createStringType(1, 1024).optional().describe('Search query string'), @@ -155,34 +152,78 @@ export const SearchReportsRequest = z.object({ export type SearchReportsRequest = z.infer; -export const ListReportsRequest = z.object({ - status: ReportStatusSchema.optional(), - limit: z.number().int().min(1).max(200).optional().describe('Maximum number of reports to return'), - offset: z.number().int().min(0).optional().describe('Number of reports to skip'), +const ReportStatusFilterEnum = createNamedStringLiteralUnion( + [ + ['pending', 'pending', 'Only reports that are waiting to be reviewed'], + ['resolved', 'resolved', 'Only reports that have been resolved'], + ], + 'Only return reports with this status', +); + +const ReportTypeFilterEnum = createNamedStringLiteralUnion( + [ + ['message', 'message', 'Only reports about a message'], + ['user', 'user', 'Only reports about a user'], + ['guild', 'guild', 'Only reports about a community'], + ], + 'Only return reports about this kind of entity', +); + +const ReportSortByQueryEnum = createNamedStringLiteralUnion( + [ + ['created_at', 'created_at', 'Sort by the time the report was created'], + ['reported_at', 'reported_at', 'Sort by the time the report was submitted'], + ['resolved_at', 'resolved_at', 'Sort by the time the report was resolved'], + ], + 'The field to sort the reports by', +); + +export const ListReportsQuery = z.object({ + q: createStringType(1, 1024).optional().describe('Free-text query matched against the searchable report fields'), + status: ReportStatusFilterEnum.optional().describe('Only return reports with this status'), + report_type: ReportTypeFilterEnum.optional().describe('Only return reports about this kind of entity'), + category: createStringType(1, 128).optional().describe('Only return reports filed under this category'), + reporter_id: SnowflakeType.optional().describe('Only return reports submitted by this user'), + reported_user_id: SnowflakeType.optional().describe('Only return reports about this user'), + reported_guild_id: SnowflakeType.optional().describe('Only return reports about this community'), + reported_channel_id: SnowflakeType.optional().describe('Only return reports about content in this channel'), + guild_context_id: SnowflakeType.optional().describe('Only return reports filed from within this community'), + resolved_by_admin_id: SnowflakeType.optional().describe('Only return reports resolved by this admin'), + sort_by: ReportSortByQueryEnum.default('reported_at').describe('The field to sort the reports by'), + sort_order: SortOrderEnum.default('desc').describe('The direction to sort the reports in'), + limit: createQueryIntegerType({defaultValue: 50, minValue: 1, maxValue: 200}).describe( + 'Maximum number of reports to return (1-200, default 50)', + ), + offset: createQueryIntegerType({defaultValue: 0, minValue: 0, maxValue: 10000}).describe('Number of reports to skip'), }); -export type ListReportsRequest = z.infer; +export type ListReportsQuery = z.infer; -export const ResolveReportRequest = z.object({ - report_id: SnowflakeType.describe('The ID of the report to resolve'), +export const UpdateReportRequest = z.object({ + status: z.literal('resolved').describe('The status to move the report to'), public_comment: createStringType(0, 512).optional().describe('Public comment to include with the resolution'), }); -export type ResolveReportRequest = z.infer; +export type UpdateReportRequest = z.infer; + +export const SearchIndexNameParam = z.object({ + index_name: SearchIndexTypeEnum.describe('The name of the search index'), +}); + +export type SearchIndexNameParam = z.infer; export const RefreshSearchIndexRequest = z.object({ - index_type: SearchIndexTypeEnum, guild_id: SnowflakeType.optional().describe('Specific guild ID to reindex'), user_id: SnowflakeType.optional().describe('Specific user ID to reindex'), }); export type RefreshSearchIndexRequest = z.infer; -export const GetIndexRefreshStatusRequest = z.object({ - job_id: createStringType(1, 128).describe('ID of the index refresh job to check'), +export const SearchIndexRefreshIdParam = z.object({ + job_id: createStringType(1, 128).describe('ID of the index refresh to read'), }); -export type GetIndexRefreshStatusRequest = z.infer; +export type SearchIndexRefreshIdParam = z.infer; export const PurgeGuildAssetsRequest = z.object({ ids: z.array(createStringType(1, 64)).max(100).describe('List of asset IDs to purge'), @@ -190,29 +231,33 @@ export const PurgeGuildAssetsRequest = z.object({ export type PurgeGuildAssetsRequest = z.infer; -export const TriggerUserArchiveRequest = z.object({ - user_id: SnowflakeType.describe('ID of the user to archive'), +export const AdminArchiveCreateRequest = z.object({ include_attachments: z.boolean().default(false).describe('Whether to include attachment binaries in the archive'), }); -export type TriggerUserArchiveRequest = z.infer; +export type AdminArchiveCreateRequest = z.infer; -export const TriggerGuildArchiveRequest = z.object({ - guild_id: SnowflakeType.describe('ID of the guild to archive'), - include_attachments: z.boolean().default(false).describe('Whether to include attachment binaries in the archive'), -}); +export const ListArchivesQuery = z + .object({ + subject_type: ArchiveListSubjectTypeEnum.default('all'), + subject_id: SnowflakeType.optional().describe('Filter by specific subject ID'), + requested_by: SnowflakeType.optional().describe('Filter by user who requested the archive'), + limit: createQueryIntegerType({defaultValue: 50, minValue: 1, maxValue: 200}).describe( + 'Maximum number of archives to return (1-200, default 50)', + ), + include_expired: QueryBooleanType.describe('Whether to include archives past their expires_at'), + }) + .superRefine((value, ctx) => { + if (value.subject_id !== undefined && value.subject_type === 'all') { + ctx.addIssue({ + code: 'custom', + message: 'subject_type must name user or guild when subject_id is supplied', + path: ['subject_type'], + }); + } + }); -export type TriggerGuildArchiveRequest = z.infer; - -export const ListArchivesRequest = z.object({ - subject_type: ArchiveListSubjectTypeEnum.default('all'), - subject_id: SnowflakeType.optional().describe('Filter by specific subject ID'), - requested_by: SnowflakeType.optional().describe('Filter by user who requested the archive'), - limit: z.number().min(1).max(200).default(50).describe('Maximum number of archives to return'), - include_expired: z.boolean().default(false).describe('Whether to include expired archives'), -}); - -export type ListArchivesRequest = z.infer; +export type ListArchivesQuery = z.infer; const IP_OR_CIDR_REGEX = /^(?:(?:\d{1,3}\.){3}\d{1,3}(?:\/\d{1,2})?|(?:[a-fA-F0-9:]+)(?:\/\d{1,3})?)$/; export const BanIpRequest = z.object({ @@ -268,12 +313,6 @@ export const BanUrlRequest = z.object({ export type BanUrlRequest = z.infer; -export const UnbanUrlRequest = z.object({ - url: createStringType(1, 2048).describe('URL to unban (must match the canonicalized form in storage)'), -}); - -export type UnbanUrlRequest = z.infer; - export const BanUrlDomainRequest = z.object({ domain: createStringType(1, 253) .refine( @@ -296,12 +335,6 @@ export const BanUrlDomainRequest = z.object({ export type BanUrlDomainRequest = z.infer; -export const UnbanUrlDomainRequest = z.object({ - domain: createStringType(1, 253).describe('Domain to unban'), -}); - -export type UnbanUrlDomainRequest = z.infer; - export const BanFileShaRequest = z.object({ sha256_hex: createStringType(64, 64) .refine((v) => /^[0-9a-fA-F]{64}$/.test(v), 'Must be a 64-character hex SHA-256') @@ -315,24 +348,6 @@ export const BanFileShaRequest = z.object({ export type BanFileShaRequest = z.infer; -export const UnbanFileShaRequest = z.object({ - sha256_hex: createStringType(64, 64).refine((v) => /^[0-9a-fA-F]{64}$/.test(v), 'Must be a 64-character hex SHA-256'), -}); - -export type UnbanFileShaRequest = z.infer; - -export const CheckUrlBlocklistRequest = z.object({ - url: createStringType(1, 2048).describe('URL to check against the blocklist'), -}); - -export type CheckUrlBlocklistRequest = z.infer; - -export const CheckFileShaRequest = z.object({ - sha256_hex: createStringType(64, 64).refine((v) => /^[0-9a-fA-F]{64}$/.test(v), 'Must be a 64-character hex SHA-256'), -}); - -export type CheckFileShaRequest = z.infer; - const AvatarHashShortType = createStringType(8, 10).refine( (v) => /^(a_)?[0-9a-fA-F]{8}$/.test(v), 'Must be an 8-character MD5 prefix (with optional "a_" prefix)', @@ -732,6 +747,7 @@ export const InstanceConfigUpdateRequest = z.object({ single_community_enabled: z.boolean().optional(), single_community_name: z.string().trim().min(1).max(100).optional(), direct_messages_disabled: z.boolean().optional(), + direct_messages_locked: z.literal(false).optional(), premium_mode: z.enum(['mirror', 'everyone']).optional(), services: z .object({ @@ -794,14 +810,22 @@ export const CreateRegistrationUrlResponse = z.object({ export type CreateRegistrationUrlResponse = z.infer; -export const RegistrationUrlActionRequest = z.object({ - id: createStringType(1, 128), +export const RegistrationUrlIdParam = z.object({ + registration_url_id: createStringType(1, 128).describe('The ID of the registration URL'), }); -export type RegistrationUrlActionRequest = z.infer; +export type RegistrationUrlIdParam = z.infer; + +const PendingRegistrationStatusSchema = createNamedStringLiteralUnion( + [ + ['approved', 'approved', 'The account may log in'], + ['rejected', 'rejected', 'The account is blocked from logging in'], + ], + 'Pending registration decision', +); export const PendingRegistrationActionRequest = z.object({ - user_id: SnowflakeStringType, + status: PendingRegistrationStatusSchema, }); export type PendingRegistrationActionRequest = z.infer; @@ -860,7 +884,7 @@ export const CreateAdminApiKeyRequest = z.object({ .refine((value) => value.trim().length > 0, 'Name cannot be empty') .describe('Display name for the API key'), expires_in_days: z.number().int().min(1).max(365).optional().describe('Number of days until the key expires'), - acls: z.array(z.string()).max(ADMIN_ACL_COUNT).describe('List of access control permissions for the key'), + acls: z.array(AdminAclType).max(ADMIN_ACL_COUNT).describe('List of access control permissions for the key'), }); export type CreateAdminApiKeyRequest = z.infer; @@ -888,6 +912,23 @@ export const ListAdminApiKeyResponse = z.object({ export type ListAdminApiKeyResponse = z.infer; +export const UpdateAdminApiKeyRequest = z.object({ + name: z + .string() + .min(1) + .max(100) + .refine((value) => value.trim().length > 0, 'Name cannot be empty') + .optional() + .describe('New display name for the API key'), + acls: z + .array(AdminAclType) + .max(ADMIN_ACL_COUNT) + .optional() + .describe('Replacement list of access control permissions for the key'), +}); + +export type UpdateAdminApiKeyRequest = z.infer; + export const SearchGuildsResponse = z.object({ guilds: z.array(GuildAdminResponse), total: z.number(), @@ -1016,7 +1057,7 @@ const AdminAuditLogChannelSummarySchema = z.object({ type: ChannelTypeSchema, guild_id: SnowflakeStringType.nullable(), }); -const AdminAuditLogResponseSchema = z.object({ +export const AdminAuditLogResponseSchema = z.object({ log_id: SnowflakeStringType, admin_user_id: SnowflakeStringType, admin_user: AdminAuditLogUserSummarySchema.nullable(), @@ -1228,8 +1269,8 @@ const AdminLookupGuildSchema = z.object({ rules_channel_id: SnowflakeStringType.nullable(), disabled_operations: Int32Type, member_count: Int32Type, - channels: z.array(AdminGuildChannelSummarySchema).max(500), - roles: z.array(AdminGuildRoleSummarySchema).max(250), + channels: z.array(AdminGuildChannelSummarySchema), + roles: z.array(AdminGuildRoleSummarySchema), }); export const LookupGuildResponse = z.object({ guild: AdminLookupGuildSchema.nullable(), @@ -1250,14 +1291,14 @@ const GuildAssetItemSchema = z.object({ export const ListGuildEmojisResponse = z.object({ guild_id: SnowflakeStringType, - emojis: z.array(GuildAssetItemSchema).max(500), + emojis: z.array(GuildAssetItemSchema), }); export type ListGuildEmojisResponse = z.infer; export const ListGuildStickersResponse = z.object({ guild_id: SnowflakeStringType, - stickers: z.array(GuildAssetItemSchema).max(500), + stickers: z.array(GuildAssetItemSchema), }); export type ListGuildStickersResponse = z.infer; @@ -1403,7 +1444,7 @@ export const ReportAdminResponseSchema = z.object({ message_context: z.array(ReportMessageContextSchema).optional(), message_responses: z.array(MessageResponseSchema).optional(), }); -export const ListReportsResponse = z.object({ +const ListReportsResponse = z.object({ reports: z.array(ReportAdminResponseSchema), }); export const ResolveReportResponse = z.object({ @@ -1412,12 +1453,13 @@ export const ResolveReportResponse = z.object({ resolved_at: z.string().nullable(), public_comment: z.string().nullable(), }); -export const SearchReportsResponse = z.object({ +const SearchReportsResponse = z.object({ reports: z.array(ReportAdminResponseSchema), total: z.number(), offset: z.number(), limit: z.number(), }); +export const AdminReportListResponse = z.union([SearchReportsResponse, ListReportsResponse]); const LimitKeyMetadataSchema = z.object({ key: z.string(), label: z.string(), diff --git a/packages/schema/src/domains/admin/AdminUserSchemas.test.ts b/packages/schema/src/domains/admin/AdminUserSchemas.test.ts index 1d3ca202b..c26b64aa6 100644 --- a/packages/schema/src/domains/admin/AdminUserSchemas.test.ts +++ b/packages/schema/src/domains/admin/AdminUserSchemas.test.ts @@ -11,6 +11,11 @@ describe('SetUserAclsRequest', () => { expect(result.success).toBe(true); }); + test('rejects an ACL outside the registry', () => { + const result = SetUserAclsRequest.safeParse({user_id: '1', acls: ['user:veiw']}); + expect(result.success).toBe(false); + }); + test('rejects more entries than there are ACLs', () => { const acls = [...Object.values(AdminACLs), 'overflow:one']; const result = SetUserAclsRequest.safeParse({user_id: '1', acls}); diff --git a/packages/schema/src/domains/admin/AdminUserSchemas.ts b/packages/schema/src/domains/admin/AdminUserSchemas.ts index 39726cfb9..f1ba2ebc6 100644 --- a/packages/schema/src/domains/admin/AdminUserSchemas.ts +++ b/packages/schema/src/domains/admin/AdminUserSchemas.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {DeletionReasons} from '@fluxer/constants/src/Core'; import { PremiumFlags, PremiumFlagsDescriptions, @@ -9,21 +9,23 @@ import { UserFlags, UserFlagsDescriptions, } from '@fluxer/constants/src/UserConstants'; +import {ADMIN_ACL_COUNT, AdminAclType} from '@fluxer/schema/src/domains/admin/AdminAclType'; import {NSFWLevelSchema} from '@fluxer/schema/src/primitives/GuildValidators'; +import {createQueryIntegerType, QueryBooleanType} from '@fluxer/schema/src/primitives/QueryValidators'; import { createBitflagInt32Type, createBitflagStringType, + createInt32EnumType, createNamedStringLiteralUnion, createStringType, Int32Type, SnowflakeStringType, SnowflakeType, + withFieldDescription, } from '@fluxer/schema/src/primitives/SchemaPrimitives'; import {DiscriminatorType, EmailType, UsernameType} from '@fluxer/schema/src/primitives/UserValidators'; import {z} from 'zod'; -const ADMIN_ACL_COUNT = Object.keys(AdminACLs).length; - export const UserAdminResponseSchema = z.object({ id: SnowflakeStringType, username: z.string(), @@ -92,22 +94,6 @@ export const LookupUserRequest = z.union([LookupUserByQueryRequest, LookupUserBy export type LookupUserRequest = z.infer; -export const SearchUsersRequest = z.object({ - query: createStringType(1, 1024).optional(), - email: createStringType(1, 320).optional(), - last_active_ip: createStringType(1, 64).optional(), - limit: z.number().int().min(1).max(200).default(50), - offset: z.number().int().min(0).default(0), -}); - -export type SearchUsersRequest = z.infer; - -export const ListUserSessionsRequest = z.object({ - user_id: SnowflakeType, -}); - -export type ListUserSessionsRequest = z.infer; - const UserContactChangeLogEntrySchema = z.object({ event_id: z.string(), field: z.string(), @@ -194,7 +180,7 @@ const AdminUserDmChannelSchema = z.object({ owner_id: SnowflakeStringType.nullable(), }); -export const ListUserDmChannelsResponse = z.object({ +const ListUserDmChannelsResponse = z.object({ channels: z.array(AdminUserDmChannelSchema).max(200), }); export const ListUserGroupDmChannelsRequest = z.object({ @@ -203,13 +189,10 @@ export const ListUserGroupDmChannelsRequest = z.object({ export type ListUserGroupDmChannelsRequest = z.infer; -export const ListUserGroupDmChannelsResponse = z.object({ +const ListUserGroupDmChannelsResponse = z.object({ channels: z.array(AdminUserDmChannelSchema).max(500), }); -export type ListUserGroupDmChannelsResponse = z.infer; -export type ListUserDmChannelsResponse = z.infer; - export const TerminateSessionsResponse = z.object({ terminated_count: Int32Type, }); @@ -228,22 +211,18 @@ const PremiumFlagValueType = createBitflagInt32Type( 'A single premium flag value to add or remove', 'PremiumFlags', ); -export const UpdateUserFlagsRequest = z.object({ +const UpdateUserFlagsRequest = z.object({ user_id: SnowflakeType.describe('ID of the user to update'), add_flags: z.array(UserFlagValueType).max(64).default([]).describe('User flags to add'), remove_flags: z.array(UserFlagValueType).max(64).default([]).describe('User flags to remove'), }); -export type UpdateUserFlagsRequest = z.infer; - -export const UpdatePremiumFlagsRequest = z.object({ +const UpdatePremiumFlagsRequest = z.object({ user_id: SnowflakeType.describe('ID of the user to update'), add_flags: z.array(PremiumFlagValueType).max(64).default([]).describe('Premium flags to add'), remove_flags: z.array(PremiumFlagValueType).max(64).default([]).describe('Premium flags to remove'), }); -export type UpdatePremiumFlagsRequest = z.infer; - export const DisableMfaRequest = z.object({ user_id: SnowflakeType.describe('ID of the user to disable MFA for'), }); @@ -339,9 +318,15 @@ export const TempBanUserRequest = z.object({ export type TempBanUserRequest = z.infer; +const DeletionReasonCodeType = createInt32EnumType( + Object.entries(DeletionReasons).map(([name, value]) => [value, name] as const), + 'Reason the account was scheduled for deletion', + 'DeletionReasonCode', +); + export const ScheduleAccountDeletionRequest = z.object({ user_id: SnowflakeType.describe('ID of the user to schedule deletion for'), - reason_code: Int32Type.describe('Code indicating the reason for deletion'), + reason_code: withFieldDescription(DeletionReasonCodeType, 'Code indicating the reason for deletion'), public_reason: createStringType(0, 512).optional().describe('Public-facing reason for the deletion'), days_until_deletion: z .number() @@ -356,7 +341,7 @@ export type ScheduleAccountDeletionRequest = z.infer; @@ -438,7 +423,7 @@ export type BulkUpdateUserFlagsRequest = z.infer; export const ListUserRelationshipsResponse = z.object({ - friends: z.array(AdminRelationshipEntrySchema).max(10000), - incoming_requests: z.array(AdminRelationshipEntrySchema).max(10000), - outgoing_requests: z.array(AdminRelationshipEntrySchema).max(10000), - blocked: z.array(AdminRelationshipEntrySchema).max(10000), + friends: z.array(AdminRelationshipEntrySchema), + incoming_requests: z.array(AdminRelationshipEntrySchema), + outgoing_requests: z.array(AdminRelationshipEntrySchema), + blocked: z.array(AdminRelationshipEntrySchema), }); export type ListUserRelationshipsResponse = z.infer; @@ -523,3 +508,166 @@ export const RemoveUserRelationshipsResponse = z.object({ }); export type RemoveUserRelationshipsResponse = z.infer; + +export const AdminAclListResponse = z.object({ + acls: z + .array(createStringType(1, 64)) + .max(ADMIN_ACL_COUNT) + .describe('Every admin access control permission the admin API recognises'), +}); + +export type AdminAclListResponse = z.infer; + +export const AdminUserListQuery = z.object({ + q: createStringType(1, 1024).optional().describe('Restrict the results to the users matching this free-text query'), + user_id: z + .union([SnowflakeStringType, z.array(SnowflakeStringType).max(100)]) + .optional() + .describe('Restrict the results to these users. Repeat the parameter to pass more than one.'), + resolve: createStringType(1, 1024) + .optional() + .describe( + 'Resolve one exact identifier: a username#discriminator tag, a user ID, an email address, or a Stripe subscription ID', + ), + email: createStringType(1, 320).optional().describe('Restrict the results to the user with this exact email address'), + last_active_ip: createStringType(1, 64) + .optional() + .describe('Restrict the results to the users whose last active IP address matches this one exactly'), + limit: createQueryIntegerType({defaultValue: 50, minValue: 1, maxValue: 200}).describe( + 'Maximum number of users to return', + ), + offset: createQueryIntegerType({defaultValue: 0, minValue: 0, maxValue: 100000}).describe( + 'Number of users to skip before returning results', + ), +}); + +export type AdminUserListQuery = z.infer; + +export const AdminUserGuildListQuery = z.object({ + before: SnowflakeType.optional().describe('Return guilds with IDs lower than this guild ID'), + after: SnowflakeType.optional().describe('Return guilds with IDs higher than this guild ID'), + limit: createQueryIntegerType({defaultValue: 200, minValue: 1, maxValue: 200}).describe( + 'Maximum number of guilds to return', + ), + with_counts: QueryBooleanType.describe('Whether to resolve live member and presence counts from the gateway'), +}); + +export type AdminUserGuildListQuery = z.infer; + +export const AdminUserDmChannelType = createNamedStringLiteralUnion( + [ + ['dm', 'DM', 'One-to-one direct message channels'], + ['group_dm', 'GROUP_DM', 'Group direct message channels'], + ], + 'Kind of direct message channel to list', +); + +export const AdminUserDmChannelListQuery = z + .object({ + type: AdminUserDmChannelType.optional() + .default('dm') + .describe('The kind of direct message channel to list. Defaults to the one-to-one direct message channels.'), + before: SnowflakeType.optional().describe('Return channels with IDs lower than this channel ID'), + after: SnowflakeType.optional().describe('Return channels with IDs higher than this channel ID'), + limit: createQueryIntegerType({defaultValue: 50, minValue: 1, maxValue: 200}).describe( + 'Maximum number of DM channels to return', + ), + }) + .refine((value) => value.before === undefined || value.after === undefined, { + message: 'before and after cannot both be provided', + }); + +export type AdminUserDmChannelListQuery = z.infer; + +export const AdminUserChangeLogQuery = z.object({ + limit: createQueryIntegerType({defaultValue: 50, minValue: 1, maxValue: 200}).describe( + 'Maximum number of entries to return', + ), + page_token: createStringType(1, 64).optional().describe('Pagination token for the next page of results'), +}); + +export type AdminUserChangeLogQuery = z.infer; + +export const AdminUserRelationshipCategoryQuery = z.object({ + category: RelationshipCategoryEnum.describe('Category of relationships the operation applies to'), +}); + +export type AdminUserRelationshipCategoryQuery = z.infer; + +export const AdminUserRelationshipParam = z.object({ + user_id: SnowflakeType.describe('The ID of the user'), + target_user_id: SnowflakeType.describe('The ID of the target user'), +}); + +export type AdminUserRelationshipParam = z.infer; + +export const AdminUserWebAuthnCredentialParam = z.object({ + user_id: SnowflakeType.describe('The ID of the user'), + credential_id: createStringType(1, 512).describe('The ID of the WebAuthn credential'), +}); + +export type AdminUserWebAuthnCredentialParam = z.infer; + +export const AdminUserClearFieldsRequest = ClearUserFieldsRequest.omit({user_id: true}); + +export type AdminUserClearFieldsRequest = z.infer; + +export const AdminUserBotStatusRequest = SetUserBotStatusRequest.omit({user_id: true}); + +export type AdminUserBotStatusRequest = z.infer; + +export const AdminUserSystemStatusRequest = SetUserSystemStatusRequest.omit({user_id: true}); + +export type AdminUserSystemStatusRequest = z.infer; + +export const AdminUserUsernameUpdateRequest = ChangeUsernameRequest.omit({user_id: true}); + +export type AdminUserUsernameUpdateRequest = z.infer; + +export const AdminUserEmailUpdateRequest = ChangeEmailRequest.omit({user_id: true}); + +export type AdminUserEmailUpdateRequest = z.infer; + +export const AdminUserBanRequest = TempBanUserRequest.omit({user_id: true}); + +export type AdminUserBanRequest = z.infer; + +export const AdminUserDeletionScheduleRequest = ScheduleAccountDeletionRequest.omit({user_id: true}); + +export type AdminUserDeletionScheduleRequest = z.infer; + +export const AdminUserAclsRequest = SetUserAclsRequest.omit({user_id: true}); + +export type AdminUserAclsRequest = z.infer; + +export const AdminUserTraitsRequest = SetUserTraitsRequest.omit({user_id: true}); + +export type AdminUserTraitsRequest = z.infer; + +export const AdminUserFlagsUpdateRequest = UpdateUserFlagsRequest.omit({user_id: true}); + +export type AdminUserFlagsUpdateRequest = z.infer; + +export const AdminUserPremiumFlagsUpdateRequest = UpdatePremiumFlagsRequest.omit({user_id: true}); + +export type AdminUserPremiumFlagsUpdateRequest = z.infer; + +export const AdminUserPhoneVerificationRequest = UpdateHasVerifiedPhoneRequest.omit({user_id: true}); + +export type AdminUserPhoneVerificationRequest = z.infer; + +export const AdminUserDobUpdateRequest = ChangeDobRequest.omit({user_id: true}); + +export type AdminUserDobUpdateRequest = z.infer; + +export const AdminUserSuspiciousActivityFlagsRequest = UpdateSuspiciousActivityFlagsRequest.omit({user_id: true}); + +export type AdminUserSuspiciousActivityFlagsRequest = z.infer; + +export const AdminUserSuspiciousDisableRequest = DisableForSuspiciousActivityRequest.omit({user_id: true}); + +export type AdminUserSuspiciousDisableRequest = z.infer; + +export const AdminUserDmChannelListResponse = z.union([ListUserDmChannelsResponse, ListUserGroupDmChannelsResponse]); + +export type AdminUserDmChannelListResponse = z.infer; diff --git a/packages/schema/src/domains/admin/AdminVoiceSchemas.ts b/packages/schema/src/domains/admin/AdminVoiceSchemas.ts index f3587a0a4..7222606af 100644 --- a/packages/schema/src/domains/admin/AdminVoiceSchemas.ts +++ b/packages/schema/src/domains/admin/AdminVoiceSchemas.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {QueryBooleanType} from '@fluxer/schema/src/primitives/QueryValidators'; import {createStringType, SnowflakeStringType, SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; import {z} from 'zod'; @@ -278,3 +279,32 @@ export const DeleteVoiceResponse = z.object({ }); export type DeleteVoiceResponse = z.infer; + +export const VoiceRegionIdParam = z.object({ + region_id: createStringType(1, 64).describe('ID of the voice region'), +}); + +export type VoiceRegionIdParam = z.infer; + +export const VoiceServerIdParam = z.object({ + region_id: createStringType(1, 64).describe('ID of the region the server belongs to'), + server_id: createStringType(1, 64).describe('ID of the voice server'), +}); + +export type VoiceServerIdParam = z.infer; + +export const ListVoiceRegionsQuery = z.object({ + include_servers: QueryBooleanType.optional() + .default(false) + .describe('Whether to include voice servers in the response'), +}); + +export type ListVoiceRegionsQuery = z.infer; + +export const GetVoiceRegionQuery = z.object({ + include_servers: QueryBooleanType.optional() + .default(true) + .describe('Whether to include voice servers in the response'), +}); + +export type GetVoiceRegionQuery = z.infer; diff --git a/packages/schema/src/domains/admin/JobsSchemas.ts b/packages/schema/src/domains/admin/JobsSchemas.ts index bf7a74fc8..2dba87552 100644 --- a/packages/schema/src/domains/admin/JobsSchemas.ts +++ b/packages/schema/src/domains/admin/JobsSchemas.ts @@ -1,9 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {SnowflakeStringType, SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; +import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; +import {createQueryIntegerType} from '@fluxer/schema/src/primitives/QueryValidators'; +import {createStringType, SnowflakeStringType, SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; import {z} from 'zod'; -const JobStatusEnum = z.enum(['queued', 'running', 'succeeded', 'failed', 'cancelled', 'deadletter']); +const JobStatusEnum = z.enum(['queued', 'running', 'succeeded', 'cancelled', 'deadletter']); export const JobLedgerEntrySchema = z.object({ job_id: SnowflakeStringType, @@ -48,27 +50,60 @@ export const ListJobsRequest = z.object({ export type ListJobsRequest = z.infer; +const CURSOR_FIELD_NAMES = ['cursor_bucket_day', 'cursor_created_at', 'cursor_job_id'] as const; + +const BUCKET_DAY_REGEX = /^\d{4}-\d{2}-\d{2}$/; +const ISO_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/; + +function isCalendarDay(value: string): boolean { + return BUCKET_DAY_REGEX.test(value) && !Number.isNaN(new Date(`${value}T00:00:00Z`).getTime()); +} + +function isIsoTimestamp(value: string): boolean { + return ISO_TIMESTAMP_REGEX.test(value) && !Number.isNaN(new Date(value).getTime()); +} + +export const ListJobsQuery = z + .object({ + limit: createQueryIntegerType({defaultValue: 50, minValue: 1, maxValue: 200}).describe( + 'Maximum number of jobs to return (1-200, default 50)', + ), + cursor_bucket_day: createStringType(10, 10) + .refine(isCalendarDay, ValidationErrorCodes.INVALID_FORMAT) + .optional() + .describe('Day bucket to resume from as a YYYY-MM-DD UTC date, taken from next_cursor.bucket_day'), + cursor_created_at: createStringType(1, 64) + .refine(isIsoTimestamp, ValidationErrorCodes.INVALID_ISO_TIMESTAMP) + .optional() + .describe('Creation time to resume before as an ISO 8601 timestamp, taken from next_cursor.created_at'), + cursor_job_id: SnowflakeStringType.optional().describe('Job to resume from, taken from next_cursor.job_id'), + max_lookback_days: createQueryIntegerType({defaultValue: 14, minValue: 1, maxValue: 60}).describe( + 'How many day buckets to scan back through (1-60, default 14)', + ), + status: JobStatusEnum.optional().describe('Filter by job status'), + task_type: createStringType(1, 128).optional().describe('Filter by task type'), + requested_by_user_id: SnowflakeType.optional().describe('Filter by admin user who scheduled the job'), + }) + .superRefine((value, ctx) => { + const supplied = CURSOR_FIELD_NAMES.filter((name) => value[name] !== undefined); + if (supplied.length === 0 || supplied.length === CURSOR_FIELD_NAMES.length) return; + for (const name of CURSOR_FIELD_NAMES) { + if (value[name] !== undefined) continue; + ctx.addIssue({code: 'custom', message: ValidationErrorCodes.INVALID_FORMAT, path: [name]}); + } + }); + +export type ListJobsQuery = z.infer; + export const ListJobsResponseSchema = z.object({ jobs: z.array(JobLedgerEntrySchema), next_cursor: ListJobsCursorSchema.nullable(), }); -export const GetJobRequest = z.object({ - job_id: SnowflakeType, -}); - -export type GetJobRequest = z.infer; - export const GetJobResponseSchema = z.object({ job: JobLedgerEntrySchema, }); -export const CancelJobRequest = z.object({ - job_id: SnowflakeType, -}); - -export type CancelJobRequest = z.infer; - export const CancelJobResponseSchema = z.object({ cancelled: z.boolean().describe('True if a cancel request was recorded; false if the job was already terminal.'), }); diff --git a/packages/schema/src/domains/common/CommonParamSchemas.ts b/packages/schema/src/domains/common/CommonParamSchemas.ts index bd99020be..ca9c2f6f6 100644 --- a/packages/schema/src/domains/common/CommonParamSchemas.ts +++ b/packages/schema/src/domains/common/CommonParamSchemas.ts @@ -191,7 +191,7 @@ export const ReportIdParam = z.object({ export type ReportIdParam = z.infer; export const KeyIdParam = z.object({ - keyId: createStringType(1, 64).describe('The ID of the key'), + key_id: SnowflakeType.describe('The ID of the key'), }); export type KeyIdParam = z.infer; @@ -219,13 +219,19 @@ const ArchiveSubjectTypeEnum = z .describe('Type of entity being archived: user for user data archives, guild for guild data archives'); export const ArchivePathParam = z.object({ - subjectType: ArchiveSubjectTypeEnum.describe('The type of subject (user or guild)'), - subjectId: SnowflakeType.describe('The ID of the subject'), - archiveId: SnowflakeType.describe('The ID of the archive'), + subject_type: ArchiveSubjectTypeEnum.describe('The type of subject (user or guild)'), + subject_id: SnowflakeType.describe('The ID of the subject'), + archive_id: SnowflakeType.describe('The ID of the archive'), }); export type ArchivePathParam = z.infer; +export const JobIdParam = z.object({ + job_id: SnowflakeType.describe('The ID of the job'), +}); + +export type JobIdParam = z.infer; + export const HarvestIdParam = z.object({ harvestId: SnowflakeType.describe('The ID of the harvest request'), }); diff --git a/packages/schema/src/domains/guild/GuildDiscoverySchemas.ts b/packages/schema/src/domains/guild/GuildDiscoverySchemas.ts index 7f3a123dc..2812ad292 100644 --- a/packages/schema/src/domains/guild/GuildDiscoverySchemas.ts +++ b/packages/schema/src/domains/guild/GuildDiscoverySchemas.ts @@ -6,14 +6,17 @@ import { DISCOVERY_MAX_TAGS, DISCOVERY_TAG_MAX_LENGTH, DISCOVERY_TAG_MIN_LENGTH, + DiscoveryApplicationStatus, + DiscoveryCategories, isValidDiscoveryLanguage, isValidDiscoveryTag, normalizeDiscoveryTag, } from '@fluxer/constants/src/DiscoveryConstants'; import {NSFWLevelSchema} from '@fluxer/schema/src/primitives/GuildValidators'; -import {SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; +import {SnowflakeStringType, SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; import {z} from 'zod'; +const DISCOVERY_CATEGORY_MAX = Math.max(...Object.values(DiscoveryCategories)); const DiscoveryTagSchema = z .string() .min(DISCOVERY_TAG_MIN_LENGTH) @@ -39,7 +42,7 @@ export const DiscoveryApplicationRequest = z.object({ .min(DISCOVERY_DESCRIPTION_MIN_LENGTH) .max(DISCOVERY_DESCRIPTION_MAX_LENGTH) .describe('Description for discovery listing'), - category_type: z.number().int().min(0).max(8).describe('Discovery category type'), + category_type: z.number().int().min(0).max(DISCOVERY_CATEGORY_MAX).describe('Discovery category type'), primary_language: DiscoveryLanguageSchema.optional(), custom_tags: DiscoveryTagsSchema.optional(), }); @@ -53,7 +56,13 @@ export const DiscoveryApplicationPatchRequest = z.object({ .max(DISCOVERY_DESCRIPTION_MAX_LENGTH) .optional() .describe('Updated description for discovery listing'), - category_type: z.number().int().min(0).max(8).optional().describe('Updated discovery category type'), + category_type: z + .number() + .int() + .min(0) + .max(DISCOVERY_CATEGORY_MAX) + .optional() + .describe('Updated discovery category type'), primary_language: DiscoveryLanguageSchema.optional(), custom_tags: DiscoveryTagsSchema.optional(), }); @@ -62,7 +71,7 @@ export type DiscoveryApplicationPatchRequest = z.infer isValidDiscoveryLanguage(value), {message: 'Unsupported language code'}) @@ -183,20 +192,59 @@ export const DiscoveryCategoryListResponse = z.array(DiscoveryCategoryResponse); export type DiscoveryCategoryListResponse = z.infer; -export const DiscoveryAdminReviewRequest = z.object({ +const DiscoveryAdminReviewRequest = z.object({ reason: z.string().max(500).optional().describe('Review reason'), }); -export type DiscoveryAdminReviewRequest = z.infer; - -export const DiscoveryAdminRejectRequest = z.object({ +const DiscoveryAdminRejectRequest = z.object({ reason: z.string().min(1).max(500).describe('Rejection reason'), }); -export type DiscoveryAdminRejectRequest = z.infer; - export const DiscoveryAdminRemoveRequest = z.object({ reason: z.string().min(1).max(500).describe('Removal reason'), }); export type DiscoveryAdminRemoveRequest = z.infer; + +export const DiscoveryAdminApplicationUpdateRequest = z.discriminatedUnion('status', [ + DiscoveryAdminReviewRequest.extend({ + status: z.literal(DiscoveryApplicationStatus.APPROVED).describe('Approve the pending application'), + }), + DiscoveryAdminRejectRequest.extend({ + status: z.literal(DiscoveryApplicationStatus.REJECTED).describe('Reject the pending application'), + }), +]); + +export type DiscoveryAdminApplicationUpdateRequest = z.infer; + +export const DiscoveryCategoryIdParam = z.object({ + category_id: z.coerce.number().int().min(0).max(DISCOVERY_CATEGORY_MAX).describe('The ID of the discovery category'), +}); + +export type DiscoveryCategoryIdParam = z.infer; + +export const DiscoveryAdminCategoryListingQuery = z.object({ + limit: z.coerce.number().int().min(1).max(100).optional().default(50).describe('Number of listings to return'), + offset: z.coerce.number().int().min(0).max(10000).optional().default(0).describe('Pagination offset'), +}); + +export type DiscoveryAdminCategoryListingQuery = z.infer; + +export const DiscoveryAdminListingBulkCategoryRequest = z.object({ + guild_ids: z.array(SnowflakeType).min(1).max(100).describe('Listed guilds to move'), + category_type: z + .number() + .int() + .min(0) + .max(DISCOVERY_CATEGORY_MAX) + .describe('Discovery category to file every named guild under'), +}); + +export type DiscoveryAdminListingBulkCategoryRequest = z.infer; + +export const DiscoveryAdminListingBulkCategoryResponse = z.object({ + updated: z.number().describe('Number of listings moved'), + failed_guild_ids: z.array(SnowflakeStringType).describe('Guilds that could not be moved'), +}); + +export type DiscoveryAdminListingBulkCategoryResponse = z.infer; diff --git a/packages/schema/src/domains/tests/AdminResponseSchemas.test.ts b/packages/schema/src/domains/tests/AdminResponseSchemas.test.ts new file mode 100644 index 000000000..7ccfe9a4f --- /dev/null +++ b/packages/schema/src/domains/tests/AdminResponseSchemas.test.ts @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {ListGuildEmojisResponse, LookupGuildResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas'; +import {ListUserRelationshipsResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas'; +import {describe, expect, it} from 'vitest'; + +function snowflakeAt(index: number): string { + return String(100000000000000000n + BigInt(index)); +} + +describe('LookupGuildResponse', () => { + const validGuild = { + id: '123456789012345678', + owner_id: '111111111111111111', + owner_username: 'owner', + owner_global_name: null, + owner_discriminator: '0001', + name: 'Test Guild', + vanity_url_code: null, + icon: null, + banner: null, + splash: null, + embed_splash: null, + features: [], + verification_level: 0, + mfa_level: 0, + nsfw_level: 0, + explicit_content_filter: 0, + default_message_notifications: 0, + afk_channel_id: null, + afk_timeout: 300, + system_channel_id: null, + system_channel_flags: 0, + rules_channel_id: null, + disabled_operations: 0, + member_count: 1, + channels: [], + roles: [], + }; + + it('accepts more roles than the old response ceiling', () => { + const result = LookupGuildResponse.safeParse({ + guild: { + ...validGuild, + roles: Array.from({length: 251}, (_, index) => ({ + id: snowflakeAt(index), + name: `role-${index}`, + color: 0, + position: index, + permissions: '0', + hoist: false, + mentionable: false, + })), + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.guild?.roles).toHaveLength(251); + } + }); + + it('accepts more channels than the old response ceiling', () => { + const result = LookupGuildResponse.safeParse({ + guild: { + ...validGuild, + channels: Array.from({length: 501}, (_, index) => ({ + id: snowflakeAt(index), + name: `channel-${index}`, + type: 0, + position: index, + parent_id: null, + nsfw: false, + url: null, + })), + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.guild?.channels).toHaveLength(501); + } + }); +}); + +describe('ListGuildEmojisResponse', () => { + it('accepts more emojis than the old response ceiling', () => { + const result = ListGuildEmojisResponse.safeParse({ + guild_id: '123456789012345678', + emojis: Array.from({length: 501}, (_, index) => ({ + id: snowflakeAt(index), + name: `emoji_${index}`, + animated: false, + creator_id: '111111111111111111', + media_url: 'https://example.com/emoji.png', + })), + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.emojis).toHaveLength(501); + } + }); +}); + +describe('ListUserRelationshipsResponse', () => { + it('accepts more friends than the old response ceiling', () => { + const result = ListUserRelationshipsResponse.safeParse({ + friends: Array.from({length: 10001}, (_, index) => ({ + target_user_id: snowflakeAt(index), + category: 'friend', + nickname: null, + since: null, + target: null, + })), + incoming_requests: [], + outgoing_requests: [], + blocked: [], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.friends).toHaveLength(10001); + } + }); +}); diff --git a/packages/schema/src/domains/tests/JobsSchemas.test.ts b/packages/schema/src/domains/tests/JobsSchemas.test.ts new file mode 100644 index 000000000..066f3bc3c --- /dev/null +++ b/packages/schema/src/domains/tests/JobsSchemas.test.ts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {describe, expect, test} from 'vitest'; +import {JobLedgerEntrySchema, ListJobsQuery, ListJobsRequest} from '../admin/JobsSchemas'; + +describe('jobs schemas', () => { + test('the job status enum rejects failed, which the ledger never writes', () => { + expect(ListJobsQuery.safeParse({status: 'failed'}).success).toBe(false); + expect(ListJobsRequest.safeParse({status: 'failed'}).success).toBe(false); + expect(JobLedgerEntrySchema.shape.status.safeParse('failed').success).toBe(false); + }); + + test('the job status enum accepts every status the ledger writes', () => { + for (const status of ['queued', 'running', 'succeeded', 'cancelled', 'deadletter']) { + expect(ListJobsQuery.safeParse({status}).success).toBe(true); + expect(JobLedgerEntrySchema.shape.status.safeParse(status).success).toBe(true); + } + }); +});