diff --git a/apps/meteor/client/cachedStores/RoomsCachedStore.ts b/apps/meteor/client/cachedStores/RoomsCachedStore.ts index f02ec85cced80..848f9424c447d 100644 --- a/apps/meteor/client/cachedStores/RoomsCachedStore.ts +++ b/apps/meteor/client/cachedStores/RoomsCachedStore.ts @@ -22,7 +22,7 @@ class RoomsCachedStore extends PrivateCachedStore { cl: room.cl, topic: room.topic, announcement: room.announcement, - broadcast: room.broadcast, + broadcast: room.broadcast || undefined, archived: room.archived, avatarETag: room.avatarETag, retention: (room as IRoomWithRetentionPolicy | undefined)?.retention, diff --git a/apps/meteor/client/cachedStores/SubscriptionsCachedStore.ts b/apps/meteor/client/cachedStores/SubscriptionsCachedStore.ts index 99ff7f9c0b012..3fea1385db7c8 100644 --- a/apps/meteor/client/cachedStores/SubscriptionsCachedStore.ts +++ b/apps/meteor/client/cachedStores/SubscriptionsCachedStore.ts @@ -34,7 +34,7 @@ class SubscriptionsCachedStore extends PrivateCachedStore({ + type: 'object', + properties: { + channel: { $ref: '#/components/schemas/IRoom' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['channel', 'success'], + additionalProperties: false, +}); + +// channels.info also serves omnichannel ('l') rooms (e.g. livechat test helpers call it), which lack the +// owner `u` that IRoom requires. Accept a full IRoom or any room-shaped object so both types validate. +const channelInfoResponseSchema = ajv.compile<{ channel: IRoom }>({ + type: 'object', + properties: { + channel: { + anyOf: [{ $ref: '#/components/schemas/IRoom' }, { type: 'object', required: ['_id', 't'], additionalProperties: true }], + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['channel', 'success'], + additionalProperties: false, +}); + +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +const stringFieldResponseSchema = (field: 'description' | 'purpose' | 'topic') => + ajv.compile({ + type: 'object', + properties: { + [field]: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: [field, 'success'], + additionalProperties: false, + }); + +const descriptionResponseSchema = stringFieldResponseSchema<{ description: string }>('description'); +const purposeResponseSchema = stringFieldResponseSchema<{ purpose: string }>('purpose'); +const topicResponseSchema = stringFieldResponseSchema<{ topic: string }>('topic'); + +const roomSettingBody = (field: string, fieldSchema: Record) => + ajv.compile({ + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + [field]: fieldSchema, + }, + required: [field], + anyOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, + }); + +// Body validator for POSTs targeting a single room (roomId or roomName) with no other required field. +const roomTargetBody = () => + ajv.compile({ + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + }, + anyOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, + }); + +// Query validator for GETs targeting a single room (roomId or roomName). +const roomTargetQuery = ajvQuery.compile<{ roomId?: string; roomName?: string }>({ + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + }, + anyOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, +}); + +const channelsListResponseSchema = ajv.compile<{ channels: IRoom[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + channels: { type: 'array', items: { $ref: '#/components/schemas/IRoom' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['channels', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.post( 'channels.addAll', { authRequired: true, - validateParams: isChannelsAddAllProps, + body: isChannelsAddAllProps, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const { activeUsersOnly, ...params } = this.bodyParams; - const findResult = await findChannelByIdOrName({ params, userId: this.userId }); + async function action() { + const { activeUsersOnly, ...params } = this.bodyParams; + const findResult = await findChannelByIdOrName({ params, userId: this.userId }); - await addAllUserToRoomFn(this.userId, findResult._id, activeUsersOnly === 'true' || activeUsersOnly === 1); + await addAllUserToRoomFn(this.userId, findResult._id, activeUsersOnly === 'true' || activeUsersOnly === 1); - return API.v1.success({ - channel: await findChannelByIdOrName({ params, userId: this.userId }), - }); - }, + return API.v1.success({ + channel: await findChannelByIdOrName({ params, userId: this.userId }), + }); }, ); -API.v1.addRoute( +API.v1.post( 'channels.archive', { authRequired: true, - validateParams: isChannelsArchiveProps, + body: isChannelsArchiveProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - await executeArchiveRoom(this.userId, findResult._id); + await executeArchiveRoom(this.userId, findResult._id); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'channels.unarchive', { authRequired: true, - validateParams: isChannelsUnarchiveProps, + body: isChannelsUnarchiveProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const findResult = await findChannelByIdOrName({ - params: this.bodyParams, - checkedArchived: false, - }); + async function action() { + const findResult = await findChannelByIdOrName({ + params: this.bodyParams, + checkedArchived: false, + }); - if (!findResult.archived) { - return API.v1.failure(`The channel, ${findResult.name}, is not archived`); - } + if (!findResult.archived) { + return API.v1.failure(`The channel, ${findResult.name}, is not archived`); + } - await executeUnarchiveRoom(this.userId, findResult._id); + await executeUnarchiveRoom(this.userId, findResult._id); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +const channelsHistoryResponseSchema = ajv.compile<{ messages: IMessage[]; firstUnread?: IMessage; unreadNotLoaded?: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + firstUnread: { $ref: '#/components/schemas/IMessage' }, + unreadNotLoaded: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'channels.history', { authRequired: true, - validateParams: isChannelsHistoryProps, + query: isChannelsHistoryProps, + response: { + 200: channelsHistoryResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async get() { - const { unreads, oldest, latest, showThreadMessages, inclusive, ...params } = this.queryParams; - const findResult = await findChannelByIdOrName({ - params, - checkedArchived: false, - }); - - const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams); - - const result = await getChannelHistory({ - rid: findResult._id, - fromUserId: this.userId, - latest: latest ? new Date(latest) : new Date(), - oldest: oldest ? new Date(oldest) : undefined, - inclusive: inclusive === 'true', - offset, - count, - unreads: unreads === 'true', - showThreadMessages: showThreadMessages === 'true', - }); - - if (!result) { - return API.v1.forbidden(); - } + async function action() { + const { unreads, oldest, latest, showThreadMessages, inclusive, ...params } = this.queryParams; + const findResult = await findChannelByIdOrName({ + params, + checkedArchived: false, + }); + + const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams); + + const result = await getChannelHistory({ + rid: findResult._id, + fromUserId: this.userId, + latest: latest ? new Date(latest) : new Date(), + oldest: oldest ? new Date(oldest) : undefined, + inclusive: inclusive === 'true', + offset, + count, + unreads: unreads === 'true', + showThreadMessages: showThreadMessages === 'true', + }); + + if (!result || typeof result !== 'object' || Array.isArray(result)) { + return API.v1.forbidden(); + } + + return API.v1.success(result); + }, +); - return API.v1.success(result); +const rolesResponseSchema = ajv.compile<{ + roles: { rid: string; u: { _id: string; username: string; name?: string }; roles: string[] }[]; +}>({ + type: 'object', + properties: { + roles: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + rid: { type: 'string' }, + u: { + type: 'object', + properties: { _id: { type: 'string' }, username: { type: 'string' }, name: { type: 'string' } }, + required: ['_id', 'username'], + additionalProperties: false, + }, + roles: { type: 'array', items: { type: 'string' } }, + }, + required: ['_id', 'rid', 'u', 'roles'], + additionalProperties: false, + }, }, + success: { type: 'boolean', enum: [true] }, }, -); + required: ['roles', 'success'], + additionalProperties: false, +}); -API.v1.addRoute( +API.v1.get( 'channels.roles', { authRequired: true, - validateParams: isChannelsRolesProps, + query: isChannelsRolesProps, + response: { + 200: rolesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async get() { - const findResult = await findChannelByIdOrName({ params: this.queryParams }); + async function action() { + const findResult = await findChannelByIdOrName({ params: this.queryParams }); - const roles = await executeGetRoomRoles(findResult._id, this.user); + const roles = await executeGetRoomRoles(findResult._id, this.user); - return API.v1.success({ - roles, - }); - }, + return API.v1.success({ + roles, + }); }, ); -API.v1.addRoute( +API.v1.post( 'channels.join', { authRequired: true, - validateParams: isChannelsJoinProps, + body: isChannelsJoinProps, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const { joinCode, ...params } = this.bodyParams; - const findResult = await findChannelByIdOrName({ params }); + async function action() { + const { joinCode, ...params } = this.bodyParams; + const findResult = await findChannelByIdOrName({ params }); - await Room.join({ room: findResult, user: this.user, joinCode }); + await Room.join({ room: findResult, user: this.user, joinCode }); - return API.v1.success({ - channel: await findChannelByIdOrName({ params, userId: this.userId }), - }); - }, + return API.v1.success({ + channel: await findChannelByIdOrName({ params, userId: this.userId }), + }); }, ); -API.v1.addRoute( +API.v1.post( 'channels.kick', { authRequired: true, - validateParams: isChannelsKickProps, + body: isChannelsKickProps, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const { ...params /* userId */ } = this.bodyParams; - const findResult = await findChannelByIdOrName({ params }); + async function action() { + const { ...params /* userId */ } = this.bodyParams; + const findResult = await findChannelByIdOrName({ params }); - const user = await getUserFromParams(this.bodyParams); - if (!user?.username) { - return API.v1.failure('Invalid user'); - } + const user = await getUserFromParams(this.bodyParams); + if (!user?.username) { + return API.v1.failure('Invalid user'); + } - await removeUserFromRoomMethod(this.userId, { rid: findResult._id, username: user.username }); + await removeUserFromRoomMethod(this.userId, { rid: findResult._id, username: user.username }); - return API.v1.success({ - channel: await findChannelByIdOrName({ params, userId: this.userId }), - }); - }, + return API.v1.success({ + channel: await findChannelByIdOrName({ params, userId: this.userId }), + }); }, ); -API.v1.addRoute( +API.v1.post( 'channels.leave', { authRequired: true, - validateParams: isChannelsLeaveProps, - }, - { - async post() { - const { ...params } = this.bodyParams; - const findResult = await findChannelByIdOrName({ params }); - - const user = await Users.findOneById(this.userId); - if (!user) { - return API.v1.failure('Invalid user'); - } - await leaveRoomMethod(user, findResult._id); - - return API.v1.success({ - channel: await findChannelByIdOrName({ params, userId: this.userId }), - }); + body: isChannelsLeaveProps, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { ...params } = this.bodyParams; + const findResult = await findChannelByIdOrName({ params }); + + const user = await Users.findOneById(this.userId); + if (!user) { + return API.v1.failure('Invalid user'); + } + await leaveRoomMethod(user, findResult._id); + + return API.v1.success({ + channel: await findChannelByIdOrName({ params, userId: this.userId }), + }); + }, ); -API.v1.addRoute( +const channelsMessagesResponseSchema = ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'channels.messages', { authRequired: true, - validateParams: isChannelsMessagesProps, + query: isChannelsMessagesProps, permissionsRequired: ['view-c-room'], - }, - { - async get() { - const { roomId, mentionIds, starredIds, pinned } = this.queryParams; - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); - - const findResult = await findChannelByIdOrName({ - params: { roomId }, - checkedArchived: false, - }); - - const parseIds = (ids: string | undefined, field: string) => - typeof ids === 'string' && ids ? { [field]: { $in: ids.split(',').map((id) => id.trim()) } } : {}; - - const ourQuery = { - ...query, - rid: findResult._id, - ...parseIds(mentionIds, 'mentions._id'), - ...parseIds(starredIds, 'starred._id'), - ...(pinned?.toLowerCase() === 'true' ? { pinned: true } : {}), - _hidden: { $ne: true }, - }; - - if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { - return API.v1.forbidden(); - } - - // Special check for the permissions - if ( - (await hasPermissionAsync(this.user, 'view-joined-room')) && - !(await Subscriptions.findOneByRoomIdAndUserId(findResult._id, this.userId, { projection: { _id: 1 } })) - ) { - return API.v1.forbidden(); - } - - const { cursor, totalCount } = Messages.findPaginated(ourQuery, { - sort: sort || { ts: -1 }, - skip: offset, - limit: count, - projection: fields, - }); - - const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); - - return API.v1.success({ - messages: await normalizeMessagesForUser(messages, this.userId), - count: messages.length, - offset, - total, - }); + response: { + 200: channelsMessagesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const { roomId, mentionIds, starredIds, pinned } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + const findResult = await findChannelByIdOrName({ + params: { roomId }, + checkedArchived: false, + }); + + const parseIds = (ids: string | undefined, field: string) => + typeof ids === 'string' && ids ? { [field]: { $in: ids.split(',').map((id) => id.trim()) } } : {}; + + const ourQuery = { + ...query, + rid: findResult._id, + ...parseIds(mentionIds, 'mentions._id'), + ...parseIds(starredIds, 'starred._id'), + ...(String(pinned).toLowerCase() === 'true' ? { pinned: true } : {}), + _hidden: { $ne: true }, + }; + + if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { + return API.v1.forbidden(); + } + + // Special check for the permissions + if ( + (await hasPermissionAsync(this.user, 'view-joined-room')) && + !(await Subscriptions.findOneByRoomIdAndUserId(findResult._id, this.userId, { projection: { _id: 1 } })) + ) { + return API.v1.forbidden(); + } + + const { cursor, totalCount } = Messages.findPaginated(ourQuery, { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + messages: await normalizeMessagesForUser(messages, this.userId), + count: messages.length, + offset, + total, + }); + }, ); -API.v1.addRoute( +API.v1.post( 'channels.open', { authRequired: true, - validateParams: isChannelsOpenProps, + body: isChannelsOpenProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const { ...params } = this.bodyParams; + async function action() { + const { ...params } = this.bodyParams; - const findResult = await findChannelByIdOrName({ - params, - checkedArchived: false, - }); + const findResult = await findChannelByIdOrName({ + params, + checkedArchived: false, + }); - const sub = await Subscriptions.findOneByRoomIdAndUserId(findResult._id, this.userId); + const sub = await Subscriptions.findOneByRoomIdAndUserId(findResult._id, this.userId); - if (!sub) { - return API.v1.failure(`The user/callee is not in the channel "${findResult.name}".`); - } + if (!sub) { + return API.v1.failure(`The user/callee is not in the channel "${findResult.name}".`); + } - if (sub.open) { - return API.v1.failure(`The channel, ${findResult.name}, is already open to the sender`); - } + if (sub.open) { + return API.v1.failure(`The channel, ${findResult.name}, is already open to the sender`); + } - await openRoom(this.userId, findResult._id); + await openRoom(this.userId, findResult._id); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'channels.setReadOnly', { authRequired: true, - validateParams: isChannelsSetReadOnlyProps, + body: isChannelsSetReadOnlyProps, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - if (findResult.ro === this.bodyParams.readOnly) { - return API.v1.failure('The channel read only setting is the same as what it would be changed to.'); - } + if (findResult.ro === this.bodyParams.readOnly) { + return API.v1.failure('The channel read only setting is the same as what it would be changed to.'); + } - await saveRoomSettings(this.userId, findResult._id, 'readOnly', this.bodyParams.readOnly); + await saveRoomSettings(this.userId, findResult._id, 'readOnly', this.bodyParams.readOnly); - return API.v1.success({ - channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), - }); - }, + return API.v1.success({ + channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), + }); }, ); -API.v1.addRoute( +const announcementResponseSchema = ajv.compile<{ announcement?: string }>({ + type: 'object', + properties: { + announcement: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.post( 'channels.setAnnouncement', { authRequired: true, - validateParams: isChannelsSetAnnouncementProps, + body: isChannelsSetAnnouncementProps, + response: { + 200: announcementResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const { announcement, ...params } = this.bodyParams; + async function action() { + const { announcement, ...params } = this.bodyParams; - const findResult = await findChannelByIdOrName({ params }); + const findResult = await findChannelByIdOrName({ params }); - await saveRoomSettings(this.userId, findResult._id, 'roomAnnouncement', announcement); + await saveRoomSettings(this.userId, findResult._id, 'roomAnnouncement', announcement); - return API.v1.success({ - announcement: this.bodyParams.announcement, - }); - }, + return API.v1.success({ + announcement: this.bodyParams.announcement, + }); }, ); -API.v1.addRoute( +const channelsMentionsResponseSchema = ajv.compile<{ mentions: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + mentions: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['mentions', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'channels.getAllUserMentionsByChannel', { authRequired: true, - validateParams: isChannelsGetAllUserMentionsByChannelProps, + query: isChannelsGetAllUserMentionsByChannelProps, + response: { + 200: channelsMentionsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async get() { - const { roomId } = this.queryParams; - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort } = await this.parseJsonQuery(); - - const mentions = await getUserMentionsByChannel(this.userId, roomId, { - sort: sort || { ts: 1 }, - skip: offset, - limit: count, - }); - - const allMentions = await getUserMentionsByChannel(this.userId, roomId, {}); + async function action() { + const { roomId } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const mentions = await getUserMentionsByChannel(this.userId, roomId, { + sort: sort || { ts: 1 }, + skip: offset, + limit: count, + }); + + const allMentions = await getUserMentionsByChannel(this.userId, roomId, {}); + + return API.v1.success({ + mentions, + count: mentions.length, + offset, + total: allMentions.length, + }); + }, +); - return API.v1.success({ - mentions, - count: mentions.length, - offset, - total: allMentions.length, - }); +const moderatorsResponseSchema = ajv.compile<{ moderators: { _id: string; username?: string; name?: string }[] }>({ + type: 'object', + properties: { + moderators: { + type: 'array', + items: { + type: 'object', + properties: { _id: { type: 'string' }, username: { type: 'string' }, name: { type: 'string' } }, + required: ['_id'], + additionalProperties: false, + }, }, + success: { type: 'boolean', enum: [true] }, }, -); + required: ['moderators', 'success'], + additionalProperties: false, +}); -API.v1.addRoute( +API.v1.get( 'channels.moderators', { authRequired: true, - validateParams: isChannelsModeratorsProps, + query: isChannelsModeratorsProps, + response: { + 200: moderatorsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async get() { - const { ...params } = this.queryParams; + async function action() { + const { ...params } = this.queryParams; - const findResult = await findChannelByIdOrName({ params }); + const findResult = await findChannelByIdOrName({ params }); - if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { - return API.v1.forbidden(); - } + if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { + return API.v1.forbidden(); + } - const moderators = await Subscriptions.findByRoomIdAndRoles(findResult._id, ['moderator'], { - projection: { u: 1, _id: 0 }, - }) - .map((sub) => sub.u) - .toArray(); + const moderators = await Subscriptions.findByRoomIdAndRoles(findResult._id, ['moderator'], { + projection: { u: 1, _id: 0 }, + }) + .map((sub) => sub.u) + .toArray(); - return API.v1.success({ - moderators, - }); - }, + return API.v1.success({ + moderators, + }); }, ); -API.v1.addRoute( +API.v1.post( 'channels.delete', { authRequired: true, - validateParams: isChannelsDeleteProps, + body: isChannelsDeleteProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const room = await findChannelByIdOrName({ - params: this.bodyParams, - checkedArchived: false, - }); + async function action() { + const room = await findChannelByIdOrName({ + params: this.bodyParams, + checkedArchived: false, + }); - await eraseRoom(room._id, this.user); + await eraseRoom(room._id, this.user); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +const channelsConvertToTeamResponseSchema = ajv.compile<{ team: ITeam }>({ + type: 'object', + properties: { + team: { $ref: '#/components/schemas/ITeam' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['team', 'success'], + additionalProperties: false, +}); + +API.v1.post( 'channels.convertToTeam', { authRequired: true, - validateParams: isChannelsConvertToTeamProps, + body: isChannelsConvertToTeamProps, permissionsRequired: ['create-team'], + response: { + 200: channelsConvertToTeamResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { channelId, channelName } = this.bodyParams; + async function action() { + const { channelId, channelName } = this.bodyParams; - if (!channelId && !channelName) { - return API.v1.failure('The parameter "channelId" or "channelName" is required'); - } + if (!channelId && !channelName) { + return API.v1.failure('The parameter "channelId" or "channelName" is required'); + } - const room = await findChannelByIdOrName({ - params: channelId !== undefined ? { roomId: channelId } : { roomName: channelName }, - userId: this.userId, - }); + const room = await findChannelByIdOrName({ + params: channelId !== undefined ? { roomId: channelId } : { roomName: channelName }, + userId: this.userId, + }); - if (!room) { - return API.v1.failure('Channel not found'); - } + if (!room) { + return API.v1.failure('Channel not found'); + } - if (!(await hasAllPermissionAsync(this.user, ['create-team', 'edit-room'], room._id))) { - return API.v1.forbidden(); - } + if (!(await hasAllPermissionAsync(this.user, ['create-team', 'edit-room'], room._id))) { + return API.v1.forbidden(); + } - const subscriptions = await Subscriptions.findByRoomId(room._id, { - projection: { 'u._id': 1 }, - }); + const subscriptions = await Subscriptions.findByRoomId(room._id, { + projection: { 'u._id': 1 }, + }); - const members = (await subscriptions.toArray()).map((s: ISubscription) => s.u?._id); + const members = (await subscriptions.toArray()).map((s: ISubscription) => s.u?._id); - const teamData = { - team: { - name: room.name ?? '', - type: room.t === 'c' ? 0 : 1, - }, - members, - room: { - name: room.name, - id: room._id, - }, - }; + const teamData = { + team: { + name: room.name ?? '', + type: room.t === 'c' ? 0 : 1, + }, + members, + room: { + name: room.name, + id: room._id, + }, + }; - const team = await Team.create(this.userId, teamData); + const team = await Team.create(this.userId, teamData); - return API.v1.success({ team }); - }, + return API.v1.success({ team }); }, ); -API.v1.addRoute( +API.v1.post( 'channels.addModerator', - { authRequired: true }, { - async post() { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + authRequired: true, + body: isChannelsModeratorsProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - const user = await getUserFromParams(this.bodyParams); + const user = await getUserFromParams(this.bodyParams); - await addRoomModerator(this.userId, findResult._id, user._id); + await addRoomModerator(this.userId, findResult._id, user._id); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'channels.addOwner', - { authRequired: true }, { - async post() { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + authRequired: true, + body: isChannelsModeratorsProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - const user = await getUserFromParams(this.bodyParams); + const user = await getUserFromParams(this.bodyParams); - await addRoomOwner(this.userId, findResult._id, user._id); + await addRoomOwner(this.userId, findResult._id, user._id); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'channels.close', - { authRequired: true }, { - async post() { - const findResult = await findChannelByIdOrName({ - params: this.bodyParams, - checkedArchived: false, - }); + authRequired: true, + body: roomTargetBody<{ roomId?: string; roomName?: string }>(), + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ + params: this.bodyParams, + checkedArchived: false, + }); - const sub = await Subscriptions.findOneByRoomIdAndUserId(findResult._id, this.userId); + const sub = await Subscriptions.findOneByRoomIdAndUserId(findResult._id, this.userId); - if (!sub) { - return API.v1.failure(`The user/callee is not in the channel "${findResult.name}.`); - } + if (!sub) { + return API.v1.failure(`The user/callee is not in the channel "${findResult.name}.`); + } - if (!sub.open) { - return API.v1.failure(`The channel, ${findResult.name}, is already closed to the sender`); - } + if (!sub.open) { + return API.v1.failure(`The channel, ${findResult.name}, is already closed to the sender`); + } - await hideRoomMethod(this.userId, findResult._id); + await hideRoomMethod(this.userId, findResult._id); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +const countersResponseSchema = ajv.compile<{ + joined: boolean; + members: number | null; + unreads: number | null; + unreadsFrom: Date | null; + msgs: number | null; + latest: Date | null; + userMentions: number | null; +}>({ + type: 'object', + properties: { + joined: { type: 'boolean' }, + members: { type: ['number', 'null'] }, + unreads: { type: ['number', 'null'] }, + unreadsFrom: { type: ['string', 'null'] }, + msgs: { type: ['number', 'null'] }, + latest: { type: ['string', 'null'] }, + userMentions: { type: ['number', 'null'] }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['joined', 'members', 'unreads', 'unreadsFrom', 'msgs', 'latest', 'userMentions', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'channels.counters', - { authRequired: true }, { - async get() { - const access = await hasPermissionAsync(this.user, 'view-room-administration'); - const { userId } = this.queryParams; - let user = this.userId; - let unreads = null; - let userMentions = null; - let unreadsFrom = null; - let joined = false; - let msgs = null; - let latest = null; - let members = null; - - if (userId) { - if (!access) { - return API.v1.forbidden(); - } - user = userId; - } - const room = await findChannelByIdOrName({ - params: this.queryParams, - }); - const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user); - const lm = room.lm ? room.lm : room._updatedAt; - - if (subscription?.open) { - unreads = await Messages.countVisibleByRoomIdBetweenTimestampsInclusive(subscription.rid, subscription.ls ?? subscription.ts, lm); - unreadsFrom = subscription.ls || subscription.ts; - userMentions = subscription.userMentions; - joined = true; - } - - if (access || joined) { - msgs = room.msgs; - latest = lm; - members = await Users.countActiveUsersInNonDMRoom(room._id); - } - - return API.v1.success({ - joined, - members, - unreads, - unreadsFrom, - msgs, - latest, - userMentions, - }); + authRequired: true, + query: ajvQuery.compile<{ roomId?: string; roomName?: string; userId?: string }>({ + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + userId: { type: 'string' }, + }, + anyOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, + }), + response: { + 200: countersResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const access = await hasPermissionAsync(this.user, 'view-room-administration'); + const { userId } = this.queryParams; + let user = this.userId; + let unreads = null; + let userMentions = null; + let unreadsFrom = null; + let joined = false; + let msgs = null; + let latest = null; + let members = null; + + if (userId) { + if (!access) { + return API.v1.forbidden(); + } + user = userId; + } + const room = await findChannelByIdOrName({ + params: this.queryParams, + }); + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user); + const lm = room.lm ? room.lm : room._updatedAt; + + if (subscription?.open) { + unreads = await Messages.countVisibleByRoomIdBetweenTimestampsInclusive(subscription.rid, subscription.ls ?? subscription.ts, lm); + unreadsFrom = subscription.ls || subscription.ts; + userMentions = subscription.userMentions; + joined = true; + } + + if (access || joined) { + msgs = room.msgs; + latest = lm; + members = await Users.countActiveUsersInNonDMRoom(room._id); + } + + return API.v1.success({ + joined, + members, + unreads, + unreadsFrom, + msgs, + latest, + userMentions, + }); + }, ); async function createChannelValidator(params: { @@ -743,125 +1065,213 @@ API.channels = { }, }; -API.v1.addRoute( +API.v1.post( 'channels.create', - { authRequired: true }, { - async post() { - const { userId, bodyParams } = this; - - let error; - - try { - await API.channels?.create.validate({ - user: { - value: userId, - }, - name: { - value: bodyParams.name, - key: 'name', - }, - members: { - value: bodyParams.members, - key: 'members', - }, - teams: { - value: bodyParams.teams, - key: 'teams', - }, - teamId: { - value: bodyParams.extraData?.teamId, - key: 'teamId', - }, + authRequired: true, + body: isChannelsCreateProps, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { userId, bodyParams } = this; + + try { + // create.validate throws a plain Error('unauthorized') on permission failure; the global + // wrapper would surface its message verbatim, so map it to forbidden() here to keep the + // stable `error: 'unauthorized'` (403) contract the clients/tests rely on. + await API.channels?.create.validate({ + user: { + value: userId, + }, + name: { + value: bodyParams.name, + key: 'name', + }, + members: { + value: bodyParams.members, + key: 'members', + }, + teams: { + value: bodyParams.teams, + key: 'teams', + }, + teamId: { + value: bodyParams.extraData?.teamId, + key: 'teamId', + }, + }); + } catch (e: any) { + if (e.message === 'unauthorized') { + return API.v1.forbidden(); + } + return API.v1.failure(e.message); + } + + if (bodyParams.teams) { + const canSeeAllTeams = await hasPermissionAsync(this.user, 'view-all-teams'); + const teams = await Team.listByNames(bodyParams.teams, { projection: { _id: 1 } }); + const teamMembers = []; + + for (const team of teams) { + const { records: members } = await Team.members(this.userId, team._id, canSeeAllTeams, { + offset: 0, + count: Number.MAX_SAFE_INTEGER, }); - } catch (e: any) { - if (e.message === 'unauthorized') { - error = API.v1.forbidden(); - } else { - error = API.v1.failure(e.message); - } + const uids = members.map((member) => member.user.username); + teamMembers.push(...uids); } - if (error) { - return error; - } + const membersToAdd = new Set([...teamMembers, ...(bodyParams.members || [])]); + bodyParams.members = [...membersToAdd].filter(Boolean) as string[]; + } - if (bodyParams.teams) { - const canSeeAllTeams = await hasPermissionAsync(this.user, 'view-all-teams'); - const teams = await Team.listByNames(bodyParams.teams, { projection: { _id: 1 } }); - const teamMembers = []; - - for (const team of teams) { - const { records: members } = await Team.members(this.userId, team._id, canSeeAllTeams, { - offset: 0, - count: Number.MAX_SAFE_INTEGER, - }); - const uids = members.map((member) => member.user.username); - teamMembers.push(...uids); - } + const result = await API.channels?.create.execute(userId, bodyParams); + if (!result) { + return API.v1.failure('Failed to create channel'); + } - const membersToAdd = new Set([...teamMembers, ...(bodyParams.members || [])]); - bodyParams.members = [...membersToAdd].filter(Boolean) as string[]; - } + return API.v1.success(result); + }, +); - return API.v1.success(await API.channels?.create.execute(userId, bodyParams)); +// Uploads accept a client-supplied `fields` projection and store `content: null` for non-e2ee files, +// so items are partial and cannot be validated against $ref IUploadWithUser (required, non-null shape). +// Validate the array container strictly; leave item props open (only _id is guaranteed). +const channelsFilesResponseSchema = ajv.compile<{ files: IUploadWithUser[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + files: { + type: 'array', + items: { + type: 'object', + properties: { _id: { type: 'string' } }, + required: ['_id'], + additionalProperties: true, + }, }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, }, -); + required: ['files', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); -API.v1.addRoute( +API.v1.get( 'channels.files', - { authRequired: true, validateParams: isChannelsFilesListProps }, { - async get() { - const { typeGroup, name, roomId, roomName, onlyConfirmed } = this.queryParams; - - const findResult = await findChannelByIdOrName({ - params: { - ...(roomId ? { roomId } : {}), - ...(roomName ? { roomName } : {}), - }, - checkedArchived: false, - }); - - if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { - return API.v1.forbidden(); - } - - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); - - const filter = { - rid: findResult._id, - ...query, - ...(name ? { name: { $regex: name || '', $options: 'i' } } : {}), - ...(typeGroup ? { typeGroup } : {}), - ...(onlyConfirmed && { expiresAt: { $exists: false } }), - }; - - const { cursor, totalCount } = await Uploads.findPaginatedWithoutThumbs(filter, { - sort: sort || { name: 1 }, - skip: offset, - limit: count, - projection: fields, - }); + authRequired: true, + query: isChannelsFilesListProps, + response: { + 200: channelsFilesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { typeGroup, name, roomId, roomName, onlyConfirmed } = this.queryParams; - const [files, total] = await Promise.all([cursor.toArray(), totalCount]); + const findResult = await findChannelByIdOrName({ + params: { + ...(roomId ? { roomId } : {}), + ...(roomName ? { roomName } : {}), + }, + checkedArchived: false, + }); + + if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { + return API.v1.forbidden(); + } + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + const filter = { + ...query, + rid: findResult._id, + ...(name ? { name: { $regex: name || '', $options: 'i' } } : {}), + ...(typeGroup ? { typeGroup } : {}), + ...(onlyConfirmed && { expiresAt: { $exists: false } }), + }; + + const { cursor, totalCount } = await Uploads.findPaginatedWithoutThumbs(filter, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [files, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + files: await addUserToFileObj(files), + count: files.length, + offset, + total, + }); + }, +); - return API.v1.success({ - files: await addUserToFileObj(files), - count: files.length, - offset, - total, - }); +const channelsGetIntegrationsQuery = ajvQuery.compile<{ + roomId?: string; + roomName?: string; + includeAllPublicChannels?: string; + offset?: number; + count?: number; + sort?: string; + query?: string; + fields?: string; +}>({ + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + includeAllPublicChannels: { type: 'string' }, + offset: { type: 'number' }, + count: { type: 'number' }, + sort: { type: 'string' }, + query: { type: 'string' }, + fields: { type: 'string' }, + }, + anyOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, +}); + +const channelsGetIntegrationsResponseSchema = ajv.compile<{ + integrations: IIntegration[]; + count: number; + offset: number; + total: number; +}>({ + type: 'object', + properties: { + integrations: { + type: 'array', + items: { + oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }], + }, }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, }, -); + required: ['integrations', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); -API.v1.addRoute( +API.v1.get( 'channels.getIntegrations', { authRequired: true, + query: channelsGetIntegrationsQuery, permissionsRequired: { GET: { permissions: [ @@ -873,638 +1283,878 @@ API.v1.addRoute( operation: 'hasAny', }, }, + response: { + 200: channelsGetIntegrationsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async get() { - const findResult = await findChannelByIdOrName({ - params: this.queryParams, - checkedArchived: false, - }); - - if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { - return API.v1.forbidden(); - } - - let includeAllPublicChannels = true; - if (typeof this.queryParams.includeAllPublicChannels !== 'undefined') { - includeAllPublicChannels = this.queryParams.includeAllPublicChannels === 'true'; - } - - let ourQuery: { channel: string | { $in: string[] } } = { - channel: `#${findResult.name}`, + async function action() { + const findResult = await findChannelByIdOrName({ + params: this.queryParams, + checkedArchived: false, + }); + + if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { + return API.v1.forbidden(); + } + + let includeAllPublicChannels = true; + if (typeof this.queryParams.includeAllPublicChannels !== 'undefined') { + includeAllPublicChannels = this.queryParams.includeAllPublicChannels === 'true'; + } + + let ourQuery: { channel: string | { $in: string[] } } = { + channel: `#${findResult.name}`, + }; + + if (includeAllPublicChannels) { + ourQuery.channel = { + $in: [ourQuery.channel as string, 'all_public_channels'], }; - - if (includeAllPublicChannels) { - ourQuery.channel = { - $in: [ourQuery.channel as string, 'all_public_channels'], - }; - } - - const params = this.queryParams; - const { offset, count } = await getPaginationItems(params); - const { sort, fields: projection, query } = await this.parseJsonQuery(); - - ourQuery = Object.assign(await mountIntegrationQueryBasedOnPermissions(this.userId), query, ourQuery); - - const { cursor, totalCount } = await Integrations.findPaginated(ourQuery, { - sort: sort || { _createdAt: 1 }, - skip: offset, - limit: count, - projection, - }); - - const [integrations, total] = await Promise.all([cursor.toArray(), totalCount]); - - return API.v1.success({ - integrations, - count: integrations.length, - offset, - total, - }); - }, + } + + const params = this.queryParams; + const { offset, count } = await getPaginationItems(params); + const { sort, fields: projection, query } = await this.parseJsonQuery(); + + // Apply the user-supplied query first, then overlay the trusted filters so a crafted `query` + // cannot override the permission scope (mountIntegrationQueryBasedOnPermissions) or the channel filter. + ourQuery = Object.assign({}, query, ourQuery, await mountIntegrationQueryBasedOnPermissions(this.userId)); + + const { cursor, totalCount } = await Integrations.findPaginated(ourQuery, { + sort: sort || { _createdAt: 1 }, + skip: offset, + limit: count, + projection, + }); + + const [integrations, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + integrations, + count: integrations.length, + offset, + total, + }); }, ); -API.v1.addRoute( +API.v1.get( 'channels.info', - { authRequired: true }, { - async get() { - const findResult = await findChannelByIdOrName({ - params: this.queryParams, - checkedArchived: false, - userId: this.userId, - }); - - if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { - return API.v1.forbidden(); - } + authRequired: true, + query: roomTargetQuery, + response: { + 200: channelInfoResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ + params: this.queryParams, + checkedArchived: false, + userId: this.userId, + }); + + if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { + return API.v1.forbidden(); + } + + return API.v1.success({ + channel: findResult, + }); + }, +); - return API.v1.success({ - channel: findResult, - }); +const channelsInviteBody = ajv.compile<({ roomId: string } | { roomName: string }) & { userId?: string; username?: string; user?: string }>( + { + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, }, + anyOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, }, ); -API.v1.addRoute( +API.v1.post( 'channels.invite', - { authRequired: true }, { - async post() { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + authRequired: true, + body: channelsInviteBody, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - // Federated rooms invite by raw username: the federated user record is created - // lazily inside addUsersToRoomMethod, so we must not require it to exist locally yet. - if (isRoomNativeFederated(findResult)) { - const users = await getUsernameListFromParams(this.bodyParams); + // Federated rooms invite by raw username: the federated user record is created + // lazily inside addUsersToRoomMethod, so we must not require it to exist locally yet. + if (isRoomNativeFederated(findResult)) { + const users = await getUsernameListFromParams(this.bodyParams); - await addUsersToRoomMethod(this.userId, { rid: findResult._id, users }, this.user); + await addUsersToRoomMethod(this.userId, { rid: findResult._id, users }, this.user); - return API.v1.success({ - channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), - }); - } + return API.v1.success({ + channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), + }); + } - const users = await getUserListFromParams(this.bodyParams); + const users = await getUserListFromParams(this.bodyParams); - if (!users.length) { - return API.v1.failure('invalid-user-invite-list', 'Cannot invite if no users are provided'); - } + if (!users.length) { + return API.v1.failure('invalid-user-invite-list', 'Cannot invite if no users are provided'); + } - await addUsersToRoomMethod(this.userId, { rid: findResult._id, users: users.map((u) => u.username).filter(isTruthy) }, this.user); + await addUsersToRoomMethod(this.userId, { rid: findResult._id, users: users.map((u) => u.username).filter(isTruthy) }, this.user); - return API.v1.success({ - channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), - }); - }, + return API.v1.success({ + channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), + }); }, ); -API.v1.addRoute( +API.v1.get( 'channels.list', { authRequired: true, permissionsRequired: { GET: { permissions: ['view-c-room', 'view-joined-room'], operation: 'hasAny' }, }, - validateParams: isChannelsListProps, + query: isChannelsListProps, + response: { + 200: channelsListResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); - const hasPermissionToSeeAllPublicChannels = await hasPermissionAsync(this.user, 'view-c-room'); - - const { _id } = this.queryParams; - - const ourQuery = { - ...query, - ...(_id ? { _id } : {}), - t: 'c', - }; - - if (!hasPermissionToSeeAllPublicChannels) { - const roomIds = ( - await Subscriptions.findByUserIdAndType(this.userId, 'c', { - projection: { rid: 1 }, - }).toArray() - ).map((s) => s.rid); - ourQuery._id = { $in: roomIds }; - } - - // teams filter - I would love to have a way to apply this filter @ db level :( - const ids = (await Subscriptions.findByUserId(this.userId, { projection: { rid: 1 } }).toArray()).map( - (item: Record) => item.rid, - ); - - ourQuery.$or = [ - { - teamId: { - $exists: false, - }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + const hasPermissionToSeeAllPublicChannels = await hasPermissionAsync(this.user, 'view-c-room'); + + const { _id } = this.queryParams; + + const ourQuery: Filter = { + ...query, + ...(_id ? { _id } : {}), + t: 'c', + }; + + if (!hasPermissionToSeeAllPublicChannels) { + const roomIds = ( + await Subscriptions.findByUserIdAndType(this.userId, 'c', { + projection: { rid: 1 }, + }).toArray() + ).map((s) => s.rid); + ourQuery._id = { $in: roomIds }; + } + + // teams filter - I would love to have a way to apply this filter @ db level :( + const ids = (await Subscriptions.findByUserId(this.userId, { projection: { rid: 1 } }).toArray()).map( + (item: Record) => item.rid, + ); + + ourQuery.$or = [ + { + teamId: { + $exists: false, }, - { - teamId: { - $exists: true, - }, - _id: { - $in: ids, - }, + }, + { + teamId: { + $exists: true, }, - ]; - - const { cursor, totalCount } = Rooms.findPaginated(ourQuery, { - sort: sort || { name: 1 }, - skip: offset, - limit: count, - projection: fields, - }); - - const [channels, total] = await Promise.all([cursor.toArray(), totalCount]); - - return API.v1.success({ - channels: await Promise.all(channels.map((room) => composeRoomWithLastMessage(room, this.userId))), - count: channels.length, - offset, - total, - }); - }, + _id: { + $in: ids, + }, + }, + ]; + + const { cursor, totalCount } = Rooms.findPaginated(ourQuery, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [channels, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + channels: await Promise.all(channels.map((room) => composeRoomWithLastMessage(room, this.userId))), + count: channels.length, + offset, + total, + }); }, ); -API.v1.addRoute( +// list.joined lists the caller's joined channels and ignores any room target, but existing clients +// still pass roomId/roomName, so accept (and ignore) them alongside the standard pagination params. +const channelsListJoinedQuery = ajvQuery.compile<{ + _id?: string; + roomId?: string; + roomName?: string; + query?: string; + count?: number; + offset?: number; + sort?: string; +}>({ + type: 'object', + properties: { + _id: { type: 'string' }, + roomId: { type: 'string' }, + roomName: { type: 'string' }, + query: { type: 'string' }, + count: { type: 'number' }, + offset: { type: 'number' }, + sort: { type: 'string' }, + }, + required: [], + additionalProperties: false, +}); + +API.v1.get( 'channels.list.joined', - { authRequired: true }, { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields } = await this.parseJsonQuery(); - - const subs = await Subscriptions.findByUserIdAndTypes(this.userId, ['c'], { projection: { rid: 1 } }).toArray(); - const rids = subs.map(({ rid }) => rid).filter(Boolean); - - if (rids.length === 0) { - return API.v1.success({ - channels: [], - offset, - count: 0, - total: 0, - }); - } - - const { cursor, totalCount } = Rooms.findPaginatedByTypeAndIds('c', rids, { - sort: sort || { name: 1 }, - skip: offset, - limit: count, - projection: fields, - }); + authRequired: true, + query: channelsListJoinedQuery, + response: { + 200: channelsListResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields } = await this.parseJsonQuery(); - const [channels, total] = await Promise.all([cursor.toArray(), totalCount]); + const subs = await Subscriptions.findByUserIdAndTypes(this.userId, ['c'], { projection: { rid: 1 } }).toArray(); + const rids = subs.map(({ rid }) => rid).filter(Boolean); + if (rids.length === 0) { return API.v1.success({ - channels: await Promise.all(channels.map((room) => composeRoomWithLastMessage(room, this.userId))), + channels: [], offset, - count: channels.length, - total, + count: 0, + total: 0, }); - }, + } + + const { cursor, totalCount } = Rooms.findPaginatedByTypeAndIds('c', rids, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [channels, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + channels: await Promise.all(channels.map((room) => composeRoomWithLastMessage(room, this.userId))), + offset, + count: channels.length, + total, + }); }, ); -API.v1.addRoute( +const channelsMembersQuery = ajvQuery.compile<{ + roomId?: string; + roomName?: string; + filter?: string; + status?: string[]; + offset?: number; + count?: number; + sort?: string; +}>({ + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + filter: { type: 'string' }, + status: { type: 'array', items: { type: 'string' } }, + offset: { type: 'number' }, + count: { type: 'number' }, + sort: { type: 'string' }, + }, + anyOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, +}); + +// findUsersOfRoom returns a fixed projection (not a full IUser), so validate against the projected +// shape rather than $ref IUser (which would require createdAt/roles/type/active and reject real data). +const channelsMembersResponseSchema = ajv.compile<{ members: IUser[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + members: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + name: { type: 'string' }, + username: { type: 'string' }, + nickname: { type: 'string' }, + status: { type: 'string' }, + avatarETag: { type: 'string' }, + federated: { type: 'boolean' }, + _updatedAt: { type: 'string' }, + }, + required: ['_id'], + additionalProperties: true, + }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['members', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'channels.members', - { authRequired: true }, { - async get() { - const findResult = await findChannelByIdOrName({ - params: this.queryParams, - checkedArchived: false, - }); - - if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { - return API.v1.forbidden(); - } - - if (findResult.broadcast && !(await hasPermissionAsync(this.user, 'view-broadcast-member-list', findResult._id))) { - return API.v1.forbidden(); - } - - const { offset: skip, count: limit } = await getPaginationItems(this.queryParams); - const { sort = {} } = await this.parseJsonQuery(); - - check( - this.queryParams, - Match.ObjectIncluding({ - status: Match.Maybe([String]), - filter: Match.Maybe(String), - }), - ); - const { status, filter } = this.queryParams; - - const { cursor, totalCount } = await findUsersOfRoom({ - rid: findResult._id, - ...(status && { status: { $in: status as UserStatus[] } }), - skip, - limit, - filter, - ...(sort?.username && { sort: { username: sort.username } }), - }); - - const [members, total] = await Promise.all([cursor.toArray(), totalCount]); - - return API.v1.success({ - members, - count: members.length, - offset: skip, - total, - }); + authRequired: true, + query: channelsMembersQuery, + response: { + 200: channelsMembersResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const findResult = await findChannelByIdOrName({ + params: this.queryParams, + checkedArchived: false, + }); + + if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { + return API.v1.forbidden(); + } + + if (findResult.broadcast && !(await hasPermissionAsync(this.user, 'view-broadcast-member-list', findResult._id))) { + return API.v1.forbidden(); + } + + const { offset: skip, count: limit } = await getPaginationItems(this.queryParams); + const { sort = {} } = await this.parseJsonQuery(); + + const { status, filter } = this.queryParams; + + const { cursor, totalCount } = await findUsersOfRoom({ + rid: findResult._id, + ...(status && { status: { $in: status as UserStatus[] } }), + skip, + limit, + filter, + ...(sort?.username && { sort: { username: sort.username } }), + }); + + const [members, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + members, + count: members.length, + offset: skip, + total, + }); + }, ); -API.v1.addRoute( +const channelsOnlineResponseSchema = ajv.compile<{ online: Pick[] }>({ + type: 'object', + properties: { + online: { + type: 'array', + items: { + type: 'object', + properties: { _id: { type: 'string' }, username: { type: 'string' } }, + required: ['_id'], + additionalProperties: false, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['online', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'channels.online', - { authRequired: true, validateParams: isChannelsOnlineProps }, { - async get() { - const { query } = await this.parseJsonQuery(); - const { _id } = this.queryParams; - - if ((!query || Object.keys(query).length === 0) && !_id) { - return API.v1.failure('Invalid query'); - } - - const filter = { - ...query, - ...(_id ? { _id } : {}), - t: 'c', - }; - - const room = await Rooms.findOne(filter as Record); - if (!room) { - return API.v1.failure('Channel does not exists'); - } - - if (!(await canAccessRoomAsync(room, this.user))) { - throw new Meteor.Error('error-not-allowed', 'Not Allowed'); - } - - const online: Pick[] = await Users.findUsersNotOffline({ - projection: { username: 1 }, - }).toArray(); - - const onlineInRoom = await Promise.all( - online.map(async (user) => { - const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { - projection: { _id: 1, username: 1 }, - }); - if (subscription) { - return { - _id: user._id, - username: user.username, - }; - } - }), - ); - - return API.v1.success({ - online: onlineInRoom.filter(Boolean) as IUser[], - }); + authRequired: true, + query: isChannelsOnlineProps, + response: { + 200: channelsOnlineResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { query } = await this.parseJsonQuery(); + const { _id } = this.queryParams; + + if ((!query || Object.keys(query).length === 0) && !_id) { + return API.v1.failure('Invalid query'); + } + + const filter = { + ...query, + ...(_id ? { _id } : {}), + t: 'c', + }; + + const room = await Rooms.findOne(filter as Record); + if (!room) { + return API.v1.failure('Channel does not exists'); + } + + if (!(await canAccessRoomAsync(room, this.user))) { + throw new Meteor.Error('error-not-allowed', 'Not Allowed'); + } + + const online: Pick[] = await Users.findUsersNotOffline({ + projection: { username: 1 }, + }).toArray(); + + const onlineInRoom = await Promise.all( + online.map(async (user) => { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { + projection: { _id: 1, username: 1 }, + }); + if (subscription) { + return { + _id: user._id, + username: user.username, + }; + } + }), + ); + + return API.v1.success({ + online: onlineInRoom.filter(isTruthy), + }); + }, ); -API.v1.addRoute( +API.v1.post( 'channels.removeModerator', - { authRequired: true }, { - async post() { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + authRequired: true, + body: isChannelsModeratorsProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - const user = await getUserFromParams(this.bodyParams); + const user = await getUserFromParams(this.bodyParams); - await removeRoomModerator(this.userId, findResult._id, user._id); + await removeRoomModerator(this.userId, findResult._id, user._id); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'channels.removeOwner', - { authRequired: true }, { - async post() { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + authRequired: true, + body: isChannelsModeratorsProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - const user = await getUserFromParams(this.bodyParams); + const user = await getUserFromParams(this.bodyParams); - await removeRoomOwner(this.userId, findResult._id, user._id); + await removeRoomOwner(this.userId, findResult._id, user._id); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'channels.rename', - { authRequired: true }, { - async post() { - if (!this.bodyParams.name?.trim()) { - return API.v1.failure('The bodyParam "name" is required'); - } + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; name: string }>('name', { type: 'string' }), + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (!this.bodyParams.name?.trim()) { + return API.v1.failure('The bodyParam "name" is required'); + } - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - if (findResult.name === this.bodyParams.name) { - return API.v1.failure('The channel name is the same as what it would be renamed to.'); - } + if (findResult.name === this.bodyParams.name) { + return API.v1.failure('The channel name is the same as what it would be renamed to.'); + } - await saveRoomSettings(this.userId, findResult._id, 'roomName', this.bodyParams.name); + await saveRoomSettings(this.userId, findResult._id, 'roomName', this.bodyParams.name); - return API.v1.success({ - channel: await findChannelByIdOrName({ - params: this.bodyParams, - userId: this.userId, - }), - }); - }, + return API.v1.success({ + channel: await findChannelByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }), + }); }, ); -API.v1.addRoute( +API.v1.post( 'channels.setCustomFields', - { authRequired: true }, { - async post() { - if (!this.bodyParams.customFields || !(typeof this.bodyParams.customFields === 'object')) { - return API.v1.failure('The bodyParam "customFields" is required with a type like object.'); - } + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; customFields: Record }>('customFields', { + type: 'object', + }), + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (!this.bodyParams.customFields || !(typeof this.bodyParams.customFields === 'object')) { + return API.v1.failure('The bodyParam "customFields" is required with a type like object.'); + } - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - await saveRoomSettings(this.userId, findResult._id, 'roomCustomFields', this.bodyParams.customFields); + await saveRoomSettings(this.userId, findResult._id, 'roomCustomFields', this.bodyParams.customFields); - return API.v1.success({ - channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), - }); - }, + return API.v1.success({ + channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), + }); }, ); -API.v1.addRoute( +API.v1.post( 'channels.setDefault', - { authRequired: true }, { - async post() { - if (typeof this.bodyParams.default === 'undefined') { - return API.v1.failure('The bodyParam "default" is required', 'error-channels-setdefault-is-same'); - } - - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; default: boolean | string }>('default', { type: ['boolean', 'string'] }), + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (typeof this.bodyParams.default === 'undefined') { + return API.v1.failure('The bodyParam "default" is required', 'error-channels-setdefault-is-same'); + } - if (findResult.default === this.bodyParams.default) { - return API.v1.failure( - 'The channel default setting is the same as what it would be changed to.', - 'error-channels-setdefault-missing-default-param', - ); - } + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - await saveRoomSettings( - this.userId, - findResult._id, - 'default', - ['true', '1'].includes(this.bodyParams.default.toString().toLowerCase()), + if (findResult.default === this.bodyParams.default) { + return API.v1.failure( + 'The channel default setting is the same as what it would be changed to.', + 'error-channels-setdefault-missing-default-param', ); - - return API.v1.success({ - channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), - }); - }, + } + + await saveRoomSettings( + this.userId, + findResult._id, + 'default', + ['true', '1'].includes(this.bodyParams.default.toString().toLowerCase()), + ); + + return API.v1.success({ + channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), + }); }, ); -API.v1.addRoute( +API.v1.post( 'channels.setDescription', - { authRequired: true }, { - async post() { - if (!this.bodyParams.hasOwnProperty('description')) { - return API.v1.failure('The bodyParam "description" is required'); - } - - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; description: string }>('description', { type: 'string' }), + response: { + 200: descriptionResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - if (findResult.description === this.bodyParams.description) { - return API.v1.failure('The channel description is the same as what it would be changed to.'); - } + if (findResult.description === this.bodyParams.description) { + return API.v1.failure('The channel description is the same as what it would be changed to.'); + } - await saveRoomSettings(this.userId, findResult._id, 'roomDescription', this.bodyParams.description || ''); + await saveRoomSettings(this.userId, findResult._id, 'roomDescription', this.bodyParams.description || ''); - return API.v1.success({ - description: this.bodyParams.description || '', - }); - }, + return API.v1.success({ + description: this.bodyParams.description || '', + }); }, ); -API.v1.addRoute( +API.v1.post( 'channels.setPurpose', - { authRequired: true }, { - async post() { - if (!this.bodyParams.hasOwnProperty('purpose')) { - return API.v1.failure('The bodyParam "purpose" is required'); - } - - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; purpose: string }>('purpose', { type: 'string' }), + response: { + 200: purposeResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - if (findResult.description === this.bodyParams.purpose) { - return API.v1.failure('The channel purpose (description) is the same as what it would be changed to.'); - } + if (findResult.description === this.bodyParams.purpose) { + return API.v1.failure('The channel purpose (description) is the same as what it would be changed to.'); + } - await saveRoomSettings(this.userId, findResult._id, 'roomDescription', this.bodyParams.purpose || ''); + await saveRoomSettings(this.userId, findResult._id, 'roomDescription', this.bodyParams.purpose || ''); - return API.v1.success({ - purpose: this.bodyParams.purpose || '', - }); - }, + return API.v1.success({ + purpose: this.bodyParams.purpose || '', + }); }, ); -API.v1.addRoute( +API.v1.post( 'channels.setTopic', - { authRequired: true }, { - async post() { - if (!this.bodyParams.hasOwnProperty('topic')) { - return API.v1.failure('The bodyParam "topic" is required'); - } - - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; topic: string }>('topic', { type: 'string' }), + response: { + 200: topicResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - if (findResult.topic === this.bodyParams.topic) { - return API.v1.failure('The channel topic is the same as what it would be changed to.'); - } + if (findResult.topic === this.bodyParams.topic) { + return API.v1.failure('The channel topic is the same as what it would be changed to.'); + } - await saveRoomSettings(this.userId, findResult._id, 'roomTopic', this.bodyParams.topic || ''); + await saveRoomSettings(this.userId, findResult._id, 'roomTopic', this.bodyParams.topic || ''); - return API.v1.success({ - topic: this.bodyParams.topic || '', - }); - }, + return API.v1.success({ + topic: this.bodyParams.topic || '', + }); }, ); -API.v1.addRoute( +API.v1.post( 'channels.setType', - { authRequired: true }, { - async post() { - if (!this.bodyParams.type?.trim()) { - return API.v1.failure('The bodyParam "type" is required'); - } + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; type: string }>('type', { type: 'string' }), + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (!this.bodyParams.type?.trim()) { + return API.v1.failure('The bodyParam "type" is required'); + } - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - if (findResult.t === this.bodyParams.type) { - return API.v1.failure('The channel type is the same as what it would be changed to.'); - } + if (findResult.t === this.bodyParams.type) { + return API.v1.failure('The channel type is the same as what it would be changed to.'); + } - await saveRoomSettings(this.userId, findResult._id, 'roomType', this.bodyParams.type as RoomType); + await saveRoomSettings(this.userId, findResult._id, 'roomType', this.bodyParams.type as RoomType); - const room = await Rooms.findOneById(findResult._id, { projection: API.v1.defaultFieldsToExclude }); + const room = await Rooms.findOneById(findResult._id, { projection: API.v1.defaultFieldsToExclude }); - if (!room) { - return API.v1.failure('The channel does not exist'); - } + if (!room) { + return API.v1.failure('The channel does not exist'); + } - return API.v1.success({ - channel: await composeRoomWithLastMessage(room, this.userId), - }); - }, + return API.v1.success({ + channel: await composeRoomWithLastMessage(room, this.userId), + }); }, ); -API.v1.addRoute( +API.v1.post( 'channels.addLeader', - { authRequired: true }, { - async post() { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + authRequired: true, + body: isChannelsModeratorsProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - const user = await getUserFromParams(this.bodyParams); + const user = await getUserFromParams(this.bodyParams); - await addRoomLeader(this.userId, findResult._id, user._id); + await addRoomLeader(this.userId, findResult._id, user._id); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'channels.removeLeader', - { authRequired: true }, { - async post() { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + authRequired: true, + body: isChannelsModeratorsProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - const user = await getUserFromParams(this.bodyParams); + const user = await getUserFromParams(this.bodyParams); - await removeRoomLeader(this.userId, findResult._id, user._id); + await removeRoomLeader(this.userId, findResult._id, user._id); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'channels.setJoinCode', - { authRequired: true }, { - async post() { - if (!this.bodyParams.joinCode?.trim()) { - return API.v1.failure('The bodyParam "joinCode" is required'); - } + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; joinCode: string }>('joinCode', { type: 'string' }), + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (!this.bodyParams.joinCode?.trim()) { + return API.v1.failure('The bodyParam "joinCode" is required'); + } - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - await saveRoomSettings(this.userId, findResult._id, 'joinCode', this.bodyParams.joinCode); + await saveRoomSettings(this.userId, findResult._id, 'joinCode', this.bodyParams.joinCode); - return API.v1.success({ - channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), - }); - }, + return API.v1.success({ + channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), + }); }, ); -API.v1.addRoute( +const channelsAnonymousReadQuery = ajvQuery.compile<{ + roomId?: string; + roomName?: string; + offset?: number; + count?: number; + sort?: string; + query?: string; + fields?: string; +}>({ + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + offset: { type: 'number' }, + count: { type: 'number' }, + sort: { type: 'string' }, + query: { type: 'string' }, + fields: { type: 'string' }, + }, + anyOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, +}); + +API.v1.get( 'channels.anonymousread', - { authOrAnonRequired: true }, { - async get() { - const findResult = await findChannelByIdOrName({ - params: this.queryParams, - checkedArchived: false, + authOrAnonRequired: true, + query: channelsAnonymousReadQuery, + response: { + 200: channelsMessagesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 404: validateNotFoundErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ + params: this.queryParams, + checkedArchived: false, + }); + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + const ourQuery = Object.assign({}, query, { rid: findResult._id }); + + if (!settings.get('Accounts_AllowAnonymousRead')) { + throw new Meteor.Error('error-not-allowed', 'Enable "Allow Anonymous Read"', { + method: 'channels.anonymousread', }); - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); - - const ourQuery = Object.assign({}, query, { rid: findResult._id }); - - if (!settings.get('Accounts_AllowAnonymousRead')) { - throw new Meteor.Error('error-not-allowed', 'Enable "Allow Anonymous Read"', { - method: 'channels.anonymousread', - }); - } - - // Public rooms of private teams should be accessible only by team members - if (findResult.teamId) { - const team = await Team.getOneById(findResult.teamId); - if (team?.type === TeamType.PRIVATE) { - if (!this.userId || !(await canAccessRoomAsync(findResult, { _id: this.userId }))) { - return API.v1.notFound('Room not found'); - } + } + + // Public rooms of private teams should be accessible only by team members + if (findResult.teamId) { + const team = await Team.getOneById(findResult.teamId); + if (team?.type === TeamType.PRIVATE) { + if (!this.userId || !(await canAccessRoomAsync(findResult, { _id: this.userId }))) { + return API.v1.notFound('Room not found'); } } - - const { cursor, totalCount } = await Messages.findPaginated(ourQuery, { - sort: sort || { ts: -1 }, - skip: offset, - limit: count, - projection: fields, - }); - - const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); - - return API.v1.success({ - messages: await normalizeMessagesForUser(messages, this.userId || ''), - count: messages.length, - offset, - total, - }); - }, + } + + const { cursor, totalCount } = await Messages.findPaginated(ourQuery, { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + messages: await normalizeMessagesForUser(messages, this.userId || ''), + count: messages.length, + offset, + total, + }); }, ); diff --git a/docs/api-endpoint-migration.md b/docs/api-endpoint-migration.md index b65829978ccaa..3c1fb0f9043a1 100644 --- a/docs/api-endpoint-migration.md +++ b/docs/api-endpoint-migration.md @@ -558,6 +558,29 @@ API.v1.post('endpoint', { }, async function action() { ... }); ``` +## Error Handling (thrown errors) + +Every route registered through `API.v1.get/post/put/delete` is wrapped by `ApiClass`'s internal handler (`_internalRouteActionHandler`), which runs the action inside a `try/catch`. A thrown `Meteor.Error` (or any core-services error carrying `error`/`message` by shape) is mapped to the matching HTTP failure: + +- `error-too-many-requests` → `429` +- `unauthorized` / `error-unauthorized` → `401` (or `403` pre-breaking-changes) +- `forbidden` / `error-forbidden` → `403` +- anything else → `API.v1.failure(message, errorType, stack, details)` → `400` + +So a migrated handler should **just throw** to signal a client error — exactly as the legacy DDP methods and `addRoute` handlers did. Do **not** wrap every handler in a `try/catch` that returns `API.v1.failure(...)`: it is redundant with the global wrapper and, worse, flattens `401`/`403`/`429` into `400`. + +```typescript +async function action() { + const room = await findChannelByIdOrName({ params: this.bodyParams }); // throws error-room-not-found + // ... + return API.v1.success({ channel: room }); +} +``` + +Declare in the `response` block every error status the handler (or the wrapper) can produce, so response validation under `TEST_MODE` accepts them — `400: validateBadRequestErrorResponse` for thrown/failure errors, plus `401`/`403` when `authRequired`/`permissionsRequired` are set. + +Add an explicit `catch` only when you need behaviour the global wrapper does not provide (e.g. returning a specific `API.v1.failure(...)` payload for a known condition, or mapping a status differently); otherwise let the error propagate. + ## Test Changes Migrating an endpoint changes how validation errors are returned. Tests must be updated accordingly. diff --git a/packages/core-typings/src/Ajv.ts b/packages/core-typings/src/Ajv.ts index 7d9a8cebbee81..a1169db0dafe0 100644 --- a/packages/core-typings/src/Ajv.ts +++ b/packages/core-typings/src/Ajv.ts @@ -20,6 +20,8 @@ import type { IReadReceiptWithUser } from './IReadReceipt'; import type { IRole } from './IRole'; import type { IRoom, IDirectoryChannelResult, IRoomAdmin } from './IRoom'; import type { ISubscription } from './ISubscription'; +import type { ITeam } from './ITeam'; +import type { IUploadWithUser } from './IUpload'; import type { IUser, IDirectoryUserResult } from './IUser'; import type { VideoConference, VideoConferenceInstructions } from './IVideoConference'; import type { SlashCommand } from './SlashCommands'; @@ -60,6 +62,8 @@ export const schemas = typia.json.schemas< | IIntegrationHistory | IMeApiUser | IReadReceiptWithUser + | ITeam + | IUploadWithUser ), CallHistoryItem, ICustomUserStatus, diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 9c39723b39351..6a47c812099e5 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -15,13 +15,13 @@ export interface IRoom extends IRocketChatRecord { fname?: string; msgs: number; default?: boolean; - broadcast?: true; + broadcast?: boolean; featured?: true; announcement?: string; joinCodeRequired?: boolean; announcementDetails?: { style?: string; - }; + } | null; encrypted?: boolean; // The existence of an abac attribute definition indicates that ABAC is enabled for the room abacAttributes?: IAbacAttributeDefinition[]; diff --git a/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts b/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts index c012a6aca96a8..aabaff2ce32ef 100644 --- a/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts +++ b/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts @@ -5,10 +5,13 @@ export type ChannelsCreateProps = { members?: string[]; teams?: string[]; readOnly?: boolean; + customFields?: Record; extraData?: { broadcast?: boolean; encrypted?: boolean; teamId?: string; + topic?: string; + federated?: boolean; }; excludeSelf?: boolean; }; @@ -21,14 +24,25 @@ const channelsCreatePropsSchema = { }, members: { type: 'array', + items: { type: 'string' }, }, teams: { type: 'array', + items: { type: 'string' }, }, - readonly: { + readOnly: { + type: 'boolean', + }, + customFields: { + type: 'object', + }, + excludeSelf: { type: 'boolean', }, extraData: { + // extraData is spread verbatim into createRoom. Keep it closed (additionalProperties: false) + // so callers can't inject arbitrary room fields (default/featured/retention/abacAttributes/...). + // The create modal only sends the fields declared below. type: 'object', properties: { broadcast: { @@ -40,9 +54,14 @@ const channelsCreatePropsSchema = { teamId: { type: 'string', }, + topic: { + type: 'string', + }, + federated: { + type: 'boolean', + }, }, additionalProperties: false, - nullable: true, }, }, required: ['name'], diff --git a/packages/rest-typings/src/v1/channels/ChannelsMessagesProps.ts b/packages/rest-typings/src/v1/channels/ChannelsMessagesProps.ts index f771a9a6eb24c..04ffe9db07229 100644 --- a/packages/rest-typings/src/v1/channels/ChannelsMessagesProps.ts +++ b/packages/rest-typings/src/v1/channels/ChannelsMessagesProps.ts @@ -8,7 +8,7 @@ export type ChannelsMessagesProps = PaginatedRequest< roomId: IRoom['_id']; mentionIds?: string; starredIds?: string; - pinned?: boolean; + pinned?: string; query?: Record; }, 'ts' diff --git a/packages/rest-typings/src/v1/channels/channels.ts b/packages/rest-typings/src/v1/channels/channels.ts index 6ae8adbd4a365..2432414c6b198 100644 --- a/packages/rest-typings/src/v1/channels/channels.ts +++ b/packages/rest-typings/src/v1/channels/channels.ts @@ -54,9 +54,11 @@ export type ChannelsEndpoints = { }>; }; '/v1/channels.history': { - GET: (params: ChannelsHistoryProps) => PaginatedResult<{ + GET: (params: ChannelsHistoryProps) => { messages: IMessage[]; - }>; + firstUnread?: IMessage; + unreadNotLoaded?: number; + }; }; '/v1/channels.archive': { POST: (params: ChannelsArchiveProps) => void; @@ -160,7 +162,7 @@ export type ChannelsEndpoints = { }; '/v1/channels.getAllUserMentionsByChannel': { GET: (params: ChannelsGetAllUserMentionsByChannelProps) => PaginatedResult<{ - mentions: IUser[]; + mentions: IMessage[]; }>; }; '/v1/channels.moderators': {