snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

43
apps/meteor/ee/LICENSE Normal file
View File

@@ -0,0 +1,43 @@
The Rocket.Chat Enterprise Edition (EE) license (the "EE License")
Copyright (c) 2015-2022 Rocket.Chat Technologies Corp.
With regard to the Rocket.Chat Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Rocket.Chat Subscription Terms of Service,
available at https://rocket.chat/terms (the "EE Terms"), or other agreement
governing the use of the Software, as agreed by you and Rocket.Chat, and otherwise
have a valid Rocket.Chat Enterprise Edition subscription for the correct number
of user seats. Subject to the foregoing sentence, you are free to modify this
Software and publish patches to the Software. You agree that Rocket.Chat and/or
its licensors (as applicable) retain all right, title and interest in and to all
such modifications and/or patches, and all such modifications and/or patches may
only be used, copied, modified, displayed, distributed, or otherwise exploited
with a valid Rocket.Chat Enterprise Edition subscription for the correct number
of user seats. Notwithstanding the foregoing, you may copy and modify the Software
for development and testing purposes, without requiring a Subscription. You agree
that Rocket.Chat and/or its licensors (as applicable) retain all right, title and
interest in and to all such modifications. You are not granted any other rights
beyond what is expressly stated herein. Subject to the foregoing, it is forbidden
to copy, merge, publish, distribute, sublicense, and/or sell the Software.
This EE License applies only to the part of this Software that is not distributed
as part of Rocket.Chat Community Edition (CE). Any part of this Software distributed
as part of Rocket.Chat CE or is served client-side as an image, font, cascading
stylesheet (CSS), file which produces or is compiled, arranged, augmented, or combined
into client-side JavaScript, in whole or in part, is copyrighted under the MIT Expat
license. The full text of this EE License shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third-party components incorporated into the Rocket.Chat Software,
those components are licensed under the original license provided by the owner
of the applicable component.

View File

@@ -0,0 +1,96 @@
export class Utilities {
static getI18nKeyForApp<TKey extends string, TAppId extends string>(key: TKey, appId: TAppId) {
return `app-${appId}.${key}` as const;
}
static curl(
{
method,
params,
auth,
headers = {},
url,
query,
content,
}: {
method: string;
params?: Record<string, string>;
auth?: string;
headers?: Record<string, string>;
url: string;
query?: Record<string, string>;
content?: unknown;
},
opts: {
verbose?: boolean;
headers?: boolean;
} = {},
) {
const newLine = '\\\n ';
const cmd = ['curl'];
// curl options
if (opts.verbose) {
cmd.push('-v');
}
if (opts.headers) {
cmd.push('-i');
}
// method
cmd.push('-X');
cmd.push((method || 'GET').toUpperCase());
// URL
let u = url;
if (typeof params === 'object') {
Object.entries(params).forEach(([key, value]) => {
u = u.replace(`:${key}`, value);
});
}
if (typeof query === 'object') {
const queryString = Object.entries(query)
.map(([key, value]) => `${key}=${value}`)
.join('&');
u += `?${queryString}`;
}
cmd.push(u);
// username
if (auth) {
cmd.push(newLine);
cmd.push('-u');
cmd.push(auth);
}
// headers
const headerKeys: string[] = [];
Object.entries(headers).forEach(([key, val]) => {
key = key.toLowerCase();
headerKeys.push(key);
cmd.push(newLine);
cmd.push('-H');
cmd.push(`"${key}${val ? ': ' : ';'}${val || ''}"`);
});
if (content) {
if (typeof content === 'object') {
if (!headerKeys.includes('content-type')) {
cmd.push(newLine);
cmd.push('-H');
cmd.push('"content-type: application/json"');
}
content = JSON.stringify(content);
}
cmd.push(newLine);
cmd.push('--data-binary');
cmd.push(`'${content}'`);
}
return cmd.join(' ');
}
}

View File

@@ -0,0 +1,19 @@
import fileType from 'file-type';
import { mime as MIME } from '../../../app/utils/lib/mimeTypes';
export function determineFileType(buffer, name) {
const mime = MIME.lookup(name);
if (mime) {
return Array.isArray(mime) ? mime[0] : mime;
}
const detectedType = fileType(buffer);
if (detectedType) {
return detectedType.mime;
}
return 'application/octet-stream';
}

View File

@@ -0,0 +1,24 @@
import { Apps } from '@rocket.chat/core-services';
import { isRunningMs } from '../../../server/lib/isRunningMs';
import { Instance } from '../../server/sdk';
export async function fetchAppsStatusFromCluster() {
const appsStatus = isRunningMs() ? await Apps.getAppsStatusInNodes() : await Instance.getAppsStatusInInstances();
Object.values(appsStatus).forEach((instances) =>
instances.sort((a, b) => {
if (a.instanceId > b.instanceId) {
return 1;
}
if (a.instanceId < b.instanceId) {
return -1;
}
return 0;
}),
);
return appsStatus;
}

View File

@@ -0,0 +1,40 @@
import type { ProxiedApp } from '@rocket.chat/apps/dist/server/ProxiedApp';
import type { AppLicenseValidationResult } from '@rocket.chat/apps/dist/server/marketplace/license/AppLicenseValidationResult';
import type { IAppStorageItem } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem';
import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
import type { AppStatusReport } from '@rocket.chat/core-services';
import type { App } from '@rocket.chat/core-typings';
import { getInstallationSourceFromAppStorageItem } from '../../../lib/apps/getInstallationSourceFromAppStorageItem';
interface IAppInfoRest extends IAppInfo {
status: AppStatus;
languages: IAppStorageItem['languageContent'];
licenseValidation?: AppLicenseValidationResult;
private: boolean;
migrated: boolean;
clusterStatus?: App['clusterStatus'];
}
export async function formatAppInstanceForRest(app: ProxiedApp, clusterStatus?: AppStatusReport): Promise<IAppInfoRest> {
const appRest: IAppInfoRest = {
...app.getInfo(),
status: await app.getStatus(),
languages: app.getStorageItem().languageContent,
private: getInstallationSourceFromAppStorageItem(app.getStorageItem()) === 'private',
migrated: !!app.getStorageItem().migrated,
};
if (clusterStatus?.[app.getID()]) {
appRest.clusterStatus = clusterStatus[app.getID()];
}
const licenseValidation = app.getLatestLicenseValidationResult();
if (licenseValidation?.hasErrors || licenseValidation?.hasWarnings) {
appRest.licenseValidation = licenseValidation;
}
return appRest;
}

View File

