Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/sharp-lemons-refuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": patch
"@rocket.chat/rest-typings": patch
---

Add OpenAPI support for the Rocket.Chat dm.close/im.close API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation.
121 changes: 85 additions & 36 deletions apps/meteor/app/api/server/v1/im.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,21 @@ type DmDeleteProps =
username: string;
};

type DmCloseProps = {
roomId: string;
userId: string;
};

const DmClosePropsSchema = {
type: 'object',
properties: {
roomId: { type: 'string' },
userId: { type: 'string' },
},
required: ['roomId', 'userId'],
additionalProperties: false,
};

const isDmDeleteProps = ajv.compile<DmDeleteProps>({
oneOf: [
{
Expand All @@ -124,6 +139,8 @@ const isDmDeleteProps = ajv.compile<DmDeleteProps>({
],
});

const isDmCloseProps = ajv.compile<DmCloseProps>(DmClosePropsSchema);

const dmDeleteEndpointsProps = {
authRequired: true,
body: isDmDeleteProps,
Expand All @@ -144,6 +161,41 @@ const dmDeleteEndpointsProps = {
},
} as const;

const dmCloseEndpointsProps = {
authRequired: true,
body: isDmCloseProps,
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
// TODO: The 403 Forbidden response is not handled as well as 400 responses.
// Currently using `never` as a placeholder type. Replace it with the correct
// schema once proper 403 error handling is implemented.
403: ajv.compile<never>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [false] },
status: { type: 'string' },
message: { type: 'string' },
error: { type: 'string' },
errorType: { type: 'string' },
},
required: ['success'],
additionalProperties: false,
}),
Comment on lines +173 to +184
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

🧩 Analysis chain

Replace placeholder 403 response with proper ForbiddenErrorResponse type.

The comment indicates this is a placeholder implementation. Since ForbiddenErrorResponse is now exported from rest-typings, use it instead of the never type with a manual schema.

Apply this fix to use the proper type:

+import {
+	ForbiddenErrorResponse,
+	// ... other imports
+} from '@rocket.chat/rest-typings';

 // In dmCloseEndpointsProps:
-		// TODO: The 403 Forbidden response is not handled as well as 400 responses.
-		//       Currently using `never` as a placeholder type. Replace it with the correct
-		//       schema once proper 403 error handling is implemented.
-		403: ajv.compile<never>({
-			type: 'object',
-			properties: {
-				success: { type: 'boolean', enum: [false] },
-				status: { type: 'string' },
-				message: { type: 'string' },
-				error: { type: 'string' },
-				errorType: { type: 'string' },
-			},
-			required: ['success'],
-			additionalProperties: false,
-		}),
+		403: ajv.compile<ForbiddenErrorResponse>({
+			type: 'object',
+			properties: {
+				success: { type: 'boolean', enum: [false] },
+				status: { type: 'string' },
+				message: { type: 'string' },
+				error: { type: 'string' },
+				errorType: { type: 'string' },
+			},
+			required: ['success'],
+			additionalProperties: false,
+		}),

🌐 Web query:

What is the correct way to handle 403 Forbidden responses in OpenAPI with TypeScript and AJV validation?

💡 Result:

Short answer

  • Declare a dedicated 403 response schema in your OpenAPI operation (or a shared component and/or default error schema). Then validate the actual response body against the schema for the response's HTTP status (not against the 200 schema). Use your runtime validator or AJV to pick the schema keyed by status and validate that one. This prevents a 403 being forced to match the success (200/201) schema. [1][2][3]

Why

  • OpenAPI responses are indexed by status code (and default). A 403 must have its own entry if you expect a structured body for that status; otherwise the validator has no correct schema to check. Many OpenAPI response validators validate responses only against the schema for the returned status (or default). Configure your validator to do that. [1][2][4]

Minimal examples

  1. OpenAPI fragment (YAML)
    components:
    schemas:
    ErrorResponse:
    type: object
    properties:
    code:
    type: string
    message:
    type: string
    required: [code, message]

paths:
/items/{id}:
get:
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Item'
'403':
description: Forbidden
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
default:
description: Other errors
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'

  1. TypeScript + AJV runtime approach (conceptual)
  • Generate or extract JSON Schemas for responses (per operation/status).
  • At runtime, after receiving response:
    • const schema = responseSchemas[operationId][String(status) || 'default'];
    • const valid = ajv.validate(schema, body);
    • if (!valid) handle validation error (log/throw/transform).
      This makes sure a 403 response is validated only against the 403 ErrorResponse schema. [3][5]

