From 4632b6b4da40415b6f45724b09aceff13174d7f8 Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Tue, 9 Sep 2025 22:41:45 +0300 Subject: [PATCH] feat(catalog): change extension point to inteface ignore other than user entity references instead error Signed-off-by: Hellgren Heikki --- .changeset/twenty-shoes-tickle.md | 9 +- ...aultNotificationRecipientResolver.test.ts} | 111 ++++++++--- .../DefaultNotificationRecipientResolver.ts | 177 ++++++++++++++++++ .../src/service/getUsersForEntityRef.ts | 159 ---------------- .../src/service/router.test.ts | 23 ++- .../src/service/router.ts | 69 ++++--- plugins/notifications-node/report.api.md | 13 +- plugins/notifications-node/src/extensions.ts | 19 +- 8 files changed, 335 insertions(+), 245 deletions(-) rename plugins/notifications-backend/src/service/{getUsersForEntityRef.test.ts => DefaultNotificationRecipientResolver.test.ts} (58%) create mode 100644 plugins/notifications-backend/src/service/DefaultNotificationRecipientResolver.ts delete mode 100644 plugins/notifications-backend/src/service/getUsersForEntityRef.ts diff --git a/.changeset/twenty-shoes-tickle.md b/.changeset/twenty-shoes-tickle.md index d13d93adde..da067d1ec9 100644 --- a/.changeset/twenty-shoes-tickle.md +++ b/.changeset/twenty-shoes-tickle.md @@ -3,7 +3,10 @@ '@backstage/plugin-notifications-node': patch --- -A new extension point method was added that can be used to modify how the users receiving notifications -are resolved. The function passed to the extension point should only return complete user entity references -based on the notification target references and the excluded entity references. Note that the input is a list +A new extension point was added that can be used to modify how the users receiving notifications +are resolved. The interface passed to the extension point should only return complete user entity references +based on the notification target references and the excluded entity references. Note that the inputs are lists of entity references that can be any entity kind, not just user entities. + +Using this extension point will override the default behavior of resolving users with the +`DefaultNotificationRecipientResolver`. diff --git a/plugins/notifications-backend/src/service/getUsersForEntityRef.test.ts b/plugins/notifications-backend/src/service/DefaultNotificationRecipientResolver.test.ts similarity index 58% rename from plugins/notifications-backend/src/service/getUsersForEntityRef.test.ts rename to plugins/notifications-backend/src/service/DefaultNotificationRecipientResolver.test.ts index 838ded5c18..cd17db3680 100644 --- a/plugins/notifications-backend/src/service/getUsersForEntityRef.test.ts +++ b/plugins/notifications-backend/src/service/DefaultNotificationRecipientResolver.test.ts @@ -15,33 +15,28 @@ */ import { mockServices } from '@backstage/backend-test-utils'; -import { getUsersForEntityRef } from './getUsersForEntityRef'; import { RELATION_HAS_MEMBER, RELATION_OWNED_BY, RELATION_PARENT_OF, } from '@backstage/catalog-model'; import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { DefaultNotificationRecipientResolver } from './DefaultNotificationRecipientResolver.ts'; describe('getUsersForEntityRef', () => { - it('should return empty array if entityRef is null', async () => { - await expect( - getUsersForEntityRef(null, [], { - auth: mockServices.auth(), - catalog: catalogServiceMock(), - }), - ).resolves.toEqual([]); - }); - it('should resolve users without calling catalog', async () => { const catalog = catalogServiceMock(); jest.spyOn(catalog, 'getEntitiesByRefs'); + const resolver = new DefaultNotificationRecipientResolver( + mockServices.auth(), + catalog, + ); await expect( - getUsersForEntityRef(['user:foo', 'user:ignored'], ['user:ignored'], { - auth: mockServices.auth(), - catalog, + resolver.resolveNotificationRecipients({ + entityRefs: ['user:foo', 'user:ignored'], + excludeEntityRefs: ['user:ignored'], }), - ).resolves.toEqual(['user:foo']); + ).resolves.toEqual({ userEntityRefs: ['user:foo'] }); expect(catalog.getEntitiesByRefs).not.toHaveBeenCalled(); }); @@ -85,16 +80,18 @@ describe('getUsersForEntityRef', () => { ], }); + const resolver = new DefaultNotificationRecipientResolver( + mockServices.auth(), + catalog, + ); await expect( - getUsersForEntityRef( - 'group:default/parent_group', - ['user:default/ignored'], - { - auth: mockServices.auth(), - catalog, - }, - ), - ).resolves.toEqual(['user:default/foo', 'user:default/bar']); + resolver.resolveNotificationRecipients({ + entityRefs: ['group:default/parent_group'], + excludeEntityRefs: ['user:default/ignored'], + }), + ).resolves.toEqual({ + userEntityRefs: ['user:default/foo', 'user:default/bar'], + }); }); it('should resolve user owner of entity from entity ref', async () => { @@ -116,12 +113,16 @@ describe('getUsersForEntityRef', () => { ], }); + const resolver = new DefaultNotificationRecipientResolver( + mockServices.auth(), + catalog, + ); await expect( - getUsersForEntityRef('component:default/test_component', [], { - auth: mockServices.auth(), - catalog, + resolver.resolveNotificationRecipients({ + entityRefs: ['component:default/test_component'], + excludeEntityRefs: [], }), - ).resolves.toEqual(['user:default/foo']); + ).resolves.toEqual({ userEntityRefs: ['user:default/foo'] }); }); it('should resolve group owner of entity from entity ref', async () => { @@ -156,11 +157,59 @@ describe('getUsersForEntityRef', () => { ], }); + const resolver = new DefaultNotificationRecipientResolver( + mockServices.auth(), + catalog, + ); await expect( - getUsersForEntityRef('component:default/test_component', [], { - auth: mockServices.auth(), - catalog, + resolver.resolveNotificationRecipients({ + entityRefs: ['component:default/test_component'], + excludeEntityRefs: [], }), - ).resolves.toEqual(['user:default/foo']); + ).resolves.toEqual({ userEntityRefs: ['user:default/foo'] }); + }); + + it('should filter excluded refs after resolving', async () => { + const catalog = catalogServiceMock({ + entities: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test_component', + }, + relations: [ + { + type: RELATION_OWNED_BY, + targetRef: 'group:default/owner_group', + }, + ], + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'owner_group', + }, + relations: [ + { + type: RELATION_HAS_MEMBER, + targetRef: 'user:default/foo', + }, + ], + }, + ], + }); + + const resolver = new DefaultNotificationRecipientResolver( + mockServices.auth(), + catalog, + ); + await expect( + resolver.resolveNotificationRecipients({ + entityRefs: ['component:default/test_component'], + excludeEntityRefs: ['user:default/foo'], + }), + ).resolves.toEqual({ userEntityRefs: [] }); }); }); diff --git a/plugins/notifications-backend/src/service/DefaultNotificationRecipientResolver.ts b/plugins/notifications-backend/src/service/DefaultNotificationRecipientResolver.ts new file mode 100644 index 0000000000..2401ec7c1f --- /dev/null +++ b/plugins/notifications-backend/src/service/DefaultNotificationRecipientResolver.ts @@ -0,0 +1,177 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Entity, + isGroupEntity, + isUserEntity, + parseEntityRef, + RELATION_HAS_MEMBER, + RELATION_OWNED_BY, + RELATION_PARENT_OF, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { AuthService } from '@backstage/backend-plugin-api'; +import { CatalogService } from '@backstage/plugin-catalog-node'; +import { NotificationRecipientResolver } from '@backstage/plugin-notifications-node'; + +const isUserEntityRef = (ref: string) => + parseEntityRef(ref).kind.toLocaleLowerCase() === 'user'; + +// Partitions array of entity references to two arrays; user entity refs and other entity refs +const partitionEntityRefs = (refs: string[]): string[][] => { + const ret = [[], []] as string[][]; + for (const ref of refs) { + if (isUserEntityRef(ref)) { + ret[0].push(ref); + } else { + ret[1].push(ref); + } + } + return ret; +}; + +export class DefaultNotificationRecipientResolver + implements NotificationRecipientResolver +{ + constructor( + private readonly auth: AuthService, + private readonly catalog: CatalogService, + ) {} + + async resolveNotificationRecipients(options: { + entityRefs: string[]; + excludeEntityRefs?: string[]; + }): Promise<{ userEntityRefs: string[] }> { + const { entityRefs, excludeEntityRefs = [] } = options; + + const [userEntityRefs, otherEntityRefs] = partitionEntityRefs(entityRefs); + const users: string[] = userEntityRefs.filter( + ref => !excludeEntityRefs.includes(ref), + ); + const filtered = otherEntityRefs.filter( + ref => !excludeEntityRefs.includes(ref), + ); + + const fields = ['kind', 'metadata.name', 'metadata.namespace', 'relations']; + let entities: Array = []; + if (filtered.length > 0) { + const fetchedEntities = await this.catalog.getEntitiesByRefs( + { + entityRefs: filtered, + fields, + }, + { credentials: await this.auth.getOwnServiceCredentials() }, + ); + entities = fetchedEntities.items; + } + + const cachedEntityRefs = new Map(); + + const mapEntity = async (entity: Entity | undefined): Promise => { + if (!entity) { + return []; + } + + const currentEntityRef = stringifyEntityRef(entity); + if (excludeEntityRefs.includes(currentEntityRef)) { + return []; + } + + if (cachedEntityRefs.has(currentEntityRef)) { + return cachedEntityRefs.get(currentEntityRef)!; + } + + if (isUserEntity(entity)) { + return [currentEntityRef]; + } + + if (isGroupEntity(entity)) { + if (!entity.relations?.length) { + return []; + } + + const groupUsers = entity.relations + .filter( + relation => + relation.type === RELATION_HAS_MEMBER && + isUserEntityRef(relation.targetRef), + ) + .map(r => r.targetRef); + + const childGroupRefs = entity.relations + .filter(relation => relation.type === RELATION_PARENT_OF) + .map(r => r.targetRef); + + let childGroupUsers: string[][] = []; + if (childGroupRefs.length > 0) { + const childGroups = await this.catalog.getEntitiesByRefs( + { + entityRefs: childGroupRefs, + fields, + }, + { credentials: await this.auth.getOwnServiceCredentials() }, + ); + childGroupUsers = await Promise.all(childGroups.items.map(mapEntity)); + } + + const ret = [ + ...new Set([...groupUsers, ...childGroupUsers.flat(2)]), + ].filter(ref => !excludeEntityRefs.includes(ref)); + cachedEntityRefs.set(currentEntityRef, ret); + return ret; + } + + if (entity.relations?.length) { + const ownerRef = entity.relations.find( + relation => relation.type === RELATION_OWNED_BY, + )?.targetRef; + + if (!ownerRef) { + return []; + } + + if (isUserEntityRef(ownerRef)) { + if (excludeEntityRefs.includes(ownerRef)) { + return []; + } + return [ownerRef]; + } + + const owner = await this.catalog.getEntityByRef(ownerRef, { + credentials: await this.auth.getOwnServiceCredentials(), + }); + const ret = await mapEntity(owner); + cachedEntityRefs.set(currentEntityRef, ret); + return ret; + } + + return []; + }; + + for (const entity of entities) { + const u = await mapEntity(entity); + users.push(...u); + } + + return { + userEntityRefs: [...new Set(users)] + .filter(Boolean) + // Need to filter again after resolving users + .filter(ref => !excludeEntityRefs.includes(ref)), + }; + } +} diff --git a/plugins/notifications-backend/src/service/getUsersForEntityRef.ts b/plugins/notifications-backend/src/service/getUsersForEntityRef.ts deleted file mode 100644 index 2fa1412992..0000000000 --- a/plugins/notifications-backend/src/service/getUsersForEntityRef.ts +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - Entity, - isGroupEntity, - isUserEntity, - parseEntityRef, - RELATION_HAS_MEMBER, - RELATION_OWNED_BY, - RELATION_PARENT_OF, - stringifyEntityRef, -} from '@backstage/catalog-model'; -import { AuthService } from '@backstage/backend-plugin-api'; -import { CatalogService } from '@backstage/plugin-catalog-node'; - -const isUserEntityRef = (ref: string) => - parseEntityRef(ref).kind.toLocaleLowerCase() === 'user'; - -// Partitions array of entity references to two arrays; user entity refs and other entity refs -const partitionEntityRefs = (refs: string[]): string[][] => - refs.reduce( - ([userEntityRefs, otherEntityRefs]: string[][], ref: string) => { - return isUserEntityRef(ref) - ? [[...userEntityRefs, ref], otherEntityRefs] - : [userEntityRefs, [...otherEntityRefs, ref]]; - }, - [[], []], - ); - -export const getUsersForEntityRef = async ( - entityRef: string | string[] | null, - excludeEntityRefs: string | string[], - options: { - auth: AuthService; - catalog: CatalogService; - }, -): Promise => { - const { auth, catalog } = options; - - if (entityRef === null) { - return []; - } - - const excluded = Array.isArray(excludeEntityRefs) - ? excludeEntityRefs - : [excludeEntityRefs]; - - const refsArr = Array.isArray(entityRef) ? entityRef : [entityRef]; - const [userEntityRefs, otherEntityRefs] = partitionEntityRefs(refsArr); - const users: string[] = userEntityRefs.filter(ref => !excluded.includes(ref)); - const entityRefs = otherEntityRefs.filter(ref => !excluded.includes(ref)); - - const fields = ['kind', 'metadata.name', 'metadata.namespace', 'relations']; - let entities: Array = []; - if (entityRefs.length > 0) { - const fetchedEntities = await catalog.getEntitiesByRefs( - { - entityRefs, - fields, - }, - { credentials: await auth.getOwnServiceCredentials() }, - ); - entities = fetchedEntities.items; - } - - const mapEntity = async (entity: Entity | undefined): Promise => { - if (!entity) { - return []; - } - - const currentEntityRef = stringifyEntityRef(entity); - if (excluded.includes(currentEntityRef)) { - return []; - } - - if (isUserEntity(entity)) { - return [currentEntityRef]; - } - - if (isGroupEntity(entity)) { - if (!entity.relations?.length) { - return []; - } - - const groupUsers = entity.relations - .filter( - relation => - relation.type === RELATION_HAS_MEMBER && - isUserEntityRef(relation.targetRef), - ) - .map(r => r.targetRef); - - const childGroupRefs = entity.relations - .filter(relation => relation.type === RELATION_PARENT_OF) - .map(r => r.targetRef); - - let childGroupUsers: string[][] = []; - if (childGroupRefs.length > 0) { - const childGroups = await catalog.getEntitiesByRefs( - { - entityRefs: childGroupRefs, - fields, - }, - { credentials: await auth.getOwnServiceCredentials() }, - ); - childGroupUsers = await Promise.all(childGroups.items.map(mapEntity)); - } - - return [...groupUsers, ...childGroupUsers.flat(2)].filter( - ref => !excluded.includes(ref), - ); - } - - if (entity.relations?.length) { - const ownerRef = entity.relations.find( - relation => relation.type === RELATION_OWNED_BY, - )?.targetRef; - - if (!ownerRef) { - return []; - } - - if (isUserEntityRef(ownerRef)) { - if (excluded.includes(ownerRef)) { - return []; - } - return [ownerRef]; - } - - const owner = await catalog.getEntityByRef(ownerRef, { - credentials: await auth.getOwnServiceCredentials(), - }); - return mapEntity(owner); - } - - return []; - }; - - for (const entity of entities) { - const u = await mapEntity(entity); - users.push(...u); - } - - return [...new Set(users)].filter(Boolean); -}; diff --git a/plugins/notifications-backend/src/service/router.test.ts b/plugins/notifications-backend/src/service/router.test.ts index c66331c278..08aa021014 100644 --- a/plugins/notifications-backend/src/service/router.test.ts +++ b/plugins/notifications-backend/src/service/router.test.ts @@ -25,7 +25,10 @@ import { TestDatabaseId, TestDatabases, } from '@backstage/backend-test-utils'; -import { NotificationSendOptions } from '@backstage/plugin-notifications-node'; +import { + NotificationRecipientResolver, + NotificationSendOptions, +} from '@backstage/plugin-notifications-node'; import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; import { DatabaseService } from '@backstage/backend-plugin-api'; import { v4 as uuid } from 'uuid'; @@ -506,7 +509,10 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => { defaultCredentials: mockCredentials.service(), }); - const recipientResolver = jest.fn(); + const resolveFn = jest.fn(); + const recipientResolver: NotificationRecipientResolver = { + resolveNotificationRecipients: resolveFn, + }; beforeAll(async () => { const router = await createRouter({ @@ -539,7 +545,9 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => { .set('Accept', 'application/json'); it('should use custom recipient resolver', async () => { - recipientResolver.mockResolvedValue(['user:default/mock']); + resolveFn.mockResolvedValue({ + userEntityRefs: ['user:default/mock'], + }); const response = await sendNotification({ recipients: { type: 'entity', @@ -571,8 +579,10 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => { expect(notifications).toHaveLength(1); }); - it('should return error if recipient resolver returns something other than an array of user entity refs', async () => { - recipientResolver.mockResolvedValue(['system:default/mock']); + it('should ignore if recipient resolver returns something other than an array of user entity refs', async () => { + resolveFn.mockResolvedValue({ + userEntityRefs: ['system:default/mock'], + }); const response = await sendNotification({ recipients: { type: 'entity', @@ -582,7 +592,8 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => { title: 'test notification', }, }); - expect(response.status).toEqual(400); + expect(response.status).toEqual(200); + expect(response.body).toEqual([]); }); }); diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 51faf66279..027d6a1732 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -49,11 +49,11 @@ import { OriginSetting, } from '@backstage/plugin-notifications-common'; import { parseEntityOrderFieldParams } from './parseEntityOrderFieldParams'; -import { getUsersForEntityRef } from './getUsersForEntityRef'; import { Config, readDurationFromConfig } from '@backstage/config'; import { durationToMilliseconds } from '@backstage/types'; import pThrottle from 'p-throttle'; import { parseEntityRef } from '@backstage/catalog-model'; +import { DefaultNotificationRecipientResolver } from './DefaultNotificationRecipientResolver.ts'; /** @internal */ export interface RouterOptions { @@ -104,6 +104,10 @@ export async function createRouter( const defaultNotificationSettings: NotificationSettings | undefined = config.getOptional('notifications.defaultSettings'); + const usedRecipientResolver = + recipientResolver ?? + new DefaultNotificationRecipientResolver(auth, catalog); + const getUser = async (req: Request) => { const credentials = await httpAuth.credentials(req, { allow: ['user'] }); const info = await userInfo.getUserInfo(credentials); @@ -637,25 +641,25 @@ export async function createRouter( return ret; }; + const filterNonUserEntityRefs = (refs: string[]): string[] => { + return refs.filter(ref => { + try { + const parsed = parseEntityRef(ref); + return parsed.kind.toLowerCase() === 'user'; + } catch { + return false; + } + }); + }; + const sendUserNotifications = async ( baseNotification: Omit, users: string[], opts: NotificationSendOptions, origin: string, ): Promise => { - if ( - users.find(u => { - const compound = parseEntityRef(u); - return compound.kind.toLocaleLowerCase('en-US') !== 'user'; - }) - ) { - throw new InputError( - 'Invalid user entity reference provided in recipients', - ); - } - const { scope } = opts.payload; - const uniqueUsers = [...new Set(users)]; + const uniqueUsers = [...new Set(filterNonUserEntityRefs(users))]; const throttled = throttle((user: string) => sendUserNotification(baseNotification, user, opts, origin, scope), ); @@ -676,7 +680,6 @@ export async function createRouter( const { recipients, payload } = opts; const { title, link } = payload; const notifications: Notification[] = []; - let users = []; if (!recipients || !title) { const missing = [ @@ -714,30 +717,26 @@ export async function createRouter( ); notifications.push(broadcast); } else if (recipients.type === 'entity') { - const entityRef = recipients.entityRef; - + const entityRefs = [recipients.entityRef].flat(); + const excludedEntityRefs = recipients.excludeEntityRef + ? [recipients.excludeEntityRef].flat() + : undefined; try { - users = recipientResolver - ? await recipientResolver( - entityRef, - recipients.excludeEntityRef ?? [], - ) - : await getUsersForEntityRef( - entityRef, - recipients.excludeEntityRef ?? [], - { auth, catalog }, - ); + const { userEntityRefs } = + await usedRecipientResolver.resolveNotificationRecipients({ + entityRefs, + excludedEntityRefs, + }); + const userNotifications = await sendUserNotifications( + baseNotification, + userEntityRefs, + opts, + origin, + ); + notifications.push(...userNotifications); } catch (e) { - throw new InputError('Failed to resolve notification receivers', e); + throw new InputError('Failed to send user notifications', e); } - - const userNotifications = await sendUserNotifications( - baseNotification, - users, - opts, - origin, - ); - notifications.push(...userNotifications); } else { throw new InputError( `Invalid recipients type, please use either 'broadcast' or 'entity'`, diff --git a/plugins/notifications-node/report.api.md b/plugins/notifications-node/report.api.md index 6b424e0fa5..ced4fac94e 100644 --- a/plugins/notifications-node/report.api.md +++ b/plugins/notifications-node/report.api.md @@ -42,10 +42,15 @@ export interface NotificationProcessor { export type NotificationProcessorFilters = NotificationProcessorFilters_2; // @public -export type NotificationRecipientResolver = ( - entityRef: string | string[] | null, - excludeEntityRefs: string | string[], -) => Promise; +export interface NotificationRecipientResolver { + // (undocumented) + resolveNotificationRecipients(options: { + entityRefs: string[]; + excludedEntityRefs?: string[]; + }): Promise<{ + userEntityRefs: string[]; + }>; +} // @public (undocumented) export type NotificationRecipients = diff --git a/plugins/notifications-node/src/extensions.ts b/plugins/notifications-node/src/extensions.ts index 1e525a8d2b..98338de221 100644 --- a/plugins/notifications-node/src/extensions.ts +++ b/plugins/notifications-node/src/extensions.ts @@ -101,17 +101,22 @@ export interface NotificationProcessor { } /** - * NotificationRecipientResolver is a function that resolves the individual users to receive the notification - * based on the entity reference(s) and the excluded entity reference(s). + * NotificationRecipientResolver interface is used to resolve the individual + * users to receive the notification. * - * The function should return a list of user entity references that should receive the notification. + * The `resolveNotificationRecipients` is used to resolve notifications sent for + * entity references, and it should return object with a list of user + * entity references that should receive the notification. In case the function + * returns other than user entity references, those are ignored. * * @public */ -export type NotificationRecipientResolver = ( - entityRef: string | string[] | null, - excludeEntityRefs: string | string[], -) => Promise; +export interface NotificationRecipientResolver { + resolveNotificationRecipients(options: { + entityRefs: string[]; + excludedEntityRefs?: string[]; + }): Promise<{ userEntityRefs: string[] }>; +} /** * @public