@@ -0,0 +1,474 @@
import { AbacAttributeStoreExternalError, getPdpHealthErrorCode } from '@rocket.chat/abac';
import { Abac } from '@rocket.chat/core-services';
import type { AbacActor } from '@rocket.chat/core-services';
import type { IServerEvents, IUser } from '@rocket.chat/core-typings';
import { ServerEvents } from '@rocket.chat/models';
import { validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings/src/v1/Ajv';
import { convertSubObjectsIntoPaths } from '@rocket.chat/tools';
import {
GenericSuccessSchema,
PUTAbacAttributeUpdateBodySchema,
GETAbacAttributesQuerySchema,
GETAbacAttributesResponseSchema,
GETAbacAttributeByIdResponseSchema,
POSTAbacAttributeDefinitionSchema,
GETAbacAttributeIsInUseResponseSchema,
POSTRoomAbacAttributesBodySchema,
POSTSingleRoomAbacAttributeBodySchema,
PUTRoomAbacAttributeValuesBodySchema,
POSTAbacUsersSyncBodySchema,
GenericErrorSchema,
GETAbacRoomsListQueryValidator,
GETAbacRoomsResponseValidator,
GETAbacAuditEventsQuerySchema,
GETAbacAuditEventsResponseSchema,
GETAbacPdpHealthResponseSchema,
GETAbacPdpHealthErrorResponseSchema,
} from './schemas';
import { API } from '../../../../server/api';
import type { ExtractRoutesFromAPI } from '../../../../server/api/ApiClass';
import { getPaginationItems } from '../../../../server/api/lib/getPaginationItems';
import { settings } from '../../../../server/settings';
const getActorFromUser = (user?: IUser | null): AbacActor | undefined =>
user?._id
? {
_id: user._id,
username: user.username,
name: user.name,
}
: undefined;
const assertLocalAttributeStore = async (): Promise<void> => {
if (await Abac.isExternalAttributeStore()) {
throw new AbacAttributeStoreExternalError();
}
};
const abacEndpoints = API.v1
.post(
'abac/rooms/:rid/attributes',
{
authRequired: true,
permissionsRequired: ['abac-management', 'manage-abac-admin-rooms'],
body: POSTRoomAbacAttributesBodySchema,
response: {
200: GenericSuccessSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
license: ['abac'],
},
async function action() {
const { rid } = this.urlParams;
const { attributes } = this.bodyParams;
if (!settings.get('ABAC_Enabled')) {
throw new Error('error-abac-not-enabled');
}
// This is a replace-all operation
// IF you need fine grained, use the other endpoints for removing, editing & adding single attributes
await Abac.setRoomAbacAttributes(rid, attributes, getActorFromUser(this.user));
return API.v1.success();
},
)
.delete(
'abac/rooms/:rid/attributes',
{
authRequired: true,
permissionsRequired: ['abac-management', 'manage-abac-admin-rooms'],
response: {
200: GenericSuccessSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { rid } = this.urlParams;
// We don't need to check if ABAC is enabled to clear attributes
// Since we're always allowing this operation
// license check is also not required
await Abac.setRoomAbacAttributes(rid, {}, getActorFromUser(this.user));
return API.v1.success();
},
)
// add an abac attribute by key
.post(
'abac/rooms/:rid/attributes/:key',
{
authRequired: true,
permissionsRequired: ['abac-management', 'manage-abac-admin-rooms'],
license: ['abac'],
body: POSTSingleRoomAbacAttributeBodySchema,
response: {
200: GenericSuccessSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { rid, key } = this.urlParams;
const { values } = this.bodyParams;
if (!settings.get('ABAC_Enabled')) {
throw new Error('error-abac-not-enabled');
}
await Abac.addRoomAbacAttributeByKey(rid, key, values, getActorFromUser(this.user));
return API.v1.success();
},
)
// edit a room attribute
.put(
'abac/rooms/:rid/attributes/:key',
{
authRequired: true,
permissionsRequired: ['abac-management', 'manage-abac-admin-rooms'],
body: PUTRoomAbacAttributeValuesBodySchema,
response: {
200: GenericSuccessSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
license: ['abac'],
},
async function action() {
const { rid, key } = this.urlParams;
const { values } = this.bodyParams;
if (!settings.get('ABAC_Enabled')) {
throw new Error('error-abac-not-enabled');
}
await Abac.replaceRoomAbacAttributeByKey(rid, key, values, getActorFromUser(this.user));
return API.v1.success();
},
)
// delete a room attribute
.delete(
'abac/rooms/:rid/attributes/:key',
{
authRequired: true,
permissionsRequired: ['abac-management', 'manage-abac-admin-rooms'],
response: {
200: GenericSuccessSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { rid, key } = this.urlParams;
await Abac.removeRoomAbacAttribute(rid, key, getActorFromUser(this.user));
return API.v1.success();
},
)
// attribute endpoints
// list attributes
.get(
'abac/attributes',
{
authRequired: true,
permissionsRequired: ['abac-management', 'manage-abac-admin-room-attributes'],
query: GETAbacAttributesQuerySchema,
response: {
200: GETAbacAttributesResponseSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { offset, count } = await getPaginationItems(this.queryParams as Record<string, string | string[] | number | null | undefined>);
const { key, values } = this.queryParams;
return API.v1.success(
await Abac.listAbacAttributes(
{
key,
values,
offset,
count,
},
getActorFromUser(this.user),
),
);
},
)
.post(
'abac/users/sync',
{
authRequired: true,
permissionsRequired: ['abac-management', 'manage-abac-admin-room-attributes'],
license: ['abac'],
body: POSTAbacUsersSyncBodySchema,
response: {
200: GenericSuccessSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
if (!settings.get('ABAC_Enabled')) {
throw new Error('error-abac-not-enabled');
}
const { usernames, ids, emails, ldapIds } = this.bodyParams;
await Abac.reevaluateUsers({ usernames, ids, emails, ldapIds });
return API.v1.success();
},
)
.post(
'abac/attributes',
{
authRequired: true,
permissionsRequired: ['abac-management', 'manage-abac-admin-room-attributes'],
license: ['abac'],
body: POSTAbacAttributeDefinitionSchema,
response: {
200: GenericSuccessSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
if (!settings.get('ABAC_Enabled')) {
throw new Error('error-abac-not-enabled');
}
await assertLocalAttributeStore();
await Abac.addAbacAttribute(this.bodyParams, getActorFromUser(this.user));
return API.v1.success();
},
)
// update attribute definition (key and/or values)
.put(
'abac/attributes/:_id',
{
authRequired: true,
permissionsRequired: ['abac-management', 'manage-abac-admin-room-attributes'],
license: ['abac'],
body: PUTAbacAttributeUpdateBodySchema,
response: {
200: GenericSuccessSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { _id } = this.urlParams;
if (!settings.get('ABAC_Enabled')) {
throw new Error('error-abac-not-enabled');
}
await assertLocalAttributeStore();
await Abac.updateAbacAttributeById(_id, this.bodyParams, getActorFromUser(this.user));
return API.v1.success();
},
)
// get single attribute with usage
.get(
'abac/attributes/:_id',
{
authRequired: true,
permissionsRequired: ['abac-management', 'manage-abac-admin-room-attributes'],
response: {
200: GETAbacAttributeByIdResponseSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { _id } = this.urlParams;
await assertLocalAttributeStore();
const result = await Abac.getAbacAttributeById(_id, getActorFromUser(this.user));
return API.v1.success(result);
},
)
// delete attribute (only if not in use)
.delete(
'abac/attributes/:_id',
{
authRequired: true,
permissionsRequired: ['abac-management', 'manage-abac-admin-room-attributes'],
response: {
200: GenericSuccessSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { _id } = this.urlParams;
await assertLocalAttributeStore();
await Abac.deleteAbacAttributeById(_id, getActorFromUser(this.user));
return API.v1.success();
},
)
// check if attribute is in use
.get(
'abac/attributes/:key/is-in-use',
{
authRequired: true,
permissionsRequired: ['abac-management', 'manage-abac-admin-room-attributes'],
response: {
200: GETAbacAttributeIsInUseResponseSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { key } = this.urlParams;
await assertLocalAttributeStore();
const inUse = await Abac.isAbacAttributeInUseByKey(key);
return API.v1.success({ inUse });
},
)
.get(
'abac/rooms',
{
authRequired: true,
permissionsRequired: ['abac-management', 'manage-abac-admin-rooms'],
response: {
200: GETAbacRoomsResponseValidator,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
query: GETAbacRoomsListQueryValidator,
},
async function action() {
const { offset, count } = await getPaginationItems(this.queryParams as Record<string, string | string[] | number | null | undefined>);
const { filter, filterType } = this.queryParams;
const result = await Abac.listAbacRooms(
{
offset,
count,
filter,
filterType,
},
getActorFromUser(this.user),
);
return API.v1.success(result);
},
)
.get(
'abac/pdp/health',
{
authRequired: true,
permissionsRequired: ['abac-management', 'manage-abac-admin-settings'],
rateLimiterOptions: {
numRequestsAllowed: 5,
intervalTimeInMS: 60000,
},
response: {
200: GETAbacPdpHealthResponseSchema,
400: GETAbacPdpHealthErrorResponseSchema,
401: validateUnauthorizedErrorResponse,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
try {
await Abac.getPDPHealth();
return API.v1.success({ available: true, message: 'ABAC_PDP_Health_OK' });
} catch (err) {
return API.v1.failure({ available: false, message: getPdpHealthErrorCode(err) });
}
},
)
.get(
'abac/audit',
{
response: {
200: GETAbacAuditEventsResponseSchema,
400: GenericErrorSchema,
401: validateUnauthorizedErrorResponse,
403: validateUnauthorizedErrorResponse,
},
query: GETAbacAuditEventsQuerySchema,
authRequired: true,
permissionsRequired: ['abac-management', 'view-abac-admin-audit'],
license: ['abac', 'auditing'],
},
async function action() {
const { start, end, actor } = this.queryParams;
const { offset, count } = await getPaginationItems(this.queryParams as Record<string, string | number | null | undefined>);
const { sort } = await this.parseJsonQuery();
const _sort = { ts: sort?.ts ? sort?.ts : -1 };
const { cursor, totalCount } = ServerEvents.findPaginated(
{
...(actor && convertSubObjectsIntoPaths({ actor })),
ts: {
$gte: start ? new Date(start) : new Date(0),
$lte: end ? new Date(end) : new Date(),
},
t: {
$in: [
'abac.attribute.changed',
'abac.object.attribute.changed',
'abac.object.attributes.removed',
'abac.action.performed',
'abac.attribute.store.switched',
],
},
},
{
sort: _sort,
skip: offset,
limit: count,
allowDiskUse: true,
},
);
const [events, total] = await Promise.all([cursor.toArray(), totalCount]);
return API.v1.success({
events: events as (
| IServerEvents['abac.action.performed']
| IServerEvents['abac.attribute.changed']
| IServerEvents['abac.object.attribute.changed']
| IServerEvents['abac.object.attributes.removed']
| IServerEvents['abac.attribute.store.switched']
)[],
count: events.length,
offset,
total,
});
},
);
export type AbacEndpoints = ExtractRoutesFromAPI<typeof abacEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends AbacEndpoints {}
}

View File

@@ -0,0 +1,422 @@
import type {
IAbacAttribute,
IAbacAttributeDefinition,
IAuditServerActor,
IRoom,
IRoomAbacRedaction,
IServerEvents,
} from '@rocket.chat/core-typings';
import type { PaginatedResult, PaginatedRequest } from '@rocket.chat/rest-typings';
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
const ATTRIBUTE_KEY_PATTERN = '^[A-Za-z0-9_-]+$';
const MAX_ROOM_ATTRIBUTE_VALUES = 10;
const MAX_USERS_SYNC_ITEMS = 100;
const MAX_ROOM_ATTRIBUTE_KEYS = 10;
const GenericSuccess = {
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
additionalProperties: false,
};
export const GenericSuccessSchema = ajv.compile<void>(GenericSuccess);
// Update ABAC attribute (request body)
const UpdateAbacAttributeBody = {
type: 'object',
properties: {
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
values: {
type: 'array',
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
minItems: 1,
uniqueItems: true,
},
},
additionalProperties: false,
anyOf: [{ required: ['key'] }, { required: ['values'] }],
};
export const PUTAbacAttributeUpdateBodySchema = ajv.compile<IAbacAttributeDefinition>(UpdateAbacAttributeBody);
const AbacAttributeDefinition = {
type: 'object',
properties: {
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
values: {
type: 'array',
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
minItems: 1,
uniqueItems: true,
},
},
required: ['key', 'values'],
additionalProperties: false,
};
export const POSTAbacAttributeDefinitionSchema = ajv.compile<IAbacAttributeDefinition>(AbacAttributeDefinition);
const GetAbacAttributesQuery = {
type: 'object',
properties: {
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
values: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
offset: { type: 'number' },
count: { type: 'number' },
},
additionalProperties: false,
};
export const GETAbacAttributesQuerySchema = ajvQuery.compile<{ key?: string; values?: string; offset: number; count?: number }>(
GetAbacAttributesQuery,
);
const AbacAttributeRecord = {
type: 'object',
properties: {
_id: { type: 'string', minLength: 1 },
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
values: {
type: 'array',
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
minItems: 1,
uniqueItems: true,
},
},
required: ['_id', 'key', 'values'],
additionalProperties: false,
};
const GetAbacAttributesResponse = {
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
attributes: {
type: 'array',
items: AbacAttributeRecord,
},
offset: { type: 'number' },
count: { type: 'number' },
total: { type: 'number' },
},
required: ['attributes', 'offset', 'count', 'total'],
additionalProperties: false,
};
export const GETAbacAttributesResponseSchema = ajv.compile<{
attributes: Pick<IAbacAttribute, '_id' | 'key' | 'values'>[];
offset: number;
count: number;
total: number;
}>(GetAbacAttributesResponse);
const GetAbacAttributeByIdResponse = {
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
_id: { type: 'string', minLength: 1 },
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
values: {
type: 'array',
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
minItems: 1,
uniqueItems: true,
},
},
required: ['key', 'values'],
additionalProperties: false,
};
export const GETAbacAttributeByIdResponseSchema = ajv.compile<{
key: string;
values: string[];
}>(GetAbacAttributeByIdResponse);
const GetAbacAttributeIsInUseResponse = {
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
inUse: { type: 'boolean' },
},
required: ['inUse'],
additionalProperties: false,
};
export const GETAbacAttributeIsInUseResponseSchema = ajv.compile<{ inUse: boolean }>(GetAbacAttributeIsInUseResponse);
const GetAbacAuditEventsQuerySchemaObject = {
type: 'object',
properties: {
start: { type: 'string', format: 'date-time', nullable: true },
end: { type: 'string', format: 'date-time', nullable: true },
offset: { type: 'number', nullable: true },
count: { type: 'number', nullable: true },
actor: {
type: 'object',
nullable: true,
properties: {
type: {
type: 'string',
nullable: true,
},
_id: {
type: 'string',
nullable: true,
},
username: {
type: 'string',
nullable: true,
},
ip: {
type: 'string',
nullable: true,
},
useragent: {
type: 'string',
nullable: true,
},
reason: {
type: 'string',
nullable: true,
},
},
},
},
additionalProperties: false,
};
export const GETAbacAuditEventsQuerySchema = ajvQuery.compile<
PaginatedRequest<{
start?: string;
end?: string;
actor?: IAuditServerActor;
}>
>(GetAbacAuditEventsQuerySchemaObject);
const GetAbacAuditEventsResponseSchemaObject = {
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
events: {
type: 'array',
items: {
type: 'object',
},
},
count: {
type: 'number',
description: 'The number of events returned in this response.',
},
offset: {
type: 'number',
description: 'The number of events that were skipped in this response.',
},
total: {
type: 'number',
description: 'The total number of events that match the query.',
},
},
required: ['events', 'count', 'offset', 'total'],
additionalProperties: false,
};
export const GETAbacAuditEventsResponseSchema = ajv.compile<{
events: (
| IServerEvents['abac.action.performed']
| IServerEvents['abac.attribute.changed']
| IServerEvents['abac.object.attribute.changed']
| IServerEvents['abac.object.attributes.removed']
| IServerEvents['abac.attribute.store.switched']
)[];
count: number;
offset: number;
total: number;
}>(GetAbacAuditEventsResponseSchemaObject);
const PostRoomAbacAttributesBody = {
type: 'object',
properties: {
attributes: {
type: 'object',
propertyNames: { type: 'string', pattern: ATTRIBUTE_KEY_PATTERN },
minProperties: 1,
maxProperties: MAX_ROOM_ATTRIBUTE_KEYS,
additionalProperties: {
type: 'array',
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
maxItems: MAX_ROOM_ATTRIBUTE_VALUES,
uniqueItems: true,
},
},
},
required: ['attributes'],
additionalProperties: false,
};
export const POSTRoomAbacAttributesBodySchema = ajv.compile<{ attributes: Record<string, string[]> }>(PostRoomAbacAttributesBody);
const PostSingleRoomAbacAttributeBody = {
type: 'object',
properties: {
values: {
type: 'array',
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
minItems: 1,
maxItems: MAX_ROOM_ATTRIBUTE_VALUES,
uniqueItems: true,
},
},
required: ['values'],
additionalProperties: false,
};
export const POSTSingleRoomAbacAttributeBodySchema = ajv.compile<{ values: string[] }>(PostSingleRoomAbacAttributeBody);
const PutRoomAbacAttributeValuesBody = {
type: 'object',
properties: {
values: {
type: 'array',
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
minItems: 1,
maxItems: MAX_ROOM_ATTRIBUTE_VALUES,
uniqueItems: true,
},
},
required: ['values'],
additionalProperties: false,
};
export const PUTRoomAbacAttributeValuesBodySchema = ajv.compile<{ values: string[] }>(PutRoomAbacAttributeValuesBody);
const GenericError = {
type: 'object',
properties: {
success: {
type: 'boolean',
},
message: {
type: 'string',
},
},
};
const PostAbacUsersSyncBody = {
type: 'object',
properties: {
usernames: {
type: 'array',
items: { type: 'string', minLength: 1 },
minItems: 1,
maxItems: MAX_USERS_SYNC_ITEMS,
uniqueItems: true,
},
ids: {
type: 'array',
items: { type: 'string', minLength: 1 },
minItems: 1,
maxItems: MAX_USERS_SYNC_ITEMS,
uniqueItems: true,
},
emails: {
type: 'array',
items: { type: 'string', minLength: 1 },
minItems: 1,
maxItems: MAX_USERS_SYNC_ITEMS,
uniqueItems: true,
},
ldapIds: {
type: 'array',
items: { type: 'string', minLength: 1 },
minItems: 1,
maxItems: MAX_USERS_SYNC_ITEMS,
uniqueItems: true,
},
},
additionalProperties: false,
anyOf: [{ required: ['usernames'] }, { required: ['ids'] }, { required: ['emails'] }, { required: ['ldapIds'] }],
};
export const POSTAbacUsersSyncBodySchema = ajv.compile<{
usernames?: string[];
ids?: string[];
emails?: string[];
ldapIds?: string[];
}>(PostAbacUsersSyncBody);
export const GenericErrorSchema = ajv.compile<{ success: boolean; message: string }>(GenericError);
export const GETAbacPdpHealthResponseSchema = ajv.compile<{
available: boolean;
message: string;
}>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
available: { type: 'boolean' },
message: { type: 'string' },
},
required: ['success', 'available', 'message'],
additionalProperties: false,
});
export const GETAbacPdpHealthErrorResponseSchema = ajv.compile<{ available: boolean; message: string }>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [false] },
available: { type: 'boolean', enum: [false] },
message: { type: 'string' },
},
required: ['success', 'available', 'message'],
additionalProperties: false,
});
const GETAbacRoomsListQuerySchema = {
type: 'object',
properties: {
filter: { type: 'string', minLength: 1 },
filterType: { type: 'string', enum: ['all', 'roomName', 'attribute', 'value'] },
offset: { type: 'number' },
count: { type: 'number' },
},
additionalProperties: false,
};
type GETAbacRoomsListQuery = PaginatedRequest<{ filter?: string; filterType?: 'all' | 'roomName' | 'attribute' | 'value' }>;
export const GETAbacRoomsListQueryValidator = ajvQuery.compile<GETAbacRoomsListQuery>(GETAbacRoomsListQuerySchema);
export const GETAbacRoomsResponseSchema = {
type: 'object',
properties: {
success: {
type: 'boolean',
enum: [true],
},
rooms: {
type: 'array',
items: { type: 'object' },
},
offset: {
type: 'number',
},
count: {
type: 'number',
},
total: {
type: 'number',
},
},
required: ['rooms', 'offset', 'count', 'total'],
additionalProperties: false,
};
type GETAbacRoomsResponse = PaginatedResult<{
rooms: Array<IRoom & IRoomAbacRedaction>;
}>;
export const GETAbacRoomsResponseValidator = ajv.compile<GETAbacRoomsResponse>(GETAbacRoomsResponseSchema);

View File

@@ -0,0 +1,37 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { License } from '@rocket.chat/license';
import { API } from '../../../server/api/api';
import type { NonEnterpriseTwoFactorOptions, Options } from '../../../server/api/definition';
import { use } from '../../../server/settings/Middleware';
// Overwrites two factor method to enforce 2FA check for enterprise APIs when
// no license was provided to prevent abuse on enterprise APIs.
const isNonEnterpriseTwoFactorOptions = (options?: Options): options is NonEnterpriseTwoFactorOptions =>
!!options && 'forceTwoFactorAuthenticationForNonEnterprise' in options && Boolean(options.forceTwoFactorAuthenticationForNonEnterprise);
API.v1.processTwoFactor = use(API.v1.processTwoFactor, ([params, ...context], next) => {
if (isNonEnterpriseTwoFactorOptions(params.options) && !License.hasValidLicense()) {
const options: NonEnterpriseTwoFactorOptions = {
...params.options,
twoFactorOptions: {
disableRememberMe: true,
requireSecondFactor: true,
disablePasswordFallback: false,
},
twoFactorRequired: true,
authRequired: true,
};
return next(
{
...params,
options,
},
...context,
);
}
return next(params, ...context);
});

View File

@@ -0,0 +1,504 @@
import type { IAuditLog, IMessage, IUser, IRoom } from '@rocket.chat/core-typings';
import { Rooms, AuditLog, ServerEvents } from '@rocket.chat/models';
import {
isServerEventsAuditSettingsProps,
ajv,
ajvQuery,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
validateNotFoundErrorResponse,
} from '@rocket.chat/rest-typings';
import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings';
import { convertSubObjectsIntoPaths } from '@rocket.chat/tools';
import { API } from '../../../server/api/api';
import { getPaginationItems } from '../../../server/api/lib/getPaginationItems';
import { findUsersOfRoom } from '../../../server/lib/findUsersOfRoom';
import { getUsersHiddenFrom, redactHiddenUsers } from '../../../server/lib/statusVisibility/hiddenUsers';
import { auditGetAuditionsMethod, auditGetMessagesMethod, auditGetOmnichannelMessagesMethod } from '../lib/audit/functions';
type AuditRoomMembersParams = PaginatedRequest<{
roomId: string;
filter: string;
}>;
const auditRoomMembersSchema = {
type: 'object',
properties: {
roomId: { type: 'string', minLength: 1 },
filter: { type: 'string' },
count: { type: 'number' },
offset: { type: 'number' },
sort: { type: 'string' },
},
required: ['roomId'],
additionalProperties: false,
};
export const isAuditRoomMembersProps = ajvQuery.compile<AuditRoomMembersParams>(auditRoomMembersSchema);
type AuditAuditionsParams = { startDate: string; endDate: string };
const auditAuditionsSchema = {
type: 'object',
properties: {
startDate: { type: 'string', minLength: 1 },
endDate: { type: 'string', minLength: 1 },
},
required: ['startDate', 'endDate'],
additionalProperties: false,
};
const isAuditAuditionsProps = ajvQuery.compile<AuditAuditionsParams>(auditAuditionsSchema);
type AuditMessagesPayload = {
rid?: string;
startDate: string;
endDate: string;
users: string[];
msg: string;
type: string;
visitor?: string;
agent?: string;
};
const auditMessagesSchema = {
type: 'object',
properties: {
rid: { type: 'string' },
startDate: { type: 'string', minLength: 1 },
endDate: { type: 'string', minLength: 1 },
users: { type: 'array', items: { type: 'string' } },
msg: { type: ['string', 'null'] },
type: { type: 'string' },
visitor: { type: ['string', 'null'] },
agent: { type: ['string', 'null'] },
},
required: ['startDate', 'endDate', 'users', 'msg', 'type'],
additionalProperties: false,
};
const isAuditMessagesProps = ajv.compile<AuditMessagesPayload>(auditMessagesSchema);
type AuditOmnichannelMessagesPayload = {
startDate: string;
endDate: string;
users: string[];
msg: string;
type: string;
visitor?: string;
agent?: string;
};
const auditOmnichannelMessagesSchema = {
type: 'object',
properties: {
startDate: { type: 'string', minLength: 1 },
endDate: { type: 'string', minLength: 1 },
users: { type: 'array', items: { type: 'string' } },
msg: { type: ['string', 'null'] },
type: { type: 'string' },
visitor: { type: ['string', 'null'] },
agent: { type: ['string', 'null'] },
},
required: ['startDate', 'endDate', 'users', 'msg', 'type'],
additionalProperties: false,
};
const isAuditOmnichannelMessagesProps = ajv.compile<AuditOmnichannelMessagesPayload>(auditOmnichannelMessagesSchema);
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Endpoints {
'/v1/audit/rooms.members': {
GET: (
params: AuditRoomMembersParams,
) => PaginatedResult<{ members: Pick<IUser, '_id' | 'name' | 'username' | 'status' | '_updatedAt'>[] }>;
};
'/v1/audit.auditions': {
GET: (params: AuditAuditionsParams) => { auditions: IAuditLog[] };
};
'/v1/audit.messages': {
POST: (params: AuditMessagesPayload) => { messages: IMessage[] };
};
'/v1/audit.omnichannelMessages': {
POST: (params: AuditOmnichannelMessagesPayload) => { messages: IMessage[] };
};
}
}
const auditRoomMembersResponseSchema = ajv.compile<
PaginatedResult<{ members: Pick<IUser, '_id' | 'name' | 'username' | 'status' | '_updatedAt'>[] }>
>({
type: 'object',
properties: {
members: {
type: 'array',
items: {
type: 'object',
properties: {
_id: { type: 'string' },
name: { type: 'string' },
username: { type: 'string' },
nickname: { type: 'string' },
status: { type: 'string', enum: ['online', 'away', 'offline', 'busy', 'disabled'] },
avatarETag: { type: 'string' },
federated: { type: 'boolean' },
_updatedAt: { type: 'string', format: 'date-time' },
},
required: ['_id'],
additionalProperties: false,
},
},
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(
'audit/rooms.members',
{
authRequired: true,
permissionsRequired: ['view-members-list-all-rooms'],
query: isAuditRoomMembersProps,
license: ['auditing'],
response: {
200: auditRoomMembersResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
},
},
async function action() {
const { roomId, filter } = this.queryParams;
const { count: limit, offset: skip } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const room = await Rooms.findOneById<Pick<IRoom, '_id' | 'name' | 'fname'>>(roomId, { projection: { _id: 1, name: 1, fname: 1 } });
if (!room) {
return API.v1.notFound();
}
const hidden = await getUsersHiddenFrom(this.userId);
const { cursor, totalCount } = await findUsersOfRoom({
rid: room._id,
hidden,
filter,
skip,
limit,
...(sort?.username && { sort: { username: sort.username } }),
});
const [members, total] = await Promise.all([cursor.toArray(), totalCount]);
await AuditLog.insertOne({
ts: new Date(),
results: total,
u: {
_id: this.user._id,
username: this.user.username,
name: this.user.name,
...(this.user.avatarETag && { avatarETag: this.user.avatarETag }),
},
fields: {
msg: 'Room_members_list',
rids: [room._id],
type: 'room_member_list',
room: room.name || room.fname,
filters: filter,
},
});
return API.v1.success({
members: redactHiddenUsers(members, hidden),
count: members.length,
offset: skip,
total,
});
},
);
API.v1.get(
'audit.settings',
{
response: {
200: ajv.compile({
additionalProperties: false,
type: 'object',
properties: {
events: {
type: 'array',
items: {
type: 'object',
},
},
count: {
type: 'number',
description: 'The number of events returned in this response.',
},
offset: {
type: 'number',
description: 'The number of events that were skipped in this response.',
},
total: {
type: 'number',
description: 'The total number of events that match the query.',
},
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
},
required: ['events', 'count', 'offset', 'total', 'success'],
}),
400: ajv.compile({
type: 'object',
properties: {
success: {
type: 'boolean',
enum: [false],
},
error: {
type: 'string',
},
errorType: {
type: 'string',
},
},
required: ['success', 'error'],
}),
},
query: isServerEventsAuditSettingsProps,
authRequired: true,
permissionsRequired: ['can-audit'],
license: ['auditing'],
},
async function action() {
const { start, end, settingId, actor } = this.queryParams;
if (start && isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
if (end && isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const _sort = { ts: sort?.ts ? sort?.ts : -1 };
const { cursor, totalCount } = ServerEvents.findPaginated(
{
...(settingId && { 'data.key': 'id', 'data.value': settingId }),
...(actor && convertSubObjectsIntoPaths({ actor })),
ts: {
$gte: start ? new Date(start) : new Date(0),
$lte: end ? new Date(end) : new Date(),
},
t: 'settings.changed',
},
{
sort: _sort,
skip: offset,
limit: count,
allowDiskUse: true,
},
);
const [events, total] = await Promise.all([cursor.toArray(), totalCount]);
return API.v1.success({
events,
count: events.length,
offset,
total,
});
},
);
const auditAuditionsResponseSchema = ajv.compile<{ auditions: IAuditLog[] }>({
type: 'object',
properties: {
auditions: {
type: 'array',
items: {
type: 'object',
properties: {
_id: { type: 'string' },
_updatedAt: { type: 'string', format: 'date-time' },
ts: { type: 'string', format: 'date-time' },
results: { type: 'number' },
u: {
type: 'object',
properties: {
_id: { type: 'string' },
username: { type: 'string' },
name: { type: 'string' },
avatarETag: { type: 'string' },
},
required: ['_id'],
additionalProperties: false,
},
fields: {
type: 'object',
properties: {
type: { type: 'string' },
msg: { type: ['string', 'null'] },
startDate: { type: ['string', 'null'], format: 'date-time' },
endDate: { type: ['string', 'null'], format: 'date-time' },
rids: { type: ['array', 'null'], items: { type: 'string' } },
room: { type: ['string', 'null'] },
users: { type: ['array', 'null'], items: { type: 'string' } },
visitor: { type: ['string', 'null'] },
agent: { type: ['string', 'null'] },
filters: { type: ['string', 'null'] },
},
required: ['type'],
additionalProperties: false,
},
},
required: ['_id', 'ts', 'results', 'u', 'fields'],
additionalProperties: false,
},
},
success: { type: 'boolean', enum: [true] },
},
required: ['auditions', 'success'],
additionalProperties: false,
});
// `messages` items are full IMessage documents. A strict `$ref` to IMessage fails response
// validation because its attachment union is a `oneOf` whose branches overlap on file/quote
// attachments (AJV `passingSchemas: 0,1`), so real audit results with attachments would 400.
// Kept relaxed until the IMessage attachment schema gains discriminators.
const auditMessagesResponseSchema = ajv.compile<{ messages: IMessage[] }>({
type: 'object',
properties: {
messages: { type: 'array', items: { type: 'object' } },
success: { type: 'boolean', enum: [true] },
},
required: ['messages', 'success'],
additionalProperties: false,
});
const auditErrorResponseSchema = ajv.compile({
type: 'object',
properties: {
success: { type: 'boolean', enum: [false] },
error: { type: 'string' },
errorType: { type: 'string' },
},
required: ['success', 'error'],
});
const parseDateOrFail = (value: string, name: string): Date => {
const ts = Date.parse(value);
if (Number.isNaN(ts)) {
throw new Error(`The "${name}" parameter must be a valid date.`);
}
return new Date(ts);
};
API.v1.get(
'audit.auditions',
{
authRequired: true,
permissionsRequired: ['can-audit-log'],
query: isAuditAuditionsProps,
license: ['auditing'],
rateLimiterOptions: { numRequestsAllowed: 10, intervalTimeInMS: 60000 },
response: {
200: auditAuditionsResponseSchema,
400: auditErrorResponseSchema,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const startDate = parseDateOrFail(this.queryParams.startDate, 'startDate');
const endDate = parseDateOrFail(this.queryParams.endDate, 'endDate');
const auditions = await auditGetAuditionsMethod(this.userId, startDate, endDate);
return API.v1.success({ auditions });
},
);
API.v1.post(
'audit.messages',
{
authRequired: true,
permissionsRequired: ['can-audit'],
body: isAuditMessagesProps,
license: ['auditing'],
rateLimiterOptions: { numRequestsAllowed: 10, intervalTimeInMS: 60000 },
response: {
200: auditMessagesResponseSchema,
400: auditErrorResponseSchema,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { rid, users, msg, type, visitor, agent } = this.bodyParams;
const startDate = parseDateOrFail(this.bodyParams.startDate, 'startDate');
const endDate = parseDateOrFail(this.bodyParams.endDate, 'endDate');
const messages = await auditGetMessagesMethod(this.userId, {
rid,
startDate,
endDate,
users,
msg,
type,
visitor,
agent,
});
return API.v1.success({ messages });
},
);
API.v1.post(
'audit.omnichannelMessages',
{
authRequired: true,
permissionsRequired: ['can-audit'],
body: isAuditOmnichannelMessagesProps,
license: ['auditing'],
rateLimiterOptions: { numRequestsAllowed: 10, intervalTimeInMS: 60000 },
response: {
200: auditMessagesResponseSchema,
400: auditErrorResponseSchema,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { users, msg, type, visitor, agent } = this.bodyParams;
const startDate = parseDateOrFail(this.bodyParams.startDate, 'startDate');
const endDate = parseDateOrFail(this.bodyParams.endDate, 'endDate');
const messages = await auditGetOmnichannelMessagesMethod(this.userId, {
startDate,
endDate,
users,
msg,
type,
visitor,
agent,
});
return API.v1.success({ messages });
},
);

View File

@@ -0,0 +1,61 @@
import type { IMessage, IReadReceiptWithUser } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import {
ajv,
isChatGetMessageReadReceiptsProps,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import { Meteor } from 'meteor/meteor';
import { API } from '../../../server/api/api';
import { getReadReceiptsFunction } from '../meteor-methods/getReadReceipts';
type GetMessageReadReceiptsProps = {
messageId: IMessage['_id'];
};
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Endpoints {
'/v1/chat.getMessageReadReceipts': {
GET: (params: GetMessageReadReceiptsProps) => {
receipts: IReadReceiptWithUser[];
};
};
}
}
const getMessageReadReceiptsResponseSchema = ajv.compile<{ receipts: IReadReceiptWithUser[] }>({
type: 'object',
properties: {
receipts: { type: 'array', items: { $ref: '#/components/schemas/IReadReceiptWithUser' } },
success: { type: 'boolean', enum: [true] },
},
required: ['receipts', 'success'],
additionalProperties: false,
});
API.v1.get(
'chat.getMessageReadReceipts',
{
authRequired: true,
query: isChatGetMessageReadReceiptsProps,
response: {
200: getMessageReadReceiptsResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
if (!License.hasModule('message-read-receipt')) {
throw new Meteor.Error('error-action-not-allowed', 'This is an enterprise feature');
}
const { messageId } = this.queryParams;
return API.v1.success({
receipts: await getReadReceiptsFunction(messageId, this.userId),
});
},
);

View File

@@ -0,0 +1,136 @@
import type { IDirectMessageRoom, IRoom } from '@rocket.chat/core-typings';
import {
ajv,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';
import { check, Match } from 'meteor/check';
import { API } from '../../../../server/api';
import { getPaginationItems } from '../../../../server/api/lib/getPaginationItems';
import { findChannelsWithNumberOfMessages } from '../../lib/engagementDashboard/channels';
import { isDateISOString, mapDateForAPI } from '../../lib/engagementDashboard/date';
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Endpoints {
'/v1/engagement-dashboard/channels/list': {
GET: (params: { start: string; end: string; offset?: number; count?: number }) => {
channels: {
room: {
_id: IRoom['_id'];
name: IRoom['name'];
ts: IRoom['ts'];
t: IRoom['t'];
_updatedAt: IRoom['_updatedAt'];
usernames?: IDirectMessageRoom['usernames'];
};
messages: number;
lastWeekMessages: number;
diffFromLastWeek: number;
}[];
count: number;
offset: number;
total: number;
};
};
}
}
const channelsListResponseSchema = ajv.compile<{
channels: {
room: {
_id: IRoom['_id'];
name: IRoom['name'];
ts: IRoom['ts'];
t: IRoom['t'];
_updatedAt: IRoom['_updatedAt'];
usernames?: IDirectMessageRoom['usernames'];
};
messages: number;
lastWeekMessages: number;
diffFromLastWeek: number;
}[];
count: number;
offset: number;
total: number;
}>({
type: 'object',
properties: {
channels: {
type: 'array',
items: {
type: 'object',
properties: {
room: {
type: 'object',
properties: {
_id: { type: 'string' },
name: { type: 'string' },
ts: { type: 'string', format: 'date-time' },
t: { type: 'string' },
_updatedAt: { type: 'string', format: 'date-time' },
usernames: { type: 'array', items: { type: 'string' } },
},
required: ['_id'],
additionalProperties: false,
},
messages: { type: 'number' },
lastWeekMessages: { type: 'number' },
diffFromLastWeek: { type: 'number' },
},
required: ['room', 'messages', 'lastWeekMessages', 'diffFromLastWeek'],
additionalProperties: false,
},
},
count: { type: 'number' },
offset: { type: 'number' },
total: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['channels', 'count', 'offset', 'total', 'success'],
additionalProperties: false,
});
API.v1.get(
'engagement-dashboard/channels/list',
{
authRequired: true,
permissionsRequired: ['view-engagement-dashboard'],
license: ['engagement-dashboard'],
response: {
200: channelsListResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
check(
this.queryParams,
Match.ObjectIncluding({
start: Match.Where(isDateISOString),
end: Match.Where(isDateISOString),
offset: Match.Maybe(String),
count: Match.Maybe(String),
}),
);
const { start, end } = this.queryParams;
const { offset, count } = await getPaginationItems(this.queryParams);
const { channels, total } = await findChannelsWithNumberOfMessages({
start: mapDateForAPI(start),
end: mapDateForAPI(end),
options: { offset, count },
});
return API.v1.success({
channels,
total,
offset,
count: channels.length,
});
},
);

View File

@@ -0,0 +1,3 @@
import './messages';
import './channels';
import './users';

View File

@@ -0,0 +1,234 @@
import type { IDirectMessageRoom, IRoom } from '@rocket.chat/core-typings';
import {
ajv,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';
import { check, Match } from 'meteor/check';
import { API } from '../../../../server/api';
import { isDateISOString, transformDatesForAPI } from '../../lib/engagementDashboard/date';
import {
findWeeklyMessagesSentData,
findMessagesSentOrigin,
findTopFivePopularChannelsByMessageSentQuantity,
} from '../../lib/engagementDashboard/messages';
const weeklyDataResponseSchema = ajv.compile<{
days: { day: Date; messages: number }[];
period: { count: number; variation: number };
yesterday: { count: number; variation: number };
}>({
type: 'object',
properties: {
days: {
type: 'array',
items: {
type: 'object',
properties: {
day: { type: 'string', format: 'date-time' },
messages: { type: 'number' },
},
required: ['day', 'messages'],
additionalProperties: false,
},
},
period: {
type: 'object',
properties: {
count: { type: 'number' },
variation: { type: 'number' },
},
required: ['count', 'variation'],
additionalProperties: false,
},
yesterday: {
type: 'object',
properties: {
count: { type: 'number' },
variation: { type: 'number' },
},
required: ['count', 'variation'],
additionalProperties: false,
},
success: { type: 'boolean', enum: [true] },
},
required: ['days', 'period', 'yesterday', 'success'],
additionalProperties: false,
});
const messagesOriginResponseSchema = ajv.compile<{ origins: { t: IRoom['t']; messages: number }[] }>({
type: 'object',
properties: {
origins: {
type: 'array',
items: {
type: 'object',
properties: {
t: { type: 'string' },
messages: { type: 'number' },
},
required: ['t', 'messages'],
additionalProperties: false,
},
},
success: { type: 'boolean', enum: [true] },
},
required: ['origins', 'success'],
additionalProperties: false,
});
const topFivePopularChannelsResponseSchema = ajv.compile<{
channels: {
t: IRoom['t'];
messages: number;
name: IRoom['name'];
usernames?: IDirectMessageRoom['usernames'];
}[];
}>({
type: 'object',
properties: {
channels: {
type: 'array',
items: {
type: 'object',
properties: {
t: { type: 'string' },
messages: { type: 'number' },
name: { type: 'string' },
usernames: { type: 'array', items: { type: 'string' } },
},
required: ['t', 'messages'],
additionalProperties: false,
},
},
success: { type: 'boolean', enum: [true] },
},
required: ['channels', 'success'],
additionalProperties: false,
});
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Endpoints {
'/v1/engagement-dashboard/messages/origin': {
GET: (params: { start: string; end: string }) => {
origins: {
t: IRoom['t'];
messages: number;
}[];
};
};
'/v1/engagement-dashboard/messages/top-five-popular-channels': {
GET: (params: { start: string; end: string }) => {
channels: {
t: IRoom['t'];
messages: number;
name: IRoom['name'];
usernames?: IDirectMessageRoom['usernames'];
}[];
};
};
'/v1/engagement-dashboard/messages/messages-sent': {
GET: (params: { start: string; end: string }) => {
days: { day: Date; messages: number }[];
period: {
count: number;
variation: number;
};
yesterday: {
count: number;
variation: number;
};
};
};
}
}
API.v1.get(
'engagement-dashboard/messages/messages-sent',
{
authRequired: true,
permissionsRequired: ['view-engagement-dashboard'],
license: ['engagement-dashboard'],
response: {
200: weeklyDataResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
check(
this.queryParams,
Match.ObjectIncluding({
start: Match.Where(isDateISOString),
end: Match.Where(isDateISOString),
}),
);
const { start, end } = this.queryParams;
const data = await findWeeklyMessagesSentData(transformDatesForAPI(start, end));
return API.v1.success(data);
},
);
API.v1.get(
'engagement-dashboard/messages/origin',
{
authRequired: true,
permissionsRequired: ['view-engagement-dashboard'],
license: ['engagement-dashboard'],
response: {
200: messagesOriginResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
check(
this.queryParams,
Match.ObjectIncluding({
start: Match.Where(isDateISOString),
end: Match.Where(isDateISOString),
}),
);
const { start, end } = this.queryParams;
const data = await findMessagesSentOrigin(transformDatesForAPI(start, end));
return API.v1.success(data);
},
);
API.v1.get(
'engagement-dashboard/messages/top-five-popular-channels',
{
authRequired: true,
permissionsRequired: ['view-engagement-dashboard'],
license: ['engagement-dashboard'],
response: {
200: topFivePopularChannelsResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
check(
this.queryParams,
Match.ObjectIncluding({
start: Match.Where(isDateISOString),
end: Match.Where(isDateISOString),
}),
);
const { start, end } = this.queryParams;
const data = await findTopFivePopularChannelsByMessageSentQuantity(transformDatesForAPI(start, end));
return API.v1.success(data);
},
);

View File

@@ -0,0 +1,361 @@
import type { IUser } from '@rocket.chat/core-typings';
import {
ajv,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';
import { check, Match } from 'meteor/check';
import { API } from '../../../../server/api';
import { isDateISOString, transformDatesForAPI } from '../../lib/engagementDashboard/date';
import {
findWeeklyUsersRegisteredData,
findActiveUsersMonthlyData,
findBusiestsChatsInADayByHours,
findBusiestsChatsWithinAWeek,
findUserSessionsByHourWithinAWeek,
} from '../../lib/engagementDashboard/users';
const newUsersResponseSchema = ajv.compile<{
days: { day: Date; users: number }[];
period: { count: number; variation: number };
yesterday: { count: number; variation: number };
}>({
type: 'object',
properties: {
days: {
type: 'array',
items: {
type: 'object',
properties: {
day: { type: 'string', format: 'date-time' },
users: { type: 'number' },
},
required: ['day', 'users'],
additionalProperties: false,
},
},
period: {
type: 'object',
properties: {
count: { type: 'number' },
variation: { type: 'number' },
},
required: ['count', 'variation'],
additionalProperties: false,
},
yesterday: {
type: 'object',
properties: {
count: { type: 'number' },
variation: { type: 'number' },
},
required: ['count', 'variation'],
additionalProperties: false,
},
success: { type: 'boolean', enum: [true] },
},
required: ['days', 'period', 'yesterday', 'success'],
additionalProperties: false,
});
const activeUsersResponseSchema = ajv.compile<{
month: { day: number; month: number; year: number; usersList: IUser['_id'][]; users: number }[];
}>({
type: 'object',
properties: {
month: {
type: 'array',
items: {
type: 'object',
properties: {
day: { type: 'number' },
month: { type: 'number' },
year: { type: 'number' },
usersList: { type: 'array', items: { type: 'string' } },
users: { type: 'number' },
},
required: ['day', 'month', 'year', 'usersList', 'users'],
additionalProperties: false,
},
},
success: { type: 'boolean', enum: [true] },
},
required: ['month', 'success'],
additionalProperties: false,
});
const hourlyDataResponseSchema = ajv.compile<{ hours: { hour: number; users: number }[] }>({
type: 'object',
properties: {
hours: {
type: 'array',
items: {
type: 'object',
properties: {
hour: { type: 'number' },
users: { type: 'number' },
},
required: ['hour', 'users'],
additionalProperties: false,
},
},
success: { type: 'boolean', enum: [true] },
},
required: ['hours', 'success'],
additionalProperties: false,
});
const weeklyChatBusierResponseSchema = ajv.compile<{
month: { day: number; month: number; year: number; users: number }[];
}>({
type: 'object',
properties: {
month: {
type: 'array',
items: {
type: 'object',
properties: {
day: { type: 'number' },
month: { type: 'number' },
year: { type: 'number' },
users: { type: 'number' },
},
required: ['day', 'month', 'year', 'users'],
additionalProperties: false,
},
},
success: { type: 'boolean', enum: [true] },
},
required: ['month', 'success'],
additionalProperties: false,
});
const userSessionsByHourResponseSchema = ajv.compile<{
week: { hour: number; day: number; month: number; year: number; users: number }[];
}>({
type: 'object',
properties: {
week: {
type: 'array',
items: {
type: 'object',
properties: {
hour: { type: 'number' },
day: { type: 'number' },
month: { type: 'number' },
year: { type: 'number' },
users: { type: 'number' },
},
required: ['hour', 'day', 'month', 'year', 'users'],
additionalProperties: false,
},
},
success: { type: 'boolean', enum: [true] },
},
required: ['week', 'success'],
additionalProperties: false,
});
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Endpoints {
'/v1/engagement-dashboard/users/active-users': {
GET: (params: { start: string; end: string }) => {
month: {
day: number;
month: number;
year: number;
usersList: IUser['_id'][];
users: number;
}[];
};
};
'/v1/engagement-dashboard/users/chat-busier/weekly-data': {
GET: (params: { start: string }) => {
month: {
users: number;
day: number;
month: number;
year: number;
}[];
};
};
'/v1/engagement-dashboard/users/chat-busier/hourly-data': {
GET: (params: { start: string }) => {
hours: {
users: number;
hour: number;
}[];
};
};
'/v1/engagement-dashboard/users/users-by-time-of-the-day-in-a-week': {
GET: (params: { start: string; end: string }) => {
week: {
users: number;
hour: number;
day: number;
month: number;
year: number;
}[];
};
};
'/v1/engagement-dashboard/users/new-users': {
GET: (params: { start: string; end: string }) => {
days: { day: Date; users: number }[];
period: {
count: number;
variation: number;
};
yesterday: {
count: number;
variation: number;
};
};
};
}
}
API.v1.get(
'engagement-dashboard/users/new-users',
{
authRequired: true,
permissionsRequired: ['view-engagement-dashboard'],
license: ['engagement-dashboard'],
response: {
200: newUsersResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
check(
this.queryParams,
Match.ObjectIncluding({
start: Match.Where(isDateISOString),
end: Match.Where(isDateISOString),
}),
);
const { start, end } = this.queryParams;
const data = await findWeeklyUsersRegisteredData(transformDatesForAPI(start, end));
return API.v1.success(data);
},
);
API.v1.get(
'engagement-dashboard/users/active-users',
{
authRequired: true,
permissionsRequired: ['view-engagement-dashboard'],
license: ['engagement-dashboard'],
response: {
200: activeUsersResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
check(
this.queryParams,
Match.ObjectIncluding({
start: Match.Where(isDateISOString),
end: Match.Where(isDateISOString),
}),
);
const { start, end } = this.queryParams;
const data = await findActiveUsersMonthlyData(transformDatesForAPI(start, end));
return API.v1.success(data);
},
);
API.v1.get(
'engagement-dashboard/users/chat-busier/hourly-data',
{
authRequired: true,
permissionsRequired: ['view-engagement-dashboard'],
license: ['engagement-dashboard'],
response: {
200: hourlyDataResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
check(
this.queryParams,
Match.ObjectIncluding({
start: Match.Where(isDateISOString),
}),
);
const { start } = this.queryParams;
const data = await findBusiestsChatsInADayByHours(transformDatesForAPI(start));
return API.v1.success(data);
},
);
API.v1.get(
'engagement-dashboard/users/chat-busier/weekly-data',
{
authRequired: true,
permissionsRequired: ['view-engagement-dashboard'],
license: ['engagement-dashboard'],
response: {
200: weeklyChatBusierResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
check(
this.queryParams,
Match.ObjectIncluding({
start: Match.Where(isDateISOString),
}),
);
const { start } = this.queryParams;
const data = await findBusiestsChatsWithinAWeek(transformDatesForAPI(start));
return API.v1.success(data);
},
);
API.v1.get(
'engagement-dashboard/users/users-by-time-of-the-day-in-a-week',
{
authRequired: true,
permissionsRequired: ['view-engagement-dashboard'],
license: ['engagement-dashboard'],
response: {
200: userSessionsByHourResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
check(
this.queryParams,
Match.ObjectIncluding({
start: Match.Where(isDateISOString),
end: Match.Where(isDateISOString),
}),
);
const { start, end } = this.queryParams;
const data = await findUserSessionsByHourWithinAWeek(transformDatesForAPI(start, end));
return API.v1.success(data);
},
);

View File

@@ -0,0 +1,53 @@
import { FederationMatrix } from '@rocket.chat/core-services';
import { getFederationRoutes } from '@rocket.chat/federation-matrix';
import { Logger } from '@rocket.chat/logger';
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
import type express from 'express';
import { WebApp } from 'meteor/webapp';
import { API } from '../../../server/api';
import { getTrimmedServerVersion } from '../../../server/api/lib/getTrimmedServerVersion';
const logger = new Logger('FederationRoutes');
API.v1.get(
'/federation/matrixIds.verify',
{
authRequired: true,
query: ajvQuery.compile<{
matrixIds: string[];
}>({
type: 'object',
properties: {
matrixIds: { type: 'array', items: { type: 'string' } },
},
}),
response: {
200: ajv.compile<{
results: { [key: string]: string };
}>({
type: 'object',
properties: {
results: { type: 'object', additionalProperties: { type: 'string' } },
},
}),
},
},
async function () {
const { matrixIds } = this.queryParams;
return API.v1.success({
results: await FederationMatrix.verifyMatrixIds(matrixIds),
});
},
);
export async function registerFederationRoutes(): Promise<void> {
try {
const routes = getFederationRoutes(getTrimmedServerVersion());
(WebApp.rawConnectHandlers as unknown as ReturnType<typeof express>).use(routes.matrix.router).use(routes.wellKnown.router);
} catch (error) {
logger.error({ msg: '[Federation] Failed to register routes:', err: error });
throw error;
}
}

View File

@@ -0,0 +1,11 @@
import './api';
import './ldap';
import './licenses';
import './sessions';
import './chat';
import './roles';
import '../apps/communication/uikit';
import './engagementDashboard';
import './audit';
import './abac';
import './mcp';

View File

@@ -0,0 +1,50 @@
import { LDAPEnterprise } from '@rocket.chat/core-services';
import { ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings';
import { API } from '../../../server/api/api';
import { hasPermissionAsync } from '../../../server/lib/authorization/hasPermission';
import { settings } from '../../../server/settings';
const ldapSyncNowResponseSchema = ajv.compile<{ message: string }>({
type: 'object',
properties: {
message: { type: 'string' },
success: { type: 'boolean', enum: [true] },
},
required: ['message', 'success'],
additionalProperties: false,
});
API.v1.post(
'ldap.syncNow',
{
authRequired: true,
forceTwoFactorAuthenticationForNonEnterprise: true,
twoFactorRequired: true,
response: {
200: ldapSyncNowResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
if (!this.userId) {
throw new Error('error-invalid-user');
}
if (!(await hasPermissionAsync(this.user, 'sync-auth-services-users'))) {
throw new Error('error-not-authorized');
}
if (settings.get('LDAP_Enable') !== true) {
throw new Error('LDAP_disabled');
}
await LDAPEnterprise.sync();
await LDAPEnterprise.syncAvatarAndAbacAttributes();
return API.v1.success({
message: 'Sync_in_progress' as const,
});
},
);

View File

@@ -0,0 +1,168 @@
import { CannedResponse } from '@rocket.chat/models';
import { escapeRegExp } from '@rocket.chat/tools';
import { hasPermissionAsync } from '../../../../server/lib/authorization/hasPermission';
import { getDepartmentsWhichUserCanAccess } from '../v1/omnichannel/lib/departments';
export async function findAllCannedResponses({ userId }) {
// If the user is an admin or livechat manager, get his own responses and all responses from all departments
if (await hasPermissionAsync(userId, 'view-all-canned-responses')) {
const cannedResponses = await CannedResponse.find({
$or: [
{
scope: 'user',
userId,
},
{
scope: 'department',
},
{
scope: 'global',
},
],
}).toArray();
return cannedResponses;
}
// Last scenario: user is an agent, so get his own responses and those from the departments he is in
const accessibleDepartments = await getDepartmentsWhichUserCanAccess(userId, true);
const cannedResponses = await CannedResponse.find({
$or: [
{
scope: 'user',
userId,
},
{
scope: 'department',
departmentId: {
$in: accessibleDepartments,
},
},
{
scope: 'global',
},
],
}).toArray();
return cannedResponses;
}
/**
* @param {Object} param0
* @param {String} param0.userId
* @param {String} [param0.shortcut]
* @param {String} [param0.text]
* @param {String} [param0.departmentId]
* @param {String} [param0.scope]
* @param {String} [param0.createdBy]
* @param {String[]} [param0.tags]
* @param {Object} param0.options
* @param {Number} param0.options.offset
* @param {Number} param0.options.count
* @param {Object} param0.options.sort
* @param {Object} param0.options.fields
*/
export async function findAllCannedResponsesFilter({ userId, shortcut, text, departmentId, scope, createdBy, tags = [], options = {} }) {
let extraFilter = [];
// if user cannot see all, filter to private + public + departments user is in
if (!(await hasPermissionAsync(userId, 'view-all-canned-responses'))) {
const accessibleDepartments = await getDepartmentsWhichUserCanAccess(userId, true);
const isDepartmentInScope = (departmentId) => !!accessibleDepartments.includes(departmentId);
const departmentIds = departmentId && isDepartmentInScope(departmentId) ? [departmentId] : accessibleDepartments;
extraFilter = [
{
$or: [
{
scope: 'user',
userId,
},
{
scope: 'department',
departmentId: {
$in: departmentIds,
},
},
{
scope: 'global',
},
],
},
];
}
if (departmentId) {
extraFilter = [
{
departmentId,
},
];
}
const textFilter = new RegExp(escapeRegExp(text), 'i');
let filter = {
$and: [
...(shortcut ? [{ shortcut }] : []),
...(text ? [{ $or: [{ shortcut: textFilter }, { text: textFilter }] }] : []),
...(scope ? [{ scope }] : []),
...(createdBy ? [{ 'createdBy._id': createdBy }] : []),
...(tags.length
? [
{
tags: {
$in: tags,
},
},
]
: []),
...extraFilter,
],
};
if (!filter.$and.length) {
filter = {};
}
const { cursor, totalCount } = CannedResponse.findPaginated(filter, {
sort: options.sort || { shortcut: 1 },
skip: options.offset,
limit: options.count,
});
const [cannedResponses, total] = await Promise.all([cursor.toArray(), totalCount]);
return {
cannedResponses,
total,
};
}
export async function findOneCannedResponse({ userId, _id }) {
if (await hasPermissionAsync(userId, 'view-all-canned-responses')) {
return CannedResponse.findOneById(_id);
}
const accessibleDepartments = await getDepartmentsWhichUserCanAccess(userId, true);
const filter = {
_id,
$or: [
{
scope: 'user',
userId,
},
{
scope: 'department',
departmentId: {
$in: accessibleDepartments,
},
},
{
scope: 'global',
},
],
};
return CannedResponse.findOne(filter);
}

View File

@@ -0,0 +1,182 @@
import type { Cloud, LicenseInfo } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { Settings, Users } from '@rocket.chat/models';
import {
ajv,
isLicensesAddProps,
isLicensesInfoProps,
isLicensesValidateProps,
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import { API } from '../../../server/api/api';
import { hasPermissionAsync } from '../../../server/lib/authorization/hasPermission';
import { notifyOnSettingChangedById } from '../../../server/lib/notifyListener';
import { settings } from '../../../server/settings';
import { updateAuditedByUser } from '../../../server/settings/lib/auditedSettingUpdates';
// LicenseInfo is a large, loosely-matching shape (branded LicenseModule names, partial `limits`,
// a nested ILicenseV3) that a strict typia $ref can't validate without fighting the type, so the
// license/announcement payloads are validated as open objects (same relaxation as omnichannel config).
const licensesInfoResponseSchema = ajv.compile<{ license: LicenseInfo; cloudSyncAnnouncement?: Cloud.ICloudSyncAnnouncement }>({
type: 'object',
properties: {
license: { type: 'object' },
cloudSyncAnnouncement: { type: 'object' },
success: { type: 'boolean', enum: [true] },
},
required: ['license', 'success'],
additionalProperties: false,
});
const successResponseSchema = ajv.compile<void>({
type: 'object',
properties: { success: { type: 'boolean', enum: [true] } },
required: ['success'],
additionalProperties: false,
});
const licensesValidateErrorSchema = ajv.compile<{
success: false;
error?: string;
errorType?: string;
stack?: string;
reasons?: unknown[];
}>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [false] },
error: { type: 'string' },
errorType: { type: 'string' },
stack: { type: 'string' },
reasons: { type: 'array' },
},
required: ['success'],
additionalProperties: false,
});
const licensesMaxActiveUsersResponseSchema = ajv.compile<{ maxActiveUsers: number | null; activeUsers: number }>({
type: 'object',
properties: {
maxActiveUsers: { type: ['number', 'null'] },
activeUsers: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['maxActiveUsers', 'activeUsers', 'success'],
additionalProperties: false,
});
API.v1.get(
'licenses.info',
{
authRequired: true,
query: isLicensesInfoProps,
response: {
200: licensesInfoResponseSchema,
401: validateUnauthorizedErrorResponse,
400: validateBadRequestErrorResponse,
},
},
async function action() {
const unrestrictedAccess = await hasPermissionAsync(this.user, 'view-privileged-setting');
const loadCurrentValues = unrestrictedAccess && Boolean(this.queryParams.loadValues);
const license = await License.getInfo({
limits: unrestrictedAccess,
license: unrestrictedAccess,
currentValues: loadCurrentValues,
});
let cloudSyncAnnouncement;
try {
// TODO: Remove this logic after setting type object is implemented.
cloudSyncAnnouncement = JSON.parse(settings.get('Cloud_Sync_Announcement_Payload') ?? null);
} catch (error) {
console.error('Unable to parse Cloud_Sync_Announcement_Payload');
}
const canManageCloud = await hasPermissionAsync(this.user, 'manage-cloud');
return API.v1.success({
license,
...(canManageCloud && cloudSyncAnnouncement && { cloudSyncAnnouncement }),
});
},
);
API.v1.post(
'licenses.add',
{
authRequired: true,
permissionsRequired: ['edit-privileged-setting'],
body: isLicensesAddProps,
response: {
200: successResponseSchema,
401: validateUnauthorizedErrorResponse,
400: validateBadRequestErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { license } = this.bodyParams;
if (!(await License.validateFormat(license))) {
return API.v1.failure('Invalid license');
}
const auditSettingOperation = updateAuditedByUser({
_id: this.userId,
username: this.user.username ?? '',
ip: this.requestIp ?? '',
useragent: this.request.headers.get('user-agent') || '',
});
(await auditSettingOperation(Settings.updateValueById, 'Enterprise_License', license)).modifiedCount &&
void notifyOnSettingChangedById('Enterprise_License');
return API.v1.success();
},
);
API.v1.post(
'licenses.validate',
{
authRequired: true,
permissionsRequired: ['edit-privileged-setting'],
body: isLicensesValidateProps,
response: {
200: successResponseSchema,
401: validateUnauthorizedErrorResponse,
400: licensesValidateErrorSchema,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { license } = this.bodyParams;
const result = await License.validateLicenseForPreview(license);
if (!result.valid) {
return API.v1.failure({ error: 'license-invalid', reasons: result.reasons });
}
return API.v1.success();
},
);
API.v1.get(
'licenses.maxActiveUsers',
{
authRequired: true,
response: {
200: licensesMaxActiveUsersResponseSchema,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const maxActiveUsers = License.getMaxActiveUsers();
const activeUsers = await Users.getActiveLocalUserCount();
return API.v1.success({ maxActiveUsers: maxActiveUsers > 0 ? maxActiveUsers : null, activeUsers });
},
);

View File

@@ -0,0 +1,235 @@
import { getCuratedTools, getExtendedTools } from './catalog';
jest.mock('../../../../server/api', () => ({
API: {
api: {
typedRoutes: {
'/api/v1/chat.postMessage': {
post: {
tags: ['Chat'],
requestBody: {
content: {
'application/json': {
schema: {
oneOf: [
{
type: 'object',
description: 'Post by room id',
properties: { roomId: { type: 'string' }, text: { type: 'string', nullable: true } },
required: ['roomId'],
},
{
type: 'object',
description: 'Post by channel',
properties: { channel: { type: 'string' }, text: { type: 'string', nullable: true } },
required: ['channel'],
},
],
},
},
},
},
},
},
'/api/v1/rooms.get': {
get: {
tags: ['Rooms'],
parameters: [
{
schema: {
type: 'object',
properties: { updatedSince: { type: 'string' } },
additionalProperties: { type: 'string', nullable: true },
},
},
],
},
},
'/api/v1/channels.create': {
post: {
tags: ['Missing Documentation'],
requestBody: {
content: {
'application/json': {
schema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
},
},
},
},
},
'/api/v1/channels.list.joined': {
get: {
tags: ['Missing Documentation'],
parameters: [{ schema: { type: 'object', properties: { count: { type: 'number' } } } }],
},
},
'/api/v1/rooms.isMember': {
get: {
tags: ['Rooms'],
parameters: [
{
schema: {
type: 'object',
properties: {
roomId: { type: 'string' },
userId: { type: 'string' },
username: { type: 'string' },
},
oneOf: [
{ type: 'object', required: ['roomId', 'userId'] },
{ type: 'object', required: ['roomId', 'username'] },
],
additionalProperties: false,
},
},
],
},
},
'/api/v1/teams.listRoomsOfUser': {
get: {
tags: ['Teams'],
parameters: [
{
schema: {
type: 'object',
properties: {
teamId: { type: 'string' },
teamName: { type: 'string' },
userId: { type: 'string' },
},
oneOf: [
{ type: 'object', required: ['teamId'] },
{ type: 'object', required: ['teamName'] },
],
required: ['userId'],
additionalProperties: false,
},
},
],
},
},
'/api/v1/dm.files': {
get: {
tags: ['DM'],
parameters: [
{
schema: {
oneOf: [
{
type: 'object',
properties: { thisIsAnExtremelyLongDiscriminatorNameThatEndsInAlpha: { type: 'string' } },
required: ['thisIsAnExtremelyLongDiscriminatorNameThatEndsInAlpha'],
},
{
type: 'object',
properties: { thisIsAnExtremelyLongDiscriminatorNameThatEndsInBeta: { type: 'string' } },
required: ['thisIsAnExtremelyLongDiscriminatorNameThatEndsInBeta'],
},
],
},
},
],
},
},
'/api/v1/users.delete': {
post: {
tags: ['Users'],
requestBody: { content: { 'application/json': { schema: { type: 'object' } } } },
},
},
'/api/v1/users.register': {
post: {
tags: ['Users'],
requestBody: {
content: {
'application/json': {
schema: { type: 'object', properties: { username: { type: 'string' } }, required: ['username'] },
},
},
},
},
},
},
},
},
}));
describe('MCP tool catalog', () => {
it('creates one curated tool per discriminated request variant', () => {
const tools = getCuratedTools();
expect(tools.map(({ name }) => name)).toEqual([
'post_chat_postMessage_by_roomId',
'post_chat_postMessage_by_channel',
'post_channels_create',
'get_channels_list_joined',
'get_rooms_get',
]);
expect(tools[0]?.inputSchema).toEqual({
type: 'object',
description: 'Post by room id',
properties: { roomId: { type: 'string' }, text: { type: 'string' } },
required: ['roomId'],
});
expect(tools[4]?.inputSchema).toMatchObject({ additionalProperties: { type: 'string' } });
});
it('only exposes allow-listed routes in the extended catalog', () => {
const tools = getExtendedTools();
expect(tools.map(({ name }) => name)).toEqual(
expect.arrayContaining([
'post_chat_postMessage_by_roomId',
'post_chat_postMessage_by_channel',
'post_channels_create',
'get_channels_list_joined',
'get_rooms_get',
'get_rooms_isMember_by_roomId_userId',
'get_rooms_isMember_by_roomId_username',
]),
);
expect(tools.some(({ name }) => name.includes('users_delete'))).toBe(false);
expect(tools.some(({ name }) => name.includes('users_register'))).toBe(false);
});
it('keeps curated tool names stable when the extended catalog is enabled', () => {
const extendedNames = new Set(getExtendedTools().map(({ name }) => name));
expect(getCuratedTools().every(({ name }) => extendedNames.has(name))).toBe(true);
});
it('generates unique valid names when variant discriminators exceed the MCP limit', () => {
const tools = getExtendedTools();
const names = tools.map(({ name }) => name);
const dmFileNames = tools.filter(({ path }) => path === '/api/v1/dm.files').map(({ name }) => name);
expect(dmFileNames).toHaveLength(2);
expect(new Set(names).size).toBe(names.length);
expect(names.every((name) => name.length <= 64 && /^[a-zA-Z0-9_-]+$/.test(name))).toBe(true);
});
it('preserves parent properties when variants only declare required fields', () => {
const tools = getExtendedTools().filter(({ name }) => name.startsWith('get_rooms_isMember'));
for (const { inputSchema } of tools) {
const properties = inputSchema.properties as Record<string, unknown>;
for (const requiredProperty of inputSchema.required as string[]) {
expect(properties).toHaveProperty(requiredProperty);
}
}
});
it('preserves shared required fields without changing variant discriminators', () => {
const tools = getExtendedTools().filter(({ name }) => name.startsWith('get_teams_listRoomsOfUser'));
expect(tools.map(({ name }) => name)).toEqual(['get_teams_listRoomsOfUser_by_teamId', 'get_teams_listRoomsOfUser_by_teamName']);
for (const { inputSchema } of tools) {
expect(inputSchema.required).toEqual(expect.arrayContaining(['userId']));
}
});
it('reuses the generated catalogs between requests', () => {
expect(getCuratedTools()).toBe(getCuratedTools());
expect(getExtendedTools()).toBe(getExtendedTools());
});
});

View File

@@ -0,0 +1,420 @@
import { createHash } from 'node:crypto';
import { API } from '../../../../server/api';
export type McpMethod = 'get' | 'post' | 'put' | 'delete';
export type McpTool = {
/** MCP tool name exposed to the client (must match ^[a-zA-Z0-9_-]{1,64}$). */
name: string;
description: string;
inputSchema: Record<string, unknown>;
/** Internal: the REST route this tool maps to. */
path: string;
method: McpMethod;
};
const CURATED: { path: string; method: McpMethod; fallbackDescription?: string }[] = [
{ path: '/api/v1/chat.postMessage', method: 'post' },
{ path: '/api/v1/chat.getMessage', method: 'get' },
{
path: '/api/v1/channels.create',
method: 'post',
fallbackDescription: 'Create a public channel. Requires `name`; optional `members` (array of usernames).',
},
{
path: '/api/v1/channels.list.joined',
method: 'get',
fallbackDescription: 'List the public channels the authenticated user has joined.',
},
{
path: '/api/v1/rooms.get',
method: 'get',
fallbackDescription: 'List rooms the authenticated user has access to (optionally updated since a timestamp).',
},
{ path: '/api/v1/users.info', method: 'get' },
];
/**
* Allow-list for the *extended* toolset, expressed as the base tool name per route — i.e.
* the `toolNameFor(path, method)` value, WITHOUT any `_by_<discriminator>` variant suffix.
* Matched at the route level, so a single entry (e.g. `get_users_info`) exposes every
* variant of that route (`get_users_info_by_userId`, `_by_username`, …).
*
* The extended set is the full catalog filtered by these names — the entire API is never
* exposed.
*/
const ALLOWED_TOOL_NAMES = new Set<string>([
// chat — reading
'get_chat_getDiscussions',
'get_chat_getMentionedMessages',
'get_chat_getMessage',
'get_chat_getPinnedMessages',
'get_chat_getStarredMessages',
'get_chat_getThreadMessages',
'get_chat_getThreadsList',
'get_chat_search',
'get_chat_syncMessages',
'get_chat_syncThreadMessages',
'get_chat_syncThreadsList',
// custom user status
'get_custom_user_status_list',
// direct messages — reading
'get_dm_files',
'get_dm_history',
'get_dm_list',
'get_dm_list_everyone',
'get_dm_members',
'get_dm_messages',
'get_dm_messages_others',
// me
'get_me',
// rooms — reading
'get_rooms_autocomplete_availableForTeams',
'get_rooms_autocomplete_channelAndPrivate',
'get_rooms_get',
'get_rooms_getDiscussions',
'get_rooms_info',
'get_rooms_isMember',
'get_rooms_membersOrderedByRole',
'get_rooms_nameExists',
// search
'get_spotlight',
// subscriptions — reading
'get_subscriptions_get',
'get_subscriptions_getOne',
// teams — reading
'get_teams_autocomplete',
'get_teams_info',
'get_teams_list',
'get_teams_listAll',
'get_teams_listChildren',
'get_teams_listRooms',
'get_teams_listRoomsOfUser',
'get_teams_members',
// users — reading
'get_users_autocomplete',
'get_users_checkUsernameAvailability',
'get_users_getPreferences',
'get_users_getPresence',
'get_users_getStatus',
'get_users_info',
'get_users_listTeams',
// chat — writing
// 'post_chat_delete',
'post_chat_followMessage',
'post_chat_pinMessage',
'post_chat_postMessage',
'post_chat_react',
'post_chat_reportMessage',
'post_chat_sendMessage',
'post_chat_starMessage',
'post_chat_unfollowMessage',
'post_chat_unPinMessage',
'post_chat_unStarMessage',
'post_chat_update',
// custom user status — writing
'post_custom_user_status_create',
// 'post_custom_user_status_delete',
'post_custom_user_status_update',
// direct messages — writing
'post_dm_close',
'post_dm_create',
// 'post_dm_delete',
'post_dm_open',
'post_dm_setTopic',
'post_im_blockUser',
// rooms — writing
'post_rooms_banUser',
'post_rooms_changeArchivationState',
'post_rooms_createDiscussion',
// 'post_rooms_delete',
'post_rooms_favorite',
'post_rooms_hide',
'post_rooms_invite',
'post_rooms_join',
'post_rooms_leave',
'post_rooms_muteUser',
'post_rooms_open',
'post_rooms_saveRoomSettings',
'post_rooms_unbanUser',
'post_rooms_unmuteUser',
// subscriptions — writing
'post_subscriptions_read',
'post_subscriptions_unread',
// teams — writing
'post_teams_addMembers',
'post_teams_addRooms',
'post_teams_convertToChannel',
'post_teams_create',
// 'post_teams_delete',
'post_teams_leave',
'post_teams_removeMember',
'post_teams_removeRoom',
'post_teams_update',
'post_teams_updateMember',
'post_teams_updateRoom',
// uploads
// 'post_uploads_delete',
// users — writing
'post_users_create',
// `users.register` rejects authenticated callers, while every MCP call is authenticated.
'post_users_setStatus',
'post_users_update',
'post_users_updateOwnBasicInfo',
]);
const FALLBACK_SCHEMA: Record<string, unknown> = { type: 'object', additionalProperties: true };
const COMBINATOR_KEYS = ['oneOf', 'anyOf', 'allOf'] as const;
/**
* Transform a route's JSON Schema into one accepted by MCP clients / the Anthropic tools
* API, which require a plain object schema and reject combinator keywords:
* - strip OpenAPI-only `nullable` (not valid JSON Schema 2020-12) and `not`,
* - resolve `oneOf`/`anyOf`/`allOf` by adopting the FIRST branch. For a schema built as
* `{ oneOf: [subSchemaA, subSchemaB] }` this means the MCP tool uses one named
* sub-schema (e.g. post-by-channel); for a bare value union like `string | string[]`
* it keeps a concrete type. The REST layer still enforces the full rule on dispatch,
* so this only shapes the advertised tool schema — the validator is untouched.
*/
const mcpSafeSchema = (value: unknown): unknown => {
if (Array.isArray(value)) {
return value.map(mcpSafeSchema);
}
if (!value || typeof value !== 'object') {
return value;
}
let node: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
if (key === 'nullable' || key === 'not') {
continue;
}
node[key] = mcpSafeSchema(val);
}
const combinator = COMBINATOR_KEYS.find((key) => Array.isArray(node[key]) && (node[key] as unknown[]).length > 0);
if (combinator) {
const firstBranch = mcpSafeSchema((node[combinator] as unknown[])[0]) as Record<string, unknown>;
for (const key of COMBINATOR_KEYS) {
delete node[key];
}
// The first branch supplies the structure (type/properties/required); this node's
// own keys (e.g. a top-level `description`) win on top.
node = { ...firstBranch, ...node };
}
return node;
};
const ensureObjectSchema = (schema: unknown): Record<string, unknown> => {
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
return FALLBACK_SCHEMA;
}
const obj = mcpSafeSchema(schema) as Record<string, unknown>;
return obj.type === 'object' ? obj : { ...obj, type: 'object' };
};
/** Pull the raw request JSON-Schema for a route (the main schema, before flattening). */
const rawSchemaForRoute = (path: string, method: McpMethod): unknown => {
const route = API.api.typedRoutes?.[path]?.[method];
if (!route) {
return undefined;
}
return method === 'post' || method === 'put' ? route.requestBody?.content?.['application/json']?.schema : route.parameters?.[0]?.schema;
};
const VARIANT_KEYS = ['oneOf', 'anyOf'] as const;
const MAX_TOOL_NAME_LENGTH = 64;
const mergeSchemaVariant = (
root: Record<string, unknown>,
variantKey: (typeof VARIANT_KEYS)[number],
variant: unknown,
): Record<string, unknown> => {
const { [variantKey]: _variants, ...baseSchema } = root;
if (!variant || typeof variant !== 'object' || Array.isArray(variant)) {
return ensureObjectSchema(baseSchema);
}
const branch = variant as Record<string, unknown>;
const baseProperties =
baseSchema.properties && typeof baseSchema.properties === 'object' && !Array.isArray(baseSchema.properties)
? (baseSchema.properties as Record<string, unknown>)
: undefined;
const branchProperties =
branch.properties && typeof branch.properties === 'object' && !Array.isArray(branch.properties)
? (branch.properties as Record<string, unknown>)
: undefined;
const baseRequired = Array.isArray(baseSchema.required) ? baseSchema.required : [];
const branchRequired = Array.isArray(branch.required) ? branch.required : [];
const required = [...new Set([...baseRequired, ...branchRequired])];
return ensureObjectSchema({
...baseSchema,
...branch,
...((baseProperties || branchProperties) && { properties: { ...baseProperties, ...branchProperties } }),
...(required.length > 0 && { required }),
});
};
/** A branch's discriminator = its `required` keys (e.g. `channel`, `roomId`, `userId`). */
const discriminatorOf = (schema: Record<string, unknown>): string | undefined => {
const { required } = schema;
if (Array.isArray(required) && required.length > 0 && required.every((r) => typeof r === 'string')) {
return required.join('_');
}
return undefined;
};
type SchemaVariant = { discriminator?: string; description?: string; schema: Record<string, unknown> };
/**
* Split a route's request schema into MCP-ready variants. When the schema is a
* `oneOf`/`anyOf` of object sub-schemas with distinct discriminators (e.g.
* post-by-channel vs post-by-roomId), each becomes its own variant; otherwise a single
* flattened object schema is returned.
*/
const variantsForRoute = (path: string, method: McpMethod): SchemaVariant[] => {
const raw = rawSchemaForRoute(path, method);
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
const root = raw as Record<string, unknown>;
const key = VARIANT_KEYS.find((k) => Array.isArray(root[k]) && (root[k] as unknown[]).length > 1);
if (key) {
const mainDescription = typeof root.description === 'string' ? root.description : undefined;
const rawBranches = root[key] as unknown[];
const branches = rawBranches.map((branch) => mergeSchemaVariant(root, key, branch));
const discriminators = rawBranches.map((branch) => discriminatorOf(ensureObjectSchema(branch)));
const allDistinct = discriminators.every(Boolean) && new Set(discriminators).size === discriminators.length;
if (allDistinct) {
return branches.map((schema, i) => ({
discriminator: discriminators[i],
description: typeof schema.description === 'string' ? schema.description : mainDescription,
schema,
}));
}
}
}
const schema = ensureObjectSchema(raw);
return [{ description: typeof schema.description === 'string' ? schema.description : undefined, schema }];
};
const fitToolName = (baseName: string, discriminator?: string): string => {
const suffix = discriminator ? `_by_${discriminator.replace(/[^a-zA-Z0-9_-]+/g, '_')}` : '';
const fullName = `${baseName}${suffix}`;
if (fullName.length <= MAX_TOOL_NAME_LENGTH) {
return fullName;
}
const minimumBaseLength = Math.min(baseName.length, 16);
if (suffix.length <= MAX_TOOL_NAME_LENGTH - minimumBaseLength) {
return `${baseName.slice(0, MAX_TOOL_NAME_LENGTH - suffix.length)}${suffix}`;
}
const hash = createHash('sha256').update(fullName).digest('hex').slice(0, 8);
return `${fullName.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;
};
const ensureUniqueToolNames = (tools: McpTool[]): McpTool[] => {
const usedNames = new Set<string>();
return tools.map((tool, index) => {
if (!usedNames.has(tool.name)) {
usedNames.add(tool.name);
return tool;
}
let attempt = 0;
let candidate: string;
do {
const hash = createHash('sha256')
.update(`${tool.method}:${tool.path}:${tool.name}:${JSON.stringify(tool.inputSchema)}:${index}:${attempt}`)
.digest('hex')
.slice(0, 8);
candidate = `${tool.name.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;
attempt += 1;
} while (usedNames.has(candidate));
usedNames.add(candidate);
return { ...tool, name: candidate };
});
};
const toolsForRoute = (baseName: string, path: string, method: McpMethod, fallbackDescription?: string): McpTool[] =>
variantsForRoute(path, method).map((variant) => ({
name: fitToolName(baseName, variant.discriminator),
description: variant.description ?? fallbackDescription ?? `${method.toUpperCase()} ${path}`,
path,
method,
inputSchema: variant.schema,
}));
const toolNameFor = (path: string, method: McpMethod): string => {
const slug = path.replace(/^\/api\/v\d+\//, '').replace(/[^a-zA-Z0-9]+/g, '_');
return `${method}_${slug}`;
};
/**
* Walk the documented routes (excluding `Missing Documentation`, mirroring the OpenAPI
* filter) and emit one or more tools per route, keeping only routes whose base tool name
* passes `isRouteAllowed`. The filter is applied to the base name, so all `_by_` variants
* of an allowed route are included together.
*/
const collectTools = (isRouteAllowed: (baseName: string) => boolean): McpTool[] => {
const tools: McpTool[] = [];
for (const [path, methods] of Object.entries(API.api.typedRoutes ?? {})) {
for (const [method, route] of Object.entries(methods)) {
if (route?.tags?.includes('Missing Documentation')) {
continue;
}
if (!['get', 'post', 'put', 'delete'].includes(method)) {
continue;
}
const baseName = toolNameFor(path, method as McpMethod);
if (!isRouteAllowed(baseName)) {
continue;
}
const fallback = route?.tags?.length
? `${method.toUpperCase()} ${path} (${route.tags.join(', ')})`
: `${method.toUpperCase()} ${path}`;
tools.push(...toolsForRoute(baseName, path, method as McpMethod, fallback));
}
}
return ensureUniqueToolNames(tools);
};
let curatedTools: McpTool[] | undefined;
export const getCuratedTools = (): McpTool[] => {
curatedTools ??= ensureUniqueToolNames(
CURATED.filter(({ path, method }) => Boolean(API.api.typedRoutes?.[path]?.[method])).flatMap(({ path, method, fallbackDescription }) =>
toolsForRoute(toolNameFor(path, method), path, method, fallbackDescription),
),
);
return curatedTools;
};
/**
* The extended toolset — the full catalog filtered by {@link ALLOWED_TOOL_NAMES}. The
* entire API is never exposed; routes outside the allow-list (and `Missing Documentation`
* routes) are excluded.
*/
let extendedTools: McpTool[] | undefined;
export const getExtendedTools = (): McpTool[] => {
if (!extendedTools) {
const toolsByName = new Map(getCuratedTools().map((tool) => [tool.name, tool]));
for (const tool of collectTools((baseName) => ALLOWED_TOOL_NAMES.has(baseName))) {
toolsByName.set(tool.name, tool);
}
extendedTools = [...toolsByName.values()];
}
return extendedTools;
};

View File

@@ -0,0 +1,156 @@
import { createMcpResponseBudget, dispatchTool } from './dispatch';
import type { McpAuth } from './server';
const auth: McpAuth = {
userId: 'user-id',
authToken: 'auth-token',
};
describe('MCP tool dispatch', () => {
const originalPort = process.env.PORT;
const runtimeGlobal = globalThis as typeof globalThis & { __meteor_runtime_config__?: { ROOT_URL_PATH_PREFIX?: string } };
const originalRuntimeConfig = runtimeGlobal.__meteor_runtime_config__;
afterEach(() => {
jest.restoreAllMocks();
if (originalPort === undefined) {
delete process.env.PORT;
} else {
process.env.PORT = originalPort;
}
if (originalRuntimeConfig === undefined) {
delete runtimeGlobal.__meteor_runtime_config__;
} else {
runtimeGlobal.__meteor_runtime_config__ = originalRuntimeConfig;
}
});
it('forwards GET arguments and caller identity to the local REST API', async () => {
process.env.PORT = '3100';
runtimeGlobal.__meteor_runtime_config__ = { ROOT_URL_PATH_PREFIX: '/chat' };
const fetchMock = jest
.spyOn(global, 'fetch')
.mockResolvedValue(new Response(JSON.stringify({ message: { _id: 'message-id' } }), { status: 200 }));
const result = await dispatchTool(
{
name: 'chat_getMessage',
description: 'Get a message',
inputSchema: { type: 'object' },
path: '/api/v1/chat.getMessage',
method: 'get',
},
{ msgId: 'message-id', fields: { msg: 1 }, optional: undefined },
auth,
'192.0.2.1',
);
expect(fetchMock).toHaveBeenCalledWith(
'http://127.0.0.1:3100/chat/api/v1/chat.getMessage?msgId=message-id&fields=%7B%22msg%22%3A1%7D',
expect.objectContaining({
method: 'GET',
redirect: 'error',
headers: {
'Content-Type': 'application/json',
'X-User-Id': 'user-id',
'X-Auth-Token': 'auth-token',
'X-Real-IP': '192.0.2.1',
},
signal: expect.any(AbortSignal),
}),
);
expect(result).toEqual({ ok: true, status: 200, body: { message: { _id: 'message-id' } } });
});
it('preserves non-JSON error responses', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(new Response('Service unavailable', { status: 503 }));
await expect(
dispatchTool(
{
name: 'chat_postMessage',
description: 'Post a message',
inputSchema: { type: 'object' },
path: '/api/v1/chat.postMessage',
method: 'post',
},
{ roomId: 'room-id', text: 'Hello' },
auth,
),
).resolves.toEqual({ ok: false, status: 503, body: 'Service unavailable' });
});
it('handles empty REST responses', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));
await expect(
dispatchTool(
{
name: 'subscriptions_read',
description: 'Mark a subscription as read',
inputSchema: { type: 'object' },
path: '/api/v1/subscriptions.read',
method: 'post',
},
{ rid: 'room-id' },
auth,
),
).resolves.toEqual({ ok: true, status: 204, body: '' });
});
it('rejects responses whose content length exceeds the MCP result size limit', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(
new Response(null, {
status: 200,
headers: { 'content-length': String(5 * 1024 * 1024 + 1) },
}),
);
await expect(
dispatchTool(
{
name: 'rooms_get',
description: 'Get rooms',
inputSchema: { type: 'object' },
path: '/api/v1/rooms.get',
method: 'get',
},
{},
auth,
),
).rejects.toThrow('MCP tool response exceeds the 5 MiB limit');
});
it('stops streaming responses that exceed the MCP result size limit', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(new Response(new Uint8Array(5 * 1024 * 1024 + 1), { status: 200 }));
await expect(
dispatchTool(
{
name: 'rooms_get',
description: 'Get rooms',
inputSchema: { type: 'object' },
path: '/api/v1/rooms.get',
method: 'get',
},
{},
auth,
),
).rejects.toThrow('MCP tool response exceeds the 5 MiB limit');
});
it('shares the response size budget across batched tool calls', async () => {
jest.spyOn(global, 'fetch').mockImplementation(async () => new Response('abc', { status: 200 }));
const responseBudget = createMcpResponseBudget(5);
const tool = {
name: 'rooms_get',
description: 'Get rooms',
inputSchema: { type: 'object' },
path: '/api/v1/rooms.get',
method: 'get' as const,
};
await expect(dispatchTool(tool, {}, auth, undefined, responseBudget)).resolves.toMatchObject({ body: 'abc' });
await expect(dispatchTool(tool, {}, auth, undefined, responseBudget)).rejects.toThrow('MCP batch response exceeds the 5 bytes limit');
});
});

View File

@@ -0,0 +1,147 @@
import type { McpTool } from './catalog';
import type { McpAuth } from './server';
export type DispatchResult = {
ok: boolean;
status: number;
body: unknown;
};
export type McpResponseBudget = {
consume: (bytes: number) => void;
};
const TOOL_CALL_TIMEOUT_MS = 20_000;
const MAX_TOOL_RESPONSE_BYTES = 5 * 1024 * 1024;
const BYTES_PER_MEBIBYTE = 1024 * 1024;
const formatByteLimit = (bytes: number): string =>
bytes % BYTES_PER_MEBIBYTE === 0 ? `${bytes / BYTES_PER_MEBIBYTE} MiB` : `${bytes} bytes`;
export const createMcpResponseBudget = (maxBytes = MAX_TOOL_RESPONSE_BYTES): McpResponseBudget => {
let remainingBytes = maxBytes;
const formattedLimit = formatByteLimit(maxBytes);
return {
consume(bytes) {
if (bytes > remainingBytes) {
throw new Error(`MCP batch response exceeds the ${formattedLimit} limit`);
}
remainingBytes -= bytes;
},
};
};
const readResponseText = async (response: Response, responseBudget?: McpResponseBudget): Promise<string> => {
const contentLength = Number(response.headers.get('content-length'));
if (Number.isFinite(contentLength) && contentLength > MAX_TOOL_RESPONSE_BYTES) {
throw new Error('MCP tool response exceeds the 5 MiB limit');
}
if (!response.body) {
return '';
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
const chunks: string[] = [];
let receivedBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
receivedBytes += value.byteLength;
if (receivedBytes > MAX_TOOL_RESPONSE_BYTES) {
await reader.cancel();
throw new Error('MCP tool response exceeds the 5 MiB limit');
}
try {
responseBudget?.consume(value.byteLength);
} catch (error) {
await reader.cancel();
throw error;
}
chunks.push(decoder.decode(value, { stream: true }));
}
chunks.push(decoder.decode());
return chunks.join('');
};
/**
* Execute the REST endpoint a tool maps to, as the authenticated user.
*
* Dispatch is done via a loopback HTTP call to the local REST API, forwarding the
* caller's PAT headers. This guarantees identical behaviour to a real REST client —
* the same auth, permission checks, parameter validation and response shape — with
* zero duplicated business logic. (An in-process Hono dispatch is a possible future
* optimisation to avoid the loopback hop.)
*
* `clientIp` (resolved server-side from the MCP connection) is forwarded as `X-Real-IP`
* so the REST per-route rate limiter keys on the real client rather than the loopback
* address — otherwise every MCP caller would share a single `127.0.0.1` bucket.
*/
export const dispatchTool = async (
tool: McpTool,
args: Record<string, unknown>,
auth: McpAuth,
clientIp?: string,
responseBudget?: McpResponseBudget,
): Promise<DispatchResult> => {
const port = process.env.PORT || '3000';
const runtimeConfig = (
globalThis as typeof globalThis & {
__meteor_runtime_config__?: { ROOT_URL_PATH_PREFIX?: string };
}
).__meteor_runtime_config__;
const base = `http://127.0.0.1:${port}${runtimeConfig?.ROOT_URL_PATH_PREFIX ?? ''}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-User-Id': auth.userId,
'X-Auth-Token': auth.authToken,
...(clientIp && { 'X-Real-IP': clientIp }),
};
let url = base + tool.path;
const init: RequestInit = {
method: tool.method.toUpperCase(),
headers,
redirect: 'error',
signal: AbortSignal.timeout(TOOL_CALL_TIMEOUT_MS),
};
if (tool.method === 'get' || tool.method === 'delete') {
const qs = new URLSearchParams();
for (const [key, value] of Object.entries(args ?? {})) {
if (value === undefined) {
continue;
}
qs.append(key, typeof value === 'string' ? value : JSON.stringify(value));
}
const query = qs.toString();
if (query) {
url += `?${query}`;
}
} else {
init.body = JSON.stringify(args ?? {});
}
const res = await fetch(url, init);
const responseText = await readResponseText(res, responseBudget);
let body: unknown = responseText;
if (responseText) {
try {
body = JSON.parse(responseText);
} catch {
// Keep non-JSON REST responses as text so callers receive the actual result.
}
}
return { ok: res.ok, status: res.status, body };
};

View File

@@ -0,0 +1,377 @@
import { AI_LICENSE_MODULE } from '@rocket.chat/ai-search';
import { Users } from '@rocket.chat/models';
import { Accounts } from 'meteor/accounts-base';
import { handleMcpGet, handleMcpPost } from './index';
import { handleRpcMessage } from './server';
import { API } from '../../../../server/api';
import { authenticationMiddlewareForHono } from '../../../../server/api/v1/middlewares/authenticationHono';
import { permissionsMiddleware } from '../../../../server/api/v1/middlewares/permissions';
import { settings } from '../../../../server/settings/cached';
import { license } from '../v1/middlewares/license';
jest.mock('meteor/accounts-base', () => ({ Accounts: { _hashLoginToken: jest.fn((token: string) => `hashed-${token}`) } }), {
virtual: true,
});
jest.mock('@rocket.chat/models', () => ({
Users: { findPersonalAccessTokenByHashedTokenAndUserId: jest.fn() },
}));
jest.mock('./server', () => ({
handleRpcMessage: jest.fn(),
isJsonRpcRequest: jest.fn(
(value: unknown) => typeof value === 'object' && value !== null && 'jsonrpc' in value && value.jsonrpc === '2.0' && 'method' in value,
),
}));
jest.mock('../../../../server/api', () => ({
API: {
v1: {
registerRateLimiterForRoute: jest.fn(),
enforceRateLimitForRoute: jest.fn(),
router: {
getHonoRouter: jest.fn(() => ({ use: jest.fn(), post: jest.fn(), get: jest.fn() })),
},
},
},
}));
jest.mock('../../../../server/api/v1/middlewares/authenticationHono', () => ({
authenticationMiddlewareForHono: jest.fn(() => jest.fn()),
}));
jest.mock('../../../../server/api/v1/middlewares/permissions', () => ({
permissionsMiddleware: jest.fn(() => jest.fn()),
}));
jest.mock('../v1/middlewares/license', () => ({
license: jest.fn(() => jest.fn()),
}));
jest.mock('../../../../server/settings/cached', () => ({
settings: { get: jest.fn() },
}));
const mockRouter = jest.mocked(API.v1.router.getHonoRouter).mock.results[0]?.value;
const context = {
bodyParams: { jsonrpc: '2.0', id: 1, method: 'ping' },
userId: 'user-id',
token: 'hashed-auth-token',
requestIp: '192.0.2.1',
request: new Request('http://localhost/api/v1/mcp', { headers: { 'x-auth-token': 'auth-token' } }),
};
describe('MCP HTTP route', () => {
beforeEach(() => {
jest.mocked(settings.get).mockReturnValue(true);
jest.mocked(handleRpcMessage).mockReset();
jest.mocked(API.v1.enforceRateLimitForRoute).mockReset().mockResolvedValue(undefined);
jest
.mocked(Users.findPersonalAccessTokenByHashedTokenAndUserId)
.mockReset()
.mockResolvedValue({ _id: 'user-id' } as never);
});
it('applies the API rate limiter before handling MCP requests', async () => {
const middleware = mockRouter.use.mock.calls[0]?.[2];
expect(middleware).toBeDefined();
if (!middleware) {
throw new Error('MCP rate-limit middleware was not registered');
}
const request = new Request('http://localhost/api/v1/mcp', {
method: 'POST',
headers: { 'x-user-id': 'user-id' },
});
const response = new Response();
const next = jest.fn().mockResolvedValue(undefined);
await middleware(
{
req: { method: 'POST', raw: request, header: (name: string) => request.headers.get(name) ?? undefined },
res: response,
get: () => '192.0.2.1',
},
next,
);
expect(API.v1.enforceRateLimitForRoute).toHaveBeenCalledWith({
route: 'mcp',
method: 'post',
request,
response,
requestIp: '192.0.2.1',
userId: 'user-id',
});
expect(next).toHaveBeenCalledTimes(1);
});
it('returns a JSON-RPC error with rate-limit headers when the limit is exceeded', async () => {
const middleware = mockRouter.use.mock.calls[0]?.[2];
expect(middleware).toBeDefined();
if (!middleware) {
throw new Error('MCP rate-limit middleware was not registered');
}
jest.mocked(API.v1.enforceRateLimitForRoute).mockImplementationOnce(async ({ response }: { response: Response }) => {
response.headers.set('X-RateLimit-Remaining', '0');
throw Object.assign(new Error('Please slow down'), { error: 'error-too-many-requests', reason: 'Please slow down' });
});
const request = new Request('http://localhost/api/v1/mcp', { method: 'POST', headers: { 'x-user-id': 'user-id' } });
const result = await middleware(
{
req: { method: 'POST', raw: request, header: (name: string) => request.headers.get(name) ?? undefined },
res: new Response(),
get: () => '192.0.2.1',
},
jest.fn(),
);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(429);
expect(result?.headers.get('X-RateLimit-Remaining')).toBe('0');
await expect(result?.json()).resolves.toMatchObject({ error: { message: 'Please slow down' } });
});
it('registers the endpoint with authentication, permission, and license gates', () => {
expect(API.v1.router.getHonoRouter).toHaveBeenCalledTimes(1);
expect(authenticationMiddlewareForHono).toHaveBeenCalledWith(API.v1, expect.objectContaining({ authRequired: true }));
expect(API.v1.registerRateLimiterForRoute).toHaveBeenCalledWith({
route: 'mcp',
rateLimiterOptions: { numRequestsAllowed: 60, intervalTimeInMS: 60_000 },
methods: ['post'],
});
expect(permissionsMiddleware).toHaveBeenCalledWith(
expect.objectContaining({
permissionsRequired: { '*': { permissions: ['access-mcp'], operation: 'hasAll' } },
}),
);
expect(license).toHaveBeenCalledWith(expect.objectContaining({ license: [AI_LICENSE_MODULE] }), expect.anything());
expect(mockRouter.use).toHaveBeenCalledWith(
'/mcp',
expect.any(Function),
expect.any(Function),
expect.any(Function),
expect.any(Function),
);
expect(mockRouter.post).toHaveBeenCalledWith('/mcp', expect.any(Function));
expect(mockRouter.get).toHaveBeenCalledWith('/mcp', expect.any(Function));
});
it('adapts Hono POST requests to exact JSON-RPC responses', async () => {
const response = { jsonrpc: '2.0' as const, id: 7, result: {} };
jest.mocked(handleRpcMessage).mockResolvedValue(response);
const request = new Request('http://localhost/api/v1/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-auth-token': 'auth-token', 'x-user-id': 'user-id' },
body: JSON.stringify({ jsonrpc: '2.0', id: 7, method: 'ping' }),
});
const handler = mockRouter.post.mock.calls[0]?.[1];
expect(handler).toBeDefined();
if (!handler) {
throw new Error('MCP POST handler was not registered');
}
const result = await handler({
req: { raw: request },
get: (key: string) => (key === 'user' ? { _id: 'user-id' } : '192.0.2.1'),
});
expect(result).toBeInstanceOf(Response);
expect(result.status).toBe(200);
await expect(result.json()).resolves.toEqual(response);
expect(Accounts._hashLoginToken).toHaveBeenCalledWith('auth-token');
expect(Users.findPersonalAccessTokenByHashedTokenAndUserId).toHaveBeenCalledWith({
userId: 'user-id',
hashedToken: 'hashed-auth-token',
});
});
it('returns a JSON-RPC parse error for malformed JSON after validating the personal access token', async () => {
const handler = mockRouter.post.mock.calls[0]?.[1];
expect(handler).toBeDefined();
if (!handler) {
throw new Error('MCP POST handler was not registered');
}
const result = await handler({
req: {
raw: new Request('http://localhost/api/v1/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-auth-token': 'auth-token', 'x-user-id': 'user-id' },
body: '{',
}),
},
get: (key: string) => (key === 'user' ? { _id: 'user-id' } : '192.0.2.1'),
});
expect(result.status).toBe(400);
await expect(result.json()).resolves.toMatchObject({ id: null, error: { code: -32700, message: 'Parse error' } });
expect(Users.findPersonalAccessTokenByHashedTokenAndUserId).toHaveBeenCalled();
expect(handleRpcMessage).not.toHaveBeenCalled();
});
it('returns not found while MCP is disabled', async () => {
jest.mocked(settings.get).mockReturnValue(false);
await expect(handleMcpPost(context)).resolves.toMatchObject({ statusCode: 404 });
expect(handleMcpGet(context)).toMatchObject({ statusCode: 404 });
});
it('rejects empty and oversized JSON-RPC batches', async () => {
await expect(handleMcpPost({ ...context, bodyParams: [] })).resolves.toMatchObject({
statusCode: 400,
body: { error: { code: -32600 } },
});
await expect(handleMcpPost({ ...context, bodyParams: Array.from({ length: 21 }, () => context.bodyParams) })).resolves.toMatchObject({
statusCode: 400,
body: { error: { code: -32600 } },
});
expect(handleRpcMessage).not.toHaveBeenCalled();
});
it('rejects JSON-RPC batches for protocol revisions that require one message per POST', async () => {
await expect(
handleMcpPost({
...context,
bodyParams: [context.bodyParams],
request: new Request('http://localhost/api/v1/mcp', {
headers: { 'mcp-protocol-version': '2025-11-25', 'x-auth-token': 'auth-token' },
}),
}),
).resolves.toMatchObject({ statusCode: 400, body: { error: { code: -32600 } } });
expect(handleRpcMessage).not.toHaveBeenCalled();
});
it('rejects a JSON-RPC response because this server does not issue client requests', async () => {
const response = { jsonrpc: '2.0', id: 1, result: {} };
await expect(handleMcpPost({ ...context, bodyParams: response })).resolves.toMatchObject({
statusCode: 400,
body: { error: { code: -32600 } },
});
expect(handleRpcMessage).not.toHaveBeenCalled();
});
it('processes valid and malformed entries independently in legacy JSON-RPC batches', async () => {
const validResponse = { jsonrpc: '2.0' as const, id: 1, result: {} };
const invalidResponse = { jsonrpc: '2.0' as const, id: null, error: { code: -32600, message: 'Invalid Request' } };
const malformedEntry = { jsonrpc: '2.0', id: 2, result: {} };
jest.mocked(handleRpcMessage).mockResolvedValueOnce(validResponse).mockResolvedValueOnce(invalidResponse);
await expect(handleMcpPost({ ...context, bodyParams: [context.bodyParams, malformedEntry] })).resolves.toEqual({
statusCode: 200,
body: [validResponse, invalidResponse],
});
expect(handleRpcMessage).toHaveBeenNthCalledWith(1, context.bodyParams, expect.anything(), context.requestIp, expect.anything());
expect(handleRpcMessage).toHaveBeenNthCalledWith(2, malformedEntry, expect.anything(), context.requestIp, expect.anything());
});
it('acknowledges notifications without a response body', async () => {
jest.mocked(handleRpcMessage).mockResolvedValue(null);
await expect(handleMcpPost(context)).resolves.toEqual({ statusCode: 202, body: undefined });
await expect(handleMcpPost({ ...context, bodyParams: [context.bodyParams] })).resolves.toEqual({
statusCode: 202,
body: undefined,
});
});
it('omits notification entries from batch responses', async () => {
const response = { jsonrpc: '2.0' as const, id: 1, result: {} };
jest.mocked(handleRpcMessage).mockResolvedValueOnce(response).mockResolvedValueOnce(null);
await expect(handleMcpPost({ ...context, bodyParams: [context.bodyParams, context.bodyParams] })).resolves.toEqual({
statusCode: 200,
body: [response],
});
const [firstCall, secondCall] = jest.mocked(handleRpcMessage).mock.calls;
expect(firstCall?.[3]).toBeDefined();
expect(firstCall?.[3]).toBe(secondCall?.[3]);
});
it('limits concurrent calls while preserving batch response order', async () => {
let inFlight = 0;
let maxInFlight = 0;
jest.mocked(handleRpcMessage).mockImplementation(async (message) => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await Promise.resolve();
inFlight -= 1;
if (typeof message !== 'object' || message === null || !('id' in message) || typeof message.id !== 'number') {
throw new Error('Expected a numbered JSON-RPC request');
}
return { jsonrpc: '2.0', id: message.id, result: {} };
});
const bodyParams = Array.from({ length: 20 }, (_, id) => ({ jsonrpc: '2.0', id, method: 'ping' }));
const response = await handleMcpPost({ ...context, bodyParams });
expect(maxInFlight).toBe(4);
expect(response.body).toEqual(bodyParams.map(({ id }) => ({ jsonrpc: '2.0', id, result: {} })));
});
it('returns method not allowed for GET requests', () => {
expect(handleMcpGet(context)).toMatchObject({ statusCode: 405, headers: { Allow: 'POST' } });
expect(Users.findPersonalAccessTokenByHashedTokenAndUserId).not.toHaveBeenCalled();
});
it('rejects authenticated sessions that are not personal access tokens', async () => {
jest.mocked(Users.findPersonalAccessTokenByHashedTokenAndUserId).mockResolvedValue(null);
await expect(handleMcpPost(context)).resolves.toMatchObject({
statusCode: 401,
body: { error: { message: 'Personal Access Token required' } },
});
expect(handleRpcMessage).not.toHaveBeenCalled();
});
it('rejects final encoded responses that exceed the MCP response limit', async () => {
jest.mocked(handleRpcMessage).mockResolvedValue({ jsonrpc: '2.0', id: 1, result: 'x'.repeat(5 * 1024 * 1024) });
await expect(handleMcpPost(context)).resolves.toMatchObject({
statusCode: 413,
body: { error: { message: 'MCP response exceeds the 5 MiB limit' } },
});
});
it('rejects browser requests from untrusted origins', async () => {
jest.mocked(settings.get).mockImplementation((setting) => {
if (setting === 'MCP_Enabled') {
return true;
}
if (setting === 'Site_Url') {
return 'https://chat.example.com';
}
return false;
});
await expect(
handleMcpPost({
...context,
request: new Request('https://chat.example.com/api/v1/mcp', {
headers: { 'origin': 'https://attacker.example', 'x-auth-token': 'auth-token' },
}),
}),
).resolves.toMatchObject({ statusCode: 403 });
expect(handleRpcMessage).not.toHaveBeenCalled();
expect(
handleMcpGet({
request: new Request('https://chat.example.com/api/v1/mcp', { headers: { origin: 'https://attacker.example' } }),
}),
).toMatchObject({ statusCode: 403 });
});
it('rejects unsupported protocol-version headers', async () => {
await expect(
handleMcpPost({
...context,
request: new Request('http://localhost/api/v1/mcp', {
headers: { 'mcp-protocol-version': '2099-01-01', 'x-auth-token': 'auth-token' },
}),
}),
).resolves.toMatchObject({ statusCode: 400 });
expect(handleRpcMessage).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,256 @@
import { AI_LICENSE_MODULE } from '@rocket.chat/ai-search';
import { License } from '@rocket.chat/license';
import { Logger } from '@rocket.chat/logger';
import { Users } from '@rocket.chat/models';
import type { MiddlewareHandler } from 'hono';
import type { StatusCode } from 'hono/utils/http-status';
import { Accounts } from 'meteor/accounts-base';
import { createMcpResponseBudget } from './dispatch';
import { handleRpcMessage, isJsonRpcRequest, type JsonRpcResponse, type McpAuth } from './server';
import { isMcpOriginAllowed, isMcpProtocolVersionSupported, supportsMcpBatching } from './transport';
import { API } from '../../../../server/api';
import type { TypedOptions } from '../../../../server/api/definition';
import { authenticationMiddlewareForHono } from '../../../../server/api/v1/middlewares/authenticationHono';
import { permissionsMiddleware } from '../../../../server/api/v1/middlewares/permissions';
import { settings } from '../../../../server/settings/cached';
import { license } from '../v1/middlewares/license';
const logger = new Logger('MCP');
type McpHttpResponse = {
statusCode: StatusCode;
body: JsonRpcResponse | JsonRpcResponse[] | undefined;
headers?: Record<string, string>;
};
const disabledResponse: McpHttpResponse = {
statusCode: 404,
body: { jsonrpc: '2.0', id: null, error: { code: -32601, message: 'MCP endpoint is disabled' } },
};
type McpActionContext = {
bodyParams: unknown;
bodyParseError?: boolean;
userId: string;
token: string;
requestIp: string;
request: Request;
};
const MAX_BATCH_SIZE = 20;
const MAX_BATCH_CONCURRENCY = 4;
const MAX_MCP_RESPONSE_BYTES = 5 * 1024 * 1024;
const MCP_ROUTE = 'mcp';
const MCP_RATE_LIMIT_OPTIONS = { numRequestsAllowed: 60, intervalTimeInMS: 60_000 };
const personalAccessTokenRequiredResponse: McpHttpResponse = {
statusCode: 401,
body: { jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Personal Access Token required' } },
};
const invalidRequestResponse: McpHttpResponse = {
statusCode: 400,
body: { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid Request' } },
};
const parseErrorResponse: McpHttpResponse = {
statusCode: 400,
body: { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } },
};
const hasPersonalAccessToken = async ({ userId, token }: Pick<McpActionContext, 'userId' | 'token'>): Promise<boolean> =>
Boolean(await Users.findPersonalAccessTokenByHashedTokenAndUserId({ userId, hashedToken: token }));
const jsonResponse = (body: JsonRpcResponse | JsonRpcResponse[]): McpHttpResponse => {
if (Buffer.byteLength(JSON.stringify(body), 'utf8') > MAX_MCP_RESPONSE_BYTES) {
return {
statusCode: 413,
body: { jsonrpc: '2.0', id: null, error: { code: -32000, message: 'MCP response exceeds the 5 MiB limit' } },
};
}
return { statusCode: 200, body };
};
const validateTransportRequest = (request: Request): McpHttpResponse | undefined => {
if (!isMcpOriginAllowed(request.headers.get('origin'))) {
return {
statusCode: 403,
body: { jsonrpc: '2.0', id: null, error: { code: -32000, message: 'Origin is not allowed' } },
};
}
if (!isMcpProtocolVersionSupported(request.headers.get('mcp-protocol-version'))) {
return {
statusCode: 400,
body: { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Unsupported MCP protocol version' } },
};
}
return undefined;
};
const handleBatch = async (messages: unknown[], auth: McpAuth, clientIp: string): Promise<(JsonRpcResponse | null)[]> => {
const responseBudget = createMcpResponseBudget(MAX_MCP_RESPONSE_BYTES);
const responses = new Array<JsonRpcResponse | null>(messages.length);
let nextIndex = 0;
const processNext = async (): Promise<void> => {
while (nextIndex < messages.length) {
const index = nextIndex++;
responses[index] = await handleRpcMessage(messages[index], auth, clientIp, responseBudget);
}
};
await Promise.all(Array.from({ length: Math.min(messages.length, MAX_BATCH_CONCURRENCY) }, () => processNext()));
return responses;
};
export const handleMcpPost = async (context: McpActionContext): Promise<McpHttpResponse> => {
if (!settings.get<boolean>('MCP_Enabled')) {
return disabledResponse;
}
const transportError = validateTransportRequest(context.request);
if (transportError) {
return transportError;
}
if (!(await hasPersonalAccessToken(context))) {
return personalAccessTokenRequiredResponse;
}
if (context.bodyParseError) {
return parseErrorResponse;
}
const message = context.bodyParams;
const auth: McpAuth = {
userId: context.userId,
authToken: String(context.request.headers.get('x-auth-token') ?? ''),
};
const clientIp = context.requestIp;
if (Array.isArray(message)) {
if (!supportsMcpBatching(context.request.headers.get('mcp-protocol-version'))) {
return invalidRequestResponse;
}
if (message.length === 0 || message.length > MAX_BATCH_SIZE) {
return invalidRequestResponse;
}
const responses = (await handleBatch(message, auth, clientIp)).filter((response): response is JsonRpcResponse => response !== null);
return responses.length ? jsonResponse(responses) : { statusCode: 202, body: undefined };
}
if (!isJsonRpcRequest(message)) {
return invalidRequestResponse;
}
const response = await handleRpcMessage(message, auth, clientIp);
if (!response) {
return { statusCode: 202, body: undefined };
}
return jsonResponse(response);
};
export const handleMcpGet = (context: Pick<McpActionContext, 'request'>): McpHttpResponse => {
if (!settings.get<boolean>('MCP_Enabled')) {
return disabledResponse;
}
const transportError = validateTransportRequest(context.request);
if (transportError) {
return transportError;
}
return {
statusCode: 405,
headers: { Allow: 'POST' },
body: { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Only POST is supported' } },
};
};
const routeOptions = {
response: {},
authRequired: true,
permissionsRequired: {
'*': { permissions: ['access-mcp'], operation: 'hasAll' },
},
license: [AI_LICENSE_MODULE],
} satisfies TypedOptions;
const sendResponse = (response: McpHttpResponse): Response => {
const headers = { 'Content-Type': 'application/json', ...response.headers };
return new Response(response.body === undefined ? null : JSON.stringify(response.body), { status: response.statusCode, headers });
};
const isRateLimitError = (error: unknown): error is { error: 'error-too-many-requests'; reason?: string } =>
typeof error === 'object' && error !== null && 'error' in error && error.error === 'error-too-many-requests';
const rateLimitMiddleware: MiddlewareHandler = async (c, next) => {
try {
await API.v1.enforceRateLimitForRoute({
route: MCP_ROUTE,
method: c.req.method.toLowerCase(),
request: c.req.raw,
response: c.res,
requestIp: c.get('remoteAddress'),
userId: c.req.header('x-user-id'),
});
} catch (error) {
if (!isRateLimitError(error)) {
throw error;
}
return sendResponse({
statusCode: 429,
body: { jsonrpc: '2.0', id: null, error: { code: -32000, message: error.reason ?? 'Too many requests' } },
headers: Object.fromEntries([...c.res.headers].filter(([name]) => name.toLowerCase().startsWith('x-ratelimit-'))),
});
}
const rateLimitHeaders = [...c.res.headers].filter(([name]) => name.toLowerCase().startsWith('x-ratelimit-'));
await next();
for (const [name, value] of rateLimitHeaders) {
c.res.headers.set(name, value);
}
};
const router = API.v1.router.getHonoRouter();
API.v1.registerRateLimiterForRoute({ route: MCP_ROUTE, rateLimiterOptions: MCP_RATE_LIMIT_OPTIONS, methods: ['post'] });
router.use(
'/mcp',
authenticationMiddlewareForHono(API.v1, { authRequired: true, logger }),
rateLimitMiddleware,
permissionsMiddleware(routeOptions),
license(routeOptions, License),
);
router.post('/mcp', async (c) => {
const request = c.req.raw;
const rawToken = request.headers.get('x-auth-token') ?? '';
const userId = request.headers.get('x-user-id');
if (!userId) {
return sendResponse(personalAccessTokenRequiredResponse);
}
let bodyParams: unknown;
let bodyParseError = false;
try {
bodyParams = await request.clone().json();
} catch {
bodyParseError = true;
}
return sendResponse(
await handleMcpPost({
bodyParams,
bodyParseError,
userId,
token: Accounts._hashLoginToken(rawToken) ?? '',
requestIp: c.get('remoteAddress'),
request,
}),
);
});
router.get('/mcp', (c) => sendResponse(handleMcpGet({ request: c.req.raw })));

View File

@@ -0,0 +1,174 @@
import { getCuratedTools, getExtendedTools } from './catalog';
import { dispatchTool } from './dispatch';
import { handleRpcMessage, isJsonRpcRequest, type McpAuth } from './server';
import { settings } from '../../../../server/settings/cached';
jest.mock('./catalog', () => ({
getCuratedTools: jest.fn(),
getExtendedTools: jest.fn(),
}));
jest.mock('./dispatch', () => ({
dispatchTool: jest.fn(),
}));
jest.mock('../../../../server/api/lib/getTrimmedServerVersion', () => ({
getTrimmedServerVersion: () => '9.0',
}));
jest.mock('../../../../server/settings/cached', () => ({
settings: { get: jest.fn() },
}));
const auth: McpAuth = {
userId: 'user-id',
authToken: 'auth-token',
};
const tool = {
name: 'get_chat_getMessage',
description: 'Get a message',
inputSchema: { type: 'object' },
path: '/api/v1/chat.getMessage',
method: 'get' as const,
};
const extendedTool = {
...tool,
name: 'get_chat_search',
description: 'Search messages',
path: '/api/v1/chat.search',
};
const initializeParams = {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'test-client', version: '1.0.0' },
};
describe('MCP JSON-RPC server', () => {
beforeEach(() => {
jest.mocked(settings.get).mockReturnValue(false);
jest.mocked(getCuratedTools).mockReset().mockReturnValue([tool]);
jest.mocked(getExtendedTools).mockReset().mockReturnValue([]);
jest.mocked(dispatchTool).mockReset();
});
it('rejects malformed JSON-RPC messages without throwing', async () => {
expect(isJsonRpcRequest({ jsonrpc: '2.0', method: 'ping' })).toBe(true);
expect(isJsonRpcRequest({ jsonrpc: '1.0', method: 'ping' })).toBe(false);
expect(isJsonRpcRequest({ jsonrpc: '2.0', id: null, method: 'ping' })).toBe(false);
await expect(handleRpcMessage(null, auth)).resolves.toEqual({
jsonrpc: '2.0',
id: null,
error: { code: -32600, message: 'Invalid Request' },
});
});
it('returns server capabilities during initialization', async () => {
await expect(handleRpcMessage({ jsonrpc: '2.0', id: 1, method: 'initialize', params: initializeParams }, auth)).resolves.toEqual({
jsonrpc: '2.0',
id: 1,
result: {
protocolVersion: '2025-06-18',
capabilities: { tools: { listChanged: false } },
serverInfo: { name: 'rocketchat', version: '9.0' },
},
});
});
it('falls back to the latest supported version when negotiation fails', async () => {
await expect(
handleRpcMessage(
{
jsonrpc: '2.0',
id: 2,
method: 'initialize',
params: { ...initializeParams, protocolVersion: 'unsupported' },
},
auth,
),
).resolves.toMatchObject({ result: { protocolVersion: '2025-11-25' } });
});
it('rejects initialize requests without the required client information', async () => {
await expect(
handleRpcMessage({ jsonrpc: '2.0', id: 2, method: 'initialize', params: { protocolVersion: '2025-11-25' } }, auth),
).resolves.toMatchObject({ error: { code: -32602, message: 'Invalid initialize parameters' } });
});
it('lists the curated toolset by default', async () => {
const response = await handleRpcMessage({ jsonrpc: '2.0', id: 3, method: 'tools/list' }, auth);
expect(getCuratedTools).toHaveBeenCalledTimes(1);
expect(getExtendedTools).not.toHaveBeenCalled();
expect(response).toMatchObject({ result: { tools: [{ name: tool.name }] } });
});
it('lists the extended toolset when enabled', async () => {
jest.mocked(settings.get).mockReturnValue(true);
jest.mocked(getExtendedTools).mockReturnValue([extendedTool]);
const response = await handleRpcMessage({ jsonrpc: '2.0', id: 3, method: 'tools/list' }, auth);
expect(getExtendedTools).toHaveBeenCalledTimes(1);
expect(getCuratedTools).not.toHaveBeenCalled();
expect(response).toMatchObject({ result: { tools: [{ name: extendedTool.name }] } });
});
it('validates tool call parameters before dispatch', async () => {
await expect(
handleRpcMessage({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: tool.name, arguments: [] } }, auth),
).resolves.toMatchObject({ error: { code: -32602 } });
expect(dispatchTool).not.toHaveBeenCalled();
});
it('dispatches a known tool as the authenticated user', async () => {
jest.mocked(dispatchTool).mockResolvedValue({ ok: true, status: 200, body: { message: { _id: 'message-id' } } });
const response = await handleRpcMessage(
{ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: tool.name, arguments: { msgId: 'message-id' } } },
auth,
'192.0.2.1',
);
expect(dispatchTool).toHaveBeenCalledWith(tool, { msgId: 'message-id' }, auth, '192.0.2.1', undefined);
expect(response).toEqual({
jsonrpc: '2.0',
id: 4,
result: {
content: [{ type: 'text', text: JSON.stringify({ message: { _id: 'message-id' } }) }],
isError: false,
},
});
});
it('marks unsuccessful REST responses as tool errors', async () => {
jest.mocked(dispatchTool).mockResolvedValue({ ok: false, status: 403, body: { error: 'Forbidden' } });
await expect(
handleRpcMessage({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: tool.name, arguments: {} } }, auth),
).resolves.toMatchObject({ result: { isError: true, content: [{ text: JSON.stringify({ error: 'Forbidden' }) }] } });
});
it('returns thrown dispatch failures as tool errors', async () => {
jest.mocked(dispatchTool).mockRejectedValue(new Error('Connection failed'));
await expect(
handleRpcMessage({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: tool.name, arguments: {} } }, auth),
).resolves.toMatchObject({ result: { isError: true, content: [{ text: 'Tool execution failed: Connection failed' }] } });
});
it('rejects unknown tools without dispatching', async () => {
await expect(
handleRpcMessage({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'unknown', arguments: {} } }, auth),
).resolves.toMatchObject({ error: { code: -32602, message: 'Unknown tool: unknown' } });
expect(dispatchTool).not.toHaveBeenCalled();
});
it('does not reply to notifications', async () => {
await expect(handleRpcMessage({ jsonrpc: '2.0', method: 'notifications/initialized' }, auth)).resolves.toBeNull();
await expect(handleRpcMessage({ jsonrpc: '2.0', method: 'unknown/notification' }, auth)).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,150 @@
import { getCuratedTools, getExtendedTools, type McpTool } from './catalog';
import { dispatchTool, type McpResponseBudget } from './dispatch';
import { SUPPORTED_PROTOCOL_VERSIONS } from './transport';
import { getTrimmedServerVersion } from '../../../../server/api/lib/getTrimmedServerVersion';
import { settings } from '../../../../server/settings/cached';
export type McpAuth = {
userId: string;
/** The raw (unhashed) Personal Access Token, forwarded to the REST layer on dispatch. */
authToken: string;
};
const DEFAULT_PROTOCOL_VERSION = '2025-11-25';
const negotiateProtocolVersion = (requestedVersion: unknown): string =>
typeof requestedVersion === 'string' && SUPPORTED_PROTOCOL_VERSIONS.has(requestedVersion) ? requestedVersion : DEFAULT_PROTOCOL_VERSION;
export type JsonRpcRequest = {
jsonrpc: '2.0';
id?: string | number;
method: string;
params?: Record<string, unknown>;
};
export type JsonRpcResponse = {
jsonrpc: '2.0';
id: string | number | null;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
};
const result = (id: JsonRpcRequest['id'], value: unknown): JsonRpcResponse => ({ jsonrpc: '2.0', id: id ?? null, result: value });
const error = (id: JsonRpcResponse['id'] | undefined, code: number, message: string, data?: unknown): JsonRpcResponse => ({
jsonrpc: '2.0',
id: id ?? null,
error: { code, message, ...(data !== undefined && { data }) },
});
const isRecord = (value: unknown): value is Record<string, unknown> => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const isInitializeParams = (params: unknown): params is Record<string, unknown> => {
if (!isRecord(params) || typeof params.protocolVersion !== 'string' || !isRecord(params.capabilities) || !isRecord(params.clientInfo)) {
return false;
}
return typeof params.clientInfo.name === 'string' && typeof params.clientInfo.version === 'string';
};
export const isJsonRpcRequest = (value: unknown): value is JsonRpcRequest => {
if (!isRecord(value) || value.jsonrpc !== '2.0' || typeof value.method !== 'string' || value.method.length === 0) {
return false;
}
if (value.id !== undefined && typeof value.id !== 'string' && typeof value.id !== 'number') {
return false;
}
return value.params === undefined || isRecord(value.params);
};
const listTools = (): McpTool[] => {
if (settings.get<boolean>('MCP_Expose_Extended_API')) {
return getExtendedTools();
}
return getCuratedTools();
};
const toToolDefinition = ({ name, description, inputSchema }: McpTool) => ({ name, description, inputSchema });
/**
* Handle a single JSON-RPC message. Returns the response object, or `null` for
* notifications (which must not produce a response per the JSON-RPC spec).
*/
export const handleRpcMessage = async (
message: unknown,
auth: McpAuth,
clientIp?: string,
responseBudget?: McpResponseBudget,
): Promise<JsonRpcResponse | null> => {
if (!isJsonRpcRequest(message)) {
return error(null, -32600, 'Invalid Request');
}
const { id, method, params } = message;
const respond = (response: JsonRpcResponse): JsonRpcResponse | null => (id === undefined ? null : response);
switch (method) {
case 'initialize': {
if (!isInitializeParams(params)) {
return respond(error(id, -32602, 'Invalid initialize parameters'));
}
return respond(
result(id, {
protocolVersion: negotiateProtocolVersion(params.protocolVersion),
capabilities: { tools: { listChanged: false } },
serverInfo: {
name: 'rocketchat',
version: getTrimmedServerVersion(),
},
}),
);
}
case 'notifications/initialized':
case 'notifications/cancelled':
return null;
case 'ping':
return respond(result(id, {}));
case 'tools/list':
return respond(result(id, { tools: listTools().map(toToolDefinition) }));
case 'tools/call': {
const name = params?.name;
const args = params?.arguments;
if (typeof name !== 'string' || (args !== undefined && !isRecord(args))) {
return respond(error(id, -32602, 'Invalid tools/call parameters'));
}
const tool = listTools().find((t) => t.name === name);
if (!tool) {
return respond(error(id, -32602, `Unknown tool: ${name}`));
}
try {
const dispatch = await dispatchTool(tool, args ?? {}, auth, clientIp, responseBudget);
return respond(
result(id, {
content: [{ type: 'text', text: JSON.stringify(dispatch.body) }],
isError: !dispatch.ok,
}),
);
} catch (err) {
return respond(
result(id, {
content: [{ type: 'text', text: `Tool execution failed: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
}),
);
}
}
default:
return respond(error(id, -32601, `Method not found: ${method}`));
}
};

View File

@@ -0,0 +1,63 @@
import { isMcpOriginAllowed, isMcpProtocolVersionSupported, supportsMcpBatching } from './transport';
import { settings } from '../../../../server/settings/cached';
jest.mock('../../../../server/settings/cached', () => ({
settings: { get: jest.fn() },
}));
describe('MCP HTTP transport validation', () => {
beforeEach(() => {
jest.mocked(settings.get).mockImplementation((setting) => {
switch (setting) {
case 'Site_Url':
return 'https://chat.example.com/';
case 'API_Enable_CORS':
return true;
case 'API_CORS_Origin':
return 'https://client.example, https://other.example/path';
default:
return undefined;
}
});
});
it('allows non-browser, same-origin, and explicitly configured browser requests', () => {
expect(isMcpOriginAllowed(null)).toBe(true);
expect(isMcpOriginAllowed('https://chat.example.com')).toBe(true);
expect(isMcpOriginAllowed('https://client.example')).toBe(true);
expect(isMcpOriginAllowed('https://other.example')).toBe(true);
});
it('rejects malformed, untrusted, and wildcard-only browser origins', () => {
expect(isMcpOriginAllowed('not-an-origin')).toBe(false);
expect(isMcpOriginAllowed('https://attacker.example')).toBe(false);
jest.mocked(settings.get).mockImplementation((setting) => {
if (setting === 'Site_Url') {
return 'https://chat.example.com';
}
if (setting === 'API_Enable_CORS') {
return true;
}
if (setting === 'API_CORS_Origin') {
return '*';
}
return undefined;
});
expect(isMcpOriginAllowed('https://attacker.example')).toBe(false);
});
it('accepts an absent or supported protocol version only', () => {
expect(isMcpProtocolVersionSupported(null)).toBe(true);
expect(isMcpProtocolVersionSupported('2025-11-25')).toBe(true);
expect(isMcpProtocolVersionSupported('2099-01-01')).toBe(false);
});
it('only accepts JSON-RPC batches for the 2025-03-26 transport revision', () => {
expect(supportsMcpBatching(null)).toBe(true);
expect(supportsMcpBatching('2025-03-26')).toBe(true);
expect(supportsMcpBatching('2025-06-18')).toBe(false);
expect(supportsMcpBatching('2025-11-25')).toBe(false);
});
});

View File

@@ -0,0 +1,52 @@
import { settings } from '../../../../server/settings/cached';
export const SUPPORTED_PROTOCOL_VERSIONS = new Set(['2025-11-25', '2025-06-18', '2025-03-26']);
export const DEFAULT_PROTOCOL_VERSION = '2025-03-26';
const normalizeOrigin = (value: unknown): string | undefined => {
if (typeof value !== 'string' || value.length === 0) {
return undefined;
}
try {
return new URL(value).origin;
} catch {
return undefined;
}
};
/**
* MCP clients running outside a browser do not send Origin. Browser requests must come
* from the workspace itself or an explicitly configured CORS origin. A wildcard CORS
* setting is intentionally not accepted because it cannot protect against DNS rebinding.
*/
export const isMcpOriginAllowed = (origin: string | null): boolean => {
if (origin === null) {
return true;
}
const normalizedOrigin = normalizeOrigin(origin);
if (!normalizedOrigin) {
return false;
}
if (normalizedOrigin === normalizeOrigin(settings.get('Site_Url'))) {
return true;
}
if (!settings.get<boolean>('API_Enable_CORS')) {
return false;
}
const configuredOrigins = settings.get<string>('API_CORS_Origin') ?? '';
if (configuredOrigins === '*') {
return false;
}
return configuredOrigins.split(',').some((configuredOrigin) => normalizeOrigin(configuredOrigin.trim()) === normalizedOrigin);
};
export const isMcpProtocolVersionSupported = (version: string | null): boolean =>
version === null || SUPPORTED_PROTOCOL_VERSIONS.has(version);
export const supportsMcpBatching = (version: string | null): boolean => (version ?? DEFAULT_PROTOCOL_VERSION) === '2025-03-26';

View File

@@ -0,0 +1,190 @@
import type { IRole } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { Roles } from '@rocket.chat/models';
import { ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings';
import { Meteor } from 'meteor/meteor';
import { API } from '../../../server/api/api';
import { hasPermissionAsync } from '../../../server/lib/authorization/hasPermission';
import { settings } from '../../../server/settings';
import { insertRoleAsync } from '../lib/roles/insertRole';
import { updateRole } from '../lib/roles/updateRole';
type RoleCreateProps = Pick<IRole, 'name'> & Partial<Pick<IRole, 'description' | 'scope' | 'mandatory2fa'>>;
const roleCreatePropsSchema = {
type: 'object',
properties: {
name: {
type: 'string',
},
description: {
type: 'string',
nullable: true,
},
scope: {
type: 'string',
enum: ['Users', 'Subscriptions'],
nullable: true,
},
mandatory2fa: {
type: 'boolean',
nullable: true,
},
},
required: ['name'],
additionalProperties: false,
};
export const isRoleCreateProps = ajv.compile<RoleCreateProps>(roleCreatePropsSchema);
type RoleUpdateProps = {
roleId: IRole['_id'];
name: IRole['name'];
} & Partial<RoleCreateProps>;
const roleUpdatePropsSchema = {
type: 'object',
properties: {
roleId: {
type: 'string',
},
name: {
type: 'string',
},
description: {
type: 'string',
nullable: true,
},
scope: {
type: 'string',
enum: ['Users', 'Subscriptions'],
nullable: true,
},
mandatory2fa: {
type: 'boolean',
nullable: true,
},
},
required: ['roleId', 'name'],
additionalProperties: false,
};
export const isRoleUpdateProps = ajv.compile<RoleUpdateProps>(roleUpdatePropsSchema);
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Endpoints {
'/v1/roles.create': {
POST: (params: RoleCreateProps) => {
role: IRole;
};
};
'/v1/roles.update': {
POST: (role: RoleUpdateProps) => {
role: IRole;
};
};
}
}
const roleResponseSchema = ajv.compile<{ role: IRole }>({
type: 'object',
properties: {
role: { $ref: '#/components/schemas/IRole' },
success: { type: 'boolean', enum: [true] },
},
required: ['role', 'success'],
additionalProperties: false,
});
API.v1.post(
'roles.create',
{
authRequired: true,
license: ['custom-roles'],
body: isRoleCreateProps,
response: {
200: roleResponseSchema,
401: validateUnauthorizedErrorResponse,
400: validateBadRequestErrorResponse,
},
},
async function action() {
if (!License.hasModule('custom-roles')) {
throw new Meteor.Error('error-action-not-allowed', 'This is an enterprise feature');
}
const { userId } = this;
if (!userId || !(await hasPermissionAsync(userId, 'access-permissions'))) {
throw new Meteor.Error('error-action-not-allowed', 'Accessing permissions is not allowed');
}
const { name, scope, description, mandatory2fa } = this.bodyParams;
if (await Roles.findOneByIdOrName(name)) {
throw new Meteor.Error('error-duplicate-role-names-not-allowed', 'Role name already exists');
}
const roleData = {
description: description || '',
...(mandatory2fa !== undefined && { mandatory2fa }),
name,
scope: scope || 'Users',
protected: false,
};
const options = {
broadcastUpdate: settings.get<boolean>('UI_DisplayRoles'),
};
const role = await insertRoleAsync(roleData, options);
return API.v1.success({ role });
},
);
API.v1.post(
'roles.update',
{
authRequired: true,
// No route-level `license` gate: the license middleware would reject before the handler,
// but updating a protected role is allowed without custom-roles (handled in-action below).
body: isRoleUpdateProps,
response: {
200: roleResponseSchema,
401: validateUnauthorizedErrorResponse,
400: validateBadRequestErrorResponse,
},
},
async function action() {
if (!(await hasPermissionAsync(this.user, 'access-permissions'))) {
throw new Meteor.Error('error-action-not-allowed', 'Accessing permissions is not allowed');
}
const { roleId, name, scope, description, mandatory2fa } = this.bodyParams;
const role = await Roles.findOne(roleId);
if (!License.hasModule('custom-roles') && !role?.protected) {
throw new Meteor.Error('error-action-not-allowed', 'This is an enterprise feature');
}
const roleData = {
description: description || '',
...(mandatory2fa !== undefined && { mandatory2fa }),
name,
scope: scope || 'Users',
protected: false,
};
const options = {
broadcastUpdate: settings.get<boolean>('UI_DisplayRoles'),
};
const updatedRole = await updateRole(roleId, roleData, options);
return API.v1.success({ role: updatedRole });
},
);

View File

@@ -0,0 +1,380 @@
import { api } from '@rocket.chat/core-services';
import type { IUser, ISession, DeviceManagementSession, DeviceManagementPopulatedSession } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { Users, Sessions } from '@rocket.chat/models';
import type { PaginatedResult, PaginatedRequest } from '@rocket.chat/rest-typings';
import {
ajv,
ajvQuery,
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
validateNotFoundErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/tools';
import { API } from '../../../server/api/api';
import { getPaginationItems } from '../../../server/api/lib/getPaginationItems';
type SessionsProps = {
sessionId: string;
};
const isSessionsProps = ajv.compile<SessionsProps>({
type: 'object',
properties: {
sessionId: {
type: 'string',
},
},
required: ['sessionId'],
additionalProperties: false,
});
type SessionsPaginateProps = PaginatedRequest<{
filter?: string;
}>;
const isSessionsPaginateProps = ajvQuery.compile<SessionsPaginateProps>({
type: 'object',
properties: {
offset: {
type: 'number',
},
count: {
type: 'number',
},
filter: {
type: 'string',
},
sort: {
type: 'string',
},
},
required: [],
additionalProperties: false,
});
const validateSortKeys = (sortKeys: string[]): boolean => {
const validSortKeys = ['loginAt', 'device.name', 'device.os.name', 'device.os.version', '_user.name', '_user.username'];
return sortKeys.every((s) => validSortKeys.includes(s));
};
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Endpoints {
'/v1/sessions/list': {
GET: (params: SessionsPaginateProps) => PaginatedResult<{ sessions: Array<DeviceManagementSession> }>;
};
'/v1/sessions/info': {
GET: (params: SessionsProps) => DeviceManagementSession;
};
'/v1/sessions/logout.me': {
POST: (params: SessionsProps) => Pick<ISession, 'sessionId'>;
};
'/v1/sessions/list.all': {
GET: (params: SessionsPaginateProps) => PaginatedResult<{ sessions: Array<DeviceManagementPopulatedSession> }>;
};
'/v1/sessions/info.admin': {
GET: (params: SessionsProps) => DeviceManagementPopulatedSession;
};
'/v1/sessions/logout': {
POST: (params: SessionsProps) => Pick<ISession, 'sessionId'>;
};
}
}
const sessionsListResponseSchema = ajv.compile<PaginatedResult<{ sessions: DeviceManagementSession[] }>>({
type: 'object',
properties: {
sessions: { type: 'array', items: { $ref: '#/components/schemas/DeviceManagementSession' } },
count: { type: 'number' },
offset: { type: 'number' },
total: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['sessions', 'count', 'offset', 'total', 'success'],
additionalProperties: false,
});
const sessionsListAllResponseSchema = ajv.compile<PaginatedResult<{ sessions: DeviceManagementPopulatedSession[] }>>({
type: 'object',
properties: {
sessions: { type: 'array', items: { $ref: '#/components/schemas/DeviceManagementPopulatedSession' } },
count: { type: 'number' },
offset: { type: 'number' },
total: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['sessions', 'count', 'offset', 'total', 'success'],
additionalProperties: false,
});
const sessionInfoResponseSchema = ajv.compile<DeviceManagementSession>({
allOf: [
{ $ref: '#/components/schemas/DeviceManagementSession' },
{
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
},
],
});
const sessionInfoAdminResponseSchema = ajv.compile<DeviceManagementPopulatedSession>({
allOf: [
{ $ref: '#/components/schemas/DeviceManagementPopulatedSession' },
{
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
},
],
});
const sessionLogoutResponseSchema = ajv.compile<Pick<ISession, 'sessionId'>>({
type: 'object',
properties: {
sessionId: { type: 'string' },
success: { type: 'boolean', enum: [true] },
},
required: ['sessionId', 'success'],
additionalProperties: false,
});
API.v1.get(
'sessions/list',
{
authRequired: true,
query: isSessionsPaginateProps,
license: ['device-management'],
response: {
200: sessionsListResponseSchema,
401: validateUnauthorizedErrorResponse,
400: validateBadRequestErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
if (!License.hasModule('device-management')) {
return API.v1.forbidden();
}
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort = { loginAt: -1 } } = await this.parseJsonQuery();
const search = escapeRegExp(this.queryParams?.filter || '');
if (!validateSortKeys(Object.keys(sort))) {
return API.v1.failure('error-invalid-sort-keys');
}
const sessions = await Sessions.aggregateSessionsByUserId({
uid: this.userId,
search,
sort,
offset,
count,
currentLoginToken: this.token,
});
return API.v1.success(sessions);
},
);
API.v1.get(
'sessions/info',
{
authRequired: true,
query: isSessionsProps,
license: ['device-management'],
response: {
200: sessionInfoResponseSchema,
401: validateUnauthorizedErrorResponse,
400: validateBadRequestErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
},
},
async function action() {
if (!License.hasModule('device-management')) {
return API.v1.forbidden();
}
const { sessionId } = this.queryParams;
const sessions = await Sessions.findOneBySessionIdAndUserId(sessionId, this.userId);
if (!sessions) {
return API.v1.notFound('Session not found');
}
// Project to DeviceManagementSession explicitly — the raw ISession carries loginToken, which must not leak.
return API.v1.success({
_id: sessions._id,
sessionId: sessions.sessionId,
device: sessions.device,
host: sessions.host,
ip: sessions.ip,
loginAt: sessions.loginAt,
logoutAt: sessions.logoutAt,
userId: sessions.userId,
});
},
);
API.v1.post(
'sessions/logout.me',
{
authRequired: true,
body: isSessionsProps,
license: ['device-management'],
response: {
200: sessionLogoutResponseSchema,
401: validateUnauthorizedErrorResponse,
400: validateBadRequestErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
},
},
async function action() {
if (!License.hasModule('device-management')) {
return API.v1.forbidden();
}
const { sessionId } = this.bodyParams;
const sessionObj = await Sessions.findOneBySessionIdAndUserId(sessionId, this.userId);
if (!sessionObj?.loginToken) {
return API.v1.notFound('Session not found');
}
await api.broadcast('user.forceLogout', sessionObj.userId, sessionId);
await Promise.all([
Users.unsetOneLoginToken(this.userId, sessionObj.loginToken),
Sessions.logoutByloginTokenAndUserId({ loginToken: sessionObj.loginToken, userId: this.userId }),
]);
return API.v1.success({ sessionId });
},
);
API.v1.get(
'sessions/list.all',
{
authRequired: true,
twoFactorRequired: true,
query: isSessionsPaginateProps,
permissionsRequired: ['view-device-management'],
license: ['device-management'],
response: {
200: sessionsListAllResponseSchema,
401: validateUnauthorizedErrorResponse,
400: validateBadRequestErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
if (!License.hasModule('device-management')) {
return API.v1.forbidden();
}
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort = { loginAt: -1 } } = await this.parseJsonQuery();
const filter = escapeRegExp(this.queryParams?.filter || '');
if (!validateSortKeys(Object.keys(sort))) {
return API.v1.failure('error-invalid-sort-keys');
}
const search: string[] = [];
if (filter) {
search.push(filter);
search.push(
...(await Users.findActiveByUsernameOrNameRegexWithExceptionsAndConditions<Pick<IUser, '_id'>>(
{ $regex: filter, $options: 'i' },
[],
{},
{ projection: { _id: 1 }, limit: 5 },
)
.map((el) => el._id)
.toArray()),
);
}
const sessions = await Sessions.aggregateSessionsAndPopulate({ search: search.join('|'), sort, offset, count });
return API.v1.success(sessions);
},
);
API.v1.get(
'sessions/info.admin',
{
authRequired: true,
twoFactorRequired: true,
query: isSessionsProps,
permissionsRequired: ['view-device-management'],
license: ['device-management'],
response: {
200: sessionInfoAdminResponseSchema,
401: validateUnauthorizedErrorResponse,
400: validateBadRequestErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
},
},
async function action() {
if (!License.hasModule('device-management')) {
return API.v1.forbidden();
}
const sessionId = this.queryParams?.sessionId;
const { sessions } = await Sessions.aggregateSessionsAndPopulate({ search: sessionId, count: 1 });
if (!sessions?.length) {
return API.v1.notFound('Session not found');
}
return API.v1.success(sessions[0]);
},
);
API.v1.post(
'sessions/logout',
{
authRequired: true,
twoFactorRequired: true,
body: isSessionsProps,
permissionsRequired: ['logout-device-management'],
license: ['device-management'],
response: {
200: sessionLogoutResponseSchema,
401: validateUnauthorizedErrorResponse,
400: validateBadRequestErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
},
},
async function action() {
if (!License.hasModule('device-management')) {
return API.v1.forbidden();
}
const { sessionId } = this.bodyParams;
const sessionObj = await Sessions.findOneBySessionId(sessionId);
if (!sessionObj?.loginToken) {
return API.v1.notFound('Session not found');
}
await api.broadcast('user.forceLogout', sessionObj.userId, sessionId);
await Promise.all([
Users.unsetOneLoginToken(sessionObj.userId, sessionObj.loginToken),
Sessions.logoutByloginTokenAndUserId({ loginToken: sessionObj.loginToken, userId: sessionObj.userId, logoutBy: this.userId }),
]);
return API.v1.success({ sessionId });
},
);

View File

@@ -0,0 +1,192 @@
import type { ILivechatDepartment, IOmnichannelCannedResponse, IUser } from '@rocket.chat/core-typings';
import { isPOSTCannedResponsesProps, isCannedResponsesProps, isDELETECannedResponsesProps } from '@rocket.chat/rest-typings';
import type { PaginatedResult, PaginatedRequest } from '@rocket.chat/rest-typings';
import { API } from '../../../../server/api';
import { getPaginationItems } from '../../../../server/api/lib/getPaginationItems';
import { removeCannedResponse } from '../../meteor-methods/removeCannedResponse';
import { saveCannedResponse } from '../../meteor-methods/saveCannedResponse';
import { findAllCannedResponses, findAllCannedResponsesFilter, findOneCannedResponse } from '../lib/canned-responses';
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Endpoints {
'/v1/canned-responses': {
GET: (
params: PaginatedRequest<{
shortcut?: string;
text?: string;
scope?: string;
createdBy?: IUser['username'];
tags?: any;
departmentId?: ILivechatDepartment['_id'];
}>,
) => PaginatedResult<{
cannedResponses: IOmnichannelCannedResponse[];
}>;
POST: (params: {
_id?: IOmnichannelCannedResponse['_id'];
shortcut: string;
text: string;
scope: string;
tags?: any;
departmentId?: ILivechatDepartment['_id'];
}) => void;
DELETE: (params: { _id: IOmnichannelCannedResponse['_id'] }) => void;
};
'/v1/canned-responses/:_id': {
GET: () => {
cannedResponse: IOmnichannelCannedResponse;
};
DELETE: () => void;
};
}
}
API.v1.addRoute(
'canned-responses.get',
{ authRequired: true, permissionsRequired: ['view-canned-responses'], license: ['canned-responses'] },
{
async get() {
return API.v1.success({
responses: await findAllCannedResponses({ userId: this.userId }),
});
},
},
);
/**
* @deprecated
* @openapi
* /api/v1/canned-responses:
* delete:
* deprecated: true
* security:
* $ref: '#/security/authenticated'
* parameters:
* - in: body
* name: body
* description: |
* **_id** (required): Canned Response ID to be removed.
* schema:
* type: object
* required:
* - _id
* properties:
* _id:
* type: string
* tags:
* - Canned_Responses
* responses:
* 200:
* description: Successful Response
* schema:
* type: object
* properties:
* status:
* type: string
* example: success
* data:
* type: object
* description: The response data
* properties:
* success:
* type: boolean
* example: true
* 401:
* $ref: '#/responses/Unauthorized'
* 403:
* $ref: '#/responses/Forbidden'
* 404:
* $ref: '#/responses/NotFound'
* 500:
* $ref: '#/responses/InternalServerError'
*/
API.v1.addRoute(
'canned-responses',
{
authRequired: true,
permissionsRequired: { GET: ['view-canned-responses'], POST: ['save-canned-responses'], DELETE: ['remove-canned-responses'] },
validateParams: { POST: isPOSTCannedResponsesProps, DELETE: isDELETECannedResponsesProps, GET: isCannedResponsesProps },
license: ['canned-responses'],
deprecations: { DELETE: { version: '8.0.0', alternatives: ['/v1/canned-responses/:_id'] } },
},
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort, fields } = await this.parseJsonQuery();
const { shortcut, text, scope, tags, departmentId, createdBy } = this.queryParams;
const { cannedResponses, total } = await findAllCannedResponsesFilter({
shortcut,
text,
scope,
tags,
departmentId,
userId: this.userId,
createdBy,
options: {
sort,
offset,
count,
fields,
},
});
return API.v1.success({
cannedResponses,
count: cannedResponses.length,
offset,
total,
});
},
async post() {
const { _id, shortcut, text, scope, departmentId, tags } = this.bodyParams;
await saveCannedResponse(
this.userId,
{
shortcut,
text,
scope,
...(tags && { tags }),
...(departmentId && { departmentId }),
},
_id,
);
return API.v1.success();
},
// deprecated
async delete() {
const { _id } = this.bodyParams;
await removeCannedResponse(this.userId, _id);
return API.v1.success();
},
},
);
API.v1.addRoute(
'canned-responses/:_id',
{
authRequired: true,
permissionsRequired: { GET: ['view-canned-responses'], DELETE: ['remove-canned-responses'] },
license: ['canned-responses'],
},
{
async get() {
const { _id } = this.urlParams;
const cannedResponse = await findOneCannedResponse({
userId: this.userId,
_id,
});
if (!cannedResponse) {
return API.v1.notFound();
}
return API.v1.success({ cannedResponse });
},
async delete() {
const { _id } = this.urlParams;
await removeCannedResponse(this.userId, _id);
return API.v1.success();
},
},
);

View File

@@ -0,0 +1,32 @@
import type { LicenseManager } from '@rocket.chat/license';
import type { MiddlewareHandler } from 'hono';
import type { FailureResult, TypedOptions } from '../../../../../server/api/definition';
export const license =
(options: TypedOptions, licenseManager: LicenseManager): MiddlewareHandler =>
async (c, next) => {
if (!options.license) {
return next();
}
const license = options.license.every((license) => licenseManager.hasModule(license));
const failure: FailureResult<{
error: string;
errorType: string;
}> = {
statusCode: 400,
body: {
success: false,
error: 'This is an enterprise feature [error-action-not-allowed]',
errorType: 'error-action-not-allowed',
},
};
if (!license) {
return c.json(failure.body, failure.statusCode);
}
return next();
};

View File

@@ -0,0 +1,127 @@
import {
isLivechatAnalyticsAgentsTotalServiceTimeProps,
isLivechatAnalyticsAgentsAverageServiceTimeProps,
isLivechatAnalyticsAgentsAvailableForServiceHistoryProps,
} from '@rocket.chat/rest-typings';
import { API } from '../../../../../server/api';
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
import {
findAllAverageServiceTimeAsync,
findAllServiceTimeAsync,
findAvailableServiceTimeHistoryAsync,
} from '../../../../../server/lib/omnichannel/analytics/agents';
API.v1.addRoute(
'livechat/analytics/agents/average-service-time',
{
authRequired: true,
permissionsRequired: ['view-livechat-manager'],
validateParams: isLivechatAnalyticsAgentsAverageServiceTimeProps,
license: ['livechat-enterprise'],
},
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { start, end } = this.queryParams;
if (isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
const startDate = new Date(start);
if (isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const endDate = new Date(end);
const { agents, total } = await findAllAverageServiceTimeAsync({
start: startDate,
end: endDate,
options: { offset, count },
});
return API.v1.success({
agents,
count: agents.length,
offset,
total,
});
},
},
);
API.v1.addRoute(
'livechat/analytics/agents/total-service-time',
{
authRequired: true,
permissionsRequired: ['view-livechat-manager'],
validateParams: isLivechatAnalyticsAgentsTotalServiceTimeProps,
license: ['livechat-enterprise'],
},
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { start, end } = this.queryParams;
if (isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
const startDate = new Date(start);
if (isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const endDate = new Date(end);
const { agents, total } = await findAllServiceTimeAsync({
start: startDate,
end: endDate,
options: { offset, count },
});
return API.v1.success({
agents,
count: agents.length,
offset,
total,
});
},
},
);
API.v1.addRoute(
'livechat/analytics/agents/available-for-service-history',
{
authRequired: true,
permissionsRequired: ['view-livechat-manager'],
validateParams: isLivechatAnalyticsAgentsAvailableForServiceHistoryProps,
license: ['livechat-enterprise'],
},
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { start, end } = this.queryParams;
const { fullReport } = this.queryParams;
if (isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
if (isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const { agents, total } = await findAvailableServiceTimeHistoryAsync({
start,
end,
fullReport: fullReport === 'true',
options: { offset, count },
});
return API.v1.success({
agents,
count: agents.length,
offset,
total,
});
},
},
);

View File

@@ -0,0 +1,44 @@
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
import type { PaginatedRequest } from '@rocket.chat/rest-typings';
import { API } from '../../../../../server/api';
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
import { findBusinessHours } from '../../../lib/omnichannel/business-hour/lib/business-hour';
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Endpoints {
'/v1/livechat/business-hours': {
GET: (params: PaginatedRequest) => {
businessHours: ILivechatBusinessHour[];
count: number;
offset: number;
total: number;
};
};
}
}
API.v1.addRoute(
'livechat/business-hours',
{ authRequired: true, permissionsRequired: ['view-livechat-business-hours'], license: ['livechat-enterprise'] },
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const { name } = this.queryParams;
return API.v1.success(
await findBusinessHours(
this.userId,
{
offset,
count,
sort,
},
name,
),
);
},
},
);

View File

@@ -0,0 +1,93 @@
import type { ILivechatContactVisitorAssociation } from '@rocket.chat/core-typings';
import { ContactVisitorAssociationSchema, ajv } from '@rocket.chat/rest-typings';
import { changeContactBlockStatus, closeBlockedRoom, ensureSingleContactLicense } from './lib/contacts';
import { API } from '../../../../../server/api';
import { logger } from '../../../lib/omnichannel/logger';
type blockContactProps = {
visitor: ILivechatContactVisitorAssociation;
};
const blockContactSchema = {
type: 'object',
properties: {
visitor: ContactVisitorAssociationSchema,
},
required: ['visitor'],
additionalProperties: false,
};
const isBlockContactProps = ajv.compile<blockContactProps>(blockContactSchema);
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Endpoints {
'/v1/omnichannel/contacts.block': {
POST: (params: blockContactProps) => void;
};
'/v1/omnichannel/contacts.unblock': {
POST: (params: blockContactProps) => void;
};
}
}
API.v1.addRoute(
'omnichannel/contacts.block',
{
authRequired: true,
permissionsRequired: ['block-livechat-contact'],
validateParams: isBlockContactProps,
license: ['livechat-enterprise'],
},
{
async post() {
ensureSingleContactLicense();
const { visitor } = this.bodyParams;
const { user } = this;
await changeContactBlockStatus({
visitor,
block: true,
});
logger.info({
msg: 'Visitor blocked',
visitorId: visitor.visitorId,
userId: user._id,
});
await closeBlockedRoom(visitor, user);
return API.v1.success();
},
},
);
API.v1.addRoute(
'omnichannel/contacts.unblock',
{
authRequired: true,
permissionsRequired: ['unblock-livechat-contact'],
validateParams: isBlockContactProps,
license: ['livechat-enterprise'],
},
{
async post() {
ensureSingleContactLicense();
const { visitor } = this.bodyParams;
const { user } = this;
await changeContactBlockStatus({
visitor,
block: false,
});
logger.info({
msg: 'Visitor unblocked',
visitorId: visitor.visitorId,
userId: user._id,
});
return API.v1.success();
},
},
);

View File

@@ -0,0 +1,344 @@
import {
isLivechatAnalyticsDepartmentsAmountOfChatsProps,
isLivechatAnalyticsDepartmentsAverageServiceTimeProps,
isLivechatAnalyticsDepartmentsAverageChatDurationTimeProps,
isLivechatAnalyticsDepartmentsTotalServiceTimeProps,
isLivechatAnalyticsDepartmentsAverageWaitingTimeProps,
isLivechatAnalyticsDepartmentsTotalTransferredChatsProps,
isLivechatAnalyticsDepartmentsTotalAbandonedChatsProps,
isLivechatAnalyticsDepartmentsPercentageAbandonedChatsProps,
} from '@rocket.chat/rest-typings';
import { API } from '../../../../../server/api';
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
import {
findAllRoomsAsync,
findAllAverageServiceTimeAsync,
findAllServiceTimeAsync,
findAllAverageWaitingTimeAsync,
findAllNumberOfTransferredRoomsAsync,
findAllNumberOfAbandonedRoomsAsync,
findPercentageOfAbandonedRoomsAsync,
findAllAverageOfChatDurationTimeAsync,
} from '../../../../../server/lib/omnichannel/analytics/departments';
API.v1.addRoute(
'livechat/analytics/departments/amount-of-chats',
{
authRequired: true,
permissionsRequired: ['view-livechat-manager'],
validateParams: isLivechatAnalyticsDepartmentsAmountOfChatsProps,
license: ['livechat-enterprise'],
},
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { start, end } = this.queryParams;
const { answered, departmentId } = this.queryParams;
if (isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
const startDate = new Date(start);
if (isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const endDate = new Date(end);
const { departments, total } = await findAllRoomsAsync({
start: startDate,
end: endDate,
answered: answered === 'true',
departmentId,
options: { offset, count },
});
return API.v1.success({
departments,
count: departments.length,
offset,
total,
});
},
},
);
API.v1.addRoute(
'livechat/analytics/departments/average-service-time',
{
authRequired: true,
permissionsRequired: ['view-livechat-manager'],
validateParams: isLivechatAnalyticsDepartmentsAverageServiceTimeProps,
license: ['livechat-enterprise'],
},
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { start, end } = this.queryParams;
const { departmentId } = this.queryParams;
if (isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
const startDate = new Date(start);
if (isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const endDate = new Date(end);
const { departments, total } = await findAllAverageServiceTimeAsync({
start: startDate,
end: endDate,
departmentId,
options: { offset, count },
});
return API.v1.success({
departments,
count: departments.length,
offset,
total,
});
},
},
);
API.v1.addRoute(
'livechat/analytics/departments/average-chat-duration-time',
{
authRequired: true,
permissionsRequired: ['view-livechat-manager'],
validateParams: isLivechatAnalyticsDepartmentsAverageChatDurationTimeProps,
license: ['livechat-enterprise'],
},
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { start, end } = this.queryParams;
const { departmentId } = this.queryParams;
if (isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
const startDate = new Date(start);
if (isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const endDate = new Date(end);
const { departments, total } = await findAllAverageOfChatDurationTimeAsync({
start: startDate,
end: endDate,
departmentId,
options: { offset, count },
});
return API.v1.success({
departments,
count: departments.length,
offset,
total,
});
},
},
);
API.v1.addRoute(
'livechat/analytics/departments/total-service-time',
{
authRequired: true,
permissionsRequired: ['view-livechat-manager'],
validateParams: isLivechatAnalyticsDepartmentsTotalServiceTimeProps,
license: ['livechat-enterprise'],
},
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { start, end } = this.queryParams;
const { departmentId } = this.queryParams;
if (isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
const startDate = new Date(start);
if (isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const endDate = new Date(end);
const { departments, total } = await findAllServiceTimeAsync({
start: startDate,
end: endDate,
departmentId,
options: { offset, count },
});
return API.v1.success({
departments,
count: departments.length,
offset,
total,
});
},
},
);
API.v1.addRoute(
'livechat/analytics/departments/average-waiting-time',
{
authRequired: true,
permissionsRequired: ['view-livechat-manager'],
validateParams: isLivechatAnalyticsDepartmentsAverageWaitingTimeProps,
license: ['livechat-enterprise'],
},
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { start, end } = this.queryParams;
const { departmentId } = this.queryParams;
if (isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
const startDate = new Date(start);
if (isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const endDate = new Date(end);
const { departments, total } = await findAllAverageWaitingTimeAsync({
start: startDate,
end: endDate,
departmentId,
options: { offset, count },
});
return API.v1.success({
departments,
count: departments.length,
offset,
total,
});
},
},
);
API.v1.addRoute(
'livechat/analytics/departments/total-transferred-chats',
{
authRequired: true,
permissionsRequired: ['view-livechat-manager'],
validateParams: isLivechatAnalyticsDepartmentsTotalTransferredChatsProps,
license: ['livechat-enterprise'],
},
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { start, end } = this.queryParams;
const { departmentId } = this.queryParams;
if (isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
const startDate = new Date(start);
if (isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const endDate = new Date(end);
const { departments, total } = await findAllNumberOfTransferredRoomsAsync({
start: startDate,
end: endDate,
departmentId,
options: { offset, count },
});
return API.v1.success({
departments,
count: departments.length,
offset,
total,
});
},
},
);
API.v1.addRoute(
'livechat/analytics/departments/total-abandoned-chats',
{
authRequired: true,
permissionsRequired: ['view-livechat-manager'],
validateParams: isLivechatAnalyticsDepartmentsTotalAbandonedChatsProps,
license: ['livechat-enterprise'],
},
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { start, end } = this.queryParams;
const { departmentId } = this.queryParams;
if (isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
const startDate = new Date(start);
if (isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const endDate = new Date(end);
const { departments, total } = await findAllNumberOfAbandonedRoomsAsync({
start: startDate,
end: endDate,
departmentId,
options: { offset, count },
});
return API.v1.success({
departments,
count: departments.length,
offset,
total,
});
},
},
);
API.v1.addRoute(
'livechat/analytics/departments/percentage-abandoned-chats',
{
authRequired: true,
permissionsRequired: ['view-livechat-manager'],
validateParams: isLivechatAnalyticsDepartmentsPercentageAbandonedChatsProps,
license: ['livechat-enterprise'],
},
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { start, end } = this.queryParams;
const { departmentId } = this.queryParams;
if (isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
const startDate = new Date(start);
if (isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const endDate = new Date(end);
const { departments, total } = await findPercentageOfAbandonedRoomsAsync({
start: startDate,
end: endDate,
departmentId,
options: { offset, count },
});
return API.v1.success({
departments,
count: departments.length,
offset,
total,
});
},
},
);

View File

@@ -0,0 +1,15 @@
import './agents';
import './departments';
import './inquiries';
import './monitors';
import './sla';
import './priorities';
import './tags';
import './units';
import './business-hours';
import './rooms';
import './transcript';
import './reports';
import './triggers';
import './contacts';
import './outbound';

View File

@@ -0,0 +1,27 @@
import { setSLAToInquiry } from './lib/inquiries';
import { API } from '../../../../../server/api';
API.v1.addRoute(
'livechat/inquiry.setSLA',
{
authRequired: true,
permissionsRequired: {
PUT: { permissions: ['view-l-room', 'manage-livechat-sla'], operation: 'hasAny' },
},
license: ['livechat-enterprise'],
},
{
async put() {
const { roomId, sla } = this.bodyParams;
if (!roomId) {
return API.v1.failure("The 'roomId' param is required");
}
await setSLAToInquiry({
userId: this.userId,
roomId,
sla,
});
return API.v1.success();
},
},
);

View File

@@ -0,0 +1,36 @@
import type { IUser, ILivechatContactVisitorAssociation } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { LivechatContacts, LivechatRooms, LivechatVisitors } from '@rocket.chat/models';
import { i18n } from '../../../../../../server/lib/i18n';
import { closeRoom } from '../../../../../../server/lib/omnichannel/closeRoom';
export async function changeContactBlockStatus({ block, visitor }: { visitor: ILivechatContactVisitorAssociation; block: boolean }) {
const result = await LivechatContacts.setChannelBlockStatus(visitor, block);
if (!result.modifiedCount) {
throw new Error('error-contact-not-found');
}
}
export function ensureSingleContactLicense() {
if (!License.hasModule('contact-id-verification')) {
throw new Error('error-action-not-allowed');
}
}
export async function closeBlockedRoom(association: ILivechatContactVisitorAssociation, user: IUser) {
const visitor = await LivechatVisitors.findOneById(association.visitorId);
if (!visitor) {
throw new Error('error-visitor-not-found');
}
const room = await LivechatRooms.findOneOpenByContactChannelVisitor(association);
if (!room) {
return;
}
return closeRoom({ room, visitor, comment: i18n.t('close-blocked-room-comment'), user });
}

View File

@@ -0,0 +1,62 @@
import type { ReportResult, ReportWithUnmatchingElements, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
import mem from 'mem';
import type { Filter } from 'mongodb';
type AggParams = { start: Date; end: Date; sort: Record<string, 1 | -1>; extraQuery: Filter<IOmnichannelRoom> };
const defaultValue = { data: [], total: 0 };
export const findAllConversationsBySource = async ({ start, end, extraQuery }: Omit<AggParams, 'sort'>): Promise<ReportResult> => {
return (await LivechatRooms.getConversationsBySource(start, end, extraQuery).toArray())[0] || defaultValue;
};
export const findAllConversationsByStatus = async ({ start, end, extraQuery }: Omit<AggParams, 'sort'>): Promise<ReportResult> => {
return (await LivechatRooms.getConversationsByStatus(start, end, extraQuery).toArray())[0] || defaultValue;
};
export const findAllConversationsByDepartment = async ({
start,
end,
sort,
extraQuery,
}: AggParams): Promise<ReportWithUnmatchingElements> => {
const [result, total] = await Promise.all([
LivechatRooms.getConversationsByDepartment(start, end, sort, extraQuery).toArray(),
LivechatRooms.getTotalConversationsWithoutDepartmentBetweenDates(start, end, extraQuery),
]);
return {
...(result?.[0] || defaultValue),
unspecified: total || 0,
};
};
export const findAllConversationsByTags = async ({ start, end, sort, extraQuery }: AggParams): Promise<ReportWithUnmatchingElements> => {
const [result, total] = await Promise.all([
LivechatRooms.getConversationsByTags(start, end, sort, extraQuery).toArray(),
LivechatRooms.getConversationsWithoutTagsBetweenDate(start, end, extraQuery),
]);
return {
...(result?.[0] || defaultValue),
unspecified: total || 0,
};
};
export const findAllConversationsByAgents = async ({ start, end, sort, extraQuery }: AggParams): Promise<ReportWithUnmatchingElements> => {
const [result, total] = await Promise.all([
LivechatRooms.getConversationsByAgents(start, end, sort, extraQuery).toArray(),
LivechatRooms.getTotalConversationsWithoutAgentsBetweenDate(start, end, extraQuery),
]);
return {
...(result?.[0] || defaultValue),
unspecified: total || 0,
};
};
export const findAllConversationsBySourceCached = mem(findAllConversationsBySource, { maxAge: 60000, cacheKey: JSON.stringify });
export const findAllConversationsByStatusCached = mem(findAllConversationsByStatus, { maxAge: 60000, cacheKey: JSON.stringify });
export const findAllConversationsByDepartmentCached = mem(findAllConversationsByDepartment, { maxAge: 60000, cacheKey: JSON.stringify });
export const findAllConversationsByTagsCached = mem(findAllConversationsByTags, { maxAge: 60000, cacheKey: JSON.stringify });
export const findAllConversationsByAgentsCached = mem(findAllConversationsByAgents, { maxAge: 60000, cacheKey: JSON.stringify });

View File

@@ -0,0 +1,11 @@
export interface IPagination {
offset: number;
count: number;
sort: Record<string, any>;
}
export interface IPaginatedResponse {
count: number;
offset: number;
total: number;
}

View File

@@ -0,0 +1,45 @@
import { LivechatDepartment, LivechatDepartmentAgents, LivechatUnit } from '@rocket.chat/models';
import { helperLogger } from '../../../../lib/omnichannel/logger';
export const getDepartmentsWhichUserCanAccess = async (userId: string, includeDisabled = false): Promise<string[]> => {
const departments = await LivechatDepartmentAgents.find(
{
agentId: userId,
},
{
projection: {
departmentId: 1,
},
},
).toArray();
const monitoredDepartments = await LivechatUnit.findMonitoredDepartmentsByMonitorId(userId, includeDisabled);
const combinedDepartments = [
...departments.map((department) => department.departmentId),
...monitoredDepartments.map((department) => department._id),
];
return [...new Set(combinedDepartments)];
};
export const hasAccessToDepartment = async (userId: string, departmentId: string): Promise<boolean> => {
const department = await LivechatDepartmentAgents.findOneByAgentIdAndDepartmentId(userId, departmentId, { projection: { _id: 1 } });
if (department) {
helperLogger.debug({
msg: 'User has access to department as agent',
userId,
departmentId,
});
return true;
}
const monitorAccess = await LivechatDepartment.checkIfMonitorIsMonitoringDepartmentById(userId, departmentId);
helperLogger.debug({
msg: 'User monitor access status for department',
userId,
departmentId,
hasAccess: monitorAccess,
});
return monitorAccess;
};

View File

@@ -0,0 +1,30 @@
import { LivechatInquiry, Users, OmnichannelServiceLevelAgreements } from '@rocket.chat/models';
import { updateRoomSLA } from './sla';
export async function setSLAToInquiry({ userId, roomId, sla }: { userId: string; roomId: string; sla?: string }): Promise<void> {
const inquiry = await LivechatInquiry.findOneByRoomId(roomId, { projection: { status: 1 } });
if (!inquiry || inquiry.status !== 'queued') {
throw new Error('error-invalid-inquiry');
}
const slaData = sla && (await OmnichannelServiceLevelAgreements.findOneByIdOrName(sla));
if (!slaData) {
throw new Error('error-invalid-sla');
}
const user = await Users.findOneById(userId, { projection: { _id: 1, username: 1, name: 1 } });
if (!user?.username) {
throw new Error('error-invalid-user');
}
await updateRoomSLA(
roomId,
{
_id: user._id,
name: user.name || '',
username: user.username,
},
slaData,
);
}

View File

@@ -0,0 +1,71 @@
import type { ILivechatMonitor, IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import type { PaginatedResult } from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/tools';
export async function findMonitors({
text,
pagination: { offset, count, sort },
}: {
text?: string;
pagination: {
offset: number;
count: number;
sort: {
[key: string]: 1 | -1;
};
};
}): Promise<PaginatedResult<{ monitors: ILivechatMonitor[] }>> {
const query = {};
if (text) {
const filterReg = new RegExp(escapeRegExp(text), 'i');
Object.assign(query, {
$or: [{ username: filterReg }, { name: filterReg }, { 'emails.address': filterReg }],
});
}
const { cursor, totalCount } = Users.findPaginatedUsersInRolesWithQuery<ILivechatMonitor>('livechat-monitor', query, {
sort: sort || { name: 1 },
skip: offset,
limit: count,
projection: {
username: 1,
name: 1,
status: 1,
statusLivechat: 1,
emails: 1,
livechat: 1,
},
});
const [monitors, total] = await Promise.all([cursor.toArray(), totalCount]);
return {
monitors,
count: monitors.length,
offset,
total,
};
}
export async function findMonitorByUsername({ username }: { username: string }): Promise<IUser> {
const user = await Users.findOne(
{ username, roles: 'livechat-monitor' },
{
projection: {
username: 1,
name: 1,
status: 1,
statusLivechat: 1,
emails: 1,
livechat: 1,
},
},
);
if (!user) {
throw new Error('invalid-user');
}
return user;
}

View File

@@ -0,0 +1,73 @@
import { Apps } from '@rocket.chat/apps';
import type {
IOutboundProvider,
ValidOutboundProvider,
IOutboundMessageProviderService,
IOutboundProviderMetadata,
IOutboundMessage,
} from '@rocket.chat/core-typings';
import { ValidOutboundProviderList } from '@rocket.chat/core-typings';
import { OutboundMessageProvider } from '../../../../../../server/lib/OutboundMessageProvider';
import { getOutboundService } from '../../../../../../server/lib/omnichannel/outboundcommunication';
export class OutboundMessageProviderService implements IOutboundMessageProviderService {
private readonly provider: OutboundMessageProvider;
constructor() {
this.provider = new OutboundMessageProvider();
}
get outboundMessageProvider() {
return this.provider;
}
private isProviderValid(type: any): type is ValidOutboundProvider {
return ValidOutboundProviderList.includes(type);
}
public listOutboundProviders(type?: string): IOutboundProvider[] {
if (type !== undefined && !this.isProviderValid(type)) {
throw new Error('Invalid type');
}
return this.provider.getOutboundMessageProviders(type);
}
public getProviderMetadata(providerId: string): Promise<IOutboundProviderMetadata> {
const provider = this.provider.findOneByProviderId(providerId);
if (!provider) {
throw new Error('error-invalid-provider');
}
return this.getProviderManager().getProviderMetadata(provider.appId, provider.type);
}
private getProviderManager() {
if (!Apps.self?.isLoaded()) {
throw new Error('apps-engine-not-loaded');
}
const manager = Apps.self?.getManager()?.getOutboundCommunicationProviderManager();
if (!manager) {
throw new Error('apps-engine-not-configured-correctly');
}
return manager;
}
public sendMessage(providerId: string, message: IOutboundMessage) {
const provider = this.provider.findOneByProviderId(providerId);
if (!provider) {
throw new Error('error-invalid-provider');
}
return this.getProviderManager().sendOutboundMessage(provider.appId, provider.type, message);
}
}
export const outboundMessageProvider = new OutboundMessageProviderService();
getOutboundService.patch(() => {
return outboundMessageProvider;
});

View File

@@ -0,0 +1,120 @@
import { Message } from '@rocket.chat/core-services';
import type { ILivechatPriority, IOmnichannelRoom, IUser } from '@rocket.chat/core-typings';
import { LivechatInquiry, LivechatPriority, LivechatRooms } from '@rocket.chat/models';
import type { PaginatedResult } from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/tools';
import type { FindOptions } from 'mongodb';
import { notifyOnLivechatInquiryChangedByRoom, notifyOnRoomChanged } from '../../../../../../server/lib/notifyListener';
import { logger } from '../../../../lib/omnichannel/logger';
type FindPriorityParams = {
text?: string;
pagination: {
offset: number;
count: number;
sort: FindOptions<ILivechatPriority>['sort'];
};
};
export async function findPriority({
text,
pagination: { offset, count, sort },
}: FindPriorityParams): Promise<PaginatedResult<{ priorities: ILivechatPriority[] }>> {
const query = {
...(text && { $or: [{ name: new RegExp(escapeRegExp(text), 'i') }, { description: new RegExp(escapeRegExp(text), 'i') }] }),
};
const { cursor, totalCount } = LivechatPriority.findPaginated(query, {
sort: sort || { name: 1 },
skip: offset,
limit: count,
});
const [priorities, total] = await Promise.all([cursor.toArray(), totalCount]);
return {
priorities,
count: priorities.length,
offset,
total,
};
}
export async function updatePriority(_id: string, data: Pick<ILivechatPriority, 'name'> & { reset?: boolean }): Promise<ILivechatPriority> {
if (data.name) {
// If we want to enforce translated duplicates we need to change this
const priority = await LivechatPriority.findOneNameUsingRegex(data.name, { projection: { name: 1 } });
if (priority && priority._id !== _id) {
throw new Error('error-duplicate-priority-name');
}
}
const createdResult = await LivechatPriority.updatePriority(_id, data.reset || false, data.name);
if (!createdResult) {
logger.error({ msg: 'Error updating priority: unsuccessful result from MongoDB', priorityId: _id, result: createdResult });
throw Error('error-unable-to-update-priority');
}
return createdResult;
}
export const updateRoomPriority = async (
rid: string,
user: Required<Pick<IUser, '_id' | 'username' | 'name'>>,
priorityId: string,
): Promise<void> => {
const room = await LivechatRooms.findOneById(rid);
if (!room) {
throw new Error('error-room-does-not-exist');
}
const priority = await LivechatPriority.findOneById(priorityId);
if (!priority) {
throw new Error('error-invalid-priority');
}
await Promise.all([
LivechatRooms.setPriorityByRoomId(rid, priority),
LivechatInquiry.setPriorityForRoom(rid, priority),
addPriorityChangeHistoryToRoom(room._id, user, priority),
]);
void notifyOnRoomChanged({ ...room, priorityId: priority._id, priorityWeight: priority.sortItem }, 'updated');
void notifyOnLivechatInquiryChangedByRoom(rid, 'updated');
};
export const removePriorityFromRoom = async (rid: string, user: Required<Pick<IUser, '_id' | 'username' | 'name'>>): Promise<void> => {
const room = await LivechatRooms.findOneById<Omit<IOmnichannelRoom, 'priorityId' | 'priorityWeight'>>(rid, {
projection: { priorityId: 0, priorityWeight: 0 },
});
if (!room) {
throw new Error('error-room-does-not-exist');
}
await Promise.all([
LivechatRooms.unsetPriorityByRoomId(rid),
LivechatInquiry.unsetPriorityForRoom(rid),
addPriorityChangeHistoryToRoom(rid, user),
]);
void notifyOnRoomChanged(room, 'updated');
void notifyOnLivechatInquiryChangedByRoom(rid, 'updated');
};
const addPriorityChangeHistoryToRoom = async (
roomId: string,
user: Required<Pick<IUser, '_id' | 'username' | 'name'>>,
priority?: Pick<ILivechatPriority, 'name' | 'i18n'>,
) => {
await Message.saveSystemMessage('omnichannel_priority_change_history', roomId, '', user, {
priorityData: {
definedBy: {
_id: user._id,
username: user.username,
},
...(priority && { priority }),
},
});
};

View File

@@ -0,0 +1,58 @@
import type { IOmnichannelServiceLevelAgreements, IUser } from '@rocket.chat/core-typings';
import { OmnichannelServiceLevelAgreements } from '@rocket.chat/models';
import type { PaginatedResult } from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/tools';
import type { FindOptions } from 'mongodb';
import {
addSlaChangeHistoryToRoom,
removeInquiryQueueSla,
removeSlaFromRoom,
updateInquiryQueueSla,
updateRoomSlaWeights,
} from '../../../../lib/omnichannel/SlaHelper';
type FindSLAParams = {
text?: string;
pagination: {
offset: number;
count: number;
sort: FindOptions<IOmnichannelServiceLevelAgreements>['sort'];
};
};
export async function findSLA({
text,
pagination: { offset, count, sort },
}: FindSLAParams): Promise<PaginatedResult<{ sla: IOmnichannelServiceLevelAgreements[] }>> {
const query = {
...(text && { $or: [{ name: new RegExp(escapeRegExp(text), 'i') }, { description: new RegExp(escapeRegExp(text), 'i') }] }),
};
const { cursor, totalCount } = OmnichannelServiceLevelAgreements.findPaginated(query, {
sort: sort || { name: 1 },
skip: offset,
limit: count,
});
const [sla, total] = await Promise.all([cursor.toArray(), totalCount]);
return {
sla,
count: sla.length,
offset,
total,
};
}
export const updateRoomSLA = async (
roomId: string,
user: Required<Pick<IUser, '_id' | 'username' | 'name'>>,
sla: Pick<IOmnichannelServiceLevelAgreements, '_id' | 'name' | 'dueTimeInMinutes'>,
) => {
await Promise.all([updateInquiryQueueSla(roomId, sla), updateRoomSlaWeights(roomId, sla), addSlaChangeHistoryToRoom(roomId, user, sla)]);
};
export const removeRoomSLA = async (roomId: string, user: Required<Pick<IUser, '_id' | 'username' | 'name'>>) => {
await Promise.all([removeInquiryQueueSla(roomId), removeSlaFromRoom(roomId), addSlaChangeHistoryToRoom(roomId, user)]);
};

View File

@@ -0,0 +1,127 @@
import type { ILivechatTag, FindTagsResult } from '@rocket.chat/core-typings';
import { LivechatTag } from '@rocket.chat/models';
import { escapeRegExp } from '@rocket.chat/tools';
import type { Filter, FindOptions } from 'mongodb';
import { getDepartmentsWhichUserCanAccess } from './departments';
import { hasPermissionAsync } from '../../../../../../server/lib/authorization/hasPermission';
import { helperLogger } from '../../../../lib/omnichannel/logger';
type FindTagsParams = {
userId: string;
text?: string;
pagination: {
offset: number;
count: number;
sort: FindOptions<ILivechatTag>['sort'];
};
department?: string;
viewAll?: boolean;
};
type FindTagsByIdParams = {
userId: string;
tagId: string;
};
type FindTagsByIdResult = ILivechatTag | null;
// If viewAll is true
// -> & user has access to all tags
// -> then all tags will be returned
// -> Pages:
// - Admin > Omnichannel > Tags
// - Current chat's panel, filter by tags
// - Canned response creation
// -> & user does not have access to all tags
// -> then only public tags will be returned (unauthorized access - no page uses this)
// If viewAll is false
// -> & user has access to all tags
// -> & department is not specified
// -> only public tags will be returned (Pages: Close chat modal tag selection for chats not associated with a department being closed by manager)
// -> & department is specified
// -> only tags associated with the department will be returned (Page: Close chat modal tag selection for chats associated with a department being closed by manager)
// -> & user does not have access to all tags (same as viewAll = false & user has access to all tags)
// -> & department is not specified
// -> only public tags will be returned (Page: Close chat modal tag selection for chats not associated with a department being closed by agent)
// -> & department is specified
// -> only tags associated with the department will be returned (Page: Close chat modal tag selection for chats associated with a department being closed by agent)
export async function findTags({
userId,
text,
department,
viewAll,
pagination: { offset, count, sort },
}: FindTagsParams): Promise<FindTagsResult> {
let filteredDepartmentIds: string[] = [];
if (!(await hasPermissionAsync(userId, 'manage-livechat-tags'))) {
if (viewAll) {
viewAll = false;
}
// Get a list of all departments this user has access to and only
// return tags that are associated with those departments
filteredDepartmentIds = await getDepartmentsWhichUserCanAccess(userId);
helperLogger.debug({
msg: 'User does not have permission to manage livechat tags. Filtering tags by departments user has access to.',
userId,
accessibleDepartmentsLength: filteredDepartmentIds.length,
top5AccessibleDepartments: filteredDepartmentIds.slice(0, 5),
});
if (department && !filteredDepartmentIds.includes(department)) {
department = undefined;
}
}
if (department) {
// In certain cases, the user would only want to see tags for a specific department
// EG: When closing the chat which is associated with a specific department
// user get's to choose which tag to use to close the chat
// (only tags associated with the department should be shown)
filteredDepartmentIds = [department];
}
const query: {
$and?: Filter<ILivechatTag>[];
} = {
$and: [
...(text ? [{ $or: [{ name: new RegExp(escapeRegExp(text), 'i') }, { description: new RegExp(escapeRegExp(text), 'i') }] }] : []),
...(!viewAll
? [
{
$or: [
{ departments: { $size: 0 } },
...(filteredDepartmentIds.length ? [{ departments: { $in: filteredDepartmentIds } }] : []),
],
},
]
: []),
],
};
if (!query?.$and?.length) {
delete query.$and;
}
const { cursor, totalCount } = LivechatTag.findPaginated(query, {
sort: sort || { name: 1 },
skip: offset,
limit: count,
});
const [tags, total] = await Promise.all([cursor.toArray(), totalCount]);
return {
tags,
count: tags.length,
offset,
total,
};
}
export async function findTagById({ tagId }: FindTagsByIdParams): Promise<FindTagsByIdResult> {
return LivechatTag.findOneById(tagId);
}

View File

@@ -0,0 +1,57 @@
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
export async function callTriggerExternalService({
url,
timeout,
fallbackMessage,
body,
headers,
}: {
url: string;
timeout: number;
fallbackMessage: string;
body: Record<string, any>;
headers: Record<string, string>;
}) {
try {
const response = await fetch(url, {
timeout: timeout || 1000,
body,
headers,
method: 'POST',
// SECURITY: Integrations can only be configured by users with enough privileges. It's ok to disable this check here.
ignoreSsrfValidation: true,
});
if (!response.ok || response.status !== 200) {
const text = await response.text();
throw new Error(text);
}
const data = await response.json();
const { contents } = data;
if (
!Array.isArray(contents) ||
!contents.length ||
!contents.every(({ msg, order }) => typeof msg === 'string' && typeof order === 'number')
) {
throw new Error('External service response does not match expected format');
}
return {
response: {
statusCode: response.status,
contents: data?.contents || [],
},
};
} catch (error: any) {
const isTimeout = error.message === 'The user aborted a request.';
return {
error: isTimeout ? 'error-timeout' : 'error-invalid-external-service-response',
response: error.message,
fallbackMessage,
};
}
}

View File

@@ -0,0 +1,92 @@
import type { IOmnichannelBusinessUnit, ILivechatUnitMonitor } from '@rocket.chat/core-typings';
import { LivechatUnitMonitors, LivechatUnit } from '@rocket.chat/models';
import { getUnitsFromUser } from '@rocket.chat/omni-core-ee';
import { escapeRegExp } from '@rocket.chat/tools';
import type { FindOptions } from 'mongodb';
export async function findUnitsOfUser({
text,
userId,
pagination: { offset, count, sort },
}: {
text?: string;
userId: string;
pagination: {
offset: number;
count: number;
sort: FindOptions<IOmnichannelBusinessUnit>['sort'];
};
}): Promise<{
units: IOmnichannelBusinessUnit[];
count: number;
offset: number;
total: number;
}> {
const filter = text && new RegExp(escapeRegExp(text), 'i');
// TODO: an aggregation woudl be faster? or nah?
const myUnits = await LivechatUnitMonitors.findByMonitorId(userId)
.map((u) => u.unitId)
.toArray();
const query = { _id: { $in: myUnits }, ...(text && { $or: [{ name: filter }] }) };
const { cursor, totalCount } = LivechatUnit.findPaginatedUnits(query, {
sort: sort || { name: 1 },
skip: offset,
limit: count,
});
const [units, total] = await Promise.all([cursor.toArray(), totalCount]);
return {
units,
count: units.length,
offset,
total,
};
}
export async function findUnits({
text,
pagination: { offset, count, sort },
}: {
text?: string;
pagination: {
offset: number;
count: number;
sort: FindOptions<IOmnichannelBusinessUnit>['sort'];
};
}): Promise<{
units: IOmnichannelBusinessUnit[];
count: number;
offset: number;
total: number;
}> {
const filter = text && new RegExp(escapeRegExp(text), 'i');
const query = { ...(text && { $or: [{ name: filter }] }) };
const { cursor, totalCount } = LivechatUnit.findPaginatedUnits(query, {
sort: sort || { name: 1 },
skip: offset,
limit: count,
});
const [units, total] = await Promise.all([cursor.toArray(), totalCount]);
return {
units,
count: units.length,
offset,
total,
};
}
export async function findUnitMonitors({ unitId }: { unitId: string }): Promise<ILivechatUnitMonitor[]> {
return LivechatUnitMonitors.find({ unitId }).toArray();
}
export async function findUnitById({ unitId, userId }: { unitId: string; userId: string }): Promise<IOmnichannelBusinessUnit | null> {
const unitsFromUser = await getUnitsFromUser(userId);
return LivechatUnit.findOneById(unitId, {}, { unitsFromUser });
}

View File

@@ -0,0 +1,128 @@
import type { ILivechatMonitor } from '@rocket.chat/core-typings';
import {
isPOSTLivechatMonitorCreateRequest,
isPOSTLivechatMonitorsDeleteRequest,
POSTLivechatMonitorsCreateSuccessResponse,
POSTLivechatMonitorsDeleteSuccessResponse,
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import { Meteor } from 'meteor/meteor';
import { findMonitors, findMonitorByUsername } from './lib/monitors';
import { API } from '../../../../../server/api';
import type { ExtractRoutesFromAPI } from '../../../../../server/api/ApiClass';
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
import { LivechatEnterprise } from '../../../lib/omnichannel/LivechatEnterprise';
API.v1.addRoute(
'livechat/monitors',
{
authRequired: true,
permissionsRequired: ['manage-livechat-monitors'],
license: ['livechat-enterprise'],
},
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const { text } = this.queryParams;
return API.v1.success(
await findMonitors({
text,
pagination: {
offset,
count,
sort,
},
}),
);
},
},
);
API.v1.addRoute(
'livechat/monitors/:username',
{
authRequired: true,
permissionsRequired: ['manage-livechat-monitors'],
license: ['livechat-enterprise'],
},
{
async get() {
const { username } = this.urlParams;
return API.v1.success(
(await findMonitorByUsername({
username,
})) as unknown as ILivechatMonitor,
);
},
},
);
const livechatMonitorsEndpoints = API.v1
.post(
'livechat/monitors.create',
{
response: {
200: POSTLivechatMonitorsCreateSuccessResponse,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
authRequired: true,
permissionsRequired: ['manage-livechat-monitors'],
license: ['livechat-enterprise'],
body: isPOSTLivechatMonitorCreateRequest,
},
async function action() {
const { username } = this.bodyParams;
const result = await LivechatEnterprise.addMonitor(username);
return API.v1.success(result);
},
)
.post(
'livechat/monitors.delete',
{
response: {
200: POSTLivechatMonitorsDeleteSuccessResponse,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
authRequired: true,
permissionsRequired: ['manage-livechat-monitors'],
license: ['livechat-enterprise'],
body: isPOSTLivechatMonitorsDeleteRequest,
},
async function action() {
const { username } = this.bodyParams;
try {
const result = await LivechatEnterprise.removeMonitor(username);
if (!result) {
return API.v1.failure('error-removing-monitor');
}
return API.v1.success();
} catch (error: unknown) {
if (error instanceof Meteor.Error) {
return API.v1.failure(error.reason);
}
return API.v1.failure('error-removing-monitor');
}
},
);
type LivechatMonitorsEndpoints = ExtractRoutesFromAPI<typeof livechatMonitorsEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends LivechatMonitorsEndpoints {}
}

View File

@@ -0,0 +1,91 @@
import { canSendOutboundMessage } from '@rocket.chat/omni-core-ee';
import {
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import { outboundMessageProvider } from './lib/outbound';
import { API } from '../../../../../server/api';
import type { ExtractRoutesFromAPI } from '../../../../../server/api/ApiClass';
import {
GETOutboundProvidersResponseSchema,
GETOutboundProviderParamsSchema,
GETOutboundProviderBadRequestErrorSchema,
GETOutboundProviderMetadataSchema,
POSTOutboundMessageParams,
POSTOutboundMessageSuccessSchema,
} from '../../../lib/omnichannel/outboundcomms/rest';
const outboundCommsEndpoints = API.v1
.get(
'omnichannel/outbound/providers',
{
response: {
200: GETOutboundProvidersResponseSchema,
400: GETOutboundProviderBadRequestErrorSchema,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
query: GETOutboundProviderParamsSchema,
permissionsRequired: ['outbound.send-messages'],
authRequired: true,
license: ['outbound-messaging'],
},
async function action() {
const { type } = this.queryParams;
const providers = outboundMessageProvider.listOutboundProviders(type);
return API.v1.success({
providers,
});
},
)
.get(
'omnichannel/outbound/providers/:id/metadata',
{
response: {
200: GETOutboundProviderMetadataSchema,
400: GETOutboundProviderBadRequestErrorSchema,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
permissionsRequired: ['outbound.send-messages'],
authRequired: true,
license: ['outbound-messaging'],
},
async function action() {
const { id } = this.urlParams;
const providerMetadata = await outboundMessageProvider.getProviderMetadata(id);
return API.v1.success({
metadata: providerMetadata,
});
},
)
.post(
'omnichannel/outbound/providers/:id/message',
{
response: {
200: POSTOutboundMessageSuccessSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
authRequired: true,
permissionsRequired: ['outbound.send-messages'],
body: POSTOutboundMessageParams,
license: ['outbound-messaging'],
},
async function action() {
const { id } = this.urlParams;
const { departmentId, agentId } = this.bodyParams;
await canSendOutboundMessage(this.userId, agentId, departmentId);
await outboundMessageProvider.sendMessage(id, this.bodyParams);
return API.v1.success();
},
);
export type OutboundCommsEndpoints = ExtractRoutesFromAPI<typeof outboundCommsEndpoints>;

View File

@@ -0,0 +1,99 @@
import { LivechatPriority } from '@rocket.chat/models';
import { isGETLivechatPrioritiesParams, isPUTLivechatPriority } from '@rocket.chat/rest-typings';
import { findPriority, updatePriority } from './lib/priorities';
import { API } from '../../../../../server/api';
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
import { notifyOnLivechatPriorityChanged } from '../../../../../server/lib/notifyListener';
API.v1.addRoute(
'livechat/priorities',
{
authRequired: true,
validateParams: isGETLivechatPrioritiesParams,
permissionsRequired: { GET: { permissions: ['manage-livechat-priorities', 'view-l-room'], operation: 'hasAny' } },
license: ['livechat-enterprise'],
},
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const { text } = this.queryParams;
return API.v1.success(
await findPriority({
text,
pagination: {
offset,
count,
sort,
},
}),
);
},
},
);
API.v1.addRoute(
'livechat/priorities/:priorityId',
{
authRequired: true,
permissionsRequired: {
GET: { permissions: ['manage-livechat-priorities', 'view-l-room'], operation: 'hasAny' },
PUT: { permissions: ['manage-livechat-priorities'], operation: 'hasAny' },
},
validateParams: { PUT: isPUTLivechatPriority },
license: ['livechat-enterprise'],
},
{
async get() {
const { priorityId } = this.urlParams;
const priority = await LivechatPriority.findOneById(priorityId);
if (!priority) {
return API.v1.notFound(`Priority with id ${priorityId} not found`);
}
return API.v1.success(priority);
},
async put() {
const { priorityId } = this.urlParams;
await updatePriority(priorityId, this.bodyParams);
void notifyOnLivechatPriorityChanged({ _id: priorityId, ...this.bodyParams });
return API.v1.success();
},
},
);
API.v1.addRoute(
'livechat/priorities.reset',
{
authRequired: true,
permissionsRequired: {
POST: { permissions: ['manage-livechat-priorities'], operation: 'hasAny' },
GET: { permissions: ['manage-livechat-priorities'], operation: 'hasAny' },
},
license: ['livechat-enterprise'],
},
{
async post() {
if (!(await LivechatPriority.canResetPriorities())) {
return API.v1.failure();
}
const eligiblePriorities = (await LivechatPriority.findByDirty().toArray()).map(({ _id }) => _id);
await LivechatPriority.resetPriorities(eligiblePriorities);
eligiblePriorities.forEach((_id) => notifyOnLivechatPriorityChanged({ _id, name: undefined }));
return API.v1.success();
},
async get() {
return API.v1.success({ reset: await LivechatPriority.canResetPriorities() });
},
},
);

View File

@@ -0,0 +1,155 @@
import { isGETDashboardConversationsByType } from '@rocket.chat/rest-typings';
import type { Moment } from 'moment';
import moment from 'moment';
import {
findAllConversationsBySourceCached,
findAllConversationsByStatusCached,
findAllConversationsByDepartmentCached,
findAllConversationsByTagsCached,
findAllConversationsByAgentsCached,
} from './lib/dashboards';
import { API } from '../../../../../server/api';
import { restrictQuery } from '../../../lib/omnichannel/restrictQuery';
const checkDates = (start: Moment, end: Moment) => {
if (!start.isValid()) {
throw new Error('The "start" query parameter must be a valid date.');
}
if (!end.isValid()) {
throw new Error('The "end" query parameter must be a valid date.');
}
// Check dates are no more than 1 year apart using moment
// 1.01 === "we allow to pass year by some hours/days"
if (moment(end).startOf('day').diff(moment(start).startOf('day'), 'year', true) > 1.01) {
throw new Error('The "start" and "end" query parameters must be less than 1 year apart.');
}
if (start.isAfter(end)) {
throw new Error('The "start" query parameter must be before the "end" query parameter.');
}
};
API.v1.addRoute(
'livechat/analytics/dashboards/conversations-by-source',
{
authRequired: true,
permissionsRequired: ['view-livechat-reports'],
validateParams: isGETDashboardConversationsByType,
license: ['livechat-enterprise'],
},
{
async get() {
const { start, end } = this.queryParams;
const startDate = moment(start);
const endDate = moment(end);
checkDates(startDate, endDate);
const extraQuery = await restrictQuery({ userId: this.userId });
const result = await findAllConversationsBySourceCached({ start: startDate.toDate(), end: endDate.toDate(), extraQuery });
return API.v1.success(result);
},
},
);
API.v1.addRoute(
'livechat/analytics/dashboards/conversations-by-status',
{
authRequired: true,
permissionsRequired: ['view-livechat-reports'],
validateParams: isGETDashboardConversationsByType,
license: ['livechat-enterprise'],
},
{
async get() {
const { start, end } = this.queryParams;
const startDate = moment(start);
const endDate = moment(end);
checkDates(startDate, endDate);
const extraQuery = await restrictQuery({ userId: this.userId });
const result = await findAllConversationsByStatusCached({ start: startDate.toDate(), end: endDate.toDate(), extraQuery });
return API.v1.success(result);
},
},
);
API.v1.addRoute(
'livechat/analytics/dashboards/conversations-by-department',
{
authRequired: true,
permissionsRequired: ['view-livechat-reports'],
validateParams: isGETDashboardConversationsByType,
license: ['livechat-enterprise'],
},
{
async get() {
const { start, end } = this.queryParams;
const { sort } = await this.parseJsonQuery();
const startDate = moment(start);
const endDate = moment(end);
checkDates(startDate, endDate);
const extraQuery = await restrictQuery({ userId: this.userId });
const result = await findAllConversationsByDepartmentCached({ start: startDate.toDate(), end: endDate.toDate(), sort, extraQuery });
return API.v1.success(result);
},
},
);
API.v1.addRoute(
'livechat/analytics/dashboards/conversations-by-tags',
{
authRequired: true,
permissionsRequired: ['view-livechat-reports'],
validateParams: isGETDashboardConversationsByType,
license: ['livechat-enterprise'],
},
{
async get() {
const { start, end } = this.queryParams;
const { sort } = await this.parseJsonQuery();
const startDate = moment(start);
const endDate = moment(end);
checkDates(startDate, endDate);
const extraQuery = await restrictQuery({ userId: this.userId });
const result = await findAllConversationsByTagsCached({ start: startDate.toDate(), end: endDate.toDate(), sort, extraQuery });
return API.v1.success(result);
},
},
);
API.v1.addRoute(
'livechat/analytics/dashboards/conversations-by-agent',
{
authRequired: true,
permissionsRequired: ['view-livechat-reports'],
validateParams: isGETDashboardConversationsByType,
license: ['livechat-enterprise'],
},
{
async get() {
const { start, end } = this.queryParams;
const { sort } = await this.parseJsonQuery();
const startDate = moment(start);
const endDate = moment(end);
checkDates(startDate, endDate);
const extraQuery = await restrictQuery({ userId: this.userId });
const result = await findAllConversationsByAgentsCached({ start: startDate.toDate(), end: endDate.toDate(), sort, extraQuery });
return API.v1.success(result);
},
},
);

View File

@@ -0,0 +1,139 @@
import { OmnichannelEEService } from '@rocket.chat/core-services';
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms, Subscriptions } from '@rocket.chat/models';
import { isLivechatRoomOnHoldProps, isLivechatRoomResumeOnHoldProps, isPOSTLivechatRoomPriorityParams } from '@rocket.chat/rest-typings';
import { removePriorityFromRoom, updateRoomPriority } from './lib/priorities';
import { API } from '../../../../../server/api';
import { hasPermissionAsync } from '../../../../../server/lib/authorization/hasPermission';
import { i18n } from '../../../../../server/lib/i18n';
API.v1.addRoute(
'livechat/room.onHold',
{
authRequired: true,
permissionsRequired: ['on-hold-livechat-room'],
validateParams: isLivechatRoomOnHoldProps,
license: ['livechat-enterprise'],
},
{
async post() {
const { roomId } = this.bodyParams;
type Room = Pick<IOmnichannelRoom, '_id' | 't' | 'open' | 'onHold' | 'u' | 'lastMessage' | 'servedBy'>;
const room = await LivechatRooms.findOneById<Room>(roomId, {
projection: { _id: 1, t: 1, open: 1, onHold: 1, u: 1, lastMessage: 1, servedBy: 1 },
});
if (!room) {
throw new Error('error-invalid-room');
}
const subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId, { projection: { _id: 1 } });
if (!subscription && !(await hasPermissionAsync(this.user, 'on-hold-others-livechat-room'))) {
throw new Error('Not_authorized');
}
const onHoldBy = { _id: this.userId, username: this.user.username, name: this.user.name };
const comment = i18n.t('Omnichannel_On_Hold_manually', {
user: onHoldBy.name || `@${onHoldBy.username}`,
});
await OmnichannelEEService.placeRoomOnHold(room, comment, this.user);
return API.v1.success();
},
},
);
API.v1.addRoute(
'livechat/room.resumeOnHold',
{
authRequired: true,
permissionsRequired: ['view-l-room'],
validateParams: isLivechatRoomResumeOnHoldProps,
license: ['livechat-enterprise'],
},
{
async post() {
const { roomId } = this.bodyParams;
if (!roomId || roomId.trim() === '') {
throw new Error('invalid-param');
}
type Room = Pick<IOmnichannelRoom, '_id' | 't' | 'open' | 'onHold' | 'servedBy' | 'u' | 'lastMessage'>;
const room = await LivechatRooms.findOneById<Room>(roomId, {
projection: { t: 1, open: 1, onHold: 1, servedBy: 1 },
});
if (!room) {
throw new Error('error-invalid-room');
}
const subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId, { projection: { _id: 1 } });
if (!subscription && !(await hasPermissionAsync(this.user, 'on-hold-others-livechat-room'))) {
throw new Error('Not_authorized');
}
const { name, username, _id: userId } = this.user;
const onHoldBy = { _id: userId, username, name };
const comment = i18n.t('Omnichannel_on_hold_chat_resumed_manually', {
user: onHoldBy.name || `@${onHoldBy.username}`,
});
await OmnichannelEEService.resumeRoomOnHold(room, comment, this.user, true);
return API.v1.success();
},
},
);
API.v1.addRoute(
'livechat/room/:rid/priority',
{
authRequired: true,
validateParams: { POST: isPOSTLivechatRoomPriorityParams },
permissionsRequired: {
POST: { permissions: ['view-l-room'], operation: 'hasAny' },
DELETE: { permissions: ['view-l-room'], operation: 'hasAny' },
},
license: ['livechat-enterprise'],
},
{
async post() {
const { rid } = this.urlParams;
const { priorityId } = this.bodyParams;
if (!this.user.username) {
return API.v1.failure('Invalid user');
}
await updateRoomPriority(
rid,
{
_id: this.user._id,
name: this.user.name || '',
username: this.user.username,
},
priorityId,
);
return API.v1.success();
},
async delete() {
const { rid } = this.urlParams;
if (!this.user.username) {
return API.v1.failure('Invalid user');
}
await removePriorityFromRoom(rid, {
_id: this.user._id,
name: this.user.name || '',
username: this.user.username,
});
return API.v1.success();
},
},
);

View File

@@ -0,0 +1,107 @@
import { OmnichannelServiceLevelAgreements } from '@rocket.chat/models';
import { isLivechatPrioritiesProps, isCreateOrUpdateLivechatSlaProps } from '@rocket.chat/rest-typings';
import { findSLA } from './lib/sla';
import { API } from '../../../../../server/api';
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
import { LivechatEnterprise } from '../../../lib/omnichannel/LivechatEnterprise';
API.v1.addRoute(
'livechat/sla',
{
authRequired: true,
permissionsRequired: {
GET: { permissions: ['manage-livechat-sla', 'view-l-room'], operation: 'hasAny' },
POST: { permissions: ['manage-livechat-sla'], operation: 'hasAny' },
},
validateParams: {
GET: isLivechatPrioritiesProps,
POST: isCreateOrUpdateLivechatSlaProps,
},
license: ['livechat-enterprise'],
},
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const { text } = this.queryParams;
return API.v1.success(
await findSLA({
text,
pagination: {
offset,
count,
sort,
},
}),
);
},
async post() {
const { name, description, dueTimeInMinutes } = this.bodyParams;
const newSla = await LivechatEnterprise.saveSLA(
null,
{
name,
description,
dueTimeInMinutes,
},
this.userId,
);
return API.v1.success({ sla: newSla });
},
},
);
API.v1.addRoute(
'livechat/sla/:slaId',
{
authRequired: true,
permissionsRequired: {
GET: { permissions: ['manage-livechat-sla', 'view-l-room'], operation: 'hasAny' },
DELETE: { permissions: ['manage-livechat-sla'], operation: 'hasAny' },
PUT: { permissions: ['manage-livechat-sla'], operation: 'hasAny' },
},
validateParams: {
PUT: isCreateOrUpdateLivechatSlaProps,
},
license: ['livechat-enterprise'],
},
{
async get() {
const { slaId } = this.urlParams;
const sla = await OmnichannelServiceLevelAgreements.findOneById(slaId);
if (!sla) {
return API.v1.notFound(`SLA with id ${slaId} not found`);
}
return API.v1.success(sla);
},
async delete() {
const { slaId } = this.urlParams;
await LivechatEnterprise.removeSLA(this.userId, slaId);
return API.v1.success();
},
async put() {
const { name, description, dueTimeInMinutes } = this.bodyParams;
const { slaId } = this.urlParams;
const updatedSla = await LivechatEnterprise.saveSLA(
slaId,
{
name,
description,
dueTimeInMinutes,
},
this.userId,
);
return API.v1.success({ sla: updatedSla });
},
},
);

View File

@@ -0,0 +1,123 @@
import {
isPOSTLivechatTagsSaveParams,
POSTLivechatTagsSaveSuccessResponse,
isPOSTLivechatTagsDeleteParams,
POSTLivechatTagsDeleteSuccessResponse,
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import { findTags, findTagById } from './lib/tags';
import { API } from '../../../../../server/api';
import type { ExtractRoutesFromAPI } from '../../../../../server/api/ApiClass';
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
import { LivechatEnterprise } from '../../../lib/omnichannel/LivechatEnterprise';
API.v1.addRoute(
'livechat/tags',
{
authRequired: true,
permissionsRequired: { GET: { permissions: ['view-l-room', 'manage-livechat-tags'], operation: 'hasAny' } },
license: ['livechat-enterprise'],
},
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const { text, viewAll, department } = this.queryParams;
return API.v1.success(
await findTags({
userId: this.userId,
text,
department,
viewAll: viewAll === 'true',
pagination: {
offset,
count,
sort: typeof sort === 'string' ? JSON.parse(sort || '{}') : sort,
},
}),
);
},
},
);
API.v1.addRoute(
'livechat/tags/:tagId',
{
authRequired: true,
permissionsRequired: { GET: { permissions: ['view-l-room', 'manage-livechat-tags'], operation: 'hasAny' } },
license: ['livechat-enterprise'],
},
{
async get() {
const { tagId } = this.urlParams;
const tag = await findTagById({
userId: this.userId,
tagId,
});
if (!tag) {
return API.v1.notFound('Tag not found');
}
return API.v1.success(tag);
},
},
);
const livechatTagsEndpoints = API.v1
.post(
'livechat/tags.save',
{
response: {
200: POSTLivechatTagsSaveSuccessResponse,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
authRequired: true,
permissions: ['manage-livechat-tags'],
license: ['livechat-enterprise'],
body: isPOSTLivechatTagsSaveParams,
},
async function action() {
const { _id, tagData, tagDepartments } = this.bodyParams;
const result = await LivechatEnterprise.saveTag(_id, tagData, tagDepartments);
return API.v1.success(result);
},
)
.post(
'livechat/tags.delete',
{
response: {
200: POSTLivechatTagsDeleteSuccessResponse,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
authRequired: true,
permissions: ['manage-livechat-tags'],
license: ['livechat-enterprise'],
body: isPOSTLivechatTagsDeleteParams,
},
async function action() {
const { id } = this.bodyParams;
await LivechatEnterprise.removeTag(id);
return API.v1.success();
},
);
type LivechatTagsEndpoints = ExtractRoutesFromAPI<typeof livechatTagsEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends LivechatTagsEndpoints {}
}

View File

@@ -0,0 +1,37 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
import { API } from '../../../../../server/api';
import { canAccessRoomAsync } from '../../../../../server/lib/authorization/canAccessRoom';
import { requestPdfTranscript } from '../../../lib/omnichannel/requestPdfTranscript';
API.v1.addRoute(
'omnichannel/:rid/request-transcript',
{ authRequired: true, permissionsRequired: ['request-pdf-transcript'], license: ['livechat-enterprise'] },
{
async post() {
const room = await LivechatRooms.findOneById<Pick<IOmnichannelRoom, '_id' | 'open' | 'v' | 't' | 'pdfTranscriptFileId'>>(
this.urlParams.rid,
{
projection: { _id: 1, open: 1, v: 1, t: 1, pdfTranscriptFileId: 1 },
},
);
if (!room) {
throw new Error('error-invalid-room');
}
if (!(await canAccessRoomAsync(room, { _id: this.userId }))) {
throw new Error('error-not-allowed');
}
// Flow is as follows:
// 1. On Test Mode, call Transcript.workOnPdf directly
// 2. On Normal Mode, call QueueWorker.queueWork to queue the work
// 3. OmnichannelTranscript.workOnPdf will be called by the worker to generate the transcript
// 4. We be happy :)
await requestPdfTranscript(room, this.userId);
return API.v1.success();
},
},
);

View File

@@ -0,0 +1,134 @@
import { isExternalServiceTrigger } from '@rocket.chat/core-typings';
import { LivechatTrigger } from '@rocket.chat/models';
import { isLivechatTriggerWebhookCallParams } from '@rocket.chat/rest-typings';
import { isLivechatTriggerWebhookTestParams } from '@rocket.chat/rest-typings/src/v1/omnichannel';
import { callTriggerExternalService } from './lib/triggers';
import { API } from '../../../../../server/api';
import { settings } from '../../../../../server/settings';
API.v1.addRoute(
'livechat/triggers/external-service/test',
{
authRequired: true,
permissionsRequired: ['view-livechat-manager'],
validateParams: isLivechatTriggerWebhookTestParams,
rateLimiterOptions: { numRequestsAllowed: 15, intervalTimeInMS: 60000 },
license: ['livechat-enterprise'],
},
{
async post() {
const { webhookUrl, timeout, fallbackMessage, extraData: clientParams } = this.bodyParams;
const token = settings.get<string>('Livechat_secret_token');
if (!token) {
throw new Error('Livechat secret token is not configured');
}
const body = {
metadata: clientParams,
visitorToken: '1234567890',
};
const headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-RocketChat-Livechat-Token': token,
};
const response = await callTriggerExternalService({
url: webhookUrl,
timeout,
fallbackMessage,
body,
headers,
});
if (response.error) {
return API.v1.failure({
triggerId: 'test-trigger',
...response,
});
}
return API.v1.success({
triggerId: 'test-trigger',
...response,
});
},
},
);
API.v1.addRoute(
'livechat/triggers/:_id/external-service/call',
{
authRequired: false,
rateLimiterOptions: {
numRequestsAllowed: 10,
intervalTimeInMS: 60000,
},
validateParams: isLivechatTriggerWebhookCallParams,
license: ['livechat-enterprise'],
},
{
async post() {
const { _id: triggerId } = this.urlParams;
const { token: visitorToken, extraData } = this.bodyParams;
const trigger = await LivechatTrigger.findOneById(triggerId);
if (!trigger) {
throw new Error('Invalid trigger');
}
if (!trigger?.actions.length || !isExternalServiceTrigger(trigger)) {
throw new Error('Trigger is not configured to use an external service');
}
const { params: { serviceTimeout = 5000, serviceUrl, serviceFallbackMessage = 'trigger-default-fallback-message' } = {} } =
trigger.actions[0];
if (!serviceUrl) {
throw new Error('Invalid service URL');
}
const token = settings.get<string>('Livechat_secret_token');
if (!token) {
throw new Error('Livechat secret token is not configured');
}
const body = {
metadata: extraData,
visitorToken,
};
const headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-RocketChat-Livechat-Token': token,
};
const response = await callTriggerExternalService({
url: serviceUrl,
timeout: serviceTimeout,
fallbackMessage: serviceFallbackMessage,
body,
headers,
});
if (response.error) {
return API.v1.failure({
triggerId,
...response,
});
}
return API.v1.success({
triggerId,
...response,
});
},
},
);

