From 5de3f38c06e3932de10e8d21b19d3eac716ee61f Mon Sep 17 00:00:00 2001 From: amitb0ra Date: Sun, 15 Mar 2026 22:05:07 +0000 Subject: [PATCH 1/3] refactor: Migrate users.register endpoint to OpenAPI with AJV validation --- .changeset/migrate-users-register-openapi.md | 7 + apps/meteor/app/api/server/v1/users.ts | 189 +++++++++++------- packages/rest-typings/src/v1/users.ts | 8 - .../src/v1/users/UserRegisterParamsPOST.ts | 47 ----- .../src/users-register.d.ts | 19 ++ 5 files changed, 147 insertions(+), 123 deletions(-) create mode 100644 .changeset/migrate-users-register-openapi.md delete mode 100644 packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts create mode 100644 packages/web-ui-registration/src/users-register.d.ts diff --git a/.changeset/migrate-users-register-openapi.md b/.changeset/migrate-users-register-openapi.md new file mode 100644 index 0000000000000..9a1f87217ed0a --- /dev/null +++ b/.changeset/migrate-users-register-openapi.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/rest-typings": minor +"@rocket.chat/web-ui-registration": minor +--- + +Add OpenAPI support for the `users.register` API endpoint by migrating to chained route definition with AJV body and response validation. diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 50c65abcd8d12..8a85c8a87cbb9 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -8,7 +8,6 @@ import { isUsersInfoParamsGetProps, isUsersListStatusProps, isUsersSendWelcomeEmailProps, - isUserRegisterParamsPOST, isUserLogoutParamsPOST, isUsersListTeamsProps, isUsersAutocompleteProps, @@ -660,73 +659,6 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'users.register', - { - authRequired: false, - rateLimiterOptions: { - numRequestsAllowed: settings.get('Rate_Limiter_Limit_RegisterUser') ?? 1, - intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default') ?? 60000, - }, - validateParams: isUserRegisterParamsPOST, - }, - { - async post() { - const { secret: secretURL, ...params } = this.bodyParams; - - if (this.userId) { - return API.v1.failure('Logged in users can not register again.'); - } - - if (params.name && !validateNameChars(params.name)) { - return API.v1.failure('Name contains invalid characters'); - } - - if (!validateUsername(this.bodyParams.username)) { - return API.v1.failure(`The username provided is not valid`); - } - - if (!(await checkUsernameAvailability(this.bodyParams.username))) { - return API.v1.failure('Username is already in use'); - } - if (!(await checkEmailAvailability(this.bodyParams.email))) { - return API.v1.failure('Email already exists'); - } - if (this.bodyParams.customFields) { - try { - await validateCustomFields(this.bodyParams.customFields); - } catch (e) { - return API.v1.failure(e); - } - } - - // Register the user - const userId = await registerUser({ - ...params, - ...(secretURL && { secretURL }), - }); - - if (typeof userId !== 'string') { - return API.v1.failure('Error creating user'); - } - - // Now set their username - const { fields } = await this.parseJsonQuery(); - await setUsernameWithValidation(userId, this.bodyParams.username); - - const user = await Users.findOneById(userId, { projection: fields }); - if (!user) { - return API.v1.failure('User not found'); - } - - if (this.bodyParams.customFields) { - await saveCustomFields(userId, this.bodyParams.customFields); - } - - return API.v1.success({ user }); - }, - }, -); API.v1.addRoute( 'users.resetAvatar', @@ -753,6 +685,52 @@ API.v1.addRoute( }, ); +type UserRegisterParamsPOST = { + username: string; + name?: string; + email: string; + pass: string; + secret?: string; + reason?: string; + customFields?: object; +}; + +const UserRegisterParamsPostSchema = { + type: 'object', + properties: { + username: { + type: 'string', + }, + + name: { + type: 'string', + nullable: true, + }, + email: { + type: 'string', + }, + pass: { + type: 'string', + }, + secret: { + type: 'string', + nullable: true, + }, + reason: { + type: 'string', + nullable: true, + }, + customFields: { + type: 'object', + nullable: true, + }, + }, + required: ['username', 'email', 'pass'], + additionalProperties: false, +}; + +const isUserRegisterParamsPOST = ajv.compile(UserRegisterParamsPostSchema); + const usersEndpoints = API.v1 .post( 'users.createToken', @@ -878,6 +856,81 @@ const usersEndpoints = API.v1 return API.v1.success({ suggestions }); }, + ) + .post( + 'users.register', + { + authRequired: false, + rateLimiterOptions: { + numRequestsAllowed: settings.get('Rate_Limiter_Limit_RegisterUser') ?? 1, + intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default') ?? 60000, + }, + body: isUserRegisterParamsPOST, + response: { + 200: ajv.compile<{ user: IUser }>({ + type: 'object', + properties: { + user: { $ref: '#/components/schemas/IUser' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['user', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + }, + }, + async function action() { + const { secret: secretURL, ...params } = this.bodyParams; + + if (this.userId) { + return API.v1.failure('Logged in users can not register again.'); + } + + if (params.name && !validateNameChars(params.name)) { + return API.v1.failure('Name contains invalid characters'); + } + + if (!validateUsername(this.bodyParams.username)) { + return API.v1.failure(`The username provided is not valid`); + } + + if (!(await checkUsernameAvailability(this.bodyParams.username))) { + return API.v1.failure('Username is already in use'); + } + if (!(await checkEmailAvailability(this.bodyParams.email))) { + return API.v1.failure('Email already exists'); + } + if (this.bodyParams.customFields) { + try { + await validateCustomFields(this.bodyParams.customFields); + } catch (e) { + return API.v1.failure(e instanceof Error ? e.message : String(e)); + } + } + + // Register the user + const userId = await registerUser({ + ...params, + ...(secretURL && { secretURL }), + }); + + if (typeof userId !== 'string') { + return API.v1.failure('Error creating user'); + } + + await setUsernameWithValidation(userId, this.bodyParams.username); + + const user = await Users.findOneById(userId, { projection: { services: 0, inviteToken: 0 } }); + if (!user) { + return API.v1.failure('User not found'); + } + + if (this.bodyParams.customFields) { + await saveCustomFields(userId, this.bodyParams.customFields); + } + + return API.v1.success({ user }); + }, ); API.v1.addRoute( diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index 565620d31ba5e..10eb4b7c19b05 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -6,7 +6,6 @@ import type { PaginatedResult } from '../helpers/PaginatedResult'; import type { UserCreateParamsPOST } from './users/UserCreateParamsPOST'; import type { UserDeactivateIdleParamsPOST } from './users/UserDeactivateIdleParamsPOST'; import type { UserLogoutParamsPOST } from './users/UserLogoutParamsPOST'; -import type { UserRegisterParamsPOST } from './users/UserRegisterParamsPOST'; import type { UserSetActiveStatusParamsPOST } from './users/UserSetActiveStatusParamsPOST'; import type { UsersAutocompleteParamsGET } from './users/UsersAutocompleteParamsGET'; import type { UsersInfoParamsGet } from './users/UsersInfoParamsGet'; @@ -333,12 +332,6 @@ export type UsersEndpoints = { }; }; - '/v1/users.register': { - POST: (params: UserRegisterParamsPOST) => { - user: Partial; - }; - }; - '/v1/users.logout': { POST: (params: UserLogoutParamsPOST) => { message: string; @@ -376,7 +369,6 @@ export * from './users/UserDeactivateIdleParamsPOST'; export * from './users/UsersInfoParamsGet'; export * from './users/UsersListStatusParamsGET'; export * from './users/UsersSendWelcomeEmailParamsPOST'; -export * from './users/UserRegisterParamsPOST'; export * from './users/UserLogoutParamsPOST'; export * from './users/UsersListTeamsParamsGET'; export * from './users/UsersAutocompleteParamsGET'; diff --git a/packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts b/packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts deleted file mode 100644 index 1d3734126e450..0000000000000 --- a/packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ajv } from '../Ajv'; - -export type UserRegisterParamsPOST = { - username: string; - name?: string; - email: string; - pass: string; - secret?: string; - reason?: string; - customFields?: object; -}; - -const UserRegisterParamsPostSchema = { - type: 'object', - properties: { - username: { - type: 'string', - }, - - name: { - type: 'string', - nullable: true, - }, - email: { - type: 'string', - }, - pass: { - type: 'string', - }, - secret: { - type: 'string', - nullable: true, - }, - reason: { - type: 'string', - nullable: true, - }, - customFields: { - type: 'object', - nullable: true, - }, - }, - required: ['username', 'email', 'pass'], - additionalProperties: false, -}; - -export const isUserRegisterParamsPOST = ajv.compile(UserRegisterParamsPostSchema); diff --git a/packages/web-ui-registration/src/users-register.d.ts b/packages/web-ui-registration/src/users-register.d.ts new file mode 100644 index 0000000000000..f64b7bf729b56 --- /dev/null +++ b/packages/web-ui-registration/src/users-register.d.ts @@ -0,0 +1,19 @@ +import type { IUser } from '@rocket.chat/core-typings'; + +declare module '@rocket.chat/rest-typings' { + interface Endpoints { + '/v1/users.register': { + POST: (params: { + username: string; + name?: string; + email: string; + pass: string; + secret?: string; + reason?: string; + customFields?: object; + }) => { + user: IUser; + }; + }; + } +} From f277d000465837ca5e35b445357d1ec29842c785 Mon Sep 17 00:00:00 2001 From: amitb0ra Date: Mon, 16 Mar 2026 01:48:32 +0000 Subject: [PATCH 2/3] fix: Remove inactiveReason from user object if null --- apps/meteor/app/api/server/v1/users.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 8a85c8a87cbb9..415813a6e3351 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -925,6 +925,10 @@ const usersEndpoints = API.v1 return API.v1.failure('User not found'); } + if ((user as Record).inactiveReason === null) { + delete (user as Record).inactiveReason; + } + if (this.bodyParams.customFields) { await saveCustomFields(userId, this.bodyParams.customFields); } From dfe39a2e51d6cdb67c4a68da791731d56bce180d Mon Sep 17 00:00:00 2001 From: amitb0ra Date: Mon, 16 Mar 2026 22:16:02 +0000 Subject: [PATCH 3/3] fix: Revert projection change in users.register migration - Restore parseJsonQuery() fields projection instead of hardcoded exclusion - Fix inactiveReason null cleanup to use proper TypeScript cast (as unknown as) - Keep error message extraction (required for 400 response schema typing) --- apps/meteor/app/api/server/v1/users.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 415813a6e3351..7730d5c8685ef 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -918,15 +918,17 @@ const usersEndpoints = API.v1 return API.v1.failure('Error creating user'); } + // Now set their username + const { fields } = await this.parseJsonQuery(); await setUsernameWithValidation(userId, this.bodyParams.username); - const user = await Users.findOneById(userId, { projection: { services: 0, inviteToken: 0 } }); + const user = await Users.findOneById(userId, { projection: fields }); if (!user) { return API.v1.failure('User not found'); } - if ((user as Record).inactiveReason === null) { - delete (user as Record).inactiveReason; + if ((user as unknown as Record).inactiveReason === null) { + delete (user as unknown as Record).inactiveReason; } if (this.bodyParams.customFields) {