diff --git a/docs/proposals/apps-accessor-consolidation/README.md b/docs/proposals/apps-accessor-consolidation/README.md index 7662127a02b89..269f6bc4ea6b4 100644 --- a/docs/proposals/apps-accessor-consolidation/README.md +++ b/docs/proposals/apps-accessor-consolidation/README.md @@ -493,6 +493,50 @@ since consolidating more logic into the runtime makes the assumption more load-b hand-rolled message strings and per-param normalization judgment with a declared contract. A phase *after* this migration, not a prerequisite; this work should produce the explicit list, the SDK formalizes it. +6. **`createProcessorId` suffix check** (`SchedulerModify`) — the job-id namespacing uses + `includes` (substring match) to decide whether an id is already namespaced; it should be + `endsWith` (suffix match), since an appId substring in the *middle* of a job id makes the function + skip the suffix, risking namespace collisions and jobs that `cancelJob` can't reach. Flagged in review of + the migration PRs (CodeRabbit + cubic). **Not fixed inside the migration** because the runtime port + is a faithful copy of the host `SchedulerModify`, which has the same `includes` — changing only the + runtime copy re-introduces host↔runtime drift, the exact thing this migration removes. Fix both + copies together (or land it after the host copy is deleted in Phase 4) as a standalone behavior + change with its own test. +7. **Parity-preserving review findings deferred out of the migration** — automated reviewers + (CodeRabbit, cubic) flagged the items below on the ported runtime accessors. In every case the + ported code is **byte-identical to its host original**, so the flag (where valid) is a *pre-existing* + behavior, not a regression introduced here. They are deferred for the same reason as #6: fixing + only the runtime copy re-introduces host↔runtime drift. Land each — where a change is actually + wanted — as a standalone change touching **both** copies, or after the host copy is deleted in + Phase 4. Split into "latent behavior" (a real edge case, fix eventually) and "type/cosmetic" (no + runtime change; align only if the interface contract is worth tightening). + + *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 this + treats the user as present, skips the app-user lookup, and sends `userId: undefined`. A value + check (`!descriptor.user`) would fall back to the app user. Host uses `Object.hasOwn` identically. + - `RoomRead.getMessages` accepts `0`/negative `limit` — the guard only rejects `> 100`, unlike the + sibling `1–100` checks (`getAllRooms`, `getUnreadByUser`). Host has the same `>100`-only check. + - `RoomRead.getMessages` mutates the caller's `options` in place (`options.limit ??= 100`, + `options.showThreadMessages ??= true`); a reused options object is observably changed after the + call. Host mutates identically. Fix = copy before defaulting. + - `ServerSettingRead.getOneById` casts the bridge result to `ISetting` with no null/undefined + guard, so a missing setting returns `null`/`undefined` despite the typed return; the sibling + `getValueById` guards and throws. Host's `getOneById` also has no guard. + - `UIController` deprecated surface APIs (`openModalView`, `updateModalView`, + `openContextualBarView`, `updateContextualBarView`) call the serializers directly, skipping the + `UIHelper.assignIds` block-ID scoping that `openSurfaceView`/`updateSurfaceView` apply, so legacy + modal/contextual-bar interactions can emit un-scoped block IDs. Host's deprecated methods skip + `assignIds` too. + + *Type/cosmetic (no runtime change):* + - `ContactRead.getById` returns `Promise` while `IContactRead` + declares `| null`. The host original also returns `| undefined` and compiles, so this is a + pre-existing annotation mismatch, not a behavior difference. + - `MessageRead.getSenderUser`/`getRoom` and `ThreadRead.getThreadById` use raw casts that don't + surface the optional (`undefined`/`null`) bridge result in their return/argument types. Runtime + behavior is identical to host; purely a cast/typing nitpick. --- @@ -540,46 +584,93 @@ tests are deleted in that same PR — gated, for MOVE accessors, on the parity c Phase 0 is an internal, behavior-preserving refactor, so no changeset is added — this document and `base-runtime-app-id-exceptions.md` are the recorded contract. -### Phase 1 — Reader family + Persistence + Environment (server-side settings) +### Phase 1 — Reader family + Persistence + Environment (server-side settings) — ✅ runtime port landed -*Landed as one PR per accessor (or tight group); each PR ports the accessor, flips its `mod.ts` -proxy entry to local, passes the §6 parity check, then deletes the host class + its tests.* - -1. Port to base-runtime: `MessageRead`, `RoomRead`, `UserRead`, `PersistenceRead`, `LivechatRead`, - `UploadRead`, `CloudWorkspaceRead`, `VideoConferenceRead`, `OAuthAppsReader`, `ContactRead`, - `ThreadRead`, `RoleRead`, `ExperimentalRead`, `ServerSettingRead`, `EnvironmentalVariableRead`, +1. ✅ Ported to base-runtime (`accessors/read/*`, `accessors/environment/*`, `accessors/Persistence.ts`): + `MessageRead`, `RoomRead`, `UserRead`, `PersistenceRead`, `LivechatRead`, `UploadRead`, + `CloudWorkspaceRead`, `VideoConferenceRead`, `OAuthAppsReader`, `ContactRead`, `ThreadRead`, + `RoleRead`, `ExperimentalRead`, `ServerSettingRead`, `EnvironmentalVariableRead`, `ServerSettingUpdater`, `ServerSettingsModify`, `Persistence`, and the `Reader` / - `EnvironmentRead` / `EnvironmentWrite` facades (except the app-settings members, which stay - proxied until Phase 3). -2. Replace the corresponding `proxify(...)` entries in `mod.ts` (`getReader`, `getPersistence`, + `EnvironmentRead` / `EnvironmentWrite` facades. App-settings members (`getSettings` on + `EnvironmentRead`/`EnvironmentWrite`) stay proxied until Phase 3. Each class takes a `RemoteBridges` + and sends `bridges:*` with the `'APP_ID'` sentinel; all portable validation/defaulting logic + (limit/sort caps, option defaults, arg-array wrapping, `getValueById` fallback, the `getAppUser` + argument-appId exception) moved verbatim. Tests: `read/tests/readers.test.ts`, + `environment/tests/environment.test.ts`, `accessors/tests/Persistence.test.ts`, and the updated + `AppAccessors.test.ts`. +2. ✅ Flipped the corresponding `mod.ts` entries to local (`getReader`, `getPersistence`, `getEnvironmentRead`/`getEnvironmentWrite` server-settings/env-var members, - `getConfigurationModify:serverSettings`). -3. Delete the host classes + prune `AppAccessorManager` construction accordingly; port tests. - -### Phase 2 — Modify family completion - -*Same cadence as Phase 1: one PR per accessor (or tight group), parity-checked before the host class -is deleted. RECONCILE members (`ModifyCreator`/`ModifyUpdater`/`ModifyExtender`/`Notifier`) follow §3 -+ the direction-aware merge rule instead of the parity check.* - -1. Port: `ModifyDeleter`, `MessageUpdater`, `LivechatUpdater`, `UserUpdater`, `LivechatCreator`, - `UploadCreator`, `EmailCreator`, `ContactCreator`, `UIController`, `SchedulerModify`, - `OAuthAppsModify`, `ModerationModify`, `Modify` facade. -2. Remove the remaining `proxify` entries in `getModifier` and the sub-creator/sub-updater proxies - inside runtime `ModifyCreator`/`ModifyUpdater`. -3. Delete host classes; port tests. After this phase, `getReader`/`getModifier`/`getPersistence`/ + `getConfigurationModify:serverSettings`). No `accessor:*` traffic remains for these paths. +3. **Host-class deletion + `AppAccessorManager` pruning deferred to Phase 4 teardown** (deviation from + the original per-PR plan, taken deliberately). `AppAccessorManager.getReader()` constructs the + whole Reader family and is still called on the host by `AppListenerManager.executePostMessageSent` + (the `getAppUser` bot gate) and to build not-yet-migrated sub-accessors; deleting e.g. `RoomRead` + now would require pulling the Phase-4 `AppListenerManager` refactor and manager surgery forward + mid-phase. Keeping the host classes in place (dead for subprocess apps, still unit-tested and + green) keeps this step small and reviewable. The two implementations coexist transiently, which is + exactly what the §6 parity harness guards against — the runtime port's emitted bridge traffic is + pinned by tests against the documented host bridge calls. + +**One RPC-boundary adaptation (documented drift):** `ServerSettingRead.getValueById` checked +`typeof set === 'undefined'` on the host; across the RPC boundary an absent host return arrives as +`null`, so the runtime treats `null` and `undefined` alike as "not found". Behaviorally identical for +apps (the host bridge only ever returns a setting or nothing). + +### Phase 2 — Modify family completion — ✅ runtime port landed + +1. ✅ Ported to base-runtime (`accessors/modify/*`): `ModifyDeleter`, `MessageUpdater`, + `LivechatUpdater`, `UserUpdater`, `LivechatCreator` (local `createToken`), `UploadCreator` + (default-user fetch), `EmailCreator`, `ContactCreator`, `UIController` (local `UIHelper.assignIds` + + UIKit interaction formatting), `SchedulerModify` (local `createProcessorId` id-namespacing), + `OAuthAppsModify`, `ModerationModify`. Each takes a `RemoteBridges`; caller-identity params use the + `'APP_ID'` sentinel, while the `ModerationModify`/`ModifyDeleter.deleteUsers` argument-appIds stay + raw (bucket B) and `UIController`/`SchedulerModify` read the real id from `AppObjectRegistry` for + their non-identity uses (block/interaction stamping, job-id suffix). Tests: + `modify/tests/modifyAccessors.test.ts` + updated `ModifyCreator.test.ts` / `ModifyUpdater.test.ts`. +2. ✅ Removed the remaining `proxify` entries in `getModifier` (`getDeleter`/`getUiController`/ + `getScheduler`/`getOAuthAppsModifier`/`getModerationModifier`) and the `accessor:*` sub-creator + (`getLivechatCreator`/`getUploadCreator`/`getEmailCreator`/`getContactCreator`) and sub-updater + (`getLivechatUpdater`/`getUserUpdater`/`getMessageUpdater`) proxies inside the runtime + `ModifyCreator`/`ModifyUpdater`. After this phase, `getReader`/`getModifier`/`getPersistence`/ `getHttp` generate **zero** `accessor:*` traffic. - -### Phase 3 — Registration surface via `AppResourceBridge` - -1. Host: implement `AppResourceBridge` (§4) + internal-bridge lookup in `handleBridgeMessage` + - `restarting` guard for registration methods. Unit-test the guard and throw-vs-silent permission - behaviors. -2. Runtime: rewrite `getConfigurationExtend` / `getConfigurationModify:slashCommands` / - `SettingRead` / `SettingUpdater` / `SettingsExtend` members as local classes calling - `RemoteBridges.getAppResourceBridge()`, preserving the `AppObjectRegistry` stash-then-forward - wrappers; replace `accessor:api:listApis` with `doListApis`. -3. Delete the host `*Extend`/`SlashCommandsModify`/`SettingRead`/`SettingUpdater` accessors; port tests. +3. Host-class deletion + `AppAccessorManager` pruning again deferred to the Phase 4 teardown, for the + same reason as Phase 1 (keeps each step small and green; parity harness guards the transient + duplication). The RECONCILE members (`ModifyCreator`/`ModifyUpdater`/`ModifyExtender`/`Notifier`) + were already runtime-canonical from Phase 0; their sub-accessors are now local too. + +**RPC-boundary note:** the sub-accessor methods that returned typed host-bridge results now flow +through `RemoteBridges` (`Promise`) and are cast to their interface return types; +`void`-returning methods (`setActiveState`, `endActiveState`, reactions, deletes) `await` instead of +returning the bridge value, matching the interface. + +### Phase 3 — Registration surface via `AppResourceBridge` — ✅ landed + +1. ✅ Host: added the concrete `AppResourceBridge` (`src/server/bridges/AppResourceBridge.ts`) + delegating to the managers (`AppSlashCommandManager`, `AppApiManager`, `AppSchedulerManager`, + `UIActionButtonManager`, `AppExternalComponentManager`, `AppVideoConfProviderManager`, + `AppOutboundCommunicationProviderManager`) and the app's `ProxiedApp` storage item + + `AppSettingsManager` for settings. Wired into `BaseRuntimeSubprocessController.handleBridgeMessage` + via a dedicated `getAppResourceBridge` lookup (resolved from a controller field, not `AppBridges`) + + the `restarting` guard keyed on `AppResourceBridge.REGISTRATION_METHODS`. Permission/conflict + semantics are unchanged because each method calls the same manager the host accessor used (the + video-conf/outbound `PermissionDeniedError` throw and the UI log-and-refuse both propagate as + before). The `AppManager` instance is passed to the bridge in the controller constructor — no + `apps/meteor` orchestrator changes needed. +2. ✅ Runtime: rewrote `getConfigurationExtend` (ui/settings/externalComponents/api/scheduler/ + videoConfProviders/outboundCommunication/slashCommands), `getConfigurationModify` + (slashCommands → modify/enable/disable; scheduler → local `SchedulerModify`), and the app-settings + `SettingRead`/`SettingUpdater`/`SettingsExtend` members to call `getAppResourceBridge().do*`, + preserving the `AppObjectRegistry` stash-then-forward. `accessor:api:listApis` is replaced by + `doListApis`. `registerButton` stays a synchronous `void` (fire-and-forget) per its interface. The + now-dead `proxify` machinery and `WithProxy` type were removed from `mod.ts` — **the runtime no + longer emits any `accessor:*` message at all.** +3. Host accessor deletion (`*Extend`/`SlashCommandsModify`/`SettingRead`/`SettingUpdater`) + the rest + of the teardown are deferred to Phase 4, consistent with Phases 1–2. Tests: + `accessors/tests/configuration.test.ts` + updated `AppAccessors.test.ts`; the host + `DenoRuntimeSubprocessController` test validates the controller wiring. + +**RPC-boundary note:** `SettingRead.getValueById` treats `null` and `undefined` alike as "does not +exist" (undefined serializes to null across the boundary), same adaptation as `ServerSettingRead`. ### Phase 4 — Teardown diff --git a/packages/apps/base-runtime/src/lib/accessors/Persistence.ts b/packages/apps/base-runtime/src/lib/accessors/Persistence.ts new file mode 100644 index 0000000000000..60c5b1b9f1377 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/Persistence.ts @@ -0,0 +1,44 @@ +import type { IPersistence } from '@rocket.chat/apps-engine/definition/accessors'; +import type { RocketChatAssociationRecord } from '@rocket.chat/apps-engine/definition/metadata'; + +import type { RemoteBridges } from '../bridges/RemoteBridges'; + +export class Persistence implements IPersistence { + constructor(private readonly bridges: RemoteBridges) {} + + public create(data: object): Promise { + return this.bridges.getPersistenceBridge().doCreate(data, 'APP_ID') as Promise; + } + + public createWithAssociation(data: object, association: RocketChatAssociationRecord): Promise { + return this.bridges.getPersistenceBridge().doCreateWithAssociations(data, new Array(association), 'APP_ID') as Promise; + } + + public createWithAssociations(data: object, associations: Array): Promise { + return this.bridges.getPersistenceBridge().doCreateWithAssociations(data, associations, 'APP_ID') as Promise; + } + + public update(id: string, data: object, upsert = false): Promise { + return this.bridges.getPersistenceBridge().doUpdate(id, data, upsert, 'APP_ID') as Promise; + } + + public updateByAssociation(association: RocketChatAssociationRecord, data: object, upsert = false): Promise { + return this.bridges.getPersistenceBridge().doUpdateByAssociations(new Array(association), data, upsert, 'APP_ID') as Promise; + } + + public updateByAssociations(associations: Array, data: object, upsert = false): Promise { + return this.bridges.getPersistenceBridge().doUpdateByAssociations(associations, data, upsert, 'APP_ID') as Promise; + } + + public remove(id: string): Promise { + return this.bridges.getPersistenceBridge().doRemove(id, 'APP_ID') as Promise; + } + + public removeByAssociation(association: RocketChatAssociationRecord): Promise> { + return this.bridges.getPersistenceBridge().doRemoveByAssociations(new Array(association), 'APP_ID') as Promise>; + } + + public removeByAssociations(associations: Array): Promise> { + return this.bridges.getPersistenceBridge().doRemoveByAssociations(associations, 'APP_ID') as Promise>; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/environment/EnvironmentRead.ts b/packages/apps/base-runtime/src/lib/accessors/environment/EnvironmentRead.ts new file mode 100644 index 0000000000000..8400a2c79a4de --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/environment/EnvironmentRead.ts @@ -0,0 +1,26 @@ +import type { + IEnvironmentalVariableRead, + IEnvironmentRead, + IServerSettingRead, + ISettingRead, +} from '@rocket.chat/apps-engine/definition/accessors'; + +export class EnvironmentRead implements IEnvironmentRead { + constructor( + private readonly settings: ISettingRead, + private readonly serverSettings: IServerSettingRead, + private readonly envRead: IEnvironmentalVariableRead, + ) {} + + public getSettings(): ISettingRead { + return this.settings; + } + + public getServerSettings(): IServerSettingRead { + return this.serverSettings; + } + + public getEnvironmentVariables(): IEnvironmentalVariableRead { + return this.envRead; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/environment/EnvironmentWrite.ts b/packages/apps/base-runtime/src/lib/accessors/environment/EnvironmentWrite.ts new file mode 100644 index 0000000000000..d13bc5b834f64 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/environment/EnvironmentWrite.ts @@ -0,0 +1,16 @@ +import type { IEnvironmentWrite, IServerSettingUpdater, ISettingUpdater } from '@rocket.chat/apps-engine/definition/accessors'; + +export class EnvironmentWrite implements IEnvironmentWrite { + constructor( + private readonly settings: ISettingUpdater, + private readonly serverSettings: IServerSettingUpdater, + ) {} + + public getSettings(): ISettingUpdater { + return this.settings; + } + + public getServerSettings(): IServerSettingUpdater { + return this.serverSettings; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/environment/EnvironmentalVariableRead.ts b/packages/apps/base-runtime/src/lib/accessors/environment/EnvironmentalVariableRead.ts new file mode 100644 index 0000000000000..aa7d0c97d8b6d --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/environment/EnvironmentalVariableRead.ts @@ -0,0 +1,19 @@ +import type { IEnvironmentalVariableRead } from '@rocket.chat/apps-engine/definition/accessors'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class EnvironmentalVariableRead implements IEnvironmentalVariableRead { + constructor(private readonly bridges: RemoteBridges) {} + + public getValueByName(envVarName: string): Promise { + return this.bridges.getEnvironmentalVariableBridge().doGetValueByName(envVarName, 'APP_ID') as Promise; + } + + public isReadable(envVarName: string): Promise { + return this.bridges.getEnvironmentalVariableBridge().doIsReadable(envVarName, 'APP_ID') as Promise; + } + + public isSet(envVarName: string): Promise { + return this.bridges.getEnvironmentalVariableBridge().doIsSet(envVarName, 'APP_ID') as Promise; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/environment/ServerSettingRead.ts b/packages/apps/base-runtime/src/lib/accessors/environment/ServerSettingRead.ts new file mode 100644 index 0000000000000..91f91c99774b9 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/environment/ServerSettingRead.ts @@ -0,0 +1,36 @@ +import type { IServerSettingRead } from '@rocket.chat/apps-engine/definition/accessors'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class ServerSettingRead implements IServerSettingRead { + constructor(private readonly bridges: RemoteBridges) {} + + public getOneById(id: string): Promise { + return this.bridges.getServerSettingBridge().doGetOneById(id, 'APP_ID') as Promise; + } + + public async getValueById(id: string): Promise { + const set = (await this.bridges.getServerSettingBridge().doGetOneById(id, 'APP_ID')) as ISetting; + + // The host accessor checks `typeof set === 'undefined'`, but across the RPC boundary an + // absent (undefined) host return is serialized as null, so both must be treated as "not found". + if (set === undefined || set === null) { + throw new Error(`No Server Setting found, or it is unaccessible, by the id of "${id}".`); + } + + if (set.value === undefined || set.value === null) { + return set.packageValue; + } + + return set.value; + } + + public getAll(): Promise> { + throw new Error('Method not implemented.'); + } + + public isReadableById(id: string): Promise { + return this.bridges.getServerSettingBridge().doIsReadableById(id, 'APP_ID') as Promise; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/environment/ServerSettingUpdater.ts b/packages/apps/base-runtime/src/lib/accessors/environment/ServerSettingUpdater.ts new file mode 100644 index 0000000000000..79a6bb43d0f9e --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/environment/ServerSettingUpdater.ts @@ -0,0 +1,16 @@ +import type { IServerSettingUpdater } from '@rocket.chat/apps-engine/definition/accessors'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class ServerSettingUpdater implements IServerSettingUpdater { + constructor(private readonly bridges: RemoteBridges) {} + + public async updateOne(setting: ISetting): Promise { + await this.bridges.getServerSettingBridge().doUpdateOne(setting, 'APP_ID'); + } + + public async incrementValue(id: ISetting['id'], value = 1): Promise { + await this.bridges.getServerSettingBridge().doIncrementValue(id, value, 'APP_ID'); + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/environment/ServerSettingsModify.ts b/packages/apps/base-runtime/src/lib/accessors/environment/ServerSettingsModify.ts new file mode 100644 index 0000000000000..45fdfb1a78eba --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/environment/ServerSettingsModify.ts @@ -0,0 +1,24 @@ +import type { IServerSettingsModify } from '@rocket.chat/apps-engine/definition/accessors'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class ServerSettingsModify implements IServerSettingsModify { + constructor(private readonly bridges: RemoteBridges) {} + + public async hideGroup(name: string): Promise { + await this.bridges.getServerSettingBridge().doHideGroup(name, 'APP_ID'); + } + + public async hideSetting(id: string): Promise { + await this.bridges.getServerSettingBridge().doHideSetting(id, 'APP_ID'); + } + + public async modifySetting(setting: ISetting): Promise { + await this.bridges.getServerSettingBridge().doUpdateOne(setting, 'APP_ID'); + } + + public async incrementValue(id: ISetting['id'], value = 1): Promise { + await this.bridges.getServerSettingBridge().doIncrementValue(id, value, 'APP_ID'); + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/environment/SettingRead.ts b/packages/apps/base-runtime/src/lib/accessors/environment/SettingRead.ts new file mode 100644 index 0000000000000..5a4dfe69e5fed --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/environment/SettingRead.ts @@ -0,0 +1,30 @@ +import type { ISettingRead } from '@rocket.chat/apps-engine/definition/accessors'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +// App settings are host-persisted metadata (ProxiedApp storage item), fronted by the internal +// AppResourceBridge. The value fallback that used to run host-side now runs locally. +export class SettingRead implements ISettingRead { + constructor(private readonly bridges: RemoteBridges) {} + + public getById(id: string): Promise { + return this.bridges.getAppResourceBridge().doGetSettingById(id, 'APP_ID') as Promise; + } + + public async getValueById(id: string): Promise { + const set = (await this.getById(id)) as ISetting; + + // The host accessor checks `typeof set === 'undefined'`; across the RPC boundary an absent + // host return arrives as null, so both are treated as "does not exist". + if (set === undefined || set === null) { + throw new Error(`Setting "${id}" does not exist.`); + } + + if (set.value === undefined || set.value === null) { + return set.packageValue; + } + + return set.value; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/environment/SettingUpdater.ts b/packages/apps/base-runtime/src/lib/accessors/environment/SettingUpdater.ts new file mode 100644 index 0000000000000..9c5dec9f1e4eb --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/environment/SettingUpdater.ts @@ -0,0 +1,19 @@ +import type { ISettingUpdater } from '@rocket.chat/apps-engine/definition/accessors/ISettingUpdater'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +// The "not found" guard and the AppSettingsManager persistence run host-side in the +// AppResourceBridge (they depend on the ProxiedApp storage item and the settings manager); the +// runtime accessor is a thin forwarder. +export class SettingUpdater implements ISettingUpdater { + constructor(private readonly bridges: RemoteBridges) {} + + public async updateValue(id: ISetting['id'], value: ISetting['value']): Promise { + await this.bridges.getAppResourceBridge().doUpdateSettingValue(id, value, 'APP_ID'); + } + + public async updateSelectOptions(id: ISetting['id'], values: ISetting['values']): Promise { + await this.bridges.getAppResourceBridge().doUpdateSettingSelectOptions(id, values, 'APP_ID'); + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/environment/tests/environment.test.ts b/packages/apps/base-runtime/src/lib/accessors/environment/tests/environment.test.ts new file mode 100644 index 0000000000000..b7b517faf0da6 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/environment/tests/environment.test.ts @@ -0,0 +1,93 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { RemoteBridges } from '../../../bridges/RemoteBridges'; +import { createRecordingSender } from '../../tests/helpers/parityHarness'; +import { EnvironmentalVariableRead } from '../EnvironmentalVariableRead'; +import { ServerSettingRead } from '../ServerSettingRead'; +import { ServerSettingUpdater } from '../ServerSettingUpdater'; +import { ServerSettingsModify } from '../ServerSettingsModify'; + +const setup = (responses = {}) => { + const rec = createRecordingSender(responses); + return { rec, bridges: new RemoteBridges(rec.sender) }; +}; + +describe('Environment accessors (base-runtime)', () => { + describe('ServerSettingRead', () => { + it('getValueById returns the value when set', async () => { + const { bridges } = setup({ 'bridges:getServerSettingBridge:doGetOneById': { value: 'v', packageValue: 'pv' } }); + assert.strictEqual(await new ServerSettingRead(bridges).getValueById('s1'), 'v'); + }); + + it('getValueById falls back to packageValue when value is null', async () => { + const nullVal = setup({ 'bridges:getServerSettingBridge:doGetOneById': { value: null, packageValue: 'pv' } }); + assert.strictEqual(await new ServerSettingRead(nullVal.bridges).getValueById('s1'), 'pv'); + }); + + it('getValueById falls back to packageValue when value is undefined', async () => { + const undefinedVal = setup({ 'bridges:getServerSettingBridge:doGetOneById': { value: undefined, packageValue: 'pv' } }); + assert.strictEqual(await new ServerSettingRead(undefinedVal.bridges).getValueById('s1'), 'pv'); + }); + + it('getValueById throws when the setting is not found', async () => { + const { bridges } = setup({ 'bridges:getServerSettingBridge:doGetOneById': undefined }); + await assert.rejects(() => new ServerSettingRead(bridges).getValueById('missing'), /No Server Setting found/); + }); + + it('getAll throws (not implemented), matching the host accessor', () => { + const { bridges } = setup(); + assert.throws(() => new ServerSettingRead(bridges).getAll(), /Method not implemented/); + }); + + it('getOneById and isReadableById forward verbatim', async () => { + const { rec, bridges } = setup(); + const read = new ServerSettingRead(bridges); + await read.getOneById('s1'); + await read.isReadableById('s1'); + assert.deepStrictEqual(rec.emitted(), [ + { method: 'bridges:getServerSettingBridge:doGetOneById', params: ['s1', 'APP_ID'] }, + { method: 'bridges:getServerSettingBridge:doIsReadableById', params: ['s1', 'APP_ID'] }, + ]); + }); + }); + + describe('EnvironmentalVariableRead', () => { + it('forwards each method to its bridge call', async () => { + const { rec, bridges } = setup(); + const env = new EnvironmentalVariableRead(bridges); + await env.getValueByName('X'); + await env.isReadable('X'); + await env.isSet('X'); + assert.deepStrictEqual(rec.methods(), [ + 'bridges:getEnvironmentalVariableBridge:doGetValueByName', + 'bridges:getEnvironmentalVariableBridge:doIsReadable', + 'bridges:getEnvironmentalVariableBridge:doIsSet', + ]); + }); + }); + + describe('ServerSettingUpdater', () => { + it('incrementValue defaults the amount to 1', async () => { + const { rec, bridges } = setup(); + await new ServerSettingUpdater(bridges).incrementValue('s1'); + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getServerSettingBridge:doIncrementValue', + params: ['s1', 1, 'APP_ID'], + }); + }); + }); + + describe('ServerSettingsModify', () => { + it('modifySetting maps to doUpdateOne and incrementValue defaults to 1', async () => { + const { rec, bridges } = setup(); + const modify = new ServerSettingsModify(bridges); + await modify.modifySetting({ id: 's1' } as any); + await modify.incrementValue('s1'); + assert.deepStrictEqual(rec.emitted(), [ + { method: 'bridges:getServerSettingBridge:doUpdateOne', params: [{ id: 's1' }, 'APP_ID'] }, + { method: 'bridges:getServerSettingBridge:doIncrementValue', params: ['s1', 1, 'APP_ID'] }, + ]); + }); + }); +}); diff --git a/packages/apps/base-runtime/src/lib/accessors/mod.ts b/packages/apps/base-runtime/src/lib/accessors/mod.ts index acd1df5a2a489..bf5d68fcbedb5 100644 --- a/packages/apps/base-runtime/src/lib/accessors/mod.ts +++ b/packages/apps/base-runtime/src/lib/accessors/mod.ts @@ -4,6 +4,7 @@ import type { IConfigurationExtend } from '@rocket.chat/apps-engine/definition/a import type { IConfigurationModify } from '@rocket.chat/apps-engine/definition/accessors/IConfigurationModify'; import type { IEnvironmentRead } from '@rocket.chat/apps-engine/definition/accessors/IEnvironmentRead'; import type { IEnvironmentWrite } from '@rocket.chat/apps-engine/definition/accessors/IEnvironmentWrite'; +import type { IExternalComponentsExtend } from '@rocket.chat/apps-engine/definition/accessors/IExternalComponentsExtend'; import type { IHttp, IHttpExtend } from '@rocket.chat/apps-engine/definition/accessors/IHttp'; import type { IModify } from '@rocket.chat/apps-engine/definition/accessors/IModify'; import type { INotifier } from '@rocket.chat/apps-engine/definition/accessors/INotifier'; @@ -11,8 +12,10 @@ import type { IOutboundCommunicationProviderExtend } from '@rocket.chat/apps-eng import type { IPersistence } from '@rocket.chat/apps-engine/definition/accessors/IPersistence'; import type { IRead } from '@rocket.chat/apps-engine/definition/accessors/IRead'; import type { ISchedulerExtend } from '@rocket.chat/apps-engine/definition/accessors/ISchedulerExtend'; +import type { ISettingsExtend } from '@rocket.chat/apps-engine/definition/accessors/ISettingsExtend'; import type { ISlashCommandsExtend } from '@rocket.chat/apps-engine/definition/accessors/ISlashCommandsExtend'; import type { ISlashCommandsModify } from '@rocket.chat/apps-engine/definition/accessors/ISlashCommandsModify'; +import type { IUIExtend } from '@rocket.chat/apps-engine/definition/accessors/IUIExtend'; import type { IVideoConfProvidersExtend } from '@rocket.chat/apps-engine/definition/accessors/IVideoConfProvidersExtend'; import type { IApi } from '@rocket.chat/apps-engine/definition/api/IApi'; import type { IApiEndpointMetadata } from '@rocket.chat/apps-engine/definition/api/IApiEndpointMetadata'; @@ -24,18 +27,43 @@ import type { IProcessor } from '@rocket.chat/apps-engine/definition/scheduler/I import type { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands/ISlashCommand'; import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders/IVideoConfProvider'; +import { Persistence } from './Persistence'; import { HttpExtend } from './extenders/HttpExtender'; -import { formatErrorResponse } from './formatResponseErrorHandler'; import { Http } from './http'; import { AppObjectRegistry } from '../../AppObjectRegistry'; +import { RemoteBridges } from '../bridges/RemoteBridges'; import * as Messenger from '../messenger'; +import { EnvironmentRead } from './environment/EnvironmentRead'; +import { EnvironmentWrite } from './environment/EnvironmentWrite'; +import { EnvironmentalVariableRead } from './environment/EnvironmentalVariableRead'; +import { ServerSettingRead } from './environment/ServerSettingRead'; +import { ServerSettingUpdater } from './environment/ServerSettingUpdater'; +import { ServerSettingsModify } from './environment/ServerSettingsModify'; +import { SettingRead } from './environment/SettingRead'; +import { SettingUpdater } from './environment/SettingUpdater'; +import { ModerationModify } from './modify/ModerationModify'; import { ModifyCreator } from './modify/ModifyCreator'; +import { ModifyDeleter } from './modify/ModifyDeleter'; import { ModifyExtender } from './modify/ModifyExtender'; import { ModifyUpdater } from './modify/ModifyUpdater'; +import { OAuthAppsModify } from './modify/OAuthAppsModify'; +import { SchedulerModify } from './modify/SchedulerModify'; +import { UIController } from './modify/UIController'; import { Notifier } from './notifier'; - -/** Helper: extends T with an internal _proxy property used for delegation. */ -type WithProxy = T & { _proxy: T }; +import { CloudWorkspaceRead } from './read/CloudWorkspaceRead'; +import { ContactRead } from './read/ContactRead'; +import { ExperimentalRead } from './read/ExperimentalRead'; +import { LivechatRead } from './read/LivechatRead'; +import { MessageRead } from './read/MessageRead'; +import { OAuthAppsReader } from './read/OAuthAppsReader'; +import { PersistenceRead } from './read/PersistenceRead'; +import { Reader } from './read/Reader'; +import { RoleRead } from './read/RoleRead'; +import { RoomRead } from './read/RoomRead'; +import { ThreadRead } from './read/ThreadRead'; +import { UploadRead } from './read/UploadRead'; +import { UserRead } from './read/UserRead'; +import { VideoConferenceRead } from './read/VideoConferenceRead'; const httpMethods = ['get', 'post', 'put', 'delete', 'head', 'options', 'patch'] as const; @@ -73,37 +101,10 @@ export class AppAccessors { private notifier?: INotifier; - private proxify: (namespace: string, overrides?: Record unknown>) => T; + private readonly bridges: RemoteBridges; constructor(private readonly senderFn: typeof Messenger.sendRequest) { - this.proxify = (namespace: string, overrides: Record unknown> = {}): T => - new Proxy( - { __kind: `accessor:${namespace}` }, - { - get: - (_target: unknown, prop: string) => - (...params: unknown[]) => { - // We don't want to send a request for this prop - if (prop === 'toJSON') { - return {}; - } - - // If the prop is inteded to be overriden by the caller - if (prop in overrides) { - return overrides[prop].apply(undefined, params); - } - - return senderFn({ - method: `accessor:${namespace}:${prop}`, - params, - }) - .then((response) => response.result) - .catch((err) => { - throw formatErrorResponse(err); - }); - }, - }, - ) as T; + this.bridges = new RemoteBridges(senderFn); this.http = new Http(this.getReader(), this.getPersistence(), this.httpExtend, this.getSenderFn()); this.notifier = new Notifier(this.getSenderFn()); @@ -115,11 +116,13 @@ export class AppAccessors { public getEnvironmentRead(): IEnvironmentRead { if (!this.environmentRead) { - this.environmentRead = { - getSettings: () => this.proxify('getEnvironmentRead:getSettings'), - getServerSettings: () => this.proxify('getEnvironmentRead:getServerSettings'), - getEnvironmentVariables: () => this.proxify('getEnvironmentRead:getEnvironmentVariables'), - }; + // App settings, server settings and environment variables all run locally now; app + // settings reach the host ProxiedApp storage item through the internal AppResourceBridge. + this.environmentRead = new EnvironmentRead( + new SettingRead(this.bridges), + new ServerSettingRead(this.bridges), + new EnvironmentalVariableRead(this.bridges), + ); } return this.environmentRead; @@ -127,10 +130,7 @@ export class AppAccessors { public getEnvironmentWrite() { if (!this.environmentWriter) { - this.environmentWriter = { - getSettings: () => this.proxify('getEnvironmentWrite:getSettings'), - getServerSettings: () => this.proxify('getEnvironmentWrite:getServerSettings'), - }; + this.environmentWriter = new EnvironmentWrite(new SettingUpdater(this.bridges), new ServerSettingUpdater(this.bridges)); } return this.environmentWriter; @@ -138,26 +138,27 @@ export class AppAccessors { public getConfigurationModify() { if (!this.configModifier) { - const slashCommandsModify: WithProxy = { - _proxy: this.proxify('getConfigurationModify:slashCommands'), + const resourceBridge = this.bridges.getAppResourceBridge(); + + const slashCommandsModify: ISlashCommandsModify = { modifySlashCommand(slashcommand: ISlashCommand) { // Store the slashcommand instance to use when the Apps-Engine calls the slashcommand AppObjectRegistry.set(`slashcommand:${slashcommand.command}`, slashcommand); - return this._proxy.modifySlashCommand(slashcommand); + return resourceBridge.doModifySlashCommand(slashcommand, 'APP_ID') as Promise; }, disableSlashCommand(command: string) { - return this._proxy.disableSlashCommand(command); + return resourceBridge.doDisableSlashCommand(command, 'APP_ID') as Promise; }, enableSlashCommand(command: string) { - return this._proxy.enableSlashCommand(command); + return resourceBridge.doEnableSlashCommand(command, 'APP_ID') as Promise; }, }; this.configModifier = { - scheduler: this.proxify('getConfigurationModify:scheduler'), + scheduler: new SchedulerModify(this.bridges), slashCommands: slashCommandsModify, - serverSettings: this.proxify('getConfigurationModify:serverSettings'), + serverSettings: new ServerSettingsModify(this.bridges), }; } @@ -166,10 +167,9 @@ export class AppAccessors { public getConfigurationExtend() { if (!this.configExtender) { - const { senderFn } = this; + const resourceBridge = this.bridges.getAppResourceBridge(); - const apiExtend: WithProxy = { - _proxy: this.proxify('getConfigurationExtend:api'), + const apiExtend: IApiExtend = { async provideApi(api: IApi) { const apiEndpoints = AppObjectRegistry.get('apiEndpoints')!; @@ -180,67 +180,83 @@ export class AppAccessors { AppObjectRegistry.set(`api:${endpoint.path}`, endpoint); }); - const result = await this._proxy.provideApi(api); + await resourceBridge.doProvideApi(api, 'APP_ID'); // Let's call the listApis method to cache the info from the endpoints // Also, since this is a side-effect, we do it async so we can return to the caller - senderFn({ method: 'accessor:api:listApis' }) - .then((response) => apiEndpoints.push(...(response.result as IApiEndpointMetadata[]))) - .catch((err) => err.error); - - return result; + resourceBridge + .doListApis('APP_ID') + .then((endpoints) => apiEndpoints.push(...(endpoints as IApiEndpointMetadata[]))) + .catch(() => undefined); }, }; - const schedulerExtend: WithProxy = { - _proxy: this.proxify('getConfigurationExtend:scheduler'), + const schedulerExtend: ISchedulerExtend = { registerProcessors(processors: IProcessor[]) { // Store the processor instance to use when the Apps-Engine calls the processor processors.forEach((processor) => { AppObjectRegistry.set(`scheduler:${processor.id}`, processor); }); - return this._proxy.registerProcessors(processors); + return resourceBridge.doRegisterProcessors(processors, 'APP_ID') as Promise>; }, }; - const videoConfProviders: WithProxy = { - _proxy: this.proxify('getConfigurationExtend:videoConfProviders'), + const videoConfProviders: IVideoConfProvidersExtend = { provideVideoConfProvider(provider: IVideoConfProvider) { // Store the videoConfProvider instance to use when the Apps-Engine calls the videoConfProvider AppObjectRegistry.set(`videoConfProvider:${provider.name}`, provider); - return this._proxy.provideVideoConfProvider(provider); + return resourceBridge.doProvideVideoConfProvider(provider, 'APP_ID') as Promise; }, }; - const outboundCommunication: WithProxy = { - _proxy: this.proxify('getConfigurationExtend:outboundCommunication'), + const outboundCommunication: IOutboundCommunicationProviderExtend = { registerEmailProvider(provider: IOutboundEmailMessageProvider) { AppObjectRegistry.set(`outboundCommunication:${provider.name}-${provider.type}`, provider); - return this._proxy.registerEmailProvider(provider); + return resourceBridge.doRegisterOutboundProvider(provider, 'APP_ID') as Promise; }, registerPhoneProvider(provider: IOutboundPhoneMessageProvider) { AppObjectRegistry.set(`outboundCommunication:${provider.name}-${provider.type}`, provider); - return this._proxy.registerPhoneProvider(provider); + return resourceBridge.doRegisterOutboundProvider(provider, 'APP_ID') as Promise; }, }; - const slashCommandsExtend: WithProxy = { - _proxy: this.proxify('getConfigurationExtend:slashCommands'), + const slashCommandsExtend: ISlashCommandsExtend = { provideSlashCommand(slashcommand: ISlashCommand) { // Store the slashcommand instance to use when the Apps-Engine calls the slashcommand AppObjectRegistry.set(`slashcommand:${slashcommand.command}`, slashcommand); - return this._proxy.provideSlashCommand(slashcommand); + return resourceBridge.doProvideSlashCommand(slashcommand, 'APP_ID') as Promise; + }, + }; + + const ui: IUIExtend = { + // `registerButton` is a synchronous `void` in the interface, but the host registration + // is async over the bridge; fire-and-forget matches the contract. The host manager + // logs-and-refuses rather than throwing, so there is no rejection to surface here. + registerButton(button) { + void resourceBridge.doRegisterActionButton(button, 'APP_ID').catch(() => undefined); + }, + }; + + const settings: ISettingsExtend = { + provideSetting(setting) { + return resourceBridge.doProvideSetting(setting, 'APP_ID') as Promise; + }, + }; + + const externalComponents: IExternalComponentsExtend = { + register(externalComponent) { + return resourceBridge.doRegisterExternalComponent(externalComponent, 'APP_ID') as Promise; }, }; this.configExtender = { - ui: this.proxify('getConfigurationExtend:ui'), + ui, http: this.httpExtend, - settings: this.proxify('getConfigurationExtend:settings'), - externalComponents: this.proxify('getConfigurationExtend:externalComponents'), + settings, + externalComponents, api: apiExtend, scheduler: schedulerExtend, videoConfProviders, @@ -268,27 +284,29 @@ export class AppAccessors { public getReader() { if (!this.reader) { - this.reader = { - getEnvironmentReader: () => ({ - getSettings: () => this.proxify('getReader:getEnvironmentReader:getSettings'), - getServerSettings: () => this.proxify('getReader:getEnvironmentReader:getServerSettings'), - getEnvironmentVariables: () => this.proxify('getReader:getEnvironmentReader:getEnvironmentVariables'), - }), - getMessageReader: () => this.proxify('getReader:getMessageReader'), - getPersistenceReader: () => this.proxify('getReader:getPersistenceReader'), - getRoomReader: () => this.proxify('getReader:getRoomReader'), - getUserReader: () => this.proxify('getReader:getUserReader'), - getNotifier: () => this.getNotifier(), - getLivechatReader: () => this.proxify('getReader:getLivechatReader'), - getUploadReader: () => this.proxify('getReader:getUploadReader'), - getCloudWorkspaceReader: () => this.proxify('getReader:getCloudWorkspaceReader'), - getVideoConferenceReader: () => this.proxify('getReader:getVideoConferenceReader'), - getOAuthAppsReader: () => this.proxify('getReader:getOAuthAppsReader'), - getThreadReader: () => this.proxify('getReader:getThreadReader'), - getRoleReader: () => this.proxify('getReader:getRoleReader'), - getContactReader: () => this.proxify('getReader:getContactReader'), - getExperimentalReader: () => this.proxify('getReader:getExperimentalReader'), - }; + const environmentReader = new EnvironmentRead( + new SettingRead(this.bridges), + new ServerSettingRead(this.bridges), + new EnvironmentalVariableRead(this.bridges), + ); + + this.reader = new Reader( + environmentReader, + new MessageRead(this.bridges), + new PersistenceRead(this.bridges), + new RoomRead(this.bridges), + new UserRead(this.bridges), + this.getNotifier(), + new LivechatRead(this.bridges), + new UploadRead(this.bridges), + new CloudWorkspaceRead(this.bridges), + new VideoConferenceRead(this.bridges), + new ContactRead(this.bridges), + new OAuthAppsReader(this.bridges), + new ThreadRead(this.bridges), + new RoleRead(this.bridges), + new ExperimentalRead(this.bridges), + ); } return this.reader; @@ -300,12 +318,12 @@ export class AppAccessors { getCreator: this.getCreator.bind(this), getUpdater: this.getUpdater.bind(this), getExtender: this.getExtender.bind(this), - getDeleter: () => this.proxify('getModifier:getDeleter'), + getDeleter: () => new ModifyDeleter(this.bridges), getNotifier: () => this.getNotifier(), - getUiController: () => this.proxify('getModifier:getUiController'), - getScheduler: () => this.proxify('getModifier:getScheduler'), - getOAuthAppsModifier: () => this.proxify('getModifier:getOAuthAppsModifier'), - getModerationModifier: () => this.proxify('getModifier:getModerationModifier'), + getUiController: () => new UIController(this.bridges), + getScheduler: () => new SchedulerModify(this.bridges), + getOAuthAppsModifier: () => new OAuthAppsModify(this.bridges), + getModerationModifier: () => new ModerationModify(this.bridges), }; } @@ -314,7 +332,7 @@ export class AppAccessors { public getPersistence() { if (!this.persistence) { - this.persistence = this.proxify('getPersistence'); + this.persistence = new Persistence(this.bridges); } return this.persistence; diff --git a/packages/apps/base-runtime/src/lib/accessors/modify/ContactCreator.ts b/packages/apps/base-runtime/src/lib/accessors/modify/ContactCreator.ts new file mode 100644 index 0000000000000..bed84bbb2e6df --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/modify/ContactCreator.ts @@ -0,0 +1,22 @@ +import type { IContactCreator } from '@rocket.chat/apps-engine/definition/accessors/IContactCreator'; +import type { ILivechatContact } from '@rocket.chat/apps-engine/definition/livechat'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class ContactCreator implements IContactCreator { + constructor(private readonly bridges: RemoteBridges) {} + + public verifyContact(verifyContactChannelParams: { + contactId: string; + field: string; + value: string; + visitorId: string; + roomId: string; + }): Promise { + return this.bridges.getContactBridge().doVerifyContact(verifyContactChannelParams, 'APP_ID') as Promise; + } + + public addContactEmail(contactId: ILivechatContact['_id'], email: string): Promise { + return this.bridges.getContactBridge().doAddContactEmail(contactId, email, 'APP_ID') as Promise; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/modify/EmailCreator.ts b/packages/apps/base-runtime/src/lib/accessors/modify/EmailCreator.ts new file mode 100644 index 0000000000000..efea83eee4631 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/modify/EmailCreator.ts @@ -0,0 +1,12 @@ +import type { IEmailCreator } from '@rocket.chat/apps-engine/definition/accessors/IEmailCreator'; +import type { IEmail } from '@rocket.chat/apps-engine/definition/email'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class EmailCreator implements IEmailCreator { + constructor(private readonly bridges: RemoteBridges) {} + + public async send(email: IEmail): Promise { + await this.bridges.getEmailBridge().doSendEmail(email, 'APP_ID'); + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/modify/LivechatCreator.ts b/packages/apps/base-runtime/src/lib/accessors/modify/LivechatCreator.ts new file mode 100644 index 0000000000000..6836045bbfa1b --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/modify/LivechatCreator.ts @@ -0,0 +1,40 @@ +import { randomBytes } from 'node:crypto'; + +import type { ILivechatCreator } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IExtraRoomParams } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator'; +import type { ILivechatRoom } from '@rocket.chat/apps-engine/definition/livechat/ILivechatRoom'; +import type { + IVisitorExternalIdentifier, + IVisitor, + ResolveVisitorContactData, +} from '@rocket.chat/apps-engine/definition/livechat/IVisitor'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class LivechatCreator implements ILivechatCreator { + constructor(private readonly bridges: RemoteBridges) {} + + public resolveVisitor(externalId: IVisitorExternalIdentifier, contactData?: ResolveVisitorContactData): Promise { + return this.bridges.getLivechatBridge().doResolveVisitor(externalId, contactData, 'APP_ID') as Promise; + } + + public createRoom(visitor: IVisitor, agent: IUser, extraParams?: IExtraRoomParams): Promise { + return this.bridges.getLivechatBridge().doCreateRoom(visitor, agent, 'APP_ID', extraParams) as Promise; + } + + /** + * @deprecated Use `createAndReturnVisitor` instead. + */ + public createVisitor(visitor: IVisitor): Promise { + return this.bridges.getLivechatBridge().doCreateVisitor(visitor, 'APP_ID') as Promise; + } + + public createAndReturnVisitor(visitor: IVisitor): Promise { + return this.bridges.getLivechatBridge().doCreateAndReturnVisitor(visitor, 'APP_ID') as Promise; + } + + public createToken(): string { + return randomBytes(16).toString('hex'); // Ensures 128 bits of entropy + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/modify/LivechatUpdater.ts b/packages/apps/base-runtime/src/lib/accessors/modify/LivechatUpdater.ts new file mode 100644 index 0000000000000..31442cb5f42e5 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/modify/LivechatUpdater.ts @@ -0,0 +1,33 @@ +import type { ILivechatUpdater } from '@rocket.chat/apps-engine/definition/accessors'; +import type { + ILivechatRoom, + ILivechatTransferData, + IVisitor, + IVisitorExternalIdentifier, +} from '@rocket.chat/apps-engine/definition/livechat'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class LivechatUpdater implements ILivechatUpdater { + constructor(private readonly bridges: RemoteBridges) {} + + public transferVisitor(visitor: IVisitor, transferData: ILivechatTransferData): Promise { + return this.bridges.getLivechatBridge().doTransferVisitor(visitor, transferData, 'APP_ID') as Promise; + } + + public closeRoom(room: ILivechatRoom, comment: string, closer?: IUser): Promise { + return this.bridges.getLivechatBridge().doCloseRoom(room, comment, closer, 'APP_ID') as Promise; + } + + public setCustomFields(token: IVisitor['token'], key: string, value: string, overwrite: boolean): Promise { + return this.bridges + .getLivechatBridge() + .doSetCustomFields({ token, key, value, overwrite }, 'APP_ID') + .then((result) => (result as number) > 0); + } + + public updateVisitorExternalId(visitorId: string, externalId: Omit): Promise { + return this.bridges.getLivechatBridge().doUpdateVisitorExternalId(visitorId, externalId, 'APP_ID') as Promise; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/modify/MessageUpdater.ts b/packages/apps/base-runtime/src/lib/accessors/modify/MessageUpdater.ts new file mode 100644 index 0000000000000..0735ff79c13f2 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/modify/MessageUpdater.ts @@ -0,0 +1,16 @@ +import type { IMessageUpdater } from '@rocket.chat/apps-engine/definition/accessors/IMessageUpdater'; +import type { Reaction } from '@rocket.chat/apps-engine/definition/messages'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class MessageUpdater implements IMessageUpdater { + constructor(private readonly bridges: RemoteBridges) {} + + public async addReaction(messageId: string, userId: string, reaction: Reaction): Promise { + await this.bridges.getMessageBridge().doAddReaction(messageId, userId, reaction, 'APP_ID'); + } + + public async removeReaction(messageId: string, userId: string, reaction: Reaction): Promise { + await this.bridges.getMessageBridge().doRemoveReaction(messageId, userId, reaction, 'APP_ID'); + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/modify/ModerationModify.ts b/packages/apps/base-runtime/src/lib/accessors/modify/ModerationModify.ts new file mode 100644 index 0000000000000..d3f48c9b94232 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/modify/ModerationModify.ts @@ -0,0 +1,24 @@ +import type { IModerationModify } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +// Every method here takes `appId` as an app-supplied argument (not caller identity); the host +// accessor likewise ignores its constructor appId and forwards the method-arg one. So the appId is +// forwarded raw (see docs/base-runtime-app-id-exceptions.md, bucket B). +export class ModerationModify implements IModerationModify { + constructor(private readonly bridges: RemoteBridges) {} + + public report(messageId: string, description: string, userId: string, appId: string): Promise { + return this.bridges.getModerationBridge().doReport(messageId, description, userId, appId) as Promise; + } + + public dismissReportsByMessageId(messageId: IMessage['id'], reason: string, action: string, appId: string): Promise { + return this.bridges.getModerationBridge().doDismissReportsByMessageId(messageId, reason, action, appId) as Promise; + } + + public dismissReportsByUserId(userId: IUser['id'], reason: string, action: string, appId: string): Promise { + return this.bridges.getModerationBridge().doDismissReportsByUserId(userId, reason, action, appId) as Promise; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/modify/ModifyCreator.ts b/packages/apps/base-runtime/src/lib/accessors/modify/ModifyCreator.ts index 853db97e38351..097229befbde7 100644 --- a/packages/apps/base-runtime/src/lib/accessors/modify/ModifyCreator.ts +++ b/packages/apps/base-runtime/src/lib/accessors/modify/ModifyCreator.ts @@ -1,5 +1,3 @@ -import { randomBytes } from 'node:crypto'; - import type { IContactCreator } from '@rocket.chat/apps-engine/definition/accessors/IContactCreator'; import type { IDiscussionBuilder } from '@rocket.chat/apps-engine/definition/accessors/IDiscussionBuilder'; import type { IEmailCreator } from '@rocket.chat/apps-engine/definition/accessors/IEmailCreator'; @@ -19,6 +17,10 @@ import type { IBotUser } from '@rocket.chat/apps-engine/definition/users/IBotUse import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; import { UserType } from '@rocket.chat/apps-engine/definition/users/UserType'; +import { ContactCreator } from './ContactCreator'; +import { EmailCreator } from './EmailCreator'; +import { LivechatCreator } from './LivechatCreator'; +import { UploadCreator } from './UploadCreator'; import { AppObjectRegistry } from '../../../AppObjectRegistry'; import { UIHelper } from '../../UIHelper'; import { RemoteBridges } from '../../bridges/RemoteBridges'; @@ -32,7 +34,6 @@ import { RoomBuilder } from '../builders/RoomBuilder'; import { UserBuilder } from '../builders/UserBuilder'; import type { AppVideoConference } from '../builders/VideoConferenceBuilder'; import { VideoConferenceBuilder } from '../builders/VideoConferenceBuilder'; -import { formatErrorResponse } from '../formatResponseErrorHandler'; export class ModifyCreator implements IModifyCreator { private readonly bridges: RemoteBridges; @@ -44,94 +45,19 @@ export class ModifyCreator implements IModifyCreator { } getLivechatCreator(): ILivechatCreator { - return new Proxy( - { __kind: 'getLivechatCreator' }, - { - get: (_target: unknown, prop: string) => { - // It's not worthwhile to make an asynchronous request for such a simple method - if (prop === 'createToken') { - return () => randomBytes(16).toString('hex'); - } - - if (prop === 'toJSON') { - return () => ({}); - } - - return (...params: unknown[]) => - this.senderFn({ - method: `accessor:getModifier:getCreator:getLivechatCreator:${prop}`, - params, - }) - .then((response) => response.result) - .catch((err) => { - throw formatErrorResponse(err); - }); - }, - }, - ) as ILivechatCreator; + return new LivechatCreator(this.bridges); } getUploadCreator(): IUploadCreator { - return new Proxy( - { __kind: 'getUploadCreator' }, - { - get: - (_target: unknown, prop: string) => - (...params: unknown[]) => - prop === 'toJSON' - ? {} - : this.senderFn({ - method: `accessor:getModifier:getCreator:getUploadCreator:${prop}`, - params, - }) - .then((response) => response.result) - .catch((err) => { - throw formatErrorResponse(err); - }), - }, - ) as IUploadCreator; + return new UploadCreator(this.bridges); } getEmailCreator(): IEmailCreator { - return new Proxy( - { __kind: 'getEmailCreator' }, - { - get: - (_target: unknown, prop: string) => - (...params: unknown[]) => - prop === 'toJSON' - ? {} - : this.senderFn({ - method: `accessor:getModifier:getCreator:getEmailCreator:${prop}`, - params, - }) - .then((response) => response.result) - .catch((err) => { - throw formatErrorResponse(err); - }), - }, - ) as IEmailCreator; + return new EmailCreator(this.bridges); } getContactCreator(): IContactCreator { - return new Proxy( - { __kind: 'getContactCreator' }, - { - get: - (_target: unknown, prop: string) => - (...params: unknown[]) => - prop === 'toJSON' - ? {} - : this.senderFn({ - method: `accessor:getModifier:getCreator:getContactCreator:${prop}`, - params, - }) - .then((response) => response.result) - .catch((err) => { - throw formatErrorResponse(err); - }), - }, - ) as IContactCreator; + return new ContactCreator(this.bridges); } getBlockBuilder() { diff --git a/packages/apps/base-runtime/src/lib/accessors/modify/ModifyDeleter.ts b/packages/apps/base-runtime/src/lib/accessors/modify/ModifyDeleter.ts new file mode 100644 index 0000000000000..4fa4a1b808d2e --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/modify/ModifyDeleter.ts @@ -0,0 +1,39 @@ +import type { IModifyDeleter } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IUser, UserType } from '@rocket.chat/apps-engine/definition/users'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class ModifyDeleter implements IModifyDeleter { + constructor(private readonly bridges: RemoteBridges) {} + + public async deleteRoom(roomId: string): Promise { + await this.bridges.getRoomBridge().doDelete(roomId, 'APP_ID'); + } + + // `appId` here is an app-supplied argument identifying which app's users to delete, not caller + // identity, so it is forwarded raw (see + // docs/proposals/apps-accessor-consolidation/base-runtime-app-id-exceptions.md, bucket B). + public async deleteUsers(appId: Exclude, userType: UserType.APP | UserType.BOT): Promise { + return this.bridges.getUserBridge().doDeleteUsersCreatedByApp(appId, userType) as Promise; + } + + public async deleteMessage(message: IMessage, user: IUser): Promise { + await this.bridges.getMessageBridge().doDelete(message, user, 'APP_ID'); + } + + /** + * Removes `usernames` from the room's member list + * + * For performance reasons, it is only possible to remove 50 users in one + * call to this method. Removing users is an expensive operation due to the + * amount of entity relationships that need to be modified. + */ + public async removeUsersFromRoom(roomId: string, usernames: Array) { + if (usernames.length > 50) { + throw new Error('A maximum of 50 members can be removed in a single call'); + } + + return this.bridges.getRoomBridge().doRemoveUsers(roomId, usernames, 'APP_ID') as Promise; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/modify/ModifyUpdater.ts b/packages/apps/base-runtime/src/lib/accessors/modify/ModifyUpdater.ts index 1c5bf00f4d3b4..6bb30202713d4 100644 --- a/packages/apps/base-runtime/src/lib/accessors/modify/ModifyUpdater.ts +++ b/packages/apps/base-runtime/src/lib/accessors/modify/ModifyUpdater.ts @@ -10,13 +10,15 @@ import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; import { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType.js'; import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; +import { LivechatUpdater } from './LivechatUpdater'; +import { MessageUpdater } from './MessageUpdater'; +import { UserUpdater } from './UserUpdater'; import { AppObjectRegistry } from '../../../AppObjectRegistry'; import { UIHelper } from '../../UIHelper'; import { RemoteBridges } from '../../bridges/RemoteBridges'; import type * as Messenger from '../../messenger'; import { MessageBuilder } from '../builders/MessageBuilder'; import { RoomBuilder } from '../builders/RoomBuilder'; -import { formatErrorResponse } from '../formatResponseErrorHandler'; export class ModifyUpdater implements IModifyUpdater { private readonly livechatUpdater: ILivechatUpdater; @@ -31,32 +33,9 @@ export class ModifyUpdater implements IModifyUpdater { // The facade reads `this.senderFn` at call time (rather than capturing it) so // that tests which swap out `senderFn` after construction remain intercepted. this.bridges = new RemoteBridges((request) => this.senderFn(request)); - this.livechatUpdater = this.proxify('getLivechatUpdater'); - this.userUpdater = this.proxify('getUserUpdater'); - this.messageUpdater = this.proxify('getMessageUpdater'); - } - - private proxify( - target: 'getLivechatUpdater' | 'getUserUpdater' | 'getMessageUpdater', - ): T { - return new Proxy( - { __kind: target }, - { - get: - (_target: unknown, prop: string) => - (...params: unknown[]) => - prop === 'toJSON' - ? {} - : this.senderFn({ - method: `accessor:getModifier:getUpdater:${target}:${prop}`, - params, - }) - .then((response) => response.result) - .catch((err) => { - throw formatErrorResponse(err); - }), - }, - ) as T; + this.livechatUpdater = new LivechatUpdater(this.bridges); + this.userUpdater = new UserUpdater(this.bridges); + this.messageUpdater = new MessageUpdater(this.bridges); } public getLivechatUpdater(): ILivechatUpdater { diff --git a/packages/apps/base-runtime/src/lib/accessors/modify/OAuthAppsModify.ts b/packages/apps/base-runtime/src/lib/accessors/modify/OAuthAppsModify.ts new file mode 100644 index 0000000000000..64c66ed9c434d --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/modify/OAuthAppsModify.ts @@ -0,0 +1,20 @@ +import type { IOAuthAppParams } from '@rocket.chat/apps-engine/definition/accessors/IOAuthApp'; +import type { IOAuthAppsModify } from '@rocket.chat/apps-engine/definition/accessors/IOAuthAppsModify'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class OAuthAppsModify implements IOAuthAppsModify { + constructor(private readonly bridges: RemoteBridges) {} + + public async createOAuthApp(oAuthApp: IOAuthAppParams): Promise { + return this.bridges.getOAuthAppsBridge().doCreate(oAuthApp, 'APP_ID') as Promise; + } + + public async updateOAuthApp(oAuthApp: IOAuthAppParams, id: string): Promise { + await this.bridges.getOAuthAppsBridge().doUpdate(oAuthApp, id, 'APP_ID'); + } + + public async deleteOAuthApp(id: string): Promise { + await this.bridges.getOAuthAppsBridge().doDelete(id, 'APP_ID'); + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/modify/SchedulerModify.ts b/packages/apps/base-runtime/src/lib/accessors/modify/SchedulerModify.ts new file mode 100644 index 0000000000000..1682e6adc2ed0 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/modify/SchedulerModify.ts @@ -0,0 +1,40 @@ +import type { ISchedulerModify } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IOnetimeSchedule, IRecurringSchedule } from '@rocket.chat/apps-engine/definition/scheduler'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry'; +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +// Namespaces a job id with the app id, matching the host SchedulerModify. The app id here is used to +// build the id string (a non-identity, local use), so it reads the real id from the registry rather +// than the 'APP_ID' sentinel. +function createProcessorId(jobId: string, appId: string): string { + return jobId.includes(`_${appId}`) ? jobId : `${jobId}_${appId}`; +} + +export class SchedulerModify implements ISchedulerModify { + constructor(private readonly bridges: RemoteBridges) {} + + private get appId(): string { + return AppObjectRegistry.get('id') || ''; + } + + public async scheduleOnce(job: IOnetimeSchedule): Promise { + return this.bridges.getSchedulerBridge().doScheduleOnce({ ...job, id: createProcessorId(job.id, this.appId) }, 'APP_ID') as Promise< + void | string + >; + } + + public async scheduleRecurring(job: IRecurringSchedule): Promise { + return this.bridges + .getSchedulerBridge() + .doScheduleRecurring({ ...job, id: createProcessorId(job.id, this.appId) }, 'APP_ID') as Promise; + } + + public async cancelJob(jobId: string): Promise { + await this.bridges.getSchedulerBridge().doCancelJob(createProcessorId(jobId, this.appId), 'APP_ID'); + } + + public async cancelAllJobs(): Promise { + await this.bridges.getSchedulerBridge().doCancelAllJobs('APP_ID'); + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/modify/UIController.ts b/packages/apps/base-runtime/src/lib/accessors/modify/UIController.ts new file mode 100644 index 0000000000000..893365f87e727 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/modify/UIController.ts @@ -0,0 +1,133 @@ +import type { IUIController } from '@rocket.chat/apps-engine/definition/accessors'; +import type { + IUIKitErrorInteractionParam, + IUIKitInteractionParam, + IUIKitSurfaceViewParam, +} from '@rocket.chat/apps-engine/definition/accessors/IUIController'; +import { UIKitInteractionType, UIKitSurfaceType } from '@rocket.chat/apps-engine/definition/uikit'; +import { + formatContextualBarInteraction, + formatErrorInteraction, + formatModalInteraction, +} from '@rocket.chat/apps-engine/definition/uikit/UIKitInteractionPayloadFormatter'; +import type { + IUIKitContextualBarViewParam, + IUIKitModalViewParam, +} from '@rocket.chat/apps-engine/definition/uikit/UIKitInteractionResponder'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry'; +import { UIHelper } from '../../UIHelper'; +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class UIController implements IUIController { + constructor(private readonly bridges: RemoteBridges) {} + + // The real app id, used to stamp block/interaction payloads (a non-identity, local use). The + // trailing appId passed to `doNotifyUser` uses the 'APP_ID' sentinel instead so the host + // substitutes the verified id for its permission check. + private get appId(): string { + return AppObjectRegistry.get('id') || ''; + } + + /** + * @deprecated please prefer the `openSurfaceView` method + */ + public openModalView(view: IUIKitModalViewParam, context: IUIKitInteractionParam, user: IUser) { + return this.openModal(view, context, user); + } + + /** + * @deprecated please prefer the `updateSurfaceView` method + */ + public updateModalView(view: IUIKitModalViewParam, context: IUIKitInteractionParam, user: IUser) { + return this.openModal(view, context, user, true); + } + + /** + * @deprecated please prefer the `openSurfaceView` method + */ + public openContextualBarView(view: IUIKitContextualBarViewParam, context: IUIKitInteractionParam, user: IUser) { + return this.openContextualBar(view, context, user); + } + + /** + * @deprecated please prefer the `updateSurfaceView` method + */ + public updateContextualBarView(view: IUIKitContextualBarViewParam, context: IUIKitInteractionParam, user: IUser) { + return this.openContextualBar(view, context, user, true); + } + + public openSurfaceView(view: IUIKitSurfaceViewParam, context: IUIKitInteractionParam, user: IUser) { + const blocks = UIHelper.assignIds(view.blocks, this.appId); + const viewWithIds = { ...view, blocks }; + + switch (view.type) { + case UIKitSurfaceType.CONTEXTUAL_BAR: + return this.openContextualBar(viewWithIds, context, user); + case UIKitSurfaceType.MODAL: + return this.openModal(viewWithIds, context, user); + } + } + + public updateSurfaceView(view: IUIKitSurfaceViewParam, context: IUIKitInteractionParam, user: IUser) { + const blocks = UIHelper.assignIds(view.blocks, this.appId); + const viewWithIds = { ...view, blocks }; + + switch (view.type) { + case UIKitSurfaceType.CONTEXTUAL_BAR: + return this.openContextualBar(viewWithIds, context, user, true); + case UIKitSurfaceType.MODAL: + return this.openModal(viewWithIds, context, user, true); + } + } + + public setViewError(errorInteraction: IUIKitErrorInteractionParam, context: IUIKitInteractionParam, user: IUser) { + const interactionContext = { + ...context, + type: UIKitInteractionType.ERRORS, + appId: this.appId, + }; + + return this.bridges + .getUiInteractionBridge() + .doNotifyUser(user, formatErrorInteraction(errorInteraction, interactionContext), 'APP_ID') as Promise; + } + + private openContextualBar( + view: IUIKitContextualBarViewParam, + context: IUIKitInteractionParam, + user: IUser, + isUpdate = false, + ): Promise { + let type = UIKitInteractionType.CONTEXTUAL_BAR_OPEN; + if (isUpdate) { + type = UIKitInteractionType.CONTEXTUAL_BAR_UPDATE; + } + const interactionContext = { + ...context, + type, + appId: this.appId, + }; + + return this.bridges + .getUiInteractionBridge() + .doNotifyUser(user, formatContextualBarInteraction(view, interactionContext), 'APP_ID') as Promise; + } + + private openModal(view: IUIKitModalViewParam, context: IUIKitInteractionParam, user: IUser, isUpdate = false): Promise { + let type = UIKitInteractionType.MODAL_OPEN; + if (isUpdate) { + type = UIKitInteractionType.MODAL_UPDATE; + } + const interactionContext = { + ...context, + type, + appId: this.appId, + }; + + return this.bridges + .getUiInteractionBridge() + .doNotifyUser(user, formatModalInteraction(view, interactionContext), 'APP_ID') as Promise; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/modify/UploadCreator.ts b/packages/apps/base-runtime/src/lib/accessors/modify/UploadCreator.ts new file mode 100644 index 0000000000000..3c56645899eea --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/modify/UploadCreator.ts @@ -0,0 +1,27 @@ +import type { IUploadCreator } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IUpload } from '@rocket.chat/apps-engine/definition/uploads'; +import type { IUploadDescriptor } from '@rocket.chat/apps-engine/definition/uploads/IUploadDescriptor'; +import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class UploadCreator implements IUploadCreator { + constructor(private readonly bridges: RemoteBridges) {} + + public async uploadBuffer(buffer: Buffer, descriptor: IUploadDescriptor): Promise { + if (!Object.hasOwn(descriptor, 'user') && !descriptor.visitorToken) { + descriptor.user = (await this.bridges.getUserBridge().doGetAppUser('APP_ID')) as IUser; + } + + const details = { + name: descriptor.filename, + size: buffer.length, + rid: descriptor.room.id, + userId: descriptor.user?.id, + visitorToken: descriptor.visitorToken, + } as IUploadDetails; + + return this.bridges.getUploadBridge().doCreateUpload(details, buffer, 'APP_ID') as Promise; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/modify/UserUpdater.ts b/packages/apps/base-runtime/src/lib/accessors/modify/UserUpdater.ts new file mode 100644 index 0000000000000..a06dc34946029 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/modify/UserUpdater.ts @@ -0,0 +1,40 @@ +import type { IUserUpdater } from '@rocket.chat/apps-engine/definition/accessors/IUserUpdater'; +import type { UserStatusConnection } from '@rocket.chat/apps-engine/definition/users'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class UserUpdater implements IUserUpdater { + constructor(private readonly bridges: RemoteBridges) {} + + public async updateStatusText(user: IUser, statusText: IUser['statusText']) { + return this.bridges.getUserBridge().doUpdate(user, { statusText }, 'APP_ID') as Promise; + } + + public async updateStatus(user: IUser, statusText: IUser['statusText'], status: UserStatusConnection) { + return this.bridges.getUserBridge().doUpdate(user, { statusText, status }, 'APP_ID') as Promise; + } + + public async updateBio(user: IUser, bio: IUser['bio']) { + return this.bridges.getUserBridge().doUpdate(user, { bio }, 'APP_ID') as Promise; + } + + public async updateCustomFields(user: IUser, customFields: IUser['customFields']) { + return this.bridges.getUserBridge().doUpdate(user, { customFields }, 'APP_ID') as Promise; + } + + public async deactivate(userId: IUser['id'], confirmRelinquish: boolean) { + return this.bridges.getUserBridge().doDeactivate(userId, confirmRelinquish, 'APP_ID') as Promise; + } + + public async setActiveState( + userId: IUser['id'], + state: Pick, + ): Promise { + await this.bridges.getUserBridge().doSetActiveState(userId, state, 'APP_ID'); + } + + public async endActiveState(userId: IUser['id'], statusId?: string): Promise { + await this.bridges.getUserBridge().doEndActiveState(userId, 'APP_ID', statusId); + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/modify/tests/modifyAccessors.test.ts b/packages/apps/base-runtime/src/lib/accessors/modify/tests/modifyAccessors.test.ts new file mode 100644 index 0000000000000..b4c5be42032cc --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/modify/tests/modifyAccessors.test.ts @@ -0,0 +1,171 @@ +import * as assert from 'node:assert'; +import { after, beforeEach, describe, it } from 'node:test'; + +import { AppObjectRegistry } from '../../../../AppObjectRegistry'; +import { RemoteBridges } from '../../../bridges/RemoteBridges'; +import { createRecordingSender } from '../../tests/helpers/parityHarness'; +import { LivechatUpdater } from '../LivechatUpdater'; +import { ModerationModify } from '../ModerationModify'; +import { ModifyDeleter } from '../ModifyDeleter'; +import { OAuthAppsModify } from '../OAuthAppsModify'; +import { SchedulerModify } from '../SchedulerModify'; +import { UIController } from '../UIController'; +import { UploadCreator } from '../UploadCreator'; +import { UserUpdater } from '../UserUpdater'; + +const setup = (responses = {}) => { + const rec = createRecordingSender(responses); + return { rec, bridges: new RemoteBridges(rec.sender) }; +}; + +describe('Modify accessors (base-runtime)', () => { + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('id', 'deno-test'); + }); + + after(() => { + AppObjectRegistry.clear(); + }); + + describe('ModifyDeleter', () => { + it('deleteRoom/deleteMessage/removeUsersFromRoom use the APP_ID sentinel', async () => { + const { rec, bridges } = setup(); + const deleter = new ModifyDeleter(bridges); + const message = { id: 'm1' } as any; + const user = { id: 'u1' } as any; + await deleter.deleteRoom('r1'); + await deleter.deleteMessage(message, user); + await deleter.removeUsersFromRoom('r1', ['a', 'b']); + assert.deepStrictEqual(rec.emitted(), [ + { method: 'bridges:getRoomBridge:doDelete', params: ['r1', 'APP_ID'] }, + { method: 'bridges:getMessageBridge:doDelete', params: [message, user, 'APP_ID'] }, + { method: 'bridges:getRoomBridge:doRemoveUsers', params: ['r1', ['a', 'b'], 'APP_ID'] }, + ]); + }); + + it('removeUsersFromRoom rejects more than 50 usernames', async () => { + const { bridges } = setup(); + await assert.rejects( + () => + new ModifyDeleter(bridges).removeUsersFromRoom( + 'r1', + Array.from({ length: 51 }, (_v, i) => `u${i}`), + ), + /maximum of 50 members/, + ); + }); + + it('deleteUsers forwards the app-supplied appId argument raw (not the sentinel)', async () => { + const { rec, bridges } = setup(); + await new ModifyDeleter(bridges).deleteUsers('target-app', 'bot' as any); + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getUserBridge:doDeleteUsersCreatedByApp', + params: ['target-app', 'bot'], + }); + }); + }); + + describe('SchedulerModify', () => { + it('namespaces the job id with the real app id and sends the APP_ID sentinel as identity', async () => { + const { rec, bridges } = setup(); + await new SchedulerModify(bridges).scheduleOnce({ id: 'job', when: 'x' } as any); + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getSchedulerBridge:doScheduleOnce', + params: [{ id: 'job_deno-test', when: 'x' }, 'APP_ID'], + }); + }); + + it('does not double-namespace an id that already carries the app id', async () => { + const { rec, bridges } = setup(); + await new SchedulerModify(bridges).cancelJob('job_deno-test'); + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getSchedulerBridge:doCancelJob', + params: ['job_deno-test', 'APP_ID'], + }); + }); + }); + + describe('ModerationModify', () => { + it('forwards the app-supplied appId argument raw', async () => { + const { rec, bridges } = setup(); + await new ModerationModify(bridges).report('m1', 'desc', 'u1', 'target-app'); + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getModerationBridge:doReport', + params: ['m1', 'desc', 'u1', 'target-app'], + }); + }); + }); + + describe('OAuthAppsModify', () => { + it('createOAuthApp forwards with the APP_ID sentinel', async () => { + const { rec, bridges } = setup(); + await new OAuthAppsModify(bridges).createOAuthApp({ name: 'x' } as any); + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getOAuthAppsBridge:doCreate', + params: [{ name: 'x' }, 'APP_ID'], + }); + }); + }); + + describe('UserUpdater', () => { + it('updateStatus wraps statusText+status into a partial user update', async () => { + const { rec, bridges } = setup(); + await new UserUpdater(bridges).updateStatus({ id: 'u1' } as any, 'brb', 'online' as any); + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getUserBridge:doUpdate', + params: [{ id: 'u1' }, { statusText: 'brb', status: 'online' }, 'APP_ID'], + }); + }); + + it('endActiveState sends the sentinel in the middle position, statusId last', async () => { + const { rec, bridges } = setup(); + await new UserUpdater(bridges).endActiveState('u1', 'status-1'); + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getUserBridge:doEndActiveState', + params: ['u1', 'APP_ID', 'status-1'], + }); + }); + }); + + describe('LivechatUpdater', () => { + it('setCustomFields maps a >0 host result to boolean true', async () => { + const { bridges } = setup({ 'bridges:getLivechatBridge:doSetCustomFields': 1 }); + assert.strictEqual(await new LivechatUpdater(bridges).setCustomFields('tok', 'k', 'v', true), true); + + const zero = setup({ 'bridges:getLivechatBridge:doSetCustomFields': 0 }); + assert.strictEqual(await new LivechatUpdater(zero.bridges).setCustomFields('tok', 'k', 'v', true), false); + }); + }); + + describe('UploadCreator', () => { + it('does not fetch the app user when a visitorToken is provided', async () => { + const { rec, bridges } = setup(); + await new UploadCreator(bridges).uploadBuffer(Buffer.from([1]), { + filename: 'f', + room: { id: 'r1' }, + visitorToken: 'vtok', + } as any); + assert.deepStrictEqual(rec.methods(), ['bridges:getUploadBridge:doCreateUpload']); + }); + }); + + describe('UIController', () => { + it('openSurfaceView routes a modal through doNotifyUser with the APP_ID sentinel and stamps the real app id', async () => { + const { rec, bridges } = setup(); + const user = { id: 'u1' } as any; + await new UIController(bridges).openSurfaceView( + { type: 'modal', title: { type: 'plain_text', text: 't' }, blocks: [{ type: 'section' }] } as any, + { triggerId: 'tr1' } as any, + user, + ); + + const call = rec.emitted()[0]; + assert.deepEqual(call.params[0], user); + assert.strictEqual(call.method, 'bridges:getUiInteractionBridge:doNotifyUser'); + assert.strictEqual(call.params[2], 'APP_ID'); + // The interaction payload carries the resolved app id (nested field, not sentinel-substituted). + assert.strictEqual((call.params[1] as any).appId, 'deno-test'); + }); + }); +}); diff --git a/packages/apps/base-runtime/src/lib/accessors/read/CloudWorkspaceRead.ts b/packages/apps/base-runtime/src/lib/accessors/read/CloudWorkspaceRead.ts new file mode 100644 index 0000000000000..74bbad1628d1b --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/read/CloudWorkspaceRead.ts @@ -0,0 +1,12 @@ +import type { ICloudWorkspaceRead } from '@rocket.chat/apps-engine/definition/accessors/ICloudWorkspaceRead'; +import type { IWorkspaceToken } from '@rocket.chat/apps-engine/definition/cloud/IWorkspaceToken'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class CloudWorkspaceRead implements ICloudWorkspaceRead { + constructor(private readonly bridges: RemoteBridges) {} + + public async getWorkspaceToken(scope: string): Promise { + return this.bridges.getCloudWorkspaceBridge().doGetWorkspaceToken(scope, 'APP_ID') as Promise; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/read/ContactRead.ts b/packages/apps/base-runtime/src/lib/accessors/read/ContactRead.ts new file mode 100644 index 0000000000000..ee0cbe5021dde --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/read/ContactRead.ts @@ -0,0 +1,12 @@ +import type { IContactRead } from '@rocket.chat/apps-engine/definition/accessors/IContactRead'; +import type { ILivechatContact } from '@rocket.chat/apps-engine/definition/livechat'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class ContactRead implements IContactRead { + constructor(private readonly bridges: RemoteBridges) {} + + public getById(contactId: ILivechatContact['_id']): Promise { + return this.bridges.getContactBridge().doGetById(contactId, 'APP_ID') as Promise; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/read/ExperimentalRead.ts b/packages/apps/base-runtime/src/lib/accessors/read/ExperimentalRead.ts new file mode 100644 index 0000000000000..f2565bdff2771 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/read/ExperimentalRead.ts @@ -0,0 +1,10 @@ +import type { IExperimentalRead } from '@rocket.chat/apps-engine/definition/accessors'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +// The host ExperimentalRead is an empty placeholder (no public methods); the runtime +// mirror keeps the same shape. The bridges reference is retained for parity with the +// host constructor and for the methods this accessor is expected to grow. +export class ExperimentalRead implements IExperimentalRead { + constructor(protected readonly bridges: RemoteBridges) {} +} diff --git a/packages/apps/base-runtime/src/lib/accessors/read/LivechatRead.ts b/packages/apps/base-runtime/src/lib/accessors/read/LivechatRead.ts new file mode 100644 index 0000000000000..5a348dcc5cdac --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/read/LivechatRead.ts @@ -0,0 +1,81 @@ +import type { ILivechatRead } from '@rocket.chat/apps-engine/definition/accessors/ILivechatRead'; +import type { IDepartment } from '@rocket.chat/apps-engine/definition/livechat'; +import type { ILivechatRoom } from '@rocket.chat/apps-engine/definition/livechat/ILivechatRoom'; +import type { IVisitor } from '@rocket.chat/apps-engine/definition/livechat/IVisitor'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class LivechatRead implements ILivechatRead { + constructor(private readonly bridges: RemoteBridges) {} + + /** + * @deprecated please use the `isOnlineAsync` method instead. + * In the next major, this method will be `async` + * + * NOTE: the underlying bridge call is asynchronous, so unlike the (formerly + * synchronous, host-resolved) accessor this returns a Promise. This matches + * how every other bridge call behaves from inside the subprocess and preserves + * the deprecation warning. + */ + public isOnline(departmentId?: string): boolean { + console.warn( + "The `LivechatRead.isOnline` method is deprecated and won't behave as intended. Please use `LivechatRead.isOnlineAsync` instead", + ); + + return this.bridges.getLivechatBridge().doIsOnline(departmentId, 'APP_ID') as unknown as boolean; + } + + public isOnlineAsync(departmentId?: string): Promise { + return this.bridges.getLivechatBridge().doIsOnlineAsync(departmentId, 'APP_ID') as Promise; + } + + public getDepartmentsEnabledWithAgents(): Promise> { + return this.bridges.getLivechatBridge().doFindDepartmentsEnabledWithAgents('APP_ID') as Promise>; + } + + public getLivechatRooms(visitor: IVisitor, departmentId?: string): Promise> { + return this.bridges.getLivechatBridge().doFindRooms(visitor, departmentId, 'APP_ID') as Promise>; + } + + public getLivechatTotalOpenRoomsByAgentId(agentId: string): Promise { + return this.bridges.getLivechatBridge().doCountOpenRoomsByAgentId(agentId, 'APP_ID') as Promise; + } + + public getLivechatOpenRoomsByAgentId(agentId: string): Promise> { + return this.bridges.getLivechatBridge().doFindOpenRoomsByAgentId(agentId, 'APP_ID') as Promise>; + } + + /** + * @deprecated This method does not adhere to the conversion practices applied + * elsewhere in the Apps-Engine and will be removed in the next major version. + * Prefer the alternative methods to fetch visitors. + */ + public getLivechatVisitors(query: object): Promise> { + return this.bridges.getLivechatBridge().doFindVisitors(query, 'APP_ID') as Promise>; + } + + public getLivechatVisitorById(id: string): Promise { + return this.bridges.getLivechatBridge().doFindVisitorById(id, 'APP_ID') as Promise; + } + + public getLivechatVisitorByEmail(email: string): Promise { + return this.bridges.getLivechatBridge().doFindVisitorByEmail(email, 'APP_ID') as Promise; + } + + public getLivechatVisitorByToken(token: string): Promise { + return this.bridges.getLivechatBridge().doFindVisitorByToken(token, 'APP_ID') as Promise; + } + + public getLivechatVisitorByPhoneNumber(phoneNumber: string): Promise { + return this.bridges.getLivechatBridge().doFindVisitorByPhoneNumber(phoneNumber, 'APP_ID') as Promise; + } + + public getLivechatDepartmentByIdOrName(value: string): Promise { + return this.bridges.getLivechatBridge().doFindDepartmentByIdOrName(value, 'APP_ID') as Promise; + } + + public _fetchLivechatRoomMessages(roomId: string): Promise> { + return this.bridges.getLivechatBridge().do_fetchLivechatRoomMessages('APP_ID', roomId) as Promise>; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/read/MessageRead.ts b/packages/apps/base-runtime/src/lib/accessors/read/MessageRead.ts new file mode 100644 index 0000000000000..1ddc70f3d8c0b --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/read/MessageRead.ts @@ -0,0 +1,34 @@ +import type { IMessageRead } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class MessageRead implements IMessageRead { + constructor(private readonly bridges: RemoteBridges) {} + + public getById(id: string): Promise { + return this.bridges.getMessageBridge().doGetById(id, 'APP_ID') as Promise; + } + + public async getSenderUser(messageId: string): Promise { + const msg = (await this.bridges.getMessageBridge().doGetById(messageId, 'APP_ID')) as IMessage; + + if (!msg) { + return undefined; + } + + return msg.sender; + } + + public async getRoom(messageId: string): Promise { + const msg = (await this.bridges.getMessageBridge().doGetById(messageId, 'APP_ID')) as IMessage; + + if (!msg) { + return undefined; + } + + return msg.room; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/read/OAuthAppsReader.ts b/packages/apps/base-runtime/src/lib/accessors/read/OAuthAppsReader.ts new file mode 100644 index 0000000000000..8a424eb6814f2 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/read/OAuthAppsReader.ts @@ -0,0 +1,16 @@ +import type { IOAuthApp } from '@rocket.chat/apps-engine/definition/accessors/IOAuthApp'; +import type { IOAuthAppsReader } from '@rocket.chat/apps-engine/definition/accessors/IOAuthAppsReader'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class OAuthAppsReader implements IOAuthAppsReader { + constructor(private readonly bridges: RemoteBridges) {} + + public async getOAuthAppById(id: string): Promise { + return this.bridges.getOAuthAppsBridge().doGetByid(id, 'APP_ID') as Promise; + } + + public async getOAuthAppByName(name: string): Promise> { + return this.bridges.getOAuthAppsBridge().doGetByName(name, 'APP_ID') as Promise>; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/read/PersistenceRead.ts b/packages/apps/base-runtime/src/lib/accessors/read/PersistenceRead.ts new file mode 100644 index 0000000000000..dc4a987fe5f06 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/read/PersistenceRead.ts @@ -0,0 +1,20 @@ +import type { IPersistenceRead } from '@rocket.chat/apps-engine/definition/accessors'; +import type { RocketChatAssociationRecord } from '@rocket.chat/apps-engine/definition/metadata'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class PersistenceRead implements IPersistenceRead { + constructor(private readonly bridges: RemoteBridges) {} + + public read(id: string): Promise { + return this.bridges.getPersistenceBridge().doReadById(id, 'APP_ID') as Promise; + } + + public readByAssociation(association: RocketChatAssociationRecord): Promise> { + return this.bridges.getPersistenceBridge().doReadByAssociations(new Array(association), 'APP_ID') as Promise>; + } + + public readByAssociations(associations: Array): Promise> { + return this.bridges.getPersistenceBridge().doReadByAssociations(associations, 'APP_ID') as Promise>; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/read/Reader.ts b/packages/apps/base-runtime/src/lib/accessors/read/Reader.ts new file mode 100644 index 0000000000000..2f73452b1af85 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/read/Reader.ts @@ -0,0 +1,98 @@ +import type { + ICloudWorkspaceRead, + IEnvironmentRead, + IExperimentalRead, + ILivechatRead, + IMessageRead, + INotifier, + IPersistenceRead, + IRead, + IRoomRead, + IUploadRead, + IUserRead, + IVideoConferenceRead, +} from '@rocket.chat/apps-engine/definition/accessors'; +import type { IContactRead } from '@rocket.chat/apps-engine/definition/accessors/IContactRead'; +import type { IOAuthAppsReader } from '@rocket.chat/apps-engine/definition/accessors/IOAuthAppsReader'; +import type { IRoleRead } from '@rocket.chat/apps-engine/definition/accessors/IRoleRead'; +import type { IThreadRead } from '@rocket.chat/apps-engine/definition/accessors/IThreadRead'; + +export class Reader implements IRead { + constructor( + private env: IEnvironmentRead, + private message: IMessageRead, + private persist: IPersistenceRead, + private room: IRoomRead, + private user: IUserRead, + private noti: INotifier, + private livechat: ILivechatRead, + private upload: IUploadRead, + private cloud: ICloudWorkspaceRead, + private videoConf: IVideoConferenceRead, + private contactRead: IContactRead, + private oauthApps: IOAuthAppsReader, + private thread: IThreadRead, + private role: IRoleRead, + private experimental: IExperimentalRead, + ) {} + + public getEnvironmentReader(): IEnvironmentRead { + return this.env; + } + + public getThreadReader(): IThreadRead { + return this.thread; + } + + public getMessageReader(): IMessageRead { + return this.message; + } + + public getPersistenceReader(): IPersistenceRead { + return this.persist; + } + + public getRoomReader(): IRoomRead { + return this.room; + } + + public getUserReader(): IUserRead { + return this.user; + } + + public getNotifier(): INotifier { + return this.noti; + } + + public getLivechatReader(): ILivechatRead { + return this.livechat; + } + + public getUploadReader(): IUploadRead { + return this.upload; + } + + public getCloudWorkspaceReader(): ICloudWorkspaceRead { + return this.cloud; + } + + public getVideoConferenceReader(): IVideoConferenceRead { + return this.videoConf; + } + + public getOAuthAppsReader(): IOAuthAppsReader { + return this.oauthApps; + } + + public getRoleReader(): IRoleRead { + return this.role; + } + + public getContactReader(): IContactRead { + return this.contactRead; + } + + public getExperimentalReader(): IExperimentalRead { + return this.experimental; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/read/RoleRead.ts b/packages/apps/base-runtime/src/lib/accessors/read/RoleRead.ts new file mode 100644 index 0000000000000..64150dd1257b9 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/read/RoleRead.ts @@ -0,0 +1,16 @@ +import type { IRoleRead } from '@rocket.chat/apps-engine/definition/accessors/IRoleRead'; +import type { IRole } from '@rocket.chat/apps-engine/definition/roles'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class RoleRead implements IRoleRead { + constructor(private readonly bridges: RemoteBridges) {} + + public getOneByIdOrName(idOrName: string): Promise { + return this.bridges.getRoleBridge().doGetOneByIdOrName(idOrName, 'APP_ID') as Promise; + } + + public getCustomRoles(): Promise> { + return this.bridges.getRoleBridge().doGetCustomRoles('APP_ID') as Promise>; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/read/RoomRead.ts b/packages/apps/base-runtime/src/lib/accessors/read/RoomRead.ts new file mode 100644 index 0000000000000..d5fd6f5105861 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/read/RoomRead.ts @@ -0,0 +1,114 @@ +import type { IRoomRead } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IMessageRaw } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom, IRoomRaw } from '@rocket.chat/apps-engine/definition/rooms'; +import { + type GetMessagesOptions, + type GetRoomsFilters, + type GetRoomsOptions, + GetMessagesSortableFields, +} from '@rocket.chat/apps-engine/definition/rooms/IGetMessagesOptions'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class RoomRead implements IRoomRead { + constructor(private readonly bridges: RemoteBridges) {} + + public getById(id: string): Promise { + return this.bridges.getRoomBridge().doGetById(id, 'APP_ID') as Promise; + } + + public getCreatorUserById(id: string): Promise { + return this.bridges.getRoomBridge().doGetCreatorById(id, 'APP_ID') as Promise; + } + + public getByName(name: string): Promise { + return this.bridges.getRoomBridge().doGetByName(name, 'APP_ID') as Promise; + } + + public getCreatorUserByName(name: string): Promise { + return this.bridges.getRoomBridge().doGetCreatorByName(name, 'APP_ID') as Promise; + } + + public getMessages(roomId: string, options: Partial = {}): Promise { + if (typeof options.limit !== 'undefined' && (!Number.isFinite(options.limit) || options.limit > 100)) { + throw new Error(`Invalid limit provided. Expected number <= 100, got ${options.limit}`); + } + + options.limit ??= 100; + options.showThreadMessages ??= true; + + if (options.sort) { + this.validateSort(options.sort); + } + + return this.bridges.getRoomBridge().doGetMessages(roomId, options as GetMessagesOptions, 'APP_ID') as Promise; + } + + public getMembers(roomId: string): Promise> { + return this.bridges.getRoomBridge().doGetMembers(roomId, 'APP_ID') as Promise>; + } + + public getAllRooms(filters: GetRoomsFilters = {}, { limit = 100, skip = 0 }: GetRoomsOptions = {}): Promise | undefined> { + if (!Number.isFinite(limit) || limit <= 0 || limit > 100) { + throw new Error(`Invalid limit provided. Expected number between 1 and 100, got ${limit}`); + } + + if (!Number.isFinite(skip) || skip < 0) { + throw new Error(`Invalid skip provided. Expected number >= 0, got ${skip}`); + } + + return this.bridges.getRoomBridge().doGetAllRooms(filters, { limit, skip }, 'APP_ID') as Promise | undefined>; + } + + public getDirectByUsernames(usernames: Array): Promise { + return this.bridges.getRoomBridge().doGetDirectByUsernames(usernames, 'APP_ID') as Promise; + } + + public getModerators(roomId: string): Promise> { + return this.bridges.getRoomBridge().doGetModerators(roomId, 'APP_ID') as Promise>; + } + + public getOwners(roomId: string): Promise> { + return this.bridges.getRoomBridge().doGetOwners(roomId, 'APP_ID') as Promise>; + } + + public getLeaders(roomId: string): Promise> { + return this.bridges.getRoomBridge().doGetLeaders(roomId, 'APP_ID') as Promise>; + } + + public async getUnreadByUser(roomId: string, uid: string, options: Partial = {}): Promise { + const { limit = 100, sort = { createdAt: 'asc' }, skip = 0, showThreadMessages = true } = options; + + if (typeof roomId !== 'string' || roomId.trim().length === 0) { + throw new Error('Invalid roomId: must be a non-empty string'); + } + + if (!Number.isFinite(limit) || limit <= 0 || limit > 100) { + throw new Error(`Invalid limit provided. Expected number between 1 and 100, got ${limit}`); + } + + this.validateSort(sort); + + const completeOptions: GetMessagesOptions = { limit, sort, skip, showThreadMessages }; + + return this.bridges.getRoomBridge().doGetUnreadByUser(roomId, uid, completeOptions, 'APP_ID') as Promise; + } + + public getUserUnreadMessageCount(roomId: string, uid: string): Promise { + return this.bridges.getRoomBridge().doGetUserUnreadMessageCount(roomId, uid, 'APP_ID') as Promise; + } + + // If there are any invalid fields or values, throw + private validateSort(sort: Record) { + Object.entries(sort).forEach(([key, value]) => { + if (!GetMessagesSortableFields.includes(key as (typeof GetMessagesSortableFields)[number])) { + throw new Error(`Invalid key "${key}" used in sort. Available keys for sorting are ${GetMessagesSortableFields.join(', ')}`); + } + + if (value !== 'asc' && value !== 'desc') { + throw new Error(`Invalid sort direction for field "${key}". Expected "asc" or "desc", got ${value}`); + } + }); + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/read/ThreadRead.ts b/packages/apps/base-runtime/src/lib/accessors/read/ThreadRead.ts new file mode 100644 index 0000000000000..e859f89d3225e --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/read/ThreadRead.ts @@ -0,0 +1,12 @@ +import type { IThreadRead } from '@rocket.chat/apps-engine/definition/accessors/IThreadRead'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class ThreadRead implements IThreadRead { + constructor(private readonly bridges: RemoteBridges) {} + + public getThreadById(id: string): Promise> { + return this.bridges.getThreadBridge().doGetById(id, 'APP_ID') as Promise>; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/read/UploadRead.ts b/packages/apps/base-runtime/src/lib/accessors/read/UploadRead.ts new file mode 100644 index 0000000000000..d84ff31d614d3 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/read/UploadRead.ts @@ -0,0 +1,22 @@ +import type { IUploadRead } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IUpload } from '@rocket.chat/apps-engine/definition/uploads'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class UploadRead implements IUploadRead { + constructor(private readonly bridges: RemoteBridges) {} + + public getById(id: string): Promise { + return this.bridges.getUploadBridge().doGetById(id, 'APP_ID') as Promise; + } + + public getBuffer(upload: IUpload): Promise { + return this.bridges.getUploadBridge().doGetBuffer(upload, 'APP_ID') as Promise; + } + + public async getBufferById(id: string): Promise { + const upload = (await this.bridges.getUploadBridge().doGetById(id, 'APP_ID')) as IUpload; + + return this.bridges.getUploadBridge().doGetBuffer(upload, 'APP_ID') as Promise; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/read/UserRead.ts b/packages/apps/base-runtime/src/lib/accessors/read/UserRead.ts new file mode 100644 index 0000000000000..618e1acc00224 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/read/UserRead.ts @@ -0,0 +1,39 @@ +import type { IUserRead } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class UserRead implements IUserRead { + constructor(private readonly bridges: RemoteBridges) {} + + public getById(id: string): Promise { + return this.bridges.getUserBridge().doGetById(id, 'APP_ID') as Promise; + } + + public getByUsername(username: string): Promise { + return this.bridges.getUserBridge().doGetByUsername(username, 'APP_ID') as Promise; + } + + public getBySipExtension(extension: string): Promise { + if (!extension) { + return Promise.resolve(undefined); + } + + return this.bridges.getUserBridge().doGetBySipExtension(extension, 'APP_ID') as Promise; + } + + // `appId` is an app-supplied argument, not caller identity, so it is NOT normalized + // to the 'APP_ID' sentinel - it defaults to the sentinel only when the app omits it. + // See docs/base-runtime-app-id-exceptions.md (bucket B). + public getAppUser(appId: string = 'APP_ID'): Promise { + return this.bridges.getUserBridge().doGetAppUser(appId) as Promise; + } + + public getUserUnreadMessageCount(uid: string): Promise { + return this.bridges.getUserBridge().doGetUserUnreadMessageCount(uid, 'APP_ID') as Promise; + } + + public getUserRoomIds(userId: string): Promise { + return this.bridges.getUserBridge().doGetUserRoomIds(userId, 'APP_ID') as Promise; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/read/VideoConferenceRead.ts b/packages/apps/base-runtime/src/lib/accessors/read/VideoConferenceRead.ts new file mode 100644 index 0000000000000..4b1ab53acc4f9 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/read/VideoConferenceRead.ts @@ -0,0 +1,12 @@ +import type { IVideoConferenceRead } from '@rocket.chat/apps-engine/definition/accessors'; +import type { VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences'; + +import type { RemoteBridges } from '../../bridges/RemoteBridges'; + +export class VideoConferenceRead implements IVideoConferenceRead { + constructor(private readonly bridges: RemoteBridges) {} + + public getById(id: string): Promise { + return this.bridges.getVideoConferenceBridge().doGetById(id, 'APP_ID') as Promise; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/read/tests/readers.test.ts b/packages/apps/base-runtime/src/lib/accessors/read/tests/readers.test.ts new file mode 100644 index 0000000000000..b183e0a1b6728 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/read/tests/readers.test.ts @@ -0,0 +1,184 @@ +/* eslint-disable testing-library/no-await-sync-queries -- accessor read methods are not testing-library DOM queries */ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { RemoteBridges } from '../../../bridges/RemoteBridges'; +import { createRecordingSender } from '../../tests/helpers/parityHarness'; +import { CloudWorkspaceRead } from '../CloudWorkspaceRead'; +import { ContactRead } from '../ContactRead'; +import { LivechatRead } from '../LivechatRead'; +import { MessageRead } from '../MessageRead'; +import { OAuthAppsReader } from '../OAuthAppsReader'; +import { PersistenceRead } from '../PersistenceRead'; +import { RoleRead } from '../RoleRead'; +import { RoomRead } from '../RoomRead'; +import { ThreadRead } from '../ThreadRead'; +import { UploadRead } from '../UploadRead'; +import { UserRead } from '../UserRead'; +import { VideoConferenceRead } from '../VideoConferenceRead'; + +const setup = (responses = {}) => { + const rec = createRecordingSender(responses); + return { rec, bridges: new RemoteBridges(rec.sender) }; +}; + +describe('Reader family (base-runtime)', () => { + describe('MessageRead', () => { + it('getById forwards to getMessageBridge:doGetById with the APP_ID sentinel', async () => { + const { rec, bridges } = setup({ 'bridges:getMessageBridge:doGetById': { id: 'm1' } }); + const result = await new MessageRead(bridges).getById('m1'); + + assert.deepStrictEqual(rec.emitted(), [{ method: 'bridges:getMessageBridge:doGetById', params: ['m1', 'APP_ID'] }]); + assert.deepStrictEqual(result, { id: 'm1' }); + }); + + it('getSenderUser returns the message sender, or undefined when the message is missing', async () => { + const withMsg = setup({ 'bridges:getMessageBridge:doGetById': { id: 'm1', sender: { id: 'u1' } } }); + assert.deepStrictEqual(await new MessageRead(withMsg.bridges).getSenderUser('m1'), { id: 'u1' }); + + const noMsg = setup({ 'bridges:getMessageBridge:doGetById': null }); + assert.strictEqual(await new MessageRead(noMsg.bridges).getSenderUser('m1'), undefined); + }); + + it('getRoom returns the message room, or undefined when the message is missing', async () => { + const withMsg = setup({ 'bridges:getMessageBridge:doGetById': { id: 'm1', room: { id: 'r1' } } }); + assert.deepStrictEqual(await new MessageRead(withMsg.bridges).getRoom('m1'), { id: 'r1' }); + + const noMsg = setup({ 'bridges:getMessageBridge:doGetById': undefined }); + assert.strictEqual(await new MessageRead(noMsg.bridges).getRoom('m1'), undefined); + }); + }); + + describe('RoomRead', () => { + it('getMessages applies limit/showThreadMessages defaults', async () => { + const { rec, bridges } = setup({ 'bridges:getRoomBridge:doGetMessages': [] }); + await new RoomRead(bridges).getMessages('r1'); + + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getRoomBridge:doGetMessages', + params: ['r1', { limit: 100, showThreadMessages: true }, 'APP_ID'], + }); + }); + + it('getMessages rejects a limit above 100', async () => { + const { bridges } = setup(); + assert.throws(() => new RoomRead(bridges).getMessages('r1', { limit: 101 }), /Expected number <= 100, got 101/); + }); + + it('getMessages rejects an invalid sort key and direction', async () => { + const { bridges } = setup(); + assert.throws(() => new RoomRead(bridges).getMessages('r1', { sort: { bogus: 'asc' } as any }), /Invalid key "bogus"/); + // `createdAt` is a valid sortable field, so this exercises the direction check specifically. + assert.throws(() => new RoomRead(bridges).getMessages('r1', { sort: { createdAt: 'sideways' } as any }), /Invalid sort direction/); + }); + + it('getAllRooms validates limit and skip and forwards normalized options', async () => { + const { rec, bridges } = setup({ 'bridges:getRoomBridge:doGetAllRooms': [] }); + await new RoomRead(bridges).getAllRooms(); + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getRoomBridge:doGetAllRooms', + params: [{}, { limit: 100, skip: 0 }, 'APP_ID'], + }); + + assert.throws(() => new RoomRead(setup().bridges).getAllRooms({}, { limit: 0 } as any), /between 1 and 100/); + assert.throws(() => new RoomRead(setup().bridges).getAllRooms({}, { skip: -1 } as any), /Expected number >= 0/); + }); + + it('getUnreadByUser validates roomId and fills option defaults', async () => { + const { rec, bridges } = setup({ 'bridges:getRoomBridge:doGetUnreadByUser': [] }); + await new RoomRead(bridges).getUnreadByUser('r1', 'u1'); + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getRoomBridge:doGetUnreadByUser', + params: ['r1', 'u1', { limit: 100, sort: { createdAt: 'asc' }, skip: 0, showThreadMessages: true }, 'APP_ID'], + }); + + await assert.rejects(() => new RoomRead(setup().bridges).getUnreadByUser(' ', 'u1'), /non-empty string/); + }); + + it('passthrough getters forward verbatim', async () => { + const { rec, bridges } = setup(); + const room = new RoomRead(bridges); + await room.getById('r1'); + await room.getMembers('r1'); + await room.getUserUnreadMessageCount('r1', 'u1'); + assert.deepStrictEqual(rec.methods(), [ + 'bridges:getRoomBridge:doGetById', + 'bridges:getRoomBridge:doGetMembers', + 'bridges:getRoomBridge:doGetUserUnreadMessageCount', + ]); + }); + }); + + describe('UserRead', () => { + it('getBySipExtension short-circuits to undefined for an empty extension (no bridge call)', async () => { + const { rec, bridges } = setup(); + assert.strictEqual(await new UserRead(bridges).getBySipExtension(''), undefined); + assert.strictEqual(rec.emitted().length, 0); + }); + + it('getAppUser defaults to the APP_ID sentinel but forwards an explicit app id raw', async () => { + const a = setup(); + await new UserRead(a.bridges).getAppUser(); + assert.deepStrictEqual(a.rec.emitted()[0], { method: 'bridges:getUserBridge:doGetAppUser', params: ['APP_ID'] }); + + const b = setup(); + await new UserRead(b.bridges).getAppUser('another-app'); + assert.deepStrictEqual(b.rec.emitted()[0], { method: 'bridges:getUserBridge:doGetAppUser', params: ['another-app'] }); + }); + }); + + describe('PersistenceRead', () => { + it('readByAssociation wraps the single association into an array', async () => { + const { rec, bridges } = setup({ 'bridges:getPersistenceBridge:doReadByAssociations': [] }); + const assoc = { type: 1, id: 'x' } as any; + await new PersistenceRead(bridges).readByAssociation(assoc); + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getPersistenceBridge:doReadByAssociations', + params: [[assoc], 'APP_ID'], + }); + }); + }); + + describe('UploadRead', () => { + it('getBufferById fetches the upload then its buffer (two bridge calls)', async () => { + const { rec, bridges } = setup({ 'bridges:getUploadBridge:doGetById': { _id: 'up1' } }); + await new UploadRead(bridges).getBufferById('up1'); + assert.deepStrictEqual(rec.emitted(), [ + { method: 'bridges:getUploadBridge:doGetById', params: ['up1', 'APP_ID'] }, + { method: 'bridges:getUploadBridge:doGetBuffer', params: [{ _id: 'up1' }, 'APP_ID'] }, + ]); + }); + }); + + describe('LivechatRead', () => { + it('_fetchLivechatRoomMessages sends the app id first, then the room id', async () => { + const { rec, bridges } = setup({ 'bridges:getLivechatBridge:do_fetchLivechatRoomMessages': [] }); + await new LivechatRead(bridges)._fetchLivechatRoomMessages('r1'); + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getLivechatBridge:do_fetchLivechatRoomMessages', + params: ['APP_ID', 'r1'], + }); + }); + }); + + describe('simple passthrough readers', () => { + it('route to their matching bridge method', async () => { + const { rec, bridges } = setup(); + await new CloudWorkspaceRead(bridges).getWorkspaceToken('scope'); + new VideoConferenceRead(bridges).getById('c1'); + await new OAuthAppsReader(bridges).getOAuthAppById('o1'); + new ContactRead(bridges).getById('ct1'); + await new ThreadRead(bridges).getThreadById('t1'); + await new RoleRead(bridges).getCustomRoles(); + + assert.deepStrictEqual(rec.emitted(), [ + { method: 'bridges:getCloudWorkspaceBridge:doGetWorkspaceToken', params: ['scope', 'APP_ID'] }, + { method: 'bridges:getVideoConferenceBridge:doGetById', params: ['c1', 'APP_ID'] }, + { method: 'bridges:getOAuthAppsBridge:doGetByid', params: ['o1', 'APP_ID'] }, + { method: 'bridges:getContactBridge:doGetById', params: ['ct1', 'APP_ID'] }, + { method: 'bridges:getThreadBridge:doGetById', params: ['t1', 'APP_ID'] }, + { method: 'bridges:getRoleBridge:doGetCustomRoles', params: ['APP_ID'] }, + ]); + }); + }); +}); diff --git a/packages/apps/base-runtime/src/lib/accessors/tests/AppAccessors.test.ts b/packages/apps/base-runtime/src/lib/accessors/tests/AppAccessors.test.ts index a41891315685d..48a13bc0572cd 100644 --- a/packages/apps/base-runtime/src/lib/accessors/tests/AppAccessors.test.ts +++ b/packages/apps/base-runtime/src/lib/accessors/tests/AppAccessors.test.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion -- acceptable in this test file */ /* eslint-disable testing-library/no-await-sync-queries */ import * as assert from 'node:assert'; -import { after, beforeEach, describe, it } from 'node:test'; +import { after, beforeEach, describe, it, mock } from 'node:test'; import type { IRead, IModify, IHttp, IPersistence } from '@rocket.chat/apps-engine/definition/accessors'; import type { SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands'; @@ -31,44 +31,60 @@ describe('AppAccessors', () => { AppObjectRegistry.clear(); }); + // The Reader family, Persistence and server-side Environment accessors now run locally in the + // subprocess (Phase 1): they emit `bridges:*` messages with the 'APP_ID' sentinel instead of the + // old host-resolved `accessor:*` messages. We spy on the sender to assert the emitted bridge call, + // which is robust for the accessors that transform their result or return void. it('creates the correct format for IRead calls', async () => { - const roomRead = appAccessors.getReader()!.getRoomReader(); - const room = await roomRead.getById('123'); - - assert.deepStrictEqual(room, { - params: ['123'], - method: 'accessor:getReader:getRoomReader:getById', - }); + const spy = mock.fn(senderFn); + const roomRead = new AppAccessors(spy).getReader()!.getRoomReader(); + await roomRead.getById('123'); + + assert.deepStrictEqual(spy.mock.calls[0].arguments, [ + { + params: ['123', 'APP_ID'], + method: 'bridges:getRoomBridge:doGetById', + }, + ]); }); it('creates the correct format for IEnvironmentRead calls from IRead', async () => { - const reader = appAccessors.getReader()!.getEnvironmentReader().getEnvironmentVariables(); - const room = await reader.getValueByName('NODE_ENV'); - - assert.deepStrictEqual(room, { - params: ['NODE_ENV'], - method: 'accessor:getReader:getEnvironmentReader:getEnvironmentVariables:getValueByName', - }); + const spy = mock.fn(senderFn); + const reader = new AppAccessors(spy).getReader()!.getEnvironmentReader().getEnvironmentVariables(); + await reader.getValueByName('NODE_ENV'); + + assert.deepStrictEqual(spy.mock.calls[0].arguments, [ + { + params: ['NODE_ENV', 'APP_ID'], + method: 'bridges:getEnvironmentalVariableBridge:doGetValueByName', + }, + ]); }); it('creates the correct format for IEvironmentRead calls', async () => { - const envRead = appAccessors.getEnvironmentRead(); - const env = await envRead.getServerSettings().getValueById('123'); - - assert.deepStrictEqual(env, { - params: ['123'], - method: 'accessor:getEnvironmentRead:getServerSettings:getValueById', - }); + const spy = mock.fn(senderFn); + const envRead = new AppAccessors(spy).getEnvironmentRead(); + await envRead.getServerSettings().getOneById('123'); + + assert.deepStrictEqual(spy.mock.calls[0].arguments, [ + { + params: ['123', 'APP_ID'], + method: 'bridges:getServerSettingBridge:doGetOneById', + }, + ]); }); it('creates the correct format for IEvironmentWrite calls', async () => { - const envRead = appAccessors.getEnvironmentWrite(); - const env = await envRead.getServerSettings().incrementValue('123', 6); - - assert.deepStrictEqual(env, { - params: ['123', 6], - method: 'accessor:getEnvironmentWrite:getServerSettings:incrementValue', - }); + const spy = mock.fn(senderFn); + const envWrite = new AppAccessors(spy).getEnvironmentWrite(); + await envWrite.getServerSettings().incrementValue('123', 6); + + assert.deepStrictEqual(spy.mock.calls[0].arguments, [ + { + params: ['123', 6, 'APP_ID'], + method: 'bridges:getServerSettingBridge:doIncrementValue', + }, + ]); }); it('creates the correct format for IConfigurationModify calls', async () => { @@ -94,8 +110,9 @@ describe('AppAccessors', () => { i18nParamsExample: 'test', providesPreview: true, }, + 'APP_ID', ], - method: 'accessor:getConfigurationModify:slashCommands:modifySlashCommand', + method: 'bridges:getAppResourceBridge:doModifySlashCommand', }); }); @@ -120,7 +137,7 @@ describe('AppAccessors', () => { delete (result as any).params[0].executor; assert.deepStrictEqual(result, { - method: 'accessor:getConfigurationExtend:slashCommands:provideSlashCommand', + method: 'bridges:getAppResourceBridge:doProvideSlashCommand', params: [ { command: 'test', @@ -128,6 +145,7 @@ describe('AppAccessors', () => { i18nParamsExample: 'test', providesPreview: true, }, + 'APP_ID', ], }); }); diff --git a/packages/apps/base-runtime/src/lib/accessors/tests/ModifyCreator.test.ts b/packages/apps/base-runtime/src/lib/accessors/tests/ModifyCreator.test.ts index caf3d254d1232..26a985eec289f 100644 --- a/packages/apps/base-runtime/src/lib/accessors/tests/ModifyCreator.test.ts +++ b/packages/apps/base-runtime/src/lib/accessors/tests/ModifyCreator.test.ts @@ -62,38 +62,54 @@ describe('ModifyCreator', () => { ]); }); - it('sends the correct payload in the request to upload a buffer', async () => { - const modifyCreator = new ModifyCreator(senderFn); - - const result = await modifyCreator - .getUploadCreator() - .uploadBuffer(Buffer.from([1, 2, 3, 4]), { filename: 'testfile' } as IUploadDescriptor); - - assert.deepStrictEqual(result, { - method: 'accessor:getModifier:getCreator:getUploadCreator:uploadBuffer', - params: [Buffer.from([1, 2, 3, 4]), { filename: 'testfile' }], + it('sends the correct payload in the request to upload a buffer (defaults user to the app user, then creates the upload)', async () => { + // Resolve the app-user lookup to a real user so we can assert it is propagated as the uploader. + const appUserSender = (req: any) => senderFn(req.method === 'bridges:getUserBridge:doGetAppUser' ? { id: 'app-user' } : req); + const spying = mock.fn(appUserSender); + const modifyCreator = new ModifyCreator(spying); + const buffer = Buffer.from([1, 2, 3, 4]); + + await modifyCreator.getUploadCreator().uploadBuffer(buffer, { filename: 'testfile', room: { id: 'r1' } } as IUploadDescriptor); + + // No `user`/`visitorToken` on the descriptor, so the app user is fetched first... + assert.deepStrictEqual(spying.mock.calls[0].arguments, [{ method: 'bridges:getUserBridge:doGetAppUser', params: ['APP_ID'] }]); + // ...then the upload is created with the derived details, carrying the fetched app user's id. + const [createCall] = spying.mock.calls[1].arguments as any[]; + assert.strictEqual(createCall.method, 'bridges:getUploadBridge:doCreateUpload'); + assert.deepStrictEqual(createCall.params[0], { + name: 'testfile', + size: 4, + rid: 'r1', + userId: 'app-user', + visitorToken: undefined, }); + assert.deepStrictEqual(createCall.params[1], buffer); + assert.strictEqual(createCall.params[2], 'APP_ID'); }); it('sends the correct payload in the request to create a visitor', async () => { - const modifyCreator = new ModifyCreator(senderFn); + const spying = mock.fn(senderFn); + const modifyCreator = new ModifyCreator(spying); - const result = (await modifyCreator.getLivechatCreator().createVisitor({ + await modifyCreator.getLivechatCreator().createVisitor({ token: 'random token', username: 'random username for visitor', name: 'Random Visitor', - })) as any; // We modified the send function so it changed the original return type of the function - - assert.deepStrictEqual(result, { - method: 'accessor:getModifier:getCreator:getLivechatCreator:createVisitor', - params: [ - { - token: 'random token', - username: 'random username for visitor', - name: 'Random Visitor', - }, - ], }); + + assert.deepStrictEqual(spying.mock.calls[0].arguments, [ + { + method: 'bridges:getLivechatBridge:doCreateVisitor', + params: [ + { + token: 'random token', + username: 'random username for visitor', + name: 'Random Visitor', + }, + 'APP_ID', + ], + }, + ]); }); // This test is important because if we return a promise we break API compatibility diff --git a/packages/apps/base-runtime/src/lib/accessors/tests/ModifyUpdater.test.ts b/packages/apps/base-runtime/src/lib/accessors/tests/ModifyUpdater.test.ts index e748ff2084443..e6e626c57d74a 100644 --- a/packages/apps/base-runtime/src/lib/accessors/tests/ModifyUpdater.test.ts +++ b/packages/apps/base-runtime/src/lib/accessors/tests/ModifyUpdater.test.ts @@ -104,30 +104,48 @@ describe('ModifyUpdater', () => { }); it('correctly formats requests to UserUpdater methods', async () => { - const result = (await modifyUpdater.getUserUpdater().updateStatusText({ id: '123' } as IUser, 'Hello World')) as any; + const _spy = mock.method(modifyUpdater, 'senderFn' as any); - assert.deepStrictEqual(result, { - method: 'accessor:getModifier:getUpdater:getUserUpdater:updateStatusText', - params: [{ id: '123' }, 'Hello World'], - }); + await modifyUpdater.getUserUpdater().updateStatusText({ id: '123' } as IUser, 'Hello World'); + + assert.deepStrictEqual(_spy.mock.calls[0].arguments, [ + { + method: 'bridges:getUserBridge:doUpdate', + params: [{ id: '123' }, { statusText: 'Hello World' }, 'APP_ID'], + }, + ]); + + _spy.mock.restore(); }); it('correctly formats requests to LivechatUpdater methods', async () => { - const result = (await modifyUpdater.getLivechatUpdater().closeRoom({ id: '123' } as IRoom, 'close it!')) as any; + const _spy = mock.method(modifyUpdater, 'senderFn' as any); - assert.deepStrictEqual(result, { - method: 'accessor:getModifier:getUpdater:getLivechatUpdater:closeRoom', - params: [{ id: '123' }, 'close it!'], - }); + await modifyUpdater.getLivechatUpdater().closeRoom({ id: '123' } as IRoom, 'close it!'); + + assert.deepStrictEqual(_spy.mock.calls[0].arguments, [ + { + method: 'bridges:getLivechatBridge:doCloseRoom', + params: [{ id: '123' }, 'close it!', undefined, 'APP_ID'], + }, + ]); + + _spy.mock.restore(); }); it('correctly formats requests to MessageUpdater methods', async () => { - const result = (await modifyUpdater.getMessageUpdater().addReaction('message-id', 'user-id', ':smile:')) as any; + const _spy = mock.method(modifyUpdater, 'senderFn' as any); - assert.deepStrictEqual(result, { - method: 'accessor:getModifier:getUpdater:getMessageUpdater:addReaction', - params: ['message-id', 'user-id', ':smile:'], - }); + await modifyUpdater.getMessageUpdater().addReaction('message-id', 'user-id', ':smile:' as any); + + assert.deepStrictEqual(_spy.mock.calls[0].arguments, [ + { + method: 'bridges:getMessageBridge:doAddReaction', + params: ['message-id', 'user-id', ':smile:', 'APP_ID'], + }, + ]); + + _spy.mock.restore(); }); describe('Error Handling', () => { diff --git a/packages/apps/base-runtime/src/lib/accessors/tests/Persistence.test.ts b/packages/apps/base-runtime/src/lib/accessors/tests/Persistence.test.ts new file mode 100644 index 0000000000000..f2e50b38b3fe4 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/tests/Persistence.test.ts @@ -0,0 +1,94 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { RemoteBridges } from '../../bridges/RemoteBridges'; +import { Persistence } from '../Persistence'; +import { createRecordingSender } from './helpers/parityHarness'; + +const setup = () => { + const rec = createRecordingSender(); + return { rec, bridges: new RemoteBridges(rec.sender) }; +}; + +describe('Persistence (base-runtime)', () => { + it('create forwards data with the APP_ID sentinel', async () => { + const { rec, bridges } = setup(); + await new Persistence(bridges).create({ a: 1 }); + assert.deepStrictEqual(rec.emitted()[0], { method: 'bridges:getPersistenceBridge:doCreate', params: [{ a: 1 }, 'APP_ID'] }); + }); + + it('createWithAssociation wraps the single association into an array (doCreateWithAssociations)', async () => { + const { rec, bridges } = setup(); + const assoc = { type: 1, id: 'x' } as any; + await new Persistence(bridges).createWithAssociation({ a: 1 }, assoc); + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getPersistenceBridge:doCreateWithAssociations', + params: [{ a: 1 }, [assoc], 'APP_ID'], + }); + }); + + it('update defaults upsert to false', async () => { + const { rec, bridges } = setup(); + await new Persistence(bridges).update('id1', { a: 1 }); + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getPersistenceBridge:doUpdate', + params: ['id1', { a: 1 }, false, 'APP_ID'], + }); + }); + + it('updateByAssociation wraps the association and defaults upsert to false', async () => { + const { rec, bridges } = setup(); + const assoc = { type: 1, id: 'x' } as any; + await new Persistence(bridges).updateByAssociation(assoc, { a: 1 }); + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getPersistenceBridge:doUpdateByAssociations', + params: [[assoc], { a: 1 }, false, 'APP_ID'], + }); + }); + + it('removeByAssociation wraps the single association into an array', async () => { + const { rec, bridges } = setup(); + const assoc = { type: 1, id: 'x' } as any; + await new Persistence(bridges).removeByAssociation(assoc); + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getPersistenceBridge:doRemoveByAssociations', + params: [[assoc], 'APP_ID'], + }); + }); + + it('createWithAssociations forwards the associations array as-is', async () => { + const { rec, bridges } = setup(); + const assocs = [{ type: 1, id: 'x' } as any, { type: 2, id: 'y' } as any]; + await new Persistence(bridges).createWithAssociations({ a: 1 }, assocs); + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getPersistenceBridge:doCreateWithAssociations', + params: [{ a: 1 }, assocs, 'APP_ID'], + }); + }); + + it('updateByAssociations forwards the associations array and defaults upsert to false', async () => { + const { rec, bridges } = setup(); + const assocs = [{ type: 1, id: 'x' } as any, { type: 2, id: 'y' } as any]; + await new Persistence(bridges).updateByAssociations(assocs, { a: 1 }); + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getPersistenceBridge:doUpdateByAssociations', + params: [assocs, { a: 1 }, false, 'APP_ID'], + }); + }); + + it('remove forwards the id with the APP_ID sentinel', async () => { + const { rec, bridges } = setup(); + await new Persistence(bridges).remove('id1'); + assert.deepStrictEqual(rec.emitted()[0], { method: 'bridges:getPersistenceBridge:doRemove', params: ['id1', 'APP_ID'] }); + }); + + it('removeByAssociations forwards the associations array as-is', async () => { + const { rec, bridges } = setup(); + const assocs = [{ type: 1, id: 'x' } as any, { type: 2, id: 'y' } as any]; + await new Persistence(bridges).removeByAssociations(assocs); + assert.deepStrictEqual(rec.emitted()[0], { + method: 'bridges:getPersistenceBridge:doRemoveByAssociations', + params: [assocs, 'APP_ID'], + }); + }); +}); diff --git a/packages/apps/base-runtime/src/lib/accessors/tests/configuration.test.ts b/packages/apps/base-runtime/src/lib/accessors/tests/configuration.test.ts new file mode 100644 index 0000000000000..c2f4c5c17133a --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/tests/configuration.test.ts @@ -0,0 +1,123 @@ +import * as assert from 'node:assert'; +import { after, beforeEach, describe, it, mock } from 'node:test'; + +import type { IApi } from '@rocket.chat/apps-engine/definition/api'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry'; +import { AppAccessors } from '../mod'; + +// The senderFn echoes the request as the SuccessObject `result`, so a bridge call resolves to the +// `{ method, params }` descriptor and the recorded spy shows exactly what was sent. +const senderFn = (r: any) => + Promise.resolve({ + id: 'test', + jsonrpc: '2.0', + result: r, + serialize() { + return JSON.stringify(this); + }, + }); + +describe('ConfigurationExtend/Modify + app settings (base-runtime, via AppResourceBridge)', () => { + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('id', 'deno-test'); + if (!AppObjectRegistry.has('apiEndpoints')) { + AppObjectRegistry.set('apiEndpoints', []); + } + }); + + after(() => { + AppObjectRegistry.clear(); + }); + + it('provideSlashCommand stashes the instance and calls doProvideSlashCommand', async () => { + const spy = mock.fn(senderFn); + const configExtend = new AppAccessors(spy).getConfigurationExtend(); + + const command = { command: 'my-cmd', executor() {} } as any; + await configExtend.slashCommands.provideSlashCommand(command); + + assert.strictEqual(AppObjectRegistry.get('slashcommand:my-cmd'), command); + assert.strictEqual((spy.mock.calls[0].arguments[0] as any).method, 'bridges:getAppResourceBridge:doProvideSlashCommand'); + assert.strictEqual((spy.mock.calls[0].arguments[0] as any).params[1], 'APP_ID'); + }); + + it('registerProcessors stashes each processor and calls doRegisterProcessors', async () => { + const spy = mock.fn(senderFn); + const configExtend = new AppAccessors(spy).getConfigurationExtend(); + + await configExtend.scheduler.registerProcessors([{ id: 'p1' } as any, { id: 'p2' } as any]); + + assert.ok(AppObjectRegistry.get('scheduler:p1')); + assert.ok(AppObjectRegistry.get('scheduler:p2')); + assert.deepStrictEqual(spy.mock.calls[0].arguments, [ + { method: 'bridges:getAppResourceBridge:doRegisterProcessors', params: [[{ id: 'p1' }, { id: 'p2' }], 'APP_ID'] }, + ]); + }); + + it('provideApi stashes endpoints, calls doProvideApi, and then doListApis', async () => { + const spy = mock.fn(senderFn); + const configExtend = new AppAccessors(spy).getConfigurationExtend(); + + const api = { endpoints: [{ path: 'hello', get() {} }] } as unknown as IApi; + await configExtend.api.provideApi(api); + + assert.ok(AppObjectRegistry.get('api:hello')); + assert.strictEqual((spy.mock.calls[0].arguments[0] as any).method, 'bridges:getAppResourceBridge:doProvideApi'); + // _availableMethods is computed locally before sending + assert.deepStrictEqual((spy.mock.calls[0].arguments[0] as any).params[0].endpoints[0]._availableMethods, ['get']); + assert.strictEqual((spy.mock.calls[1].arguments[0] as any).method, 'bridges:getAppResourceBridge:doListApis'); + }); + + it('registerButton / provideSetting / externalComponents route to their bridge methods', async () => { + const spy = mock.fn(senderFn); + const configExtend = new AppAccessors(spy).getConfigurationExtend(); + + await configExtend.ui.registerButton({ actionId: 'b1' } as any); + await configExtend.settings.provideSetting({ id: 's1' } as any); + await configExtend.externalComponents.register({ name: 'c1' } as any); + + assert.deepStrictEqual( + spy.mock.calls.map((c) => (c.arguments[0] as any).method), + [ + 'bridges:getAppResourceBridge:doRegisterActionButton', + 'bridges:getAppResourceBridge:doProvideSetting', + 'bridges:getAppResourceBridge:doRegisterExternalComponent', + ], + ); + }); + + it('ConfigurationModify.slashCommands maps to modify/enable/disable bridge methods', async () => { + const spy = mock.fn(senderFn); + const configModify = new AppAccessors(spy).getConfigurationModify(); + + await configModify.slashCommands.modifySlashCommand({ command: 'c', executor() {} } as any); + await configModify.slashCommands.enableSlashCommand('c'); + await configModify.slashCommands.disableSlashCommand('c'); + + assert.deepStrictEqual( + spy.mock.calls.map((c) => (c.arguments[0] as any).method), + [ + 'bridges:getAppResourceBridge:doModifySlashCommand', + 'bridges:getAppResourceBridge:doEnableSlashCommand', + 'bridges:getAppResourceBridge:doDisableSlashCommand', + ], + ); + }); + + it('app-settings read/update go through the AppResourceBridge', async () => { + const spy = mock.fn(senderFn); + const accessors = new AppAccessors(spy); + + accessors.getEnvironmentRead().getSettings().getById('s1'); + await accessors.getEnvironmentWrite().getSettings().updateValue('s1', 'v'); + + assert.deepStrictEqual(spy.mock.calls[0].arguments, [ + { method: 'bridges:getAppResourceBridge:doGetSettingById', params: ['s1', 'APP_ID'] }, + ]); + assert.deepStrictEqual(spy.mock.calls[1].arguments, [ + { method: 'bridges:getAppResourceBridge:doUpdateSettingValue', params: ['s1', 'v', 'APP_ID'] }, + ]); + }); +}); diff --git a/packages/apps/base-runtime/src/lib/bridges/RemoteBridges.ts b/packages/apps/base-runtime/src/lib/bridges/RemoteBridges.ts index 31a596a6322b0..f34c42d98bd7e 100644 --- a/packages/apps/base-runtime/src/lib/bridges/RemoteBridges.ts +++ b/packages/apps/base-runtime/src/lib/bridges/RemoteBridges.ts @@ -153,4 +153,14 @@ export class RemoteBridges { public getUiInteractionBridge(): RemoteBridge { return this.buildBridge('getUiInteractionBridge'); } + + /** + * The internal bridge that fronts host manager registries and the app's settings storage + * (slash commands, APIs, scheduler, UI buttons, providers, external components, app settings). + * It is not part of the app-facing `AppBridges` surface; the host resolves it via a dedicated + * lookup in `handleBridgeMessage`. See docs/base-runtime-accessor-consolidation.md §4. + */ + public getAppResourceBridge(): RemoteBridge { + return this.buildBridge('getAppResourceBridge'); + } } diff --git a/packages/apps/src/server/bridges/AppResourceBridge.ts b/packages/apps/src/server/bridges/AppResourceBridge.ts new file mode 100644 index 0000000000000..b427f99133618 --- /dev/null +++ b/packages/apps/src/server/bridges/AppResourceBridge.ts @@ -0,0 +1,177 @@ +import type { IApi } from '@rocket.chat/apps-engine/definition/api'; +import type { IApiEndpointMetadata } from '@rocket.chat/apps-engine/definition/api/IApiEndpointMetadata'; +import type { IExternalComponent } from '@rocket.chat/apps-engine/definition/externalComponent/IExternalComponent'; +import type { IOutboundMessageProviders } from '@rocket.chat/apps-engine/definition/outboundCommunication'; +import type { IProcessor } from '@rocket.chat/apps-engine/definition/scheduler'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; +import type { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands'; +import type { IUIActionButtonDescriptor } from '@rocket.chat/apps-engine/definition/ui'; +import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders'; + +import type { AppManager } from '../AppManager'; + +/** + * Internal, engine-owned bridge that fronts the host manager registries and the app's settings + * storage for the subprocess. It is deliberately NOT part of the app-facing {@link AppBridges} + * surface (which orchestrator embeddings must implement) - it is a concrete class living in + * `packages/apps` and delegating to the managers that also live here. The subprocess reaches it + * through the same `bridges:*` channel as any other bridge; `BaseRuntimeSubprocessController` + * resolves the `getAppResourceBridge` name via a dedicated lookup rather than through `AppBridges`. + * + * This is the compromise described in docs/base-runtime-accessor-consolidation.md §4: the + * registration/configuration surface cannot move into the sandbox (the registries are host state, + * read by the host UI/event/dispatch layers and used to arbitrate cross-app conflicts), so the + * accessor *behavior* (validation/shaping/stashing) lives in the runtime while the *state and + * enforcement* stay here, reachable through a uniform `do*` surface. + * + * Permission and conflict semantics are preserved because each method delegates to the same manager + * the host accessor used: `AppVideoConfProviderManager.addProvider` / + * `AppOutboundCommunicationProviderManager.addProvider` still throw `PermissionDeniedError`; + * `UIActionButtonManager.registerActionButton` still logs-and-refuses; the slash-command/api + * conflict errors still propagate. The propagated error travels back as a JSON-RPC error exactly as + * it did through `handleAccessorMessage`. + */ +export class AppResourceBridge { + /** + * The registration methods that must be suppressed while a subprocess is restarting. Re-running + * `app:initialize` must not double-register resources that are already registered on the host, + * but the subprocess still rebuilds its local `AppObjectRegistry` entries. This mirrors the old + * `handleAccessorMessage` guard that short-circuited the `getConfigurationExtend` origin, but is + * keyed on an explicit method set rather than a name prefix so the guarded surface stays + * auditable and decoupled from naming discipline. + * + * NOTE: this is exactly the `getConfigurationExtend` provide/register surface - not the + * read/update/modify/enable/disable methods, which are safe to run during a restart. + */ + public static readonly REGISTRATION_METHODS: ReadonlySet = new Set([ + 'doProvideSlashCommand', + 'doProvideApi', + 'doRegisterProcessors', + 'doRegisterActionButton', + 'doRegisterExternalComponent', + 'doProvideVideoConfProvider', + 'doRegisterOutboundProvider', + 'doProvideSetting', + ]); + + constructor(private readonly manager: AppManager) {} + + // --- Slash commands (AppSlashCommandManager) --- + + public async doProvideSlashCommand(command: ISlashCommand, appId: string): Promise { + await this.manager.getCommandManager().addCommand(appId, command); + } + + public async doModifySlashCommand(command: ISlashCommand, appId: string): Promise { + await this.manager.getCommandManager().modifyCommand(appId, command); + } + + public async doEnableSlashCommand(command: string, appId: string): Promise { + await this.manager.getCommandManager().enableCommand(appId, command); + } + + public async doDisableSlashCommand(command: string, appId: string): Promise { + await this.manager.getCommandManager().disableCommand(appId, command); + } + + // --- HTTP APIs (AppApiManager) --- + + public async doProvideApi(api: IApi, appId: string): Promise { + this.manager.getApiManager().addApi(appId, api); + } + + public async doListApis(appId: string): Promise> { + return this.manager.getApiManager().listApis(appId); + } + + // --- Scheduler (AppSchedulerManager) --- + + public async doRegisterProcessors(processors: Array, appId: string): Promise> { + return this.manager.getSchedulerManager().registerProcessors(processors, appId); + } + + // --- UI action buttons (UIActionButtonManager) --- + + public async doRegisterActionButton(button: IUIActionButtonDescriptor, appId: string): Promise { + this.manager.getUIActionButtonManager().registerActionButton(appId, button); + } + + // --- External components (AppExternalComponentManager) --- + + public async doRegisterExternalComponent(externalComponent: IExternalComponent, appId: string): Promise { + this.manager.getExternalComponentManager().addExternalComponent(appId, externalComponent); + } + + // --- Video conference providers (AppVideoConfProviderManager) --- + + public async doProvideVideoConfProvider(provider: IVideoConfProvider, appId: string): Promise { + this.manager.getVideoConfProviderManager().addProvider(appId, provider); + } + + // --- Outbound message providers (AppOutboundCommunicationProviderManager) --- + + public async doRegisterOutboundProvider(provider: IOutboundMessageProviders, appId: string): Promise { + this.manager.getOutboundCommunicationProviderManager().addProvider(appId, provider); + } + + // --- App settings (ProxiedApp storage item + AppSettingsManager) --- + + /** Mirrors the host `SettingsExtend.provideSetting`. */ + public async doProvideSetting(setting: ISetting, appId: string): Promise { + const storageItem = this.manager.getOneById(appId).getStorageItem(); + const existing = storageItem.settings[setting.id]; + + if (existing) { + setting.createdAt = existing.createdAt; + setting.updatedAt = new Date(); + setting.value = existing.value; + + storageItem.settings[setting.id] = setting; + + return; + } + + setting.createdAt = new Date(); + setting.updatedAt = new Date(); + storageItem.settings[setting.id] = setting; + } + + /** Mirrors the host `SettingRead.getById`. */ + public async doGetSettingById(id: string, appId: string): Promise { + return this.manager.getOneById(appId).getStorageItem().settings[id]; + } + + /** Mirrors the host `SettingUpdater.updateValue`. */ + public async doUpdateSettingValue(id: ISetting['id'], value: ISetting['value'], appId: string): Promise { + const storageItem = this.manager.getOneById(appId).getStorageItem(); + + if (!storageItem.settings?.[id]) { + throw new Error(`Setting "${id}" not found for app ${appId}`); + } + + const setting = this.manager.getSettingsManager().getAppSetting(appId, id); + + await this.manager.getSettingsManager().updateAppSetting(appId, { + ...setting, + updatedAt: new Date(), + value, + }); + } + + /** Mirrors the host `SettingUpdater.updateSelectOptions`. */ + public async doUpdateSettingSelectOptions(id: ISetting['id'], values: ISetting['values'], appId: string): Promise { + const storageItem = this.manager.getOneById(appId).getStorageItem(); + + if (!storageItem.settings?.[id]) { + throw new Error(`Setting "${id}" not found for app ${appId}`); + } + + const setting = this.manager.getSettingsManager().getAppSetting(appId, id); + + await this.manager.getSettingsManager().updateAppSetting(appId, { + ...setting, + updatedAt: new Date(), + values, + }); + } +} diff --git a/packages/apps/src/server/runtime/base/BaseRuntimeSubprocessController.ts b/packages/apps/src/server/runtime/base/BaseRuntimeSubprocessController.ts index 31dd7364e8d2e..92f1c22458af9 100644 --- a/packages/apps/src/server/runtime/base/BaseRuntimeSubprocessController.ts +++ b/packages/apps/src/server/runtime/base/BaseRuntimeSubprocessController.ts @@ -14,6 +14,7 @@ import { bundleLegacyApp } from './bundler'; import { newDecoder } from './codec'; import type { AppManager } from '../../AppManager'; import type { AppBridges } from '../../bridges'; +import { AppResourceBridge } from '../../bridges/AppResourceBridge'; import type { IParseAppPackageResult } from '../../compiler'; import { AppConsole, type ILoggerStorageEntry } from '../../logging'; import type { AppAccessorManager, AppApiManager } from '../../managers'; @@ -122,6 +123,8 @@ export abstract class BaseRuntimeSubprocessController extends EventEmitter imple private readonly bridges: AppBridges; + private readonly appResourceBridge: AppResourceBridge; + private readonly messenger: ProcessMessenger; private readonly livenessManager: LivenessManager; @@ -157,6 +160,7 @@ export abstract class BaseRuntimeSubprocessController extends EventEmitter imple this.api = manager.getApiManager(); this.logStorage = manager.getLogStorage(); this.bridges = manager.getBridges(); + this.appResourceBridge = new AppResourceBridge(manager); } /** @@ -525,15 +529,32 @@ export abstract class BaseRuntimeSubprocessController extends EventEmitter imple this.debug('Handling bridge message %s().%s() with params %s', bridgeName, bridgeMethod, inspect(params)); - const bridge = this.bridges[bridgeName as keyof typeof this.bridges]; - - if (!bridgeMethod.startsWith('do') || typeof bridge !== 'function' || !Array.isArray(params)) { + if (!bridgeMethod.startsWith('do') || !Array.isArray(params)) { throw new Error('Invalid bridge request'); } - const bridgeInstance = bridge.call(this.bridges); + let bridgeInstance: unknown; + + // The internal AppResourceBridge is not part of the app-facing `AppBridges` surface; it is + // resolved through its own reference. Registration methods are suppressed while the process + // is restarting so that re-running `app:initialize` does not re-register host resources. + if (bridgeName === 'getAppResourceBridge') { + if (this.state === 'restarting' && AppResourceBridge.REGISTRATION_METHODS.has(bridgeMethod)) { + return jsonrpc.success(id, null); + } + + bridgeInstance = this.appResourceBridge; + } else { + const bridge = this.bridges[bridgeName as keyof typeof this.bridges]; + + if (typeof bridge !== 'function') { + throw new Error('Invalid bridge request'); + } + + bridgeInstance = bridge.call(this.bridges); + } - const methodRef = bridgeInstance[bridgeMethod as keyof typeof bridge] as unknown; + const methodRef = (bridgeInstance as Record)[bridgeMethod] as unknown; if (typeof methodRef !== 'function') { throw new Error('Invalid bridge request');