View File

@@ -0,0 +1,153 @@
import type { ILivechatUnitMonitor, IOmnichannelBusinessUnit, OmnichannelBusinessUnitPayload } from '@rocket.chat/core-typings';
import type { PaginatedResult, PaginatedRequest } from '@rocket.chat/rest-typings';
import { findUnits, findUnitById, findUnitMonitors, findUnitsOfUser } from './lib/units';
import { API } from '../../../../../server/api';
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
import { hasPermissionAsync } from '../../../../../server/lib/authorization/hasPermission';
import { findAllDepartmentsAvailable, findAllDepartmentsByUnit } from '../../../lib/omnichannel/Department';
import { LivechatEnterprise } from '../../../lib/omnichannel/LivechatEnterprise';
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Endpoints {
'/v1/livechat/units/:unitId/monitors': {
GET: (params: { unitId: string }) => { monitors: ILivechatUnitMonitor[] };
};
'/v1/livechat/units': {
GET: (params: PaginatedRequest<{ text?: string }>) => PaginatedResult & { units: IOmnichannelBusinessUnit[] };
POST: (params: OmnichannelBusinessUnitPayload) => Omit<IOmnichannelBusinessUnit, '_updatedAt'>;
};
'/v1/livechat/units/:id': {
GET: () => IOmnichannelBusinessUnit;
POST: (params: OmnichannelBusinessUnitPayload) => Omit<IOmnichannelBusinessUnit, '_updatedAt'>;
DELETE: () => number;
};
}
}
API.v1.addRoute(
'livechat/units/:unitId/monitors',
{ authRequired: true, permissionsRequired: ['manage-livechat-monitors'], license: ['livechat-enterprise'] },
{
async get() {
const { unitId } = this.urlParams;
if (!unitId) {
return API.v1.failure('The "unitId" parameter is required');
}
return API.v1.success({
monitors: await findUnitMonitors({
unitId,
}),
});
},
},
);
API.v1.addRoute(
'livechat/units',
{ authRequired: true, permissionsRequired: { POST: ['manage-livechat-units'], GET: [] }, license: ['livechat-enterprise'] },
{
async get() {
const params = this.queryParams;
const { offset, count } = await getPaginationItems(params);
const { sort } = await this.parseJsonQuery();
const { text } = this.queryParams;
if (!(await hasPermissionAsync(this.user, 'manage-livechat-units'))) {
return API.v1.success(await findUnitsOfUser({ text, userId: this.userId, pagination: { offset, count, sort } }));
}
return API.v1.success(
await findUnits({
text,
pagination: {
offset,
count,
sort,
},
}),
);
},
async post() {
const { unitData, unitMonitors, unitDepartments } = this.bodyParams;
return API.v1.success(await LivechatEnterprise.saveUnit(null, unitData, unitMonitors, unitDepartments, this.userId));
},
},
);
API.v1.addRoute(
'livechat/units/:id',
{ authRequired: true, permissionsRequired: ['manage-livechat-units'], license: ['livechat-enterprise'] },
{
async get() {
const { id } = this.urlParams;
const unit = await findUnitById({
unitId: id,
userId: this.userId,
});
return API.v1.success(unit);
},
async post() {
const { unitData, unitMonitors, unitDepartments } = this.bodyParams;
const { id } = this.urlParams;
return API.v1.success(await LivechatEnterprise.saveUnit(id, unitData, unitMonitors, unitDepartments, this.userId));
},
async delete() {
const { id } = this.urlParams;
return API.v1.success((await LivechatEnterprise.removeUnit(id, this.userId)).deletedCount);
},
},
);
API.v1.addRoute(
'livechat/units/:unitId/departments',
{ authRequired: true, permissionsRequired: ['manage-livechat-units'], license: ['livechat-enterprise'] },
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { unitId } = this.urlParams;
const { departments, total } = await findAllDepartmentsByUnit(unitId, offset, count);
return API.v1.success({
departments,
count: departments.length,
offset,
total,
});
},
},
);
API.v1.addRoute(
'livechat/units/:unitId/departments/available',
{ authRequired: true, permissionsRequired: ['manage-livechat-units'], license: ['livechat-enterprise'] },
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { unitId } = this.urlParams;
const { text, onlyMyDepartments } = this.queryParams;
const { departments, total } = await findAllDepartmentsAvailable(
this.userId,
unitId,
offset,
count,
text,
onlyMyDepartments === 'true',
);
return API.v1.success({
departments,
count: departments.length,
offset,
total,
});
},
},
);

