feat(admin)!: move the admin api to rest and fix its defects (#2515)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 15:36:41 +02:00
1 parent a70924d4b0
commit 7f8f09ee51
143 files changed
+15238 -12790

No files matched your search

@@ -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<AdminACL>];
export const ADMIN_ACL_COUNT = ADMIN_ACL_VALUES.length;
export const AdminAclType = z.enum(ADMIN_ACL_VALUES);
export type AdminAclType = z.infer<typeof AdminAclType>;
@@ -39,11 +39,20 @@ export const ApplicationAdminResponse = z.object({
export type ApplicationAdminResponse = z.infer<typeof ApplicationAdminResponse>;
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<typeof LookupApplicationRequest>;
export type AdminApplicationIdParam = z.infer<typeof AdminApplicationIdParam>;
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<typeof ListApplicationsQuery>;
export const LookupApplicationResponse = z.object({
application: ApplicationAdminResponse.nullable(),
@@ -51,32 +60,13 @@ export const LookupApplicationResponse = z.object({
export type LookupApplicationResponse = z.infer<typeof LookupApplicationResponse>;
export const ListUserApplicationsRequest = z.object({
user_id: SnowflakeType.describe('ID of the user whose applications to list'),
});
export type ListUserApplicationsRequest = z.infer<typeof ListUserApplicationsRequest>;
export const ListUserApplicationsResponse = z.object({
export const ListApplicationsResponse = z.object({
applications: z.array(ApplicationAdminResponse),
});
export type ListUserApplicationsResponse = z.infer<typeof ListUserApplicationsResponse>;
export const ListGuildApplicationsRequest = z.object({
guild_id: SnowflakeType.describe('ID of the guild whose installed bot applications to list'),
});
export type ListGuildApplicationsRequest = z.infer<typeof ListGuildApplicationsRequest>;
export const ListGuildApplicationsResponse = z.object({
applications: z.array(ApplicationAdminResponse),
});
export type ListGuildApplicationsResponse = z.infer<typeof ListGuildApplicationsResponse>;
export type ListApplicationsResponse = z.infer<typeof ListApplicationsResponse>;
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'),
});
@@ -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<typeof AdminBlocklistListType>;
const BlocklistScopeType = BanProfileSubstringRequest.shape.scope;
export const BlocklistTypeParam = z.object({
list_type: AdminBlocklistListType.describe('The blocklist to operate on'),
});
export type BlocklistTypeParam = z.infer<typeof BlocklistTypeParam>;
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<typeof BlocklistEntryParam>;
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<typeof AdminBlocklistScopeQuery>;
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<typeof AdminBlocklistEntryListQuery>;
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');
@@ -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<typeof AdminBulkJobCreateRequest>;
@@ -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<typeof KickGuildMemberRequest>;
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<typeof SearchGuildsRequest>;
export type GetProcessMemoryStatsQuery = z.infer<typeof GetProcessMemoryStatsQuery>;
export const ReloadGuildRequest = z.object({
guild_id: SnowflakeType,
});
export type ReloadGuildRequest = z.infer<typeof ReloadGuildRequest>;
export const ShutdownGuildRequest = z.object({
guild_id: SnowflakeType,
});
export type ShutdownGuildRequest = z.infer<typeof ShutdownGuildRequest>;
export const GetProcessMemoryStatsRequest = z.object({
limit: z.number().int().min(100).max(1000).default(100),
});
export type GetProcessMemoryStatsRequest = z.infer<typeof GetProcessMemoryStatsRequest>;
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<typeof UpdateGuildFeaturesRequest>;
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<typeof ClearGuildFieldsRequest>;
export const DeleteGuildRequest = z.object({
guild_id: SnowflakeType.describe('ID of the guild to delete'),
});
export type DeleteGuildRequest = z.infer<typeof DeleteGuildRequest>;
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<typeof ListGuildAuditLogsRequest
export const ListGuildAuditLogsResponse = GuildAuditLogListResponse;
export type ListGuildAuditLogsResponse = z.infer<typeof ListGuildAuditLogsResponse>;
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<typeof ListGuildsQuery>;
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<typeof ListGuildMembersQuery>;
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<typeof UpdateGuildRequest>;
export const BanGuildMemberBody = BanGuildMemberRequest.omit({guild_id: true, user_id: true});
export type BanGuildMemberBody = z.infer<typeof BanGuildMemberBody>;
@@ -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<typeof SearchChannelMessagesRequest>;
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<typeof SearchChannelMessagesResponse>;
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<typeof AdminChannelMessageListQuery>;
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<typeof AdminMessageSearchQuery>;
export const AdminMessageSearchResponse = z.union([SearchChannelMessagesResponse, LookupMessageResponse]);
export type AdminMessageSearchResponse = z.infer<typeof AdminMessageSearchResponse>;
@@ -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<typeof MessageShredResponse>;
export const MessageShredStatusRequest = z.object({
job_id: z.string(),
});
export type MessageShredStatusRequest = z.infer<typeof MessageShredStatusRequest>;
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<typeof BulkDeleteUserMessagesRequest>;
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<typeof AdminMessageDetailQuery>;
export const AdminUserMessageShredRequest = z.object({
entries: z.array(MessageShredEntryType).min(1).max(1000),
});
export type AdminUserMessageShredRequest = z.infer<typeof AdminUserMessageShredRequest>;
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<typeof AdminUserMessageDeleteQuery>;
export const MessageShredJobIdParam = z.object({
job_id: SnowflakeType.describe('The ID of the message shred job'),
});
export type MessageShredJobIdParam = z.infer<typeof MessageShredJobIdParam>;
+129 -87
View File
@@ -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<typeof ListAuditLogsRequest>;
export type AuditLogIdParam = z.infer<typeof AuditLogIdParam>;
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<typeof SearchAuditLogsRequest>;
export type ListAdminAuditLogsQuery = z.infer<typeof ListAdminAuditLogsQuery>;
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<typeof SearchReportsRequest>;
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<typeof ListReportsRequest>;
export type ListReportsQuery = z.infer<typeof ListReportsQuery>;
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<typeof ResolveReportRequest>;
export type UpdateReportRequest = z.infer<typeof UpdateReportRequest>;
export const SearchIndexNameParam = z.object({
index_name: SearchIndexTypeEnum.describe('The name of the search index'),
});
export type SearchIndexNameParam = z.infer<typeof SearchIndexNameParam>;
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<typeof RefreshSearchIndexRequest>;
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<typeof GetIndexRefreshStatusRequest>;
export type SearchIndexRefreshIdParam = z.infer<typeof SearchIndexRefreshIdParam>;
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<typeof PurgeGuildAssetsRequest>;
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<typeof TriggerUserArchiveRequest>;
export type AdminArchiveCreateRequest = z.infer<typeof AdminArchiveCreateRequest>;
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<typeof TriggerGuildArchiveRequest>;
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<typeof ListArchivesRequest>;
export type ListArchivesQuery = z.infer<typeof ListArchivesQuery>;
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<typeof BanUrlRequest>;
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<typeof UnbanUrlRequest>;
export const BanUrlDomainRequest = z.object({
domain: createStringType(1, 253)
.refine(
@@ -296,12 +335,6 @@ export const BanUrlDomainRequest = z.object({
export type BanUrlDomainRequest = z.infer<typeof BanUrlDomainRequest>;
export const UnbanUrlDomainRequest = z.object({
domain: createStringType(1, 253).describe('Domain to unban'),
});
export type UnbanUrlDomainRequest = z.infer<typeof UnbanUrlDomainRequest>;
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<typeof BanFileShaRequest>;
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<typeof UnbanFileShaRequest>;
export const CheckUrlBlocklistRequest = z.object({
url: createStringType(1, 2048).describe('URL to check against the blocklist'),
});
export type CheckUrlBlocklistRequest = z.infer<typeof CheckUrlBlocklistRequest>;
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<typeof CheckFileShaRequest>;
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<typeof CreateRegistrationUrlResponse>;
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<typeof RegistrationUrlActionRequest>;
export type RegistrationUrlIdParam = z.infer<typeof RegistrationUrlIdParam>;
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<typeof PendingRegistrationActionRequest>;
@@ -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<typeof CreateAdminApiKeyRequest>;
@@ -888,6 +912,23 @@ export const ListAdminApiKeyResponse = z.object({
export type ListAdminApiKeyResponse = z.infer<typeof ListAdminApiKeyResponse>;
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<typeof UpdateAdminApiKeyRequest>;
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<typeof ListGuildEmojisResponse>;
export const ListGuildStickersResponse = z.object({
guild_id: SnowflakeStringType,
stickers: z.array(GuildAssetItemSchema).max(500),
stickers: z.array(GuildAssetItemSchema),
});
export type ListGuildStickersResponse = z.infer<typeof ListGuildStickersResponse>;
@@ -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(),
@@ -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});
@@ -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<typeof LookupUserRequest>;
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<typeof SearchUsersRequest>;
export const ListUserSessionsRequest = z.object({
user_id: SnowflakeType,
});
export type ListUserSessionsRequest = z.infer<typeof ListUserSessionsRequest>;
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<typeof ListUserGroupDmChannelsRequest>;
export const ListUserGroupDmChannelsResponse = z.object({
const ListUserGroupDmChannelsResponse = z.object({
channels: z.array(AdminUserDmChannelSchema).max(500),
});
export type ListUserGroupDmChannelsResponse = z.infer<typeof ListUserGroupDmChannelsResponse>;
export type ListUserDmChannelsResponse = z.infer<typeof ListUserDmChannelsResponse>;
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<typeof UpdateUserFlagsRequest>;
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<typeof UpdatePremiumFlagsRequest>;
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<typeof TempBanUserRequest>;
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<typeof ScheduleAccountDelet
export const SetUserAclsRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to set ACLs for'),
acls: z.array(createStringType(1, 64)).max(ADMIN_ACL_COUNT).describe('List of access control permissions to assign'),
acls: z.array(AdminAclType).max(ADMIN_ACL_COUNT).describe('List of access control permissions to assign'),
});
export type SetUserAclsRequest = z.infer<typeof SetUserAclsRequest>;
@@ -438,7 +423,7 @@ export type BulkUpdateUserFlagsRequest = z.infer<typeof BulkUpdateUserFlagsReque
export const BulkScheduleUserDeletionRequest = z.object({
user_ids: z.array(SnowflakeType).max(1000).describe('List of user IDs 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()
@@ -493,10 +478,10 @@ export const AdminRelationshipEntrySchema = z.object({
export type AdminRelationshipEntry = z.infer<typeof AdminRelationshipEntrySchema>;
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<typeof ListUserRelationshipsResponse>;
@@ -523,3 +508,166 @@ export const RemoveUserRelationshipsResponse = z.object({
});
export type RemoveUserRelationshipsResponse = z.infer<typeof RemoveUserRelationshipsResponse>;
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<typeof AdminAclListResponse>;
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<typeof AdminUserListQuery>;
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<typeof AdminUserGuildListQuery>;
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<typeof AdminUserDmChannelListQuery>;
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<typeof AdminUserChangeLogQuery>;
export const AdminUserRelationshipCategoryQuery = z.object({
category: RelationshipCategoryEnum.describe('Category of relationships the operation applies to'),
});
export type AdminUserRelationshipCategoryQuery = z.infer<typeof AdminUserRelationshipCategoryQuery>;
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<typeof AdminUserRelationshipParam>;
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<typeof AdminUserWebAuthnCredentialParam>;
export const AdminUserClearFieldsRequest = ClearUserFieldsRequest.omit({user_id: true});
export type AdminUserClearFieldsRequest = z.infer<typeof AdminUserClearFieldsRequest>;
export const AdminUserBotStatusRequest = SetUserBotStatusRequest.omit({user_id: true});
export type AdminUserBotStatusRequest = z.infer<typeof AdminUserBotStatusRequest>;
export const AdminUserSystemStatusRequest = SetUserSystemStatusRequest.omit({user_id: true});
export type AdminUserSystemStatusRequest = z.infer<typeof AdminUserSystemStatusRequest>;
export const AdminUserUsernameUpdateRequest = ChangeUsernameRequest.omit({user_id: true});
export type AdminUserUsernameUpdateRequest = z.infer<typeof AdminUserUsernameUpdateRequest>;
export const AdminUserEmailUpdateRequest = ChangeEmailRequest.omit({user_id: true});
export type AdminUserEmailUpdateRequest = z.infer<typeof AdminUserEmailUpdateRequest>;
export const AdminUserBanRequest = TempBanUserRequest.omit({user_id: true});
export type AdminUserBanRequest = z.infer<typeof AdminUserBanRequest>;
export const AdminUserDeletionScheduleRequest = ScheduleAccountDeletionRequest.omit({user_id: true});
export type AdminUserDeletionScheduleRequest = z.infer<typeof AdminUserDeletionScheduleRequest>;
export const AdminUserAclsRequest = SetUserAclsRequest.omit({user_id: true});
export type AdminUserAclsRequest = z.infer<typeof AdminUserAclsRequest>;
export const AdminUserTraitsRequest = SetUserTraitsRequest.omit({user_id: true});
export type AdminUserTraitsRequest = z.infer<typeof AdminUserTraitsRequest>;
export const AdminUserFlagsUpdateRequest = UpdateUserFlagsRequest.omit({user_id: true});
export type AdminUserFlagsUpdateRequest = z.infer<typeof AdminUserFlagsUpdateRequest>;
export const AdminUserPremiumFlagsUpdateRequest = UpdatePremiumFlagsRequest.omit({user_id: true});
export type AdminUserPremiumFlagsUpdateRequest = z.infer<typeof AdminUserPremiumFlagsUpdateRequest>;
export const AdminUserPhoneVerificationRequest = UpdateHasVerifiedPhoneRequest.omit({user_id: true});
export type AdminUserPhoneVerificationRequest = z.infer<typeof AdminUserPhoneVerificationRequest>;
export const AdminUserDobUpdateRequest = ChangeDobRequest.omit({user_id: true});
export type AdminUserDobUpdateRequest = z.infer<typeof AdminUserDobUpdateRequest>;
export const AdminUserSuspiciousActivityFlagsRequest = UpdateSuspiciousActivityFlagsRequest.omit({user_id: true});
export type AdminUserSuspiciousActivityFlagsRequest = z.infer<typeof AdminUserSuspiciousActivityFlagsRequest>;
export const AdminUserSuspiciousDisableRequest = DisableForSuspiciousActivityRequest.omit({user_id: true});
export type AdminUserSuspiciousDisableRequest = z.infer<typeof AdminUserSuspiciousDisableRequest>;
export const AdminUserDmChannelListResponse = z.union([ListUserDmChannelsResponse, ListUserGroupDmChannelsResponse]);
export type AdminUserDmChannelListResponse = z.infer<typeof AdminUserDmChannelListResponse>;
@@ -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<typeof DeleteVoiceResponse>;
export const VoiceRegionIdParam = z.object({
region_id: createStringType(1, 64).describe('ID of the voice region'),
});
export type VoiceRegionIdParam = z.infer<typeof VoiceRegionIdParam>;
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<typeof VoiceServerIdParam>;
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<typeof ListVoiceRegionsQuery>;
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<typeof GetVoiceRegionQuery>;
@@ -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<typeof ListJobsRequest>;
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<typeof ListJobsQuery>;
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<typeof GetJobRequest>;
export const GetJobResponseSchema = z.object({
job: JobLedgerEntrySchema,
});
export const CancelJobRequest = z.object({
job_id: SnowflakeType,
});
export type CancelJobRequest = z.infer<typeof CancelJobRequest>;
export const CancelJobResponseSchema = z.object({
cancelled: z.boolean().describe('True if a cancel request was recorded; false if the job was already terminal.'),
});
@@ -191,7 +191,7 @@ export const ReportIdParam = z.object({
export type ReportIdParam = z.infer<typeof ReportIdParam>;
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<typeof KeyIdParam>;
@@ -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<typeof ArchivePathParam>;
export const JobIdParam = z.object({
job_id: SnowflakeType.describe('The ID of the job'),
});
export type JobIdParam = z.infer<typeof JobIdParam>;
export const HarvestIdParam = z.object({
harvestId: SnowflakeType.describe('The ID of the harvest request'),
});
@@ -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<typeof DiscoveryApplicati
export const DiscoverySearchQuery = z.object({
query: z.string().max(100).optional().describe('Search query'),
category: z.coerce.number().int().min(0).max(8).optional().describe('Filter by category'),
category: z.coerce.number().int().min(0).max(DISCOVERY_CATEGORY_MAX).optional().describe('Filter by category'),
language: z
.string()
.refine((value) => isValidDiscoveryLanguage(value), {message: 'Unsupported language code'})
@@ -183,20 +192,59 @@ export const DiscoveryCategoryListResponse = z.array(DiscoveryCategoryResponse);
export type DiscoveryCategoryListResponse = z.infer<typeof DiscoveryCategoryListResponse>;
export const DiscoveryAdminReviewRequest = z.object({
const DiscoveryAdminReviewRequest = z.object({
reason: z.string().max(500).optional().describe('Review reason'),
});
export type DiscoveryAdminReviewRequest = z.infer<typeof DiscoveryAdminReviewRequest>;
export const DiscoveryAdminRejectRequest = z.object({
const DiscoveryAdminRejectRequest = z.object({
reason: z.string().min(1).max(500).describe('Rejection reason'),
});
export type DiscoveryAdminRejectRequest = z.infer<typeof DiscoveryAdminRejectRequest>;
export const DiscoveryAdminRemoveRequest = z.object({
reason: z.string().min(1).max(500).describe('Removal reason'),
});
export type DiscoveryAdminRemoveRequest = z.infer<typeof DiscoveryAdminRemoveRequest>;
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<typeof DiscoveryAdminApplicationUpdateRequest>;
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<typeof DiscoveryCategoryIdParam>;
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<typeof DiscoveryAdminCategoryListingQuery>;
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<typeof DiscoveryAdminListingBulkCategoryRequest>;
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<typeof DiscoveryAdminListingBulkCategoryResponse>;
@@ -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);
}
});
});
@@ -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);
}
});
});