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
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (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

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,
});
},
},
);