View File

@@ -0,0 +1,68 @@
import { cronJobs } from '@rocket.chat/cron';
import { License } from '@rocket.chat/license';
import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch';
import { appRequestNotififyForUsers } from './marketplace/appRequestNotifyUsers';
import { Apps } from './orchestrator';
import { getWorkspaceAccessToken } from '../../../server/lib/cloud';
import { settings } from '../../../server/settings';
const appsNotifyAppRequests = async function _appsNotifyAppRequests() {
try {
if (License.hasOfflineLicense()) {
return;
}
const installedApps = await Apps.installedApps({ enabled: true });
if (!installedApps || installedApps.length === 0) {
return;
}
const workspaceUrl = settings.get<string>('Site_Url');
const token = await getWorkspaceAccessToken();
if (!token) {
Apps.debugLog(`could not load workspace token to send app requests notifications`);
return;
}
const options = {
headers: {
Authorization: `Bearer ${token}`,
},
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
ignoreSsrfValidation: true,
} as ExtendedFetchOptions;
const pendingSentUrl = `v1/app-request/sent/pending`;
const result = await Apps.getMarketplaceClient().fetch(pendingSentUrl, options);
const { data } = await result.json();
const filtered = installedApps.filter((app) => data.indexOf(app.getID()) !== -1);
for await (const app of filtered) {
const appId = app.getID();
const appName = app.getName();
const usersNotified = await appRequestNotififyForUsers(Apps.getMarketplaceClient().getMarketplaceUrl(), workspaceUrl, appId, appName)
.then(async (response) => {
// Mark all app requests as sent
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
await Apps.getMarketplaceClient().fetch(`v1/app-request/markAsSent/${appId}`, { ...options, method: 'POST' });
return response;
})
.catch((err) => {
Apps.debugLog(`could not send app request notifications for app ${appId}. Error: ${err}`);
return err;
});
const errors = (usersNotified as (string | Error)[]).filter((batch) => batch instanceof Error);
if (errors.length > 0) {
Apps.debugLog(`Some batches of users could not be notified for app ${appId}. Errors: ${errors}`);
}
}
} catch (err) {
Apps.debugLog(err);
}
};
await cronJobs.add('Apps-Request-End-Users:notify', '0 */12 * * *', async () => appsNotifyAppRequests());