Practical notes / gotchas

  • Place shared error structure in components/schemas and reference it for 4xx/5xx entries. [1]
  • If using a middleware (express-openapi-validator, openapi-response-validator, codegen libraries), enable response validation and ensure it validates by status. Many libraries offer validateResponses and options to customize behavior. [4][6]
  • If you see a 403 failing against the 200 schema, you’re either (a) missing a 403/default response schema in the OpenAPI doc, or (b) your validator is configured incorrectly (validating against the wrong schema or only the 200). Fix the OpenAPI doc or validator config. [4][6]

References

  1. OpenAPI errors / response guidance. [Speakeasy / OpenAPI errors]. [2]
  2. openapi-typescript-validator (generate TS + AJV decoders from OpenAPI schemas). [openapi-typescript-validator README]. [3]
  3. openapi-response-validator (validates responses per status schema). [openapi-response-validator]. [5]
  4. express-openapi-validator docs — validateResponses option and behavior. [express-openapi-validator documentation]. [6]

If you want, I can: (a) inspect your OpenAPI file and point out missing 403/default entries, or (b) produce a small TypeScript/AJV code sample wired to your generated schemas.


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Searching repo for 'ForbiddenErrorResponse'..."
rg -n --hidden --no-ignore -S "ForbiddenErrorResponse" || true

echo
echo "Searching repo for imports from '@rocket.chat/rest-typings'..."
rg -n --hidden --no-ignore -S "@rocket.chat/rest-typings" || true

TARGET="apps/meteor/app/api/server/v1/im.ts"
echo
if [ -f "$TARGET" ]; then
  echo "Showing $TARGET (lines 150-200):"
  nl -ba -w4 -s": " "$TARGET" | sed -n '150,200p'
else
  echo "$TARGET not found"
fi

echo
echo "Searching for occurrences of 'ajv.compile<never>'..."
rg -n --hidden --no-ignore -S "ajv.compile<never>" || true

echo
echo "Searching for '403: ajv.compile' patterns..."
rg -n --hidden --no-ignore -S "403: ajv.compile" || true

echo
echo "Searching package.json files for '@rocket.chat/rest-typings'..."
rg -n --hidden --no-ignore -S "\"@rocket.chat/rest-typings\"" || true

Length of output: 227718


Replace inline 403 placeholder with exported validateForbiddenErrorResponse

Import validateForbiddenErrorResponse from '@rocket.chat/rest-typings' and replace the ajv.compile 403 block in apps/meteor/app/api/server/v1/im.ts (lines ~173–184) with:
403: validateForbiddenErrorResponse,

🤖 Prompt for AI Agents
In apps/meteor/app/api/server/v1/im.ts around lines 173 to 184, replace the
inline AJV schema used for the 403 response with the exported validator: import
validateForbiddenErrorResponse from '@rocket.chat/rest-typings' (add the import
at the top of the file alongside other imports) and change the 403 entry from
the ajv.compile<never>({...}) block to 403: validateForbiddenErrorResponse,
ensuring you remove the inline schema block and update imports accordingly.

200: ajv.compile<void>({
type: 'object',
properties: {
success: {
type: 'boolean',
enum: [true],
},
},
required: ['success'],
additionalProperties: false,
}),
},
} as const;

const dmDeleteAction = <Path extends string>(_path: Path): TypedAction<typeof dmDeleteEndpointsProps, Path> =>
async function action() {
const { room } = await findDirectMessageRoom(this.bodyParams, this.userId);
Expand All @@ -160,51 +212,48 @@ const dmDeleteAction = <Path extends string>(_path: Path): TypedAction<typeof dm
return API.v1.success();
};

