From 863ba620943959026c800d9ea74372adee8c2361 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 09:45:07 -0300 Subject: [PATCH 01/30] chore(api): migrate channels.ts to typed HTTP methods (batch 1/n) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP migrating channels.ts from addRoute to typed API.v1 (moderation pattern — kept manual ChannelsEndpoints entry, no augment). Batch 1: addAll, archive, unarchive, join, kick, leave. - Shared channelResponseSchema ($ref IRoom) + successResponseSchema. - findChannelByIdOrName throws Meteor.Error; the typed router does not map throws to 400, so each handler catches and returns API.v1.failure(msg, errorType) to preserve the previous behavior. --- apps/meteor/server/api/v1/channels.ts | 140 ++++++++++++++++++++------ 1 file changed, 110 insertions(+), 30 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index b26e89221547d..636b003e3feae 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -29,6 +29,9 @@ import { isChannelsListProps, isChannelsFilesListProps, isChannelsOnlineProps, + ajv, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { isTruthy } from '@rocket.chat/tools'; import { check, Match } from 'meteor/check'; @@ -106,14 +109,36 @@ async function findChannelByIdOrName({ return room; } -API.v1.addRoute( +const channelResponseSchema = ajv.compile<{ channel: IRoom }>({ + type: 'object', + properties: { + channel: { $ref: '#/components/schemas/IRoom' }, + 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, +}); + +API.v1.post( 'channels.addAll', { authRequired: true, - validateParams: isChannelsAddAllProps, + body: isChannelsAddAllProps, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { + async function action() { + try { const { activeUsersOnly, ...params } = this.bodyParams; const findResult = await findChannelByIdOrName({ params, userId: this.userId }); @@ -122,35 +147,55 @@ API.v1.addRoute( return API.v1.success({ channel: await findChannelByIdOrName({ params, userId: this.userId }), }); - }, + } catch (error) { + return API.v1.failure( + error instanceof Meteor.Error ? error.message : String(error), + error instanceof Meteor.Error && typeof error.error === 'string' ? error.error : undefined, + ); + } }, ); -API.v1.addRoute( +API.v1.post( 'channels.archive', { authRequired: true, - validateParams: isChannelsArchiveProps, + body: isChannelsArchiveProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { + async function action() { + try { const findResult = await findChannelByIdOrName({ params: this.bodyParams }); await executeArchiveRoom(this.userId, findResult._id); return API.v1.success(); - }, + } catch (error) { + return API.v1.failure( + error instanceof Meteor.Error ? error.message : String(error), + error instanceof Meteor.Error && typeof error.error === 'string' ? error.error : undefined, + ); + } }, ); -API.v1.addRoute( +API.v1.post( 'channels.unarchive', { authRequired: true, - validateParams: isChannelsUnarchiveProps, + body: isChannelsUnarchiveProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { + async function action() { + try { const findResult = await findChannelByIdOrName({ params: this.bodyParams, checkedArchived: false, @@ -163,7 +208,12 @@ API.v1.addRoute( await executeUnarchiveRoom(this.userId, findResult._id); return API.v1.success(); - }, + } catch (error) { + return API.v1.failure( + error instanceof Meteor.Error ? error.message : String(error), + error instanceof Meteor.Error && typeof error.error === 'string' ? error.error : undefined, + ); + } }, ); @@ -223,14 +273,19 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +API.v1.post( 'channels.join', { authRequired: true, - validateParams: isChannelsJoinProps, + body: isChannelsJoinProps, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { + async function action() { + try { const { joinCode, ...params } = this.bodyParams; const findResult = await findChannelByIdOrName({ params }); @@ -239,18 +294,28 @@ API.v1.addRoute( return API.v1.success({ channel: await findChannelByIdOrName({ params, userId: this.userId }), }); - }, + } catch (error) { + return API.v1.failure( + error instanceof Meteor.Error ? error.message : String(error), + error instanceof Meteor.Error && typeof error.error === 'string' ? error.error : undefined, + ); + } }, ); -API.v1.addRoute( +API.v1.post( 'channels.kick', { authRequired: true, - validateParams: isChannelsKickProps, + body: isChannelsKickProps, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { + async function action() { + try { const { ...params /* userId */ } = this.bodyParams; const findResult = await findChannelByIdOrName({ params }); @@ -264,18 +329,28 @@ API.v1.addRoute( return API.v1.success({ channel: await findChannelByIdOrName({ params, userId: this.userId }), }); - }, + } catch (error) { + return API.v1.failure( + error instanceof Meteor.Error ? error.message : String(error), + error instanceof Meteor.Error && typeof error.error === 'string' ? error.error : undefined, + ); + } }, ); -API.v1.addRoute( +API.v1.post( 'channels.leave', { authRequired: true, - validateParams: isChannelsLeaveProps, + body: isChannelsLeaveProps, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { + async function action() { + try { const { ...params } = this.bodyParams; const findResult = await findChannelByIdOrName({ params }); @@ -288,7 +363,12 @@ API.v1.addRoute( return API.v1.success({ channel: await findChannelByIdOrName({ params, userId: this.userId }), }); - }, + } catch (error) { + return API.v1.failure( + error instanceof Meteor.Error ? error.message : String(error), + error instanceof Meteor.Error && typeof error.error === 'string' ? error.error : undefined, + ); + } }, ); From d9d142aaea14bba35ebd33f98eb9e280f2164a6b Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 10:53:05 -0300 Subject: [PATCH 02/30] fix(api): preserve errorType in channels error responses Errors from core-services (e.g. Room.join) cross a service boundary and are not instanceof the local Meteor.Error, so the catch dropped their errorType and the channels.join e2e assertions (`expected ... to have property 'errorType'`) failed. Extract message/errorType by shape instead of instanceof, matching the legacy addRoute behavior. --- apps/meteor/server/api/v1/channels.ts | 44 ++++++++++++--------------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index 636b003e3feae..fe5fd013c4bf3 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -126,6 +126,14 @@ const successResponseSchema = ajv.compile({ additionalProperties: false, }); +// findChannelByIdOrName and the room methods throw for client errors. The typed router does not map thrown +// errors to 400 (the legacy addRoute wrapper did), so handlers catch and return a failure. Errors coming from +// core-services cross a boundary and are not `instanceof Meteor.Error`, so extract message/errorType by shape. +function errorToFailureArgs(error: unknown): [string, string | undefined] { + const e = error as { message?: unknown; error?: unknown }; + return [typeof e?.message === 'string' ? e.message : String(error), typeof e?.error === 'string' ? e.error : undefined]; +} + API.v1.post( 'channels.addAll', { @@ -148,10 +156,8 @@ API.v1.post( channel: await findChannelByIdOrName({ params, userId: this.userId }), }); } catch (error) { - return API.v1.failure( - error instanceof Meteor.Error ? error.message : String(error), - error instanceof Meteor.Error && typeof error.error === 'string' ? error.error : undefined, - ); + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); } }, ); @@ -175,10 +181,8 @@ API.v1.post( return API.v1.success(); } catch (error) { - return API.v1.failure( - error instanceof Meteor.Error ? error.message : String(error), - error instanceof Meteor.Error && typeof error.error === 'string' ? error.error : undefined, - ); + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); } }, ); @@ -209,10 +213,8 @@ API.v1.post( return API.v1.success(); } catch (error) { - return API.v1.failure( - error instanceof Meteor.Error ? error.message : String(error), - error instanceof Meteor.Error && typeof error.error === 'string' ? error.error : undefined, - ); + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); } }, ); @@ -295,10 +297,8 @@ API.v1.post( channel: await findChannelByIdOrName({ params, userId: this.userId }), }); } catch (error) { - return API.v1.failure( - error instanceof Meteor.Error ? error.message : String(error), - error instanceof Meteor.Error && typeof error.error === 'string' ? error.error : undefined, - ); + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); } }, ); @@ -330,10 +330,8 @@ API.v1.post( channel: await findChannelByIdOrName({ params, userId: this.userId }), }); } catch (error) { - return API.v1.failure( - error instanceof Meteor.Error ? error.message : String(error), - error instanceof Meteor.Error && typeof error.error === 'string' ? error.error : undefined, - ); + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); } }, ); @@ -364,10 +362,8 @@ API.v1.post( channel: await findChannelByIdOrName({ params, userId: this.userId }), }); } catch (error) { - return API.v1.failure( - error instanceof Meteor.Error ? error.message : String(error), - error instanceof Meteor.Error && typeof error.error === 'string' ? error.error : undefined, - ); + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); } }, ); From bb7f3c9a3cea6e549469e09df1096ce213cb8de4 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 11:42:45 -0300 Subject: [PATCH 03/30] docs(api): document typed-router error handling (throws become 500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The typed router has no global error handler, so thrown errors (incl. Meteor.Error) become 500 — handlers must catch client-error throws and return API.v1.failure. core-services errors are not instanceof the local Meteor.Error, so extract message/errorType by shape. --- docs/api-endpoint-migration.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/api-endpoint-migration.md b/docs/api-endpoint-migration.md index b65829978ccaa..fb2a00cf71078 100644 --- a/docs/api-endpoint-migration.md +++ b/docs/api-endpoint-migration.md @@ -558,6 +558,40 @@ API.v1.post('endpoint', { }, async function action() { ... }); ``` +## Error Handling (thrown errors) + +The typed router does **not** convert thrown errors into HTTP error responses the way the legacy `addRoute` wrapper did. `addRoute` wrapped every handler in a `try/catch` that turned a thrown `Meteor.Error` into a `400` failure (with `error`/`errorType`). The typed router has **no global error handler** — an uncaught throw propagates and becomes a **500 Internal Server Error**. + +So when migrating a handler (or a helper it calls, e.g. `findChannelByIdOrName`, `Room.join`, `requestPdfTranscript`) that throws to signal a client error, you must catch it and return an explicit failure to preserve the previous status: + +```typescript +async function action() { + try { + const room = await findChannelByIdOrName({ params: this.bodyParams }); + // ... + return API.v1.success({ channel: room }); + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } +} +``` + +Remember to declare `400: validateBadRequestErrorResponse` in the `response` block (any handler that can return `API.v1.failure()` needs it). + +### Extract `errorType` by shape, not `instanceof` + +Errors thrown by **`@rocket.chat/core-services`** calls (e.g. `Room.join`, `Team.*`) cross a service boundary and are **not** `instanceof` the local `Meteor.Error`, so an `error instanceof Meteor.Error` check silently drops their `error`/`errorType`. Extract them by shape (matching the legacy `addRoute` behavior), otherwise e2e assertions like `expect(res.body).to.have.property('errorType', ...)` fail: + +```typescript +function errorToFailureArgs(error: unknown): [string, string | undefined] { + const e = error as { message?: unknown; error?: unknown }; + return [typeof e?.message === 'string' ? e.message : String(error), typeof e?.error === 'string' ? e.error : undefined]; +} +``` + +Keep the `API.v1.failure(...)` call **inline** in the `catch` (a helper that itself returns `API.v1.failure(...)` breaks `TypedAction` return-type inference); factor out only the argument extraction. + ## Test Changes Migrating an endpoint changes how validation errors are returned. Tests must be updated accordingly. From 0c7e238206aea6a3f6916c6d2b393028c80ca14d Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 12:03:55 -0300 Subject: [PATCH 04/30] chore(api): migrate channels.ts set* endpoints (batch 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate rename, setCustomFields, setDefault, setDescription, setPurpose, setTopic, setType. These addRoute endpoints had no validateParams, so use inline ajv body validators (roomSettingBody helper: room target + setting field), matching the inline-validator style used in rooms.ts/users.ts. Responses: channelResponseSchema ($ref IRoom) or the {field} schema; findChannelByIdOrName throws are caught → API.v1.failure. --- apps/meteor/server/api/v1/channels.ts | 163 +++++++++++++++++++++----- 1 file changed, 135 insertions(+), 28 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index fe5fd013c4bf3..bd9a5c94bcea3 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -134,6 +134,34 @@ function errorToFailureArgs(error: unknown): [string, string | undefined] { return [typeof e?.message === 'string' ? e.message : String(error), typeof e?.error === 'string' ? e.error : undefined]; } +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'); + +// Body validator for the `channels.set*` endpoints: a room target (roomId or roomName) plus the setting field. +const roomSettingBody = (field: string, fieldSchema: Record) => + ajv.compile({ + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + [field]: fieldSchema, + }, + required: [field], + additionalProperties: false, + }); + API.v1.post( 'channels.addAll', { @@ -1303,11 +1331,19 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +API.v1.post( 'channels.rename', - { authRequired: true }, { - async post() { + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; name: string }>('name', { type: 'string' }), + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + try { if (!this.bodyParams.name?.trim()) { return API.v1.failure('The bodyParam "name" is required'); } @@ -1326,15 +1362,28 @@ API.v1.addRoute( userId: this.userId, }), }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); -API.v1.addRoute( +API.v1.post( 'channels.setCustomFields', - { authRequired: true }, { - async post() { + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; customFields: Record }>('customFields', { + type: 'object', + }), + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + try { if (!this.bodyParams.customFields || !(typeof this.bodyParams.customFields === 'object')) { return API.v1.failure('The bodyParam "customFields" is required with a type like object.'); } @@ -1346,15 +1395,26 @@ API.v1.addRoute( return API.v1.success({ channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); -API.v1.addRoute( +API.v1.post( 'channels.setDefault', - { authRequired: true }, { - async post() { + 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() { + try { if (typeof this.bodyParams.default === 'undefined') { return API.v1.failure('The bodyParam "default" is required', 'error-channels-setdefault-is-same'); } @@ -1378,15 +1438,26 @@ API.v1.addRoute( return API.v1.success({ channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); -API.v1.addRoute( +API.v1.post( 'channels.setDescription', - { authRequired: true }, { - async post() { + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; description: string }>('description', { type: 'string' }), + response: { + 200: descriptionResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + try { if (!this.bodyParams.hasOwnProperty('description')) { return API.v1.failure('The bodyParam "description" is required'); } @@ -1402,15 +1473,26 @@ API.v1.addRoute( return API.v1.success({ description: this.bodyParams.description || '', }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); -API.v1.addRoute( +API.v1.post( 'channels.setPurpose', - { authRequired: true }, { - async post() { + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; purpose: string }>('purpose', { type: 'string' }), + response: { + 200: purposeResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + try { if (!this.bodyParams.hasOwnProperty('purpose')) { return API.v1.failure('The bodyParam "purpose" is required'); } @@ -1426,15 +1508,26 @@ API.v1.addRoute( return API.v1.success({ purpose: this.bodyParams.purpose || '', }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); -API.v1.addRoute( +API.v1.post( 'channels.setTopic', - { authRequired: true }, { - async post() { + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; topic: string }>('topic', { type: 'string' }), + response: { + 200: topicResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + try { if (!this.bodyParams.hasOwnProperty('topic')) { return API.v1.failure('The bodyParam "topic" is required'); } @@ -1450,15 +1543,26 @@ API.v1.addRoute( return API.v1.success({ topic: this.bodyParams.topic || '', }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); -API.v1.addRoute( +API.v1.post( 'channels.setType', - { authRequired: true }, { - async post() { + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; type: string }>('type', { type: 'string' }), + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + try { if (!this.bodyParams.type?.trim()) { return API.v1.failure('The bodyParam "type" is required'); } @@ -1480,7 +1584,10 @@ API.v1.addRoute( return API.v1.success({ channel: await composeRoomWithLastMessage(room, this.userId), }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); From 90af3f4896e8375c909761c9d24dafbe3d3463bb Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 12:06:53 -0300 Subject: [PATCH 05/30] chore(api): migrate channels.ts role endpoints (batch 3) Migrate addModerator, addOwner, removeModerator, removeOwner, addLeader, removeLeader (all void, body: isChannelsModeratorsProps). findChannelByIdOrName throws caught -> API.v1.failure. --- apps/meteor/server/api/v1/channels.ts | 114 ++++++++++++++++++++------ 1 file changed, 90 insertions(+), 24 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index bd9a5c94bcea3..7ed3c845cae40 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -665,11 +665,19 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +API.v1.post( 'channels.addModerator', - { authRequired: true }, { - async post() { + authRequired: true, + body: isChannelsModeratorsProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + try { const findResult = await findChannelByIdOrName({ params: this.bodyParams }); const user = await getUserFromParams(this.bodyParams); @@ -677,15 +685,26 @@ API.v1.addRoute( await addRoomModerator(this.userId, findResult._id, user._id); return API.v1.success(); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); -API.v1.addRoute( +API.v1.post( 'channels.addOwner', - { authRequired: true }, { - async post() { + authRequired: true, + body: isChannelsModeratorsProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + try { const findResult = await findChannelByIdOrName({ params: this.bodyParams }); const user = await getUserFromParams(this.bodyParams); @@ -693,7 +712,10 @@ API.v1.addRoute( await addRoomOwner(this.userId, findResult._id, user._id); return API.v1.success(); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); @@ -1299,11 +1321,19 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +API.v1.post( 'channels.removeModerator', - { authRequired: true }, { - async post() { + authRequired: true, + body: isChannelsModeratorsProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + try { const findResult = await findChannelByIdOrName({ params: this.bodyParams }); const user = await getUserFromParams(this.bodyParams); @@ -1311,15 +1341,26 @@ API.v1.addRoute( await removeRoomModerator(this.userId, findResult._id, user._id); return API.v1.success(); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); -API.v1.addRoute( +API.v1.post( 'channels.removeOwner', - { authRequired: true }, { - async post() { + authRequired: true, + body: isChannelsModeratorsProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + try { const findResult = await findChannelByIdOrName({ params: this.bodyParams }); const user = await getUserFromParams(this.bodyParams); @@ -1327,7 +1368,10 @@ API.v1.addRoute( await removeRoomOwner(this.userId, findResult._id, user._id); return API.v1.success(); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); @@ -1591,11 +1635,19 @@ API.v1.post( }, ); -API.v1.addRoute( +API.v1.post( 'channels.addLeader', - { authRequired: true }, { - async post() { + authRequired: true, + body: isChannelsModeratorsProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + try { const findResult = await findChannelByIdOrName({ params: this.bodyParams }); const user = await getUserFromParams(this.bodyParams); @@ -1603,15 +1655,26 @@ API.v1.addRoute( await addRoomLeader(this.userId, findResult._id, user._id); return API.v1.success(); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); -API.v1.addRoute( +API.v1.post( 'channels.removeLeader', - { authRequired: true }, { - async post() { + authRequired: true, + body: isChannelsModeratorsProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + try { const findResult = await findChannelByIdOrName({ params: this.bodyParams }); const user = await getUserFromParams(this.bodyParams); @@ -1619,7 +1682,10 @@ API.v1.addRoute( await removeRoomLeader(this.userId, findResult._id, user._id); return API.v1.success(); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); From 7dca3edcbd79dce2f03c9f6c6015a88562290f84 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 13:52:40 -0300 Subject: [PATCH 06/30] chore(api): migrate channels.ts open/setReadOnly/setAnnouncement (batch 4) --- apps/meteor/server/api/v1/channels.ts | 64 ++++++++++++++++++++------- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index 7ed3c845cae40..f50c788d5af3a 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -457,14 +457,19 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +API.v1.post( 'channels.open', { authRequired: true, - validateParams: isChannelsOpenProps, + body: isChannelsOpenProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { + async function action() { + try { const { ...params } = this.bodyParams; const findResult = await findChannelByIdOrName({ @@ -485,18 +490,26 @@ API.v1.addRoute( await openRoom(this.userId, findResult._id); return API.v1.success(); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); -API.v1.addRoute( +API.v1.post( 'channels.setReadOnly', { authRequired: true, - validateParams: isChannelsSetReadOnlyProps, + body: isChannelsSetReadOnlyProps, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { + async function action() { + try { const findResult = await findChannelByIdOrName({ params: this.bodyParams }); if (findResult.ro === this.bodyParams.readOnly) { @@ -508,18 +521,36 @@ API.v1.addRoute( return API.v1.success({ channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); -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() { + async function action() { + try { const { announcement, ...params } = this.bodyParams; const findResult = await findChannelByIdOrName({ params }); @@ -529,7 +560,10 @@ API.v1.addRoute( return API.v1.success({ announcement: this.bodyParams.announcement, }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); From 352041a7d8821c787004ba71bcd99d696730a3df Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 14:34:22 -0300 Subject: [PATCH 07/30] chore(api): require room target in channels.set* body schema Add an anyOf(roomId, roomName) constraint to the shared roomSettingBody validator so channels.set*/rename bodies must carry a channel identifier, matching the exported REST contracts, and drop the redundant helper comment. --- apps/meteor/server/api/v1/channels.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index f50c788d5af3a..7212232735cff 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -149,7 +149,6 @@ const descriptionResponseSchema = stringFieldResponseSchema<{ description: strin const purposeResponseSchema = stringFieldResponseSchema<{ purpose: string }>('purpose'); const topicResponseSchema = stringFieldResponseSchema<{ topic: string }>('topic'); -// Body validator for the `channels.set*` endpoints: a room target (roomId or roomName) plus the setting field. const roomSettingBody = (field: string, fieldSchema: Record) => ajv.compile({ type: 'object', @@ -159,6 +158,7 @@ const roomSettingBody = (field: string, fieldSchema: Record) [field]: fieldSchema, }, required: [field], + anyOf: [{ required: ['roomId'] }, { required: ['roomName'] }], additionalProperties: false, }); From 34f86579fe745f5a97f65dc23d22b27742edf765 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 15:38:21 -0300 Subject: [PATCH 08/30] chore(api): migrate channels.info + channels.list (batch 5) - info: GET, roomTargetQuery (ajvQuery), returns { channel } ($ref IRoom); 403 for canAccessRoom. - list: GET, isChannelsListProps query, PaginatedResult<{ channels: IRoom[] }>; typed ourQuery as Filter. Adds roomTargetQuery + channelsListResponseSchema helpers. --- apps/meteor/server/api/v1/channels.ts | 167 ++++++++++++++++---------- 1 file changed, 105 insertions(+), 62 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index 7212232735cff..64cce6bc0d885 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -30,12 +30,15 @@ import { isChannelsFilesListProps, isChannelsOnlineProps, ajv, + ajvQuery, validateBadRequestErrorResponse, + validateForbiddenErrorResponse, validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { isTruthy } from '@rocket.chat/tools'; import { check, Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import type { Filter } from 'mongodb'; import { canAccessRoomAsync } from '../../lib/authorization'; import { hasAllPermissionAsync, hasPermissionAsync } from '../../lib/authorization/hasPermission'; @@ -162,6 +165,30 @@ const roomSettingBody = (field: string, fieldSchema: Record) 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', { @@ -1085,11 +1112,20 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +API.v1.get( 'channels.info', - { authRequired: true }, { - async get() { + authRequired: true, + query: roomTargetQuery, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + try { const findResult = await findChannelByIdOrName({ params: this.queryParams, checkedArchived: false, @@ -1103,7 +1139,10 @@ API.v1.addRoute( return API.v1.success({ channel: findResult, }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); @@ -1141,75 +1180,79 @@ API.v1.addRoute( }, ); -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 }; - } + 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, - ); + // 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, - }, + 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, + }); }, ); From 2f127ded7615867df47da3c7856bb0ed5303eded Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 15:48:48 -0300 Subject: [PATCH 09/30] chore(api): migrate channels roles/moderators/delete (batch 6) - roles: GET, returns { roles: RoomRoles[] } (schema includes runtime _id the type omits). - moderators: GET, returns { moderators: sub.u[] }; 403 for canAccessRoom. - delete: POST, void. --- apps/meteor/server/api/v1/channels.ts | 103 ++++++++++++++++++++++---- 1 file changed, 88 insertions(+), 15 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index 64cce6bc0d885..0b94774f953ec 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -311,14 +311,49 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +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.get( 'channels.roles', { authRequired: true, - validateParams: isChannelsRolesProps, + query: isChannelsRolesProps, + response: { + 200: rolesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async get() { + async function action() { + try { const findResult = await findChannelByIdOrName({ params: this.queryParams }); const roles = await executeGetRoomRoles(findResult._id, this.user); @@ -326,7 +361,10 @@ API.v1.addRoute( return API.v1.success({ roles, }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); @@ -624,14 +662,38 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +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.get( 'channels.moderators', { authRequired: true, - validateParams: isChannelsModeratorsProps, + query: isChannelsModeratorsProps, + response: { + 200: moderatorsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async get() { + async function action() { + try { const { ...params } = this.queryParams; const findResult = await findChannelByIdOrName({ params }); @@ -649,18 +711,26 @@ API.v1.addRoute( return API.v1.success({ moderators, }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); -API.v1.addRoute( +API.v1.post( 'channels.delete', { authRequired: true, - validateParams: isChannelsDeleteProps, + body: isChannelsDeleteProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { + async function action() { + try { const room = await findChannelByIdOrName({ params: this.bodyParams, checkedArchived: false, @@ -669,7 +739,10 @@ API.v1.addRoute( await eraseRoom(room._id, this.user); return API.v1.success(); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); From 76db06f69cd64bb8b68bb9e2d5ea863234be73db Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 15:54:06 -0300 Subject: [PATCH 10/30] chore(api): migrate channels close + counters (batch 7) - close: POST void (roomTargetBody helper added). - counters: GET, nullable numeric/date fields, userId query param, 403. --- apps/meteor/server/api/v1/channels.ts | 84 ++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 8 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index 0b94774f953ec..1dec78384f701 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -165,6 +165,18 @@ const roomSettingBody = (field: string, fieldSchema: Record) 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', @@ -853,11 +865,19 @@ API.v1.post( }, ); -API.v1.addRoute( +API.v1.post( 'channels.close', - { authRequired: true }, { - async post() { + authRequired: true, + body: roomTargetBody<{ roomId?: string; roomName?: string }>(), + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + try { const findResult = await findChannelByIdOrName({ params: this.bodyParams, checkedArchived: false, @@ -876,15 +896,60 @@ API.v1.addRoute( await hideRoomMethod(this.userId, findResult._id); return API.v1.success(); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); -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() { + 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() { + try { const access = await hasPermissionAsync(this.user, 'view-room-administration'); const { userId } = this.queryParams; let user = this.userId; @@ -930,7 +995,10 @@ API.v1.addRoute( latest, userMentions, }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); From 62cb88da55a634763bc0ec9899e704abbc95ae59 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 16:10:43 -0300 Subject: [PATCH 11/30] chore(api): migrate channels members/online (batch 8) Co-Authored-By: Claude Opus 4.8 --- apps/meteor/server/api/v1/channels.ts | 103 +++++++++++++++++++++----- 1 file changed, 86 insertions(+), 17 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index 1dec78384f701..34e59c786b9bf 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -36,7 +36,6 @@ import { validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { isTruthy } from '@rocket.chat/tools'; -import { check, Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import type { Filter } from 'mongodb'; @@ -1436,11 +1435,56 @@ API.v1.addRoute( }, ); -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, +}); + +const channelsMembersResponseSchema = ajv.compile<{ members: IUser[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + members: { type: 'array', items: { $ref: '#/components/schemas/IUser' } }, + 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() { + authRequired: true, + query: channelsMembersQuery, + response: { + 200: channelsMembersResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + try { const findResult = await findChannelByIdOrName({ params: this.queryParams, checkedArchived: false, @@ -1457,13 +1501,6 @@ API.v1.addRoute( 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({ @@ -1483,15 +1520,44 @@ API.v1.addRoute( offset: skip, total, }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); -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() { + authRequired: true, + query: isChannelsOnlineProps, + response: { + 200: channelsOnlineResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + try { const { query } = await this.parseJsonQuery(); const { _id } = this.queryParams; @@ -1533,9 +1599,12 @@ API.v1.addRoute( ); return API.v1.success({ - online: onlineInRoom.filter(Boolean) as IUser[], + online: onlineInRoom.filter(isTruthy), }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); From f8df36983ac0da3c38cf08fb210b0eada726f350 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 16:16:12 -0300 Subject: [PATCH 12/30] chore(api): migrate channels getIntegrations/list.joined/setJoinCode (batch 9) Co-Authored-By: Claude Opus 4.8 --- apps/meteor/server/api/v1/channels.ts | 99 +++++++++++++++++++++++---- 1 file changed, 87 insertions(+), 12 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index 34e59c786b9bf..1007df6f65e94 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -1185,10 +1185,54 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +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: { $ref: '#/components/schemas/IIntegration' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['integrations', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'channels.getIntegrations', { authRequired: true, + query: channelsGetIntegrationsQuery, permissionsRequired: { GET: { permissions: [ @@ -1200,9 +1244,15 @@ API.v1.addRoute( operation: 'hasAny', }, }, + response: { + 200: channelsGetIntegrationsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async get() { + async function action() { + try { const findResult = await findChannelByIdOrName({ params: this.queryParams, checkedArchived: false, @@ -1248,7 +1298,10 @@ API.v1.addRoute( offset, total, }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); @@ -1396,11 +1449,19 @@ API.v1.get( }, ); -API.v1.addRoute( +API.v1.get( 'channels.list.joined', - { authRequired: true }, { - async get() { + authRequired: true, + query: isChannelsListProps, + response: { + 200: channelsListResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + try { const { offset, count } = await getPaginationItems(this.queryParams); const { sort, fields } = await this.parseJsonQuery(); @@ -1431,7 +1492,10 @@ API.v1.addRoute( count: channels.length, total, }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); @@ -1976,11 +2040,19 @@ API.v1.post( }, ); -API.v1.addRoute( +API.v1.post( 'channels.setJoinCode', - { authRequired: true }, { - async post() { + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; joinCode: string }>('joinCode', { type: 'string' }), + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + try { if (!this.bodyParams.joinCode?.trim()) { return API.v1.failure('The bodyParam "joinCode" is required'); } @@ -1992,7 +2064,10 @@ API.v1.addRoute( return API.v1.success({ channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); From e5e60f23b4972f9e92b56fec250e2cbe43a8636a Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 16:56:31 -0300 Subject: [PATCH 13/30] chore(api): migrate channels.messages (batch 10) Uses $ref IMessage response schema (matches chat.ts). Single-endpoint batch to surface any attachment-oneOf response-validation drift in CI early. Co-Authored-By: Claude Opus 4.8 --- apps/meteor/server/api/v1/channels.ts | 34 +++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index 1007df6f65e94..ae8d00a292d22 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -2,6 +2,8 @@ import { Team, Room } from '@rocket.chat/core-services'; import { TeamType, isRoomNativeFederated, + type IIntegration, + type IMessage, type IRoom, type ISubscription, type IUser, @@ -472,15 +474,34 @@ API.v1.post( }, ); -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'], + response: { + 200: channelsMessagesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async get() { + async function action() { + try { const { roomId, mentionIds, starredIds, pinned } = this.queryParams; const { offset, count } = await getPaginationItems(this.queryParams); const { sort, fields, query } = await this.parseJsonQuery(); @@ -529,7 +550,10 @@ API.v1.addRoute( offset, total, }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); From 55ef8ce9819f042a3bc14263a3a9aab099b75c86 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 16:59:27 -0300 Subject: [PATCH 14/30] chore(api): migrate channels.anonymousread (batch 11) Co-Authored-By: Claude Opus 4.8 --- apps/meteor/server/api/v1/channels.ts | 44 ++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index ae8d00a292d22..1fb3bde590abd 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -35,6 +35,7 @@ import { ajvQuery, validateBadRequestErrorResponse, validateForbiddenErrorResponse, + validateNotFoundErrorResponse, validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { isTruthy } from '@rocket.chat/tools'; @@ -2095,11 +2096,43 @@ API.v1.post( }, ); -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() { + authOrAnonRequired: true, + query: channelsAnonymousReadQuery, + response: { + 200: channelsMessagesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 404: validateNotFoundErrorResponse, + }, + }, + async function action() { + try { const findResult = await findChannelByIdOrName({ params: this.queryParams, checkedArchived: false, @@ -2140,6 +2173,9 @@ API.v1.addRoute( offset, total, }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); From ed973c4b5f2fb0243c3faa74c69a28cdd2c90845 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 17:29:35 -0300 Subject: [PATCH 15/30] chore(api): migrate channels history/getAllUserMentionsByChannel (batch 12) Fixes rest-typings return-type drift: channels.history returns {messages, firstUnread?, unreadNotLoaded?} (not PaginatedResult), and getAllUserMentionsByChannel returns IMessage[] (getUserMentionsByChannel result), not IUser[]. Also corrects ChannelsMessagesProps.pinned to string (matches the ajv schema and query-string runtime value). Co-Authored-By: Claude Opus 4.8 --- apps/meteor/server/api/v1/channels.ts | 66 +++++++++++++++---- .../src/v1/channels/ChannelsMessagesProps.ts | 2 +- .../rest-typings/src/v1/channels/channels.ts | 8 ++- 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index 1fb3bde590abd..1c09301c90008 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -288,14 +288,32 @@ API.v1.post( }, ); -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() { + async function action() { + try { const { unreads, oldest, latest, showThreadMessages, inclusive, ...params } = this.queryParams; const findResult = await findChannelByIdOrName({ params, @@ -316,12 +334,15 @@ API.v1.addRoute( showThreadMessages: showThreadMessages === 'true', }); - if (!result) { + if (!result || typeof result !== 'object' || Array.isArray(result)) { return API.v1.forbidden(); } return API.v1.success(result); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); @@ -520,7 +541,7 @@ API.v1.get( rid: findResult._id, ...parseIds(mentionIds, 'mentions._id'), ...parseIds(starredIds, 'starred._id'), - ...(pinned?.toLowerCase() === 'true' ? { pinned: true } : {}), + ...(String(pinned).toLowerCase() === 'true' ? { pinned: true } : {}), _hidden: { $ne: true }, }; @@ -668,14 +689,32 @@ API.v1.post( }, ); -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() { + async function action() { + try { const { roomId } = this.queryParams; const { offset, count } = await getPaginationItems(this.queryParams); const { sort } = await this.parseJsonQuery(); @@ -694,7 +733,10 @@ API.v1.addRoute( offset, total: allMentions.length, }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); 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': { From 329f264591b6662d0e50b8a6090d5463cdacc6b7 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 18:02:56 -0300 Subject: [PATCH 16/30] chore(api): migrate channels.create (batch 13) Completes ChannelsCreateProps schema to match the handler: adds customFields and excludeSelf, and fixes the miscased readonly->readOnly property (the old schema would have 400'd valid create requests carrying customFields once enforced). Co-Authored-By: Claude Opus 4.8 --- apps/meteor/server/api/v1/channels.ts | 91 +++++++++++-------- .../src/v1/channels/ChannelsCreateProps.ts | 12 ++- 2 files changed, 62 insertions(+), 41 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index 1c09301c90008..7930cb2837492 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -26,6 +26,7 @@ import { isChannelsGetAllUserMentionsByChannelProps, isChannelsModeratorsProps, isChannelsConvertToTeamProps, + isChannelsCreateProps, isChannelsSetReadOnlyProps, isChannelsDeleteProps, isChannelsListProps, @@ -1137,49 +1138,51 @@ 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', - }, - }); - } catch (e: any) { - if (e.message === 'unauthorized') { - error = API.v1.forbidden(); - } else { - error = API.v1.failure(e.message); - } - } + authRequired: true, + body: isChannelsCreateProps, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { userId, bodyParams } = this; - if (error) { - return 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', + }, + }); + } catch (e: any) { + if (e.message === 'unauthorized') { + return API.v1.forbidden(); } + return API.v1.failure(e.message); + } + try { if (bodyParams.teams) { const canSeeAllTeams = await hasPermissionAsync(this.user, 'view-all-teams'); const teams = await Team.listByNames(bodyParams.teams, { projection: { _id: 1 } }); @@ -1198,8 +1201,16 @@ API.v1.addRoute( bodyParams.members = [...membersToAdd].filter(Boolean) as string[]; } - return API.v1.success(await API.channels?.create.execute(userId, bodyParams)); - }, + const result = await API.channels?.create.execute(userId, bodyParams); + if (!result) { + return API.v1.failure('Failed to create channel'); + } + + return API.v1.success(result); + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); diff --git a/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts b/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts index c012a6aca96a8..8308fd4782f1f 100644 --- a/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts +++ b/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts @@ -5,6 +5,7 @@ export type ChannelsCreateProps = { members?: string[]; teams?: string[]; readOnly?: boolean; + customFields?: Record; extraData?: { broadcast?: boolean; encrypted?: boolean; @@ -25,8 +26,17 @@ const channelsCreatePropsSchema = { teams: { type: 'array', }, - readonly: { + readOnly: { type: 'boolean', + nullable: true, + }, + customFields: { + type: 'object', + nullable: true, + }, + excludeSelf: { + type: 'boolean', + nullable: true, }, extraData: { type: 'object', From 37d8a6dd35e8a343fcdef63008ac56af64141543 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 18:30:09 -0300 Subject: [PATCH 17/30] chore(api): migrate channels.invite (batch 14) Inline body validator (isChannelsInviteProps is not exported from the rest-typings channels value barrel). Co-Authored-By: Claude Opus 4.8 --- apps/meteor/server/api/v1/channels.ts | 34 +++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index 7930cb2837492..f9200390c7e19 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -1417,11 +1417,34 @@ API.v1.get( }, ); -API.v1.addRoute( +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.post( 'channels.invite', - { authRequired: true }, { - async post() { + authRequired: true, + body: channelsInviteBody, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + try { const findResult = await findChannelByIdOrName({ params: this.bodyParams }); // Federated rooms invite by raw username: the federated user record is created @@ -1447,7 +1470,10 @@ API.v1.addRoute( return API.v1.success({ channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); From dc59c5796489fa6e9df95479c51fede2be93561f Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 18:55:48 -0300 Subject: [PATCH 18/30] =?UTF-8?q?chore(api):=20migrate=20channels=20files/?= =?UTF-8?q?convertToTeam=20(batch=2015)=20=E2=80=94=20channels=2041/41?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers ITeam and IUploadWithUser in the core-typings typia schemas so their response bodies can be validated via $ref (channels.convertToTeam -> {team: ITeam}, channels.files -> IUploadWithUser[]). Completes the channels.ts addRoute -> typed API.v1 migration. Co-Authored-By: Claude Opus 4.8 --- apps/meteor/server/api/v1/channels.ts | 64 +++++++++++++++++++++++---- packages/core-typings/src/Ajv.ts | 4 ++ 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index f9200390c7e19..93f646483019e 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -6,6 +6,8 @@ import { type IMessage, type IRoom, type ISubscription, + type ITeam, + type IUploadWithUser, type IUser, type RoomType, type UserStatus, @@ -825,15 +827,31 @@ API.v1.post( }, ); -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() { + async function action() { + try { const { channelId, channelName } = this.bodyParams; if (!channelId && !channelName) { @@ -874,7 +892,10 @@ API.v1.addRoute( const team = await Team.create(this.userId, teamData); return API.v1.success({ team }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); @@ -1214,11 +1235,33 @@ API.v1.post( }, ); -API.v1.addRoute( +const channelsFilesResponseSchema = ajv.compile<{ files: IUploadWithUser[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + files: { type: 'array', items: { $ref: '#/components/schemas/IUploadWithUser' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['files', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'channels.files', - { authRequired: true, validateParams: isChannelsFilesListProps }, { - async get() { + authRequired: true, + query: isChannelsFilesListProps, + response: { + 200: channelsFilesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + try { const { typeGroup, name, roomId, roomName, onlyConfirmed } = this.queryParams; const findResult = await findChannelByIdOrName({ @@ -1259,7 +1302,10 @@ API.v1.addRoute( offset, total, }); - }, + } catch (error) { + const [message, errorType] = errorToFailureArgs(error); + return API.v1.failure(message, errorType); + } }, ); 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, From 784a83d83a14e215042ee3ea9b80e0b03e873d99 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 23:55:47 -0300 Subject: [PATCH 19/30] fix(api): resolve channels.getIntegrations boot crash (missing IIntegration schema) IIntegration is a union alias (IIncomingIntegration | IOutgoingIntegration); typia emits the members as component schemas but no combined `IIntegration` key, so `$ref: #/components/schemas/IIntegration` was unresolvable and threw MissingRefError at server boot, failing every API/UI test. Reference the two concrete members via anyOf instead. Co-Authored-By: Claude Opus 4.8 --- apps/meteor/server/api/v1/channels.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index 93f646483019e..c18724c4aa773 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -1342,7 +1342,12 @@ const channelsGetIntegrationsResponseSchema = ajv.compile<{ }>({ type: 'object', properties: { - integrations: { type: 'array', items: { $ref: '#/components/schemas/IIntegration' } }, + integrations: { + type: 'array', + items: { + anyOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }], + }, + }, count: { type: 'number' }, offset: { type: 'number' }, total: { type: 'number' }, From 614e24ea7751e1cdfe64df38cd4deadce9f6df8f Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 17 Jul 2026 15:09:52 -0300 Subject: [PATCH 20/30] fix(api): channels response schemas reject real data (members/files/invite) TEST_MODE response validation failed on real payloads because the typia $ref schemas are stricter than actual API responses: - channels.members returns a fixed findUsersOfRoom projection, not a full IUser (no createdAt/roles/type/active) -> validate the projected shape. - channels.files stores content: null for non-e2ee uploads and accepts a client `fields` projection, so items are partial -> open item schema. - IRoom.announcementDetails is null in Mongo but typia optional rejects null; made it nullable so channelResponseSchema ($ref IRoom) accepts channels carrying it (was failing channels.invite and latent on every channel POST). Also: errorToFailureArgs now prefers Meteor.Error.reason so the [error-code] suffix stops leaking into the response error field (CodeRabbit). Co-Authored-By: Claude Opus 4.8 --- apps/meteor/server/api/v1/channels.ts | 46 ++++++++++++++++++++++++--- packages/core-typings/src/IRoom.ts | 2 +- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index c18724c4aa773..af028f621fc8d 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -138,8 +138,16 @@ const successResponseSchema = ajv.compile({ // errors to 400 (the legacy addRoute wrapper did), so handlers catch and return a failure. Errors coming from // core-services cross a boundary and are not `instanceof Meteor.Error`, so extract message/errorType by shape. function errorToFailureArgs(error: unknown): [string, string | undefined] { - const e = error as { message?: unknown; error?: unknown }; - return [typeof e?.message === 'string' ? e.message : String(error), typeof e?.error === 'string' ? e.error : undefined]; + const e = error as { reason?: unknown; message?: unknown; error?: unknown }; + // Prefer `reason` so the `[error-code]` suffix that Meteor.Error appends to `message` does not leak to clients. + let message = String(error); + if (typeof e?.message === 'string') { + message = e.message; + } + if (typeof e?.reason === 'string') { + message = e.reason; + } + return [message, typeof e?.error === 'string' ? e.error : undefined]; } const stringFieldResponseSchema = (field: 'description' | 'purpose' | 'topic') => @@ -1235,10 +1243,21 @@ API.v1.post( }, ); +// 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: { $ref: '#/components/schemas/IUploadWithUser' } }, + files: { + type: 'array', + items: { + type: 'object', + properties: { _id: { type: 'string' } }, + required: ['_id'], + additionalProperties: true, + }, + }, count: { type: 'number' }, offset: { type: 'number' }, total: { type: 'number' }, @@ -1677,10 +1696,29 @@ const channelsMembersQuery = ajvQuery.compile<{ 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: { $ref: '#/components/schemas/IUser' } }, + 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: false, + }, + }, count: { type: 'number' }, offset: { type: 'number' }, total: { type: 'number' }, diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 9c39723b39351..bf3d66943ffbf 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -21,7 +21,7 @@ export interface IRoom extends IRocketChatRecord { 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[]; From 32324939918af35e6fc78831f56a2f8f1aff10af Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 17 Jul 2026 15:12:20 -0300 Subject: [PATCH 21/30] fix(api): channels.list.joined 400s on ignored roomId query param isChannelsListProps (additionalProperties:false, no roomId) rejected the roomId that existing clients/tests pass to list.joined even though the endpoint ignores it. Use a tolerant inline query that accepts and ignores roomId/roomName. Also loosen members item validation (additionalProperties) to avoid rejecting unforeseen projected fields. Co-Authored-By: Claude Opus 4.8 --- apps/meteor/server/api/v1/channels.ts | 29 +++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index af028f621fc8d..f081a917e5fd9 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -1623,11 +1623,36 @@ API.v1.get( }, ); +// 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, - query: isChannelsListProps, + query: channelsListJoinedQuery, response: { 200: channelsListResponseSchema, 400: validateBadRequestErrorResponse, @@ -1716,7 +1741,7 @@ const channelsMembersResponseSchema = ajv.compile<{ members: IUser[]; count: num _updatedAt: { type: 'string' }, }, required: ['_id'], - additionalProperties: false, + additionalProperties: true, }, }, count: { type: 'number' }, From cbbd1c9eb253064963f6da8cc3f5dd98012600ad Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 17 Jul 2026 16:03:51 -0300 Subject: [PATCH 22/30] fix(api): keep [error-code] suffix in channels error messages Revert errorToFailureArgs to use Meteor.Error.message (with the [error-code] suffix) instead of .reason. Existing e2e tests assert the code is present in the response `error` field (channels dup-name, teams.delete room-not-found), matching legacy addRoute behavior. The earlier .reason change (CodeRabbit suggestion) is incompatible with that contract. Co-Authored-By: Claude Opus 4.8 --- apps/meteor/server/api/v1/channels.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index f081a917e5fd9..f59fb2b0cacd1 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -138,16 +138,8 @@ const successResponseSchema = ajv.compile({ // errors to 400 (the legacy addRoute wrapper did), so handlers catch and return a failure. Errors coming from // core-services cross a boundary and are not `instanceof Meteor.Error`, so extract message/errorType by shape. function errorToFailureArgs(error: unknown): [string, string | undefined] { - const e = error as { reason?: unknown; message?: unknown; error?: unknown }; - // Prefer `reason` so the `[error-code]` suffix that Meteor.Error appends to `message` does not leak to clients. - let message = String(error); - if (typeof e?.message === 'string') { - message = e.message; - } - if (typeof e?.reason === 'string') { - message = e.reason; - } - return [message, typeof e?.error === 'string' ? e.error : undefined]; + const e = error as { message?: unknown; error?: unknown }; + return [typeof e?.message === 'string' ? e.message : String(error), typeof e?.error === 'string' ? e.error : undefined]; } const stringFieldResponseSchema = (field: 'description' | 'purpose' | 'topic') => From 1675ed13687a615cd8306d11702117a7c2ebeb6f Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 17 Jul 2026 21:27:13 -0300 Subject: [PATCH 23/30] fix(api): channels.create extraData + channels.info livechat rooms - create: the create modal sends extraData.{topic,federated} (spread verbatim into createRoom), but the schema's extraData was additionalProperties:false without those keys, so UI channel creation 400'd and hung. Allow arbitrary extraData (add topic/federated, additionalProperties:true). - info: channels.info also serves omnichannel ('l') rooms (livechat test helpers call it) which lack the owner `u` that IRoom requires. Use a dedicated schema accepting a full IRoom or any room-shaped object. Co-Authored-By: Claude Opus 4.8 --- apps/meteor/server/api/v1/channels.ts | 16 +++++++++++++++- .../src/v1/channels/ChannelsCreateProps.ts | 14 ++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index f59fb2b0cacd1..8c7dfe4f06f88 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -127,6 +127,20 @@ const channelResponseSchema = ajv.compile<{ channel: IRoom }>({ 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] } }, @@ -1451,7 +1465,7 @@ API.v1.get( authRequired: true, query: roomTargetQuery, response: { - 200: channelResponseSchema, + 200: channelInfoResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, 403: validateForbiddenErrorResponse, diff --git a/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts b/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts index 8308fd4782f1f..e473ceebf3bc1 100644 --- a/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts +++ b/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts @@ -10,7 +10,9 @@ export type ChannelsCreateProps = { broadcast?: boolean; encrypted?: boolean; teamId?: string; - }; + topic?: string; + federated?: boolean; + } & Record; excludeSelf?: boolean; }; @@ -39,6 +41,8 @@ const channelsCreatePropsSchema = { nullable: true, }, extraData: { + // extraData is spread verbatim into createRoom, so it carries arbitrary room fields + // (the create modal sends topic/broadcast/encrypted/federated). Keep it open. type: 'object', properties: { broadcast: { @@ -50,8 +54,14 @@ const channelsCreatePropsSchema = { teamId: { type: 'string', }, + topic: { + type: 'string', + }, + federated: { + type: 'boolean', + }, }, - additionalProperties: false, + additionalProperties: true, nullable: true, }, }, From f43a0ca51fbc57b54f4c5a88463c9e4d7e7089b6 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 17 Jul 2026 22:47:07 -0300 Subject: [PATCH 24/30] fix(types): IRoom.broadcast is boolean, not literal true The create modal sends broadcast:false, which createRoom stores verbatim, so rooms genuinely carry broadcast:false. IRoom typed it as `true`, making typia emit enum:[true], which failed response validation (400) on channels.create and hung UI channel/team-channel creation. Widen to boolean. Co-Authored-By: Claude Opus 4.8 --- packages/core-typings/src/IRoom.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index bf3d66943ffbf..6a47c812099e5 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -15,7 +15,7 @@ export interface IRoom extends IRocketChatRecord { fname?: string; msgs: number; default?: boolean; - broadcast?: true; + broadcast?: boolean; featured?: true; announcement?: string; joinCodeRequired?: boolean; From de6e2a09ded4ab8a7ea67520eeb9a3d03dde5811 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Sat, 18 Jul 2026 00:47:46 -0300 Subject: [PATCH 25/30] fix(client): narrow room.broadcast when copying to subscription store IRoom.broadcast widened to boolean, but ISubscription.broadcast is `true | undefined`. Coerce with `|| undefined` (false -> undefined) at the two cached store copy sites so the assignment stays `true | undefined` without widening the subscription type. Co-Authored-By: Claude Opus 4.8 --- apps/meteor/client/cachedStores/RoomsCachedStore.ts | 2 +- apps/meteor/client/cachedStores/SubscriptionsCachedStore.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 Date: Tue, 28 Jul 2026 16:55:23 -0300 Subject: [PATCH 26/30] fix(api): address channels.ts migration review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - channels.create: close extraData (additionalProperties: false) so callers can't inject arbitrary room fields (default/featured/retention/abacAttributes); the create modal only sends the declared fields (mass-assignment fix). - channels.getIntegrations / channels.files: apply the user-supplied query first and overlay the trusted permission/rid filters last so a crafted query can't override the ownership scope or room id. Correctness: - errorToFailureArgs now also forwards stack (TEST_MODE) and details, and each catch passes them to API.v1.failure — restores the legacy addRoute error body. - channels.create: type members/teams items as strings; drop stray nullable on readOnly/customFields/excludeSelf/extraData (contract now matches the type). - Remove dead 'bodyParam X is required' branches in set{Description,Purpose, Topic} — the body schema already marks the field required. Left as follow-up (pre-existing, not this migration): raw error-message disclosure in errorToFailureArgs for non-Meteor errors, and the anyOf vs oneOf integrations response shape (anyOf validates correctly; oneOf risks overlap). --- apps/meteor/server/api/v1/channels.ts | 193 +++++++++--------- .../src/v1/channels/ChannelsCreateProps.ts | 15 +- 2 files changed, 101 insertions(+), 107 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index 8c7dfe4f06f88..bfb860fb8fedb 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -151,9 +151,14 @@ const successResponseSchema = ajv.compile({ // findChannelByIdOrName and the room methods throw for client errors. The typed router does not map thrown // errors to 400 (the legacy addRoute wrapper did), so handlers catch and return a failure. Errors coming from // core-services cross a boundary and are not `instanceof Meteor.Error`, so extract message/errorType by shape. -function errorToFailureArgs(error: unknown): [string, string | undefined] { - const e = error as { message?: unknown; error?: unknown }; - return [typeof e?.message === 'string' ? e.message : String(error), typeof e?.error === 'string' ? e.error : undefined]; +function errorToFailureArgs(error: unknown): [string, string | undefined, string | undefined, unknown] { + const e = error as { message?: unknown; error?: unknown; stack?: unknown; details?: unknown }; + return [ + typeof e?.message === 'string' ? e.message : String(error), + typeof e?.error === 'string' ? e.error : undefined, + process.env.TEST_MODE && typeof e?.stack === 'string' ? e.stack : undefined, + e?.details, + ]; } const stringFieldResponseSchema = (field: 'description' | 'purpose' | 'topic') => @@ -242,8 +247,8 @@ API.v1.post( channel: await findChannelByIdOrName({ params, userId: this.userId }), }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -267,8 +272,8 @@ API.v1.post( return API.v1.success(); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -299,8 +304,8 @@ API.v1.post( return API.v1.success(); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -357,8 +362,8 @@ API.v1.get( return API.v1.success(result); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -414,8 +419,8 @@ API.v1.get( roles, }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -442,8 +447,8 @@ API.v1.post( channel: await findChannelByIdOrName({ params, userId: this.userId }), }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -475,8 +480,8 @@ API.v1.post( channel: await findChannelByIdOrName({ params, userId: this.userId }), }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -507,8 +512,8 @@ API.v1.post( channel: await findChannelByIdOrName({ params, userId: this.userId }), }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -590,8 +595,8 @@ API.v1.get( total, }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -630,8 +635,8 @@ API.v1.post( return API.v1.success(); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -661,8 +666,8 @@ API.v1.post( channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -700,8 +705,8 @@ API.v1.post( announcement: this.bodyParams.announcement, }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -751,8 +756,8 @@ API.v1.get( total: allMentions.length, }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -807,8 +812,8 @@ API.v1.get( moderators, }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -835,8 +840,8 @@ API.v1.post( return API.v1.success(); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -907,8 +912,8 @@ API.v1.post( return API.v1.success({ team }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -934,8 +939,8 @@ API.v1.post( return API.v1.success(); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -961,8 +966,8 @@ API.v1.post( return API.v1.success(); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -999,8 +1004,8 @@ API.v1.post( return API.v1.success(); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -1098,8 +1103,8 @@ API.v1.get( userMentions, }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -1243,8 +1248,8 @@ API.v1.post( return API.v1.success(result); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -1305,8 +1310,8 @@ API.v1.get( const { sort, fields, query } = await this.parseJsonQuery(); const filter = { - rid: findResult._id, ...query, + rid: findResult._id, ...(name ? { name: { $regex: name || '', $options: 'i' } } : {}), ...(typeGroup ? { typeGroup } : {}), ...(onlyConfirmed && { expiresAt: { $exists: false } }), @@ -1328,8 +1333,8 @@ API.v1.get( total, }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -1435,7 +1440,9 @@ API.v1.get( const { offset, count } = await getPaginationItems(params); const { sort, fields: projection, query } = await this.parseJsonQuery(); - ourQuery = Object.assign(await mountIntegrationQueryBasedOnPermissions(this.userId), query, ourQuery); + // 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 }, @@ -1453,8 +1460,8 @@ API.v1.get( total, }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -1487,8 +1494,8 @@ API.v1.get( channel: findResult, }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -1547,8 +1554,8 @@ API.v1.post( channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -1698,8 +1705,8 @@ API.v1.get( total, }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -1809,8 +1816,8 @@ API.v1.get( total, }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -1890,8 +1897,8 @@ API.v1.get( online: onlineInRoom.filter(isTruthy), }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -1917,8 +1924,8 @@ API.v1.post( return API.v1.success(); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -1944,8 +1951,8 @@ API.v1.post( return API.v1.success(); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -1982,8 +1989,8 @@ API.v1.post( }), }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -2015,8 +2022,8 @@ API.v1.post( channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -2058,8 +2065,8 @@ API.v1.post( channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -2077,10 +2084,6 @@ API.v1.post( }, async function action() { try { - if (!this.bodyParams.hasOwnProperty('description')) { - return API.v1.failure('The bodyParam "description" is required'); - } - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); if (findResult.description === this.bodyParams.description) { @@ -2093,8 +2096,8 @@ API.v1.post( description: this.bodyParams.description || '', }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -2112,10 +2115,6 @@ API.v1.post( }, async function action() { try { - if (!this.bodyParams.hasOwnProperty('purpose')) { - return API.v1.failure('The bodyParam "purpose" is required'); - } - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); if (findResult.description === this.bodyParams.purpose) { @@ -2128,8 +2127,8 @@ API.v1.post( purpose: this.bodyParams.purpose || '', }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -2147,10 +2146,6 @@ API.v1.post( }, async function action() { try { - if (!this.bodyParams.hasOwnProperty('topic')) { - return API.v1.failure('The bodyParam "topic" is required'); - } - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); if (findResult.topic === this.bodyParams.topic) { @@ -2163,8 +2158,8 @@ API.v1.post( topic: this.bodyParams.topic || '', }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -2204,8 +2199,8 @@ API.v1.post( channel: await composeRoomWithLastMessage(room, this.userId), }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -2231,8 +2226,8 @@ API.v1.post( return API.v1.success(); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -2258,8 +2253,8 @@ API.v1.post( return API.v1.success(); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -2289,8 +2284,8 @@ API.v1.post( channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); @@ -2373,8 +2368,8 @@ API.v1.get( total, }); } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); + const [message, errorType, stack, details] = errorToFailureArgs(error); + return API.v1.failure(message, errorType, stack, details); } }, ); diff --git a/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts b/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts index e473ceebf3bc1..aabaff2ce32ef 100644 --- a/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts +++ b/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts @@ -12,7 +12,7 @@ export type ChannelsCreateProps = { teamId?: string; topic?: string; federated?: boolean; - } & Record; + }; excludeSelf?: boolean; }; @@ -24,25 +24,25 @@ const channelsCreatePropsSchema = { }, members: { type: 'array', + items: { type: 'string' }, }, teams: { type: 'array', + items: { type: 'string' }, }, readOnly: { type: 'boolean', - nullable: true, }, customFields: { type: 'object', - nullable: true, }, excludeSelf: { type: 'boolean', - nullable: true, }, extraData: { - // extraData is spread verbatim into createRoom, so it carries arbitrary room fields - // (the create modal sends topic/broadcast/encrypted/federated). Keep it open. + // 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: { @@ -61,8 +61,7 @@ const channelsCreatePropsSchema = { type: 'boolean', }, }, - additionalProperties: true, - nullable: true, + additionalProperties: false, }, }, required: ['name'], From 596e008ec7735b25353de9c6c0a7fbb6b2c01206 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 28 Jul 2026 17:52:25 -0300 Subject: [PATCH 27/30] refactor(api): drop channels errorToFailureArgs; rely on the global ApiClass error wrapper The global ApiClass wrapper (_internalRouteActionHandler) already catches thrown Meteor.Errors and maps them: error-too-many-requests->429, unauthorized->401/403, forbidden->403/400, default->api.failure(...)=400. The per-handler errorToFailureArgs catch-all was therefore redundant, and worse, it flattened 401/403/429 responses into 400. Removed the helper and all 41 per-handler try/catch wrappers so handlers just throw and let the wrapper map the status. Kept the channels.create validator's custom catch (unauthorized->forbidden), which is not the errorToFailureArgs pattern. Per review. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/meteor/server/api/v1/channels.ts | 1440 +++++++++++-------------- 1 file changed, 611 insertions(+), 829 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index bfb860fb8fedb..153103b862b42 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -148,19 +148,6 @@ const successResponseSchema = ajv.compile({ additionalProperties: false, }); -// findChannelByIdOrName and the room methods throw for client errors. The typed router does not map thrown -// errors to 400 (the legacy addRoute wrapper did), so handlers catch and return a failure. Errors coming from -// core-services cross a boundary and are not `instanceof Meteor.Error`, so extract message/errorType by shape. -function errorToFailureArgs(error: unknown): [string, string | undefined, string | undefined, unknown] { - const e = error as { message?: unknown; error?: unknown; stack?: unknown; details?: unknown }; - return [ - typeof e?.message === 'string' ? e.message : String(error), - typeof e?.error === 'string' ? e.error : undefined, - process.env.TEST_MODE && typeof e?.stack === 'string' ? e.stack : undefined, - e?.details, - ]; -} - const stringFieldResponseSchema = (field: 'description' | 'purpose' | 'topic') => ajv.compile({ type: 'object', @@ -237,19 +224,14 @@ API.v1.post( }, }, async function action() { - try { - const { activeUsersOnly, ...params } = this.bodyParams; - const findResult = await findChannelByIdOrName({ params, userId: this.userId }); + 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 }), - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ + channel: await findChannelByIdOrName({ params, userId: this.userId }), + }); }, ); @@ -265,16 +247,11 @@ API.v1.post( }, }, async function action() { - try { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - await executeArchiveRoom(this.userId, findResult._id); + await executeArchiveRoom(this.userId, findResult._id); - return API.v1.success(); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success(); }, ); @@ -290,23 +267,18 @@ API.v1.post( }, }, async function action() { - try { - const findResult = await findChannelByIdOrName({ - params: this.bodyParams, - checkedArchived: false, - }); + 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(); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success(); }, ); @@ -335,36 +307,31 @@ API.v1.get( }, }, async function action() { - try { - 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 { unreads, oldest, latest, showThreadMessages, inclusive, ...params } = this.queryParams; + const findResult = await findChannelByIdOrName({ + params, + checkedArchived: false, + }); - 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', - }); + const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams); - if (!result || typeof result !== 'object' || Array.isArray(result)) { - return API.v1.forbidden(); - } + 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', + }); - return API.v1.success(result); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); + if (!result || typeof result !== 'object' || Array.isArray(result)) { + return API.v1.forbidden(); } + + return API.v1.success(result); }, ); @@ -410,18 +377,13 @@ API.v1.get( }, }, async function action() { - try { - const findResult = await findChannelByIdOrName({ params: this.queryParams }); + 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, - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ + roles, + }); }, ); @@ -437,19 +399,14 @@ API.v1.post( }, }, async function action() { - try { - const { joinCode, ...params } = this.bodyParams; - const findResult = await findChannelByIdOrName({ params }); + 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 }), - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ + channel: await findChannelByIdOrName({ params, userId: this.userId }), + }); }, ); @@ -465,24 +422,19 @@ API.v1.post( }, }, async function action() { - try { - const { ...params /* userId */ } = this.bodyParams; - const findResult = await findChannelByIdOrName({ params }); + 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 }), - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ + channel: await findChannelByIdOrName({ params, userId: this.userId }), + }); }, ); @@ -498,23 +450,18 @@ API.v1.post( }, }, async function action() { - try { - const { ...params } = this.bodyParams; - const findResult = await findChannelByIdOrName({ params }); + 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 }), - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); + 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 }), + }); }, ); @@ -545,59 +492,54 @@ API.v1.get( }, }, async function action() { - try { - const { roomId, mentionIds, starredIds, pinned } = this.queryParams; - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); + 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 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 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 }, - }; + 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(); - } + 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(); - } + // 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 { cursor, totalCount } = Messages.findPaginated(ourQuery, { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + projection: fields, + }); - const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); - return API.v1.success({ - messages: await normalizeMessagesForUser(messages, this.userId), - count: messages.length, - offset, - total, - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ + messages: await normalizeMessagesForUser(messages, this.userId), + count: messages.length, + offset, + total, + }); }, ); @@ -613,31 +555,26 @@ API.v1.post( }, }, async function action() { - try { - const { ...params } = this.bodyParams; + 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(); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success(); }, ); @@ -653,22 +590,17 @@ API.v1.post( }, }, async function action() { - try { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + 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 }), - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ + channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), + }); }, ); @@ -694,20 +626,15 @@ API.v1.post( }, }, async function action() { - try { - const { announcement, ...params } = this.bodyParams; + 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, - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ + announcement: this.bodyParams.announcement, + }); }, ); @@ -736,29 +663,24 @@ API.v1.get( }, }, async function action() { - try { - 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 { 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, {}); + const allMentions = await getUserMentionsByChannel(this.userId, roomId, {}); - return API.v1.success({ - mentions, - count: mentions.length, - offset, - total: allMentions.length, - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ + mentions, + count: mentions.length, + offset, + total: allMentions.length, + }); }, ); @@ -793,28 +715,23 @@ API.v1.get( }, }, async function action() { - try { - const { ...params } = this.queryParams; + 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, - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ + moderators, + }); }, ); @@ -830,19 +747,14 @@ API.v1.post( }, }, async function action() { - try { - const room = await findChannelByIdOrName({ - params: this.bodyParams, - checkedArchived: false, - }); + 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(); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success(); }, ); @@ -870,51 +782,46 @@ API.v1.post( }, }, async function action() { - try { - const { channelId, channelName } = this.bodyParams; + 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 }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ team }); }, ); @@ -930,18 +837,13 @@ API.v1.post( }, }, async function action() { - try { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + 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(); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success(); }, ); @@ -957,18 +859,13 @@ API.v1.post( }, }, async function action() { - try { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + 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(); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success(); }, ); @@ -984,29 +881,24 @@ API.v1.post( }, }, async function action() { - try { - const findResult = await findChannelByIdOrName({ - params: this.bodyParams, - checkedArchived: false, - }); + 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(); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success(); }, ); @@ -1056,56 +948,51 @@ API.v1.get( }, }, async function action() { - try { - 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); + 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; + } - return API.v1.success({ - joined, - members, - unreads, - unreadsFrom, - msgs, - latest, - userMentions, - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); + 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, + }); }, ); @@ -1222,35 +1109,30 @@ API.v1.post( return API.v1.failure(e.message); } - try { - 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); - } + if (bodyParams.teams) { + const canSeeAllTeams = await hasPermissionAsync(this.user, 'view-all-teams'); + const teams = await Team.listByNames(bodyParams.teams, { projection: { _id: 1 } }); + const teamMembers = []; - const membersToAdd = new Set([...teamMembers, ...(bodyParams.members || [])]); - bodyParams.members = [...membersToAdd].filter(Boolean) as string[]; + 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); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); + const result = await API.channels?.create.execute(userId, bodyParams); + if (!result) { + return API.v1.failure('Failed to create channel'); } + + return API.v1.success(result); }, ); @@ -1291,51 +1173,46 @@ API.v1.get( }, }, async function action() { - try { - const { typeGroup, name, roomId, roomName, onlyConfirmed } = this.queryParams; + const { typeGroup, name, roomId, roomName, onlyConfirmed } = this.queryParams; - const findResult = await findChannelByIdOrName({ - params: { - ...(roomId ? { roomId } : {}), - ...(roomName ? { roomName } : {}), - }, - checkedArchived: false, - }); + const findResult = await findChannelByIdOrName({ + params: { + ...(roomId ? { roomId } : {}), + ...(roomName ? { roomName } : {}), + }, + checkedArchived: false, + }); - if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { - return API.v1.forbidden(); - } + 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 { 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 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 { 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]); + const [files, total] = await Promise.all([cursor.toArray(), totalCount]); - return API.v1.success({ - files: await addUserToFileObj(files), - count: files.length, - offset, - total, - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ + files: await addUserToFileObj(files), + count: files.length, + offset, + total, + }); }, ); @@ -1411,58 +1288,53 @@ API.v1.get( }, }, async function action() { - try { - const findResult = await findChannelByIdOrName({ - params: this.queryParams, - checkedArchived: false, - }); + const findResult = await findChannelByIdOrName({ + params: this.queryParams, + checkedArchived: false, + }); - if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { - return API.v1.forbidden(); - } + 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 includeAllPublicChannels = true; + if (typeof this.queryParams.includeAllPublicChannels !== 'undefined') { + includeAllPublicChannels = this.queryParams.includeAllPublicChannels === 'true'; + } - let ourQuery: { channel: string | { $in: string[] } } = { - channel: `#${findResult.name}`, - }; + 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(); + 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)); + // 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 { cursor, totalCount } = await Integrations.findPaginated(ourQuery, { + sort: sort || { _createdAt: 1 }, + skip: offset, + limit: count, + projection, + }); - const [integrations, total] = await Promise.all([cursor.toArray(), totalCount]); + const [integrations, total] = await Promise.all([cursor.toArray(), totalCount]); - return API.v1.success({ - integrations, - count: integrations.length, - offset, - total, - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ + integrations, + count: integrations.length, + offset, + total, + }); }, ); @@ -1479,24 +1351,19 @@ API.v1.get( }, }, async function action() { - try { - const findResult = await findChannelByIdOrName({ - params: this.queryParams, - checkedArchived: false, - userId: this.userId, - }); - - if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { - return API.v1.forbidden(); - } + const findResult = await findChannelByIdOrName({ + params: this.queryParams, + checkedArchived: false, + userId: this.userId, + }); - return API.v1.success({ - channel: findResult, - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); + if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { + return API.v1.forbidden(); } + + return API.v1.success({ + channel: findResult, + }); }, ); @@ -1527,36 +1394,31 @@ API.v1.post( }, }, async function action() { - try { - 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); - - await addUsersToRoomMethod(this.userId, { rid: findResult._id, users }, this.user); + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - return API.v1.success({ - channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), - }); - } + // 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); - const users = await getUserListFromParams(this.bodyParams); - - 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 }, this.user); return API.v1.success({ channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); } + + const users = await getUserListFromParams(this.bodyParams); + + 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); + + return API.v1.success({ + channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), + }); }, ); @@ -1673,41 +1535,36 @@ API.v1.get( }, }, async function action() { - try { - 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, - }); + 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, }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); } + + 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, + }); }, ); @@ -1779,46 +1636,41 @@ API.v1.get( }, }, async function action() { - try { - const findResult = await findChannelByIdOrName({ - params: this.queryParams, - checkedArchived: false, - }); + const findResult = await findChannelByIdOrName({ + params: this.queryParams, + checkedArchived: false, + }); - if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { - return API.v1.forbidden(); - } + 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(); - } + 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 { offset: skip, count: limit } = await getPaginationItems(this.queryParams); + const { sort = {} } = await this.parseJsonQuery(); - const { status, filter } = this.queryParams; + 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 { 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]); + const [members, total] = await Promise.all([cursor.toArray(), totalCount]); - return API.v1.success({ - members, - count: members.length, - offset: skip, - total, - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ + members, + count: members.length, + offset: skip, + total, + }); }, ); @@ -1852,54 +1704,49 @@ API.v1.get( }, }, async function action() { - try { - const { query } = await this.parseJsonQuery(); - const { _id } = this.queryParams; + const { query } = await this.parseJsonQuery(); + const { _id } = this.queryParams; - if ((!query || Object.keys(query).length === 0) && !_id) { - return API.v1.failure('Invalid query'); - } + if ((!query || Object.keys(query).length === 0) && !_id) { + return API.v1.failure('Invalid query'); + } - const filter = { - ...query, - ...(_id ? { _id } : {}), - t: 'c', - }; + 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'); - } + 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'); - } + 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, - }; - } - }), - ); + const online: Pick[] = await Users.findUsersNotOffline({ + projection: { username: 1 }, + }).toArray(); - return API.v1.success({ - online: onlineInRoom.filter(isTruthy), - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + 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), + }); }, ); @@ -1915,18 +1762,13 @@ API.v1.post( }, }, async function action() { - try { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + 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(); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success(); }, ); @@ -1942,18 +1784,13 @@ API.v1.post( }, }, async function action() { - try { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + 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(); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success(); }, ); @@ -1969,29 +1806,24 @@ API.v1.post( }, }, async function action() { - try { - if (!this.bodyParams.name?.trim()) { - return API.v1.failure('The bodyParam "name" is required'); - } + 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, - }), - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ + channel: await findChannelByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }), + }); }, ); @@ -2009,22 +1841,17 @@ API.v1.post( }, }, async function action() { - try { - if (!this.bodyParams.customFields || !(typeof this.bodyParams.customFields === 'object')) { - return API.v1.failure('The bodyParam "customFields" is required with a type like object.'); - } + 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 }), - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ + channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), + }); }, ); @@ -2040,34 +1867,29 @@ API.v1.post( }, }, async function action() { - try { - 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 }); + 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 }), - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); } + + 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 }), + }); }, ); @@ -2083,22 +1905,17 @@ API.v1.post( }, }, async function action() { - try { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + 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 || '', - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ + description: this.bodyParams.description || '', + }); }, ); @@ -2114,22 +1931,17 @@ API.v1.post( }, }, async function action() { - try { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + 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 || '', - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ + purpose: this.bodyParams.purpose || '', + }); }, ); @@ -2145,22 +1957,17 @@ API.v1.post( }, }, async function action() { - try { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + 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 || '', - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ + topic: this.bodyParams.topic || '', + }); }, ); @@ -2176,32 +1983,27 @@ API.v1.post( }, }, async function action() { - try { - if (!this.bodyParams.type?.trim()) { - return API.v1.failure('The bodyParam "type" is required'); - } - - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + if (!this.bodyParams.type?.trim()) { + return API.v1.failure('The bodyParam "type" is required'); + } - if (findResult.t === this.bodyParams.type) { - return API.v1.failure('The channel type is the same as what it would be changed to.'); - } + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); - await saveRoomSettings(this.userId, findResult._id, 'roomType', this.bodyParams.type as RoomType); + if (findResult.t === this.bodyParams.type) { + return API.v1.failure('The channel type is the same as what it would be changed to.'); + } - const room = await Rooms.findOneById(findResult._id, { projection: API.v1.defaultFieldsToExclude }); + await saveRoomSettings(this.userId, findResult._id, 'roomType', this.bodyParams.type as RoomType); - if (!room) { - return API.v1.failure('The channel does not exist'); - } + const room = await Rooms.findOneById(findResult._id, { projection: API.v1.defaultFieldsToExclude }); - return API.v1.success({ - channel: await composeRoomWithLastMessage(room, this.userId), - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); + if (!room) { + return API.v1.failure('The channel does not exist'); } + + return API.v1.success({ + channel: await composeRoomWithLastMessage(room, this.userId), + }); }, ); @@ -2217,18 +2019,13 @@ API.v1.post( }, }, async function action() { - try { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + 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(); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success(); }, ); @@ -2244,18 +2041,13 @@ API.v1.post( }, }, async function action() { - try { - const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + 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(); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success(); }, ); @@ -2271,22 +2063,17 @@ API.v1.post( }, }, async function action() { - try { - if (!this.bodyParams.joinCode?.trim()) { - return API.v1.failure('The bodyParam "joinCode" is required'); - } + 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 }), - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ + channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), + }); }, ); @@ -2326,50 +2113,45 @@ API.v1.get( }, }, async function action() { - try { - const findResult = await findChannelByIdOrName({ - params: this.queryParams, - checkedArchived: false, - }); - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); + 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 }); + 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', - }); - } + 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 { 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]); + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); - return API.v1.success({ - messages: await normalizeMessagesForUser(messages, this.userId || ''), - count: messages.length, - offset, - total, - }); - } catch (error) { - const [message, errorType, stack, details] = errorToFailureArgs(error); - return API.v1.failure(message, errorType, stack, details); - } + return API.v1.success({ + messages: await normalizeMessagesForUser(messages, this.userId || ''), + count: messages.length, + offset, + total, + }); }, ); From b4312d99a54561a62cd09f08417c4450acabf101 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 28 Jul 2026 18:00:01 -0300 Subject: [PATCH 28/30] refactor(api): drop channels.create error catch; fix error-handling docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - channels.create: throw Meteor.Error('unauthorized') from the create.validate permission gate (was a plain Error) and remove the handler's try/catch, so the global ApiClass wrapper maps it (403, or 401 under breaking-changes) like every other endpoint — instead of forcing 403 locally. - docs/api-endpoint-migration.md: correct the 'Error Handling' section. The typed router DOES map thrown errors: ApiClass wraps every route (incl. typed) in a try/catch that maps Meteor.Error -> 429/401/403/400. Handlers should just throw; the per-handler catch-all + errorToFailureArgs were redundant. --- apps/meteor/server/api/v1/channels.ts | 51 ++++++++++++--------------- docs/api-endpoint-migration.md | 35 +++++++----------- 2 files changed, 34 insertions(+), 52 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index 153103b862b42..2a3fb8c02090b 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -1011,7 +1011,7 @@ async function createChannelValidator(params: { (!teamId && !(await hasPermissionAsync(params.user.value, 'create-c'))) || (teamId && team && !(await hasPermissionAsync(params.user.value, 'create-team-channel', team.roomId))) ) { - throw new Error('unauthorized'); + throw new Meteor.Error('unauthorized', 'Not allowed to create channels'); } if (!params.name?.value) { @@ -1080,34 +1080,27 @@ API.v1.post( async function action() { const { userId, bodyParams } = this; - 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', - }, - }); - } catch (e: any) { - if (e.message === 'unauthorized') { - return API.v1.forbidden(); - } - return API.v1.failure(e.message); - } + 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', + }, + }); if (bodyParams.teams) { const canSeeAllTeams = await hasPermissionAsync(this.user, 'view-all-teams'); diff --git a/docs/api-endpoint-migration.md b/docs/api-endpoint-migration.md index fb2a00cf71078..3c1fb0f9043a1 100644 --- a/docs/api-endpoint-migration.md +++ b/docs/api-endpoint-migration.md @@ -560,37 +560,26 @@ API.v1.post('endpoint', { ## Error Handling (thrown errors) -The typed router does **not** convert thrown errors into HTTP error responses the way the legacy `addRoute` wrapper did. `addRoute` wrapped every handler in a `try/catch` that turned a thrown `Meteor.Error` into a `400` failure (with `error`/`errorType`). The typed router has **no global error handler** — an uncaught throw propagates and becomes a **500 Internal Server Error**. +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: -So when migrating a handler (or a helper it calls, e.g. `findChannelByIdOrName`, `Room.join`, `requestPdfTranscript`) that throws to signal a client error, you must catch it and return an explicit failure to preserve the previous status: +- `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() { - try { - const room = await findChannelByIdOrName({ params: this.bodyParams }); - // ... - return API.v1.success({ channel: room }); - } catch (error) { - const [message, errorType] = errorToFailureArgs(error); - return API.v1.failure(message, errorType); - } + const room = await findChannelByIdOrName({ params: this.bodyParams }); // throws error-room-not-found + // ... + return API.v1.success({ channel: room }); } ``` -Remember to declare `400: validateBadRequestErrorResponse` in the `response` block (any handler that can return `API.v1.failure()` needs it). - -### Extract `errorType` by shape, not `instanceof` - -Errors thrown by **`@rocket.chat/core-services`** calls (e.g. `Room.join`, `Team.*`) cross a service boundary and are **not** `instanceof` the local `Meteor.Error`, so an `error instanceof Meteor.Error` check silently drops their `error`/`errorType`. Extract them by shape (matching the legacy `addRoute` behavior), otherwise e2e assertions like `expect(res.body).to.have.property('errorType', ...)` fail: - -```typescript -function errorToFailureArgs(error: unknown): [string, string | undefined] { - const e = error as { message?: unknown; error?: unknown }; - return [typeof e?.message === 'string' ? e.message : String(error), typeof e?.error === 'string' ? e.error : undefined]; -} -``` +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. -Keep the `API.v1.failure(...)` call **inline** in the `catch` (a helper that itself returns `API.v1.failure(...)` breaks `TypedAction` return-type inference); factor out only the argument extraction. +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 From 5de17ac195686057e1ecd8b6108e43c220f6ab0f Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 28 Jul 2026 18:14:40 -0300 Subject: [PATCH 29/30] refactor(api): use oneOf for channels.getIntegrations response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IIncomingIntegration/IOutgoingIntegration are discriminated by a required `type` enum (webhook-incoming vs webhook-outgoing), so they are disjoint and oneOf matches exactly one — no overlap risk. Matches the IIntegration contract convention. --- apps/meteor/server/api/v1/channels.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index 2a3fb8c02090b..bce3c25549de6 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -1245,7 +1245,7 @@ const channelsGetIntegrationsResponseSchema = ajv.compile<{ integrations: { type: 'array', items: { - anyOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }], + oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }], }, }, count: { type: 'number' }, From 52100340a6ef710a0a95acc81c1d0939e2a8f409 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 28 Jul 2026 18:55:09 -0300 Subject: [PATCH 30/30] fix(api): restore channels.create unauthorized->forbidden mapping The create.validate permission gate throws a plain Error('unauthorized'); the global wrapper would surface its raw message. Restore the local catch that maps it to API.v1.forbidden() so the response keeps the stable { error: 'unauthorized' } (403) contract the e2e test asserts. Reverts the over-eager catch removal in b4312d99a5. --- apps/meteor/server/api/v1/channels.ts | 54 ++++++++++++++++----------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts index bce3c25549de6..42c46741641ac 100644 --- a/apps/meteor/server/api/v1/channels.ts +++ b/apps/meteor/server/api/v1/channels.ts @@ -1011,7 +1011,7 @@ async function createChannelValidator(params: { (!teamId && !(await hasPermissionAsync(params.user.value, 'create-c'))) || (teamId && team && !(await hasPermissionAsync(params.user.value, 'create-team-channel', team.roomId))) ) { - throw new Meteor.Error('unauthorized', 'Not allowed to create channels'); + throw new Error('unauthorized'); } if (!params.name?.value) { @@ -1080,27 +1080,37 @@ API.v1.post( async function action() { const { userId, bodyParams } = this; - 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', - }, - }); + 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');