View File

@@ -0,0 +1,15 @@
import { API } from '../../../../../server/api';
import type { AppsRestApi } from '../rest';
export const registerActionButtonsHandler = ({ api, _manager }: AppsRestApi) =>
void api.addRoute(
'actionButtons',
{ authRequired: false },
{
async get() {
const buttons = await _manager.getUIActionButtonManager().getAllActionButtons();
return API.v1.success(buttons);
},
},
);

View File

@@ -0,0 +1,72 @@
import { isAppLogsProps, ajv } from '@rocket.chat/rest-typings';
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
import type { AppsRestApi } from '../rest';
import { makeAppLogsQuery } from './lib/makeAppLogsQuery';
// TODO: this is a very common error response schema. We should find a way to standardize it.
const errorResponse = ajv.compile<{
success: false;
error: string;
}>({
additionalProperties: false,
type: 'object',
properties: {
error: { type: 'string' },
status: { type: 'string', nullable: true },
message: { type: 'string', nullable: true },
success: { type: 'boolean', description: 'Indicates if the request was successful.' },
},
required: ['success', 'error'],
});
export const registerAppGeneralLogsHandler = ({ api, _orch }: AppsRestApi) =>
void api.get(
'logs',
{
authRequired: true,
permissionsRequired: ['manage-apps'],
query: isAppLogsProps,
response: {
200: ajv.compile({
type: 'object',
properties: {
offset: { type: 'number' },
// TODO: reference a schema for the array items
logs: { type: 'array' },
count: { type: 'number' },
total: { type: 'number' },
success: { type: 'boolean' },
},
required: ['offset', 'logs', 'count', 'total', 'success'],
additionalProperties: false,
}),
400: errorResponse,
401: errorResponse,
403: errorResponse,
404: errorResponse,
},
},
async function action() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const options = {
sort: sort || { _updatedAt: -1 },
skip: offset,
limit: count,
};
let query: Record<string, any>;
try {
query = makeAppLogsQuery(this.queryParams);
} catch (error) {
return api.failure({ error: error instanceof Error ? error.message : 'Unknown error' });
}
const result = await _orch.getLogStorage().findPaginated(query, options);
return api.success({ offset, logs: result.logs, count: result.logs.length, total: result.total });
},
);