const dmEndpoints = API.v1
.post('im.delete', dmDeleteEndpointsProps, dmDeleteAction('im.delete'))
.post('dm.delete', dmDeleteEndpointsProps, dmDeleteAction('dm.delete'));
const dmCloseAction = <Path extends string>(_path: Path): TypedAction<typeof dmCloseEndpointsProps, Path> =>
async function action() {
const { roomId } = this.bodyParams;
if (!roomId) {
throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" is required');
}

API.v1.addRoute(
['dm.close', 'im.close'],
{ authRequired: true },
{
async post() {
const { roomId } = this.bodyParams;
if (!roomId) {
throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" is required');
let subscription;

const roomExists = !!(await Rooms.findOneById(roomId));
if (!roomExists) {
// even if the room doesn't exist, we should allow the user to close the subscription anyways
subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId);
} else {
const canAccess = await canAccessRoomIdAsync(roomId, this.userId);
if (!canAccess) {
return API.v1.forbidden();
}

let subscription;
const { subscription: subs } = await findDirectMessageRoom({ roomId }, this.userId);

const roomExists = !!(await Rooms.findOneById(roomId));
if (!roomExists) {
// even if the room doesn't exist, we should allow the user to close the subscription anyways
subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId);
} else {
const canAccess = await canAccessRoomIdAsync(roomId, this.userId);
if (!canAccess) {
return API.v1.forbidden();
}

const { subscription: subs } = await findDirectMessageRoom({ roomId }, this.userId);
subscription = subs;
}

subscription = subs;
}
if (!subscription) {
return API.v1.failure(`The user is not subscribed to the room`);
}

if (!subscription) {
return API.v1.failure(`The user is not subscribed to the room`);
}
if (!subscription.open) {
return API.v1.failure(`The direct message room, is already closed to the sender`);
}

if (!subscription.open) {
return API.v1.failure(`The direct message room, is already closed to the sender`);
}
await hideRoomMethod(this.userId, roomId);

await hideRoomMethod(this.userId, roomId);
return API.v1.success();
};

Comment on lines +215 to 251
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Logic issue: userId parameter is never used in dmCloseAction.

The DmCloseProps type includes a userId field, but dmCloseAction never uses it. The action always uses this.userId (the authenticated user) instead. This mismatch could lead to confusion.

Either:

  1. Remove the unused userId from DmCloseProps if it's not needed:
 type DmCloseProps = {
 	roomId: string;
-	userId: string;
 };
  1. Or clarify the intent - should this support closing DMs on behalf of other users (with proper permission checks)?

Committable suggestion skipped: line range outside the PR's diff.

return API.v1.success();
},
},
);
const dmEndpoints = API.v1
.post('im.delete', dmDeleteEndpointsProps, dmDeleteAction('im.delete'))
.post('dm.delete', dmDeleteEndpointsProps, dmDeleteAction('dm.delete'))
.post('dm.close', dmCloseEndpointsProps, dmCloseAction('dm.close'))
.post('im.close', dmCloseEndpointsProps, dmCloseAction('im.close'));

// https://github.com/RocketChat/Rocket.Chat/pull/9679 as reference
API.v1.addRoute(
Expand Down
2 changes: 1 addition & 1 deletion packages/rest-typings/src/v1/Ajv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const UnauthorizedErrorResponseSchema = {

export const validateUnauthorizedErrorResponse = ajv.compile<UnauthorizedErrorResponse>(UnauthorizedErrorResponseSchema);

type ForbiddenErrorResponse = {
export type ForbiddenErrorResponse = {
success: false;
status?: string;
message?: string;
Expand Down
22 changes: 0 additions & 22 deletions packages/rest-typings/src/v1/dm/DmCloseProps.ts

This file was deleted.

1 change: 0 additions & 1 deletion packages/rest-typings/src/v1/dm/dm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type { ImEndpoints } from './im';

export type DmEndpoints = {
'/v1/dm.create': ImEndpoints['/v1/im.create'];
'/v1/dm.close': ImEndpoints['/v1/im.close'];
'/v1/dm.counters': ImEndpoints['/v1/im.counters'];
'/v1/dm.files': ImEndpoints['/v1/im.files'];
'/v1/dm.history': ImEndpoints['/v1/im.history'];
Expand Down
7 changes: 0 additions & 7 deletions packages/rest-typings/src/v1/dm/im.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { IMessage, IRoom, IUser, IUploadWithUser } from '@rocket.chat/core-typings';

import type { DmCloseProps } from './DmCloseProps';
import type { DmCreateProps } from './DmCreateProps';
import type { DmFileProps } from './DmFileProps';
import type { DmHistoryProps } from './DmHistoryProps';
Expand All @@ -16,12 +15,6 @@ export type ImEndpoints = {
room: IRoom & { rid: IRoom['_id'] };
};
};
'/v1/im.close': {
POST: (params: DmCloseProps) => void;
};
'/v1/im.kick': {
POST: (params: DmCloseProps) => void;
};
'/v1/im.leave': {
POST: (params: DmLeaveProps) => void;
};
Expand Down
Loading