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

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,463 @@
# ADR 0001 — Accessor logic lives in the base-runtime, not the host
## Status
**Accepted — implemented.** All phases landed. Supersedes the `apps-accessor-consolidation` proposal.
- **Date:** 2026-07
- **Scope:** `packages/apps` (host) and `packages/apps/base-runtime` (subprocess runtime)
## Decision
1. The base-runtime is the **single source of truth** for accessor behavior.
2. The `accessor:*` message category — and `handleAccessorMessage` with it — is **eliminated**;
`bridges:*` is the only host-bound RPC category originated by accessors.
3. Registration accessors, whose side effects had no bridge `do*`, ride `bridges:*` through one new
internal host bridge, `AppResourceBridge`.
**Drift rule.** Where the runtime and host implementations had diverged: **merge non-conflicting logic;
on genuine conflict the runtime supersedes the host.** The runtime implementations are what apps
actually experienced in production (host copies were unreachable from the subprocess). Two refinements:
- **Direction-aware.** For **MOVE** accessors the host was authoritative (the app hit it via the
proxy), so merging preserves live behavior. For **RECONCILE** accessors the runtime was
authoritative, so folding in *host-only* logic **adds observable behavior** — a deliberate, tested
change, never a neutral reconciliation.
- **Drift is classified before adoption.** Runtime-is-superset → adopt silently. Runtime *dropped or
changed* observable behavior → requires a justification that the drop is intentional plus a test
pinning the resulting shape, so the migration cannot immortalize an accidental regression.
## End state
`BaseRuntimeSubprocessController` handles exactly one app-originated RPC category — `bridges:*` — with
a single dispatcher and a single permission model. The `@rocket.chat/apps` host contains no accessor
implementation and no accessor manager (`src/server/accessors/` and `AppAccessorManager` are deleted);
the only accessor-shaped host code is the bridge layer (`AppBridges`/`do*`) plus `AppResourceBridge`.
Every accessor an app uses is built and resolved inside `packages/apps/base-runtime`.
## Follow-ups
Out of scope here; unblocked or motivated by this work.
1. **Bridge-level input hardening** — move load-bearing caps into the bridge `do*` wrappers (the real
trust boundary). Caps like `getMessages` ≤ 100 and `removeUsersFromRoom` ≤ 50 were *already*
bypassable before this migration: app code runs via `new Function` in the runtime's own JS realm
(globals are shadowed, not a security boundary — see `construct.ts`) and can emit arbitrary
`bridges:*` messages. This work introduced **no new exposure**, it only made the advisory-ness
visible. Independent security item, non-blocking.
2. **Replace the restart registration guard** with an explicit `reinitialize` request so the subprocess
knows not to re-send registrations, removing controller-state-dependent message dropping.
3. **`messenger.sendRequest` timeout** — the runtime-side TODO is more prominent now that all accessor
traffic flows through it.
4. **`createProcessorId` suffix check** (`SchedulerModify`) — job-id namespacing uses `includes`
(substring) instead of `endsWith` (suffix), so an appId substring in the *middle* of a job id makes
it skip the suffix, risking collisions and jobs `cancelJob` can't reach. Flagged in review but not
fixed inside the migration: the port was a faithful copy of the host code, and fixing only the
runtime copy would have re-introduced the drift this ADR removes. The host copy is gone now, so it
can land standalone with a test.
5. **Consolidated host↔subprocess protocol/SDK** — one typed manifest of every host-bound method and
its accepted params (the APP_ID bucket table is the seed), replacing hand-rolled message strings and
per-param normalization judgment. Also where bucket C's nested-identity gap gets closed (host
substitutes nested `appId` fields, or `HttpBridge.doCall` ignores the payload `appId` and uses the
connection-known id).
6. **Deferred review findings** — reviewers flagged the items below on ported accessors; in every case
the port was byte-identical to its host original, so the flag is *pre-existing* behavior, not a
regression. Deferred for the same reason as 4, and likewise unblocked now.
*Latent behavior (identical in host):*
- `UploadCreator.uploadBuffer` uses `Object.hasOwn(descriptor, 'user')` to decide whether to fetch
the app user; with `{ user: null }` (nullable per `IUploadDescriptor`) and no visitor token it
treats the user as present and sends `userId: undefined`. A value check (`!descriptor.user`) would
fall back to the app user.
- `RoomRead.getMessages` accepts `0`/negative `limit` — the guard only rejects `> 100`, unlike the
`1–100` checks in `getAllRooms`/`getUnreadByUser`.
- `RoomRead.getMessages` mutates the caller's `options` in place (`options.limit ??= 100`,
`options.showThreadMessages ??= true`). Fix = copy before defaulting.
- `ServerSettingRead.getOneById` casts the bridge result to `ISetting` with no null guard, so a
missing setting returns `null`/`undefined` despite the typed return; sibling `getValueById` throws.
- `UIController`'s deprecated surface APIs (`openModalView`, `updateModalView`,
`openContextualBarView`, `updateContextualBarView`) call the serializers directly, skipping the
`UIHelper.assignIds` block-ID scoping that `openSurfaceView`/`updateSurfaceView` apply, so legacy
interactions can emit un-scoped block IDs.
*Type/cosmetic (no runtime change):* `MessageRead.getSenderUser`/`getRoom` cast the bridge result
(`as IMessage`) and declare `Promise<IUser>`/`Promise<IRoom>`, so the `undefined` they return for a
missing message — and which `IMessageRead` does declare — is absent from their own signatures.
## Context
Accessors used to have two implementations. The subprocess got a thin proxy layer that forwarded
`accessor:*` JSON-RPC messages to the host, where `handleAccessorMessage` walked the *real* accessor
objects built by `AppAccessorManager` from `src/server/accessors/`. That split existed because, when
the Deno runtime was introduced, module-resolution conflicts made sharing code between subprocess and
host impractical. The constraint no longer held: base-runtime assumes Node module resolution, is
compiled by `tsc`, and is consumed by Deno as TypeScript source through an import map.
Three call paths coexisted:
| Path | Example | Mechanism |
| --- | --- | --- |
| **Pure proxy** | `read.getRoomReader().getById(id)` | `proxify()` sends `accessor:getReader:getRoomReader:getById`; host resolves it via `handleAccessorMessage` → `AppAccessorManager` → `RoomBridge.doGetById` |
| **Local + direct bridge call** | `modify.getCreator().finish(builder)` | runtime validates locally, then sends `bridges:getMessageBridge:doCreate` to the permission-wrapped bridge method |
| **Fully local** | `configurationExtend.http.provideDefaultHeader()` | state lives entirely in the subprocess |
Paths 2 and 3 were already the target, so the work was migrating path 1 into them and closing the gaps
that forced accessors to stay on path 1.
**Why `bridges:*` is sufficient security-wise.** Permission enforcement never lived in the accessors;
it lives in the host bridge `do*` wrappers (`MessageBridge.doCreate` checks
`AppPermissionManager.hasPermission(appId, AppPermissions.message.write)`). Three properties make that
channel a complete trust boundary: `handleBridgeMessage` only dispatches `do`-prefixed methods; any
param equal to the literal `'APP_ID'` is replaced host-side with the app's real id, so a subprocess
cannot impersonate another app; and `hasPermission(appId, …)` takes the appId explicitly, with no
call-stack or async-context magic that breaks when the caller leaves the host process. So moving
accessor logic into the subprocess **loses nothing permission-wise**, provided every side effect still
flows through a `do*` method. The corollary: accessor-level validation is *advisory* inside the sandbox
— but it already was, since a subprocess can emit arbitrary `bridges:*` messages (follow-up 1).
**Who consumed the host accessors.** Almost exclusively the RPC channel. The only load-bearing
non-RPC use was `AppListenerManager.executePostMessageSent` calling
`getReader(appId).getUserReader().getAppUser()` to gate `IPostMessageSentToBot`. `accessors/Http.ts`
reached back into `AppAccessorManager` for HTTP pre/post handlers (dead for subprocess apps);
`accessors/AppAccessors.ts` was never instantiated; and `AppApiManager`,
`AppSlashCommandManager`, `AppVideoConfProviderManager`, `AppOutboundCommunicationProviderManager`
threaded `this.accessors` into executors that ignored it (`_accessors`) — legacy in-process-era
signatures. Nothing in `apps/meteor` touched `AppAccessorManager`.
## Architecture
### `AppResourceBridge`
Every bridge gap reduces to one capability: *invoke an app-scoped operation on a host manager or the
app's own storage item*. One internal bridge lets them ride the existing permission-checked,
appId-substituting `bridges:*` channel:
```text
AppResourceBridge (host-side, internal — not part of the app-facing definition surface)
├── doProvideSetting(setting, appId) → ProxiedApp.getStorageItem() mutation (SettingsExtend)
├── doGetAppSetting(id, appId) → app.getStorageItem().settings[id] (SettingRead)
├── doUpdateAppSetting(setting, appId) → AppSettingsManager.updateAppSetting
├── doProvideSlashCommand(command, appId) → AppSlashCommandManager.addCommand
├── doModifySlashCommand(command, appId) → AppSlashCommandManager.modifyCommand
├── doEnableSlashCommand(name, appId) → AppSlashCommandManager.enableCommand
├── doDisableSlashCommand(name, appId) → AppSlashCommandManager.disableCommand
├── doProvideApi(api, appId) → AppApiManager.addApi
├── doListApis(appId) → AppApiManager.listApis
├── doRegisterProcessors(processors, appId) → AppSchedulerManager.registerProcessors
├── doRegisterActionButton(button, appId) → UIActionButtonManager.registerActionButton
├── doRegisterExternalComponent(component, appId) → AppExternalComponentManager.addExternalComponent
├── doProvideVideoConfProvider(provider, appId) → AppVideoConfProviderManager.addProvider
└── doRegisterOutboundProvider(provider, appId) → AppOutboundCommunicationProviderManager.addProvider
```
- **Placement and ownership.** The managers live in `packages/apps`, so the bridge does too. It is
*not* an abstract getter on `AppBridges` — that would force every orchestrator/host embedding to
implement it. `handleBridgeMessage` resolves names from `this.bridges` first, then from a dedicated
`getAppResourceBridge` lookup. The bridge is **stateless** (every method takes the `appId` and
delegates to an `AppManager`-owned manager), so `AppManager` constructs it once
(`getAppResourceBridge()`) and every `BaseRuntimeSubprocessController` shares that instance. The
`do*` gate and `'APP_ID'` substitution apply identically.
- **Permission and error behavior preserved, including throw-vs-silent.** Each method calls the same
manager the host accessor used: `AppVideoConfProviderManager`/`AppOutboundCommunicationProviderManager`
`addProvider` still **throw** `PermissionDeniedError`, `UIActionButtonManager.registerActionButton`
still logs-and-refuses silently, and conflict errors (`CommandAlreadyExistsError`,
`CommandHasAlreadyBeenTouchedError`, `PathAlreadyExistsError`, `VideoConfProviderAlreadyExistsError`)
still propagate as JSON-RPC errors.
- **The restart guard moved with it — via an explicit method set, not a name prefix.**
`handleAccessorMessage` used to short-circuit any `getConfigurationExtend` call to `success(null)`
while the controller is `restarting` (re-running `app:initialize` must not re-register resources, but
the subprocess must still rebuild its local `AppObjectRegistry` entries). That guard keyed on the
accessor *origin*, structurally capturing the whole registration surface. The bridge version
deliberately does **not** re-derive the boundary from `doProvide*`/`doRegister*` prefixes — a future
registration method named otherwise would double-register during restart, and a future
non-registration method matching the prefix would be silently dropped. Instead a static
`AppResourceBridge.REGISTRATION_METHODS` set enumerates exactly the suppressed names (registrations
only — not `doGetAppSetting`/`doUpdateAppSetting`/`doModifySlashCommand`/`doEnable*`/`doDisable*`/
`doListApis`), consulted by `handleBridgeMessage` while `state === 'restarting'`. Runtime `_proxy`
wrappers still stash live instances into `AppObjectRegistry` *before* the RPC, so local
re-registration works. Stopgap; follow-up 2 is the real fix.
### `bridgeCall`
Runtime accessors do not hand-roll message strings. `base-runtime/src/lib/bridges/bridgeCall.ts`
exposes one helper:
```ts
bridgeCall<T>(senderFn, bridge: BridgeName, method: `do${string}`, ...params: unknown[]): Promise<T>
```
It encodes `bridges:<bridge>:<method>`, unwraps `response.result`, and normalizes rejections through
`formatErrorResponse`. `BridgeName` is the union of host bridges reachable from the subprocess
(including `getAppResourceBridge`); the `` `do${string}` `` type mirrors the host gate. Accessors get a
`(request) => this.senderFn(request)` thunk so instance-level `senderFn` test stubs stay intercepted.
(An earlier iteration used a `RemoteBridges` proxy facade with a getter per bridge; `bridgeCall`
replaced it with the same guarantees and no `Proxy` layer.)
**It deliberately does not auto-inject `'APP_ID'`.** Identity is passed explicitly and positionally by
each accessor, exactly as the host accessor did, so a faithful copy keeps caller-identity params
carrying the sentinel and leaves app-supplied argument-appIds raw *by construction*. Centralizing
injection would be cleaner but would make the argument-appId exceptions the dangerous default.
### `APP_ID` sentinel everywhere
All bridge params denoting the *calling app* use the `'APP_ID'` literal, substituted host-side. This
closed the pre-existing inconsistency (drift 7) and removed the runtime's reliance on
`AppObjectRegistry.get('id')` for identity — it stays available for non-identity uses like
`UIHelper.assignIds` block-id prefixes and scheduler job-id suffixes.
This is a per-param judgment that fails in **both** directions, caught by neither the type-checker nor
a green suite: over-normalizing a genuine app-supplied argument-appId silently strips the app's ability
to pass any appId; under-normalizing a caller-identity param re-opens the impersonation gap. So every
`do*` appId param was audited into exactly one of three buckets — only A is normalized:
| Bucket | Params | Value sent |
| --- | --- | --- |
| **A** — caller identity, top-level positional | The trailing appId of every `Notifier`, `ModifyCreator`, `ModifyUpdater`, `ModifyExtender`, reader, and modifier bridge call (`doNotifyUser`, `doNotifyRoom`, `doTyping`, `doGetAppUser`, `doCreate*`, `doGetById`, `doUpdate`, …), and everything not listed in B/C | `'APP_ID'` sentinel |
| **B** — app-supplied *argument* appId | `ModerationBridge.doReport`, `.doDismissReportsByMessageId`, `.doDismissReportsByUserId` (`ModerationModify` ignores its constructor `_appId` and forwards the caller's arg); `UserBridge.doDeleteUsersCreatedByApp` (the id is the *target* app that created bot users); `UserBridge.doGetAppUser` (legacy param, removing it risks a break) | raw caller value |
| **C** — nested identity field | `HttpBridge.doCall`'s single payload param `{ appId, method, url, request }` — `appId` is a *field*, and the host substitution maps only top-level positional params, so the sentinel would travel through unrewritten | resolved `AppObjectRegistry.get('id')` |
Before this work every site sent the raw resolved id except `ModifyCreator._finishMessage`'s
`doGetAppUser` — the lone correct one, and the drift that motivated the normalization. Host-observable
behavior is unchanged (the host still ends up with the real id); the wire now carries the sentinel and
the impersonation gap is closed for buckets A and B. **Audit rule when adding a `do*` call:** an appId
populated from a method parameter rather than the app's own identity belongs in bucket B.
Bucket C is a pre-existing impersonation surface predating this migration (the sandbox picks the id it
sends), so this work introduced no new exposure there; closing it needs a host change — either
substituting nested `appId` fields for known payload params, or having `HttpBridge.doCall` ignore the
payload `appId` and use the connection-known id (follow-up 5).
### Module-resolution constraints (the original reason for the proxies)
The Deno subprocess consumes base-runtime **as TypeScript source** through the import map
(`@rocket.chat/apps/base-runtime/` → `../base-runtime/src/`); compiled `dist` CJS is avoided because
its `require()` calls bypass the import map. So moved code may import only
`@rocket.chat/apps-engine/definition/*` (mapped to source), `node:` builtins, and npm deps already in
`deno.jsonc`'s import map or Deno cache — which every moved accessor satisfies.
`UIHelper` was **duplicated** into base-runtime rather than shared. It is a small pure helper (only
`node:crypto` plus two type-only imports) needed by `ModifyCreator`/`ModifyUpdater`/`UIController`, and
the runtime used to import it from `@rocket.chat/apps/dist/server/misc/UIHelper` — exactly the fragile
dist-CJS path the runtime avoids. Having the host import it *back* from base-runtime was rejected:
base-runtime already imports `apps/dist`, so that would create an unlinearizable **build cycle**
(flipping the order only moves it). Phase 0 copied it into `base-runtime/src/lib/` and left the host
copy for the still-living host accessors; since all three `src/` importers were MOVE accessors, that
copy died in teardown — single source of truth *at teardown*, no cross-package import, no build-order
change. Other small carry-overs: `createProcessorId` (a local function) and `RoomRead`'s
`GetMessagesSortableFields` sort validation (constants from apps-engine definitions).
`lodash.clonedeep` was not needed — the runtime extenders already exist without it.
### Error semantics
Validation errors formerly thrown host-side inside accessor methods (serialized as JSON-RPC error 1000)
now throw *locally* in the subprocess: same message, better stack traces, one less round trip.
Bridge-side errors keep the `handleBridgeMessage` shape (code -32000, message preserved, formatted by
`formatErrorResponse`). Permission failures on read/write bridges stay *silent-undefined*;
manager-level registration failures keep *throwing*. Both are preserved verbatim because both layers
are reused untouched.
### `AppObjectRegistry` and one-app-per-process
The registry (a process-global singleton keyed by simple strings, including a single `id`) assumes one
app per subprocess. The migration keeps that assumption, restated in code comments since consolidating
logic into the runtime makes it more load-bearing.
## Per-accessor disposition
**MOVE** — implement in base-runtime, side effects via existing bridge `do*`, delete host class.
**RECONCILE** — already in base-runtime; make canonical, delete host copy. **BRIDGE-GAP** — needs
`AppResourceBridge`. **DELETE** — already dead on the host.
**Readers (all MOVE — bridge passthroughs plus portable validation).** `Reader` (facade over 15
sub-readers); `MessageRead` (`getSenderUser`/`getRoom` are fetch-then-extract compositions); `RoomRead`
(`getMessages`/`getAllRooms`/`getUnreadByUser` limit/skip/sort validation); `UserRead`
(`getAppUser(appId?)` arg defaulting); `PersistenceRead` (single-assoc → array wrapping); `LivechatRead`
(`isOnline` deprecation warning — runtime `console` is piped to app logs); `UploadRead`
(`getBufferById` becomes two bridge RPCs); `CloudWorkspaceRead`, `VideoConferenceRead`, `OAuthAppsReader`
(`doGetByid` typo is on the bridge, unchanged), `ContactRead`, `ThreadRead`, `RoleRead` (1:1);
`ExperimentalRead` (empty shell → empty local class).
**Environment.** MOVE: `EnvironmentRead`/`EnvironmentWrite` (facades), `ServerSettingRead`
(`getValueById` `value → packageValue` fallback; `getAll` keeps throwing "not implemented" locally),
`EnvironmentalVariableRead`, `ServerSettingUpdater` (+ `incrementValue` default). BRIDGE-GAP:
`SettingRead` (reads `ProxiedApp.getStorageItem().settings`) → `doGetAppSetting`; `SettingUpdater`
(`AppSettingsManager.updateAppSetting` persists, fires `doOnAppSettingsChange`, runs the
`ON_PRE_SETTING_UPDATE` round-trip back into the app) → `doUpdateAppSetting`.
**Modify.** RECONCILE: `ModifyCreator`, `ModifyUpdater` (diff-update semantics), `ModifyExtender`,
`Notifier` (awaits typing calls). MOVE: `Modify` (facade); `LivechatCreator` (`createToken` already
local in the runtime, supersedes; rest 1:1); `UploadCreator` (`uploadBuffer` defaults user via
`doGetAppUser` — two RPCs); `EmailCreator`, `ContactCreator`, `MessageUpdater`, `OAuthAppsModify` (1:1);
`LivechatUpdater` (+ boolean coercion in `setCustomFields`); `UserUpdater` (partial-object wrapping);
`ModifyDeleter` (`removeUsersFromRoom` ≤ 50 validation); `UIController` (UIKit interaction payloads +
`UIHelper.assignIds`, then `doNotifyUser`; required relocating `UIHelper`); `SchedulerModify`
(`createProcessorId` is pure and idempotent — the subprocess knows its own appId for the suffix and the
bridge still permission-checks the real id); `ModerationModify` (appId comes from method args by design).
**Configuration extend/modify — the registration surface.** These are why `handleAccessorMessage` could
not be deleted by bridge calls alone: they write into host-only managers whose in-memory registries the
host reads to render UI, route events, and dispatch executions back into the subprocess. MOVE:
`ConfigurationExtend`, `ConfigurationModify` (facades), `ServerSettingsModify` (1:1 + increment
default). RECONCILE: `HttpExtend` (already fully local). BRIDGE-GAP: `SettingsExtend` →
`doProvideSetting`; `SlashCommandsExtend` (registry, touched-command conflict tracking,
`CommandAlreadyExistsError`) → `doProvideSlashCommand`; `ApiExtend` (registry, `PathAlreadyExistsError`)
→ `doProvideApi` plus `listApis` → `doListApis`, killing the `accessor:api:listApis` special case;
`ExternalComponentsExtend` → `doRegisterExternalComponent`; `SchedulerExtend` (wraps processors in host
callbacks, namespaces ids, guards disabled apps) → `doRegisterProcessors`; `UIExtend` (button registry
read directly by host UI, `ui.registerButtons` permission, `doActionsChanged` side effect) →
`doRegisterActionButton`; `VideoConfProviderExtend` (`PermissionDeniedError`, name-collision tracking) →
`doProvideVideoConfProvider`; `OutboundMessageProviderExtend` → `doRegisterOutboundProvider`;
`SlashCommandsModify` → `doModifySlashCommand`/`doEnableSlashCommand`/`doDisableSlashCommand`.
*Serialization note:* `ISlashCommand`, `IProcessor`, `IApi`, and the provider objects contain executor
functions. The old `accessor:*` messages already stripped them in JSON serialization — the host managers
keep metadata and dispatch executions *back into* the subprocess, which looks the live instances up in
`AppObjectRegistry`. The new bridge methods receive the same serialized shapes, so **the wire semantics
did not change, only the message prefix**.
**Standalone.** `Http` — RECONCILE (runtime version already merges `HttpExtend` defaults, runs pre/post
handlers, calls `doCall`; the host copy and its reach-back into `AppAccessorManager` are deleted).
`Persistence` — MOVE (1:1 + arg wrapping/defaults). `AppAccessors` — DELETE (never instantiated on the
host; the runtime one in `mod.ts` is live).
## Drift resolutions
Only the **bounded subset carrying logic on both sides** — the RECONCILE accessors plus the few MOVE
accessors already partially local. Pure-proxy accessors had no runtime counterpart to reconcile; they
were faithful-ported and covered by the parity check instead.
Classes: **superset/equiv** — runtime already covers the host behavior, adopt silently.
**conflict→runtime** — genuine divergence; runtime wins, observationally neutral since the runtime was
authoritative. **merge (host→runtime)** — host-only logic folded in, adds observable behavior, ships
with a test (no row needed one).
| # | Drift | Class | Decision |
| --- | --- | --- | --- |
| 1 | **Update payload shape** — host `ModifyUpdater._finishMessage/_finishRoom` sent the *full* builder object to `doUpdate`; runtime sends a *diff* (`{ id, ...builder.getChanges() }`). | conflict→runtime | Diff wins; bridges demonstrably accept it (production sends it). Host path deleted, diff shape pinned by a test. |
| 2 | **Editor tracking** — runtime `ModifyUpdater.message()` calls `builder.setEditor(editor)`; host ignored the updater param. | superset/equiv | Runtime wins; editor recorded on update. |
| 3 | **`typing()` awaits** — runtime `Notifier` awaits `doTyping`; host fire-and-forget. | superset/equiv | Runtime wins. |
| 4 | **`createToken`** — runtime generates `randomBytes(16).toString('hex')` locally; host generated it host-side. | conflict→runtime | Runtime wins; same format. |
| 5 | **HTTP method representation** — runtime uses lowercase literals; host used the `RequestMethod` enum. | superset/equiv | Runtime wins; `doCall` payload identical. |
| 6 | **`BlockBuilder` appId** — host `getBlockBuilder()` passed appId into the builder; runtime builder takes none (ids assigned later by `UIHelper.assignIds`). | conflict→runtime | Runtime wins; the drop is intentional (ids assigned later) and the block shape is pinned by a test. |
| 7 | **`APP_ID` inconsistency** — exactly one runtime call site (`ModifyCreator._finishMessage` → `doGetAppUser`) sent the sentinel; every other sent the raw `AppObjectRegistry.get('id')`. | normalization | Not a pick-a-side drift; all caller-identity params now use `'APP_ID'`, argument appIds stay raw. |
| 8 | Cosmetic error wording ("can not" vs "can't"). | conflict→runtime | Runtime wording wins. |
Where a ported accessor interacts with a reconciled one, the reconciled (runtime) semantics apply.
## Alternatives considered
- **Move the registries into the subprocess** — non-starter. The host reads
`UIActionButtonManager.getAllActionButtons` to render UI, routes slash-command/api/scheduler/provider
invocations off these registries, and enforces cross-app conflict rules (two apps claiming the same
slash command) that no single subprocess can arbitrate; app settings are host-persisted metadata read
by other subsystems. A host-side write surface must remain.
- **Keep a residual `accessor:*` channel just for registration** — works, but keeps two RPC categories,
two dispatchers, and the reflective accessor-path-walking code alive. The bridge approach reuses an
existing dispatcher, its security gates, and its error model, and let `handleAccessorMessage`,
`ALLOWED_ACCESSOR_METHODS`, `isValidOrigin`, and `getAccessorForOrigin` be deleted entirely.
## Consequences
**Positive.** One source of truth for accessor behavior; the host accessor layer and
`AppAccessorManager` deleted; subprocess controllers lose the whole `accessor:*` family and its
dispatcher; working LSP navigation into real implementations instead of proxies; local validation
throws with real stack traces and one less round trip.
**Accepted trade-offs.** Slightly chattier composite reads — `UploadRead.getBufferById` and
`UploadCreator.uploadBuffer` became two bridge RPCs where one accessor RPC (wrapping two in-host bridge
calls) sufficed; bounded, rare, symmetrical with what `Notifier`/`ModifyCreator` already did, and no
pagination-style N+1 patterns exist in this surface. Sandbox validation is advisory (unchanged in
practice, now explicit — follow-up 1). Two implementations existed transiently during rollout,
mitigated by per-accessor merge units and the parity harness.
**"Faithful port" was verified, not asserted.** For MOVE accessors the host logic was *live in
production*, so a subtly wrong port — a dropped default, an off-by-one cap, a renamed sort field — is an
immediate observable regression. Ported unit tests only prove the new class satisfies carried-over
assertions; they do not prove equivalence, so a port that drops an uncovered branch passes green. Each
host MOVE class deletion was gated on a **mechanical parity check**: either a differential harness
driving representative calls through both paths and asserting identical bridge-message output (method
string + params) and return shaping, or an enforced branch-coverage audit of the ported tests against
the host source. RECONCILE accessors did not need it (their host copy was already unreachable); the
drift rules govern them instead. The harness
(`base-runtime/src/lib/accessors/tests/helpers/parityHarness.ts`) was planned as throwaway but **kept**
— it became a test utility the base-runtime accessor suites depend on.
## Implementation record
**A phase was a milestone, not a single PR.** Each phase is independently shippable and keeps
`test:node`/`test:deno`/`test:base-runtime` green, but the *unit of merge* was the accessor: Phases 1–2
landed as a series of per-accessor (or tight cohesive-group) PRs, so blast radius per merge is one
accessor family. This mattered because the migration flips *live production behavior* for MOVE
accessors with no rollback but `git revert`, and the unit of revert equals the unit of merge. Host
accessor tests were ported to `base-runtime/src/lib/accessors/tests/`.
**Phase 0 — foundations, no behavior change.** Added the typed bridge entry point (now `bridgeCall.ts`)
with tests for message-string generation, the `do*` gate, and error formatting. Produced the APP_ID
exception list — the audit surfaced bucket C, since the sentinel substitution rewrites only *top-level
positional* params. Refactored the existing runtime accessors (`Http`, `Notifier`, `ModifyCreator`,
`ModifyUpdater`, `ModifyExtender`, `roomFactory`) onto it and normalized caller-identity params;
accessors still holding `accessor:*` sub-proxies kept `senderFn` for those until Phase 2. Duplicated
`UIHelper` into `base-runtime/src/lib/`. Stood up the parity harness. No changeset:
`@rocket.chat/apps` is `private` and has never carried one — this ADR and the exception list are the
recorded contract.
**Phase 1 — readers, `Persistence`, server-side environment.** Ported the reader family plus
`ServerSettingRead`, `EnvironmentalVariableRead`, `ServerSettingUpdater`, `ServerSettingsModify`,
`Persistence`, and the `Reader`/`EnvironmentRead`/`EnvironmentWrite` facades, moving all portable
validation/defaulting verbatim; app-settings members stayed proxied until Phase 3. Flipped the
corresponding `mod.ts` entries to local. *Host-class deletion was deferred to Phase 4* (deliberate
deviation): `AppAccessorManager.getReader()` builds the whole reader family and was still called by
`AppListenerManager.executePostMessageSent` and to build not-yet-migrated sub-accessors, so deleting
e.g. `RoomRead` then would have pulled the Phase-4 refactor forward mid-phase. *RPC-boundary
adaptation:* `ServerSettingRead.getValueById` checked `typeof set === 'undefined'` host-side; across the
boundary an absent return arrives as `null`, so the runtime treats both as "not found" — identical for
apps, since the bridge only ever returns a setting or nothing.
**Phase 2 — modify family.** Ported `ModifyDeleter`, `MessageUpdater`, `LivechatUpdater`, `UserUpdater`,
`LivechatCreator`, `UploadCreator`, `EmailCreator`, `ContactCreator`, `UIController`, `SchedulerModify`,
`OAuthAppsModify`, `ModerationModify`; caller-identity params use the sentinel, the
`ModerationModify`/`ModifyDeleter.deleteUsers` argument-appIds stay raw (bucket B), and
`UIController`/`SchedulerModify` read the real id from `AppObjectRegistry` for their non-identity uses.
Removed the remaining `proxify` entries in `getModifier` and the `accessor:*` sub-creator/sub-updater
proxies, after which `getReader`/`getModifier`/`getPersistence`/`getHttp` generated **zero**
`accessor:*` traffic. *RPC-boundary note:* sub-accessor methods now flow through `Promise<unknown>` and
are cast to their interface return types; `void`-returning methods `await` instead of returning the
bridge value.
**Phase 3 — registration surface.** Added `src/server/bridges/AppResourceBridge.ts` and wired it into
`handleBridgeMessage` via the dedicated `getAppResourceBridge` lookup plus the `restarting` guard keyed
on `REGISTRATION_METHODS`; no `apps/meteor` orchestrator changes needed. Rewrote
`getConfigurationExtend`, `getConfigurationModify`, and the app-settings
`SettingRead`/`SettingUpdater`/`SettingsExtend` members onto `getAppResourceBridge().do*`, preserving
the `AppObjectRegistry` stash-then-forward; `accessor:api:listApis` became `doListApis`;
`registerButton` stays a synchronous `void` per its interface. The dead `proxify` machinery and
`WithProxy` type were removed from `mod.ts` — **the runtime stopped emitting `accessor:*` entirely.**
*RPC-boundary note:* `SettingRead.getValueById` treats `null` and `undefined` alike, same adaptation as
`ServerSettingRead`.
**Phase 4 — message-path teardown.** Replaced the `executePostMessageSent` accessor call with
`this.manager.getBridges().getUserBridge().doGetAppUser(appId)`. Deleted `handleAccessorMessage`,
`ALLOWED_ACCESSOR_METHODS`, `isValidOrigin`, `getAccessorForOrigin`, the `accessor:` branch in
`handleIncomingMessage`, and the controller's unused `accessors`/`api` fields — **the `accessor:*`
category no longer exists**; the controller dispatches only `bridges:*`, `ready`, `log`, and the error
notifications. `JSONRPC_METHOD_NOT_FOUND` is kept (imported by `ProxiedApp`, `AppListenerManager`,
`AppVideoConfProvider`). Removed the four now-meaningless `handleAccessorMessage` cases from
`DenoRuntimeSubprocessController.test.ts`. Physical deletion of the dead classes was folded into
Phase 5, since it first required un-threading the ignored `accessors` parameter from the
sandbox-execution core — a separate behavior-neutral change that should not ride at the tail of the
teardown.
**Phase 5 — dead host accessor layer removal.** Behavior-neutral; nothing on the subprocess path
reached any of it after Phase 4. Un-threaded the vestigial `accessors` parameter from `AppApi`,
`AppSlashCommand`, `AppVideoConfProvider`, and `AppOutboundCommunicationProvider` `run*`/`runTheCode`
methods (which already ignored it as `_accessors`), updated the four managers' call sites, and dropped
their unused `accessors` fields. Deleted `AppAccessorManager`, its `managers/index.ts` export, and
`AppManager`'s field, construction, `getAccessorManager()`, and the `purifyApp(...)` call in
`removeLocal` (a no-op once no host code called `getReader`/`getModifier`). Deleted
`src/server/accessors/` (64 files) and the host `src/server/misc/UIHelper.ts`. Adapted tests: removed
`tests/server/accessors/` (35 suites, covered by the base-runtime ones),
`AppAccessorManager.test.ts`, and the accessor setup/assertions in the four manager tests,
`AppSettingsManager.test.ts`, `AppManager.test.ts`, `AppListenerManager.test.ts`, and the two
Deno-runtime integration tests. *Verification:* host `tsc` error-set unchanged vs the
environmental-only baseline; `test:node` host suites green (excluding the two `deno`-spawn integration
suites needing a `deno` binary unavailable in that environment); base-runtime suite unaffected.

137
docs/ajv-instances.md Normal file
View File

@@ -0,0 +1,137 @@
# AJV instances: `ajv` and `ajvQuery`
The `@rocket.chat/rest-typings` package uses two [AJV](https://ajv.js.org/) instances for JSON schema validation: **`ajv`** and **`ajvQuery`**. The choice between them depends on the **source of the data** being validated (request body vs query string).
---
## Instance definitions
Both are created in `packages/rest-typings/src/v1/Ajv.ts` with the same configuration except for one option:
| Option | `ajv` | `ajvQuery` |
|-------------------|---------|------------|
| `coerceTypes` | `false` | `true` |
| `allowUnionTypes` | `true` | `true` |
| `code.source` | `true` | `true` |
| `discriminator` | `true` | `true` |
In short:
- **`ajv`**: does not change data types; values must already match the types expected by the schema.
- **`ajvQuery`**: attempts to **coerce** types when the schema expects `number`, `integer`, or `boolean` (e.g. the string `"50"` becomes the number `50`).
Custom formats (`addFormats`) and keywords (e.g. `isNotEmpty`) are registered on both instances.
---
## Why two instances?
### Query params are always strings
In HTTP requests, **query parameters** (everything after `?` in the URL) reach the server as **strings**. HTTP does not carry type information; the server receives, for example:
- `?count=25` → `count` is the string `"25"`
- `?open=true` → `open` is the string `"true"`
If the schema expects `count` as `number` or `open` as `boolean`, a validator that does **not** coerce will reject:
- `"25"` is not of type `number` → error like "must be number" / "invalid-params".
- `"true"` is not of type `boolean` → validation error.
### Body can already be typed
For the **body** (JSON in POST/PUT/PATCH etc.), the client sends JSON. Parsing (e.g. `JSON.parse`) already yields numbers and booleans. In that case we do not want the validator to mutate values; we use the instance **without** coercion (`ajv`).
---
## When to use each
### Use `ajvQuery.compile` when:
- The validator is used for **query parameters** (query string).
- **GET** routes (or any method that only reads query params).
- The schema has properties of type `number`, `integer`, or `boolean` that come from the URL.
Examples of validators that should use `ajvQuery`:
- Pagination: `count`, `offset` (typically numbers).
- Flags: `open`, `readThreads` (booleans).
- Any numeric or boolean parameter the client sends in the query string.
```ts
// GET /v1/livechat/rooms?count=25&offset=0
export const isGETLivechatRoomsParams = ajvQuery.compile<GETLivechatRoomsParams>(GETLivechatRoomsParamsSchema);
```
### Use `ajv.compile` when:
- The validator is used for the request **body** (POST, PUT, PATCH, etc.).
- Data is already parsed JSON (numbers and booleans are already typed).
Examples:
- Create/update resources (JSON body).
- Response validators or internal structures that do not come from the query string.
```ts
// POST /v1/livechat/room/close — JSON body
export const isPOSTLivechatRoomCloseParams = ajv.compile<POSTLivechatRoomCloseParams>(...);
```
---
## Quick reference
| Data source | Instance | Reason |
|--------------------------------|------------|--------|
| **Query string** (GET, query params) | `ajvQuery` | Query values are strings; `coerceTypes: true` converts to number/boolean when the schema expects it. |
| **Body** (JSON in POST/PUT/PATCH) | `ajv` | JSON already has types; strict validation without mutating values. |
---
## Response schemas and `nullable`
Response schemas also use `ajv` (coerceTypes: false). In **test mode**, the Router validates every response against its declared schema (`options.response[statusCode]`). If validation fails, the Router returns a **400** with `errorType: "error-invalid-body"` instead of the original response.
With `coerceTypes: true` (old behavior), `null` values were silently coerced (e.g. `null` → `""` for strings). With `coerceTypes: false`, any field that can be `null` **must** declare `nullable: true` in the schema — otherwise the response validator rejects it.
### Example
A video conference user object may have `avatarETag: null`. The response schema must account for this:
```ts
// WRONG — fails when avatarETag is null
{ type: 'string' }
// CORRECT
{ type: 'string', nullable: true }
```
### `oneOf` / discriminator schemas
Schemas using `oneOf` with strict enum discriminators (e.g. `type: { enum: ['direct'] }`) also become stricter without coercion. If the actual data has a `type` value not listed in any branch, the `oneOf` fails. Ensure all possible discriminator values are covered, or relax the items schema (e.g. `{ type: 'object' }`) when full type-level validation is not needed at runtime.
---
## Common mistakes
1. **Using `ajv` for query params** when the schema expects `number` or `boolean`:
- Client sends `?count=25`.
- Validator expects `number` but receives the string `"25"`.
- Result: "must be number" / "invalid-params" error.
2. **Using `ajvQuery` for body**:
- Usually works, but coercion can hide incorrect types (e.g. string where a number was expected). For body, the standard is to use `ajv` and require the client to send the correct types in the JSON.
3. **Response schemas with `null` fields** (test mode only):
- With `coerceTypes: false`, `null` is no longer coerced to `""` or `0`.
- Fields that can be `null` must use `nullable: true`.
- Symptom: tests get 400 with `errorType: "error-invalid-body"` even though the endpoint logic succeeds.
---
## Where the instances are defined
- **File:** `packages/rest-typings/src/v1/Ajv.ts`
- **Export:** `export { ajv, ajvQuery };`
- **Usage:** In each rest-typings module, import the appropriate instance and call `.compile(schema)` to obtain the validator used by the API routes.

97
docs/anchor-navigation.md Normal file
View File

@@ -0,0 +1,97 @@
# Anchor Navigation
This document describes the pattern for navigating directly to a specific field within a page using URL hash fragments (e.g., `/account/accessibility-and-appearance#clockMode`).
## Problem
Rocket.Chat uses a custom SPA router that intercepts link clicks via `e.preventDefault()` and navigates with `history.pushState()`. This means the browser's native hash-based scrolling does not work for cross-page navigation. When a user clicks a link like `/page-b#field`, the router renders the new page but does not scroll to the target element.
## Solution
Scrolling to hash targets is handled automatically by `RouterProvider` via `useRouterScrollToHash`. Any element with an `id` matching the URL hash will be scrolled into view on navigation.
For pages that need to adjust layout based on the hash (e.g., expanding an accordion section), `useLocationHash()` from `@rocket.chat/ui-contexts` provides the current hash value.
### Key files
| File | Purpose |
|------|---------|
| `apps/meteor/client/hooks/useRouterScrollToHash.ts` | Scroll logic, called by `RouterProvider` |
| `packages/ui-contexts/src/hooks/useLocationHash.ts` | Generic `useLocationHash()` hook from `@rocket.chat/ui-contexts` |
## How it works
1. A **source page** links to a target page with a hash fragment in the `href`
2. The **target page** renders elements with matching `id` attributes
3. `useRouterScrollToHash` (in `RouterProvider`) scrolls to the element matching the hash via `scrollIntoView`
4. If the target page needs to adjust layout (e.g., expand a collapsed section), it reads `useLocationHash()` to check if a hash is present
```text
PreferencesMessagesSection AccessibilityPage
┌──────────────────────────┐ ┌──────────────────────────┐
│ Message TimeFormat │ │ Adjustable layout │
│ [Go to accessibility] ────┼── #clockMode ───> │ <Field id="clockMode"> │
│ │ │ │
│ Hide usernames │ │ <Field id="hideUsernames"> │
│ [Go to accessibility] ────┼── #hideUsernames ───> │ │
└──────────────────────────┘ └──────────────────────────┘
```
## Usage
### 1. Add the `id` to the target element
On the destination page, set the `id` on the element you want to scroll to:
```tsx
<Field id='myNewField'>
<FieldLabel>{t('My_New_Field')}</FieldLabel>
{/* ... */}
</Field>
```
### 2. Expand collapsed sections (if needed)
If the target field is inside a collapsed section, use `useLocationHash()` to expand it:
```tsx
import { useLocationHash } from '@rocket.chat/ui-contexts';
const MyPage = () => {
const shouldExpand = useLocationHash().length > 1;
return (
<Accordion>
<AccordionItem defaultExpanded={shouldExpand} title={t('Section')}>
<Field id='myNewField'>
{/* ... */}
</Field>
</AccordionItem>
</Accordion>
);
};
```
### 3. Link from the source page
Use the hash fragment in the `href`:
```tsx
<FieldLink href='/account/accessibility-and-appearance#myNewField'>
{t('Go_to_accessibility_and_appearance')}
</FieldLink>
```
## Why `scrollIntoView` is needed
The browser natively scrolls to hash targets on full page loads and un-intercepted anchor clicks. However, the custom router calls `e.preventDefault()` on cross-page links and uses `history.pushState()`, which by spec does not trigger scroll. `useRouterScrollToHash` compensates by calling `scrollIntoView` after each navigation.
Same-page hash navigation (clicking `#anchor` when already on that page) still works natively — the router detects matching pathnames and lets the browser handle it.
## Checklist
When adding a new anchor:
- [ ] `id` attribute set on the target element
- [ ] Source `href` includes the `#anchor` fragment
- [ ] `useLocationHash()` can be called in target page to identify if theres hash and control collapsed content, if layout adjustment is needed

View File

@@ -0,0 +1,637 @@
# API Endpoint Migration Guide
Migration from the legacy `API.v1.addRoute()` pattern to the new `API.v1.get()` / `.post()` / `.put()` / `.delete()` pattern with request/response validation.
## Why
- **Response validation** in test mode catches mismatches between code and types
- **Type safety** with AJV-compiled schemas for request and response
- **OpenAPI docs** generated automatically from schemas
- **Consistent error format** across all endpoints
## Legacy Pattern (BEFORE)
```typescript
import { isChannelsAddAllProps } from '@rocket.chat/rest-typings';
import { API } from '../api';
API.v1.addRoute(
'channels.addAll',
{
authRequired: true,
validateParams: isChannelsAddAllProps,
},
{
async post() {
const { activeUsersOnly, ...params } = this.bodyParams;
const findResult = await findChannelByIdOrName({ params, userId: this.userId });
await addAllUserToRoomFn(this.userId, findResult._id, activeUsersOnly === 'true' || activeUsersOnly === 1);
return API.v1.success({
channel: await findChannelByIdOrName({ params, userId: this.userId }),
});
},
},
);
```
Source: `apps/meteor/app/api/server/v1/channels.ts`
## New Pattern (AFTER)
```typescript
import {
ajv,
isReportHistoryProps,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';
import { API } from '../api';
import { getPaginationItems } from '../helpers/getPaginationItems';
// See "Known Pitfall: Date | string unions" — IModerationAudit uses a relaxed inline schema
const paginatedReportsResponseSchema = ajv.compile<{ reports: IModerationAudit[]; count: number; offset: number; total: number }>({
type: 'object',
properties: {
reports: {
type: 'array',
items: {
type: 'object',
properties: {
userId: { type: 'string' },
username: { type: 'string' },
name: { type: 'string' },
message: { type: 'string' },
msgId: { type: 'string' },
ts: { type: 'string' },
rooms: { type: 'array', items: { type: 'object' } },
roomIds: { type: 'array', items: { type: 'string' } },
count: { type: 'number' },
isUserDeleted: { type: 'boolean' },
},
required: ['userId', 'ts', 'rooms', 'roomIds', 'count', 'isUserDeleted'],
additionalProperties: false,
},
},
count: { type: 'number' },
offset: { type: 'number' },
total: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['reports', 'count', 'offset', 'total', 'success'],
additionalProperties: false,
});
API.v1.get(
'moderation.reportsByUsers',
{
authRequired: true,
permissionsRequired: ['view-moderation-console'],
query: isReportHistoryProps,
response: {
200: paginatedReportsResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { latest: _latest, oldest: _oldest, selector = '' } = this.queryParams;
const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const latest = _latest ? new Date(_latest) : new Date();
const oldest = _oldest ? new Date(_oldest) : new Date(0);
const reports = await ModerationReports.findMessageReportsGroupedByUser(latest, oldest, escapeRegExp(selector), {
offset,
count,
sort,
}).toArray();
return API.v1.success({
reports,
count: reports.length,
offset,
total: reports.length === 0 ? 0 : await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapeRegExp(selector), true),
});
},
);
```
Source: `apps/meteor/app/api/server/v1/moderation.ts`
## Step-by-Step Migration
### 1. Identify the HTTP method
Look at the handler keys inside the `addRoute` call:
| Handler key | New method |
| ---------------- | -------------------- |
| `async get()` | `API.v1.get(...)` |
| `async post()` | `API.v1.post(...)` |
| `async put()` | `API.v1.put(...)` |
| `async delete()` | `API.v1.delete(...)` |
### 2. Replace `addRoute` with the HTTP method
```typescript
// BEFORE
API.v1.addRoute('endpoint.name', options, { async get() { ... } });
// AFTER
API.v1.get('endpoint.name', options, async function action() { ... });
```
The handler becomes a standalone `async function action()` (named function, not arrow function).
### 3. Move `validateParams` to `query` or `body`
| HTTP method | Option name |
| ----------- | -------------------- |
| GET, DELETE | `query: validatorFn` |
| POST, PUT | `body: validatorFn` |
```typescript
// BEFORE
{
validateParams: isSomeEndpointProps;
}
// AFTER (GET)
{
query: isSomeEndpointProps;
}
// AFTER (POST)
{
body: isSomeEndpointProps;
}
```
The `validateParams` option is deprecated. Do not use it in new code.
### 4. Create response schemas
Define response schemas using `ajv.compile<T>()` **before** the endpoint registration. Every response schema must include the `success` field. When the response contains complex types from `@rocket.chat/core-typings`, prefer using `$ref` instead of `{ type: 'object' }` (see [Using Typia `$ref` for Complex Types](#using-typia-ref-for-complex-types)).
```typescript
const myResponseSchema = ajv.compile<{ items: SomeType[]; count: number }>({
type: 'object',
properties: {
items: { type: 'array', items: { $ref: '#/components/schemas/SomeType' } },
count: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['items', 'count', 'success'],
additionalProperties: false,
});
```
For endpoints that return only `{ success: true }`:
```typescript
const successResponseSchema = ajv.compile<void>({
type: 'object',
properties: { success: { type: 'boolean', enum: [true] } },
required: ['success'],
additionalProperties: false,
});
```
### 5. Add the `response` object
Always include error schemas for relevant status codes:
```typescript
response: {
200: myResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
}
```
Add `403: validateForbiddenErrorResponse` when the endpoint has `permissionsRequired`.
### 6. Update imports
```typescript
import {
ajv,
isMyEndpointProps, // request validator (from rest-typings)
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';
```
## Using Typia `$ref` for Complex Types
For response fields that use complex types already defined in `@rocket.chat/core-typings` (like `IMessage`, `ISubscription`, `ICustomSound`, `IPermission`, etc.), **do not rewrite the JSON schema manually**. Instead, use `$ref` to link to the typia-generated schema.
### How it works
1. **`packages/core-typings/src/Ajv.ts`** generates JSON schemas from TypeScript types at compile time using typia:
```typescript
import typia from 'typia';
import type { ICustomSound } from './ICustomSound';
// ...
export const schemas = typia.json.schemas<
[
ISubscription | IInvite | ICustomSound | IMessage | IOAuthApps | IPermission | IMediaCall,
CallHistoryItem,
ICustomUserStatus,
SlashCommand,
],
'3.0'
>();
```
2. **`apps/meteor/app/api/server/ajv.ts`** registers all generated schemas into the shared AJV instance:
```typescript
import { schemas } from '@rocket.chat/core-typings';
import { ajv } from '@rocket.chat/rest-typings';
const components = schemas.components?.schemas;
if (components) {
for (const key in components) {
if (Object.prototype.hasOwnProperty.call(components, key)) {
ajv.addSchema(components[key], `#/components/schemas/${key}`);
}
}
}
```
3. **Endpoints** reference the schema by `$ref` instead of writing it inline:
```typescript
const customSoundsResponseSchema = ajv.compile<PaginatedResult<{ sounds: ICustomSound[] }>>({
type: 'object',
properties: {
sounds: {
type: 'array',
items: { $ref: '#/components/schemas/ICustomSound' },
},
count: { type: 'number' },
offset: { type: 'number' },
total: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['sounds', 'count', 'offset', 'total', 'success'],
additionalProperties: false,
});
```
Source: `apps/meteor/app/api/server/v1/custom-sounds.ts`
### Available `$ref` schemas
These types are already registered and available via `$ref`:
- `#/components/schemas/ISubscription`
- `#/components/schemas/IInvite`
- `#/components/schemas/ICustomSound`
- `#/components/schemas/IMessage`
- `#/components/schemas/IOAuthApps`
- `#/components/schemas/IPermission`
- `#/components/schemas/IMediaCall`
- `#/components/schemas/IEmailInbox`
- `#/components/schemas/IImport`
- `#/components/schemas/IIntegrationHistory`
- `#/components/schemas/ICalendarEvent`
- `#/components/schemas/IRole`
- `#/components/schemas/IRoom`
- `#/components/schemas/IUser`
- `#/components/schemas/IModerationAudit`
- `#/components/schemas/IModerationReport`
- `#/components/schemas/IBanner`
- `#/components/schemas/CallHistoryItem`
- `#/components/schemas/ICustomUserStatus`
- `#/components/schemas/SlashCommand`
- `#/components/schemas/VideoConferenceCapabilities`
- `#/components/schemas/CloudRegistrationIntentData`
- `#/components/schemas/CloudRegistrationStatus`
**Union types** — these TypeScript types are unions, so typia generates individual schemas for each variant instead of a single named schema. Use `oneOf` to reference them (see below):
| TypeScript type | Generated schemas |
| ----------------------------- | ----------------------------------------------------------------------------------------------------- |
| `IIntegration` | `IIncomingIntegration`, `IOutgoingIntegration` |
| `VideoConference` | `IDirectVideoConference`, `IGroupVideoConference`, `ILivechatVideoConference`, `IVoIPVideoConference` |
| `VideoConferenceInstructions` | `DirectCallInstructions`, `ConferenceInstructions`, `LivechatInstructions` |
| `CloudConfirmationPollData` | `CloudConfirmationPollDataPending`, `CloudConfirmationPollDataSuccess` |
Plus any sub-types that these reference internally (e.g., `MessageMention`, `IVideoConferenceUser`, `VideoConferenceStatus`, etc.).
### Handling union types with `oneOf`
When a TypeScript type is a union (e.g., `IIntegration = IIncomingIntegration | IOutgoingIntegration`), typia generates separate schemas for each variant but **no single named schema** for the union itself. Use `oneOf` to reference the variants:
```typescript
// Single field
integration: {
oneOf: [
{ $ref: '#/components/schemas/IIncomingIntegration' },
{ $ref: '#/components/schemas/IOutgoingIntegration' },
],
},
// Array of union type
integrations: {
type: 'array',
items: {
oneOf: [
{ $ref: '#/components/schemas/IIncomingIntegration' },
{ $ref: '#/components/schemas/IOutgoingIntegration' },
],
},
},
```
### Handling nullable types
When a field can be `null`, combine `nullable: true` with `$ref`:
```typescript
// Nullable $ref
report: { nullable: true, $ref: '#/components/schemas/IModerationReport' },
// Nullable union
integration: {
nullable: true,
oneOf: [
{ $ref: '#/components/schemas/IIncomingIntegration' },
{ $ref: '#/components/schemas/IOutgoingIntegration' },
],
},
```
### Handling intersection types with `allOf`
When a response intersects a type with additional properties (e.g., `VideoConferenceInstructions & { providerName: string }`), use `allOf`:
```typescript
data: {
allOf: [
{
oneOf: [
{ $ref: '#/components/schemas/DirectCallInstructions' },
{ $ref: '#/components/schemas/ConferenceInstructions' },
{ $ref: '#/components/schemas/LivechatInstructions' },
],
},
{ type: 'object', properties: { providerName: { type: 'string' } }, required: ['providerName'] },
],
},
```
This also applies when the API spreads a type at root level alongside `success` (e.g., `API.v1.success(emailInbox)` producing `{ ...emailInbox, success: true }`):
```typescript
{
allOf: [
{ $ref: '#/components/schemas/IEmailInbox' },
{ type: 'object', properties: { success: { type: 'boolean', enum: [true] } }, required: ['success'] },
],
}
```
### Types that are NOT good `$ref` candidates
Some TypeScript types cannot (or should not) be represented as a single `$ref`:
- **Complex union types with many variants**: `ISetting` (union of `ISettingBase | ISettingEnterprise | ISettingColor | ...`) generates too many individual schemas without a clean single reference.
- **`Pick<>` / `Omit<>` types**: `Pick<IUser, 'username' | 'name' | '_id'>` is not registered in typia as a standalone schema. Leave these as `{ type: 'object' }` or `{ type: ['object', 'null'] }`.
- **Intersection + union types**: `LoginServiceConfiguration` and similar complex composed types.
- **Types with `Date | string` fields**: See [Known Pitfall: `Date | string` unions](#known-pitfall-date--string-unions) below.
For these cases, keep using `{ type: 'object' }` as a placeholder.
### Known Pitfall: `Date | string` unions
Some core-typings define timestamp fields as `Date | string` (e.g., `IModerationAudit.ts`, `IModerationReport.ts`). When typia generates the JSON Schema for these fields, it creates a `oneOf` with two branches: one for `Date` (which maps to `{ type: "string", format: "date-time" }`) and one for `string` (which maps to `{ type: "string" }`).
The problem: an ISO date string like `"2026-03-11T16:07:21.755Z"` satisfies **both** schemas simultaneously, causing AJV to fail with:
```
must match exactly one schema in oneOf (passingSchemas: 0,1)
```
This happens because `oneOf` requires **exactly one** match, but the value is both a valid `date-time` string and a valid `string`.
**Workaround**: Use a relaxed inline schema instead of `$ref` for these types, defining `ts` as `{ type: 'string' }`. Add a `TODO` comment referencing this issue so it can be tracked:
```typescript
// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema.
// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time")
// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1".
// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB
// aggregation actually returns, or adjusting the AJV schema generation for union types), we use a
// relaxed inline schema here that accepts `ts` as a string.
```
**Long-term fix**: Revise the core-typings to narrow `ts` to `string` (which is what MongoDB aggregation pipelines and `JSON.stringify` actually return), or adjust the AJV/typia schema generation to handle `Date | string` unions correctly (e.g., using `anyOf` instead of `oneOf`, or collapsing `Date` into `string`).
### Adding a new type to typia
If you need a `$ref` for a type that is not yet registered:
1. Edit `packages/core-typings/src/Ajv.ts`
2. Import the type and add it to the `typia.json.schemas<[...]>()` type parameter list
3. Rebuild `core-typings`: `yarn workspace @rocket.chat/core-typings run build`
4. The new schema will be automatically registered at startup via `apps/meteor/app/api/server/ajv.ts`
## Chaining Endpoints and Type Augmentation
Migrated endpoints **must always be chained** when a file registers multiple endpoints. Store the result in a variable, then use `ExtractRoutesFromAPI` to extract the route types and augment the `Endpoints` interface in `rest-typings`. This is what makes the endpoints fully typed across the entire codebase (SDK, client, tests).
### Full example
```typescript
import type { IInvite } from '@rocket.chat/core-typings';
import {
ajv,
isFindOrCreateInviteParams,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';
// Chain all endpoints from this file into a single variable
const invites = API.v1
.get(
'listInvites',
{
authRequired: true,
response: {
200: ajv.compile<IInvite[]>({
type: 'array',
items: { $ref: '#/components/schemas/IInvite' },
additionalProperties: false,
}),
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const result = await listInvites(this.userId);
return API.v1.success(result);
},
)
.post(
'findOrCreateInvite',
{
authRequired: true,
body: isFindOrCreateInviteParams,
response: {
200: findOrCreateInviteResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { rid, days, maxUses } = this.bodyParams;
return API.v1.success((await findOrCreateInvite(this.userId, { rid, days, maxUses })) as IInvite);
},
);
// Extract route types from the chained result
type InvitesEndpoints = ExtractRoutesFromAPI<typeof invites>;
// Augment the Endpoints interface so the SDK, client hooks, and tests see these routes
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends InvitesEndpoints {}
}
```
Source: `apps/meteor/app/api/server/v1/invites.ts`
### Rules
1. **Always chain**: Every `.get()` / `.post()` / `.put()` / `.delete()` call in the same file should be chained on the same variable (e.g., `const myEndpoints = API.v1.get(...).post(...).get(...)`).
2. **Store in a `const`**: The chained result must be stored in a variable so `typeof` can extract its type.
3. **Extract with `ExtractRoutesFromAPI`**: Use `type MyEndpoints = ExtractRoutesFromAPI<typeof myEndpoints>` to get the typed route definitions.
4. **Augment `Endpoints`**: Use `declare module '@rocket.chat/rest-typings'` to merge the extracted types into the global `Endpoints` interface. This is what makes `useEndpoint('listInvites')` and similar SDK calls type-safe.
5. **Import `ExtractRoutesFromAPI`** from `'../ApiClass'` (relative to the endpoint file in `v1/`).
### What augmentation enables
Once the `Endpoints` interface is augmented, the entire stack benefits:
- **Client SDK**: `useEndpoint('listInvites')` gets typed params and response
- **REST client**: `api.get('/v1/listInvites')` is type-checked
- **Tests**: response shape is inferred from the endpoint definition
- **OpenAPI**: routes appear in the generated documentation
## Endpoints with Multiple HTTP Methods
When an `addRoute` registers both GET and POST (or other combinations), split them into separate calls:
```typescript
// BEFORE
API.v1.addRoute('endpoint', { authRequired: true, validateParams: { GET: isGetProps, POST: isPostProps } }, {
async get() { ... },
async post() { ... },
});
// AFTER
API.v1.get('endpoint', {
authRequired: true,
query: isGetProps,
response: { 200: getResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse },
}, async function action() { ... });
API.v1.post('endpoint', {
authRequired: true,
body: isPostProps,
response: { 200: postResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse },
}, async function action() { ... });
```
## Error Handling (thrown errors)
Every route registered through `API.v1.get/post/put/delete` is wrapped by `ApiClass`'s internal handler (`_internalRouteActionHandler`), which runs the action inside a `try/catch`. A thrown `Meteor.Error` (or any core-services error carrying `error`/`message` by shape) is mapped to the matching HTTP failure:
- `error-too-many-requests` → `429`
- `unauthorized` / `error-unauthorized` → `401` (or `403` pre-breaking-changes)
- `forbidden` / `error-forbidden` → `403`
- anything else → `API.v1.failure(message, errorType, stack, details)` → `400`
So a migrated handler should **just throw** to signal a client error — exactly as the legacy DDP methods and `addRoute` handlers did. Do **not** wrap every handler in a `try/catch` that returns `API.v1.failure(...)`: it is redundant with the global wrapper and, worse, flattens `401`/`403`/`429` into `400`.
```typescript
async function action() {
const room = await findChannelByIdOrName({ params: this.bodyParams }); // throws error-room-not-found
// ...
return API.v1.success({ channel: room });
}
```
Declare in the `response` block every error status the handler (or the wrapper) can produce, so response validation under `TEST_MODE` accepts them — `400: validateBadRequestErrorResponse` for thrown/failure errors, plus `401`/`403` when `authRequired`/`permissionsRequired` are set.
Add an explicit `catch` only when you need behaviour the global wrapper does not provide (e.g. returning a specific `API.v1.failure(...)` payload for a known condition, or mapping a status differently); otherwise let the error propagate.
## Test Changes
Migrating an endpoint changes how validation errors are returned. Tests must be updated accordingly.
### `errorType` changes for query parameter validation
The new router returns a different `errorType` for query parameter validation errors:
```typescript
// BEFORE (addRoute with validateParams)
expect(res.body).to.have.property('errorType', 'invalid-params');
// AFTER (.get() with query)
expect(res.body).to.have.property('errorType', 'error-invalid-params');
```
This only affects **query** parameter validation (GET/DELETE). Body parameter validation (POST/PUT) keeps `'invalid-params'`.
### Error message format changes
The `[invalid-params]` suffix is removed from error messages:
```typescript
// BEFORE
expect(res.body).to.have.property('error', "must have required property 'platform' [invalid-params]");
// AFTER
expect(res.body).to.have.property('error', "must have required property 'platform'");
```
### Summary of test changes per endpoint
When migrating an endpoint, search for its tests and update:
1. `errorType` from `'invalid-params'` to `'error-invalid-params'` (for query params only)
2. Remove `' [invalid-params]'` suffix from `error` message assertions
3. Verify that status codes remain the same (400 for validation errors)
## Reference Files
| Pattern | File |
| -------------------------------- | ------------------------------------------------ |
| Chaining + augmentation | `apps/meteor/app/api/server/v1/invites.ts` |
| Chaining + augmentation + `$ref` | `apps/meteor/app/api/server/v1/custom-sounds.ts` |
| GET with `$ref` to typia schemas | `apps/meteor/app/api/server/v1/custom-sounds.ts` |
| GET with pagination | `apps/meteor/app/api/server/v1/moderation.ts` |
| POST endpoint | `apps/meteor/app/api/server/v1/import.ts` |
| Multiple endpoints (misc) | `apps/meteor/app/api/server/v1/misc.ts` |
| GET with permissions | `apps/meteor/app/api/server/v1/permissions.ts` |
| Typia schema generation | `packages/core-typings/src/Ajv.ts` |
| AJV schema registration | `apps/meteor/app/api/server/ajv.ts` |
| Error response validators | `packages/rest-typings/src/v1/Ajv.ts` |
| Request validators (examples) | `packages/rest-typings/src/v1/moderation/` |
| Router implementation | `packages/http-router/src/Router.ts` |

View File

@@ -0,0 +1,83 @@
# Apps-Engine migration to microservice
## Goal
Isolate apps execution into a microservice to decouple monolith scaling from apps-engine scaling. However, the current package architecture ends up making this transition quite difficult, so we'll move in phases.
Here is an overview of the action items required to achieve our goal:
- **Migrate app management code from @rocket.chat/apps-engine to private @rocket.chat/apps** - Stop publishing code that handles app management on the server alongside the public surface that apps actually use. Anything living _outside_ the `@rocket.chat/apps-engine/definition/` directory should either move to the internal `@rocket.chat/apps` package or be removed.
- **Consolidate all interaction with the apps internals on the server to the AppsEngineService** - Backend code handling apps business rules has to happen via a single entrypoint: the AppsEngineService class. This will give us the flexibility of serving the functionality either locally or through NATS.
- **Break the file upload flow for async validation step** - Decouple upload validation from the actual file upload request. This allows us to run the Apps-Engine `IPreFileUpload` event asynchronously and gives us a longer window for the execution, which means we're not forced to transfer file contents over NATS.
- **Create Apps-Engine service Docker image**
## Part 1: Migrate app management code from @rocket.chat/apps-engine to private @rocket.chat/apps
The Apps-Engine originally lived in its own repository, so the colocation of domain there made some sense. However, especially after the introduction of the deno-runtime, a large portion of the code published is never used by the apps themselves, reserved only for server usage. As we moved the package into the monorepo, the initial colocation stopped being a requirement.
Besides that, migrating app management code away from a public package enables us to integrate more closely with the private packages available in the monorepo, so we can effectivelly reuse more code and standardize approaches like `@rocket.chat/logger`.
To make this migration easier to understand and review, we're using a stacked PR approach on Github - similar to a feature branch but disallowing sibling PRs. They are:
- [40395](https://github.com/RocketChat/Rocket.Chat/pull/40395) The feature branch itself. It will accumulate the changes of the whole stack (⟶ `develop`)
- [40183](https://github.com/RocketChat/Rocket.Chat/pull/40183) Replaces `AppPackageParser.getEngineVersion()` - which resolved the version by traversing the filesystem relative to `__dirname` - with a direct import of `ENGINE_VERSION`. This will support the migration of the `AppPackageParser` class itself (⟶ #40395)
- [40184](https://github.com/RocketChat/Rocket.Chat/pull/40184) Copies all relevant source files from `packages/apps-engine/src/server`, `packages/apps-engine/src/client`, `packages/apps-engine/deno-runtime`, `packages/apps-engine/tests` and `packages/apps-engine/scripts` into their corresponding path at `packages/apps` (⟶ #40183)
- [40185](https://github.com/RocketChat/Rocket.Chat/pull/40185) Adapts the path resolution of the apps-engine package for the deno runtime (⟶ #40184)
- [40186](https://github.com/RocketChat/Rocket.Chat/pull/40186) Flips the switch; changes code in `apps/meteor` and `packages/core-services` that pointed to `@rocket.chat/apps-engine` and makes it point to `@rocket.chat/apps`. This is the turning point in what is actually executed (⟶ #40185)
- [40343](https://github.com/RocketChat/Rocket.Chat/pull/40343) Removes old files from the `@rocket.chat/apps-engine` package (⟶ #40186)
### Architecturally Relevant Decisions
#### 1. `@rocket.chat/apps-engine` becomes a definition-only public package
The most consequential decision in this migration is that `@rocket.chat/apps-engine` is narrowed to expose **only** its `definition/` directory. All server-side management code, the client UI host, the deno-runtime, scripts, and tests were removed from the package. The `files` field in `package.json` went from listing `client/**`, `definition/**`, `deno-runtime/**`, `lib/**`, `scripts/**`, and `server/**` down to just `definition/**`. The package description was updated to reflect this: "The public API and type definitions for Rocket.Chat App development."
This preserves the public contract for external app developers while keeping all server-side complexity private and under active control inside the monorepo.
#### 2. `@rocket.chat/apps` becomes the operational package
`@rocket.chat/apps` (already private, `"private": true`) absorbs everything that was removed from `apps-engine`. Its `package.json` gained the runtime dependencies that `apps-engine` shed (`adm-zip`, `debug`, `esbuild`, `jose`, `jsonrpc-lite`, `lodash.clonedeep`, `semver`, `stack-trace`) and its build pipeline was extended to also run `deno-cache` and produce `deno-runtime/` and `.deno-cache/` artifacts alongside the TypeScript `dist/`.
The package also received its own `turbo.json` that declares those additional output directories, ensuring Turborepo cache invalidation covers the deno artefacts.
#### 3. `ENGINE_VERSION` is exported from the definition layer
`AppPackageParser` validated app compatibility by reading the engine's `package.json` via filesystem traversal relative to `__dirname`. That assumption breaks the moment the code moves to a different package. The fix is a dedicated `packages/apps-engine/src/definition/version.ts` that exports a single constant `ENGINE_VERSION`. It uses a `require()` (not a static import) with a path determined at runtime by checking whether `__dirname` ends with `src/definition` (source/ts-node context) or not (compiled context), resolving to `../../package.json` or `../package.json` respectively. Using `require()` prevents TypeScript from resolving the path at compile time, making the two-level distinction safe.
#### 4. Dynamic Deno import map generation at subprocess spawn time
The previous static `deno.jsonc` contained `"@rocket.chat/apps-engine/": "./../src/"` — a relative path that only worked when `deno-runtime/` and `apps-engine/src/` were siblings inside the same package. After the move, `deno-runtime/` lives in `@rocket.chat/apps` while the type definitions it imports live in `@rocket.chat/apps-engine`.
The solution is to drop the static entry and instead generate a temporary `deno_runtime.jsonc` file into the subprocess temp directory before each Deno process spawn. `DenoRuntimeSubprocessController` resolves the apps-engine source directory at runtime via `require.resolve('@rocket.chat/apps-engine/package.json')` and injects it as an absolute `file:` URL into the import map. This strategy works correctly in any environment (monorepo dev, Meteor bundle, CI) without any assumptions about directory layout.
Additionally, `--sloppy-imports` was added to the Deno subprocess flags, allowing the runtime to import `.ts` files from `apps-engine` without requiring explicit `.ts` extensions in every import specifier.
#### 5. Cross-boundary types promoted to the definition layer
Several types that were previously only exported from `server/` were needed by code that must stay in the public API. Rather than giving consumers a path that crosses into `@rocket.chat/apps`, these types were moved into `definition/`:
- `GetMessagesOptions`, `GetRoomsFilters`, `GetRoomsOptions`, and `GetMessagesSortableFields` moved from `server/bridges/RoomBridge.ts` into `definition/rooms/IGetMessagesOptions.ts` and re-exported from `definition/rooms/index.ts`.
- `OAuth2Client` moved from `server/oauth2/OAuth2Client.ts` into `definition/oauth2/OAuth2Client.ts`; the server copy became a re-export pointing to the definition location.
- `IExternalComponentRoomInfo` and `IExternalComponentUserInfo` moved from `client/definition/` into `definition/externalComponent/`, keeping `IExternalComponentState` (which depends on them) coherent within the definition layer.
- `IRoomRead` and other accessors that referenced the moved types had their import paths updated accordingly.
The guiding principle: anything that crosses the public/private boundary must live in `definition/`.
#### 6. Deep `@rocket.chat/apps/dist/` path imports
An attempt to use the modern `exports` field in `@rocket.chat/apps/package.json` (to define clean subpath entry points like `./server/*` and `./client/*`) was reverted because Meteor's bundler does not support the `exports` field, and thus could not resolve import paths as expected. The package fell back to `main` + `types` for the root entry point, and server-only types are imported via deep `dist/` paths (e.g., `@rocket.chat/apps/dist/server/IGetAppsFilter`). `typesVersions` was also removed for the same reason.
#### 7. TypeScript strict mode relaxed for the migrated code
The migrated code from `apps-engine` was written without strict TypeScript checks. Rather than fixing every existing violation upfront, `packages/apps/tsconfig.json` explicitly disables `strict`, `noUnusedParameters`, `noImplicitOverride`, and `noImplicitReturns`. This is intentional technical debt: the migration is a lift-and-shift first; tightening the type-checking can happen incrementally afterwards.
#### 8. CI and Dockerfile changes flow to the new owner
The `esbuild` platform-specific binary cleanup that runs inside Docker builds previously targeted `@rocket.chat/apps-engine/node_modules/@esbuild`; this was updated to `@rocket.chat/apps/node_modules/@esbuild`. Likewise, the microservice Dockerfiles (account-service, authorization-service, ddp-streamer, etc.) that copied `packages/apps-engine/client` into the image dropped that directory, as it no longer exists in the package. The `packages/apps-engine/definition` copy was kept because those types are still consumed by core-services and other packages that run inside those microservices.
#### 9. ESLint rules unified across both packages
The root `eslint.config.mjs` was updated to apply the same rule overrides (permissive `@typescript-eslint` settings, test-file non-null assertion relaxation, and built artefact `ignores`) to both `packages/apps-engine/**/*` and `packages/apps/**/*`, treating the two packages as a coherent unit under the same lint policy.

View File

@@ -0,0 +1,123 @@
# Backend Folder Structure
This document describes how the server-side code of the main app (`apps/meteor/`) is organized, what each folder is responsible for, and — most importantly — **how to decide where new code goes**. It replaces the migration plan that drove the 2026 restructure (PRs #40259, #41115, #41126, #41155, #41225, #41315, #41381), which moved everything out of the old `app/*/server/` feature folders.
## The organizing principle
```
server/<responsibility>/<domain>/<file>
```
Code is grouped **by responsibility first** (what *kind* of code it is: an API endpoint, a hook, a domain function, a Meteor method) **and by domain second** (what *feature area* it belongs to: rooms, users, messages, omnichannel…). This is the opposite of the old layout, which grouped by feature first (`app/livechat/server/…`) and made responsibilities (methods, hooks, API, lib) repeat inside every feature.
Practical consequence: when you look for code, ask "what kind of thing is it?" before "what feature is it for?". A livechat REST endpoint is in `server/api/v1/omnichannel/`, a livechat hook in `server/hooks/omnichannel/`, a livechat domain function in `server/lib/omnichannel/`.
## Top-level map of `apps/meteor/`
| Path | What it is |
| ----------- | ---------- |
| `server/` | **All community server code.** Described in detail below. |
| `ee/server/` | Enterprise server code — an exact mirror of `server/`'s responsibility layout. See [License boundary](#the-license-boundary-ee). |
| `client/`, `ee/client/` | Frontend (React) code. |
| `app/` | **Legacy remnant, do not add server code here.** What's left is genuinely shared client+server code (`app/*/lib/`), client-only feature code (`app/*/client/`), and `app/apps/server/` (Apps-Engine bridges/converters, kept intentionally). |
| `lib/` | Small utilities shared between client and server (isomorphic, no Meteor/server imports). |
| `imports/` | Legacy Meteor-style feature folders (`personal-access-tokens`, message-read-receipt client parts). Avoid adding to it. |
| `packages/` (repo root) | Workspace packages (`@rocket.chat/*`). Cross-app logic, typings (`core-typings`, `rest-typings`), and services SDK (`core-services`) live here — prefer a package when code must be shared beyond `apps/meteor`. |
| `tests/` | Test suites — see [Tests](#tests). |
## Inside `server/`
### Entry points
- `main.ts` — the server entry point. Import order here matters (models → settings definitions → startup → `importPackages` → methods → publications).
- `importPackages.ts` — the big list of side-effect imports that load every feature at startup. If you create a module that must run at boot and nothing imports it, register it here (or in the closer aggregator: `meteor-methods/index.ts`, `startup/index.ts`).
- `models.ts` — registers the MongoDB model classes from `@rocket.chat/models`.
### Responsibility folders
| Folder | Responsibility | Domain layout |
| ------ | -------------- | ------------- |
| `api/` | REST API. `ApiClass.ts`/`router.ts` are the Hono-based framework, `v1/` holds one file per endpoint group (`users.ts`, `chat.ts`, …), `v1/omnichannel/` the livechat endpoints, `v1/middlewares/` auth/cors/metrics, `lib/` request helpers, `validation/` the ajv setup, `webhooks.ts` the `/hooks/*` incoming-webhook API. Endpoint payload types live in `packages/rest-typings`. |
| `services/` | Internal services registered on the `@rocket.chat/core-services` broker (one folder per service: `room/`, `team/`, `omnichannel/`, `federation/`, …). Callable cross-process in microservices deployments. **Preferred home for new business logic** — see the decision guide. |
| `lib/` | Everything that is a plain function/class library: domain functions and shared server utilities. See [`server/lib/` domains](#serverlib-domains). |
| `hooks/` | Event handlers registered on the `callbacks` system (`callbacks.add('afterSaveMessage', …)`). Domain subfolders: `messages/`, `rooms/`, `auth/`, `omnichannel/`; a few cross-cutting ones sit flat (`afterUserActions.ts`, `sauMonitorHooks.ts`). A hook file only registers listeners — real logic should live in `lib/` or `services/` and be called from the hook. |
| `meteor-methods/` | **Deprecated.** Meteor DDP RPC handlers, kept only for backward compatibility with existing clients. Domain subfolders (`users/`, `rooms/`, `messages/`, `auth/`, `omnichannel/`, `settings/`, `platform/`, `media/`, `import/`, `integrations/`). Registration happens via side-effect import from `meteor-methods/index.ts` — a method file that nobody imports **silently stops existing** (lint/tsc/tests all stay green), so never remove an import without removing the method. Do not add new methods; add a REST endpoint instead. |
| `publications/` | **Deprecated.** Meteor DDP publications. Same story as methods: don't add, use REST + streams. |
| `settings/` | The settings system. `index.ts` is the **registry barrel** (`settings`, `settingsRegistry` — what everything imports); `definitions.ts` loads the per-domain setting definition files (`accounts.ts`, `email.ts`, `omnichannel.ts`, …) that call `settingsRegistry.addGroup(...)`; `theme/` is the theme compiler; `lib/` settings-specific helpers. New setting? Add it to the matching definitions file, not to the registry. |
| `slashcommands/` | One file per slash command (`ban.ts`, `invite.ts`, …). |
| `bridges/` | Adapters to external systems: `irc/`, `slack/` (slackbridge), `smarsh/`, `webdav/`, `nextcloud/`. Leaf code — imports the core, nothing imports it. |
| `cron/` | Scheduled jobs. |
| `startup/` | Boot-time configuration and one-off initialization (`initialData.ts`, `rateLimiter.js`, `robots.js`, `migrations/`). |
| `routes/` | Non-REST HTTP routes (avatar serving, etc.). |
| `database/` | Mongo connection utilities (`trash`, `readSecondaryPreferred`, transaction helpers). |
| `modules/` | Larger self-contained subsystems (core-apps, listeners, notifications, streamer). |
| `features/` | Feature-flag style subsystems (e.g. `EmailInbox/`). |
| `configuration/` | Runtime configuration glue (OAuth, CAS, LDAP wiring). |
| `email/`, `ufs/`, `oauth2-server/`, `deasync/` | Infrastructure kept as-is: mailer transport, Upload-File-System storage engine, OAuth2 provider implementation, deasync shim. |
### `server/lib/` domains
`lib/` is the largest folder. Its immediate children are **domains** (plus a handful of flat cross-cutting utilities like `callbacks.ts`, `i18n.ts`, `notifyListener.ts`, `RateLimiter.js`, `debug.js`, `bugsnag.ts`, `deprecationWarningLogger.ts`, `validateEmailDomain.js`, `defaultBlockedDomainsList.ts`).
| Domain | Contents |
| ------ | -------- |
| `users/` | User domain functions: `deleteUser`, `setRealName`, `saveUserIdentity`, `getFullUserData`, availability checks, `status/` (custom user status)… |
| `rooms/` | Room domain functions: `createRoom`, `deleteRoom`, `addUserToRoom`, `roomCoordinator`, plus `settings/` (save-room-* functions), `invites/`, `retention/` (retention-policy cron). |
| `messages/` | Message domain functions: `sendMessage`, `insertMessage`, `deleteMessage`, `updateMessage`, `loadMessageHistory`… |
| `messaging/` | Messaging *features* around messages: `threads/`, `discussions/`, `reactions/`, `pins/`, `stars/`, `unread/`, `mentions/`, `markdown/`, `emoji`, `msgStream`, `Message.ts` (formatting), `getHiddenSystemMessages`, `validateCustomMessageFields`. Rule of thumb: `messages/` = lifecycle of a message document; `messaging/` = features built on top of messages. |
| `omnichannel/` | The livechat/omnichannel engine: `QueueManager`, `RoutingManager` (+ `routing/` strategies), `business-hour/`, `contacts/`, `analytics/`, transcripts, visitors, guests, departments… The biggest domain. |
| `authorization/` | Permission/role checks: `hasPermission`, `canAccessRoom`, `getRoles`, `constant/permissions.ts` (the permission list), `streamer/` (permission change notifications). |
| `auth/` | Authentication utilities: `passwordPolicy`, `generatePassword`, login attempt logging/restriction, `oauth2-server/` (OAuth app admin), `token-login`. |
| `auth-providers/` | One folder/file per external login provider: `apple/`, `crowd/`, `custom-oauth/`, `oauth/` (core OAuth glue), `github.ts`, `gitlab.ts`, `google.js`, `wordpress.ts`, … |
| `saml/`, `cas/`, `ldap/`, `2fa/`, `e2e/` | Protocol-specific auth/crypto subsystems. |
| `notifications/` | Notification delivery: `push/`, `push-config/`, `email/` (Mailer API), `mail-messages/`, `queue/`, `core/` (Notifications streams), `message/` (per-message desktop/email/mobile decision helpers), direct-reply email processing. |
| `media/` | Files and media: `file-upload/` (FileUpload + storage configs), `file/`, `emoji-custom/`, `emoji-native/`, `custom-sounds/`, `assets/`. |
| `import/` | Importer framework (`classes/`, converters) + one folder per importer (`csv/`, `slack/`, `slack-users/`, `omnichannel-contacts/`, `pending-avatars/`, `pending-files/`). |
| `integrations/` | Incoming/outgoing webhook integrations engine (the REST surface is `server/api/webhooks.ts`). |
| `cloud/` | Rocket.Chat Cloud connectivity, `version-check/`, license sync. |
| `search/`, `autotranslate/`, `statistics/`, `metrics/`, `moderation/`, `dataExport/`, `oauth/`, `roles/`, `bot-helpers/`, `cors/`, `error-handler/`, `ui-master/` | Smaller single-purpose domains, named after what they do. |
| `utils/` | Generic server utilities without a domain (`getURL`, `slashCommand` registry, `restrictions`, JWT helper, timezone…). If a function clearly belongs to a domain above, it does **not** go here. |
| `shared/` | Tiny validators shared across domains (`validateName`, `validateNameChars`…). |
## The EE tree (`ee/server/`) and the license boundary
`ee/server/` mirrors the same responsibility layout: `api/`, `hooks/`, `lib/` (with `omnichannel/`, `license/`, `ldap/`, `canned-responses/`, `abac/`, `audit/`, …), `meteor-methods/`, `settings/`, `cron/`, `models/`, `patches/`, `startup/`, `configuration/`, and `local-services/` (EE internal services — note: `ee/server/services/` is docker/build scaffolding for the microservices images, **not** a code folder).
**The directory boundary is the license boundary.** Code under an `ee/` path is governed by the Enterprise license (`apps/meteor/ee/LICENSE`); everything else is community-licensed:
- Never move a file across the `ee/` boundary in either direction — that silently relicenses it.
- Imports may cross the boundary (community code imports EE modules behind license checks and vice versa); files may not.
- EE features are gated at runtime via `License.onLicense(...)` / license modules — moving a file into `ee/` is not what gates it, but EE-licensed code must live there.
- Standalone EE workspaces (`ee/apps/*`, `ee/packages/*` at the repo root) and EE subtrees inside packages (`packages/*/src/ee/`) are separate from `apps/meteor/ee/`.
## Where do I put new code?
1. **A REST endpoint** → `server/api/v1/<group>.ts` (omnichannel ones in `v1/omnichannel/`), payload schemas in `packages/rest-typings`. This is the default way to expose anything to clients.
2. **Business logic** → prefer a **service** in `server/services/` (registered via `@rocket.chat/core-services`) when the logic has a clear service boundary or must be callable cross-process. Plain **domain functions** in `server/lib/<domain>/` are fine for logic tied to one domain that services and endpoints compose. Long-term direction: business logic trends toward `services/`; `lib/<domain>/` is the pragmatic middle ground.
3. **Reacting to an event** ("when a message is saved…", "after a user logs in…") → `server/hooks/<domain>/`. Keep the hook file thin; put the logic in `lib/`/`services/`.
4. **A Meteor method or publication** → don't. Both are deprecated; expose a REST endpoint (+ streamer event if clients need pushes). If you must touch an existing method, it lives in `server/meteor-methods/<domain>/` and must stay imported by `meteor-methods/index.ts`.
5. **A setting** → the matching definitions file under `server/settings/`.
6. **A slash command** → `server/slashcommands/`.
7. **A scheduled job** → `server/cron/`.
8. **Startup-only wiring** → `server/startup/` (and an import in `main.ts`/`importPackages.ts` if nothing else loads it).
9. **Code shared with the frontend** → `apps/meteor/lib/` for app-local isomorphic helpers, or a `packages/*` package if shared beyond the app. Never import server code from client code.
10. **Enterprise-only** → same decision tree inside `ee/server/`, gated by license checks.
11. **A new domain?** If your code doesn't fit an existing `lib/` domain, create a new `server/lib/<domain>/` folder rather than dropping files flat in `lib/` or stretching `utils/`. Name it after the business concept, not the implementing tech.
Anti-patterns to avoid:
- Adding server code under `app/` (the folder is frozen; only shared/client code remains there).
- Barrel `index.ts` files that just re-export — import the concrete file. (Existing barrels like `settings/index.ts` and `authorization/index.ts` are legacy API surfaces, kept until their consumers are rewritten.)
- Putting logic in a hook/method/endpoint file instead of `lib`/`services` — those files should be thin entry points.
- "Utility" dumping: if it has a domain, it goes in the domain.
## Tests
- **Co-located specs** (`server/**/**.spec.ts`) and **mirrored specs** (`tests/unit/server/**` mirrors the source tree; EE code is mirrored under `ee/tests/unit/**`) both exist. When you move source, move its mirror.
- Two runners with **separate globs**: mocha (`.mocharc.js` `spec` list) and jest (`jest.config.ts` `testMatch`). A spec in a folder not matched by any glob silently never runs; a jest spec caught by a mocha glob throws `jest is not defined`.
- `proxyquire`/`jest.mock` keys are **string literals matched against the loaded module's import specifiers** — lint and tsc cannot validate them. If you move a module, every mock key referencing it (or referencing its dependencies' specifiers) must be updated, and the affected suites actually run. A stale key silently loads the real dependency; if that dependency reaches `server/settings/index.ts`, the whole mocha run dies with a top-level-await transform error — that error is the signature of a stale settings mock.
- Specs must not depend on suite execution order (e.g. on another spec having installed a mocked core-services broker) — mock `@rocket.chat/core-services` locally.
## History
The structure above is the result of a 7-phase migration (finished 2026-07) that dissolved the old Meteor-package-style `app/*/server/` folders: slash commands (#40259), bridges (#41115), REST API (#41126), domain functions (#41155), meteor-methods (#41225), lib/hooks/feature code (#41315), omnichannel + final cleanup (#41381). Files were moved as-is, so `git log --follow` works across the moves. The old `app/<feature>/server/` path of any file can be found in those PRs' manifests if needed.

View File

@@ -0,0 +1,174 @@
# Bundle Optimization: barrel import patches (react-aria, react-stately, zod)
## The Problem
Meteor's bundler (`standard-minifier-js`) does **not** perform tree-shaking. When any code imports from the `react-aria` barrel package:
```typescript
import { FocusScope } from 'react-aria';
```
Meteor resolves the barrel's entry point, which re-exports **all 43 sub-packages** (`@react-aria/dnd`, `@react-aria/calendar`, `@react-aria/table`, etc.). Since there is no dead-code elimination, every sub-package ends up in the main JS bundle — even if only `FocusScope` is used.
The same applies to `react-stately` (20 sub-packages).
**Measured impact before the fix:** 832 KB of react-aria + react-stately in the main bundle, of which ~700 KB was unused code.
## The Solution
Two complementary changes:
### 1. Yarn patches on the barrel packages
`yarn patch` replaces the barrel entry points (`dist/main.js`, `dist/module.js`, `dist/import.mjs`) with slim versions that only re-export the sub-packages actually used.
The patches live in `.yarn/patches/` and are referenced in `package.json` resolutions. They are applied automatically on every `yarn install`.
**react-aria** — retained sub-packages:
`button`, `focus`, `i18n`, `interactions`, `listbox`, `menu`, `overlays`, `select`, `separator`, `slider`, `utils`, `visually-hidden`, `dialog`
**react-stately** — retained sub-packages:
`collections`, `menu`, `overlays`, `select`, `slider`, `tree`
### 2. Direct sub-package imports in source code
All imports across `apps/meteor` and workspace packages (`gazzodown`, `ui-client`, `ui-contexts`, `ui-voip`) were changed from barrel to direct sub-package imports:
```typescript
// Before
import { FocusScope } from 'react-aria';
import { useOverlayTriggerState } from 'react-stately';
// After
import { FocusScope } from '@react-aria/focus';
import { useOverlayTriggerState } from '@react-stately/overlays';
```
This is the correct long-term fix, but alone it was not enough because `@rocket.chat/fuselage` (published npm package) still uses `require("react-aria")` internally via its UMD build. The yarn patch covers that case.
## Results (react-aria + react-stately)
| Metric | Before | After | Delta |
|---|---|---|---|
| Main JS (minified) | 3688 KB | 3199 KB | **-489 KB (-13%)** |
| Main JS (gzip) | 939 KB | 841 KB | **-98 KB (-10%)** |
---
## Zod v4 locale barrel
### The Problem
Zod v4 re-exports all 50 locale files from its main entry point:
```js
// zod/v4/classic/external.js
export * as locales from "../locales/index.js";
```
This means `import { z } from 'zod'` pulls in error messages for Arabic, Hebrew, Thai, Russian, and 46 other languages — even though Rocket.Chat only uses the English locale (loaded by default via `config(en())`).
**Measured impact:** 147 KB (50 locale files) out of 278 KB total for zod — **53% of the zod bundle was unused locale data**.
### The Solution
A `yarn patch` on `zod@4.3.6` removes the `export * as locales` line from the barrel. The English locale (`en.js`) remains loaded since it's imported separately by `config(en())`. All other locales remain available for explicit import if needed:
```typescript
// Still works:
import pt from 'zod/v4/locales/pt';
import { config } from 'zod';
config(pt());
```
### What requires attention
#### Upgrading zod
When upgrading zod, the patch must be re-created:
1. Remove the old patch reference from `package.json` resolutions
2. Delete `.yarn/patches/zod-*.patch`
3. Run `yarn install`
4. Run `yarn patch zod@npm:<new-version>`
5. Remove the `export * as locales` line from `v4/classic/external.js`
6. Remove the `exports.locales` require from `v4/classic/external.cjs`
7. Run `yarn patch-commit -s <patch-folder>`
#### If zod locales are needed at runtime
If a feature requires localized zod error messages (e.g., form validation in the user's language), import the specific locale directly instead of relying on the barrel:
```typescript
import de from 'zod/v4/locales/de';
import { config } from 'zod';
config(de());
```
This loads only the one locale needed (~3 KB) instead of all 50 (~147 KB).
## What requires attention
### Adding new react-aria hooks or components
If you need to use a react-aria hook or component that is **not** in the slim barrel (e.g., `useCalendar`, `useTable`, `useDrag`), you must:
1. **Import from the sub-package directly** (preferred):
```typescript
import { useCalendar } from '@react-aria/calendar';
```
2. **Or update the yarn patch** if the import comes from a dependency you don't control (like `@rocket.chat/fuselage`):
```bash
yarn patch react-aria@npm:3.37.0
# Add the missing export to dist/import.mjs, dist/module.js, and dist/main.js
yarn patch-commit -s <patch-folder>
```
### Upgrading react-aria / react-stately versions
When upgrading these packages, the yarn patches must be re-created for the new version:
1. Remove the old patch references from `package.json` resolutions
2. Delete the old `.yarn/patches/react-aria-*.patch` and `react-stately-*.patch` files
3. Run `yarn install` to get the unpatched version
4. Run `yarn patch react-aria@npm:<new-version>` and re-apply the slim barrel
5. Run `yarn patch react-stately@npm:<new-version>` and re-apply the slim barrel
6. Verify no new exports are needed by running the app and checking for runtime errors
### Upgrading @rocket.chat/fuselage
When the fuselage package is updated, check if it uses any new react-aria exports. You can verify with:
```bash
grep -oE 'react_aria_1\.[a-zA-Z]+' node_modules/@rocket.chat/fuselage/dist/fuselage.development.js | sort -u
grep -oE 'react_stately_1\.[a-zA-Z]+' node_modules/@rocket.chat/fuselage/dist/fuselage.development.js | sort -u
```
If there are new exports, add them to the yarn patch.
### The long-term fix
The react-aria/react-stately yarn patches are a workaround. The proper fix is for `@rocket.chat/fuselage` to import from sub-packages directly instead of the barrel, which eliminates the need for those patches entirely. Once that is done, only the direct sub-package imports in source code are needed.
The zod patch is a workaround for a design choice in zod v4 (`export * as locales` in the main barrel). This may be addressed upstream — track https://github.com/colinhacks/zod for changes to the locale export strategy.
## How to analyze the bundle
Meteor generates a `.stats.json` file alongside each build. To inspect it:
```bash
# Find the latest stats file
ls -t .meteor/local/build/programs/web.browser/*.stats.json | head -1
# Quick summary
python3 -c "
import json
with open('<stats-file>') as f:
data = json.load(f)
print(f'Total: {data[\"totalMinifiedBytes\"]/1024:.0f} KB')
print(f'Gzip: {data[\"totalMinifiedGzipBytes\"]/1024:.0f} KB')
"
```
The stats file contains a per-package breakdown in `minifiedBytesByPackage`, with `packages/modules.js` containing a nested tree of every npm module included in the main bundle.

125
docs/coverage.md Normal file
View File

@@ -0,0 +1,125 @@
# Code Coverage
This document explains how code coverage instrumentation works in Rocket.Chat's build and CI pipeline.
## Overview
Coverage is collected during E2E test runs (API, UI, Livechat) to measure how much of the server-side code is exercised by tests. The instrumentation uses [Istanbul](https://istanbul.js.org/)-compatible tooling, which injects counters (`__coverage__`) into the compiled code at build time.
## Architecture
The coverage pipeline has three components:
1. **Build-time instrumentation** - injects coverage counters into the code during Meteor build
2. **Runtime collection** - the `rocketchat:coverage` Meteor package collects `__coverage__` data on process exit
3. **CI reporting** - test workflows merge coverage data and upload reports
```
Build (SWC + plugin) --> Run tests --> Process exit triggers report --> Merge & upload
```
## Build-time instrumentation
### Modern build stack (SWC)
Rocket.Chat uses Meteor's modern build stack with [SWC](https://swc.rs/) as the transpiler. For coverage builds, the [`swc-plugin-coverage-instrument`](https://github.com/kwonoj/swc-plugin-coverage-instrument) plugin is injected into `.swcrc` at build time.
This is configured in `.github/actions/meteor-build/action.yml`:
```yaml
env:
BABEL_ENV: ${{ inputs.type }} # "production" or "coverage"
```
When `BABEL_ENV=coverage`, the build script:
1. Adds `rocketchat:coverage` to `.meteor/packages`
2. Injects `swc-plugin-coverage-instrument` into `.swcrc` via a node script:
```js
const swcrc = JSON.parse(fs.readFileSync('./apps/meteor/.swcrc', 'utf8'));
swcrc.jsc.experimental = swcrc.jsc.experimental || {};
swcrc.jsc.experimental.plugins = swcrc.jsc.experimental.plugins || [];
swcrc.jsc.experimental.plugins.push(['swc-plugin-coverage-instrument', {}]);
fs.writeFileSync('./apps/meteor/.swcrc', JSON.stringify(swcrc, null, 2) + '\n');
```
This approach ensures the same build pipeline (SWC) is used for both regular and coverage builds, avoiding behavioral differences between build modes.
### Legacy build stack (Babel)
Before the modern build stack, coverage was handled via `babel-plugin-istanbul` configured in `.babelrc`:
```json
{
"env": {
"coverage": {
"plugins": [
["istanbul", { "exclude": ["**/*.spec.js", "**/*.test.js"] }]
]
}
}
}
```
This section is still present in `.babelrc` as a fallback for files that fall back to Babel compilation (e.g., SWC-incompatible code).
## Runtime collection: `rocketchat:coverage`
The `rocketchat:coverage` Meteor package (`apps/meteor/packages/rocketchat-coverage/`) is only added to the build during coverage runs. It:
1. Registers a `process.on('exit')` handler
2. Reads `globalThis['__coverage__']` (populated by the instrumentation)
3. Generates a coverage report using `istanbul-lib-coverage` and `istanbul-reports`
Configuration via environment variables:
| Variable | Description | Example |
|---|---|---|
| `COVERAGE_DIR` | Output directory for reports | `/tmp/coverage/api` |
| `COVERAGE_FILE_NAME` | Report filename | `api-1.json` |
| `COVERAGE_REPORTER` | Istanbul reporter format | `json`, `lcov` |
## CI workflow
Coverage is collected in the `ci-test-e2e.yml` workflow:
1. **Build**: `meteor-build` action runs with `type: coverage`, producing a Docker image with instrumented code
2. **Test**: E2E tests run against the instrumented server. On each test shard:
- `COVERAGE_DIR`, `COVERAGE_FILE_NAME`, and `COVERAGE_REPORTER` are set
- When the Rocket.Chat process exits after tests, the coverage plugin writes a JSON report
3. **Merge**: `nyc merge` combines per-shard JSON reports into a single coverage file
4. **Upload**: Coverage data is uploaded to Codecov
## Local development
To build with coverage locally:
```bash
cd apps/meteor
# Inject the SWC coverage plugin into .swcrc
node -e "
const fs = require('fs');
const swcrc = JSON.parse(fs.readFileSync('.swcrc', 'utf8'));
swcrc.jsc.experimental = { plugins: [['swc-plugin-coverage-instrument', {}]] };
fs.writeFileSync('.swcrc', JSON.stringify(swcrc, null, 2));
"
# Add the coverage package
echo -e "rocketchat:coverage\n" >> .meteor/packages
# Set env vars and run
COVERAGE_DIR=/tmp/coverage COVERAGE_FILE_NAME=local.json COVERAGE_REPORTER=lcov yarn dev
```
Remember to restore `.swcrc` and `.meteor/packages` after testing.
## Dependencies
| Package | Purpose |
|---|---|
| `swc-plugin-coverage-instrument` | SWC plugin for Istanbul-compatible instrumentation |
| `istanbul-lib-coverage` | Coverage map creation (used by `rocketchat:coverage`) |
| `istanbul-lib-report` | Report context creation |
| `istanbul-reports` | Report formatters (json, lcov, etc.) |

View File

@@ -0,0 +1,215 @@
# Plan: Experimental REST API endpoints
## Goal
Allow REST endpoints to ship to production but **change or be removed in any release
without a major-version bump**. The mechanism must be general-purpose (any team can
use it), not specific to one feature.
## The contract
> Endpoints under `/api/experimental/...` are unstable. They may change shape or be
> removed in any release, without notice and without a deprecation cycle. No semver
> promise attaches to this namespace.
The namespace **is** the contract. A caller hitting `/api/experimental/*` has opted
into instability by the URL alone. `/v1` keeps its implicit semver-stability promise,
untouched.
**Only the new typed API is allowed on experimental routes.** Endpoints must be
registered with `.get()` / `.post()` / `.put()` / `.delete()` (with AJV `body` / `query`
/ `response` validators). The deprecated `.addRoute()` must not be used — new surface
area should not be born on the legacy registration path. This is a documented rule, not a
compiler-enforced one: `API.experimental` is a plain `APIClass`, and `.addRoute()` already
carries `@deprecated` everywhere it is reachable.
## Why this design (key findings from the current code)
- `createApi({ version })` turns the `version` string into the URL path segment, so a
new instance with `version: 'experimental'` mounts at `/api/experimental/<name>`
without changing any router internals — the new router still has to be mounted in
`startRestAPI` (see Step 2). See `apps/meteor/server/api/api.ts` (the `API` object and
`createApi`) and `apps/meteor/server/api/ApiClass.ts` (`apiPath` composition in the
`APIClass` constructor).
- The typed route methods `.get()/.post()/.put()/.delete()` are generic over
`TSubPathPattern extends string` (`ApiClass.ts`, `APIClass.method()` and its
per-verb wrappers) — they are **not** gated on `keyof Endpoints`. Routes accumulate
*outward* into the instance's `TOperations`, read back by `ExtractApiClassEndpoints`
(`apps/meteor/server/api/api.ts`). So a separate experimental instance works *with*
the type system.
- `PathPattern`, `Method`, `Path`, and the typed client are all derived from the
`Endpoints` interface (`packages/rest-typings/src/index.ts`). Keeping
experimental paths **out** of that interface keeps the stable client surface clean
and forces explicit opt-in for experimental ones.
- The deprecation framework already writes `x-deprecation-*` response headers
(`writeDeprecationHeader` in `apps/meteor/server/lib/deprecationWarningLogger.ts`). We
mirror that pattern for an `x-experimental` / `Warning` signal.
- Auth, permissions, rate limiting, CORS, AJV validation, and metrics all come from
`createApi` + the middleware chain in `startRestAPI` — experimental endpoints get
them for free.
---
## Commit constraint
**Each implementation step below ships as exactly one commit.** No step is split across
multiple commits, and no commit spans more than one step. This keeps the history
bisectable, makes each phase independently reviewable and revertable, and maps the PR
review 1:1 onto the plan.
- A step's commit must leave the tree in a compiling, lint-clean state (`yarn lint
--quiet` passes) — partial work is squashed before committing.
- Commit message subject names the step, e.g. `feat(api): add experimental API instance
(step 1)`.
- If a step turns out to require a prerequisite not in the plan, add it to that step's
single commit rather than introducing an out-of-band commit.
## Implementation steps
### Step 1 — Add the `experimental` API instance
**File:** `apps/meteor/server/api/api.ts`
1. In the `API` object literal, add:
```ts
experimental: createApi({ version: 'experimental', useDefaultAuth: true }),
```
Place it between `v1` and `default`.
2. Add an `experimental` entry to the `API` type annotation so it is typed:
`APIClass<'/experimental'>`. Hiding `addRoute()` from that type was considered and
dropped: the typed methods return `this`, so a restricted surface only holds until the
first chained registration, and closing that hole means duplicating every typed
signature and widening the route-extraction types that pattern-match `APIClass`. Not
worth it for a rule `@deprecated` already signals.
3. Refreshing experimental routes when settings change is a **required** parity
condition, not an optional extra — the contract above promises experimental
endpoints get rate limiting "for free", which only holds if the refresh callbacks
cover them. The `settings.watch(...)` callbacks in this file that must also update
`API.experimental`:
- `API_Enable_Rate_Limiter_Limit_Time_Default` → `reloadRoutesToRefreshRateLimiter()`
- `API_Enable_Rate_Limiter_Limit_Calls_Default` → `reloadRoutesToRefreshRateLimiter()`
- `Accounts_CustomFields` → `setLimitedCustomFields()`
**Known gap:** the rate-limiter watchers are at parity; the `Accounts_CustomFields`
watcher still updates `API.v1` only. That is currently harmless — no experimental
endpoint returns user objects — but it must be closed before one does.
**Acceptance:** `API.experimental.get('ping', { ... }, handler)` compiles and serves at
`GET /api/experimental/ping`.
**Commit (1 of 5):** `feat(api): add experimental API instance`
### Step 2 — Mount it in the request pipeline
**File:** `apps/meteor/server/api/api.ts`, `startRestAPI`
1. Insert `.use(API.experimental.router)` into the chain, **before**
`.use(API.default.router)`. Order matters: `default` is the catch-all.
2. Add a second `metricsMiddleware` block pointed at `API.experimental` so experimental
traffic is measured. Metrics are the canary used later to decide whether an endpoint is
ready for promotion to `/v1`. Because every block shares the same `/api` mount, each one
needs a guard or a request is sampled more than once: the versioned blocks opt in via
`basePathRegex`, and a catch-all block for `API.default` (`/api/info`, `/api/docs/json`,
unmatched `/api/*`) opts out via `excludePathRegex`. Without that catch-all block the
guards silently drop default-router traffic that used to be sampled.
**Acceptance:** experimental requests appear in the REST API Prometheus metrics labelled
`version=experimental` — specifically that label, not merely a distinguishable one. A
change that only adjusted the path regex while leaving experimental traffic under the
`v1` version label does not satisfy this. `/api/v1/*` and default-router traffic each
still record exactly one sample under their own label.
**Commit (2 of 5):** `feat(api): mount experimental router and metrics`
### Step 3 — Runtime "unstable" signal (mirror deprecation headers)
**New file:** `apps/meteor/server/api/v1/middlewares/experimental.ts`, colocated with the
existing middlewares.
1. Write a middleware that sets, on every response from the experimental instance:
```
Warning: 299 - "experimental: endpoint is unstable and may change without notice"
x-experimental: true
```
`x-experimental: true` is the **supported programmatic signal** — clients should detect
experimental responses with it. `Warning: 299` is a legacy compatibility signal only:
warn code 299 came from RFC 7234, which RFC 9111 has since obsoleted along with the
`Warning` header itself, so modern clients are not expected to generate or interpret
it. It is emitted for the benefit of tooling that still surfaces it, and may be dropped
without it being a breaking change. Model the header-writing on `writeDeprecationHeader`
in `apps/meteor/server/lib/deprecationWarningLogger.ts`.
2. Register the middleware on the shared `/api` mount in `startRestAPI`, **ahead of**
`cors`, scoped to `/api/experimental` by a `basePathRegex` (same shape as the metrics
middleware guard). It cannot live on `API.experimental.router`: `cors` answers rejected
preflights with 403/405 without calling `next()`, so a router-scoped middleware would
never run for those responses.
**Acceptance:** every `/api/experimental/*` response carries both headers — including 404s
and CORS preflight rejections; `/api/v1/*` responses do not.
**Commit (3 of 5):** `feat(api): add experimental unstable-signal middleware`
### Step 4 — Separate, opt-in SDK typings
**File:** `packages/rest-typings/src/index.ts` (+ a new file for the declarations)
1. Create `packages/rest-typings/src/experimental/index.ts` (new folder) and declare:
```ts
export type ExperimentalEndpoints = {
'/experimental/<name>': {
GET: (params: ...) => ...;
};
// ...
};
```
Follow the existing per-resource endpoint style (e.g.
`packages/rest-typings/src/v1/channels/channels.ts`).
2. Export `ExperimentalEndpoints` from the package root, but **do NOT** add it to the
`interface Endpoints extends ...` union. This keeps `PathPattern`,
`Method`, `Path`, and the stable typed client free of experimental paths.
3. Consumers who want typed experimental calls import `ExperimentalEndpoints`
explicitly.
**Acceptance:** `import type { Endpoints } from '@rocket.chat/rest-typings'` does NOT
include experimental paths; `import type { ExperimentalEndpoints }` does.
**Commit (4 of 5):** `feat(rest-typings): add opt-in ExperimentalEndpoints`
### Step 5 — Guardrails (because it is a general mechanism)
1. **No path in both unions.** A type-level CI guard for this was considered and dropped:
union keys are full paths, so `/experimental/x` and `/v1/x` never collide, and the
transition window described below does not produce a collision either. Keep the rule in
the docs instead — promotion means *moving* the declaration to a stable `*Endpoints`
type, not leaving a copy behind.
2. **Promotion path:** document that stabilizing an endpoint means copying it to `/v1`
(optionally keeping the experimental path forwarding for a transition window).
Removal needs no deprecation cycle — but log removals for courtesy.
3. **Docs/CONTRIBUTING note:** state the no-semver guarantee and how to add an
experimental endpoint, so the mechanism is discoverable.
4. **OpenAPI/doc generation:** decide deliberately whether generated API docs scan only
`Endpoints` (experimental endpoints hidden — probably desirable) or also
`ExperimentalEndpoints`.
**Commit (5 of 5):** `chore(api): add experimental guardrails and docs`
---
## Test checklist
- [ ] `GET /api/experimental/<name>` resolves and returns the `x-experimental` + `Warning` headers.
- [ ] `/api/v1/*` responses are unchanged (no experimental headers).
- [ ] Auth / permissions / rate limiting enforced on an experimental route exactly as on `/v1`.
- [ ] `Endpoints` type does not include experimental paths; `ExperimentalEndpoints` does.
- [ ] Experimental requests show up in REST API metrics.
## Files touched (summary)
| File | Change |
| ---- | ------ |
| `apps/meteor/server/api/api.ts` | Add `experimental` instance, type entry, mount in `startRestAPI`, metrics blocks |
| `apps/meteor/server/api/v1/middlewares/experimental.ts` (new) | `x-experimental` / `Warning` header middleware |
| `apps/meteor/server/api/v1/middlewares/metrics.ts` | `basePathRegex` / `excludePathRegex` sampling guards |
| `packages/rest-typings/src/experimental/index.ts` (new) | `ExperimentalEndpoints` type, NOT merged into `Endpoints` |
| `packages/rest-typings/src/index.ts` | Export `ExperimentalEndpoints` |
| docs / CONTRIBUTING | Document the contract + promotion path |

View File

@@ -0,0 +1,116 @@
# Experimental REST API endpoints
> Developer guide. For how the mechanism is built, see
> [experimental-api-endpoints-plan.md](experimental-api-endpoints-plan.md).
## What they are
Experimental endpoints live under `/api/experimental/...` and carry an explicit
stability contract:
> Endpoints under `/api/experimental/...` are **unstable**. They may change shape or be
> removed in **any** release — without notice and without a deprecation cycle. No semver
> promise attaches to this namespace.
Compare with `/api/v1/...`, which is the official, stable surface: its endpoints follow
semver, breaking changes require a major-version bump, and removals go through a
deprecation cycle.
## Why they exist
We sometimes need to ship something to production *before* its API shape has settled:
- A new feature whose request/response contract is still being learned from real usage.
- An endpoint built for a specific client (e.g. our own UI) where we are not yet ready
to commit to it as a public, supported interface.
- Something we want behind a clear "use at your own risk" sign while it matures.
Without an experimental lane, the only choices are bad ones: either freeze a design we
are not confident in onto `/v1` (and then carry it forever, or break it with a major
bump), or keep the feature out of production until the API is perfect. Experimental
endpoints give a third path — ship now, iterate freely, commit later.
## Should I use one? — decision guide
**Use an experimental endpoint when:**
- The request/response shape is likely to change as the feature matures.
- You want production traffic / real feedback before committing to a contract.
- The consumer is internal or opted-in, and can tolerate breaking changes between
releases.
- You would otherwise be tempted to "just put it on `/v1` for now and fix it later."
**Do NOT use an experimental endpoint when:**
- The endpoint is meant for third-party integrators who expect stability. They should
not have to track breakage release-to-release.
- The contract is already well understood and unlikely to change — put it on `/v1`.
- You are tempted to use `experimental` as a permanent home to avoid the discipline of
a stable API. It is a staging area, not a dumping ground (see below).
## Expectations if you publish one
- **It is not forever.** Every experimental endpoint is expected to either be
**elevated to `/v1`** once its contract stabilizes, or be **removed** if it does not
pan out. An endpoint that sits in `experimental` indefinitely is a smell — it means a
decision is overdue.
- **Callers are warned at runtime.** Every experimental response carries
`x-experimental: true` — that is the supported signal to detect and surface in client
code. Responses also carry a `Warning: 299 ...` header, kept only for legacy tooling
that still reads it: RFC 9111 obsoletes the `Warning` header and its warn codes, so do
not build new client logic on it.
- **Typed clients must opt in.** Experimental endpoints are declared in a separate
`ExperimentalEndpoints` type, not in the main `Endpoints` union, so the stable SDK
surface stays honest. Consumers import them deliberately.
- **Use the typed API only.** Register with `.get()` / `.post()` / `.put()` /
`.delete()` and AJV validators. Do not use `.addRoute()`: it is already deprecated
across the whole API, and a namespace created to iterate on new contracts is the last
place that should add to the legacy path.
## Lifecycle: experimental → official
```text
stabilizes
experimental ───────────────▶ v1 (official, semver-stable)
(/api/experimental/x) (/api/v1/x)
│
│ does not pan out
▼
removed (no deprecation cycle needed)
```
**Elevating to `/v1`:**
1. Confirm the contract is stable and you are ready to support it under semver.
2. Add the endpoint under `/v1`: register it on `API.v1` and declare its types in the
appropriate `*Endpoints` type that *is* part of the `Endpoints` union.
3. Optionally keep the experimental path forwarding to the new `/v1` path for a
transition window so existing callers are not broken on the day of promotion.
4. Remove the experimental declaration once the transition window closes.
**Removing an experimental endpoint** needs no deprecation cycle — that freedom is the
whole point of the namespace. Still, log the removal and give a heads-up to any known
consumers as a courtesy.
## Guardrails & tooling
- **No path lives in both unions.** A duplicate key would silently attach a semver
obligation to a path advertised as unstable, so promotion means *moving* the declaration
to a stable `*Endpoints` type, not leaving a copy behind. Nothing enforces this — the
`/experimental/` path prefix keeps the two unions from overlapping in practice.
- **Generated API docs intentionally skip experimental endpoints.** OpenAPI /
doc generation scans the `Endpoints` union, which experimental paths are
deliberately kept out of, so they do not appear in public API docs. This is
by design: an unstable surface should not be advertised as part of the
documented contract. The runtime `x-experimental` / `Warning` headers and
this guide are how the namespace is surfaced instead.
- **Metrics are the promotion signal.** Experimental traffic is recorded in the
REST API Prometheus metrics under `version=experimental`, so real usage can
inform whether an endpoint is ready to graduate to `/v1` or should be removed.
## TL;DR
Experimental endpoints let you ship an API to production while its shape is still in
flux, without locking yourself into semver. They are a **staging area, not a permanent
home**: every one is expected to graduate to `/v1` or be removed. If you need stability
guarantees, use `/v1`. If a third party will depend on it, use `/v1`.

116
docs/features/ban-user.md Normal file
View File

@@ -0,0 +1,116 @@
# Ban User
## Overview
Banning prevents a user from participating in a specific room. Unlike kicking (which deletes the subscription), banning **keeps the subscription record** with `status: 'BANNED'`, creating a persistent access barrier.
## Ban Flow
1. A user with `ban-user` permission (roles: `admin`, `owner`, `moderator`) triggers the ban via UI, API (`POST /v1/rooms.banUser`), or slash command (`/ban @username`).
2. **Validations** (`banUserFromRoomMethod` in `server/lib/banUserFromRoom.ts`):
- Checks `ban-user` permission scoped to the room.
- Checks if the room type allows the action (via `roomDirectives.allowMemberAction`).
- Checks if the banning user has access to the room.
- Checks if the target user exists and is in the room.
- Rejects ban if the target is already banned.
- Rejects ban if the target is the last owner.
3. **Execution** (`performUserBan` in `app/lib/server/functions/banUserFromRoom.ts`):
- Updates the subscription to `status: 'BANNED'` (does not delete the record).
- Removes the room from the user's `__rooms` array.
- Decrements the room's `usersCount`.
- Removes room-scoped roles (`moderator`, `owner`, `leader`) in channels and groups.
- If the room is a team's main room, removes the member from the team.
- Saves a `user-banned` system message.
- Notifies the client with a `removed` event on the subscription (so the client drops the stream).
4. **Callback** `afterBanFromRoom` fires (used by Matrix federation to propagate the ban).
## Unban Flow
1. Triggered via UI (contextual bar "Banned Users"), API (`POST /v1/rooms.unbanUser`), or slash command (`/unban @username`).
2. Finds the subscription via `findOneBannedSubscription`.
3. **Removes the subscription entirely** (`Subscriptions.removeById`) — does not restore it to active status.
4. Saves a `user-unbanned` system message.
5. **Callback** `afterUnbanFromRoom` fires (federation).
**Important:** after unban the user **does not become a member** of the room again. The banned subscription is deleted. The user must be invited or join again.
## Join / Invite / Re-entry Behavior
A banned user **cannot** re-enter the room through any path. The ban must be explicitly lifted first. Below is how each entry point enforces this for both normal and federated rooms.
### Invite via API / UI (`groups.invite`, `channels.invite`, "Add Users")
`addUsersToRoom` checks for a `BANNED` subscription before calling `addUserToRoom`:
- Returns `error-user-is-banned` — the invite is rejected.
- The UI shows a warning modal asking the admin to unban first.
- Applies equally to normal and federated rooms (the check is in the method layer, before the room-type branch).
### Invite link (`useInviteToken`)
`useInviteToken` checks for a `BANNED` subscription before saving the invite token or calling `addUserToRoom`:
- Returns `error-user-is-banned` — the token is not consumed.
- Because the check runs before `Users.updateInviteToken`, the secondary path through `setUsername` (for users who register via invite link) is also blocked.
### Direct join (`channels.join`, `joinRoom`)
`Room.join` calls `canAccessRoom` before `addUserToRoom`:
- For **public rooms** and **public rooms inside teams**, the `canAccessRoom` validators explicitly check `findOneBannedSubscription` and deny access.
- For **private rooms**, `countByRoomIdAndUserId` excludes `BANNED` subscriptions (`status: { $exists: false }`), so the "already joined" validator returns false and access is denied.
### Federation invite events
When a Matrix homeserver sends an invite for a user who is banned locally:
- `handleInvite` in `federation-matrix/src/events/member.ts` finds the existing (banned) subscription and returns early without creating a new one.
- The user never receives an `INVITED` subscription, so `handleJoin` is never reached.
### Expected flow
1. **Unban** the user via `POST /v1/rooms.unbanUser`, `/unban @username`, or the "Banned Users" contextual bar. This deletes the banned subscription.
2. **Invite or join** — the user can now be invited (API, UI, invite link) or join (public rooms) normally.
## Access Control
The `canAccessRoom` validators check for bans in two public room scenarios:
- **Public rooms inside teams** — if banned, access is denied.
- **Regular public rooms** — if banned, access is denied.
For private rooms, access is controlled by the subscription: `countByRoomIdAndUserId` excludes `BANNED` subscriptions, so a banned user has no valid subscription and cannot access the room.
## UI
- **Ban action:** appears in the user info panel (inside a room), gated by `ban-user` permission + `roomCanBan` + federation rules.
- **Banned users list:** "Banned Users" tab in the room toolbox (icon: `ban`, order: 13, requires `ban-user`), with virtualized scroll and infinite pagination via `GET /v1/rooms.bannedUsers`.
- **Unban action:** context menu on each item in the banned users list.
- **Confirmation:** both actions show a `GenericModal` with `danger` variant.
## System Messages
| Key | When |
|-----|------|
| `user-banned` | A user is banned from the room |
| `user-unbanned` | A user is unbanned (including via re-addition) |
## REST Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/v1/rooms.banUser` | Ban a user (accepts `userId` or `username` + `roomId`) |
| POST | `/v1/rooms.unbanUser` | Unban a user |
| GET | `/v1/rooms.bannedUsers` | List banned users (paginated) |
## Key Files
| Layer | File |
|-------|------|
| API routes | `app/api/server/v1/rooms.ts` |
| Validation & permissions | `server/lib/banUserFromRoom.ts` |
| Core ban logic | `app/lib/server/functions/banUserFromRoom.ts` |
| Core unban logic | `app/lib/server/functions/executeUnbanUserFromRoom.ts` |
| Slash commands | `app/slashcommands-ban/server/ban.ts`, `unban.ts` |
| Client ban hook | `client/views/room/hooks/useBanUser.tsx` |
| Client unban hook | `client/views/room/hooks/useUnbanUser.tsx` |
| Ban action (user info) | `client/views/room/hooks/useUserInfoActions/actions/useBanUserAction.tsx` |
| Banned users UI | `client/views/room/contextualBar/BannedUsers/` |
| Subscription types | `packages/core-typings/src/ISubscription.ts` |
| REST typings | `packages/rest-typings/src/v1/rooms.ts` |
| Model typings | `packages/model-typings/src/models/ISubscriptionsModel.ts` |

View File

@@ -0,0 +1,243 @@
# Custom Sidebar Categories
## Overview
Custom categories let each user group their sidebar rooms into personal, named collections that appear
alongside the system groups (Favorites, Teams, Channels, Direct messages, …). A user can create, rename,
delete and reorder categories; move rooms in and out of them via context menus; and toggle whether each
category keeps showing unread rooms while collapsed.
Categories are **per-user** and **client-only in meaning** — they are a presentation grouping of the user's
own subscriptions. They carry **no access, security or membership semantics**: moving a room into a category
never changes who can see the room or the room itself. Assignment is **exclusive** — a room lives in exactly
one place at a time (one custom category, *or* Favorites, *or* its system group).
Requires the `experimental-enterprise-features` license module.
## Data Model
### Category record — `ISidebarCategory`
```ts
// packages/core-typings/src/IUser.ts
export interface ISidebarCategory {
_id: string; // Random.id() for custom; system group key for defaults
name: string; // display name (trimmed; max 30 chars)
default?: boolean; // true for system-group entries managed by the platform
showUnreads?: boolean; // when collapsed, keep listing unread rooms (default: false)
keepUnreadsOnTop?: boolean; // when expanded, sort unread rooms to the top (default: false)
}
```
### Room assignment
Room-to-category assignment is stored on the **subscription**, not on the category:
```ts
// Subscription.category: string | null
```
Set via `POST /experimental/rooms.setCategory`. The subscription field is the source of truth for which
category a room belongs to; the category record only carries display metadata and ordering.
## Persistence
### `sidebarCategories` preference (unified group ordering + category metadata)
The preference `sidebarCategories: ISidebarCategory[]` is the **single source of truth** for both the
ordered list of all visible groups and the metadata of custom categories. It contains entries for custom
categories *and* for any system group whose metadata has been touched (show-unreads, keep-unreads-on-top,
or position).
**Invariant enforced on every write:** all five dynamic groups (`Incoming_Calls`, `Incoming_Livechats`,
`Open_Livechats`, `On_Hold_Chats`, `Unread`) are always stored first, in the order prescribed by
`sidebarSectionsOrder`. Custom and static system groups follow. This is enforced by `withDynamicFirst`
in `useCategoryList.ts`, called by every write hook.
All mutations go through `usePersistCategoriesMutation`, which calls:
```http
POST /v1/users.setPreferences { data: { sidebarCategories: ISidebarCategory[] } }
```
### Room assignment — `POST /experimental/rooms.setCategory`
```http
POST /experimental/rooms.setCategory
{ roomIds: string[], category: string | null }
```
- Requires `experimental-enterprise-features` license.
- Rejects system group keys (`SIDEBAR_SYSTEM_GROUP_KEYS`) as the target category.
- Verifies the target category exists in the user's `sidebarCategories` preference before writing.
- Writes to `subscription.category` and fires change notifications.
- `category: null` unassigns the room, returning it to its system group.
## Group Ordering
### `sidebarSectionsOrder` preference
An **admin-configurable** ordered list of system group keys that defines the default group order when
`sidebarCategories` has no explicit entry for a key. Falls back to `SIDEBAR_SYSTEM_GROUP_KEYS`.
### `mergeWithSectionsOrder(explicitIds, sectionsOrder)` — READ side
Used in `useCategoryList` when building the visible category list. Inserts any key missing from
`explicitIds` before its first successor that is already present, maintaining the `sectionsOrder`
relative ordering for system groups.
### `withDynamicFirst(ids, sectionsOrder)` — WRITE side
Called by every write hook before persisting `sidebarCategories`. Guarantees:
1. All five dynamic group keys appear first (in `sectionsOrder` canonical order), regardless of whether
they are currently visible.
2. Static ids (custom + system) follow, merged with `sectionsOrder` via `mergeWithSectionsOrder`.
This makes moving logic simple: swapping two adjacent static groups is always a valid operation — dynamic
groups can never appear below a static group in the stored list, so a DOWN move can never cross one.
### `SIDEBAR_DYNAMIC_GROUP_KEYS`
```ts
export const SIDEBAR_DYNAMIC_GROUP_KEYS: readonly string[] = [
'Incoming_Calls', 'Incoming_Livechats', 'Open_Livechats', 'On_Hold_Chats', 'Unread',
];
```
Groups that are hidden from the sidebar when empty. They are excluded from user reordering
(`canMoveGroup` returns `false` for them) and are always stored first by `withDynamicFirst`.
## Behaviors / Flows
### Create — `useCreateCustomCategory`
1. Generates `_id` with `Random.id()`.
2. Inserts the new entry at the first position after the last dynamic group entry in the stored list
(using `findLastIndex`), so it appears first among static groups.
3. Optionally assigns rooms via `useSetCategory` in the same call (`Create and move` flow).
### Move room — `useMoveRoomCategory`
Accepts a `target` (category `_id`, `'favorites'` sentinel, or `undefined` to remove).
- `'favorites'`: calls `POST /v1/rooms.favorite` and removes the category assignment.
- Custom category `_id`: calls `POST /experimental/rooms.setCategory`.
- `undefined` / same as current: removes assignment (unfavorites or sets category to `null`).
- Dispatches a toast unless `silent: true` or the room has no name.
### Move category position — `useMoveCategoryPosition`
Takes `currentKeys` (visible group keys in render order), a `key` to move, and a direction.
Performs an adjacent swap in `currentKeys`, then calls `withDynamicFirst` on the result before
persisting — so dynamic groups are always re-sorted to the front regardless of what the swap produced.
### Toggle show-unreads / keep-unreads-on-top — `useToggleUnreads`
`upsertGroupEntry(id, patch)` — updates the matching `ISidebarCategory` entry in `sidebarCategories`
(or creates a new one if the entry doesn't exist yet). Always calls `withDynamicFirst` before persisting.
### Delete — `useDeleteCategory`
Removes the category entry from `sidebarCategories` and calls `POST /experimental/rooms.setCategory`
with `category: null` for all rooms that were in it — returning them to their system group.
### Name validation — `useValidateCategoryName`
Returns an i18n error string or `undefined`. Checks in order:
1. Empty / whitespace → `Please_enter_a_category_name`
2. Longer than `MAX_CATEGORY_NAME_LENGTH` (30) → `Category_name_is_too_long__max__maxLength__characters`
3. Matches a translated system group name (case-insensitive) → `Category_name_conflicts_with_system_group`
4. Duplicate among existing custom categories (case-insensitive, excluding `excludeId`) → `A_category_with_this_name_already_exists`
### Favorites interaction
`FAVORITES_TARGET = 'favorites'` is a sentinel for the Favorites system group. Moving a room to Favorites
calls `POST /v1/rooms.favorite` and strips the category assignment. Moving a room from Favorites into a
category unfavorites it. Assignment is exclusive.
### Visibility in the room list
`filterGroupVisibility` (in `useCategoryList.ts`) applies when building the room list:
- **Dynamic groups** — only shown when non-empty.
- **All other groups** (custom + static system) — always shown when EE is active, even if empty.
This means custom categories are always present in the visible group list with EE, so no category entry
in `sidebarCategories` can be silently dropped during a move.
## Sidebar UI
### Category collapser kebab — `CategoryMenu.tsx`
**Custom category:**
- Order: Move up / Move down / New channel
- Manage: Rename / Delete / New category
- When collapsed: Show unreads toggle
- When expanded: Keep unreads on top toggle
**System group:**
- Order: Move up / Move down (dynamic groups have both disabled)
- When collapsed: Show unreads toggle
- When expanded: Keep unreads on top toggle
### Room context menu — `CategoryRoomMenu.tsx`
For rooms inside a custom category, adds a "Remove from {category}" item.
### Room kebab — `useCategoryMenuItems.ts`
Builds the "Move to" submenu: Favorites + each custom category with a checkmark on the current one.
Appended with a "New category" item and "Remove from {category}" when applicable.
### Room-header grouping menu — `RoomHeaderCategoryMenu.tsx`
Placed before the room title in `RoomHeader`. Icon: `star-filled` (favorited) / `folder` (custom category) / `star` (default). Same item list as the room kebab submenu.
### Modals — `categories/`
| File | Purpose |
|------|---------|
| `CreateCategoryModal.tsx` | Create or "create and move" — single name field, max 30 chars. |
| `ManageCategoryModal.tsx` | Rename with duplicate/empty validation. |
| `DeleteCategoryModal.tsx` | Confirmation + "rooms will return to default groups" warning. |
| `hooks/useCategoryModals.tsx` | Single entry point: `openCreate(room?)` / `openManage(category)` / `openDelete(category)`. |
### Navbar create menu
`useCreateNewMenu.ts` adds a "Category" item (icon: `folder`) in the navbar `+` menu.
## REST Contract
| Method | Endpoint | Purpose |
|--------|----------|---------|
| POST | `/v1/users.setPreferences` | Persist `sidebarCategories` (ordering + metadata) |
| POST | `/experimental/rooms.setCategory` | Assign / unassign rooms to a category |
| POST | `/v1/rooms.favorite` | Favorite / unfavorite (used by the Favorites target) |
## Key Files
| Layer | File |
|-------|------|
| Types | `packages/core-typings/src/IUser.ts` (`ISidebarCategory`, `SIDEBAR_SYSTEM_GROUP_KEYS`) |
| Category list + ordering | `apps/meteor/client/sidebar/hooks/useCategoryList.ts` |
| Room list | `apps/meteor/client/sidebar/hooks/useRoomList.ts` |
| Persist mutation | `apps/meteor/client/sidebar/categories/hooks/usePersistCategoriesMutation.ts` |
| Create category | `apps/meteor/client/sidebar/categories/hooks/useCreateCustomCategory.ts` |
| Move room | `apps/meteor/client/sidebar/categories/hooks/useMoveRoomCategory.ts` |
| Move category position | `apps/meteor/client/sidebar/categories/hooks/useMoveCategoryPosition.ts` |
| Toggle unreads | `apps/meteor/client/sidebar/categories/hooks/useToggleUnreads.ts` |
| Name validation | `apps/meteor/client/sidebar/categories/hooks/useValidateCategoryName.ts` |
| Custom categories list | `apps/meteor/client/sidebar/categories/hooks/useCustomCategories.ts` |
| Set category (client) | `apps/meteor/client/sidebar/categories/hooks/useSetCategory.ts` |
| Server endpoint | `apps/meteor/server/api/experimental/rooms.setCategory.ts` |
| Category UI | `apps/meteor/client/sidebar/categories/` |
| Room kebab | `apps/meteor/client/sidebar/RoomMenu.tsx` |
| Navbar create menu | `apps/meteor/client/navbar/NavBarPagesGroup/hooks/useCreateNewMenu.ts` |
| Room-header grouping | `apps/meteor/client/views/room/Header/icons/RoomGroupingMenu.tsx` |
| i18n | `packages/i18n/src/locales/en.i18n.json` |
## Tests
| Kind | File |
|------|------|
| API (preference persistence + validation) | `apps/meteor/tests/end-to-end/api/sidebar-custom-categories.ts` |
| E2E (Playwright) | `apps/meteor/tests/e2e/sidebar-custom-categories.spec.ts` |
| E2E page object | `apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts` (custom-category helpers) |

150
docs/features/mcp-server.md Normal file
View File

@@ -0,0 +1,150 @@
# MCP Server (Model Context Protocol)
> **Status: Alpha.** Off by default. Its capabilities and configuration may evolve as we gather feedback.
## Overview
The MCP server exposes the Rocket.Chat REST API to [Model Context Protocol](https://modelcontextprotocol.io) clients (Claude Desktop/Code, IDE agents, custom agents). It speaks **JSON-RPC 2.0 over Streamable HTTP** and turns existing REST endpoints into MCP **tools**, so an AI client can drive a workspace (post messages, search, manage rooms, look up users, …) using the user's own credentials and permissions.
It is **enterprise (protected) code** under `apps/meteor/ee/`. The design goal is _native + minimum changes_: it mounts on the existing Hono API router and reuses its authentication, permissions, license checks, remote-address resolution, logging, metrics, tracing, CORS, and generated typed-route metadata. Tool execution goes through the REST API, including each target endpoint's validation, authorization, and rate limiting. The endpoint adds MCP-specific Origin validation for DNS-rebinding protection. There is **no new runtime dependency** — the JSON-RPC layer is implemented directly.
## Endpoint
| Method | Endpoint | Behavior |
| ------ | ------------- | ------------------------------------------------------------------------------------------------------------- |
| POST | `/api/v1/mcp` | JSON-RPC 2.0 message. The `2025-03-26` revision also accepts batch arrays. Response is `application/json`. |
| GET | `/api/v1/mcp` | `405` with `Allow: POST` — this transport does not offer a server-initiated SSE stream. |
The endpoint is attached directly to the existing `/api/v1` Hono router because its JSON-RPC envelopes are defined by the MCP specification rather than Rocket.Chat's REST response contract. It still uses the standard authentication, `access-mcp` permission, and AI license middleware. When `MCP_Enabled` is off the action returns `404`.
## Request lifecycle
The MCP handshake is the standard JSON-RPC flow:
1. **`initialize`** — client and server exchange protocol version + capabilities. The server negotiates the handshake-based Streamable HTTP revisions it supports (`2025-03-26`, `2025-06-18`, and `2025-11-25`) and advertises `{ tools: { listChanged: false } }`. The transport is stateless and does not issue a session id.
2. **`tools/list`** — returns the available tools (name, description, `inputSchema`).
3. **`tools/call`** — runs a tool by name with arguments and returns its result as `content`.
Also handled: `ping`, and the `notifications/initialized` / `notifications/cancelled` notifications (acknowledged with `202`, no body).
After initialization, clients should send the negotiated version in the `MCP-Protocol-Version` header. Requests with an unsupported version are rejected with `400`; when the header is omitted, the transport follows the specification's `2025-03-26` compatibility default. JSON-RPC batching is accepted only for that revision because later Streamable HTTP revisions require one message per POST.
The shared CORS middleware advertises this request header only while MCP is enabled.
## Authentication
Authentication is **reused from the REST layer** — no MCP-specific credential type:
- The client sends a **Personal Access Token** as `X-User-Id` + `X-Auth-Token` headers on every request (including `initialize`), because the route is `authRequired: true`.
- For POST requests, the standard auth middleware resolves the user; the MCP action then verifies that the matching login-token record is a Personal Access Token. Missing, invalid, and session tokens are rejected with `401`. GET is still covered by route authentication but returns `405` without performing the additional token-type lookup because it cannot execute tools.
- Every MCP action additionally requires the **`access-mcp`** permission (`permissionsRequired: ['access-mcp']`), enforced by the standard permissions middleware. Without it the request is rejected with `403`. The permission is granted to `admin` by default; admins can grant it to other roles from the Permissions admin page.
> When creating the PAT, tick **"Ignore Two Factor Authentication"**, otherwise header auth is rejected with a 2FA challenge.
## Transport security
The endpoint validates the `Origin` header to protect browser-accessible deployments from DNS-rebinding attacks. Requests without `Origin` are accepted for native MCP clients. Browser requests are accepted only when their normalized origin matches `Site_Url` or an explicit entry in `API_CORS_Origin` while CORS is enabled. The wildcard (`*`) does not authorize a browser origin for MCP.
Tool dispatch is restricted to server-generated, allow-listed REST paths on `127.0.0.1`; client input cannot select a URL. Redirects are rejected, calls time out after 20 seconds, and each REST response and final encoded MCP response is capped at 5 MiB. Batches run at most four calls concurrently and share the same 5 MiB streaming response budget.
## Licensing
The feature is gated behind the **Rocket.Chat AI add-on** (`chat.rocket.rc-ai`):
- The route is registered with `license: [AI_LICENSE_MODULE]`, so requests are rejected unless the workspace license includes the module.
- The `MCP_*` settings use the same module and an `invalidValue` of `false`, so without the add-on they fall back to **off** and the feature cannot be enabled.
## Tool catalog
Two **bounded** sets are exposed, selected by the `MCP_Expose_Extended_API` setting. The full unfiltered API is **never** exposed.
| `MCP_Expose_Extended_API` | Exposed tools |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **off** (default) | **Minimal curated set** — a small hand-picked list (`post_chat_postMessage`, `get_chat_getMessage`, `post_channels_create`, `get_channels_list_joined`, `get_rooms_get`, `get_users_info`). |
| **on** | **Extended set** — the full catalog filtered by the `ALLOWED_TOOL_NAMES` allow-list (~100 routes), still excluding routes tagged `Missing Documentation`. |
Both sets are built from registered typed-route metadata. The extended set filters generated base names through the allow-list, while the curated set filters routes through its smaller explicit list and supplies fallback descriptions where metadata is incomplete.
### Tool naming & variant expansion
A route's base tool name is `toolNameFor(path, method)` — e.g. `GET /api/v1/users.info` → `get_users_info`. Curated and extended catalogs use the same names, so enabling the extended catalog only adds tools.
When a route's request schema is a `oneOf`/`anyOf` of object sub-schemas with **distinct discriminators** (its `required` keys), each branch becomes its **own tool**, named `<base>_by_<discriminator>`:
- `chat.postMessage` → `post_chat_postMessage_by_channel`, `post_chat_postMessage_by_roomId`
- `users.info` → `get_users_info_by_userId`, `get_users_info_by_username`, `get_users_info_by_importId`, …
The allow-list is matched on the **base name**, so a single entry (`get_users_info`) admits all of that route's variants.
### Schema handling
MCP / the Anthropic tools API require each `inputSchema` to be a plain object schema and reject several JSON-Schema constructs. `mcpSafeSchema` normalizes a route's schema for the tool view (the REST validator is untouched):
- strips OpenAPI-only `nullable` and `not`;
- resolves `oneOf`/`anyOf`/`allOf` by adopting the first branch (so a value union like `string | string[]` keeps a concrete type, and a sub-schema union yields a clean object);
- forces a top-level `type: "object"`.
Tool **descriptions** are sourced from the route schema's own `description` (added in `rest-typings`), falling back to a per-route string.
## Dispatch
`tools/call` executes the target endpoint **as the authenticated user** via a loopback HTTP call to the local REST API (`http://127.0.0.1:<PORT><ROOT_URL_PATH_PREFIX>/api/v1/<route>`), forwarding:
- `X-User-Id` + `X-Auth-Token` (the caller's PAT), so all validation and permission checks run exactly as for a real REST client — **zero duplicated business logic**;
- `X-Real-IP` set to the resolved client address (`this.requestIp`), so the target endpoint's per-route rate limiter keys on the real client rather than the loopback address.
The REST response is wrapped as MCP `content` (`type: "text"`); a non-2xx REST response is returned with `isError: true`. Internal calls time out after 20 seconds. REST bodies and final encoded MCP responses are capped at 5 MiB; batched calls use bounded concurrency and share a single streaming body budget to limit retained response data.
## Rate limiting
The MCP endpoint uses Rocket.Chat's **built-in per-route rate limiter** with a limit of 60 requests per minute (enabled by `API_Enable_Rate_Limiter`, honoring `api-bypass-rate-limit`). Every tool call is also subject to its target REST endpoint's rate limit. The resolved client address is propagated to that endpoint rather than being counted as loopback traffic. MCP protocol requests are additionally bounded to 20 batch entries, four concurrent dispatches, 20-second calls, and a shared 5 MiB response budget. As with every Rocket.Chat API route, deployments behind a proxy must configure `HTTP_FORWARDED_COUNT` and trusted forwarding headers correctly.
## Settings
Registered under **Admin → AI Center → MCP** (`server/settings/ai.ts`):
| Setting | Default | Description |
| ------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MCP_Enabled` | `false` | Enables the `/api/v1/mcp` endpoint. Flagged **alpha** via an admin warning callout (`MCP_Alpha_Alert`). |
| `MCP_Expose_Extended_API` | `false` | When on, exposes the extended allow-listed toolset instead of the minimal curated one. Gated behind `MCP_Enabled`. The full API is never exposed. |
## Connecting a client
```bash
claude mcp add --transport http rocketchat http://<host>/api/v1/mcp \
--header "X-User-Id: <userId>" \
--header "X-Auth-Token: <personalAccessToken>"
```
Raw smoke test:
```bash
H=(-H "Content-Type: application/json" -H "X-User-Id: <uid>" -H "X-Auth-Token: <token>")
curl -s "${H[@]}" http://localhost:3000/api/v1/mcp -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
curl -s "${H[@]}" http://localhost:3000/api/v1/mcp \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"post_chat_postMessage_by_channel","arguments":{"channel":"#general","text":"hello from MCP"}}}'
```
> The client **must** send `Content-Type: application/json` on POST — the shared router only parses the body for that content type.
## Limitations
- **Alpha**, off by default.
- Implements the handshake-based Streamable HTTP lifecycle through protocol version `2025-11-25`; newer lifecycle methods are not yet supported.
- Requires the **Rocket.Chat AI add-on** — both the route and the settings are gated by it.
- One HTTP response per POST. The `2025-03-26` compatibility revision accepts a bounded batch of up to 20 messages; later revisions accept one message per POST. There is no server-initiated SSE stream (`GET` → `405`).
- The official `@modelcontextprotocol/sdk` is intentionally not used; the files are structured so it can be dropped into the transport/server layer later for SSE and richer session handling.
## Key Files
| Layer | File |
| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Hono route registration (`/api/v1/mcp`) + `MCP_Enabled` gate | `apps/meteor/ee/server/api/mcp/index.ts` |
| JSON-RPC handlers (`initialize`/`tools/list`/`tools/call`/…) | `apps/meteor/ee/server/api/mcp/server.ts` |
| Tool catalog (curated + extended allow-list, variants, schema normalization) | `apps/meteor/ee/server/api/mcp/catalog.ts` |
| Tool dispatch (loopback to REST as the user) | `apps/meteor/ee/server/api/mcp/dispatch.ts` |
| License-gated permission seed (`access-mcp`) | `apps/meteor/ee/server/startup/mcp.ts` |
| EE module load | `apps/meteor/ee/server/api/index.ts` |
| Settings (license-gated) | `apps/meteor/server/settings/ai.ts` |
| License module | `packages/ai-search/src/constants.ts` (`AI_LICENSE_MODULE`) |
| Schema descriptions reused as tool docs | `packages/rest-typings/src/v1/chat.ts`, `packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts` |
| i18n | `packages/i18n/src/locales/en.i18n.json` (`MCP_*` keys) |

View File

@@ -0,0 +1,182 @@
# SlackBridge Manual Test Plan
The SlackBridge is a bi-directional bridge between a Rocket.Chat workspace and one or more Slack
workspaces — each token line in the SlackBridge settings gets its own connection and adapter. It
has no automated end-to-end coverage: exercising it requires a real Slack workspace, a real bot
installation, and a human watching both sides at once. This document is the checklist to walk
through when touching the bridge.
**Scope:** the matrices below assume a **single** bridged Slack workspace — configure exactly one
token per setting while running them. Bridging several workspaces at once is a supported
configuration that this plan does not cover; see [Coverage gaps](#coverage-gaps).
Code lives in [apps/meteor/server/bridges/slack/](../../apps/meteor/server/bridges/slack/) —
`SlackAdapter.ts` handles Slack → Rocket.Chat, `RocketAdapter.ts` handles Rocket.Chat → Slack
(hooked in through the `afterSaveMessage`, `afterDeleteMessage`, `afterSetReaction` and
`afterUnsetReaction` callbacks). Settings are declared in
[apps/meteor/server/settings/slackbridge.ts](../../apps/meteor/server/settings/slackbridge.ts).
## Setup
Configure the bridge in **Admin → Settings → SlackBridge**:
| Setting | Notes |
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `SlackBridge_Enabled` | Master switch. Must be on. |
| `SlackBridge_UseLegacy` | Chooses the connection mode — see below. Run the matrices in the mode you are testing. |
| `SlackBridge_APIToken` | Legacy mode only (RTM). |
| `SlackBridge_BotToken`, `SlackBridge_SigningSecret`, `SlackBridge_AppToken` | App mode only (Bolt / Socket Mode). |
| `SlackBridge_Out_Enabled` | Required for anything to flow Rocket.Chat → Slack. |
| `SlackBridge_Out_All` / `SlackBridge_Out_Channels` | Which Rocket.Chat rooms are bridged out. |
| `SlackBridge_Reactions_Enabled` | Gates reaction propagation in both directions. |
| `SlackBridge_AliasFormat` | Optional, Slack → Rocket.Chat only. `%s` is the Slack username; test with `%s (via Slack)`. |
| `SlackBridge_FileUpload_Enabled` | Gates file transfer. |
Two connection modes exist and they use different Slack event plumbing, so a regression can
appear in one and not the other. **Run the full matrix in both** when changing anything in the
connect/event-subscription path:
- **Legacy (`SlackBridge_UseLegacy: true`)** — RTM connection with an API token.
- **App (`SlackBridge_UseLegacy: false`)** — Slack app using bot token, signing secret and app
token.
You also need:
- A Slack workspace where you can create channels and install/invite the bot (referred to below
as **rocketbot**).
- At least one channel that exists on both sides **with** rocketbot in it, and one that exists on
both sides **without** rocketbot in it. Several cases below hinge on that difference.
- A second Slack user, if possible — some behaviors only show up when the message author is not
the same account as the bot.
Watch `SlackBridge` logs (`slackLogger` / `rocketLogger`) while testing; several expected
failures are only visible there.
## How to read the matrices
- **Origin** — where the message being acted on was originally posted.
- **Action performed on** — the side where the tester performs the action.
- Every matrix must also be repeated **on a channel that rocketbot is not a member of**. Nothing
should propagate in either direction for those channels, and nothing should error.
## Sending messages
| # | Origin | Action performed on | Expected result |
| --- | ----------- | ------------------- | ------------------------------------------------------------------------------------------------------- |
| 1 | Slack | Slack | Message appears in the linked Rocket.Chat room, under the alias `SlackBridge_AliasFormat` produces. |
| 2 | Rocket.Chat | Rocket.Chat | Message appears in the linked Slack channel, posted by rocketbot with the sender's username and avatar. |
`SlackBridge_AliasFormat` only affects case 1: `addAliasToMsg` sets `alias` on the **Rocket.Chat**
message, so with `%s (via Slack)` a message from Slack user `alice` shows as `alice (via Slack)`.
Case 2 carries the sender's identity through `chat.postMessage`'s `username`/`icon_url` and ignores
the setting entirely. Set the format, run case 1 with it set and again with it empty, then **restore
the original value** before moving on — the later matrices assume the workspace's normal setup.
Repeat both on a channel without rocketbot: neither message crosses over.
## Editing messages
Slack does not allow an app to edit messages it did not post, and it does not allow humans to
edit bot messages. Two of the four cases are therefore **expected failures** — they are listed
because "nothing crashes and the local side stays consistent" is the requirement.
| # | Origin | Action performed on | Expected result |
| --- | ----------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Slack | Slack | Edit propagates to Rocket.Chat. |
| 2 | Rocket.Chat | Rocket.Chat | Edit propagates to Slack. |
| 3 | Slack | Rocket.Chat | **Expected failure.** Rocket.Chat shows the edit; Slack is unchanged. `chat.update` is rejected because the message is not the bot's. |
| 4 | Rocket.Chat | Slack | **Expected failure.** Slack does not even offer the edit affordance for a bot message. |
Repeat on a channel without rocketbot: edits stay local on both sides.
## Deleting messages
Case 3 needs care about _who_ deletes. A Rocket.Chat-origin message is authored on Slack by
rocketbot, and Slack offers no delete affordance for an app's message to a regular member, so
pick one of:
- Delete it from the Slack UI as a **workspace owner or admin** — they can delete any message,
including an app's.
- Call `chat.delete` with the very token the bridge is configured with, so the caller is
rocketbot itself: `SlackBridge_APIToken` in legacy/RTM mode, `SlackBridge_BotToken` in
app/Socket Mode. Any other token fails with `cant_delete_message`, which is case 4's failure,
not case 3's.
Both routes emit the same `message_deleted` event, so either is a valid way to run the case.
| # | Origin | Action performed on | Expected result |
| --- | ----------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Slack | Slack | Deletion propagates to Rocket.Chat. |
| 2 | Rocket.Chat | Rocket.Chat | Deletion propagates to Slack. |
| 3 | Rocket.Chat | Slack | Deletion propagates to Rocket.Chat — deleted as a workspace admin or via `chat.delete` with rocketbot's own token, per the note above. |
| 4 | Slack | Rocket.Chat | **Expected failure.** The message is deleted in Rocket.Chat only; Slack keeps it, since `chat.delete` cannot remove another user's message. |
Repeat on a channel without rocketbot: deletions stay local on both sides.
## Reactions
Requires `SlackBridge_Reactions_Enabled`. Reactions sent to Slack are added by the bot account,
so on the Slack side every Rocket.Chat reaction shows up as rocketbot's reaction regardless of
who actually reacted.
| # | Origin | Action performed on | Expected result |
| --- | ----------- | ------------------- | ----------------------------------------------------- |
| 1 | Slack | Slack | Reaction appears on the Rocket.Chat message. |
| 2 | Rocket.Chat | Slack | Reaction appears on the Rocket.Chat message. |
| 3 | Rocket.Chat | Rocket.Chat | Reaction appears on the Slack message (as rocketbot). |
| 4 | Slack | Rocket.Chat | Reaction appears on the Slack message (as rocketbot). |
Also check removal of each reaction, and confirm a reaction is not echoed back to its origin
(the bridge de-duplicates through its reactions map — a double-count means that broke).
Repeat on a channel without rocketbot: reactions stay local.
### `SlackBridge_Reactions_Enabled` toggle
| Setting state | Expected result |
| ------------- | -------------------------------------------------------------------------------------------------------------- |
| Disabled | React to both a Slack-origin and a Rocket.Chat-origin message from both sides — nothing propagates, no errors. |
| Enabled | Re-run the same reactions — they propagate as in the matrix above, without restarting the server. |
## Channel lifecycle
These cases cover how the bridge reacts to channels appearing, disappearing, and the bot joining
or leaving. Send a message **from each side** after every step and verify propagation.
### Channel exists on both sides, rocketbot not in the Slack channel
1. Confirm nothing propagates in either direction.
2. Invite rocketbot to the Slack channel.
3. Send from Slack and from Rocket.Chat — both directions now work.
### Channel exists on Rocket.Chat only
1. Create the matching channel in Slack and invite rocketbot.
2. Send from Rocket.Chat — it reaches Slack.
3. Send from Slack — it reaches Rocket.Chat.
### Channel exists on Slack only
1. Invite rocketbot to the Slack channel.
2. Send from Slack — the Rocket.Chat channel is created and receives the message.
3. Send from Rocket.Chat — it reaches Slack.
### Channel exists on both sides with rocketbot
1. Delete the Rocket.Chat channel, then send from Slack — behaves like the "Slack only" case
above (the channel is recreated).
2. Remove rocketbot from the Slack channel, then send from Slack and from Rocket.Chat — nothing
propagates in either direction, and no errors are logged.
## Coverage gaps
The matrices above are the historical manual suite and deliberately mirror it. They do **not**
cover, and it is worth checking manually when relevant:
- File uploads (`SlackBridge_FileUpload_Enabled`).
- Slack history import (`/slackbridge-import`, `slackbridge_import.server.ts`).
- Threads (`thread_ts` mapping in `postMessage`).
- Private channels and DMs.
- `SlackBridge_ExcludeBotnames` filtering.
- The **Remove Channel Links** admin action (`SlackBridge_Remove_Channel_Links`).
- Multiple Slack workspaces bridged at once (one token per line in each token setting).

235
docs/form-validation.md Normal file
View File

@@ -0,0 +1,235 @@
# Form Validation Guidelines
This document outlines the standardized form validation patterns and guidelines established in PR [#39590](https://github.com/RocketChat/Rocket.Chat/pull/39590) to ensure consistent user experience across Rocket.Chat forms.
## Overview
The form validation standardization aims to:
- **Improve accessibility** by keeping submit buttons enabled and letting validation run on submit
- **Provide consistent UX** with validation triggered on form submission and re-validation on field changes
- **Prevent unnecessary API calls** by using dirty-checks and appropriate revalidation modes
- **Enhance user feedback** with clear error messages and proper ARIA attributes
## Core Principles
### 1. Submit-First validation (`mode: 'onSubmit'`)
Forms should use `mode: 'onSubmit'` in react-hook-form to trigger initial validation only when the user attempts to submit the form.
**Why:** This approach improves accessibility by:
- Keeping submit buttons enabled (allowing screen readers and keyboard users to discover validation requirements)
- Avoiding premature error messages that can confuse users
- Letting users complete the form at their own pace before seeing validation feedback
**Example:**
```tsx
const {
control,
formState: { errors, isDirty, isSubmitting },
handleSubmit,
} = useForm<FormData>({
mode: 'onSubmit', // This can be omitted, `onSubmit` it's the default mode value
defaultValues: initialData,
});
```
### 2. Smart revalidation strategy
After the first submit attempt, forms should revalidate fields intelligently:
#### Default: `reValidateMode: 'onChange'`
For most forms, use the default onChange revalidation to provide immediate feedback as users correct errors.
#### Exception: `reValidateMode: 'onBlur'` for Async Validation
For forms with **async validation** (e.g., username availability, email uniqueness checks), explicitly set `reValidateMode: 'onBlur'` to avoid excessive API calls.
**Example with async validation:**
```tsx
const {
control,
formState: { errors, isDirty },
handleSubmit,
} = useForm<FormData>({
reValidateMode: 'onBlur', // Avoid API calls on every keystroke
defaultValues: initialData,
});
```
### 3. Dirty-check with `useFormSubmitWithDirtyCheck`
Use the `useFormSubmitWithDirtyCheck` hook to provide user-friendly feedback when attempting to save unchanged forms.
Usually applicable on edit forms, where fields are already populated.
**Purpose:**
- Prevents unnecessary save operations on unchanged data
- Shows informative toast message: "No changes to save"
- Maintains accessibility by keeping buttons enabled
**Signature:**
Receives a callback as the first parameter (your submit handler), and an object as the second parameter containing `isDirty` and an optional `noChangesMessage` translation key, to be dispatched in the info toast.
**Usage:**
```tsx
import { useFormSubmitWithDirtyCheck } from '/hooks/useFormSubmitWithDirtyCheck';
const handleSave = useFormSubmitWithDirtyCheck(
async (data: FormData) => {
try {
await saveData(data);
dispatchToastMessage({ type: 'success', message: t('Saved') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
},
{ isDirty }
);
// In JSX:
<form onSubmit={handleSubmit(handleSave)}>
```
**When to use dirty-check:**
This hook is recommended when the same form component is used for both creation (new) and editing existing data. The hook intelligently handles both scenarios:
- ✅ **Create mode** (no existing data): Allows submission without dirty check
- ✅ **Edit mode** (existing data): Shows "No changes to save" toast when form is unchanged
- ✅ **Unified component**: Simplifies logic by handling both create and edit in one place
## Form Implementation Patterns
### Basic Form Structure
```tsx
import { useForm, Controller } from 'react-hook-form';
import { useFormSubmitWithDirtyCheck } from '../../../hooks/useFormSubmitWithDirtyCheck';
type FormData = {
name: string;
email: string;
};
const MyForm = ({ data, onSave }: FormProps) => {
const { t } = useTranslation();
const {
control,
formState: { errors, isDirty, isSubmitting },
handleSubmit,
} = useForm<FormData>({
defaultValues: data || {},
});
const handleFormSubmit = useFormSubmitWithDirtyCheck(
async (formData: FormData) => {
await onSave(formData);
},
{ isDirty }
);
return (
<form onSubmit={handleSubmit(handleFormSubmit)} id={formId}>
{/* Form fields */}
</form>
);
};
```
## Button State Management
### Submit Button States
```tsx
<Button
primary
type='submit'
form={formId}
loading={isSubmitting}
>
{t('Save')}
</Button>
```
**Key Points:**
- Use `loading={isSubmitting}` to show loading state during submission
- Never disable the save button (keep enabled for a11y)
- Always connect button to form via `form={formId}` attribute
## Basic checklist
When updating an existing form to follow these guidelines:
- [ ] Use `mode` to `'onSubmit'` in `useForm`
- [ ] Add `reValidateMode: 'onBlur'` if form has async validation
- [ ] Wrap submit handler with `useFormSubmitWithDirtyCheck` (for create and edit forms)
- [ ] Add ARIA attributes: `aria-describedby`, `aria-invalid`, `role='alert'` when applicable
- [ ] Button states: `loading={isSubmitting}`, but never `disabled`
- [ ] Verify accessibility with screen reader testing
## Basic DOs and DON'Ts
### ❌ Don't: Disable buttons based on form validity
```tsx
// Bad - prevents discovery of validation requirements
<Button disabled={!isValid || !isDirty}>Save</Button>
```
### ✅ Do: Keep buttons enabled, let validation run on submit
```tsx
// Good - accessible and provides feedback
<Button
type='submit'
disabled={existingId ? !isDirty : false}
loading={isSubmitting}
>
Save
</Button>
```
### ❌ Don't: Use `mode: 'onChange'` for initial validation
```tsx
// Bad - shows errors immediately, poor UX
useForm({ mode: 'onChange' })
```
### ✅ Do: Use `mode: 'onSubmit'` for initial validation
```tsx
// Good - validates on submit, revalidates on change
useForm({ mode: 'onSubmit' })
```
### ❌ Don't: Use `reValidateMode: 'onChange'` with async validation
```tsx
// Bad - causes API call on every keystroke
useForm({
mode: 'onSubmit',
// Uses default 'onChange' revalidation - too many API calls!
})
```
### ✅ Do: Use `reValidateMode: 'onBlur'` with async validation
```tsx
// Good - reduces API calls while maintaining feedback
useForm({
mode: 'onSubmit',
reValidateMode: 'onBlur',
})
```
## Additional Resources
- [React Hook Form Documentation](https://react-hook-form.com/)
- [WCAG 2.1 Form Guidelines](https://www.w3.org/WAI/WCAG21/quickref/?showtechniques=332#error-identification)
- [PR #39590 - Form Validation Standardization](https://github.com/RocketChat/Rocket.Chat/pull/39590)

View File

@@ -0,0 +1,480 @@
# Building components
A component is a reusable piece of code that represents a single UI element. Components vary by complexity and type, existing at either the **application** level or the **Fuselage library** level:
- **Application components** remain specific to particular Rocket.Chat applications, without cross-application reusability.
- **Fuselage library components** are reusable across all Rocket.Chat applications and represent the recommended approach.
## Component rules matrix
| Combination | Fuselage level | Application level |
| ---------------- | -------------- | ----------------- |
| Simple & Visual | ✅ | ❌ |
| Complex & Visual | ✅ | ✅ |
| Simple & Logical | ❌ | ❌ |
| Complex & Logical| ❌ | ✅ |
## Simple components
Simple components represent atomic UI elements like buttons or text fields.
### Prefer variation over styles
Use prop names suggesting component variation rather than style-based props. Instead of `color="blue"`, use `variation="primary"` for improved readability and consistency.
### Avoid hardcoded values or magic numbers
Preferred:
```jsx
<Button small square>
<Icon name='circle-arrow-down' size='x24' />
</Button>
```
Not recommended:
```jsx
<Button height='50px' width='50px' square>
<Icon name='circle-arrow-down' size='x24' />
</Button>
```
### Customize via CSS variables
Prioritize CSS variable customization over arbitrary values:
```scss
$modal-margin: theme('modal-margin', auto);
.rcx-modal {
position: static;
display: flex;
width: 100%;
max-height: 100%;
margin: $modal-margin;
}
```
### Document variations in Storybook
Display all possible variations with descriptive explanations for non-obvious options.
### Unit test all behaviors
Comprehensive unit tests ensure reliability across all intended scenarios:
```jsx
describe('[Menu Component]', () => {
it('should renders without crashing', () => {
render(<Simple {...Simple.args} />);
});
it('should open options when click', async () => {
const { getByTestId } = render(<Simple {...Simple.args} />);
const button = getByTestId('menu');
userEvent.click(button);
expect(await screen.findByText('Make Admin')).toBeInTheDocument();
});
it('should have no options when click twice', async () => {
const { getByTestId } = render(<Simple {...Simple.args} />);
const button = getByTestId('menu');
await userEvent.click(button);
await userEvent.click(button);
expect(screen.queryByText('Make Admin')).toBeNull();
});
it('should have no options when click on menu and then elsewhere', async () => {
const { getByTestId } = render(<Simple {...Simple.args} />);
const button = getByTestId('menu');
await userEvent.click(button);
await userEvent.click(document.body);
expect(screen.queryByText('Make Admin')).toBeNull();
});
});
```
### Avoid "Boxed" components
The `Box` component works as a wildcard primarily for simple or complex components during prototyping. For simple components specifically, construct using HTML tags instead.
## Complex components
Complex components combine multiple simple components for sophisticated UI elements like modals or tables.
### Visual only, no logic
Concentrate solely on the user interface design, ensuring it is poised to incorporate the required logic seamlessly.
```jsx
export const Default = () => (
<Modal>
<ModalHeader>
<ModalHeaderText>
<ModalTitle>Modal Header</ModalTitle>
</ModalHeaderText>
<ModalClose />
</ModalHeader>
<ModalContent>Modal Body</ModalContent>
<ModalFooter>
<ModalFooterControllers>
<Button>Cancel</Button>
<Button primary onClick={action('click')}>
Submit
</Button>
</ModalFooterControllers>
</ModalFooter>
</Modal>
);
```
### Split components for clarity
Structure components into understandable, logical segments.
### Develop with Storybook first
Start with Storybook to separate interface from logic:
```tsx
export const CallingDM: ComponentStory<typeof VideoConfMessage> = () => (
<VideoConfMessage>
<VideoConfMessageRow>
<VideoConfMessageIcon variant='incoming' />
<VideoConfMessageText>Calling...</VideoConfMessageText>
</VideoConfMessageRow>
<VideoConfMessageFooter>
<VideoConfMessageAction primary>Join</VideoConfMessageAction>
<VideoConfMessageFooterText>Waiting for answer</VideoConfMessageFooterText>
</VideoConfMessageFooter>
</VideoConfMessage>
);
export const CallEndedDM: ComponentStory<typeof VideoConfMessage> = () => (
<VideoConfMessage>
<VideoConfMessageRow>
<VideoConfMessageIcon />
<VideoConfMessageText>Call ended</VideoConfMessageText>
</VideoConfMessageRow>
<VideoConfMessageFooter>
<VideoConfMessageAction>Call Back</VideoConfMessageAction>
<VideoConfMessageFooterText>Call was not answered</VideoConfMessageFooterText>
</VideoConfMessageFooter>
</VideoConfMessage>
);
```
### Child components must remain scoped
❌ Incorrect:
```tsx
export const MyComponent: ComponentStory<typeof VideoConfMessage> = () => (
<Box display='flex'>
<form>
<VideoConfMessageAction>Call ended</VideoConfMessageAction>
</form>
</Box>
);
```
✅ Correct:
```tsx
export const MyComponent: ComponentStory<typeof VideoConfMessage> = () => (
<Box display='flex'>
<form>
<VideoConfMessage>
<VideoConfMessageFooter>
<VideoConfMessageAction>Call ended</VideoConfMessageAction>
</VideoConfMessageFooter>
</VideoConfMessage>
</form>
</Box>
);
```
### HTML elements, box, and box props should be encapsulated
Encapsulation ensures predictable behavior and streamlined debugging by accessing HTML elements and Box props solely through the Box component's API.
❌ Incorrect:
```tsx
export const VideoConfMessage = () => (
<Box mbs='x4' maxWidth='345px' borderWidth={2} borderColor='neutral-200' borderRadius='x4'>
<Box p='x16' display='flex' alignItems='center'>
<Icon name='link' />
<div>My Text</div>
</Box>
</Box>
);
```
✅ Correct:
```tsx
export type VideoConfMessageProps = Omit<AllHTMLAttributes<HTMLDivElement>, 'is'>;
const VideoConfMessage = (props: VideoConfMessageProps) => (
<Box mbs='x4' maxWidth='345px' borderWidth={2} borderColor='neutral-200' borderRadius='x4' {...props} />
);
```
### Provide hooks as helpers
In the following example, the `useVideoConfControllers` hook is provided as a helper to manage the state of the popup's controllers:
```ts
export const useVideoConfControllers = (
initialPreferences: controllersConfigProps = { mic: true, cam: false },
): { controllersConfig: controllersConfigProps; handleToggleMic: () => void; handleToggleCam: () => void } => {
const [controllersConfig, setControllersConfig] = useState(initialPreferences);
const handleToggleMic = useCallback((): void => {
setControllersConfig((prevState) => ({ ...prevState, mic: !prevState.mic }));
}, []);
const handleToggleCam = useCallback((): void => {
setControllersConfig((prevState) => ({ ...prevState, cam: !prevState.cam }));
}, []);
return {
controllersConfig,
handleToggleMic,
handleToggleCam,
};
};
```
```tsx
const { controllersConfig } = useVideoConfControllers();
return (
<VideoConfPopup>
<VideoConfPopupHeader>
<VideoConfPopupTitle text={t('Calling')} counter />
<VideoConfPopupControllers>
<VideoConfController
active={controllersConfig.cam}
title={controllersConfig.cam ? t('Cam_on') : t('Cam_off')}
icon={controllersConfig.cam ? 'video' : 'video-off'}
disabled
/>
<VideoConfController
active={controllersConfig.mic}
title={controllersConfig.mic ? t('Mic_on') : t('Mic_off')}
icon={controllersConfig.mic ? 'mic' : 'mic-off'}
disabled
/>
</VideoConfPopupControllers>
</VideoConfPopupHeader>
</VideoConfPopup>
);
```
## Understanding the component and defining the scope
New components typically emerge from requirements put forth by the Product Design Team. The frontend engineer holds the responsibility of assessing the genuine necessity of such components. Due to the substantial effort involved in creating a new component, it is prudent to collaborate with product managers and designers. It is advisable to explore the feasibility of employing Complex Components as an MVP to validate concepts and user flows. After successful validation, the progression to developing a new Fuselage level component can be considered.
**How do I know my component should be part of the Fuselage library?**
Consider the `VerticalBar` component as a clear example. It began as a Complex Component for a single application but has now advanced to the Fuselage level. This shift is driven by its usefulness in multiple applications, like Rocket.Chat and Cloud Portal. This case demonstrates how components can grow from specific solutions to versatile tools with broader applications.
## Logical components
### Use child components to compose a logical complex component
Leverage the integration of child components to construct a unified and logical complex component:
```tsx
const OutgoingPopup = ({ room, onClose, id }: OutgoingPopupProps): ReactElement => {
const t = useTranslation();
const videoConfPreferences = useVideoConfPreferences();
const { controllersConfig } = useVideoConfControllers();
return (
<VideoConfPopup>
<VideoConfPopupHeader>
<VideoConfPopupTitle text={t('Calling')} counter />
<VideoConfPopupControllers>
<VideoConfController
active={controllersConfig.cam}
title={controllersConfig.cam ? t('Cam_on') : t('Cam_off')}
icon={controllersConfig.cam ? 'video' : 'video-off'}
disabled
/>
<VideoConfController
active={controllersConfig.mic}
title={controllersConfig.mic ? t('Mic_on') : t('Mic_off')}
icon={controllersConfig.mic ? 'mic' : 'mic-off'}
disabled
/>
</VideoConfPopupControllers>
</VideoConfPopupHeader>
</VideoConfPopup>
);
};
```
### Customization through variations
Provide users with the ability to customize a component's appearance or behavior by selecting from predefined variations or options. This approach enhances user experience and flexibility in adapting components to specific requirements.
```tsx
<VideoConfController
active={controllersConfig.mic}
title={controllersConfig.mic ? t('Mic_on') : t('Mic_off')}
icon={controllersConfig.mic ? 'mic' : 'mic-off'}
disabled
/>
```
```tsx
const VideoConfController = ({ icon, active, secondary, disabled, small = true, ...props }: VideoConfControllerProps): ReactElement => {
const id = useUniqueId();
return (
<IconButton
small={small}
icon={icon}
id={id}
info={active}
disabled={disabled}
secondary={secondary || active || disabled}
{...props}
/>
);
};
```
### Avoid direct styles
Refrain from applying direct styles to components. By avoiding inline styling, the code maintains a cleaner structure and promotes better separation of concerns, enhancing maintainability and readability.
❌ Avoid:
```tsx
<VideoConfPopup>
<VideoConfPopupHeader>
<VideoConfPopupTitle text={t('Calling')} counter />
<VideoConfPopupControllers>
<Box display='flex' alignItems='center'>
<VideoConfController
width='50px'
height='50px'
active={controllersConfig.cam}
title={controllersConfig.cam ? t('Cam_on') : t('Cam_off')}
icon={controllersConfig.cam ? 'video' : 'video-off'}
disabled
/>
<VideoConfController
active={controllersConfig.mic}
title={controllersConfig.mic ? t('Mic_on') : t('Mic_off')}
icon={controllersConfig.mic ? 'mic' : 'mic-off'}
disabled
/>
</Box>
</VideoConfPopupControllers>
</VideoConfPopupHeader>
</VideoConfPopup>
```
### Don't write CSS styles in JS files
This approach separates your component's logic from styling, promoting better code organization and maintainability while avoiding inline CSS-in-JS styling.
Define your custom styling in an external CSS file:
```css
/* styles.css */
.customClass {
border: 1px solid black;
padding: 1.5rem;
}
```
Then, apply the class to your component:
```tsx
import './styles.css';
return (
<VideoConfPopup>
<VideoConfPopupHeader>
<VideoConfPopupTitle text={t('Calling')} counter />
<VideoConfPopupControllers>
<VideoConfController
className='customClass'
active={controllersConfig.cam}
title={controllersConfig.cam ? t('Cam_on') : t('Cam_off')}
icon={controllersConfig.cam ? 'video' : 'video-off'}
disabled
/>
<VideoConfController
active={controllersConfig.mic}
title={controllersConfig.mic ? t('Mic_on') : t('Mic_off')}
icon={controllersConfig.mic ? 'mic' : 'mic-off'}
disabled
/>
</VideoConfPopupControllers>
</VideoConfPopupHeader>
</VideoConfPopup>
);
```
### Use the states of the component
By using component states to conditionally render different complex components, you maintain a clear and organized structure in your code, enhancing readability and maintainability.
```tsx
if (isReceiving) {
return <IncomingPopup room={room} id={id} position={position} onClose={onClose} onMute={handleMute} onConfirm={handleConfirm} />;
}
if (isCalling) {
return <OutgoingPopup room={room} id={id} onClose={onClose} />;
}
return <StartCallPopup loading={starting} room={room} id={id} onClose={dismissOutgoing} onConfirm={handleStartCall} />;
```
Each state should render the proper Complex Component:
```tsx
const OutgoingPopup = ({ room, onClose, id }: OutgoingPopupProps): ReactElement => {
const t = useTranslation();
const videoConfPreferences = useVideoConfPreferences();
const { controllersConfig } = useVideoConfControllers();
return (
<VideoConfPopup>
<VideoConfPopupHeader>
<VideoConfPopupTitle text={t('Calling')} counter />
<VideoConfPopupControllers>
<VideoConfController
active={controllersConfig.cam}
title={controllersConfig.cam ? t('Cam_on') : t('Cam_off')}
icon={controllersConfig.cam ? 'video' : 'video-off'}
disabled
/>
<VideoConfController
active={controllersConfig.mic}
title={controllersConfig.mic ? t('Mic_on') : t('Mic_off')}
icon={controllersConfig.mic ? 'mic' : 'mic-off'}
disabled
/>
</VideoConfPopupControllers>
</VideoConfPopupHeader>
</VideoConfPopup>
);
};
```
## Visual components
Visual components are responsible for the appearance of a UI element. They define the element's style, layout, and other visual properties.
Adhering to the guidelines in Fuselage's componentization offers the value of modular, reusable, and maintainable UI components. This approach enables efficient development, ensures consistent behavior, and supports the evolution of solutions from specific contexts to broader applications. By encapsulating logic, avoiding direct styles, and leveraging API-driven customization, developers can create a streamlined and user-centered experience.

94
docs/frontend/i18n.md Normal file
View File

@@ -0,0 +1,94 @@
# Translations (i18n) in the client
Client-side conventions only. How keys are stored, named, and written — naming, interpolation, plurals, formatters, what the linter enforces — is in [docs/i18n.md](../i18n.md), and applies here too.
## Reading translations
Use `useTranslation` from `react-i18next`:
```tsx
import { useTranslation } from 'react-i18next';
const DeleteRoomButton = () => {
const { t } = useTranslation();
return <Button danger>{t('Delete_room')}</Button>;
};
```
❌ Not `useTranslation` from `@rocket.chat/ui-contexts` — it is `@deprecated` and re-renders every consumer of the translation context on language change. ~125 client modules still import it against ~890 for the `react-i18next` one; don't add to the count.
[`TranslationProvider`](../../apps/meteor/client/providers/TranslationProvider.tsx) bundles `en` statically and sets it as `fallbackLng`, so English keys resolve immediately. Only a non-English active locale is fetched over HTTP (`partialBundledLanguages: true`); until it arrives, and for any key it omits, the English string is used. The server, by contrast, holds all 68 locales in memory.
## Never build keys at runtime
`t()` does not reject unknown keys (see [docs/i18n.md](../i18n.md#types-are-generated-from-en)), so a computed key has nothing checking it — and it makes the key invisible to anyone grepping for it.
❌ Incorrect:
```tsx
{t(`App_status_${instance.status}` as TranslationKey)}
```
✅ Correct — map the value to a literal key:
```tsx
const APP_STATUS_LABELS = {
installed: 'App_status_installed',
updating: 'App_status_updating',
} as const satisfies Record<AppStatus, RocketchatI18nKeys>;
{t(APP_STATUS_LABELS[instance.status])}
```
The map is the thing that tells you a key is missing when a new status is added, and `satisfies` is what makes the map itself checkable. `as TranslationKey` appears about 70 times in the client; treat every one as debt rather than precedent.
## Rich text: the `Trans` component
When a sentence contains a link, bold run, or nested element, don't split it into several keys. Use `Trans` and put the markup in the translation:
```json
{
"Unique_ID_change_detected_learn_more_link": "Read the <a>documentation</a> before proceeding."
}
```
```tsx
import { Trans } from 'react-i18next';
<Trans i18nKey='Unique_ID_change_detected_learn_more_link' components={{ a: <ExternalLink to={links.go.fingerPrintChangedFaq} /> }} />;
```
Name your tags. Indexed placeholders (`<1>`, `<3>`) appear in older keys and are effectively unreadable for a translator, who can't tell what index 3 wraps:
❌ Avoid:
```json
{ "Limits_reached": "Your workspace reached the <1>{{val}}</1> limit. <3>Manage subscription</3>." }
```
✅ Prefer:
```json
{ "Limits_reached": "Your workspace reached the <bold>{{val}}</bold> limit. <link>Manage subscription</link>." }
```
The base locale is currently split about evenly between the two (~50 values each), so this is a direction, not an established majority.
Pass interpolation values through `values` and `t` options through `tOptions`:
```tsx
<Trans i18nKey='Airgapped_workspace_warning' values={{ remainingDays }} />
```
## Interpolated values are not HTML-escaped
`escapeValue` is `false`. That is safe only because React escapes strings when it renders them as text nodes — it is not a property of the translation itself.
So if a translated string is injected as HTML, sanitize it, as the two existing call sites do:
```tsx
<FieldHint dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(t(provider.description)) }} />
```
Prefer `Trans` over injected HTML wherever possible — it needs no sanitizing because the components are real React elements, not parsed markup.

View File

@@ -0,0 +1,61 @@
# Migrating from JavaScript
## TypeScript is a superset of JavaScript
TypeScript is an extension of JavaScript, meaning that when transitioning from JavaScript to TypeScript, you can employ the identical syntax used in JavaScript. Errors flagged by the TypeScript compiler (`tsc`) and ESLint enforce best practices, and both gate CI — [`ci-code-check`](../../.github/workflows/ci-code-check.yml) runs `turbo run typecheck` and `yarn lint` — so resolve them rather than leaving them in place.
When a diagnostic is genuinely wrong about the code, suppress it explicitly and narrowly:
- `@ts-expect-error` with a short reason, rather than `@ts-ignore` — it fails once the underlying error is gone, so the suppression can't outlive its cause;
- `eslint-disable-next-line`, naming the specific rule rather than disabling the line wholesale.
## JSDoc
When `allowJs` is enabled in `tsconfig.json`, JSDoc comments can document types within JavaScript code. This is particularly useful during gradual migration when `tsc` struggles with type inference.
Example with `@typedef`:
```js
// module.js
/**
* @typedef {Object} Foo
* @property {string} bar
* @property {string} [qux]
*/
/** @type {Foo} */
export const foo = { bar: 'baz' };
foo.qux = 'quux';
```
Alternative using the `@type` tag:
```js
// module.js
/**
* @type {{ bar: string; qux?: string }}
*/
export const foo = { bar: 'baz' };
foo.qux = 'quux';
```
Both approaches help ensure TypeScript accurately recognizes the complete type structure.
## Declare a `*.d.ts` file
Creating a `.d.ts` declaration file is strongly recommended when migrating large JavaScript modules. It describes the module's public surface for type checking and is easier to read as a whole than JSDoc scattered through the implementation.
A declaration file is types only: it is erased at build time and creates no runtime code. Place it alongside the JavaScript module it describes, with the same basename, and declare only exports that module actually has — TypeScript will trust the declaration over the `.js`, so anything you declare that isn't really exported will typecheck at the import site and be `undefined` at runtime.
```ts
// hugeModule.d.ts — sits next to hugeModule.js
export function foo(): void; // hugeModule.js exports `foo`
export function bar(): void; // hugeModule.js exports `bar`
```
Writing the file is still a good way to plan the shape you're migrating toward, but it only records exports; it can't create or relocate them. If an export belongs in another module, move the implementation first, then declare it beside its new home.

258
docs/frontend/react.md Normal file
View File

@@ -0,0 +1,258 @@
# React
> Most of the recommendations here are based on Alex Kondov's [Tao of React](https://alexkondov.com/tao-of-react/).
## Prefer functional components
React initially introduced class components to leverage JavaScript class syntax for managing state and component lifecycles. However, class components have significant drawbacks:
- they tend to be verbose;
- they often involve a misuse of the inheritance mechanism through `extends` and `super`.
Hooks were introduced to provide an alternative approach to declaring state and effects. They maintain the core concept of the component's render function without the need for classes, streamlining the development process.
## Declare one component per file
While colocation is a commendable concept, it's not consistently followed for React components within a single file. The main reason is that we've noticed people misusing this approach before. It might start with something as straightforward as adding a basic modal component alongside a page component, but it can quickly lead to a confusing jumble of components that becomes difficult to manage.
## Name components
Failing to name a component is a common mistake that can lead to prolonged debugging efforts. It results in less informative error stacks and challenges while navigating components in React Dev Tools. There are two approaches to properly name a component:
1. By assigning the function straight to a binding. The arrow function is itself anonymous, but assigning it to a `const` makes JavaScript infer the name from the variable:
```jsx
const Foo = () => {
return <div>Foo</div>;
};
console.log('The component name is:', Foo.name);
```
2. By setting the `displayName` property. Once a wrapper call sits between the binding and the function, there is nothing for inference to attach to — the arrow is an argument to `memo`, and `memo` returns an object rather than a function:
```jsx
const Foo = memo(() => {
return <div>Foo</div>;
});
Foo.displayName = 'Foo'; // `Foo.name` is `undefined`
console.log('The component name is:', Foo.displayName);
```
## Use default export at the end of file
While named exports are often preferred, using default export enhances code readability, especially when dealing with Higher Order Components (HOCs) like `memo` and `forwardRef`, and it aligns neatly with code splitting using `lazy`.
```tsx
// Component.tsx
import { memo } from 'react';
type ComponentProps = {
name: string;
};
// Anonymous arrow, but the const assignment infers the name "Component"
const Component = (props: ComponentProps) => {
return <div>Hello, {props.name}</div>;
};
export default memo(Component); // the component name is preserved
```
```ts
// index.ts
import { lazy } from 'react';
const Component = lazy(() => import('./Component'));
```
The same example based on named exports is less readable:
```tsx
// Component.tsx
import { memo } from 'react';
type ComponentProps = {
name: string;
};
// Anonymous arrow in an argument position — no name is inferred
export const Component = memo((props: ComponentProps) => {
return <div>Hello, {props.name}</div>;
});
Component.displayName = 'Component'; // needed for React Dev Tools
```
```ts
// index.ts
import { lazy } from 'react';
const Component = lazy(async () => {
const { Component } = await import('./Component');
return { default: Component }; // you need to reconstruct the default export
});
```
## Extract helper functions
A drawback associated with the adoption of React Hooks is the tendency for individuals to define helper functions within the component. This is facilitated by the fact that there is no requirement to pass arguments; instead, variables from the encompassing scope can be directly accessed and used.
```jsx
const Component = () => {
const value = useMyHook();
const isValueOK = () => value === 'OK';
return isValueOK() ? <>OK</> : null;
};
```
Typically, effective helper functions adhere to the principle of being pure, which makes them simpler to debug. However, when you bind variables into the helper's scope, you're making it impure. Furthermore, with each rendering cycle, the function value gets redefined. While this process is efficient in terms of CPU and memory usage, it can lead to scenarios where you must rely on techniques like `useCallback` to prevent unnecessary re-renders of child components that receive your helper function as a prop.
Here's the ideal case:
```jsx
const isValueOK = (value) => value === 'OK';
const Component = () => {
const value = useMyHook();
return isValueOK(value) ? <>OK</> : null;
};
```
## Do not refer to the `children` prop explicitly
Children should always be actual children, not passed in as a prop. Use the `eslint-plugin-react` rule [`react/no-children-prop`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/no-children-prop.md) as reference.
❌ Incorrect:
```jsx
export const MyComponent = ({ children }) => <div children={children} />;
```
✅ Correct:
```jsx
export const MyComponent = ({ children }) => <div>{children}</div>;
```
## Declare props type explicitly
Each component named, for instance, `Component` must have an _exported_ type declaration for `ComponentProps`.
```tsx
export type MyComponentProps = {
// ...
};
const MyComponent = (props: MyComponentProps) => /* ... */;
export default MyComponent;
```
## Use generic types when appropriate
Generic types are a great way to ensure type safety once they enable us to not fall into the unsoundness of type assertions.
❌ Incorrect:
```tsx
// MyComponent.tsx
export type MyComponentProps = {
value: unknown;
onChange: (newValue: unknown) => void;
};
const MyComponent = ({ value, onChange }: MyComponentProps) => /* ... */;
export default MyComponent;
// MyApp.tsx
import MyComponent from './MyComponent';
const MyApp = () => {
const [value, setValue] = useState('');
const handleChange = (value: unknown) => {
setValue(value as string); // unsound type assertion, as unknown can be anything
};
return <MyComponent value={value} onChange={handleChange} />;
};
export default MyApp;
```
✅ Correct:
```tsx
// MyComponent.tsx
export type MyComponentProps<TValue> = {
value: TValue;
onChange: (newValue: TValue) => void;
};
const MyComponent = <TValue,>({ value, onChange }: MyComponentProps<TValue>) => /* ... */;
export default MyComponent;
// MyApp.tsx
import MyComponent from './MyComponent';
const MyApp = () => {
const [value, setValue] = useState('');
const handleChange = (value: string) => {
setValue(value);
};
// since `value` is string, `onChange` type will be inferred
// as `(value: string) => void`
return <MyComponent value={value} onChange={handleChange} />;
};
export default MyApp;
```
## Avoid naming identifiers with their value type/kind
This is a common programming convention, but it seems easier to violate when working with React concepts. In general, identifiers should not include information about the data structure they refer to.
```ts
// Examples of bad identifiers
const amountConst = 123; // instead of "amount"
let countNumber = 0; // instead of "count"
function logFunction(lineString: string) {
// instead of "log" and "line"
console.log(lineString);
}
```
Identifiers like this mimic the old-fashioned Hungarian notation. In React, this happens quite often:
```tsx
// Examples of bad identifiers in React constructs
function useThingHook() {
// ...
} // instead of "useThing"
const useThingContext = () => useContext(ThingContext);
// "ThingContext" already exposes itself as a context that provides a "Thing".
// "useThing" is good enough, since it doesn't need to expose its context dependency.
const MyComponent = () => <div />;
// Fine for code examples and components passed by props*, but in general
// capital cased function names used in JSX already communicate they are of
// "Component" kind
// * when "Component" is okay as a suffix
type MyPanelProps = {
panelComponent: ElementType<{}>;
};
function MyPanel({ panelComponent: PanelComponent }: MyPanelProps) {
// "PanelComponent" is fine here
return (
<div className='panel'>
<PanelComponent />
</div>
);
}
```

View File

@@ -0,0 +1,199 @@
# TypeScript general conventions
## Don't use CommonJS features
Avoid using CommonJS features such as `require` and `module` alongside ES module constructs like `import` and `export`. The preference is for ES modules due to their enhanced portability and user-friendly nature. However, keep in mind that synchronous conditional imports aren't possible in ES modules.
Example of CommonJS (not recommended):
```ts
// commonjs.ts
if (condition) {
const foo = require('foo');
module.exports = foo;
} else {
module.exports = {};
}
```
Invalid ES module pattern:
```ts
// esmodule.ts
if (condition) {
import foo from 'foo';
export default foo;
} else {
export default {};
}
```
## Prefer `import type` over `import`
Prioritize using `import type` for type-only imports. While a regular `import` works for both JavaScript and TypeScript, it can include module output code in the bundle. The `import type` construct is specific to TypeScript compilation and avoids unnecessary code inclusion.
```ts
// Foo.ts
export class Foo {
bar: string;
constructor(bar: string) {
this.bar = bar;
}
}
// Bar.ts
export class Bar {
foo: Foo;
constructor(foo: Foo) {
this.foo = foo;
}
}
// index.ts
import { Foo } from './Foo';
import type { Bar } from './Bar';
export const foo = new Foo('baz');
declare const bar: Bar;
// index.js (transpiled from index.ts)
import { Foo } from './Foo';
export const foo = new Foo('baz');
```
`Foo` is constructed, so its import is emitted; `Bar` is only ever a type, so `import type { Bar }` disappears entirely.
Note that TypeScript also elides a plain `import` whose bindings are only used in type positions, so writing `import` where `import type` belongs is usually not what bloats the output. `import type` is preferred because it states the intent, fails loudly if someone later uses the binding as a value, and is what tooling that transpiles file-by-file — `isolatedModules`, `verbatimModuleSyntax`, esbuild, SWC — needs in order to strip the import without consulting the type checker.
## Know the difference between `type` and `interface`
An `interface` serves as a type declaration similar to a class construct, while a `class` functions as an actual class declaration.
```ts
interface IThing {
prop: string;
method(): void;
}
class Thing implements IThing {
public prop = 'foo';
method(): void {
console.log('bar');
}
}
```
A `type` construct is similar to an interface but offers greater flexibility. The `type` construct permits declaration of union types:
```ts
type Foo = string | number;
```
An `interface` can declare generic types:
```ts
interface IFoo<T> {
prop: T;
}
```
A `type` also supports generics and conditional types, providing more adaptability:
```ts
type Foo<T> = T extends string ? { foo: number } : { bar: number };
```
## Avoid using classes as namespaces
Pattern to avoid:
```ts
// foo.ts
class Foo {
bar(): void {
// ...
}
}
export const foo = new Foo();
// index.ts
import { foo } from './foo';
foo.bar();
```
Preferred approach (when no state is needed):
```ts
// foo.ts
export function bar(): void {
// ...
}
// index.ts
import * as foo from './foo';
foo.bar();
```
Valid use case (when managing state):
```ts
// foo.ts
class Foo {
baz: number;
bar(): void {
// perform actions referencing and modifying `this.baz`
}
}
export const foo = new Foo();
// index.ts
import { foo } from './foo';
foo.bar();
```
Classes are reasonable when they encapsulate state and provide a controlled interface for modification.
## Avoid using the `any` type except when it's used as a constraint
Refrain from using `any` under most circumstances. Instead, follow this model:
- `unknown` serves as the universal type, encompassing all potential values;
- `any` should not be seen as an actual type, but rather as a means to disable TypeScript's type checking.
Using `any` is discouraged as it indicates unawareness regarding the type being manipulated. Working with `unknown` necessitates type narrowing, leading to more robust code.
```ts
// Avoid using any
declare const foo: any;
foo.bar(); // No compilation error
// Prefer using unknown
declare const bar: unknown;
bar.baz(); // Compilation error
const hasBaz = (bar: unknown): bar is { baz(): void } =>
typeof bar === 'object' && bar !== null && 'baz' in bar && typeof (bar as { baz: unknown }).baz === 'function';
if (hasBaz(bar)) {
bar.baz(); // No compilation error
}
```
Exception — generic type constraints:
```ts
type X<F> = F extends (x: unknown) => void ? true : false;
type Y<F> = F extends (x: any) => void ? true : false;
type A = X<(x: string) => void>; // `false`, because x is not `unknown`
type B = Y<(x: string) => void>; // `true`, because x is anything
```

237
docs/i18n.md Normal file
View File

@@ -0,0 +1,237 @@
# Translations (i18n)
How translation keys are stored, named, and written. This applies to every runtime that consumes `@rocket.chat/i18n`.
Client-specific guidance — hooks, the `Trans` component, escaping — lives in [docs/frontend/i18n.md](frontend/i18n.md).
## Where translations live
All translations are flat JSON files in [`packages/i18n/src/locales/`](../packages/i18n/src/locales/), one per language, named `<language>.i18n.json` (68 locales at the time of writing).
`en.i18n.json` is the **base language**. It is the only file you should edit when adding a feature:
- keys absent from a locale fall back to `en` (`fallbackLng: 'en'` in every runtime);
- keys present in a locale but *not* in `en` are treated as stale and removed by the linter (`wipe-extra-keys`);
- key order in every other locale is derived from `en`, so reordering `en` reorders all of them.
Translations for other languages arrive separately. Don't hand-write them for your own feature.
### Types are generated from `en`
`packages/i18n/src/resources.ts` is a checked-in dummy. The real key union is generated into `dist/resources.d.ts` from `en.i18n.json` at build time. At the time of writing it doesn't restrict `t`'s signature — the declaration adds overloads to i18next's `TFunction` rather than narrowing it, and narrowing was avoided for typecheck performance. **A misspelled key is not a compile error**, so verify keys by grepping the base locale.
A key you just added won't appear in the generated types until the package is rebuilt:
```sh
yarn workspace @rocket.chat/i18n build
```
## Naming keys
The dominant convention is capitalized snake case — `Sentence_case_with_underscores`:
```json
{
"Cam_on": "Camera on",
"Delete_room": "Delete room",
"You_are_offline_please_reconnect": "You are offline, please reconnect"
}
```
Write the key to describe the *meaning*, not the value, and not where it is rendered. `Delete_room` survives a copy change; `Delete_room_red_button` does not.
Reuse an existing key when the meaning is identical. Do not reuse one because the English happens to match — languages that inflect by context will need them separated, and splitting a shared key later is a breaking change for every locale.
### Namespaces
A key may be prefixed with one of exactly five namespaces, separated by a dot (`nsSeparator: '.'`):
`core` (the default) · `onboarding` · `registration` · `cloud` · `subscription`
```json
{
"onboarding.component.form.action.next": "Next",
"subscription.callout.title.limitsReached": "Limits reached"
}
```
An unprefixed key lands in `core`. Namespaces exist so a client can load a subset of the bundle, not as general grouping. The set is defined in [`packages/i18n/src/index.ts`](../packages/i18n/src/index.ts). Within a namespace, keys use lowercase dotted paths rather than snake case, following the existing entries.
## Interpolation
Use i18next named placeholders, `{{likeThis}}`, with camelCase names:
```json
{
"Room_removed": "Room {{roomName}} removed from ABAC management"
}
```
Two obsolete forms still exist in the base locale and must not be copied:
| Form | Status |
| ---- | ------ |
| `{{name}}` | ✅ correct |
| `__name__` | ❌ auto-rewritten by the linter (`replace-2-underscores`) |
| `%s` | ❌ legacy sprintf, positional; ~75 remaining, tracked by `find-sprintf-params` |
The sprintf form still works at runtime — both the client and the Meteor server install `i18next-sprintf-postprocessor` and wrap `t` with `addSprinfToI18n` — but it is positional, so a translator reordering a sentence silently swaps the values. Don't add new ones.
Some *key names* also embed the old marker — `Added__username__to_team`, `__count__result_found`. That's cosmetic legacy in the name only; the values use `{{...}}`. Don't imitate it in new keys.
### Never assemble a sentence from fragments
Word order is not universal, and a translator only sees the pieces:
❌ Incorrect:
```ts
`${t('Deleted')} ${count} ${t('messages')}`;
```
✅ Correct — one key holds the whole sentence:
```ts
t('Messages_deleted', { count });
```
A key carrying a count needs plural forms too, so `Messages_deleted` would be defined as an object — see [Pluralization](#pluralization).
Composing a `Label: value` pair from a label key and a runtime value is fine; splitting prose across keys is not.
### Formatters
Placeholders accept an i18next formatter after a comma. The Intl-backed built-ins work in every runtime:
```json
{
"Exceeded_limits": "Your workspace exceeded the {{val, list}} license limits.",
"Seats_used": "{{count, number}} seats used"
}
```
One custom formatter, `capitalize`, is registered **only in the client** ([`TranslationProvider`](../apps/meteor/client/providers/TranslationProvider.tsx)). It exists so that a language needing a different word order can capitalize whichever word ends up first, inside the translation file rather than in code. No base key uses it yet. Don't reach for it in a key the server also renders — there it would pass through unformatted.
## Pluralization
Give the key an object of plural forms and pass `count`. i18next picks the form using the locale's CLDR rules:
```json
{
"message_counter": {
"one": "{{count}} message",
"other": "{{count}} messages"
}
}
```
For `en` that means `one` and `other`. Other locales have different sets — Arabic has six — which is exactly why this must not be hand-rolled:
❌ Incorrect:
```ts
count === 1 ? t('message_counter_one') : t('message_counter_other');
```
✅ Correct:
```ts
t('message_counter', { count });
```
i18next also honours a special `zero` form, used when a dedicated empty-state phrasing reads better than "0 items":
```json
{
"Calls_in_queue": {
"zero": "Queue is empty",
"one": "{{count}} call in queue",
"other": "{{count}} calls in queue"
}
}
```
Only add `zero` when the wording genuinely differs; `other` already covers 0 in English.
Plural forms are validated per language: forms outside that language's CLDR set are stripped (`wipe-invalid-plurals`), and a locale missing a form that `en` defines is reported (`find-missing-plurals`).
## Server-side usage
Three runtimes initialise the shared resources independently:
| Runtime | Init |
| ------- | ---- |
| Client | [`client/providers/TranslationProvider.tsx`](../apps/meteor/client/providers/TranslationProvider.tsx) — `en` bundled, a non-English active locale fetched over HTTP |
| Meteor server | [`server/lib/i18n.ts`](../apps/meteor/server/lib/i18n.ts) — eager, all 68 locales in memory |
| `omnichannel-transcript` service | [`ee/apps/omnichannel-transcript/src/i18n.ts`](../ee/apps/omnichannel-transcript/src/i18n.ts) — same eager shape |
On the server, import the instance rather than creating one:
```ts
import { i18n } from '../../app/utils/lib/i18n';
```
### Always pass `lng`
> This actually exposes a gap in the server-side i18n design.
The server instance is initialised with `lng: 'en'` and there is no per-request language context. Omitting `lng` is not an error — it silently returns English. Only about a third of the ~200 server call sites pass it, so the surrounding code is not a reliable guide here.
❌ Incorrect — English regardless of who receives it:
```ts
i18n.t('Username_and_message_must_not_be_empty');
```
✅ Correct:
```ts
i18n.t('Username_and_message_must_not_be_empty', { lng: user.language || settings.get('Language') || 'en' });
```
That fallback chain — recipient's language, then the workspace `Language` setting, then `en` — is the established pattern. There is no shared helper for it yet, so it is written out at each call site.
Pick the language of whoever **reads** the string, which is not always the acting user: a notification, an email, or an export is rendered for its recipient.
### Don't translate at the API boundary
Prefer returning a key from an endpoint and translating in the client, which is what most endpoints already do. The client knows the reader's language; the server has to be told.
## Separate systems
[`packages/livechat`](../packages/livechat/) has its **own** translations in `src/i18n/`, unrelated to `@rocket.chat/i18n`: 59 locales, plain `<language>.json`, nested under a single `translation` root key, `lower_snake_case` keys, and plurals as `_one`/`_other` key suffixes instead of nested objects.
None of the rules on this page — the linter included — apply there. Don't copy conventions in either direction.
## What the linter enforces
`yarn workspace @rocket.chat/i18n lint` runs ESLint plus [`src/scripts/check.mts`](../packages/i18n/src/scripts/check.mts). Most findings are auto-fixable with `lint:fix`.
| Task | Rule |
| ---- | ---- |
| `sort-base-keys` | `en` keys sorted alphabetically, case-insensitive |
| `sort-keys` | every locale follows `en`'s key order |
| `wipe-extra-keys` | locales may not hold keys absent from `en` |
| `wipe-invalid-plurals` | plural forms must be valid for that language (plus `zero`) |
| `find-missing-plurals` | a locale must define every plural form `en` defines |
| `replace-2-underscores` | `__name__` → `{{name}}` |
| `missing-placeholders` / `extra-placeholders` | placeholders must match `en` exactly |
| `find-duplicate-keys` | no duplicate JSON keys |
| `trim-eof` | no trailing whitespace at end of file |
`sort-base-keys` runs before `sort-keys`, since every other locale's order is derived from `en`. A new key can go anywhere in `en`; `lint:fix` moves it into place and reorders the other 67 files to match.
Two details of the order are not alphabetical, and both are forced by JavaScript rather than chosen:
- integer-like keys come first (`"500"`), because `JSON.parse` hoists them to the front of the object no matter where the file puts them;
- keys differing only in case (`Private` / `private`, 69 such pairs) tie under the case-insensitive comparison and are broken by a plain code-point comparison, so the order stays canonical.
One task is defined but excluded from the default run, so it won't fail your build: `find-sprintf-params`, which flags the leftover `%s` values in `en` (6 keys today). It is excluded because of an existing backlog, and shouldn't be "fixed" as a side effect of a feature PR. You can inspect it without changing anything:
```sh
cd packages/i18n && node --experimental-transform-types ./src/scripts/check.mts -t find-sprintf-params
```
## Committing
Translation-only changes use the `i18n:` commit type, per the [pull request template](../.github/PULL_REQUEST_TEMPLATE.md).

View File

@@ -0,0 +1,34 @@
# Meteor's Modern Build Stack
We now use Meteor's [Modern Build Stack](https://docs.meteor.com/about/modern-build-stack.html) to build and run the project. This is a set of components that improve the building process as a whole, with better speed and new features and plugin support.
## Issues with File Watching
The modern stack uses [@parcel/watcher](https://github.com/parcel-bundler/watcher) to watch for file changes. This is a module that supports multiple watcher backends and will use the best option available, following this priority order:
- `FSEvents` on macOS
- `Watchman` if installed
- `inotify` on Linux
- `ReadDirectoryChangesW` on Windows
- `kqueue` on FreeBSD, or as an alternative to FSEvents on macOS
However, Watchman seems to [have a problem with Meteor's file structure](https://forums.meteor.com/t/modern-watcher-had-failed-to-start-watcher-for-error-message/63830) and it might not work properly.
If you ever had to build our ReactNative app, you likely have watchman installed on your computers. And so, if you're not using a Mac, the modern stack may fail to build and/or watch files. In this case, your options are:
1. Uninstalling watchman
2. Disabling Meteor's modern watcher on the package.json file:
```
"meteor": {
"modern": {
"watcher": false
}
},
```
### Additional Issues with TurboRepo
TurboRepo has an issue where the subprocesses it spawns are not always terminated along with the parent process. You may have noticed this before, as you often need to hit Ctrl+C twice to completely shutdown all processes. When Watchman is used as the watcher backend, this TurboRepo issue is aggravated and you now need to kill the watchman and node processes manually any time you want to stop the Rocket.Chat server.
TurboRepo has [just recently released](https://github.com/vercel/turborepo/pull/12607) an improvement related to this, but we have not updated to it yet.

144
docs/npm-publishing.md Normal file
View File

@@ -0,0 +1,144 @@
# npm publishing
The public `@rocket.chat/*` packages under `packages/` are published to npm by
`.github/workflows/release.yml`, which delegates to the local `packages/release-action`.
Everything else in the workspace is `private: true` and never published.
## How a publish happens
`release.yml` is the **only** workflow that publishes. It carries three triggers and maps each one
to a `release-action` mode:
| Trigger | `action` | Publishes? | dist-tag |
| ----------------------------- | --------------- | ---------- | --------------- |
| `push` to `master` | `publish-final` | yes | `latest` |
| `schedule` (monthly, 20th) | `next` | yes | `rc` |
| `workflow_dispatch` → `cut` | `cut` | yes | `latest` / `rc` |
| `workflow_dispatch` → `next` | `next` | yes | `rc` |
| `workflow_dispatch` → `patch` | `patch` | no | — |
`publishRelease.ts` and `bumpNextVersion.ts` both end with `yarn changeset publish --no-git-tag`.
Changesets then spawns `npm publish <packageDir> --access public --tag <tag>` from the repo root, so
**the npm CLI on `PATH` is what authenticates** — not yarn.
## Trusted publishing (OIDC)
Publishing authenticates via [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/)
rather than a long-lived token. GitHub Actions mints a short-lived, job-scoped credential at publish
time, and npm attaches a provenance attestation to every published tarball.
Two hard requirements, both handled in `release.yml`:
- `permissions: id-token: write` on the publishing job — without it the runner never exposes
`ACTIONS_ID_TOKEN_REQUEST_URL` and the OIDC exchange cannot happen.
- **npm >= 11.5.1** (the `Setup npm` step installs it). `engines.node` is 22.x, which bundles npm 10.
### How `NPM_TOKEN` interacts with the exchange
`npm publish` runs the OIDC exchange *before* it reads credentials, and on success it overrides the
`~/.npmrc` auth line with the freshly minted token. A present `NPM_TOKEN` therefore does **not**
prevent trusted publishing — the exchange still wins whenever it succeeds.
The token matters only when the exchange fails. npm's `oidc()` is written to never throw: every
failure path (missing `id-token` permission, package not registered, exchange rejected) returns
quietly, and `publish` then proceeds with the npmrc token — no provenance, no error, exit code 0.
That makes the token a **migration aid and a hazard at the same time**:
- While packages are being registered one at a time, it keeps releases from hard-failing on the
ones that are not registered yet.
- Once everything is registered, it hides regressions — a broken registration keeps publishing
under token auth instead of failing.
So `release.yml` keeps `NPM_TOKEN` only until every package publishes with provenance, then drops
both references (the `setup-node` input and the `Release` step's `env`).
### Migrating packages incrementally
npm allows one trusted publisher per package, so packages move over one at a time:
1. Register the package (see below). Watch for packages previously released from another repo —
`@rocket.chat/emitter`'s last provenance came from `RocketChat/fuselage`'s `cd.yml`, and that
registration has to be re-pointed here before this workflow can publish it.
2. Let a release run. The job log is not evidence — a token fallback looks identical to success.
3. Confirm with `npm view @rocket.chat/<name> --json dist.attestations`; non-null means the package
is publishing through OIDC.
4. When all published packages are attested, remove `NPM_TOKEN` from `release.yml`.
### Why there is only one release workflow
npm allows **one trusted publisher per package**, and it validates the _entry-point_ workflow
filename — the `workflow_ref` OIDC claim. `workflow_call` reusable workflows inherit the caller's
`workflow_ref`, so splitting the publish step into a shared workflow does not help: every caller
would need its own registration. Hence the single `release.yml` with three triggers.
**Renaming or moving `release.yml` breaks publishing** until every package's trusted publisher is
updated on npmjs.com.
### Registering a newly published package
npm cannot configure a trusted publisher for a package that does not exist yet, so a brand-new
package needs one manual first publish before it can be registered.
Register from a machine with npm >= 11.15.0 and account-level 2FA (granular tokens with the
bypass-2FA option are rejected):
```sh
npm trust github @rocket.chat/<name> \
--file release.yml \
--repo RocketChat/Rocket.Chat \
--allow-publish
npm trust list @rocket.chat/<name>
```
Or on `npmjs.com/package/@rocket.chat/<name>/access`:
| Field | Value |
| ----------------- | -------------------------------------- |
| Provider | GitHub Actions |
| Organization | `RocketChat` |
| Repository | `Rocket.Chat` |
| Workflow filename | `release.yml` (filename only, no path) |
| Environment | _(blank)_ |
| Allowed actions | `npm publish` |
No GitHub Environment is configured: the monthly RC cron and the push-to-master final release both
run unattended, and an environment with required reviewers would stall them.
### Provenance requires a correct `repository` field
npm generates provenance automatically for public packages published from a public repo via OIDC,
and **rejects the publish (422) when `repository.url` does not match the repository the workflow ran
in**. Every published package therefore needs:
```json
"repository": {
"type": "git",
"url": "git+https://github.com/RocketChat/Rocket.Chat.git",
"directory": "packages/<name>"
}
```
The org/repo casing (`RocketChat/Rocket.Chat`) has to match too.
Verify after a release:
```sh
npm view @rocket.chat/<name> --json dist.attestations
```
A non-null result is proof the publish went through OIDC — token-based publishes from this repo
never carried provenance.
## Troubleshooting
- **`E404` / `ENEEDAUTH` on `/-/npm/v1/oidc/token/exchange/package/…`** — almost always a
trusted-publisher mismatch: wrong repository, or the workflow filename entered with a path
(`.github/workflows/release.yml`) instead of bare `release.yml`.
- **`E401` / `ENEEDAUTH` with no token configured** — the exchange failed and there was nothing to
fall back to. Run with `--loglevel verbose`: `oidc()` logs its reason (`Skipped because incorrect
permissions`, `Failed token exchange request…`) instead of throwing.
- **422 on publish** — provenance mismatch; check the package's `repository` field.
- **Silent fallback to token auth** — if a valid token is present npm may authenticate with it
instead. Confirm via `dist.attestations` rather than the job log.

View File

@@ -0,0 +1,285 @@
# Proposal: Migrate the Apps-Engine converters to Zod codecs
## Status
In progress (PR #41205). Phases 0–5 landed; `transformMappedData` removed. See the per-phase
notes below, including the messages/threads deviation in Phase 4.
## Problem
`apps/meteor/app/apps/server/converters/` translates data between two models in both
directions:
- **Rocket.Chat → Apps-Engine** ("to app"): Mongo documents (`IUser`, `IRoom`, `IMessage`, …)
become Apps-Engine objects (`IAppsUser`, `IAppsRoom`, `IAppsMessage`, …). Methods such as
`convertToApp` / `convertRoom` / `convertMessage`.
- **Apps-Engine → Rocket.Chat** ("from app"): the reverse. Methods such as `convertToRocketChat` /
`convertApp*`.
Today the two directions are implemented by two unrelated mechanisms:
- The **to-app** direction is driven by `transformMappedData`, a declarative field remapper
(`{ to: 'from' | fn | { from, map, list } }`) that also collects everything it did not map into an
`_unmappedProperties_` bucket.
- The **from-app** direction is hand-written object construction with long chains of
`...(cond && { … })` spreads, re-merging `_unmappedProperties_` at the end.
This split has real costs:
1. **Two sources of truth per entity.** The field mapping is expressed once in a `map` and again,
inverted, in a hand-written builder. They drift.
2. **No runtime validation at the boundary.** The from-app path trusts whatever an app sends and
spreads it onto a Mongo document.
3. **Weak typing.** Several converters were untyped `.js`; the mapping is stringly-typed and the
relationship between the two directions is invisible to the compiler.
### Inventory
| Converter | Was | To-app | From-app | Async / DB | Notable hazards |
|--------------------|-----------|:------:|:--------:|:----------:|-----------------|
| `settings` | js → ts | ✅ | — | sync | `SettingType` enum |
| `roles` | ts | ✅ | — | sync | trivial |
| `videoConferences` | ts | ✅ | ✅ | sync | pass-through clone |
| `visitors` | js → ts | ✅ | ✅ | sync | `_unmappedProperties_` bucket |
| `departments` | js → ts | ✅ | ✅ | sync | `_unmappedProperties_` bucket |
| `users` | js → ts | ✅ | ✅ | sync | `UserType` / `UserStatusConnection` enums, contextual `console.warn` |
| `uploads` | js → ts | ✅ | ✅ | **async** | cross-converter (rooms/users/visitors) |
| `rooms` | js → ts | ✅ | ✅ | **async** | cross-converter fan-out, `isPartial`, `secureFieldsMapper`, `RoomType` enum |
| `messages` | js → ts | ✅ | ✅ | **async** | `WeakMap` + `cachedFunction` memoization, `isPartial`, attachment sub-map, visitor-sender fallback |
| `threads` | ts | ✅ | — | **async** | duplicates the messages attachment map |
| `contacts` | ts | ✅ | ✅ | sync | deep nested `list` reverse map |
Infrastructure: `transformMappedData.ts`, `cachedFunction.ts`, `convertMessageFiles.ts`.
## Proposed Solution
Model each entity as a **Zod codec** — a single bidirectional artifact that replaces the split
"map + hand-written inverse" pair and adds runtime validation.
Zod 4.3 (`~4.3.6`) is already a dependency, and the repo already ships a codec —
`TimestampSchema` in `packages/core-typings/src/utils.ts`:
```ts
export const TimestampSchema = z.codec(z.iso.datetime(), z.date(), {
encode: (date) => date.toISOString(),
decode: (str) => new Date(str),
});
```
`z.codec(RcSchema, AppSchema, { decode, encode })` maps directly onto our two directions:
- `decode` : Rocket.Chat → Apps-Engine (today's `convertToApp` / `convertRoom` / …)
- `encode` : Apps-Engine → Rocket.Chat (today's `convertToRocketChat` / `convertApp*`)
`z.decode(codec, x)` / `z.encode(codec, x)` run them synchronously; `z.decodeAsync` / `z.encodeAsync`
run them when the transforms are async.
The converter **classes stay** as the public façade (they implement the `IAppXConverter` interfaces
and are reached via `orch.getConverters().get('x')`, used in ~25 files). Codecs are an internal
implementation detail those methods delegate to.
## Key design decisions
1. **Async transforms → `z.decodeAsync` / `z.encodeAsync`.** rooms/messages/uploads/threads perform
DB lookups inside the mapping. Their codec transforms are async and must be driven with the async
entry points; the sync `z.decode` throws `$ZodAsyncError` on an async codec. Sync converters
(settings, roles, users, visitors, departments, contacts, videoConferences) use plain
`z.decode` / `z.encode`. *Validated against the installed Zod build during Phase 0.*
2. **Orchestrator dependency → codec factories.** Cross-converter converters cannot be static
singletons — they need `orch`. Expose `createRoomCodec(orch)`, `createUploadsCodec(orch)`, etc.,
returning a closure-bound codec. DB-free converters export a static codec constant.
3. **Preserve the `_unmappedProperties_` contract — do not drop it.** It is load-bearing: the reverse
converters merge it back, the EE redactor (`ee/server/apps/lib/redactor.ts`) references the path,
and `RoomBridge` reads it. A plain `z.object` strips unknown keys; `z.looseObject` keeps them
inline but *without* the bucket. We will build a small reusable helper (working name
`mappedCodec`) that reproduces `transformMappedData`'s bucket semantics exactly, so output is
byte-identical. This keeps the migration behaviour-preserving rather than a behaviour change.
The helper is intentionally **not** built up-front — it will be co-designed with the first
bidirectional converter (Phase 2) to avoid guessing the abstraction.
4. **`isPartial` (rooms/messages from-app) stays in the class method, not the codec.** Partial mode
skips required-field generation, skips the unmapped merge, and strips `undefined`. Model it as the
class calling either `z.encode` (full) or a partial path; do not encode the flag into the schema.
5. **Enum conversions → shared codec constants.** `UserType`, `UserStatusConnection`, `RoomType`,
`SettingType` become small codecs (like `TimestampSchema`) in `converters/codecs/enums.ts`,
reproducing the current `switch` logic including the pass-through/upper-case fallbacks.
Contextual `console.warn` calls that need data unavailable to a pure enum mapping (e.g. the
affected user's id/username in the status-connection warning) stay in the converter layer.
6. **Memoization (messages/threads) stays.** The `WeakMap` + `cachedFunction` dedup of user/room
lookups within a single conversion is preserved by constructing the message codec per-conversion
through the factory, passing in the memoized lookups.
7. **Loose validation on the from-app path.** Apps send arbitrary data today; strict schemas would
reject payloads that currently pass. Use `.loose()` / `.optional()` generously on the app-side
schemas so the migration introduces no rejections. Tightening is a deliberate, separate follow-up.
8. **Preserve the public surface.** The `IAppXConverter` interfaces and the
`orch.getConverters().get('x')` usage must not change.
### Direction convention (illustrative)
```ts
const UserCodec = z.codec(UserRocketChatSchema, AppsUserSchema, {
decode: (user) => ({ id: user._id, /* … */ }), // convertToApp
encode: (appUser) => removeEmpty({ _id: appUser.id, /* … */ }), // convertToRocketChat
});
// RC -> App: z.decode(UserCodec, user)
// App -> RC: z.encode(UserCodec, appUser)
```
## Migration phases
Each phase is independently shippable, keeps the class façade, and is gated by the golden tests.
### Phase 0 — Scaffolding & de-risk (done, PR #41205)
- Converted the 7 remaining `.js` converters to `.ts`, behaviour-preserving. `rooms`/`messages` keep
loose typing on their transform maps for now; that tightens when each is codec-ified.
- Fixed three pre-existing latent bugs that surfaced once the converters were typed (see
[Incidental fixes](#incidental-fixes)). These are the only intentional behaviour changes in the
phase and are each pinned by a golden/focused test.
- Added `converters/codecs/` with the first shared primitives: bidirectional enum codecs
(`UserType`, `UserStatusConnection`, `RoomType`, `SettingType`).
- Added the **behavioural safety net**: enum-codec unit tests (asserting parity with the legacy
helpers) and **golden-snapshot tests** locking the current RC ↔ Apps-Engine field mapping,
including `_unmappedProperties_` bucketing, for settings, users, visitors, departments, roles,
videoConferences, contacts and uploads.
- Validated the async-codec approach (`z.decodeAsync` / `z.encodeAsync`, `$ZodAsyncError`) that
Phases 3–4 depend on.
#### Incidental fixes
Typing the converters exposed three latent bugs in the legacy code. They are small, self-contained,
and fixed in Phase 0 rather than carried forward as "behaviour to preserve":
1. **`users` — `utfOffset` typo (from-app).** `convertToRocketChat` read the misspelled
`appUser.utfOffset`, so a round-tripped user always lost its `utcOffset`. It now reads
`utcOffset` first and keeps `utfOffset` only as a compatibility fallback for any app still
emitting the old key. The golden round-trip fixture uses `utcOffset`; a focused test pins the
legacy fallback.
2. **`messages` — dead visitor-sender fallback (to-app).** The `sender` resolver deleted
`message.u` *before* the "old system message without token" fallback re-read it, so the fallback
always received `undefined`. It now captures the sender before deletion. A focused test drives a
missing primary lookup and asserts the fallback resolves the original sender.
3. **`messages` — null editor deref (from-app).** `convertAppMessage` dereferenced
`Users.findOneById(editor.id)` through a non-null assertion, throwing if the editor no longer
exists. It now falls back to the editor data carried on the app payload, mirroring the adjacent
sender handling.
Alongside these, a few defensive guards were added where a destructuring or lookup could hit an
optional value that the types claimed was always present (`uploads` `rid`/`room`, `users`
`convertByUsername`, `rooms` `visitorChannelInfo`). These only turn a latent `TypeError` into the
already-intended empty result and match the guards their sibling fields already had, so they change
no covered output.
### Phase 1 — Trivial, one-way, sync
`settings`, `roles`, `videoConferences`. Proves the codec + enum-codec pattern end-to-end at the
lowest risk.
### Phase 2 — Self-contained, bidirectional, sync
`visitors`, `departments`, `users`. Introduces and hardens the `mappedCodec` unmapped-bucket helper
and exercises the enum codecs with the `console.warn` side effects preserved in both directions.
### Phase 3 — Async, cross-converter
`uploads`, then `rooms`. Introduces the codec-factory pattern, `z.decodeAsync` / `z.encodeAsync`,
`isPartial` handled in the class layer, and `secureFieldsMapper` integration.
### Phase 4 — Async + memoized + nested (done)
`messages`, `threads` and `contacts`. `contacts` became a clean bidirectional `ContactCodec`
(`decode` clones; `encode` maps every attribute via the nested field map). `mappedDecodeAsync` was
extended with the nested `{ from, map, list }` branch so it fully reproduces `transformMappedData`.
**Deviation — messages and threads are not standalone codecs.** Unlike every other converter, these
two were migrated by swapping their `transformMappedData` calls for the shared `mappedDecodeAsync`
helper *in place*, rather than being wrapped in `create*Codec` factories. Reasons:
1. **Test harness.** `messages.tests.js` uses `proxyquire` to stub `@rocket.chat/models`,
`@rocket.chat/random` and `@rocket.chat/core-typings` (`isMessageFromVisitor`) on the `messages.ts`
module. `proxyquire` only replaces a module's *own* `require`s, so moving `convertAppMessage`'s
model/`Random` usage into a separate `codecs/messages.ts` module would bypass the stubs and break
the test (real `Rooms.findOneById` → no `GENERAL` room → the "proper schema"/"invalid room" cases
fail). Keeping that usage in the class preserves the harness without editing it.
2. **Poor fit for the codec shape.** `convertMessage` builds its field map per call, closing over a
per-message memoization cache (`WeakMap` + `cachedFunction`), the source `msgObj`
(`isMessageFromVisitor`) and `mainFile`; `convertAppMessage` adds an `isPartial` flag and
`Random.id`/`new Date()` defaults. None of this expresses cleanly as a static bidirectional
`z.codec`, and forcing it would add casts and risk for no real gain.
The migration goal for these two is therefore narrower but still met: the `transformMappedData`
dependency is removed and the mapping logic is the shared, tested `mappedDecodeAsync`. The
attachment sub-maps stayed per-converter because messages' and threads' variants differ (threads has
extra `author`/`timestamp`/`fileId` guards) — sharing them would change behaviour. Wrapping messages
in a codec later remains possible (e.g. via `proxyquire`'s `@global` stubs) if desired.
### Phase 5 — Cleanup (done)
Every converter is off `transformMappedData`, so `transformMappedData.ts` was deleted and its
(previously importer-located) spec was retired — its coverage now lives in the `mappedDecodeAsync`
tests under `tests/unit/app/apps/server/codecs/`. `cachedFunction` stays: it is still used by the
messages and threads memoization, which remained in the class layer (see the Phase 4 deviation).
## Testing strategy
- **Golden snapshots** (added in Phase 0) are the equivalence oracle: each phase must keep them green
while swapping internals. Output is compared after `JSON.parse(JSON.stringify(...))` so Dates
normalise to ISO strings and `undefined` fields drop — matching how payloads cross the app bridge.
- **Enum-codec unit tests** assert `decode`/`encode` match the legacy `_convert*` helpers for every
input, including fallbacks.
- The existing `rooms.tests.ts` and `messages.tests.js` remain the oracle for the two hardest
converters.
- New codecs get their own focused tests as they are introduced.
## Risks
- **`_unmappedProperties_` fidelity** — the single biggest behavioural trap; the `mappedCodec` helper
plus golden tests are the mitigation.
- **From-app validation rejecting live app payloads** — mitigated by loose schemas; do not tighten
during the migration.
- **Async codec ergonomics** — de-risked in Phase 0, but the factory wiring for rooms/messages should
still be spiked before committing to Phase 3.
- **Test rewrite** — the existing tests use `proxyquire.noCallThru()` against module paths; codec-ing
each converter means updating those loaders.
## Type strategy for the mapping helpers
The mapping helpers in `converters/codecs/mappedData.ts` are split by how well the source type is
known, which is what makes generic typing worthwhile in some cases and not others:
- **Sync string maps → fully generic and inferred.** `mappedDecode<Source>(fieldMap)` and
`createMappedCodec<Source>(fieldMap)` are generic over the source document type. The field map is
typed `FieldMap<Source> = Record<string, Extract<keyof Source, string>>`, so a renamed or
misspelled source field is a **compile error** (a `@ts-expect-error` test locks this). The decode
result is the inferred `Decoded<Source, Map>` — each renamed target as an optional property plus the
`_unmappedProperties_` bucket typed as `Omit<Source, mappedKeys>` — rather than `Record<string, any>`.
`mappedEncode` is the inverse and returns `Partial<Source>`. `Source` defaults to a loose record, so
untyped call sites (e.g. tests) still work. Consumers with clean document types (`visitors`,
`departments`, `roles`) pass their `Source` and get the typo-safety for free.
- **Async/function/nested maps → loose map, generic result.** `mappedDecodeAsync` keeps its
`AsyncFieldMap` loosely typed on purpose: its consumers (`rooms`, `messages`, `uploads`, `contacts`)
map many optional/livechat-only fields that are *not* on the base document types (which is why the
original converters used dynamic access), and the return shape depends on arbitrary function/nested
entries. Fully inferring that would resurrect the large conditional type the old helper avoided. It
does take a `Result` type parameter — `mappedDecodeAsync<IAppsUpload>(data, map)` — so call sites
assert the produced shape once instead of trailing every call with `as unknown as Promise<…>`.
- **Each apps-type boundary keeps a single, documented cast.** The Apps-Engine interfaces
(`IAppsVisitor`, …) are hand-authored and don't structurally equal the produced `Decoded`/mapped
shape, so each affected codec boundary — supplied role, department, user and visitor — retains one
localized `as unknown as <AppsType>` where the codec meets that interface. Those casts are the
deliberate loose-validation seams (see design decision 7), not incidental looseness.
## Non-goals
- Changing the `IAppXConverter` interfaces or any app-facing behaviour.
- Tightening the from-app validation surface (tracked as a separate follow-up).
- Migrating converters that are not in the directory above.

View File

@@ -0,0 +1,104 @@
# Proposal: Compact AST Format for message-parser
## Status
Draft
## Problem
The current message-parser AST is verbose — every node carries its full text content as self-contained strings. For a message like `**hello** world`, the AST stores `"hello"` inside the Bold node and `" world"` in a separate Plain node, even though both substrings already exist in the original message. This redundancy inflates payload size, especially for messages with deeply nested formatting (bold inside italic inside strikethrough, etc.).
In high-traffic environments, parsed messages are stored and transmitted frequently. Reducing the AST footprint has a direct impact on storage costs, cache efficiency, and wire transfer size.
## Proposed Solution
Introduce a **compact AST format** that replaces self-contained string values with **span references** (`[start, end]`) into the original message text.
### Core Idea
Instead of:
```json
{
"type": "PARAGRAPH",
"value": [
{ "type": "BOLD", "value": [{ "type": "PLAIN_TEXT", "value": "hello" }] },
{ "type": "PLAIN_TEXT", "value": " world" }
]
}
```
The compact format stores:
```json
{ "t": "p", "c": [{ "t": "b", "c": [[2, 7]] }, [8, 14]] }
```
Plain text nodes become simple `[start, end]` tuples. Structural nodes use short type keys (`b`, `i`, `s`, `p`, `h`, etc.) and reference children via the same span mechanism.
### Key Operations
| Function | Description |
|---|---|
| `compactify(ast, msg)` | Converts a verbose AST + original message into a compact AST |
| `expand(compactAst, msg)` | Reconstructs the full verbose AST from a compact AST + original message |
| `validateRoundtrip(ast, msg)` | Verifies `expand(compactify(ast, msg), msg)` equals the original AST |
### Compact Type Mapping
| Verbose Type | Compact Key | Notes |
|---|---|---|
| `PLAIN_TEXT` | `[start, end]` | Span tuple, no wrapper object |
| `BOLD` | `b` | |
| `ITALIC` | `i` | |
| `STRIKE` | `s` | |
| `SPOILER` | `\|\|` | |
| `INLINE_CODE` | `` ` `` | |
| `MENTION_USER` | `@` | |
| `MENTION_CHANNEL` | `#` | |
| `INLINE_KATEX` | `$` | |
| `LINK` | `a` | |
| `IMAGE` | `img` | |
| `EMOJI` | `:` | |
| `TIMESTAMP` | `ts` | |
| `COLOR` | `c` | Stores RGBA as `[r, g, b, a]` |
| `PARAGRAPH` | `p` | |
| `HEADING` | `h` | Includes level `l: 1..4` |
| `CODE` | ```` ``` ```` | |
| `BLOCKQUOTE` | `>` | |
| `QUOTE` | `q` | |
| `SPOILER_BLOCK` | `\|\|\|` | |
| `ORDERED_LIST` | `ol` | |
| `UNORDERED_LIST` | `ul` | |
| `TASKS` | `tl` | |
| `KATEX` | `$$` | |
| `LINE_BREAK` | `br` | |
| `BIG_EMOJI` | `E` | |
## Trade-offs
### Advantages
- Significant size reduction on typical messages (observed 30-60% in initial tests)
- Lossless — roundtrip conversion preserves the full AST
- The original message is already stored alongside the AST, so no additional data is needed
- Short keys further reduce payload size
### Concerns
- Requires the original message text to be available at expansion time
- Adds a conversion layer — any bug in `compactify`/`expand` could corrupt message rendering
- Span-based references are fragile if the message text is modified after compaction
- Increases complexity in the message-parser package
## Open Questions
1. **Storage strategy** — Should compact ASTs replace verbose ones in the database, or coexist (e.g., compact for wire transfer, verbose for rendering)?
2. **Migration path** — How do we handle existing messages already stored with verbose ASTs?
3. **Rendering integration** — Should gazzodown learn to render compact ASTs directly, or always expand first?
4. **Message edits** — When a message is edited, do we reparse and recompact, or invalidate the compact form?
5. **Performance budget** — Is the compactify/expand overhead acceptable on the hot path, or should it be deferred to a background job?
## Reference
A working proof-of-concept implementation exists with full bidirectional conversion and roundtrip validation tests. It covers all current AST node types including BigEmoji, lists, tasks, code blocks, KaTeX, and color nodes.

View File

@@ -0,0 +1,114 @@
# Proposal: License Capability Enforcement via Branded Proofs
## Status
Draft — v2. Supersedes the earlier `CloudConnection`-only draft: the single-purpose connection capability is generalized into one proof mechanism that can guard any license-controlled capability, with the specific entitlement captured in the type.
## Problem
License enforcement today is a runtime side-condition the compiler knows nothing about, in two distinct places:
1. **Module entitlements.** `License.hasModule('auditing')` returns a `boolean`. Nothing ties that boolean to the code it guards: a feature entry point can be called without any check, a check for the *wrong* module still typechecks, and a refactor that moves code out from under its `if` block breaks enforcement silently.
2. **Offline (air-gapped) licenses.** When `license.information.offline` is true, the workspace must never initiate outbound connections to Rocket.Chat-owned endpoints. This is enforced by ~15 scattered `hasOfflineLicense()` checks (sync, marketplace, telemetry, push gateway, Gravatar). Any new feature can `import { serverFetch }` and ship a compliance violation no type error catches — and two real bugs of exactly this class were found during QA (a startup race in the usage report, and a stale verdict held by the push retry chain).
Both are the same underlying flaw: **the license check produces no evidence**. Nothing forces the check to happen, to happen for the right entitlement, or to happen at the right time.
## Proposed Solution
Make every license check return an unforgeable **proof value**, and make guarded code demand that proof in its signature. The pattern has three parts.
### 1. The lock — branded proof types
One brand mechanism, parameterized by what it proves. The generic parameter captures the exact literal passed to the checker, so proofs for different modules are not interchangeable:
```ts
// packages/core-typings/src/license/LicenseModule.ts (already exists)
export type InternalModuleName = (typeof CoreModules)[number];
export type ExternalModuleName = `${string}.${string}`;
export type LicenseModule = InternalModuleName | ExternalModuleName;
// ee/packages/license/src/proofs.ts (new)
declare const LicenseAuthorized: unique symbol; // NOT exported — unforgeable outside the package
/** Proof that the current license grants module M. */
export type ModuleProof<M extends LicenseModule> = {
readonly [LicenseAuthorized]: M;
};
/** Proof that the current license permits outbound calls to Rocket.Chat-owned
* endpoints (absent when the license carries the offline flag). */
export type CloudEgressProof = {
readonly [LicenseAuthorized]: 'cloud:egress';
};
```
The brand property is phantom — no such field exists at runtime. Proofs are frozen empty objects: zero allocation cost (a shared singleton per kind), zero serialization surface.
### 2. The keymaker — the license package owns construction
The only casts live inside `ee/packages/license`, next to the state they attest to:
```ts
// on LicenseManager — additive API; the existing boolean hasModule() stays
public proveModule<M extends LicenseModule>(module: M): ModuleProof<M> | undefined {
return this.hasModule(module) ? (PROOF as ModuleProof<M>) : undefined;
}
public proveCloudEgress(): CloudEgressProof | undefined {
return this.hasOfflineLicense() ? undefined : (PROOF as CloudEgressProof);
}
```
Design decisions:
- **Additive, not a signature change.** `hasModule(): boolean` has hundreds of call sites, including display logic and the REST surface the client consumes. Those keep the boolean. `proveModule()` is what *server-side entry points* migrate to.
- **Synchronous.** License state is in memory; proofs are free to acquire, which matters for the per-attempt rule below.
- **The offline gate is a proof family, not a module.** The offline flag has inverted semantics — it *revokes* a permission rather than granting a feature — but the same brand expresses it: `proveCloudEgress()` returns `undefined` exactly when the offline license forbids egress. A `proveCloudEgressOrThrow(message?)` variant throws the existing `CloudOfflineLicenseError` for interactive flows (registration, cloud login, billing), preserving today's error contract.
### 3. The guard — signatures demand proofs
```ts
// Module-gated feature entry point:
function initAuditing(proof: ModuleProof<'auditing'>) { ... }
initAuditing(); // ❌ compile error: expected 1 argument
const token = License.proveModule('auditing');
initAuditing(token); // ❌ compile error: possibly undefined
if (token) {
initAuditing(token); // ✅ narrowed to ModuleProof<'auditing'>
}
// Cloud I/O — the single fetch wrapper in apps/meteor/server/lib/cloud/cloudClient.ts:
export function cloudFetch(proof: CloudEgressProof, input: string, options?: ExtendedFetchOptions): Promise<Response>;
```
The developer experience does the enforcement: to call the function you need the token; the only way to get the token is the checker whose name and JSDoc explain the rule; the `| undefined` return forces an explicit decision about the denied case (skip silently vs. throw); and truthiness narrowing makes the happy path read naturally.
The proof is compile-time evidence, not the runtime gate itself: `cloudFetch` **re-validates `hasOfflineLicense()` immediately before dispatching** and drops the request if the license changed while the operation was in flight. The proof parameter guarantees the check can't be forgotten; the dispatch-time re-check guarantees it can't go stale.
Inner helpers that are only reachable from a guarded entry point take the proof as a parameter rather than re-checking — the signature documents "this function performs cloud I/O / module-X work", and the compiler walks the requirement up the call graph to wherever the proof is legitimately acquired.
## The staleness rule: acquire per attempt, never cache
Licenses change at runtime — swapped, upgraded, invalidated, or an offline license applied mid-flight. A proof is a **point-in-time verdict**, and both QA-discovered bugs in the offline work were staleness bugs:
- the usage report evaluated its gate at function entry, then spent seconds generating statistics before fetching (the verdict predated license application);
- the push gateway retry chain captured its decision in `setTimeout` closures that outlived a license change.
Rules, to be stated in the proofs' JSDoc and enforced in review:
1. Acquire the proof at the top of each operation *attempt*; retry closures re-acquire on every attempt (the factories are sync — this costs nothing).
2. Never store a proof on a class field, module scope, or queue payload.
3. A proof is not a subscription. Long-lived module features (services started when a module is granted) must still use the existing lifecycle events — `License.onValidFeature` / `onInvalidFeature` / `onToggledFeature` — for startup and teardown. Proofs guard entry points; events manage lifetimes.
4. Proofs are server-only and must never cross the API boundary; the client keeps boolean checks driven by `licenses.info`.
## Migration
1. **Cloud egress first** (the offline license feature, already enforced at runtime in ~27 files): introduce `proveCloudEgress()` and `cloudFetch(proof, ...)`, then convert the four established patterns — interactive flows (`OrThrow`), background jobs (`undefined` → keep side effects, skip), the marketplace client (proof acquired per `fetch()` call, never stored on the instance), and retry closures (per-attempt). The existing `hasOfflineLicense()` helpers remain for behavior gates that don't perform I/O themselves (`shouldUseGateway()`, cron-body skips, Gravatar suggestion filtering).
2. **Module proofs opportunistically**: as EE features are touched, their server entry points gain `ModuleProof<'...'>` parameters, starting with features whose checks have historically drifted from their code. No big-bang rewrite; the boolean API keeps working throughout.
## Limitations
TypeScript cannot forbid an import: a new file can still `import { serverFetch }` directly and hardcode an endpoint, and `{} as ModuleProof<'auditing'>` defeats the brand (both are greppable and glaring in review — the cast requires importing a type whose only documented constructor is the license manager). What the type system guarantees is narrower but real: **code routed through proof-demanding signatures cannot skip the check, cannot check the wrong module, and must handle the denied case explicitly** — and violations shrink to two obvious review signals: a raw `serverFetch` near a Rocket.Chat domain, or a forged cast.
A directory-scoped lint rule (`no-restricted-imports` on `@rocket.chat/server-fetch` with `cloudClient.ts` exempt) or a CI grep could close the import hole; both were considered and deliberately left out in favor of a types-only approach.