View File

@@ -0,0 +1,65 @@
import { ajv } from '@rocket.chat/rest-typings';
import type { AppsRestApi } from '../rest';
// This might be a good candidate for a default validator function exported by @rocket.chat/rest-typings
const errorResponse = ajv.compile<{
success: false;
error: string;
}>({
additionalProperties: false,
type: 'object',
properties: {
error: {
type: 'string',
},
status: {
type: 'string',
nullable: true,
},
message: {
type: 'string',
nullable: true,
},
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
},
required: ['success', 'error'],
});
export const registerAppLogsDistinctInstanceHandler = ({ api, _orch }: AppsRestApi) =>
void api.get(
':id/logs/distinctValues',
{
authRequired: true,
permissionsRequired: ['manage-apps'],
response: {
200: ajv.compile({
type: 'object',
properties: {
instanceIds: { type: 'array', items: { type: 'string' } },
methods: { type: 'array', items: { type: 'string' } },
success: { type: 'boolean' },
},
required: ['instanceIds', 'methods', 'success'],
additionalProperties: false,
}),
401: errorResponse,
403: errorResponse,
404: errorResponse,
},
},
async function action() {
const app = await _orch.getManager()?.getOneById(this.urlParams.id);
if (!app) {
return api.notFound(`No app found with id: ${this.urlParams.id}`);
}
const result = await _orch.getLogStorage().distinctValues(this.urlParams.id);
return api.success(result);
},
);

View File

@@ -0,0 +1,134 @@
import type { IUser } from '@rocket.chat/core-typings';
import { isAppLogsExportProps } from '@rocket.chat/rest-typings';
import { ajv } from '@rocket.chat/rest-typings/src/v1/Ajv';
import { parse } from 'cookie';
import { json2csv } from 'json-2-csv';
import type { AppsRestApi } from '../rest';
import { makeAppLogsQuery } from './lib/makeAppLogsQuery';
import { APIClass } from '../../../../../server/api/ApiClass';
import type { APIActionContext } from '../../../../../server/api/router';
const isErrorResponse = ajv.compile<{
success: false;
error: string;
}>({
type: 'object',
properties: {
success: {
type: 'boolean',
enum: [false],
},
error: {
type: 'string',
},
},
});
class ExportHandlerAPI extends APIClass {
public override async authenticatedRoute(routeContext: APIActionContext): Promise<IUser | null> {
const { rc_uid, rc_token } = parse(routeContext.request.headers.get('cookie') || '');
if (rc_uid) {
routeContext.request.headers.set('x-user-id', rc_uid);
}
if (rc_token) {
routeContext.request.headers.set('x-auth-token', rc_token);
}
return super.authenticatedRoute(routeContext);
}
}
const adhocApi = new ExportHandlerAPI({
useDefaultAuth: false,
prettyJson: process.env.NODE_ENV !== 'development',
});
export const registerAppLogsExportHandler = ({ api, _manager, _orch }: AppsRestApi) => {
adhocApi.get(
':id/export-logs',
{
authRequired: true,
permissionsRequired: ['manage-apps'],
query: isAppLogsExportProps,
response: {
200: ajv.compile({
type: 'object',
properties: {
body: {
type: 'string',
format: 'binary',
description: 'The content of the exported logs file, either in JSON or CSV format.',
},
},
}),
400: isErrorResponse,
401: isErrorResponse,
404: isErrorResponse,
},
},
async function () {
const proxiedApp = _manager.getOneById(this.urlParams.id);
if (!proxiedApp) {
return api.notFound(`No App found by the id of: ${this.urlParams.id}`);
}
let count = 100;
if (this.queryParams.count !== undefined && this.queryParams.count !== null) {
count = parseInt(String(this.queryParams.count || 100));
}
const { sort } = await this.parseJsonQuery();
const options = {
sort: sort || { _updatedAt: -1 },
skip: 0,
limit: Math.min(count, 2000),
};
let query: ReturnType<typeof makeAppLogsQuery>;
try {
query = makeAppLogsQuery({ appId: this.urlParams.id, ...this.queryParams });
} catch (error) {
return api.failure({ error: error instanceof Error ? error.message : 'Unknown error' });
}
const result = await _orch.getLogStorage().find(query, options);
if (!result.length) {
return api.failure({ error: 'No logs found for the specified criteria' });
}
let fileContent: Buffer;
let filename: string;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
if (this.queryParams.type === 'json') {
fileContent = Buffer.from(JSON.stringify(result, null, 2), 'utf8');
filename = `app-logs-${this.urlParams.id}-${timestamp}.json`;
} else {
fileContent = Buffer.from(json2csv(result, { expandArrayObjects: true }), 'utf8');
filename = `app-logs-${this.urlParams.id}-${timestamp}.csv`;
}
return {
body: fileContent,
statusCode: 200,
headers: {
// 'application/json' here creates problems down the line with the router
'Content-Type': 'text/plain',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': fileContent.length.toString(),
},
};
},
);
api.router.use(adhocApi.router);
};

View File

@@ -0,0 +1,80 @@
import { isAppLogsProps, ajv } from '@rocket.chat/rest-typings';
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
import type { AppsRestApi } from '../rest';
import { makeAppLogsQuery } from './lib/makeAppLogsQuery';
const errorResponse = ajv.compile<{
success: false;
error: string;
}>({
additionalProperties: false,
type: 'object',
properties: {
error: { type: 'string' },
status: { type: 'string', nullable: true },
message: { type: 'string', nullable: true },
success: { type: 'boolean', description: 'Indicates if the request was successful.' },
},
required: ['success', 'error'],
});
export const registerAppLogsHandler = ({ api, _manager, _orch }: AppsRestApi) =>
void api.get(
':id/logs',
{
authRequired: true,
permissionsRequired: ['manage-apps'],
query: isAppLogsProps,
response: {
200: ajv.compile({
type: 'object',
properties: {
offset: { type: 'number' },
logs: { type: 'array' },
count: { type: 'number' },
total: { type: 'number' },
success: { type: 'boolean' },
},
required: ['offset', 'logs', 'count', 'total', 'success'],
additionalProperties: false,
}),
400: errorResponse,
401: errorResponse,
403: errorResponse,
404: errorResponse,
},
},
async function action() {
const proxiedApp = _manager.getOneById(this.urlParams.id);
if (!proxiedApp) {
return api.notFound(`No App found by the id of: ${this.urlParams.id}`);
}
if (this.queryParams.appId && this.queryParams.appId !== this.urlParams.id) {
return api.notFound(`Invalid query parameter "appId": ${this.queryParams.appId}`);
}
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const options = {
sort: sort || { _updatedAt: -1 },
skip: offset,
limit: count,
};
let query: Record<string, any>;
try {
query = makeAppLogsQuery({ appId: this.urlParams.id, ...this.queryParams });
} catch (error) {
return api.failure({ error: error instanceof Error ? error.message : 'Unknown error' });
}
const result = await _orch.getLogStorage().findPaginated(query, options);
return api.success({ offset, logs: result.logs, count: result.logs.length, total: result.total });
},
);

View File

@@ -0,0 +1,28 @@
import type { AppManager } from '@rocket.chat/apps/dist/server/AppManager';
import { License } from '@rocket.chat/license';
import { getInstallationSourceFromAppStorageItem } from '../../../../../lib/apps/getInstallationSourceFromAppStorageItem';
import { API } from '../../../../../server/api';
import type { AppsRestApi } from '../rest';
export const registerAppsCountHandler = ({ api, _manager }: AppsRestApi) =>
void api.addRoute(
'count',
{ authRequired: false },
{
async get() {
const manager = _manager as AppManager;
const apps = await manager.get({ enabled: true });
const { maxMarketplaceApps, maxPrivateApps } = License.getAppsConfig();
return API.v1.success({
totalMarketplaceEnabled: apps.filter((app) => getInstallationSourceFromAppStorageItem(app.getStorageItem()) === 'marketplace')
.length,
totalPrivateEnabled: apps.filter((app) => getInstallationSourceFromAppStorageItem(app.getStorageItem()) === 'private').length,
maxMarketplaceApps,
maxPrivateApps,
});
},
},
);

View File

@@ -0,0 +1,63 @@
import type { AppLogsProps } from '@rocket.chat/rest-typings';
/**
* Creates a query object for fetching app logs based on provided parameters.
*
* NOTE: This function expects that all values are in the correct format, as it is
* used by an endpoint handler which has query parameter validation.
*
* @param queryParams - The query parameters.
* @returns A query object for fetching app logs.
* @throws {Error} If the date range is invalid.
*/
export function makeAppLogsQuery(queryParams: AppLogsProps) {
const query: Record<string, any> = {};
if (queryParams.appId) {
query.appId = queryParams.appId;
}
if (queryParams.logLevel) {
const queryLogLevel = Number(queryParams.logLevel);
const logLevel = ['error'];
if (queryLogLevel >= 1) {
logLevel.push('warn', 'info', 'log');
}
if (queryLogLevel >= 2) {
logLevel.push('debug', 'success');
}
query['entries.severity'] = { $in: logLevel };
}
if (queryParams.method) {
query.method = queryParams.method;
}
if (queryParams.instanceId) {
query.instanceId = queryParams.instanceId;
}
if (queryParams.startDate) {
query._updatedAt = {
$gte: new Date(queryParams.startDate),
};
}
if (queryParams.endDate) {
const endDate = new Date(queryParams.endDate);
if (query._updatedAt?.$gte && query._updatedAt.$gte >= endDate) {
throw new Error('Invalid date range');
}
query._updatedAt = {
...(query._updatedAt || {}),
$lte: endDate,
};
}
return query;
}

View File

@@ -0,0 +1,12 @@
export enum AppEvents {
APP_ADDED = 'app/added',
APP_REMOVED = 'app/removed',
APP_UPDATED = 'app/updated',
APP_STATUS_CHANGE = 'app/statusUpdate',
APP_SETTING_UPDATED = 'app/settingUpdated',
COMMAND_ADDED = 'command/added',
COMMAND_DISABLED = 'command/disabled',
COMMAND_UPDATED = 'command/updated',
COMMAND_REMOVED = 'command/removed',
ACTIONS_CHANGED = 'actions/changed',
}

View File

