fix(api)!: correct declared bounds and hide public bot mfa (#2485)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 15:06:15 +02:00
1 parent 3be4171256
commit bec34ea147
15 files changed
+222 -41

No files matched your search

@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {MAX_APPLICATION_REDIRECT_URIS} from '@fluxer/constants/src/LimitConstants';
import {
createStringType,
Int32Type,
@@ -21,7 +22,10 @@ export const ApplicationAdminResponse = z.object({
bot_discriminator: z.string().nullable().describe('The discriminator of the bot user, if any'),
bot_is_public: z.boolean().describe('Whether the bot is publicly joinable'),
bot_require_code_grant: z.boolean().describe('Whether an OAuth2 code grant is required for this bot'),
oauth2_redirect_uris: z.array(z.string()).max(100).describe('Registered OAuth2 redirect URIs'),
oauth2_redirect_uris: z
.array(z.string())
.max(MAX_APPLICATION_REDIRECT_URIS)
.describe('Registered OAuth2 redirect URIs'),
has_client_secret: z.boolean().describe('Whether a hashed client secret is stored for this application'),
has_bot_token: z.boolean().describe('Whether a hashed bot token is stored for this application'),
bot_token_preview: z.string().nullable().describe('The preview (last few characters) of the bot token, if any'),
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {CONTENT_WARNING_TEXT_MAX_LENGTH} from '@fluxer/constants/src/GuildConstants';
import {MAX_GROUP_DM_OTHER_RECIPIENTS} from '@fluxer/constants/src/LimitConstants';
import {MAX_GROUP_DM_OTHER_RECIPIENTS, MAX_GROUP_DM_RECIPIENTS} from '@fluxer/constants/src/LimitConstants';
import {type UserPartial, UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {ChannelOverwriteTypeSchema, ChannelTypeSchema} from '@fluxer/schema/src/primitives/ChannelValidators';
import {ContentWarningLevelSchema} from '@fluxer/schema/src/primitives/GuildValidators';
@@ -71,7 +71,6 @@ export const ChannelResponse = z.object({
.describe('The ISO 8601 timestamp of when the last pinned message was pinned'),
permission_overwrites: z
.array(ChannelOverwriteResponse)
.max(500)
.optional()
.describe('The permission overwrites for this channel'),
recipients: z
@@ -125,7 +124,7 @@ export const ChannelPartialResponse = z.object({
type: ChannelTypeSchema.describe('The type of the channel'),
recipients: z
.array(ChannelPartialRecipientResponse)
.max(MAX_GROUP_DM_OTHER_RECIPIENTS)
.max(MAX_GROUP_DM_RECIPIENTS)
.optional()
.describe('The recipients of the DM channel'),
});
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {MAX_GUILD_STICKER_TAGS} from '@fluxer/constants/src/LimitConstants';
import {type UserPartial, UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
@@ -27,7 +28,7 @@ export const GuildStickerResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier for this sticker'),
name: z.string().describe('The name of the sticker'),
description: z.string().describe('The description of the sticker'),
tags: z.array(z.string()).max(100).describe('Autocomplete/suggestion tags for the sticker'),
tags: z.array(z.string()).max(MAX_GUILD_STICKER_TAGS).describe('Autocomplete/suggestion tags for the sticker'),
animated: z.boolean().describe('Whether this sticker is animated'),
nsfw: z.boolean().describe('Deprecated; always false. Retained for compatibility with older clients'),
});
@@ -38,7 +39,7 @@ export const GuildStickerWithUserResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier for this sticker'),
name: z.string().describe('The name of the sticker'),
description: z.string().describe('The description of the sticker'),
tags: z.array(z.string()).max(100).describe('Autocomplete/suggestion tags for the sticker'),
tags: z.array(z.string()).max(MAX_GUILD_STICKER_TAGS).describe('Autocomplete/suggestion tags for the sticker'),
animated: z.boolean().describe('Whether this sticker is animated'),
nsfw: z.boolean().describe('Deprecated; always false. Retained for compatibility with older clients'),
user: z.lazy(() => UserPartialResponse).describe('The user who uploaded this sticker'),
@@ -10,6 +10,8 @@ import {
import {
AVATAR_MAX_SIZE,
EMOJI_MAX_SIZE,
MAX_GUILD_ROLES,
MAX_GUILD_STICKER_TAGS,
MAX_TEMP_BAN_DURATION_SECONDS,
MIN_TEMP_BAN_DURATION_SECONDS,
STICKER_MAX_SIZE,
@@ -142,10 +144,10 @@ export const GuildMemberUpdateRequest = z.object({
.describe('The nickname to set for the member (1-32 characters)'),
roles: z
.array(SnowflakeType)
.max(100, 'Maximum 100 roles allowed')
.max(MAX_GUILD_ROLES, `Maximum ${MAX_GUILD_ROLES} roles allowed`)
.optional()
.transform((ids) => (ids ? new Set(ids) : undefined))
.describe('Array of role IDs to assign to the member (max 100)'),
.describe(`Array of role IDs to assign to the member (max ${MAX_GUILD_ROLES})`),
avatar: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
.nullish()
.describe('Base64-encoded image data for the member guild avatar'),
@@ -241,10 +243,10 @@ export const GuildStickerCreateRequest = z.object({
tags: z
.array(createStringType(1, 30))
.min(0)
.max(10)
.max(MAX_GUILD_STICKER_TAGS)
.optional()
.default([])
.describe('Array of autocomplete/suggestion tags (max 10 tags, each 1-30 characters)'),
.describe(`Array of autocomplete/suggestion tags (max ${MAX_GUILD_STICKER_TAGS} tags, each 1-30 characters)`),
image: createBase64StringType(1, Math.ceil(STICKER_MAX_SIZE * (4 / 3))).describe(
'Base64-encoded image data for the sticker',
),
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ApplicationFlags, BotFlags, BotFlagsDescriptions} from '@fluxer/constants/src/BotConstants';
import {AVATAR_MAX_SIZE} from '@fluxer/constants/src/LimitConstants';
import {AVATAR_MAX_SIZE, MAX_APPLICATION_REDIRECT_URIS} from '@fluxer/constants/src/LimitConstants';
import {
PublicUserFlags,
PublicUserFlagsDescriptions,
@@ -176,7 +176,10 @@ const ApplicationBotResponse = z
export const ApplicationResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier of the application'),
name: z.string().describe('The name of the application'),
redirect_uris: z.array(z.string()).max(20).describe('The registered redirect URIs for OAuth2'),
redirect_uris: z
.array(z.string())
.max(MAX_APPLICATION_REDIRECT_URIS)
.describe('The registered redirect URIs for OAuth2'),
bot_public: z.boolean().describe('Whether the bot can be invited by anyone'),
bot_require_code_grant: z.boolean().describe('Whether the bot requires OAuth2 code grant'),
client_secret: z.string().optional().describe('The client secret for OAuth2 authentication'),
@@ -295,10 +298,16 @@ export const ApplicationPublicResponse = z.object({
name: z.string().describe('The name of the application'),
icon: z.string().nullable().describe('The icon hash of the application'),
description: z.string().nullable().describe('The description of the application'),
redirect_uris: z.array(z.string()).max(20).describe('The registered redirect URIs for OAuth2'),
redirect_uris: z
.array(z.string())
.max(MAX_APPLICATION_REDIRECT_URIS)
.describe('The registered redirect URIs for OAuth2'),
scopes: z.array(z.string()).max(50).describe('The available OAuth2 scopes'),
bot_public: z.boolean().describe('Whether the bot can be invited by anyone'),
bot: ApplicationBotResponse.nullable().describe('The bot user associated with the application'),
bot: ApplicationBotResponse.omit({mfa_enabled: true, authenticator_types: true})
.describe('Detailed bot user metadata')
.nullable()
.describe('The bot user associated with the application'),
current_user: UserPartialResponse.nullable()
.optional()
.describe('Partial user data for the authenticated requester, when a session token is present'),
@@ -316,7 +325,11 @@ export const ApplicationsMeResponse = z.object({
verify_key: z.string().describe('Compatibility placeholder for AppInfo clients until keys are persisted'),
owner: UserPartialResponse.describe('The owner of the application'),
bot: ApplicationBotResponse.optional().describe('The bot user associated with the application'),
redirect_uris: z.array(z.string()).max(20).optional().describe('The registered redirect URIs for OAuth2'),
redirect_uris: z
.array(z.string())
.max(MAX_APPLICATION_REDIRECT_URIS)
.optional()
.describe('The registered redirect URIs for OAuth2'),
});
export type ApplicationsMeResponse = z.infer<typeof ApplicationsMeResponse>;
@@ -406,7 +419,7 @@ export const ApplicationCreateRequest = z.object({
name: createStringType(1, 100).describe('The name of the application'),
redirect_uris: z
.array(OAuth2RedirectURICreateType)
.max(10, 'Maximum of 10 redirect URIs allowed')
.max(MAX_APPLICATION_REDIRECT_URIS, `Maximum of ${MAX_APPLICATION_REDIRECT_URIS} redirect URIs allowed`)
.optional()
.nullable()
.transform((value) => value ?? [])
@@ -421,7 +434,7 @@ export const ApplicationUpdateRequest = z.object({
name: createStringType(1, 100).optional().describe('The name of the application'),
redirect_uris: z
.array(OAuth2RedirectURIUpdateType)
.max(10, 'Maximum of 10 redirect URIs allowed')
.max(MAX_APPLICATION_REDIRECT_URIS, `Maximum of ${MAX_APPLICATION_REDIRECT_URIS} redirect URIs allowed`)
.optional()
.nullable()
.transform((value) => (value === undefined ? undefined : (value ?? [])))
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {MAX_GROUP_DM_RECIPIENTS} from '@fluxer/constants/src/LimitConstants';
import {
ChannelOverwriteResponse,
ChannelPartialResponse,
@@ -105,6 +106,22 @@ describe('ChannelResponse', () => {
expect(result.data.permission_overwrites).toHaveLength(2);
}
});
it('accepts more permission_overwrites than the old response ceiling', () => {
const channel = {
...validChannel,
permission_overwrites: Array.from({length: 501}, (_, index) => ({
id: String(100000000000000000n + BigInt(index)),
type: 0,
allow: '8',
deny: '0',
})),
};
const result = ChannelResponse.safeParse(channel);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.permission_overwrites).toHaveLength(501);
}
});
it('accepts voice channel properties', () => {
const voiceChannel = {
...validChannel,
@@ -221,4 +238,23 @@ describe('ChannelPartialResponse', () => {
});
expect(result.success).toBe(true);
});
it('accepts a full group DM with every recipient listed', () => {
const result = ChannelPartialResponse.safeParse({
id: '123456789012345678',
type: 3,
recipients: Array.from({length: MAX_GROUP_DM_RECIPIENTS}, (_, index) => ({username: `user${index}`})),
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.recipients).toHaveLength(MAX_GROUP_DM_RECIPIENTS);
}
});
it('rejects more recipients than a group DM can hold', () => {
const result = ChannelPartialResponse.safeParse({
id: '123456789012345678',
type: 3,
recipients: Array.from({length: MAX_GROUP_DM_RECIPIENTS + 1}, (_, index) => ({username: `user${index}`})),
});
expect(result.success).toBe(false);
});
});
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {MAX_GUILD_STICKER_TAGS} from '@fluxer/constants/src/LimitConstants';
import {GuildStickerResponse} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import {describe, expect, it} from 'vitest';
const buildSticker = (tagCount: number) => ({
id: '1234567890123456789',
name: 'sticker',
description: 'A sticker',
tags: Array.from({length: tagCount}, (_, index) => `tag${index}`),
animated: false,
nsfw: false,
});
describe('GuildStickerResponse', () => {
it('accepts the maximum tag count', () => {
expect(GuildStickerResponse.safeParse(buildSticker(MAX_GUILD_STICKER_TAGS)).success).toBe(true);
});
it('rejects one tag beyond the maximum', () => {
expect(GuildStickerResponse.safeParse(buildSticker(MAX_GUILD_STICKER_TAGS + 1)).success).toBe(false);
});
});
@@ -1,7 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {MAX_TEMP_BAN_DURATION_SECONDS, MIN_TEMP_BAN_DURATION_SECONDS} from '@fluxer/constants/src/LimitConstants';
import {GuildBanCreateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import {
MAX_GUILD_ROLES,
MAX_GUILD_STICKER_TAGS,
MAX_TEMP_BAN_DURATION_SECONDS,
MIN_TEMP_BAN_DURATION_SECONDS,
} from '@fluxer/constants/src/LimitConstants';
import {
GuildBanCreateRequest,
GuildMemberUpdateRequest,
GuildStickerCreateRequest,
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import {describe, expect, it} from 'vitest';
describe('GuildBanCreateRequest', () => {
@@ -20,3 +29,31 @@ describe('GuildBanCreateRequest', () => {
);
});
});
const buildRoleIds = (count: number) =>
Array.from({length: count}, (_, index) => `${1234567890123456789n + BigInt(index)}`);
describe('GuildMemberUpdateRequest', () => {
it('accepts every role a member can hold', () => {
expect(GuildMemberUpdateRequest.safeParse({roles: buildRoleIds(MAX_GUILD_ROLES)}).success).toBe(true);
});
it('rejects one role beyond the guild role limit', () => {
expect(GuildMemberUpdateRequest.safeParse({roles: buildRoleIds(MAX_GUILD_ROLES + 1)}).success).toBe(false);
});
});
describe('GuildStickerCreateRequest', () => {
const buildTags = (count: number) => Array.from({length: count}, (_, index) => `tag${index}`);
const image = `data:image/png;base64,${Buffer.from('sticker').toString('base64')}`;
it('accepts the maximum tag count', () => {
expect(
GuildStickerCreateRequest.safeParse({name: 'sticker', tags: buildTags(MAX_GUILD_STICKER_TAGS), image}).success,
).toBe(true);
});
it('rejects one tag beyond the maximum', () => {
expect(
GuildStickerCreateRequest.safeParse({name: 'sticker', tags: buildTags(MAX_GUILD_STICKER_TAGS + 1), image})
.success,
).toBe(false);
});
});
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {MAX_APPLICATION_REDIRECT_URIS} from '@fluxer/constants/src/LimitConstants';
import {ApplicationCreateRequest, ApplicationResponse} from '@fluxer/schema/src/domains/oauth/OAuthSchemas';
import {describe, expect, it} from 'vitest';
const buildRedirectURIs = (count: number) =>
Array.from({length: count}, (_, index) => `https://example.com/callback/${index}`);
const buildApplicationResponse = (redirectURIs: Array<string>) => ({
id: '1234567890123456789',
name: 'Test Application',
redirect_uris: redirectURIs,
bot_public: true,
bot_require_code_grant: false,
});
describe('application redirect_uris bounds', () => {
it('accepts the maximum redirect URI count on both the request and the response', () => {
const uris = buildRedirectURIs(MAX_APPLICATION_REDIRECT_URIS);
expect(ApplicationCreateRequest.safeParse({name: 'Test Application', redirect_uris: uris}).success).toBe(true);
expect(ApplicationResponse.safeParse(buildApplicationResponse(uris)).success).toBe(true);
});
it('rejects one redirect URI beyond the maximum on both the request and the response', () => {
const uris = buildRedirectURIs(MAX_APPLICATION_REDIRECT_URIS + 1);
expect(ApplicationCreateRequest.safeParse({name: 'Test Application', redirect_uris: uris}).success).toBe(false);
expect(ApplicationResponse.safeParse(buildApplicationResponse(uris)).success).toBe(false);
});
});