@@ -0,0 +1,5 @@
import { AppsRestApi } from './rest';
import { AppUIKitInteractionApi } from './uikit';
import { AppServerNotifier } from './websockets';
export { AppUIKitInteractionApi, AppsRestApi, AppServerNotifier };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,359 @@
import { AppEvents, type IAppServerOrchestrator } from '@rocket.chat/apps';
import { UiKitCoreApp } from '@rocket.chat/core-services';
import type { OperationParams, OperationResult, UrlParams } from '@rocket.chat/rest-typings';
import bodyParser from 'body-parser';
import cors from 'cors';
import type { Request, Response } from 'express';
import express from 'express';
import rateLimit from 'express-rate-limit';
import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
import { authenticationMiddleware } from '../../../../server/api/v1/middlewares/authentication';
import { settings } from '../../../../server/settings';
import { Apps } from '../orchestrator';
const apiServer = express();
apiServer.disable('x-powered-by');
let corsEnabled = false;
let allowListOrigins: string[] = [];
settings.watch('API_Enable_CORS', (value: boolean) => {
corsEnabled = value;
});
settings.watch('API_CORS_Origin', (value: string) => {
allowListOrigins = value
? value
.trim()
.split(',')
.map((origin) => String(origin).trim().toLocaleLowerCase())
: [];
});
WebApp.rawConnectHandlers.use(apiServer);
// eslint-disable-next-line new-cap
const router = express.Router();
const unauthorized = (res: Response): unknown =>
res.status(401).send({
status: 'error',
message: 'You must be logged in to do this.',
});
Meteor.startup(() => {
// use specific rate limit of 600 (which is 60 times the default limits) requests per minute (around 10/second)
const apiLimiter = rateLimit({
windowMs: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'),
max: (settings.get('API_Enable_Rate_Limiter_Limit_Calls_Default') as number) * 60,
skip: () =>
settings.get('API_Enable_Rate_Limiter') !== true ||
(process.env.NODE_ENV === 'development' && settings.get('API_Enable_Rate_Limiter_Dev') !== true),
});
router.use(apiLimiter);
});
router.use(authenticationMiddleware({ rejectUnauthorized: false }));
router.use(async (req: Request, res, next) => {
const { 'x-visitor-token': visitorToken } = req.headers;
if (visitorToken && typeof visitorToken === 'string') {
req.body.visitor = await Apps.getConverters()?.get('visitors').convertByToken(visitorToken);
}
if (!req.user && !req.body.visitor) {
return unauthorized(res);
}
next();
});
const corsOptions: cors.CorsOptions = {
origin: (origin, callback) => {
if (
!origin ||
!corsEnabled ||
allowListOrigins.includes('*') ||
allowListOrigins.includes(origin) ||
origin === settings.get('Site_Url')
) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'), false);
}
},
};
apiServer.use('/api/apps/ui.interaction/', bodyParser.json(), cors(corsOptions), router); // didn't have the rateLimiter option
type UiKitUserInteractionRequest = Request<
UrlParams<'/apps/ui.interaction/:id'>,
OperationResult<'POST', '/apps/ui.interaction/:id'> | { error: unknown },
OperationParams<'POST', '/apps/ui.interaction/:id'> & {
visitor?: {
id: string;
username: string;
name?: string;
department?: string;
updatedAt?: Date;
token: string;
phone?: { phoneNumber: string }[] | null;
visitorEmails?: { address: string }[];
livechatData?: Record<string, unknown>;
status?: 'online' | 'away' | 'offline' | 'busy' | 'disabled';
};
}
>;
router.post('/:id', async (req: UiKitUserInteractionRequest, res, next) => {
const { id: appId } = req.params;
const isCoreApp = await UiKitCoreApp.isRegistered(appId);
if (!isCoreApp) {
return next();
}
try {
const { user } = req;
switch (req.body.type) {
case 'blockAction': {
const { type, actionId, triggerId, payload, container, visitor } = req.body;
const message = 'mid' in req.body ? req.body.mid : undefined;
const room = 'rid' in req.body ? req.body.rid : undefined;
const result = await UiKitCoreApp.blockAction({
appId,
type,
actionId,
triggerId,
container,
message,
payload,
user,
visitor,
room,
});
// Using ?? to always send something in the response, even if the app had no result.
res.send(result ?? {});
return;
}
case 'viewSubmit': {
const { type, actionId, triggerId, payload } = req.body;
const result = await UiKitCoreApp.viewSubmit({
appId,
type,
actionId,
triggerId,
payload,
user,
});
// Using ?? to always send something in the response, even if the app had no result.
res.send(result ?? {});
return;
}
case 'viewClosed': {
const {
type,
payload: { view, isCleared },
triggerId,
} = req.body;
const result = await UiKitCoreApp.viewClosed({
appId,
triggerId,
type,
user,
payload: {
view,
isCleared,
},
});
// Using ?? to always send something in the response, even if the app had no result.
res.send(result ?? {});
return;
}
default:
throw new Error('Type not supported');
}
} catch (e) {
const error = e instanceof Error ? e.message : e;
res.status(500).send({ error });
}
});
export class AppUIKitInteractionApi {
orch: IAppServerOrchestrator;
constructor(orch: IAppServerOrchestrator) {
this.orch = orch;
router.post('/:id', this.routeHandler.bind(this));
}
private async routeHandler(
req: UiKitUserInteractionRequest,
res: Response<OperationResult<'POST', '/apps/ui.interaction/:id'> | { error: unknown }>,
): Promise<void> {
const { orch } = this;
const { id: appId } = req.params;
switch (req.body.type) {
case 'blockAction': {
const { type, actionId, triggerId, payload, container, rid } = req.body;
const mid = 'mid' in req.body ? req.body.mid : undefined;
const { visitor } = req.body;
const user = orch.getConverters()?.get('users').convertToApp(req.user);
const message = mid ? await orch.getConverters()?.get('messages').convertById(mid) : undefined;
const room = rid ? await orch.getConverters()?.get('rooms').convertById(rid) : undefined;
const action = {
type,
container,
appId,
actionId,
message,
triggerId,
payload,
user,
visitor,
room,
};
try {
const eventInterface = !visitor ? AppEvents.IUIKitInteractionHandler : AppEvents.IUIKitLivechatInteractionHandler;
const result = await orch.triggerEvent(eventInterface, action);
res.send(result);
} catch (e) {
const error = e instanceof Error ? e.message : e;
res.status(500).send({ error });
}
break;
}
case 'viewClosed': {
const {
type,
rid,
payload: { view, isCleared },
} = req.body;
const user = orch.getConverters()?.get('users').convertToApp(req.user);
const room = rid ? await orch.getConverters()?.get('rooms').convertById(rid) : undefined;
const action = {
type,
appId,
user,
room,
payload: {
view,
isCleared,
},
};
try {
const result = await orch.triggerEvent(AppEvents.IUIKitInteractionHandler, action);
res.send(result);
} catch (e) {
const error = e instanceof Error ? e.message : e;
res.status(500).send({ error });
}
break;
}
case 'viewSubmit': {
const { type, actionId, triggerId, payload, rid } = req.body;
const user = orch.getConverters()?.get('users').convertToApp(req.user);
const room = rid ? await orch.getConverters()?.get('rooms').convertById(rid) : undefined;
const action = {
type,
appId,
actionId,
triggerId,
payload,
user,
room,
};
try {
const result = await orch.triggerEvent(AppEvents.IUIKitInteractionHandler, action);
res.send(result);
} catch (e) {
const error = e instanceof Error ? e.message : e;
res.status(500).send({ error });
}
break;
}
case 'actionButton': {
const {
type,
actionId,
triggerId,
rid,
mid,
tmid,
payload: { context, message: msgText },
} = req.body;
const user = orch.getConverters()?.get('users').convertToApp(req.user);
const room = rid ? await orch.getConverters()?.get('rooms').convertById(rid) : undefined;
const message = mid ? await orch.getConverters()?.get('messages').convertById(mid) : undefined;
const action = {
type,
appId,
actionId,
triggerId,
user,
room,
message,
tmid,
payload: {
context,
...(msgText ? { message: msgText } : {}),
},
};
try {
const result = await orch.triggerEvent(AppEvents.IUIKitInteractionHandler, action);
res.send(result);
} catch (e) {
const error = e instanceof Error ? e.message : e;
res.status(500).send({ error });
}
break;
}
default: {
res.status(400).send({ error: 'Unknown action' });
}
}
// TODO: validate payloads per type
}
}

View File

@@ -0,0 +1,214 @@
import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
import { AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus';
import type { ISetting as AppsSetting } from '@rocket.chat/apps-engine/definition/settings';
import { api } from '@rocket.chat/core-services';
import { InstanceStatus } from '@rocket.chat/instance-status';
import { AppEvents } from './events';
import { SystemLogger } from '../../../../server/lib/logger/system';
import notifications from '../../../../server/lib/notifications/core/lib/Notifications';
import type { IStreamer } from '../../../../server/modules/streamer/types';
import type { AppServerOrchestrator } from '../orchestrator';
export { AppEvents };
export class AppServerListener {
private orch: AppServerOrchestrator;
engineStreamer: IStreamer<'apps-engine'>;
clientStreamer: IStreamer<'apps'>;
received;
constructor(
orch: AppServerOrchestrator,
engineStreamer: IStreamer<'apps-engine'>,
clientStreamer: IStreamer<'apps'>,
received: Map<any, any>,
) {
this.orch = orch;
this.engineStreamer = engineStreamer;
this.clientStreamer = clientStreamer;
this.received = received;
this.engineStreamer.on(AppEvents.APP_STATUS_CHANGE, this.onAppStatusUpdated.bind(this));
this.engineStreamer.on(AppEvents.APP_REMOVED, this.onAppRemoved.bind(this));
this.engineStreamer.on(AppEvents.APP_UPDATED, this.onAppUpdated.bind(this));
this.engineStreamer.on(AppEvents.APP_ADDED, this.onAppAdded.bind(this));
this.engineStreamer.on(AppEvents.ACTIONS_CHANGED, this.onActionsChanged.bind(this));
this.engineStreamer.on(AppEvents.APP_SETTING_UPDATED, this.onAppSettingUpdated.bind(this));
this.engineStreamer.on(AppEvents.COMMAND_ADDED, this.onCommandAdded.bind(this));
this.engineStreamer.on(AppEvents.COMMAND_DISABLED, this.onCommandDisabled.bind(this));
this.engineStreamer.on(AppEvents.COMMAND_UPDATED, this.onCommandUpdated.bind(this));
this.engineStreamer.on(AppEvents.COMMAND_REMOVED, this.onCommandRemoved.bind(this));
}
async onAppAdded(appId: string): Promise<void> {
await (this.orch.getManager()! as any).addLocal(appId); // TO-DO: fix type
this.clientStreamer.emitWithoutBroadcast(AppEvents.APP_ADDED, appId);
}
async onAppStatusUpdated({ appId, status }: { appId: string; status: AppStatus }): Promise<void> {
const app = this.orch.getManager()?.getOneById(appId);
if (!app || (await app.getStatus()) === status) {
return;
}
this.received.set(`${AppEvents.APP_STATUS_CHANGE}_${appId}`, {
appId,
status,
when: new Date(),
});
if (AppStatusUtils.isEnabled(status)) {
await this.orch.getManager()?.enable(appId).catch(SystemLogger.error);
this.clientStreamer.emitWithoutBroadcast(AppEvents.APP_STATUS_CHANGE, { appId, status });
} else if (AppStatusUtils.isDisabled(status)) {
await this.orch.getManager()?.disable(appId, status, true).catch(SystemLogger.error);
this.clientStreamer.emitWithoutBroadcast(AppEvents.APP_STATUS_CHANGE, { appId, status });
}
}
async onAppSettingUpdated({ appId, setting }: { appId: string; setting: AppsSetting }): Promise<void> {
this.received.set(`${AppEvents.APP_SETTING_UPDATED}_${appId}_${setting.id}`, {
appId,
setting,
when: new Date(),
});
await this.orch
.getManager()!
.getSettingsManager()
.updateAppSetting(appId, setting as any); // TO-DO: fix type of `setting`
this.clientStreamer.emitWithoutBroadcast(AppEvents.APP_SETTING_UPDATED, { appId, setting });
}
async onAppUpdated(appId: string): Promise<void> {
this.received.set(`${AppEvents.APP_UPDATED}_${appId}`, { appId, when: new Date() });
const storageItem = await this.orch.getStorage()!.retrieveOne(appId);
const appPackage = await this.orch.getAppSourceStorage()!.fetch(storageItem);
const isEnabled = AppStatusUtils.isEnabled(storageItem.status);
if (isEnabled) {
await this.orch.getManager()!.updateAndStartupLocal(storageItem, appPackage);
} else {
await this.orch.getManager()!.updateAndInitializeLocal(storageItem, appPackage);
}
this.clientStreamer.emitWithoutBroadcast(AppEvents.APP_UPDATED, appId);
}
async onAppRemoved(appId: string): Promise<void> {
const app = this.orch.getManager()!.getOneById(appId);
if (!app) {
return;
}
await this.orch.getManager()!.removeLocal(appId);
this.clientStreamer.emitWithoutBroadcast(AppEvents.APP_REMOVED, appId);
}
async onCommandAdded(command: string): Promise<void> {
this.clientStreamer.emitWithoutBroadcast(AppEvents.COMMAND_ADDED, command);
}
async onCommandDisabled(command: string): Promise<void> {
this.clientStreamer.emitWithoutBroadcast(AppEvents.COMMAND_DISABLED, command);
}
async onCommandUpdated(command: string): Promise<void> {
this.clientStreamer.emitWithoutBroadcast(AppEvents.COMMAND_UPDATED, command);
}
async onCommandRemoved(command: string): Promise<void> {
this.clientStreamer.emitWithoutBroadcast(AppEvents.COMMAND_REMOVED, command);
}
async onActionsChanged(): Promise<void> {
this.clientStreamer.emitWithoutBroadcast(AppEvents.ACTIONS_CHANGED);
}
}
export class AppServerNotifier {
engineStreamer: IStreamer<'apps-engine'>;
clientStreamer: IStreamer<'apps'>;
received: Map<any, any>;
listener: AppServerListener;
constructor(orch: AppServerOrchestrator) {
this.engineStreamer = notifications.streamAppsEngine;
// This is used to broadcast to the web clients
this.clientStreamer = notifications.streamApps;
this.received = new Map();
this.listener = new AppServerListener(orch, this.engineStreamer, this.clientStreamer, this.received);
}
async appAdded(appId: string): Promise<void> {
void api.broadcast('apps.added', appId);
}
async appRemoved(appId: string): Promise<void> {
void api.broadcast('apps.removed', appId);
}
async appUpdated(appId: string): Promise<void> {
if (this.received.has(`${AppEvents.APP_UPDATED}_${appId}`)) {
this.received.delete(`${AppEvents.APP_UPDATED}_${appId}`);
return;
}
void api.broadcast('apps.updated', appId, InstanceStatus.id());
}
async appStatusUpdated(appId: string, status: AppStatus): Promise<void> {
if (this.received.has(`${AppEvents.APP_STATUS_CHANGE}_${appId}`)) {
const details = this.received.get(`${AppEvents.APP_STATUS_CHANGE}_${appId}`);
if (details.status === status) {
this.received.delete(`${AppEvents.APP_STATUS_CHANGE}_${appId}`);
return;
}
}
void api.broadcast('apps.statusUpdate', appId, status);
}
async appSettingsChange(appId: string, setting: AppsSetting): Promise<void> {
if (this.received.has(`${AppEvents.APP_SETTING_UPDATED}_${appId}_${setting.id}`)) {
this.received.delete(`${AppEvents.APP_SETTING_UPDATED}_${appId}_${setting.id}`);
return;
}
void api.broadcast('apps.settingUpdated', appId, setting);
}
async commandAdded(command: string): Promise<void> {
void api.broadcast('command.added', command);
}
async commandDisabled(command: string): Promise<void> {
void api.broadcast('command.disabled', command);
}
async commandUpdated(command: string): Promise<void> {
void api.broadcast('command.updated', command);
}
async commandRemoved(command: string): Promise<void> {
void api.broadcast('command.removed', command);
}
async actionsChanged(): Promise<void> {
void api.broadcast('actions.changed');
}
}

View File

@@ -0,0 +1,116 @@
import type { ProxiedApp } from '@rocket.chat/apps/dist/server/ProxiedApp';
import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
import { cronJobs } from '@rocket.chat/cron';
import { License } from '@rocket.chat/license';
import { Settings, Users } from '@rocket.chat/models';
import { Apps } from './orchestrator';
import { getWorkspaceAccessToken } from '../../../server/lib/cloud';
import { i18n } from '../../../server/lib/i18n';
import { sendMessagesToAdmins } from '../../../server/lib/sendMessagesToAdmins';
const notifyAdminsAboutInvalidApps = async function _notifyAdminsAboutInvalidApps(apps?: ProxiedApp[]) {
if (!apps) {
return;
}
const hasInvalidApps = !!apps.find((app) => app.getLatestLicenseValidationResult().hasErrors);
if (!hasInvalidApps) {
return;
}
const id = 'someAppInInvalidState';
const title = 'Warning';
const link = '/marketplace/installed';
await sendMessagesToAdmins({
msgs: async ({ adminUser }) => ({
msg: `*${i18n.t(title, { lng: adminUser.language || 'en' })}*\n${i18n.t('Invalid_apps_admin_message', {
lng: adminUser.language || 'en',
marketplace: i18n.t('Marketplace', { lng: adminUser.language || 'en' }),
installed: i18n.t('Installed', { lng: adminUser.language || 'en' }),
})}`,
}),
banners: async ({ adminUser }) => {
await Users.removeBannerById(adminUser._id, id);
return [
{
id,
priority: 10,
title,
text: i18n.t('Invalid_apps_banner_text', { lng: adminUser.language || 'en' }),
modifiers: ['danger'],
link,
},
];
},
});
return apps;
};
const notifyAdminsAboutRenewedApps = async function _notifyAdminsAboutRenewedApps(apps?: ProxiedApp[]) {
if (!apps) {
return;
}
const renewedApps = apps.filter(
async (app) => (await app.getStatus()) === AppStatus.DISABLED && app.getPreviousStatus() === AppStatus.INVALID_LICENSE_DISABLED,
);
if (renewedApps.length === 0) {
return;
}
await sendMessagesToAdmins({
msgs: async ({ adminUser }) => ({
msg: i18n.t('Disabled_apps_admin_message', {
lng: adminUser.language || 'en',
marketplace: i18n.t('Marketplace', { lng: adminUser.language || 'en' }),
installed: i18n.t('Installed', { lng: adminUser.language || 'en' }),
}),
}),
});
};
const appsUpdateMarketplaceInfo = async function _appsUpdateMarketplaceInfo() {
if (License.hasOfflineLicense()) {
return;
}
const token = await getWorkspaceAccessToken();
const workspaceIdSetting = await Settings.getValueById('Cloud_Workspace_Id');
const currentSeats = await Users.getActiveLocalUserCount();
const fullUrl = `v1/workspaces/${workspaceIdSetting}/apps`;
let data = [];
try {
const response = await Apps.getMarketplaceClient().fetch(fullUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
params: {
seats: currentSeats,
},
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
ignoreSsrfValidation: true,
});
const result = await response.json();
if (Array.isArray(result)) {
data = result;
}
} catch (err) {
Apps.debugLog(err);
}
await Apps.updateAppsMarketplaceInfo(data).then(notifyAdminsAboutInvalidApps).then(notifyAdminsAboutRenewedApps);
};
await cronJobs.add('Apps-Engine:check', '0 4 * * *', async () => appsUpdateMarketplaceInfo());

View File

@@ -0,0 +1,4 @@
import './cron';
import './appRequestsCron';
export { Apps } from './orchestrator';

View File

@@ -0,0 +1,42 @@
import fastRedact from 'fast-redact';
const requestFields = [
'headers.Cookie',
'headers.cookie',
'headers["x-auth-token"]',
'headers["X-Auth-Token"]',
'headers.auth',
'headers.Auth',
'headers.authorization',
'headers.Authorization',
'headers.access_token',
'content.password',
'content.pass',
'data.password',
'data.pass',
];
const entityFields = ['password', 'pass', 'customFields.*', '_unmappedProperties_'];
const roomFields = ['customFields.*', '_unmappedProperties_', ...entityFields.map((field) => `creator.${field}`)];
export const redactionFieldPaths = [
// Incoming requests to the Apps API endpoints
...requestFields,
...entityFields.map((field) => `user.${field}`),
'query.access_token',
'query.query', // The deprecated `query` search param
// Outgoing requests from the Apps to the outter webs
...requestFields.map((field) => `request.${field}`),
`request.query`, // `query` here is a string, so we have to redact it all
// Slashcommands
...roomFields.map((field) => `params[0].room.${field}`),
...entityFields.map((field) => `params[0].sender.${field}`),
];
export const redact = fastRedact({
paths: redactionFieldPaths,
censor: '[Redacted]',
serialize: false,
strict: false,
});

View File

@@ -0,0 +1,111 @@
import { License } from '@rocket.chat/license';
import { type ExtendedFetchOptions, Response, serverFetch } from '@rocket.chat/server-fetch';
import { isTesting } from './isTesting';
import { CloudOfflineLicenseError } from '../../../../lib/errors/CloudOfflineLicenseError';
export class MarketplaceAPIClient {
#fetchStrategy: (input: string, options?: ExtendedFetchOptions, allowSelfSignedCerts?: boolean) => Promise<Response>;
#marketplaceUrl: string;
constructor() {
if (typeof process.env.OVERWRITE_INTERNAL_MARKETPLACE_URL === 'string' && process.env.OVERWRITE_INTERNAL_MARKETPLACE_URL !== '') {
this.#marketplaceUrl = process.env.OVERWRITE_INTERNAL_MARKETPLACE_URL;
} else {
this.#marketplaceUrl = 'https://marketplace.rocket.chat';
}
if (isTesting()) {
this.#fetchStrategy = mockMarketplaceFetch;
} else {
this.#fetchStrategy = serverFetch;
}
}
public getMarketplaceUrl(): string {
return this.#marketplaceUrl;
}
public setStrategy(strategyName: 'default' | 'mock'): void {
switch (strategyName) {
case 'default':
this.#fetchStrategy = serverFetch;
break;
case 'mock':
this.#fetchStrategy = mockMarketplaceFetch;
break;
default:
throw new Error('Unknown strategy');
}
}
public fetch(input: string, options?: ExtendedFetchOptions, allowSelfSignedCerts?: boolean): ReturnType<typeof serverFetch> {
if (License.hasOfflineLicense()) {
return Promise.reject(
new CloudOfflineLicenseError('Marketplace connectivity is disabled by the offline license applied to this workspace'),
);
}
if (!input.startsWith('http://') && !input.startsWith('https://')) {
input = this.getMarketplaceUrl().concat(!input.startsWith('/') ? '/' : '', input);
}
return this.#fetchStrategy(input, options, allowSelfSignedCerts);
}
}
/**
* Provide mocked HTTP responses for supported Marketplace API endpoints.
*
* This allows us to prevent actual calls to Marketplace service
* during TEST_MODE (CI, local tests, etc.), i.e., remove our dependency
* an external unrelated service
*
* The response content provided has minimal structure to allow for the program
* to not crash by receiving something different from the expected structure
*
* @param input - The request URL or path used to determine which mock response to return
* @returns A `Response` with status 200 and a JSON body corresponding to the requested marketplace endpoint
* @throws Error when `input` does not match any supported mock endpoint
*/
function mockMarketplaceFetch(input: string, _options?: ExtendedFetchOptions, _allowSelfSignedCerts?: boolean): Promise<Response> {
let content: string;
switch (true) {
// This is not an exhaustive list of endpoints
case input.indexOf('v1/apps') !== -1:
case input.indexOf('v1/categories') !== -1:
case input.indexOf('v1/bundles') !== -1:
content = '[]';
break;
case input.indexOf('v1/featured-apps') !== -1:
content = '{"sections":[]}';
break;
case input.indexOf('v1/app-request/stats') !== -1:
content = '{"data":{"totalSeen":0,"totalUnseen":0}}';
break;
case input.indexOf('v1/app-request/markAsSeen') !== -1:
content = '{"success":false}';
break;
case input.indexOf('v1/app-request') !== -1:
content = '{"data":[],"meta":{"limit":25,"offset":0,"sort":"","filter":"","total":0}}';
break;
case input.indexOf('v1/workspaces') !== -1:
content = '{}';
break;
default:
throw new Error(`Invalid marketplace mock request ${input}`);
}
const response = new Response(Buffer.from(content), {
headers: {
'content-type': 'application/json',
},
status: 200,
});
return Promise.resolve(response);
}

View File

@@ -0,0 +1,58 @@
import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
import { Info } from '../../../../app/utils/rocketchat.info';
import { getWorkspaceAccessToken } from '../../../../server/lib/cloud';
import { settings } from '../../../../server/settings';
import { Apps } from '../orchestrator';
type MarketplaceNotificationType = 'install' | 'update' | 'uninstall';
/**
* Notify the marketplace about an app install, update, or uninstall event.
*
* Attempts to POST a notification to the marketplace client at `v1/apps/{id}/install` containing the action, app metadata, Rocket.Chat and engine versions, and site URL. If a workspace access token is available it is included in the Authorization header. Any errors encountered while obtaining the token, reading settings, or sending the request are ignored.
*
* @param action - The marketplace event type: 'install', 'update', or 'uninstall'
* @param appInfo - App metadata (including `id`, `name`, `nameSlug`, and `version`) to include in the notification
*/
export async function notifyMarketplace(action: MarketplaceNotificationType, appInfo: IAppInfo): Promise<void> {
const headers: { Authorization?: string } = {};
try {
const token = await getWorkspaceAccessToken();
headers.Authorization = `Bearer ${token}`;
// eslint-disable-next-line no-empty
} catch {}
let siteUrl = '';
try {
siteUrl = settings.get<string>('Site_Url');
// eslint-disable-next-line no-empty
} catch {}
const data = {
action,
appName: appInfo.name,
appSlug: appInfo.nameSlug,
appVersion: appInfo.version,
rocketChatVersion: Info.version,
engineVersion: Info.marketplaceApiVersion,
siteUrl,
};
const pendingSentUrl = `v1/apps/${appInfo.id}/install`;
try {
await Apps.getMarketplaceClient().fetch(pendingSentUrl, {
method: 'POST',
headers,
body: data,
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
ignoreSsrfValidation: true,
});
// eslint-disable-next-line no-empty
} catch {}
}

View File

@@ -0,0 +1,109 @@
import type { AppRequest, IUser } from '@rocket.chat/core-typings';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { getWorkspaceAccessToken } from '../../../../server/lib/cloud';
import { i18n } from '../../../../server/lib/i18n';
import { sendDirectMessageToUsers } from '../../../../server/lib/sendDirectMessageToUsers';
const ROCKET_CAT_USERID = 'rocket.cat';
const DEFAULT_LIMIT = 100;
const notifyBatchOfUsersError = (error: Error) => {
return new Error(`could not notify the batch of users. Error ${error}`);
};
const notifyBatchOfUsers = async (appName: string, learnMoreUrl: string, appRequests: AppRequest[]): Promise<string[]> => {
const batchRequesters = appRequests.reduce((acc: string[], appRequest: AppRequest) => {
// Prevent duplicate requesters
if (!acc.includes(appRequest.requester.id)) {
acc.push(appRequest.requester.id);
}
return acc;
}, []);
const msgFn = (user: Pick<IUser, 'language'>): string => {
const defaultLang = user.language || 'en';
const msg = `${i18n.t('App_request_enduser_message', { appName, learnmore: learnMoreUrl, lng: defaultLang })}`;
return msg;
};
try {
return await sendDirectMessageToUsers(ROCKET_CAT_USERID, batchRequesters, msgFn);
} catch (e) {
throw e;
}
};
export const appRequestNotififyForUsers = async (
marketplaceBaseUrl: string,
workspaceUrl: string,
appId: string,
appName: string,
): Promise<(string | Error)[]> => {
try {
const token = await getWorkspaceAccessToken();
const headers = {
Authorization: `Bearer ${token}`,
};
// First request
const pagination = { limit: DEFAULT_LIMIT, offset: 0 };
// First request to get the total and the first batch
const response = await fetch(`${marketplaceBaseUrl}/v1/app-request`, {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
ignoreSsrfValidation: true,
headers,
params: {
appId,
q: 'notification-not-sent',
limit: pagination.limit,
offset: pagination.offset,
},
});
const data = (await response.json()) as { meta: { total: number }; data: any };
const { total } = data.meta;
if (total === undefined || total === 0) {
return [];
}
// Calculate the number of loops - 1 because the first request was already made
const loops = Math.ceil(total / pagination.limit) - 1;
const requestsCollection = [];
const learnMore = `${workspaceUrl}marketplace/explore/info/${appId}`;
// Notify first batch
requestsCollection.push(notifyBatchOfUsers(appName, learnMore, data.data).catch(notifyBatchOfUsersError));
// Batch requests
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const _i of Array.from({ length: loops })) {
pagination.offset += pagination.limit;
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const request = await fetch(
`${marketplaceBaseUrl}/v1/app-request?appId=${appId}&q=notification-not-sent&limit=${pagination.limit}&offset=${pagination.offset}`,
{
ignoreSsrfValidation: true,
headers,
},
);
const { data } = await request.json();
requestsCollection.push(notifyBatchOfUsers(appName, learnMore, data));
}
const finalResult = await Promise.all(requestsCollection);
// Return the list of users that were notified
return finalResult.flat();
} catch (e) {
throw e;
}
};

View File

@@ -0,0 +1,196 @@
import type { App } from '@rocket.chat/core-typings';
import * as z from 'zod';
import { getMarketplaceHeaders } from './getMarketplaceHeaders';
import { MarketplaceAppsError, MarketplaceConnectionError, MarketplaceUnsupportedVersionError } from './marketplaceErrors';
import { CloudOfflineLicenseError } from '../../../../lib/errors/CloudOfflineLicenseError';
import { getWorkspaceAccessToken } from '../../../../server/lib/cloud';
import { settings } from '../../../../server/settings';
import { Apps } from '../orchestrator';
type FetchMarketplaceAppsParams = {
endUserID?: string;
};
const markdownObject = z.object({
raw: z.string().optional(),
rendered: z.string().optional(),
});
const fetchMarketplaceAppsSchema = z.array(
z.object({
appId: z.string(),
latest: z.object({
internalId: z.string(),
id: z.string(),
name: z.string(),
nameSlug: z.string(),
version: z.string(),
categories: z.array(z.string()),
languages: z.array(z.string()).optional(),
shortDescription: z.string().optional(),
description: z.string(),
privacyPolicySummary: z.string().optional(),
documentationUrl: z.string().optional(),
detailedDescription: markdownObject,
detailedChangelog: markdownObject,
requiredApiVersion: z.string(),
versionIncompatible: z.boolean().optional(),
permissions: z
.array(
z.object({
name: z.string(),
domains: z.array(z.string()).optional(),
scopes: z.array(z.string()).optional(),
}),
)
.optional(),
addon: z.string().optional(),
author: z.object({
name: z.string(),
support: z.string(),
homepage: z.string(),
}),
classFile: z.string(),
iconFile: z.string(),
iconFileData: z.string(),
status: z.enum(['submitted', 'author-rejected', 'author-approved', 'rejected', 'approved', 'published']),
reviewedNote: z.string().optional(),
rejectionNote: z.string().optional(),
changesNote: z.string().optional(),
internalChangesNote: z.string().optional(),
isVisible: z.boolean(),
createdDate: z.string(),
modifiedDate: z.string(),
}),
isAddon: z.boolean(),
addonId: z.string().optional(),
isEnterpriseOnly: z.boolean(),
isBundle: z.boolean(),
bundledAppsIn: z.array(z.string()).optional(),
bundledIn: z.array(
z.object({
bundleId: z.string(),
bundleName: z.string(),
addonTierId: z.string().optional(),
}),
),
isPurchased: z.boolean(),
isSubscribed: z.boolean(),
subscriptionInfo: z
.object({
typeOf: z.enum(['app', 'service', '']),
status: z.enum(['trialing', 'active', 'cancelled', 'cancelling', 'pastDue', '']),
statusFromBilling: z.boolean(),
isSeatBased: z.boolean(),
seats: z.number(),
maxSeats: z.number(),
license: z.object({
license: z.string(),
version: z.number(),
expireDate: z.string(),
}),
startDate: z.string(),
periodEnd: z.string(),
endDate: z.string().optional(),
externallyManaged: z.boolean(),
isSubscribedViaBundle: z.boolean(),
})
.optional(),
price: z.number(),
purchaseType: z.enum(['', 'buy', 'subscription']),
pricingPlans: z
.array(
z.object({
id: z.string(),
enabled: z.boolean(),
price: z.number(),
trialDays: z.number(),
strategy: z.enum(['once', 'monthly', 'yearly']),
isPerSeat: z.boolean(),
tiers: z
.array(
z.object({
perUnit: z.boolean(),
minimum: z.number(),
maximum: z.number(),
price: z.number(),
refId: z.string().optional(),
}),
)
.optional(),
}),
)
.optional(),
isUsageBased: z.boolean().optional(),
requestedEndUser: z.boolean().optional(),
requested: z.boolean().optional(),
appRequestStats: z.object({}).optional(),
createdAt: z.string(),
modifiedAt: z.string(),
}),
);
/**
* Fetches marketplace apps available to the workspace.
*
* @param endUserID - Optional end-user identifier used to filter apps returned by the marketplace.
* @returns An array of marketplace `App` objects describing available apps and their latest release metadata.
* @throws MarketplaceConnectionError when the marketplace cannot be reached.
* @throws MarketplaceUnsupportedVersionError when the marketplace reports an unsupported client version.
* @throws MarketplaceAppsError for marketplace-side errors, including invalid apps engine version, internal marketplace errors, or a generic failure to fetch apps.
*/
export async function fetchMarketplaceApps({ endUserID }: FetchMarketplaceAppsParams = {}): Promise<App[]> {
const headers = getMarketplaceHeaders();
const token = await getWorkspaceAccessToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
let request;
try {
request = await Apps.getMarketplaceClient().fetch(`v1/apps`, {
headers,
ignoreSsrfValidation: false,
allowList: settings.get<string>('SSRF_Allowlist'),
params: {
...(endUserID && { endUserID }),
},
});
} catch (error) {
// Offline (air-gapped) licenses reject before any request is made; keep the
// typed error so the REST layer can report the real reason instead of a
// generic connectivity failure.
if (error instanceof CloudOfflineLicenseError) {
throw error;
}
throw new MarketplaceConnectionError('Marketplace_Bad_Marketplace_Connection');
}
if (request.status === 200) {
const response = await request.json();
fetchMarketplaceAppsSchema.parse(response);
return response;
}
const response = await request.json();
Apps.getRocketChatLogger().error({ msg: 'Error fetching marketplace apps', status: request.status, response });
// TODO: Refactor cloud to return a proper error code on unsupported version
if (request.status === 426 && 'errorMsg' in response && response.errorMsg === 'unsupported version') {
throw new MarketplaceUnsupportedVersionError();
}
if (request.status === 400 && response.code === 200) {
throw new MarketplaceAppsError('Marketplace_Invalid_Apps_Engine_Version');
}
const INTERNAL_MARKETPLACE_ERROR_CODES = [266, 256, 166, 221, 257, 320];
if (request.status === 500 && INTERNAL_MARKETPLACE_ERROR_CODES.includes(response.code)) {
throw new MarketplaceAppsError('Marketplace_Internal_Error');
}
throw new MarketplaceAppsError('Marketplace_Failed_To_Fetch_Apps');
}

View File

@@ -0,0 +1,76 @@
import type { AppCategory } from '@rocket.chat/core-typings';
import * as z from 'zod';
import { getMarketplaceHeaders } from './getMarketplaceHeaders';
import { MarketplaceAppsError, MarketplaceConnectionError, MarketplaceUnsupportedVersionError } from './marketplaceErrors';
import { CloudOfflineLicenseError } from '../../../../lib/errors/CloudOfflineLicenseError';
import { getWorkspaceAccessToken } from '../../../../server/lib/cloud';
import { settings } from '../../../../server/settings';
import { Apps } from '../orchestrator';
const fetchMarketplaceCategoriesSchema = z.array(
z.object({
id: z.string(),
title: z.string(),
description: z.string(),
hidden: z.boolean(),
createdDate: z.string(),
modifiedDate: z.string(),
}),
);
/**
* Fetches marketplace categories from the marketplace API.
*
* @returns An array of marketplace categories (`AppCategory[]`).
* @throws MarketplaceConnectionError when the HTTP request cannot be made.
* @throws MarketplaceUnsupportedVersionError when the marketplace responds with status 426 and `errorMsg` equals `"unsupported version"`.
* @throws MarketplaceAppsError when the marketplace returns an internal error (specific internal codes) or any other non-successful response.
*/
export async function fetchMarketplaceCategories(): Promise<AppCategory[]> {
const headers = getMarketplaceHeaders();
const token = await getWorkspaceAccessToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
let request;
try {
request = await Apps.getMarketplaceClient().fetch(`v1/categories`, {
headers,
ignoreSsrfValidation: false,
allowList: settings.get<string>('SSRF_Allowlist'),
});
} catch (error) {
// Offline (air-gapped) licenses reject before any request is made; keep the
// typed error so the REST layer can report the real reason instead of a
// generic connectivity failure.
if (error instanceof CloudOfflineLicenseError) {
throw error;
}
throw new MarketplaceConnectionError('Marketplace_Bad_Marketplace_Connection');
}
if (request.status === 200) {
const response = await request.json();
fetchMarketplaceCategoriesSchema.parse(response);
return response;
}
const response = await request.json();
Apps.getRocketChatLogger().error({ msg: 'Error fetching marketplace categories', status: request.status, response });
// TODO: Refactor cloud to return a proper error code on unsupported version
if (request.status === 426 && 'errorMsg' in response && response.errorMsg === 'unsupported version') {
throw new MarketplaceUnsupportedVersionError();
}
const INTERNAL_MARKETPLACE_ERROR_CODES = [189, 266];
if (request.status === 500 && INTERNAL_MARKETPLACE_ERROR_CODES.includes(response.code)) {
throw new MarketplaceAppsError('Marketplace_Internal_Error');
}
throw new MarketplaceAppsError('Marketplace_Failed_To_Fetch_Categories');
}

View File

@@ -0,0 +1,7 @@
import { Info } from '../../../../app/utils/rocketchat.info';
export function getMarketplaceHeaders(): Record<string, any> {
return {
'X-Apps-Engine-Version': Info.marketplaceApiVersion.replace(/-.*/g, ''),
};
}

View File

@@ -0,0 +1,8 @@
/**
* Determine whether the application is running in testing mode.
*
* @returns `true` if the `TEST_MODE` environment variable equals `'true'`, `false` otherwise.
*/
export function isTesting() {
return process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api';
}

View File

@@ -0,0 +1,17 @@
export class MarketplaceAppsError extends Error {
constructor(message: string) {
super(message);
}
}
export class MarketplaceConnectionError extends Error {
constructor(message: string) {
super(message);
}
}
export class MarketplaceUnsupportedVersionError extends Error {
constructor() {
super('Marketplace_Unsupported_Version');
}
}

View File

@@ -0,0 +1,448 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { AppBridges, AppEvents, IAppConvertersMap, IAppServerNotifier, IAppServerOrchestrator } from '@rocket.chat/apps';
import { registerOrchestrator } from '@rocket.chat/apps';
import { AppManager } from '@rocket.chat/apps/dist/server/AppManager';
import type { IGetAppsFilter } from '@rocket.chat/apps/dist/server/IGetAppsFilter';
import type { ProxiedApp } from '@rocket.chat/apps/dist/server/ProxiedApp';
import type { IMarketplaceInfo } from '@rocket.chat/apps/dist/server/marketplace/IMarketplaceInfo';
import { AppInstallationSource } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem';
import { EssentialAppDisabledException } from '@rocket.chat/apps-engine/definition/exceptions';
import type { IExternalComponent } from '@rocket.chat/apps-engine/definition/externalComponent';
import { Logger } from '@rocket.chat/logger';
import { AppLogs, Apps as AppsModel, AppsPersistence, Statistics } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { AppServerNotifier, AppsRestApi, AppUIKitInteractionApi } from './communication';
import { redactionFieldPaths } from './lib/redactor';
import { MarketplaceAPIClient } from './marketplace/MarketplaceAPIClient';
import { isTesting } from './marketplace/isTesting';
import { AppRealLogStorage, AppRealStorage, ConfigurableAppSourceStorage } from './storage';
import { RealAppBridges } from '../../../app/apps/server/bridges';
import type { HandleEvent } from '../../../app/apps/server/bridges/listeners';
import {
AppMessagesConverter,
AppRoomsConverter,
AppSettingsConverter,
AppUsersConverter,
AppVideoConferencesConverter,
AppDepartmentsConverter,
AppUploadsConverter,
AppVisitorsConverter,
AppRolesConverter,
AppContactsConverter,
} from '../../../app/apps/server/converters';
import { AppThreadsConverter } from '../../../app/apps/server/converters/threads';
import { settings } from '../../../server/settings';
import { canEnableApp } from '../lib/license/canEnableApp';
const DISABLED_PRIVATE_APP_INSTALLATION = ['yes', 'true'].includes(String(process.env.DISABLE_PRIVATE_APP_INSTALLATION).toLowerCase());
type AppCommunicators = {
notifier: AppServerNotifier;
restapi: AppsRestApi;
uikit: AppUIKitInteractionApi;
};
interface IAppCommunicatorsMap extends Map<keyof AppCommunicators, AppCommunicators[keyof AppCommunicators]> {
get<T extends keyof AppCommunicators>(key: T): AppCommunicators[T];
}
export class AppServerOrchestrator implements IAppServerOrchestrator {
private _isInitialized: boolean;
private readonly marketplaceClient: MarketplaceAPIClient;
private _rocketchatLogger: Logger;
private _model: typeof AppsModel;
private _logModel: typeof AppLogs;
private _persistModel: typeof AppsPersistence;
private _statisticsModel: typeof Statistics;
private _storage: AppRealStorage;
private _logStorage: AppRealLogStorage;
private _appSourceStorage: ConfigurableAppSourceStorage;
private _converters: IAppConvertersMap;
private _bridges: RealAppBridges;
private _manager: AppManager;
private _communicators: IAppCommunicatorsMap;
constructor() {
this._isInitialized = false;
this.marketplaceClient = new MarketplaceAPIClient();
}
initialize(): void {
if (this._isInitialized) {
return;
}
this._rocketchatLogger = new Logger('Rocket.Chat Apps', { redact: redactionFieldPaths });
this._model = AppsModel;
this._logModel = AppLogs;
this._persistModel = AppsPersistence;
this._statisticsModel = Statistics;
this._storage = new AppRealStorage(this._model);
this._logStorage = new AppRealLogStorage(this._logModel);
this._appSourceStorage = new ConfigurableAppSourceStorage(
settings.get<string>('Apps_Framework_Source_Package_Storage_Type'),
settings.get<string>('Apps_Framework_Source_Package_Storage_FileSystem_Path'),
);
this._converters = new Map() as IAppConvertersMap;
this._converters.set('messages', new AppMessagesConverter(this));
this._converters.set('rooms', new AppRoomsConverter(this));
this._converters.set('settings', new AppSettingsConverter(this));
this._converters.set('users', new AppUsersConverter(this));
this._converters.set('visitors', new AppVisitorsConverter(this));
this._converters.set('contacts', new AppContactsConverter());
this._converters.set('departments', new AppDepartmentsConverter(this));
this._converters.set('uploads', new AppUploadsConverter(this));
this._converters.set('videoConferences', new AppVideoConferencesConverter());
this._converters.set('threads', new AppThreadsConverter(this));
this._converters.set('roles', new AppRolesConverter());
this._bridges = new RealAppBridges(this);
const tempFilePath = path.join(os.tmpdir(), 'apps-engine-temp');
try {
// We call this only once at server startup, so using the synchronous version is fine
fs.mkdirSync(tempFilePath);
} catch (err) {
// If the temp directory already exists, we can continue
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
throw new Error('Failed to initialize the Apps-Engine', { cause: err });
}
}
this._manager = new AppManager({
metadataStorage: this._storage,
logStorage: this._logStorage,
bridges: this.getBridges(),
sourceStorage: this._appSourceStorage,
tempFilePath,
});
this._communicators = new Map() as IAppCommunicatorsMap;
this._communicators.set('notifier', new AppServerNotifier(this));
this._communicators.set('restapi', new AppsRestApi(this, this._manager));
this._communicators.set('uikit', new AppUIKitInteractionApi(this));
this._isInitialized = true;
}
getMarketplaceClient(): MarketplaceAPIClient {
return this.marketplaceClient;
}
getModel(): typeof AppsModel {
return this._model;
}
getPersistenceModel(): typeof AppsPersistence {
return this._persistModel;
}
getStatisticsModel(): typeof Statistics {
return this._statisticsModel;
}
getStorage(): AppRealStorage {
return this._storage;
}
getLogStorage(): AppRealLogStorage {
if (!this._logStorage) {
throw new Error('Apps-Engine not yet fully initialized');
}
return this._logStorage;
}
getConverters(): IAppConvertersMap {
return this._converters;
}
getBridges(): AppBridges {
// `RealAppBridges` does extend `AppBridges`, but its listener bridge takes a single event object
// instead of the positional arguments `IListenerBridge` declares, so the structural check on the
// bridge collection has to be bypassed. Internal callers use `this._bridges` to keep the real types.
return this._bridges as unknown as AppBridges;
}
getNotifier(): IAppServerNotifier {
return this._communicators.get('notifier');
}
getManager(): AppManager {
return this._manager;
}
getProvidedComponents(): IExternalComponent[] {
return this._manager.getExternalComponentManager().getProvidedComponents();
}
getAppSourceStorage(): ConfigurableAppSourceStorage {
return this._appSourceStorage;
}
isInitialized(): boolean {
return this._isInitialized;
}
isLoaded(): boolean {
return this.getManager().areAppsLoaded();
}
isDebugging(): boolean {
return !isTesting();
}
shouldDisablePrivateAppInstallation(): boolean {
return DISABLED_PRIVATE_APP_INSTALLATION;
}
getRocketChatLogger(): Logger {
return this._rocketchatLogger;
}
debugLog(...args: any[]): void {
if (this.isDebugging()) {
// The logger only declares a single message parameter, but callers have always relied on
// the extra arguments being forwarded to the underlying pino instance.
this.getRocketChatLogger().debug(...(args as [object | string]));
}
}
async load(): Promise<void> {
// Don't try to load it again if it has
// already been loaded
if (this.isLoaded()) {
return;
}
await this.getManager().load();
// Before enabling each app we verify if there is still room for it
const apps = await this.getManager().get();
// This needs to happen sequentially to keep track of app limits
for (const app of apps) {
try {
await canEnableApp(app.getStorageItem());
await this.getManager().loadOne(app.getID(), true);
} catch (error) {
this._rocketchatLogger.warn({
msg: 'App could not be enabled',
appName: app.getInfo().name,
err: error,
});
}
}
await this._bridges.getSchedulerBridge().startScheduler();
const appCount = (await this.getManager().get({ enabled: true })).length;
this._rocketchatLogger.info({
msg: 'Loaded the Apps Framework and apps',
appCount,
});
}
async migratePrivateApps(): Promise<void> {
const apps = await this.getManager().get({ installationSource: AppInstallationSource.PRIVATE });
await Promise.all(apps.map((app) => this.getManager().migrate(app.getID())));
await Promise.all(apps.map((app) => this.getNotifier().appUpdated(app.getID())));
}
async findMajorVersionUpgradeDate(targetVersion = 7): Promise<Date | null> {
let upgradeToV7Date: Date | null = null;
let hadPreTargetVersion = false;
try {
const statistics = await this.getStatisticsModel().findInstallationDates();
if (!statistics || statistics.length === 0) {
this._rocketchatLogger.info('No statistics found');
return upgradeToV7Date;
}
const statsAscendingByInstallDate = statistics.sort(
(a, b) => new Date(a.installedAt ?? NaN).getTime() - new Date(b.installedAt ?? NaN).getTime(),
);
for (const stat of statsAscendingByInstallDate) {
const version = stat.version || '';
if (!version) {
continue;
}
const majorVersion = parseInt(version.split('.')[0], 10);
if (isNaN(majorVersion)) {
continue;
}
if (majorVersion < targetVersion) {
hadPreTargetVersion = true;
}
if (hadPreTargetVersion && majorVersion >= targetVersion) {
upgradeToV7Date = new Date(stat.installedAt ?? NaN);
this._rocketchatLogger.info({
msg: 'Found upgrade to target version date',
targetVersion,
upgradeToDate: upgradeToV7Date.toISOString(),
});
break;
}
}
} catch (err) {
this._rocketchatLogger.error({
msg: 'Error checking statistics for version history',
err,
});
}
return upgradeToV7Date;
}
async disableMarketplaceApps(): Promise<void> {
return this.disableApps(AppInstallationSource.MARKETPLACE, false, 5);
}
async disablePrivateApps(): Promise<void> {
return this.disableApps(AppInstallationSource.PRIVATE, true, 0);
}
async disableApps(installationSource: AppInstallationSource, grandfatherApps: boolean, maxApps: number): Promise<void> {
const upgradeToV7Date = await this.findMajorVersionUpgradeDate();
const apps = await this.getManager().get({ installationSource });
const grandfathered: ProxiedApp[] = [];
const toKeep: ProxiedApp[] = [];
const toDisable: ProxiedApp[] = [];
for (const app of apps) {
const storageItem = app.getStorageItem();
const isEnabled = ['enabled', 'manually_enabled', 'auto_enabled'].includes(storageItem.status);
const marketplaceInfo = storageItem.marketplaceInfo?.[0];
const wasInstalledBeforeV7 = upgradeToV7Date && storageItem.createdAt && new Date(storageItem.createdAt) < upgradeToV7Date;
if (wasInstalledBeforeV7 && isEnabled && grandfatherApps) {
grandfathered.push(app);
continue;
}
if (marketplaceInfo?.isEnterpriseOnly === true && installationSource === AppInstallationSource.MARKETPLACE) {
toDisable.push(app);
continue;
}
if (isEnabled) {
toKeep.push(app);
}
}
toKeep.sort((a, b) => new Date(a.getStorageItem().createdAt || 0).getTime() - new Date(b.getStorageItem().createdAt || 0).getTime());
if (toKeep.length > maxApps) {
toDisable.push(...toKeep.splice(maxApps));
}
if (toDisable.length === 0) {
return;
}
const disablePromises = toDisable.map((app) => {
const appId = app.getID();
return this.getManager().disable(appId);
});
try {
await Promise.all(disablePromises);
this._rocketchatLogger.info({
msg: 'Apps processing complete',
installationSource,
keptCount: grandfathered.length + toKeep.length,
disabledCount: toDisable.length,
});
} catch (error) {
this._rocketchatLogger.error({
msg: 'Error disabling apps',
err: error,
});
}
}
async unload(): Promise<void> {
// Don't try to unload it if it's already been
// unlaoded or wasn't unloaded to start with
if (!this.isLoaded()) {
return;
}
return this._manager
.unload(false)
.then(() => this._rocketchatLogger.info('Unloaded the Apps Framework.'))
.catch((err) =>
this._rocketchatLogger.error({
msg: 'Failed to unload the Apps Framework!',
err,
}),
);
}
async updateAppsMarketplaceInfo(apps: Array<{ latest: IMarketplaceInfo }> = []): Promise<ProxiedApp[] | undefined> {
if (!this.isLoaded()) {
return;
}
return this._manager.updateAppsMarketplaceInfo(apps).then(() => this._manager.get());
}
async installedApps(filter: IGetAppsFilter = {}): Promise<ProxiedApp[] | undefined> {
if (!this.isLoaded()) {
return;
}
return this._manager.get(filter);
}
async triggerEvent(event: AppEvents, ...payload: any[]): Promise<any> {
if (!this.isLoaded()) {
return;
}
return this._bridges
.getListenerBridge()
.handleEvent({ event, payload } as HandleEvent)
.catch((error: unknown) => {
if (error instanceof EssentialAppDisabledException) {
throw new Meteor.Error('error-essential-app-disabled');
}
throw error;
});
}
}
export const Apps = new AppServerOrchestrator();
registerOrchestrator(Apps);

View File

@@ -0,0 +1,83 @@
import { License } from '@rocket.chat/license';
import { Apps } from './orchestrator';
import { settings, settingsRegistry } from '../../../server/settings';
import { disableAppsWithAddonsCallback } from '../lib/apps/disableAppsWithAddonsCallback';
export const startupApp = async function startupApp() {
await settingsRegistry.addGroup('General', async function () {
await this.section('Apps', async function () {
await this.add('Apps_Logs_TTL', '30_days', {
type: 'select',
values: [
{
key: '7_days',
i18nLabel: 'Apps_Logs_TTL_7days',
},
{
key: '14_days',
i18nLabel: 'Apps_Logs_TTL_14days',
},
{
key: '30_days',
i18nLabel: 'Apps_Logs_TTL_30days',
},
],
public: true,
hidden: true,
alert: 'Apps_Logs_TTL_Alert',
});
await this.add('Apps_Framework_Source_Package_Storage_Type', 'gridfs', {
type: 'select',
values: [
{
key: 'gridfs',
i18nLabel: 'GridFS',
},
{
key: 'filesystem',
i18nLabel: 'FileSystem',
},
],
public: true,
hidden: false,
alert: 'Apps_Framework_Source_Package_Storage_Type_Alert',
});
await this.add('Apps_Framework_Source_Package_Storage_FileSystem_Path', '', {
type: 'string',
public: true,
enableQuery: {
_id: 'Apps_Framework_Source_Package_Storage_Type',
value: 'filesystem',
},
alert: 'Apps_Framework_Source_Package_Storage_FileSystem_Alert',
});
});
});
async function migratePrivateAppsCallback() {
void Apps.migratePrivateApps();
void Apps.disablePrivateApps();
void Apps.disableMarketplaceApps();
}
License.onInvalidateLicense(migratePrivateAppsCallback);
License.onRemoveLicense(migratePrivateAppsCallback);
// Disable apps that depend on add-ons (external modules) if they are invalidated
License.onModule(disableAppsWithAddonsCallback);
Apps.initialize();
void Apps.load();
settings.change<'filesystem' | 'gridfs'>('Apps_Framework_Source_Package_Storage_Type', (value) =>
Apps.getAppSourceStorage()?.setStorage(value),
);
settings.change<string>('Apps_Framework_Source_Package_Storage_FileSystem_Path', (value) =>
Apps.getAppSourceStorage()?.setFileSystemStoragePath(value),
);
};

View File

@@ -0,0 +1,69 @@
import { promises as fs } from 'node:fs';
import { join, normalize } from 'node:path';
import { AppSourceStorage } from '@rocket.chat/apps/dist/server/storage/AppSourceStorage';
import type { IAppStorageItem } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem';
export class AppFileSystemSourceStorage extends AppSourceStorage {
private pathPrefix = 'fs:/';
private path: string;
public setPath(path: string): void {
this.path = path;
}
public checkPath(): void {
if (!this.path) {
throw new Error('Invalid path configured for file system App storage');
}
}
public async store(item: IAppStorageItem, zip: Buffer): Promise<string> {
this.checkPath();
const filePath = this.itemToFilename(item);
await fs.writeFile(filePath, zip);
return this.filenameToSourcePath(filePath);
}
public async fetch(item: IAppStorageItem): Promise<Buffer> {
if (!item.sourcePath) {
throw new Error('Invalid source path');
}
return fs.readFile(this.sourcePathToFilename(item.sourcePath));
}
public async update(item: IAppStorageItem, zip: Buffer): Promise<string> {
this.checkPath();
const filePath = this.itemToFilename(item);
await fs.writeFile(filePath, zip);
return this.filenameToSourcePath(filePath);
}
public async remove(item: IAppStorageItem): Promise<void> {
if (!item.sourcePath) {
return;
}
return fs.unlink(this.sourcePathToFilename(item.sourcePath));
}
private itemToFilename(item: IAppStorageItem): string {
return `${normalize(join(this.path, item.id))}.zip`;
}
private filenameToSourcePath(filename: string): string {
return this.pathPrefix + filename;
}
private sourcePathToFilename(sourcePath: string): string {
return sourcePath.substring(this.pathPrefix.length);
}
}

View File

@@ -0,0 +1,85 @@
import { AppSourceStorage } from '@rocket.chat/apps/dist/server/storage/AppSourceStorage';
import type { IAppStorageItem } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem';
import { streamToBuffer } from '@rocket.chat/tools';
import { MongoInternals } from 'meteor/mongo';
import { NpmModuleMongodb } from 'meteor/npm-mongo';
import { ObjectId } from 'mongodb';
export class AppGridFSSourceStorage extends AppSourceStorage {
private pathPrefix = 'GridFS:/';
private bucket: NpmModuleMongodb.GridFSBucket;
constructor() {
super();
const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo;
this.bucket = new NpmModuleMongodb.GridFSBucket(db as any, {
bucketName: 'rocketchat_apps_packages',
chunkSizeBytes: 1024 * 255,
});
}
public async store(item: IAppStorageItem, zip: Buffer): Promise<string> {
return new Promise((resolve, reject) => {
const filename = this.itemToFilename(item);
const writeStream: NpmModuleMongodb.GridFSBucketWriteStream = this.bucket
.openUploadStream(filename)
.on('finish', () => resolve(this.idToPath(writeStream.id)))
.on('error', (error: any) => reject(error));
writeStream.write(zip);
writeStream.end();
});
}
public async fetch(item: IAppStorageItem): Promise<Buffer> {
return streamToBuffer(this.bucket.openDownloadStream(this.itemToObjectId(item)));
}
public async update(item: IAppStorageItem, zip: Buffer): Promise<string> {
return new Promise((resolve, reject) => {
const fileId = this.itemToFilename(item);
const writeStream: NpmModuleMongodb.GridFSBucketWriteStream = this.bucket
.openUploadStream(fileId)
.on('finish', () => {
resolve(this.idToPath(writeStream.id));
// An error in the following line would not cause the update process to fail
// eslint-disable-next-line @typescript-eslint/no-empty-function
this.remove(item).catch(() => {});
})
.on('error', (error: any) => reject(error));
writeStream.write(zip);
writeStream.end();
});
}
public async remove(item: IAppStorageItem): Promise<void> {
try {
await this.bucket.delete(this.itemToObjectId(item));
} catch (error: any) {
if (error.message.includes('File not found for id')) {
console.warn(
`This instance could not remove the ${item.info.name} app package. If you are running Rocket.Chat in a cluster with multiple instances, possibly other instance removed the package. If this is not the case, it is possible that the file in the database got renamed or removed manually.`,
);
return;
}
throw error;
}
}
private itemToFilename(item: IAppStorageItem): string {
return `${item.info.nameSlug}-${item.info.version}.package`;
}
private idToPath(id: NpmModuleMongodb.GridFSBucketWriteStream['id']): string {
return this.pathPrefix + id;
}
private itemToObjectId(item: IAppStorageItem): ObjectId {
return new ObjectId(item.sourcePath?.substring(this.pathPrefix.length));
}
}

View File

@@ -0,0 +1,62 @@
import type { ILoggerStorageEntry } from '@rocket.chat/apps/dist/server/logging/ILoggerStorageEntry';
import type { IAppLogStorageFindOptions } from '@rocket.chat/apps/dist/server/storage/AppLogStorage';
import { AppLogStorage } from '@rocket.chat/apps/dist/server/storage/AppLogStorage';
import { InstanceStatus } from '@rocket.chat/instance-status';
import type { AppLogs } from '@rocket.chat/models';
import { redact } from '../lib/redactor';
export class AppRealLogStorage extends AppLogStorage {
constructor(private db: typeof AppLogs) {
super('mongodb');
}
async find(
query: {
[field: string]: any;
},
options: IAppLogStorageFindOptions,
) {
return this.db.find<ILoggerStorageEntry>(query, options).toArray();
}
async findPaginated(
query: {
[field: string]: any;
},
options: IAppLogStorageFindOptions,
) {
const { cursor, totalCount } = this.db.findPaginated<ILoggerStorageEntry>(query, options);
const [logs, total] = await Promise.all([cursor.toArray(), totalCount]);
return {
logs,
total,
};
}
async distinctValues(appId: string) {
return this.db.getDistinctFieldsForFilters(appId);
}
async storeEntries(logEntry: ILoggerStorageEntry): Promise<ILoggerStorageEntry> {
logEntry.instanceId = InstanceStatus.id();
logEntry.entries.forEach((entry) => {
entry.args.forEach(redact);
});
const id = (await this.db.insertOne(logEntry)).insertedId;
return this.db.findOneById(id);
}
async getEntriesFor(appId: string): Promise<ILoggerStorageEntry[]> {
return this.db.find({ appId }).toArray();
}
async removeEntriesFor(appId: string): Promise<void> {
await this.db.deleteOne({ appId });
}
}

View File

@@ -0,0 +1,102 @@
import type { IMarketplaceInfo } from '@rocket.chat/apps/dist/server/marketplace/IMarketplaceInfo';
import { AppMetadataStorage } from '@rocket.chat/apps/dist/server/storage/AppMetadataStorage';
import type { IAppStorageItem } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem';
import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
import type { ISetting } from '@rocket.chat/apps-engine/definition/settings';
import type { Apps } from '@rocket.chat/models';
import { removeEmpty } from '@rocket.chat/tools';
import type { UpdateFilter } from 'mongodb';
export class AppRealStorage extends AppMetadataStorage {
constructor(private db: typeof Apps) {
super('mongodb');
}
public async create(item: IAppStorageItem): Promise<IAppStorageItem> {
item.createdAt = new Date();
item.updatedAt = new Date();
const doc = await this.db.findOne({ $or: [{ id: item.id }, { 'info.nameSlug': item.info.nameSlug }] });
if (doc) {
throw new Error('App already exists.');
}
const nonEmptyItem = removeEmpty(item);
const id = (await this.db.insertOne(nonEmptyItem)).insertedId as unknown as string;
nonEmptyItem._id = id;
return nonEmptyItem;
}
public async retrieveOne(id: string): Promise<IAppStorageItem> {
return this.db.findOne({ $or: [{ _id: id }, { id }] });
}
public async retrieveAll(): Promise<Map<string, IAppStorageItem>> {
const docs = await this.db.find({}).toArray();
const items = new Map();
docs.forEach((i) => items.set(i.id, i));
return items;
}
public async retrieveAllPrivate(): Promise<Map<string, IAppStorageItem>> {
const docs = await this.db.find({ installationSource: 'private' }).toArray();
const items = new Map();
docs.forEach((i) => items.set(i.id, i));
return items;
}
public async remove(id: string): Promise<{ success: boolean }> {
await this.db.deleteOne({ id });
return { success: true };
}
public async updatePartialAndReturnDocument(
{ _id, ...item }: IAppStorageItem,
{ unsetPermissionsGranted = false } = {},
): Promise<IAppStorageItem> {
if (!_id) {
throw new Error('Property _id is required to update an app storage item');
}
const updateQuery: UpdateFilter<IAppStorageItem> = {
$set: item,
};
if (unsetPermissionsGranted) {
delete item.permissionsGranted;
updateQuery.$unset = { permissionsGranted: 1 };
}
// TODO need to change method return type to IAppStorageItem | null, because findOneAndUpdate can return null if the document is not found.
// But for now, we are asserting that it will always return a document.
return this.db.findOneAndUpdate({ _id }, updateQuery, { returnDocument: 'after' }) as Promise<IAppStorageItem>;
}
public async updateStatus(_id: string, status: AppStatus): Promise<boolean> {
const result = await this.db.updateOne({ _id }, { $set: { status } });
return result.modifiedCount > 0;
}
public async updateSetting(_id: string, setting: ISetting): Promise<boolean> {
const result = await this.db.updateOne({ _id }, { $set: { [`settings.${setting.id}`]: setting } });
return result.modifiedCount > 0;
}
public async updateAppInfo(_id: string, info: IAppInfo): Promise<boolean> {
const result = await this.db.updateOne({ _id }, { $set: { info } });
return result.modifiedCount > 0;
}
public async updateMarketplaceInfo(_id: string, marketplaceInfo: IMarketplaceInfo[]): Promise<boolean> {
const result = await this.db.updateOne({ _id }, { $set: { marketplaceInfo } });
return result.modifiedCount > 0;
}
}

View File

@@ -0,0 +1,57 @@
import { AppSourceStorage } from '@rocket.chat/apps/dist/server/storage/AppSourceStorage';
import type { IAppStorageItem } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem';
import { AppFileSystemSourceStorage } from './AppFileSystemSourceStorage';
import { AppGridFSSourceStorage } from './AppGridFSSourceStorage';
export class ConfigurableAppSourceStorage extends AppSourceStorage {
private filesystem: AppFileSystemSourceStorage;
private gridfs: AppGridFSSourceStorage;
private storage: AppSourceStorage;
constructor(
readonly storageType: string,
filesystemStoragePath: string,
) {
super();
this.filesystem = new AppFileSystemSourceStorage();
this.gridfs = new AppGridFSSourceStorage();
this.setStorage(storageType);
this.setFileSystemStoragePath(filesystemStoragePath);
}
public setStorage(type: string): void {
switch (type) {
case 'filesystem':
this.storage = this.filesystem;
break;
case 'gridfs':
this.storage = this.gridfs;
break;
}
}
public setFileSystemStoragePath(path: string): void {
this.filesystem.setPath(path);
}
public async store(item: IAppStorageItem, zip: Buffer): Promise<string> {
return this.storage.store(item, zip);
}
public async fetch(item: IAppStorageItem): Promise<Buffer> {
return this.storage.fetch(item);
}
public async update(item: IAppStorageItem, zip: Buffer): Promise<string> {
return this.storage.update(item, zip);
}
public async remove(item: IAppStorageItem): Promise<void> {
return this.storage.remove(item);
}
}

View File

@@ -0,0 +1,6 @@
import './AppFileSystemSourceStorage';
import './AppGridFSSourceStorage';
export { AppRealLogStorage } from './AppRealLogStorage';
export { AppRealStorage } from './AppRealStorage';
export { ConfigurableAppSourceStorage } from './ConfigurableAppSourceStorage';

View File

@@ -0,0 +1,67 @@
import { Abac, LDAPEnterprise } from '@rocket.chat/core-services';
import { cronJobs } from '@rocket.chat/cron';
import { License } from '@rocket.chat/license';
import { Users } from '@rocket.chat/models';
import { isValidCron } from 'cron-validator';
import { Meteor } from 'meteor/meteor';
import { settings } from '../../../server/settings';
const VIRTRU_PDP_SYNC_JOB = 'ABAC_Virtru_PDP_Sync';
Meteor.startup(async () => {
let stopWatcher: () => void;
let stopCronWatcher: () => void;
License.onToggledFeature('abac', {
up: async () => {
const { addSettings } = await import('../settings/abac');
const { createPermissions } = await import('../lib/abac');
await addSettings();
await createPermissions();
await import('../hooks/abac');
stopWatcher = settings.watch('ABAC_Enabled', async (value) => {
if (value && settings.get<string>('ABAC_PDP_Type') !== 'virtru') {
await LDAPEnterprise.syncUsersAbacAttributes(Users.findLDAPUsers());
}
});
async function configureVirtruPdpSync(): Promise<void> {
if (await cronJobs.has(VIRTRU_PDP_SYNC_JOB)) {
await cronJobs.remove(VIRTRU_PDP_SYNC_JOB);
}
const abacEnabled = settings.get('ABAC_Enabled');
const pdpType = settings.get<string>('ABAC_PDP_Type');
if (!abacEnabled || pdpType !== 'virtru') {
return;
}
const cronValue = settings.get<string>('ABAC_Virtru_Sync_Interval');
if (!cronValue || !isValidCron(cronValue)) {
return;
}
await cronJobs.add(VIRTRU_PDP_SYNC_JOB, cronValue, () => Abac.evaluateRoomMembership());
}
stopCronWatcher = settings.watchMultiple(
['ABAC_Enabled', 'ABAC_PDP_Type', 'ABAC_Virtru_Sync_Interval'],
() => void configureVirtruPdpSync(),
);
},
down: async () => {
stopWatcher?.();
stopCronWatcher?.();
if (await cronJobs.has(VIRTRU_PDP_SYNC_JOB)) {
await cronJobs.remove(VIRTRU_PDP_SYNC_JOB);
}
},
});
});

View File

@@ -0,0 +1,7 @@
import { Meteor } from 'meteor/meteor';
import { addSettings } from '../settings/contact-verification';
Meteor.startup(async () => {
await addSettings();
});

View File

@@ -0,0 +1,8 @@
import './contact-verification';
import './ldap';
import './oauth';
import './outlookCalendar';
import './saml';
import './videoConference';
import './voip';
import './abac';

View File

@@ -0,0 +1,127 @@
import { LDAPEnterprise } from '@rocket.chat/core-services';
import type { IImportUser, ILDAPEntry, IUser } from '@rocket.chat/core-typings';
import { cronJobs } from '@rocket.chat/cron';
import { License } from '@rocket.chat/license';
import { Settings } from '@rocket.chat/models';
import { isValidCron } from 'cron-validator';
import { Meteor } from 'meteor/meteor';
import { callbacks } from '../../../server/lib/callbacks';
import type { LDAPConnection } from '../../../server/lib/ldap/Connection';
import { logger } from '../../../server/lib/ldap/Logger';
import { settings } from '../../../server/settings';
import { LDAPEEManager } from '../lib/ldap/Manager';
import { addSettings, ldapIntervalValuesToCronMap } from '../settings/ldap';
Meteor.startup(async () => {
await License.onLicense('ldap-enterprise', async () => {
await addSettings();
// Configure background sync cronjob
function configureBackgroundSync(jobName: string, enableSetting: string, intervalSetting: string, cb: () => void): () => Promise<void> {
let lastSchedule: string;
return async function addCronJobDebounced(): Promise<void> {
if (settings.get('LDAP_Enable') !== true || settings.get(enableSetting) !== true) {
if (await cronJobs.has(jobName)) {
logger.info({ msg: 'Disabling LDAP Background Sync', jobName });
await cronJobs.remove(jobName);
}
return;
}
const settingValue = settings.get<string>(intervalSetting);
const schedule =
ldapIntervalValuesToCronMap[settingValue] ??
(isValidCron(settingValue) ? settingValue : ((await Settings.findOneById(intervalSetting))?.packageValue as string));
if (schedule) {
if (schedule !== lastSchedule && (await cronJobs.has(jobName))) {
await cronJobs.remove(jobName);
}
lastSchedule = schedule;
logger.info({ msg: 'Enabling LDAP Background Sync', jobName });
await cronJobs.add(jobName, schedule, cb);
}
};
}
const addCronJob = configureBackgroundSync('LDAP_Sync', 'LDAP_Background_Sync', 'LDAP_Background_Sync_Interval', () =>
LDAPEnterprise.sync(),
);
const addAvatarCronJob = configureBackgroundSync(
'LDAP_AvatarSync',
'LDAP_Background_Sync_Avatars',
'LDAP_Background_Sync_Avatars_Interval',
() => LDAPEnterprise.syncAvatars(),
);
const addLogoutCronJob = configureBackgroundSync(
'LDAP_AutoLogout',
'LDAP_Sync_AutoLogout_Enabled',
'LDAP_Sync_AutoLogout_Interval',
() => LDAPEnterprise.syncLogout(),
);
const addAbacCronJob = configureBackgroundSync(
'LDAP_AbacSync',
'LDAP_Background_Sync_ABAC_Attributes',
'LDAP_Background_Sync_ABAC_Attributes_Interval',
() => LDAPEnterprise.syncAbacAttributes(),
);
settings.watchMultiple(['LDAP_Background_Sync', 'LDAP_Background_Sync_Interval'], addCronJob);
settings.watchMultiple(['LDAP_Background_Sync_Avatars', 'LDAP_Background_Sync_Avatars_Interval'], addAvatarCronJob);
settings.watchMultiple(['LDAP_Sync_AutoLogout_Enabled', 'LDAP_Sync_AutoLogout_Interval'], addLogoutCronJob);
settings.watchMultiple(['LDAP_Background_Sync_ABAC_Attributes', 'LDAP_Background_Sync_ABAC_Attributes_Interval'], addAbacCronJob);
settings.watch('LDAP_Enable', async () => {
await addCronJob();
await addAvatarCronJob();
await addLogoutCronJob();
await addAbacCronJob();
});
settings.watch<string>('LDAP_Groups_To_Rocket_Chat_Teams', (value) => {
try {
LDAPEEManager.validateLDAPTeamsMappingChanges(value);
} catch (err) {
logger.error({ err });
}
});
settings.watch<string>('LDAP_ABAC_AttributeMap', (value) => {
try {
LDAPEEManager.validateLDAPABACAttributeMap(value);
} catch (err) {
logger.error({ err });
}
});
callbacks.add(
'mapLDAPUserData',
(userData: IImportUser, ldapUser?: ILDAPEntry) => {
if (!ldapUser) {
return;
}
LDAPEEManager.copyCustomFields(ldapUser, userData);
LDAPEEManager.copyActiveState(ldapUser, userData);
},
callbacks.priority.MEDIUM,
'mapLDAPCustomFields',
);
callbacks.add(
'onLDAPLogin',
async ({ user, ldapUser, isNewUser }: { user: IUser; ldapUser: ILDAPEntry; isNewUser: boolean }, ldap?: LDAPConnection) => {
if (!ldap) {
return;
}
await LDAPEEManager.advancedSyncForUser(ldap, user, isNewUser, ldapUser.dn);
},
callbacks.priority.MEDIUM,
'advancedLDAPSync',
);
});
});

View File

@@ -0,0 +1,99 @@
import type { IUser } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { Logger } from '@rocket.chat/logger';
import { Roles } from '@rocket.chat/models';
import { capitalize } from '@rocket.chat/tools';
import { callbacks } from '../../../server/lib/callbacks';
import { settings } from '../../../server/settings';
import { OAuthEEManager } from '../lib/oauth/Manager';
import { syncUserRoles } from '../lib/syncUserRoles';
interface IOAuthUserService {
serviceName: string;
serviceData: Record<string, any>;
user: IUser;
}
interface IOAuthUserIdentity {
serviceName: string;
identity: Record<string, any>;
user: IUser;
}
interface IOAuthSettings {
mapChannels: boolean;
mergeRoles: boolean;
rolesToSync: string;
rolesClaim: string;
groupsClaim: string;
channelsAdmin: string;
channelsMap: string;
}
const logger = new Logger('EECustomOAuth');
function getOAuthSettings(serviceName: string): IOAuthSettings {
return {
mapChannels: settings.get<boolean>(`Accounts_OAuth_Custom-${serviceName}-map_channels`),
mergeRoles: settings.get<boolean>(`Accounts_OAuth_Custom-${serviceName}-merge_roles`),
rolesToSync: settings.get<string>(`Accounts_OAuth_Custom-${serviceName}-roles_to_sync`),
rolesClaim: settings.get<string>(`Accounts_OAuth_Custom-${serviceName}-roles_claim`),
groupsClaim: settings.get<string>(`Accounts_OAuth_Custom-${serviceName}-groups_claim`),
channelsAdmin: settings.get<string>(`Accounts_OAuth_Custom-${serviceName}-channels_admin`),
channelsMap: settings.get<string>(`Accounts_OAuth_Custom-${serviceName}-groups_channel_map`),
};
}
function getChannelsMap(channelsMap: string): Record<string, any> | undefined {
channelsMap = (channelsMap || '{}').trim();
try {
return JSON.parse(channelsMap);
} catch (err) {
logger.error({ msg: 'Unexpected error', err });
}
}
await License.onLicense('oauth-enterprise', () => {
callbacks.add('afterProcessOAuthUser', async (auth: IOAuthUserService) => {
auth.serviceName = capitalize(auth.serviceName);
const settings = getOAuthSettings(auth.serviceName);
if (settings.mapChannels) {
const channelsMap = getChannelsMap(settings.channelsMap);
await OAuthEEManager.mapSSOGroupsToChannels(auth.user, auth.serviceData, settings.groupsClaim, channelsMap, settings.channelsAdmin);
}
if (settings.mergeRoles) {
await OAuthEEManager.updateRolesFromSSO(
auth.user,
auth.serviceData,
settings.rolesClaim,
settings.rolesToSync.split(',').map((role) => role.trim()),
);
}
});
callbacks.add('afterValidateNewOAuthUser', async (auth: IOAuthUserIdentity) => {
auth.serviceName = capitalize(auth.serviceName);
const settings = getOAuthSettings(auth.serviceName);
if (settings.mapChannels) {
const channelsMap = getChannelsMap(settings.channelsMap);
await OAuthEEManager.mapSSOGroupsToChannels(auth.user, auth.identity, settings.groupsClaim, channelsMap, settings.channelsAdmin);
}
if (settings.mergeRoles) {
const rolesFromSSO = await OAuthEEManager.mapRolesFromSSO(auth.identity, settings.rolesClaim);
const mappedRoles = (await Roles.findInIdsOrNames(rolesFromSSO).toArray()).map((role) => role._id);
const rolesToSync = settings.rolesToSync.split(',').map((role) => role.trim());
const allowedRoles = (await Roles.findInIdsOrNames(rolesToSync).toArray()).map((role) => role._id);
await syncUserRoles(auth.user._id, mappedRoles, {
allowedRoles,
});
}
});
});

View File

@@ -0,0 +1,14 @@
import { Calendar } from '@rocket.chat/core-services';
import { License } from '@rocket.chat/license';
import { Meteor } from 'meteor/meteor';
import { addSettings } from '../settings/outlookCalendar';
Meteor.startup(() =>
License.onLicense('outlook-calendar', async () => {
addSettings();
await Calendar.setupNextNotification();
await Calendar.setupNextStatusChange();
}),
);

View File

@@ -0,0 +1,73 @@
import { License } from '@rocket.chat/license';
import { Roles, Users } from '@rocket.chat/models';
import { ensureArray } from '../../../lib/utils/arrayUtils';
import type { ISAMLUser } from '../../../server/lib/saml/definition/ISAMLUser';
import { SAMLUtils } from '../../../server/lib/saml/lib/Utils';
import { settings } from '../../../server/settings';
import { addSettings } from '../settings/saml';
await License.onLicense('saml-enterprise', () => {
SAMLUtils.events.on('mapUser', async ({ profile, userObject }: { profile: Record<string, any>; userObject: ISAMLUser }) => {
const roleAttributeName = settings.get('SAML_Custom_Default_role_attribute_name') as string;
const roleAttributeSync = settings.get('SAML_Custom_Default_role_attribute_sync');
if (!roleAttributeSync) {
return;
}
if (roleAttributeName && profile[roleAttributeName]) {
let value = profile[roleAttributeName] || '';
if (typeof value === 'string') {
value = value.split(',');
}
const savedRoles = await Roles.findInIdsOrNames(ensureArray<string>(value)).toArray();
userObject.roles = savedRoles.map((role) => role._id);
}
});
SAMLUtils.events.on('loadConfigs', (service: string, configs: Record<string, any>): void => {
// Include ee settings on the configs object so that they can be copied to the login service too
const rawCustomAuthnContext = settings.get(`${service}_custom_authn_context`);
const customAuthnContext = typeof rawCustomAuthnContext === 'string' ? rawCustomAuthnContext.trim() : undefined;
Object.assign(configs, {
...(customAuthnContext !== undefined && { customAuthnContext }),
authnContextComparison: settings.get(`${service}_authn_context_comparison`),
identifierFormat: settings.get(`${service}_identifier_format`),
nameIDPolicyTemplate: settings.get(`${service}_NameId_template`),
authnContextTemplate: settings.get(`${service}_AuthnContext_template`),
authRequestTemplate: settings.get(`${service}_AuthRequest_template`),
logoutResponseTemplate: settings.get(`${service}_LogoutResponse_template`),
logoutRequestTemplate: settings.get(`${service}_LogoutRequest_template`),
metadataCertificateTemplate: settings.get(`${service}_MetadataCertificate_template`),
metadataTemplate: settings.get(`${service}_Metadata_template`),
});
});
SAMLUtils.events.on('updateCustomFields', async (loginResult: Record<string, any>, updatedUser: { userId: string; token: string }) => {
const userDataCustomFieldMap = settings.get('SAML_Custom_Default_user_data_custom_fieldmap') as string;
const customMap: Record<string, any> = JSON.parse(userDataCustomFieldMap);
const customFieldsList: Record<string, any> = {};
for (const spCustomFieldName in customMap) {
if (!customMap.hasOwnProperty(spCustomFieldName)) {
continue;
}
const customAttribute = customMap[spCustomFieldName];
const value = SAMLUtils.getProfileValue(loginResult.profile, {
fieldName: spCustomFieldName,
});
customFieldsList[customAttribute] = value;
}
await Users.updateCustomFieldsById(updatedUser.userId, customFieldsList);
});
});
// For setting creation we add the listener first because the event is emmited during startup
SAMLUtils.events.on('addSettings', (name: string): void | Promise<void> => License.onLicense('saml-enterprise', () => addSettings(name)));

Some files were not shown because too many files have changed in this diff Show More