diff --git a/packages/core/src/agent/Agent.ts b/packages/core/src/agent/Agent.ts index f0944078ff..50e00f6610 100644 --- a/packages/core/src/agent/Agent.ts +++ b/packages/core/src/agent/Agent.ts @@ -38,6 +38,7 @@ import { Dispatcher } from './Dispatcher' import { EnvelopeService } from './EnvelopeService' import { EventEmitter } from './EventEmitter' import { AgentEventTypes } from './Events' +import { FeatureRegistry } from './FeatureRegistry' import { MessageReceiver } from './MessageReceiver' import { MessageSender } from './MessageSender' import { TransportService } from './TransportService' @@ -92,6 +93,13 @@ export class Agent extends BaseAgent { return this.eventEmitter } + /** + * Agent's feature registry + */ + public get features() { + return this.featureRegistry + } + public async initialize() { await super.initialize() @@ -156,6 +164,7 @@ export class Agent extends BaseAgent { dependencyManager.registerSingleton(TransportService) dependencyManager.registerSingleton(Dispatcher) dependencyManager.registerSingleton(EnvelopeService) + dependencyManager.registerSingleton(FeatureRegistry) dependencyManager.registerSingleton(JwsService) dependencyManager.registerSingleton(CacheRepository) dependencyManager.registerSingleton(DidCommMessageRepository) diff --git a/packages/core/src/agent/BaseAgent.ts b/packages/core/src/agent/BaseAgent.ts index c18f937f25..ed407c881d 100644 --- a/packages/core/src/agent/BaseAgent.ts +++ b/packages/core/src/agent/BaseAgent.ts @@ -23,6 +23,7 @@ import { WalletApi } from '../wallet/WalletApi' import { WalletError } from '../wallet/error' import { EventEmitter } from './EventEmitter' +import { FeatureRegistry } from './FeatureRegistry' import { MessageReceiver } from './MessageReceiver' import { MessageSender } from './MessageSender' import { TransportService } from './TransportService' @@ -33,6 +34,7 @@ export abstract class BaseAgent { protected logger: Logger public readonly dependencyManager: DependencyManager protected eventEmitter: EventEmitter + protected featureRegistry: FeatureRegistry protected messageReceiver: MessageReceiver protected transportService: TransportService protected messageSender: MessageSender @@ -75,6 +77,7 @@ export abstract class BaseAgent { // Resolve instances after everything is registered this.eventEmitter = this.dependencyManager.resolve(EventEmitter) + this.featureRegistry = this.dependencyManager.resolve(FeatureRegistry) this.messageSender = this.dependencyManager.resolve(MessageSender) this.messageReceiver = this.dependencyManager.resolve(MessageReceiver) this.transportService = this.dependencyManager.resolve(TransportService) diff --git a/packages/core/src/agent/FeatureRegistry.ts b/packages/core/src/agent/FeatureRegistry.ts new file mode 100644 index 0000000000..bfcf3e5f8c --- /dev/null +++ b/packages/core/src/agent/FeatureRegistry.ts @@ -0,0 +1,56 @@ +import type { FeatureQuery, Feature } from './models' + +import { injectable } from 'tsyringe' + +@injectable() +class FeatureRegistry { + private features: Feature[] = [] + + /** + * Register a single or set of Features on the registry + * + * @param features set of {Feature} objects or any inherited class + */ + public register(...features: Feature[]) { + for (const feature of features) { + const index = this.features.findIndex((item) => item.type === feature.type && item.id === feature.id) + + if (index > -1) { + this.features[index] = this.features[index].combine(feature) + } else { + this.features.push(feature) + } + } + } + + /** + * Perform a set of queries in the registry, supporting wildcards (*) as + * expressed in Aries RFC 0557. + * + * @see https://github.com/hyperledger/aries-rfcs/blob/560ffd23361f16a01e34ccb7dcc908ec28c5ddb1/features/0557-discover-features-v2/README.md + * + * @param queries set of {FeatureQuery} objects to query features + * @returns array containing all matching features (can be empty) + */ + public query(...queries: FeatureQuery[]) { + const output = [] + for (const query of queries) { + const items = this.features.filter((item) => item.type === query.featureType) + // An * will return all features of a given type (e.g. all protocols, all goal codes, all AIP configs) + if (query.match === '*') { + output.push(...items) + // An string ending with * will return a family of features of a certain type + // (e.g. all versions of a given protocol, all subsets of an AIP, etc.) + } else if (query.match.endsWith('*')) { + const match = query.match.slice(0, -1) + output.push(...items.filter((m) => m.id.startsWith(match))) + // Exact matching (single feature) + } else { + output.push(...items.filter((m) => m.id === query.match)) + } + } + return output + } +} + +export { FeatureRegistry } diff --git a/packages/core/src/agent/__tests__/Agent.test.ts b/packages/core/src/agent/__tests__/Agent.test.ts index c890630ca9..8e33e303ff 100644 --- a/packages/core/src/agent/__tests__/Agent.test.ts +++ b/packages/core/src/agent/__tests__/Agent.test.ts @@ -27,6 +27,7 @@ import { WalletError } from '../../wallet/error' import { Agent } from '../Agent' import { Dispatcher } from '../Dispatcher' import { EnvelopeService } from '../EnvelopeService' +import { FeatureRegistry } from '../FeatureRegistry' import { MessageReceiver } from '../MessageReceiver' import { MessageSender } from '../MessageSender' @@ -194,7 +195,36 @@ describe('Agent', () => { expect(container.resolve(MessageSender)).toBe(container.resolve(MessageSender)) expect(container.resolve(MessageReceiver)).toBe(container.resolve(MessageReceiver)) expect(container.resolve(Dispatcher)).toBe(container.resolve(Dispatcher)) + expect(container.resolve(FeatureRegistry)).toBe(container.resolve(FeatureRegistry)) expect(container.resolve(EnvelopeService)).toBe(container.resolve(EnvelopeService)) }) }) + + it('all core features are properly registered', () => { + const agent = new Agent(config, dependencies) + const registry = agent.dependencyManager.resolve(FeatureRegistry) + + const protocols = registry.query({ featureType: 'protocol', match: '*' }).map((p) => p.id) + + expect(protocols).toEqual( + expect.arrayContaining([ + 'https://didcomm.org/basicmessage/1.0', + 'https://didcomm.org/connections/1.0', + 'https://didcomm.org/coordinate-mediation/1.0', + 'https://didcomm.org/didexchange/1.0', + 'https://didcomm.org/discover-features/1.0', + 'https://didcomm.org/discover-features/2.0', + 'https://didcomm.org/issue-credential/1.0', + 'https://didcomm.org/issue-credential/2.0', + 'https://didcomm.org/messagepickup/1.0', + 'https://didcomm.org/messagepickup/2.0', + 'https://didcomm.org/out-of-band/1.1', + 'https://didcomm.org/present-proof/1.0', + 'https://didcomm.org/revocation_notification/1.0', + 'https://didcomm.org/revocation_notification/2.0', + 'https://didcomm.org/questionanswer/1.0', + ]) + ) + expect(protocols.length).toEqual(15) + }) }) diff --git a/packages/core/src/agent/models/features/Feature.ts b/packages/core/src/agent/models/features/Feature.ts new file mode 100644 index 0000000000..1a5b3e461c --- /dev/null +++ b/packages/core/src/agent/models/features/Feature.ts @@ -0,0 +1,58 @@ +import { Expose } from 'class-transformer' +import { IsString } from 'class-validator' + +import { AriesFrameworkError } from '../../../error' +import { JsonTransformer } from '../../../utils/JsonTransformer' + +export interface FeatureOptions { + id: string + type: string +} + +export class Feature { + public id!: string + + public constructor(props: FeatureOptions) { + if (props) { + this.id = props.id + this.type = props.type + } + } + + @IsString() + @Expose({ name: 'feature-type' }) + public readonly type!: string + + /** + * Combine this feature with another one, provided both are from the same type + * and have the same id + * + * @param feature object to combine with this one + * @returns a new object resulting from the combination between this and feature + */ + public combine(feature: this) { + if (feature.id !== this.id) { + throw new AriesFrameworkError('Can only combine with a feature with the same id') + } + + const obj1 = JsonTransformer.toJSON(this) + const obj2 = JsonTransformer.toJSON(feature) + + for (const key in obj2) { + try { + if (Array.isArray(obj2[key])) { + obj1[key] = [...new Set([...obj1[key], ...obj2[key]])] + } else { + obj1[key] = obj2[key] + } + } catch (e) { + obj1[key] = obj2[key] + } + } + return JsonTransformer.fromJSON(obj1, Feature) + } + + public toJSON(): Record { + return JsonTransformer.toJSON(this) + } +} diff --git a/packages/core/src/agent/models/features/FeatureQuery.ts b/packages/core/src/agent/models/features/FeatureQuery.ts new file mode 100644 index 0000000000..aab269b5db --- /dev/null +++ b/packages/core/src/agent/models/features/FeatureQuery.ts @@ -0,0 +1,23 @@ +import { Expose } from 'class-transformer' +import { IsString } from 'class-validator' + +export interface FeatureQueryOptions { + featureType: string + match: string +} + +export class FeatureQuery { + public constructor(options: FeatureQueryOptions) { + if (options) { + this.featureType = options.featureType + this.match = options.match + } + } + + @Expose({ name: 'feature-type' }) + @IsString() + public featureType!: string + + @IsString() + public match!: string +} diff --git a/packages/core/src/agent/models/features/GoalCode.ts b/packages/core/src/agent/models/features/GoalCode.ts new file mode 100644 index 0000000000..71a0b9fdd3 --- /dev/null +++ b/packages/core/src/agent/models/features/GoalCode.ts @@ -0,0 +1,13 @@ +import type { FeatureOptions } from './Feature' + +import { Feature } from './Feature' + +export type GoalCodeOptions = Omit + +export class GoalCode extends Feature { + public constructor(props: GoalCodeOptions) { + super({ ...props, type: GoalCode.type }) + } + + public static readonly type = 'goal-code' +} diff --git a/packages/core/src/agent/models/features/GovernanceFramework.ts b/packages/core/src/agent/models/features/GovernanceFramework.ts new file mode 100644 index 0000000000..ce174e6ebd --- /dev/null +++ b/packages/core/src/agent/models/features/GovernanceFramework.ts @@ -0,0 +1,13 @@ +import type { FeatureOptions } from './Feature' + +import { Feature } from './Feature' + +export type GovernanceFrameworkOptions = Omit + +export class GovernanceFramework extends Feature { + public constructor(props: GovernanceFrameworkOptions) { + super({ ...props, type: GovernanceFramework.type }) + } + + public static readonly type = 'gov-fw' +} diff --git a/packages/core/src/agent/models/features/Protocol.ts b/packages/core/src/agent/models/features/Protocol.ts new file mode 100644 index 0000000000..ddfc63d384 --- /dev/null +++ b/packages/core/src/agent/models/features/Protocol.ts @@ -0,0 +1,25 @@ +import type { FeatureOptions } from './Feature' + +import { IsOptional, IsString } from 'class-validator' + +import { Feature } from './Feature' + +export interface ProtocolOptions extends Omit { + roles?: string[] +} + +export class Protocol extends Feature { + public constructor(props: ProtocolOptions) { + super({ ...props, type: Protocol.type }) + + if (props) { + this.roles = props.roles + } + } + + public static readonly type = 'protocol' + + @IsString({ each: true }) + @IsOptional() + public roles?: string[] +} diff --git a/packages/core/src/agent/models/features/index.ts b/packages/core/src/agent/models/features/index.ts new file mode 100644 index 0000000000..ad3b62896c --- /dev/null +++ b/packages/core/src/agent/models/features/index.ts @@ -0,0 +1,5 @@ +export * from './Feature' +export * from './FeatureQuery' +export * from './GoalCode' +export * from './GovernanceFramework' +export * from './Protocol' diff --git a/packages/core/src/agent/models/index.ts b/packages/core/src/agent/models/index.ts new file mode 100644 index 0000000000..3a9ffdf3ca --- /dev/null +++ b/packages/core/src/agent/models/index.ts @@ -0,0 +1,2 @@ +export * from './features' +export * from './InboundMessageContext' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index afadf847a1..ef2d804359 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,8 +6,9 @@ export { Agent } from './agent/Agent' export { BaseAgent } from './agent/BaseAgent' export * from './agent' export { EventEmitter } from './agent/EventEmitter' +export { FeatureRegistry } from './agent/FeatureRegistry' export { Handler, HandlerInboundMessage } from './agent/Handler' -export { InboundMessageContext } from './agent/models/InboundMessageContext' +export * from './agent/models' export { AgentConfig } from './agent/AgentConfig' export { AgentMessage } from './agent/AgentMessage' export { Dispatcher } from './agent/Dispatcher' @@ -36,6 +37,7 @@ export * from './transport' export * from './modules/basic-messages' export * from './modules/common' export * from './modules/credentials' +export * from './modules/discover-features' export * from './modules/proofs' export * from './modules/connections' export * from './modules/ledger' diff --git a/packages/core/src/modules/basic-messages/BasicMessagesModule.ts b/packages/core/src/modules/basic-messages/BasicMessagesModule.ts index 03da109ff4..169e3afc48 100644 --- a/packages/core/src/modules/basic-messages/BasicMessagesModule.ts +++ b/packages/core/src/modules/basic-messages/BasicMessagesModule.ts @@ -1,5 +1,9 @@ +import type { FeatureRegistry } from '../../agent/FeatureRegistry' import type { DependencyManager, Module } from '../../plugins' +import { Protocol } from '../../agent/models' + +import { BasicMessageRole } from './BasicMessageRole' import { BasicMessagesApi } from './BasicMessagesApi' import { BasicMessageRepository } from './repository' import { BasicMessageService } from './services' @@ -8,7 +12,7 @@ export class BasicMessagesModule implements Module { /** * Registers the dependencies of the basic message module on the dependency manager. */ - public register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager, featureRegistry: FeatureRegistry) { // Api dependencyManager.registerContextScoped(BasicMessagesApi) @@ -17,5 +21,13 @@ export class BasicMessagesModule implements Module { // Repositories dependencyManager.registerSingleton(BasicMessageRepository) + + // Features + featureRegistry.register( + new Protocol({ + id: 'https://didcomm.org/basicmessage/1.0', + roles: [BasicMessageRole.Sender, BasicMessageRole.Receiver], + }) + ) } } diff --git a/packages/core/src/modules/basic-messages/__tests__/BasicMessagesModule.test.ts b/packages/core/src/modules/basic-messages/__tests__/BasicMessagesModule.test.ts index caaaedae00..4a9f106810 100644 --- a/packages/core/src/modules/basic-messages/__tests__/BasicMessagesModule.test.ts +++ b/packages/core/src/modules/basic-messages/__tests__/BasicMessagesModule.test.ts @@ -1,3 +1,4 @@ +import { FeatureRegistry } from '../../../agent/FeatureRegistry' import { DependencyManager } from '../../../plugins/DependencyManager' import { BasicMessagesApi } from '../BasicMessagesApi' import { BasicMessagesModule } from '../BasicMessagesModule' @@ -9,9 +10,14 @@ const DependencyManagerMock = DependencyManager as jest.Mock const dependencyManager = new DependencyManagerMock() +jest.mock('../../../agent/FeatureRegistry') +const FeatureRegistryMock = FeatureRegistry as jest.Mock + +const featureRegistry = new FeatureRegistryMock() + describe('BasicMessagesModule', () => { test('registers dependencies on the dependency manager', () => { - new BasicMessagesModule().register(dependencyManager) + new BasicMessagesModule().register(dependencyManager, featureRegistry) expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(BasicMessagesApi) diff --git a/packages/core/src/modules/connections/ConnectionsModule.ts b/packages/core/src/modules/connections/ConnectionsModule.ts index 5e6c98ee0a..a76d95ee71 100644 --- a/packages/core/src/modules/connections/ConnectionsModule.ts +++ b/packages/core/src/modules/connections/ConnectionsModule.ts @@ -1,9 +1,13 @@ +import type { FeatureRegistry } from '../../agent/FeatureRegistry' import type { DependencyManager, Module } from '../../plugins' import type { ConnectionsModuleConfigOptions } from './ConnectionsModuleConfig' +import { Protocol } from '../../agent/models' + import { ConnectionsApi } from './ConnectionsApi' import { ConnectionsModuleConfig } from './ConnectionsModuleConfig' import { DidExchangeProtocol } from './DidExchangeProtocol' +import { ConnectionRole, DidExchangeRole } from './models' import { ConnectionRepository } from './repository' import { ConnectionService, TrustPingService } from './services' @@ -17,7 +21,7 @@ export class ConnectionsModule implements Module { /** * Registers the dependencies of the connections module on the dependency manager. */ - public register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager, featureRegistry: FeatureRegistry) { // Api dependencyManager.registerContextScoped(ConnectionsApi) @@ -31,5 +35,17 @@ export class ConnectionsModule implements Module { // Repositories dependencyManager.registerSingleton(ConnectionRepository) + + // Features + featureRegistry.register( + new Protocol({ + id: 'https://didcomm.org/connections/1.0', + roles: [ConnectionRole.Invitee, ConnectionRole.Inviter], + }), + new Protocol({ + id: 'https://didcomm.org/didexchange/1.0', + roles: [DidExchangeRole.Requester, DidExchangeRole.Responder], + }) + ) } } diff --git a/packages/core/src/modules/connections/__tests__/ConnectionsModule.test.ts b/packages/core/src/modules/connections/__tests__/ConnectionsModule.test.ts index 2231f69b07..34ebb476f7 100644 --- a/packages/core/src/modules/connections/__tests__/ConnectionsModule.test.ts +++ b/packages/core/src/modules/connections/__tests__/ConnectionsModule.test.ts @@ -1,3 +1,4 @@ +import { FeatureRegistry } from '../../../agent/FeatureRegistry' import { DependencyManager } from '../../../plugins/DependencyManager' import { ConnectionsApi } from '../ConnectionsApi' import { ConnectionsModule } from '../ConnectionsModule' @@ -11,10 +12,15 @@ const DependencyManagerMock = DependencyManager as jest.Mock const dependencyManager = new DependencyManagerMock() +jest.mock('../../../agent/FeatureRegistry') +const FeatureRegistryMock = FeatureRegistry as jest.Mock + +const featureRegistry = new FeatureRegistryMock() + describe('ConnectionsModule', () => { test('registers dependencies on the dependency manager', () => { const connectionsModule = new ConnectionsModule() - connectionsModule.register(dependencyManager) + connectionsModule.register(dependencyManager, featureRegistry) expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(ConnectionsApi) diff --git a/packages/core/src/modules/credentials/CredentialsModule.ts b/packages/core/src/modules/credentials/CredentialsModule.ts index 9f3456b4d2..475224b0f8 100644 --- a/packages/core/src/modules/credentials/CredentialsModule.ts +++ b/packages/core/src/modules/credentials/CredentialsModule.ts @@ -1,6 +1,9 @@ +import type { FeatureRegistry } from '../../agent/FeatureRegistry' import type { DependencyManager, Module } from '../../plugins' import type { CredentialsModuleConfigOptions } from './CredentialsModuleConfig' +import { Protocol } from '../../agent/models' + import { CredentialsApi } from './CredentialsApi' import { CredentialsModuleConfig } from './CredentialsModuleConfig' import { IndyCredentialFormatService } from './formats/indy' @@ -19,7 +22,7 @@ export class CredentialsModule implements Module { /** * Registers the dependencies of the credentials module on the dependency manager. */ - public register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager, featureRegistry: FeatureRegistry) { // Api dependencyManager.registerContextScoped(CredentialsApi) @@ -34,6 +37,26 @@ export class CredentialsModule implements Module { // Repositories dependencyManager.registerSingleton(CredentialRepository) + // Features + featureRegistry.register( + new Protocol({ + id: 'https://didcomm.org/issue-credential/1.0', + roles: ['holder', 'issuer'], + }), + new Protocol({ + id: 'https://didcomm.org/issue-credential/2.0', + roles: ['holder', 'issuer'], + }), + new Protocol({ + id: 'https://didcomm.org/revocation_notification/1.0', + roles: ['holder'], + }), + new Protocol({ + id: 'https://didcomm.org/revocation_notification/2.0', + roles: ['holder'], + }) + ) + // Credential Formats dependencyManager.registerSingleton(IndyCredentialFormatService) } diff --git a/packages/core/src/modules/credentials/__tests__/CredentialsModule.test.ts b/packages/core/src/modules/credentials/__tests__/CredentialsModule.test.ts index b430d90d9c..cb76cc2840 100644 --- a/packages/core/src/modules/credentials/__tests__/CredentialsModule.test.ts +++ b/packages/core/src/modules/credentials/__tests__/CredentialsModule.test.ts @@ -1,3 +1,4 @@ +import { FeatureRegistry } from '../../../agent/FeatureRegistry' import { DependencyManager } from '../../../plugins/DependencyManager' import { CredentialsApi } from '../CredentialsApi' import { CredentialsModule } from '../CredentialsModule' @@ -12,10 +13,15 @@ const DependencyManagerMock = DependencyManager as jest.Mock const dependencyManager = new DependencyManagerMock() +jest.mock('../../../agent/FeatureRegistry') +const FeatureRegistryMock = FeatureRegistry as jest.Mock + +const featureRegistry = new FeatureRegistryMock() + describe('CredentialsModule', () => { test('registers dependencies on the dependency manager', () => { const credentialsModule = new CredentialsModule() - credentialsModule.register(dependencyManager) + credentialsModule.register(dependencyManager, featureRegistry) expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(CredentialsApi) diff --git a/packages/core/src/modules/discover-features/DiscoverFeaturesApi.ts b/packages/core/src/modules/discover-features/DiscoverFeaturesApi.ts index 5ab8193324..1f49e65624 100644 --- a/packages/core/src/modules/discover-features/DiscoverFeaturesApi.ts +++ b/packages/core/src/modules/discover-features/DiscoverFeaturesApi.ts @@ -1,101 +1,153 @@ -import type { AgentMessageProcessedEvent } from '../../agent/Events' -import type { ParsedMessageType } from '../../utils/messageType' +import type { Feature } from '../../agent/models' +import type { DiscloseFeaturesOptions, QueryFeaturesOptions, ServiceMap } from './DiscoverFeaturesApiOptions' +import type { DiscoverFeaturesDisclosureReceivedEvent } from './DiscoverFeaturesEvents' +import type { DiscoverFeaturesService } from './services' import { firstValueFrom, of, ReplaySubject, Subject } from 'rxjs' import { catchError, filter, map, takeUntil, timeout } from 'rxjs/operators' import { AgentContext } from '../../agent' -import { Dispatcher } from '../../agent/Dispatcher' import { EventEmitter } from '../../agent/EventEmitter' -import { filterContextCorrelationId, AgentEventTypes } from '../../agent/Events' import { MessageSender } from '../../agent/MessageSender' import { createOutboundMessage } from '../../agent/helpers' import { InjectionSymbols } from '../../constants' +import { AriesFrameworkError } from '../../error' import { inject, injectable } from '../../plugins' -import { canHandleMessageType, parseMessageType } from '../../utils/messageType' import { ConnectionService } from '../connections/services' -import { DiscloseMessageHandler, QueryMessageHandler } from './handlers' -import { DiscloseMessage } from './messages' -import { DiscoverFeaturesService } from './services' +import { DiscoverFeaturesEventTypes } from './DiscoverFeaturesEvents' +import { DiscoverFeaturesModuleConfig } from './DiscoverFeaturesModuleConfig' +import { V1DiscoverFeaturesService, V2DiscoverFeaturesService } from './protocol' +export interface QueryFeaturesReturnType { + features?: Feature[] +} + +export interface DiscoverFeaturesApi { + queryFeatures(options: QueryFeaturesOptions): Promise + discloseFeatures(options: DiscloseFeaturesOptions): Promise +} @injectable() -export class DiscoverFeaturesApi { +export class DiscoverFeaturesApi< + DFSs extends DiscoverFeaturesService[] = [V1DiscoverFeaturesService, V2DiscoverFeaturesService] +> implements DiscoverFeaturesApi +{ + /** + * Configuration for Discover Features module + */ + public readonly config: DiscoverFeaturesModuleConfig + private connectionService: ConnectionService private messageSender: MessageSender - private discoverFeaturesService: DiscoverFeaturesService private eventEmitter: EventEmitter private stop$: Subject private agentContext: AgentContext + private serviceMap: ServiceMap public constructor( - dispatcher: Dispatcher, connectionService: ConnectionService, messageSender: MessageSender, - discoverFeaturesService: DiscoverFeaturesService, + v1Service: V1DiscoverFeaturesService, + v2Service: V2DiscoverFeaturesService, eventEmitter: EventEmitter, @inject(InjectionSymbols.Stop$) stop$: Subject, - agentContext: AgentContext + agentContext: AgentContext, + config: DiscoverFeaturesModuleConfig ) { this.connectionService = connectionService this.messageSender = messageSender - this.discoverFeaturesService = discoverFeaturesService - this.registerHandlers(dispatcher) this.eventEmitter = eventEmitter this.stop$ = stop$ this.agentContext = agentContext + this.config = config + + // Dynamically build service map. This will be extracted once services are registered dynamically + this.serviceMap = [v1Service, v2Service].reduce( + (serviceMap, service) => ({ + ...serviceMap, + [service.version]: service, + }), + {} + ) as ServiceMap } - public async isProtocolSupported(connectionId: string, message: { type: ParsedMessageType }) { - const { protocolUri } = message.type - - // Listen for response to our feature query - const replaySubject = new ReplaySubject(1) - this.eventEmitter - .observable(AgentEventTypes.AgentMessageProcessed) - .pipe( - // Stop when the agent shuts down - takeUntil(this.stop$), - filterContextCorrelationId(this.agentContext.contextCorrelationId), - // filter by connection id and query disclose message type - filter( - (e) => - e.payload.connection?.id === connectionId && - canHandleMessageType(DiscloseMessage, parseMessageType(e.payload.message.type)) - ), - // Return whether the protocol is supported - map((e) => { - const message = e.payload.message as DiscloseMessage - return message.protocols.map((p) => p.protocolId).includes(protocolUri) - }), - // TODO: make configurable - // If we don't have an answer in 7 seconds (no response, not supported, etc...) error - timeout(7000), - // We want to return false if an error occurred - catchError(() => of(false)) - ) - .subscribe(replaySubject) - - await this.queryFeatures(connectionId, { - query: protocolUri, - comment: 'Detect if protocol is supported', - }) + public getService(protocolVersion: PVT): DiscoverFeaturesService { + if (!this.serviceMap[protocolVersion]) { + throw new AriesFrameworkError(`No discover features service registered for protocol version ${protocolVersion}`) + } - const isProtocolSupported = await firstValueFrom(replaySubject) - return isProtocolSupported + return this.serviceMap[protocolVersion] } - public async queryFeatures(connectionId: string, options: { query: string; comment?: string }) { - const connection = await this.connectionService.getById(this.agentContext, connectionId) + /** + * Send a query to an existing connection for discovering supported features of any kind. If desired, do the query synchronously, + * meaning that it will await the response (or timeout) before resolving. Otherwise, response can be hooked by subscribing to + * {DiscoverFeaturesDisclosureReceivedEvent}. + * + * Note: V1 protocol only supports a single query and is limited to protocols + * + * @param options feature queries to perform, protocol version and optional comment string (only used + * in V1 protocol). If awaitDisclosures is set, perform the query synchronously with awaitDisclosuresTimeoutMs timeout. + */ + public async queryFeatures(options: QueryFeaturesOptions) { + const service = this.getService(options.protocolVersion) - const queryMessage = await this.discoverFeaturesService.createQuery(options) + const connection = await this.connectionService.getById(this.agentContext, options.connectionId) + + const { message: queryMessage } = await service.createQuery({ + queries: options.queries, + comment: options.comment, + }) const outbound = createOutboundMessage(connection, queryMessage) + + const replaySubject = new ReplaySubject(1) + if (options.awaitDisclosures) { + // Listen for response to our feature query + this.eventEmitter + .observable(DiscoverFeaturesEventTypes.DisclosureReceived) + .pipe( + // Stop when the agent shuts down + takeUntil(this.stop$), + // filter by connection id + filter((e) => e.payload.connection?.id === connection.id), + // Return disclosures + map((e) => e.payload.disclosures), + // If we don't have an answer in timeoutMs miliseconds (no response, not supported, etc...) error + timeout(options.awaitDisclosuresTimeoutMs ?? 7000), // TODO: Harmonize default timeouts across the framework + // We want to return false if an error occurred + catchError(() => of([])) + ) + .subscribe(replaySubject) + } + await this.messageSender.sendMessage(this.agentContext, outbound) + + return { features: options.awaitDisclosures ? await firstValueFrom(replaySubject) : undefined } } - private registerHandlers(dispatcher: Dispatcher) { - dispatcher.registerHandler(new DiscloseMessageHandler()) - dispatcher.registerHandler(new QueryMessageHandler(this.discoverFeaturesService)) + /** + * Disclose features to a connection, either proactively or as a response to a query. + * + * Features are disclosed based on queries that will be performed to Agent's Feature Registry, + * meaning that they should be registered prior to disclosure. When sending disclosure as response, + * these queries will usually match those from the corresponding Query or Queries message. + * + * Note: V1 protocol only supports sending disclosures as a response to a query. + * + * @param options multiple properties like protocol version to use, disclosure queries and thread id + * (in case of disclosure as response to query) + */ + public async discloseFeatures(options: DiscloseFeaturesOptions) { + const service = this.getService(options.protocolVersion) + + const connection = await this.connectionService.getById(this.agentContext, options.connectionId) + const { message: disclosuresMessage } = await service.createDisclosure({ + disclosureQueries: options.disclosureQueries, + threadId: options.threadId, + }) + + const outbound = createOutboundMessage(connection, disclosuresMessage) + await this.messageSender.sendMessage(this.agentContext, outbound) } } diff --git a/packages/core/src/modules/discover-features/DiscoverFeaturesApiOptions.ts b/packages/core/src/modules/discover-features/DiscoverFeaturesApiOptions.ts new file mode 100644 index 0000000000..5cd0b88d38 --- /dev/null +++ b/packages/core/src/modules/discover-features/DiscoverFeaturesApiOptions.ts @@ -0,0 +1,45 @@ +import type { FeatureQueryOptions } from '../../agent/models' +import type { DiscoverFeaturesService } from './services' + +/** + * Get the supported protocol versions based on the provided discover features services. + */ +export type ProtocolVersionType = DFSs[number]['version'] + +/** + * Get the service map for usage in the discover features module. Will return a type mapping of protocol version to service. + * + * @example + * ``` + * type DiscoverFeaturesServiceMap = ServiceMap<[V1DiscoverFeaturesService,V2DiscoverFeaturesService]> + * + * // equal to + * type DiscoverFeaturesServiceMap = { + * v1: V1DiscoverFeatureService + * v2: V2DiscoverFeaturesService + * } + * ``` + */ +export type ServiceMap = { + [DFS in DFSs[number] as DFS['version']]: DiscoverFeaturesService +} + +interface BaseOptions { + connectionId: string +} + +export interface QueryFeaturesOptions + extends BaseOptions { + protocolVersion: ProtocolVersionType + queries: FeatureQueryOptions[] + awaitDisclosures?: boolean + awaitDisclosuresTimeoutMs?: number + comment?: string +} + +export interface DiscloseFeaturesOptions + extends BaseOptions { + protocolVersion: ProtocolVersionType + disclosureQueries: FeatureQueryOptions[] + threadId?: string +} diff --git a/packages/core/src/modules/discover-features/DiscoverFeaturesEvents.ts b/packages/core/src/modules/discover-features/DiscoverFeaturesEvents.ts new file mode 100644 index 0000000000..2e4340ffbc --- /dev/null +++ b/packages/core/src/modules/discover-features/DiscoverFeaturesEvents.ts @@ -0,0 +1,31 @@ +import type { AgentMessage } from '../../agent/AgentMessage' +import type { BaseEvent } from '../../agent/Events' +import type { Feature, FeatureQueryOptions } from '../../agent/models' +import type { ConnectionRecord } from '../connections' + +export enum DiscoverFeaturesEventTypes { + QueryReceived = 'QueryReceived', + DisclosureReceived = 'DisclosureReceived', +} + +export interface DiscoverFeaturesQueryReceivedEvent extends BaseEvent { + type: typeof DiscoverFeaturesEventTypes.QueryReceived + payload: { + message: AgentMessage + queries: FeatureQueryOptions[] + protocolVersion: string + connection: ConnectionRecord + threadId: string + } +} + +export interface DiscoverFeaturesDisclosureReceivedEvent extends BaseEvent { + type: typeof DiscoverFeaturesEventTypes.DisclosureReceived + payload: { + message: AgentMessage + disclosures: Feature[] + protocolVersion: string + connection: ConnectionRecord + threadId: string + } +} diff --git a/packages/core/src/modules/discover-features/DiscoverFeaturesModule.ts b/packages/core/src/modules/discover-features/DiscoverFeaturesModule.ts index 8490fc88b7..d7be4b3732 100644 --- a/packages/core/src/modules/discover-features/DiscoverFeaturesModule.ts +++ b/packages/core/src/modules/discover-features/DiscoverFeaturesModule.ts @@ -1,17 +1,45 @@ +import type { FeatureRegistry } from '../../agent/FeatureRegistry' import type { DependencyManager, Module } from '../../plugins' +import type { DiscoverFeaturesModuleConfigOptions } from './DiscoverFeaturesModuleConfig' + +import { Protocol } from '../../agent/models' import { DiscoverFeaturesApi } from './DiscoverFeaturesApi' -import { DiscoverFeaturesService } from './services' +import { DiscoverFeaturesModuleConfig } from './DiscoverFeaturesModuleConfig' +import { V1DiscoverFeaturesService } from './protocol/v1' +import { V2DiscoverFeaturesService } from './protocol/v2' export class DiscoverFeaturesModule implements Module { + public readonly config: DiscoverFeaturesModuleConfig + + public constructor(config?: DiscoverFeaturesModuleConfigOptions) { + this.config = new DiscoverFeaturesModuleConfig(config) + } + /** * Registers the dependencies of the discover features module on the dependency manager. */ - public register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager, featureRegistry: FeatureRegistry) { // Api dependencyManager.registerContextScoped(DiscoverFeaturesApi) + // Config + dependencyManager.registerInstance(DiscoverFeaturesModuleConfig, this.config) + // Services - dependencyManager.registerSingleton(DiscoverFeaturesService) + dependencyManager.registerSingleton(V1DiscoverFeaturesService) + dependencyManager.registerSingleton(V2DiscoverFeaturesService) + + // Features + featureRegistry.register( + new Protocol({ + id: 'https://didcomm.org/discover-features/1.0', + roles: ['requester', 'responder'], + }), + new Protocol({ + id: 'https://didcomm.org/discover-features/2.0', + roles: ['requester', 'responder'], + }) + ) } } diff --git a/packages/core/src/modules/discover-features/DiscoverFeaturesModuleConfig.ts b/packages/core/src/modules/discover-features/DiscoverFeaturesModuleConfig.ts new file mode 100644 index 0000000000..9417b5c213 --- /dev/null +++ b/packages/core/src/modules/discover-features/DiscoverFeaturesModuleConfig.ts @@ -0,0 +1,25 @@ +/** + * DiscoverFeaturesModuleConfigOptions defines the interface for the options of the DiscoverFeaturesModuleConfig class. + * This can contain optional parameters that have default values in the config class itself. + */ +export interface DiscoverFeaturesModuleConfigOptions { + /** + * Whether to automatically accept feature queries. Applies to all protocol versions. + * + * @default true + */ + autoAcceptQueries?: boolean +} + +export class DiscoverFeaturesModuleConfig { + private options: DiscoverFeaturesModuleConfigOptions + + public constructor(options?: DiscoverFeaturesModuleConfigOptions) { + this.options = options ?? {} + } + + /** {@inheritDoc DiscoverFeaturesModuleConfigOptions.autoAcceptQueries} */ + public get autoAcceptQueries() { + return this.options.autoAcceptQueries ?? true + } +} diff --git a/packages/core/src/modules/discover-features/DiscoverFeaturesServiceOptions.ts b/packages/core/src/modules/discover-features/DiscoverFeaturesServiceOptions.ts new file mode 100644 index 0000000000..5dcbb04bdc --- /dev/null +++ b/packages/core/src/modules/discover-features/DiscoverFeaturesServiceOptions.ts @@ -0,0 +1,16 @@ +import type { AgentMessage } from '../../agent/AgentMessage' +import type { FeatureQueryOptions } from '../../agent/models' + +export interface CreateQueryOptions { + queries: FeatureQueryOptions[] + comment?: string +} + +export interface CreateDisclosureOptions { + disclosureQueries: FeatureQueryOptions[] + threadId?: string +} + +export interface DiscoverFeaturesProtocolMsgReturnType { + message: MessageType +} diff --git a/packages/core/src/modules/discover-features/__tests__/DiscoverFeaturesModule.test.ts b/packages/core/src/modules/discover-features/__tests__/DiscoverFeaturesModule.test.ts index c47b85bb36..e4c259b69c 100644 --- a/packages/core/src/modules/discover-features/__tests__/DiscoverFeaturesModule.test.ts +++ b/packages/core/src/modules/discover-features/__tests__/DiscoverFeaturesModule.test.ts @@ -1,21 +1,40 @@ +import { FeatureRegistry } from '../../../agent/FeatureRegistry' +import { Protocol } from '../../../agent/models' import { DependencyManager } from '../../../plugins/DependencyManager' import { DiscoverFeaturesApi } from '../DiscoverFeaturesApi' import { DiscoverFeaturesModule } from '../DiscoverFeaturesModule' -import { DiscoverFeaturesService } from '../services' +import { V1DiscoverFeaturesService } from '../protocol/v1' +import { V2DiscoverFeaturesService } from '../protocol/v2' jest.mock('../../../plugins/DependencyManager') const DependencyManagerMock = DependencyManager as jest.Mock +jest.mock('../../../agent/FeatureRegistry') +const FeatureRegistryMock = FeatureRegistry as jest.Mock + const dependencyManager = new DependencyManagerMock() +const featureRegistry = new FeatureRegistryMock() describe('DiscoverFeaturesModule', () => { test('registers dependencies on the dependency manager', () => { - new DiscoverFeaturesModule().register(dependencyManager) + new DiscoverFeaturesModule().register(dependencyManager, featureRegistry) expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(DiscoverFeaturesApi) - expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(1) - expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(DiscoverFeaturesService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(2) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(V1DiscoverFeaturesService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(V2DiscoverFeaturesService) + + expect(featureRegistry.register).toHaveBeenCalledWith( + new Protocol({ + id: 'https://didcomm.org/discover-features/1.0', + roles: ['requester', 'responder'], + }), + new Protocol({ + id: 'https://didcomm.org/discover-features/2.0', + roles: ['requester', 'responder'], + }) + ) }) }) diff --git a/packages/core/src/modules/discover-features/__tests__/DiscoverFeaturesService.test.ts b/packages/core/src/modules/discover-features/__tests__/DiscoverFeaturesService.test.ts deleted file mode 100644 index eb911df214..0000000000 --- a/packages/core/src/modules/discover-features/__tests__/DiscoverFeaturesService.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Dispatcher } from '../../../agent/Dispatcher' - -import { QueryMessage } from '../messages' -import { DiscoverFeaturesService } from '../services/DiscoverFeaturesService' - -const supportedProtocols = [ - 'https://didcomm.org/connections/1.0', - 'https://didcomm.org/notification/1.0', - 'https://didcomm.org/issue-credential/1.0', -] - -describe('DiscoverFeaturesService', () => { - const discoverFeaturesService = new DiscoverFeaturesService({ supportedProtocols } as Dispatcher) - - describe('createDisclose', () => { - it('should return all protocols when query is *', async () => { - const queryMessage = new QueryMessage({ - query: '*', - }) - - const message = await discoverFeaturesService.createDisclose(queryMessage) - - expect(message.protocols.map((p) => p.protocolId)).toStrictEqual([ - 'https://didcomm.org/connections/1.0', - 'https://didcomm.org/notification/1.0', - 'https://didcomm.org/issue-credential/1.0', - ]) - }) - - it('should return only one protocol if the query specifies a specific protocol', async () => { - const queryMessage = new QueryMessage({ - query: 'https://didcomm.org/connections/1.0', - }) - - const message = await discoverFeaturesService.createDisclose(queryMessage) - - expect(message.protocols.map((p) => p.protocolId)).toStrictEqual(['https://didcomm.org/connections/1.0']) - }) - - it('should respect a wild card at the end of the query', async () => { - const queryMessage = new QueryMessage({ - query: 'https://didcomm.org/connections/*', - }) - - const message = await discoverFeaturesService.createDisclose(queryMessage) - - expect(message.protocols.map((p) => p.protocolId)).toStrictEqual(['https://didcomm.org/connections/1.0']) - }) - }) - - describe('createQuery', () => { - it('should return a query message with the query and comment', async () => { - const message = await discoverFeaturesService.createQuery({ - query: '*', - comment: 'Hello', - }) - - expect(message.query).toBe('*') - expect(message.comment).toBe('Hello') - }) - }) -}) diff --git a/packages/core/src/modules/discover-features/__tests__/FeatureRegistry.test.ts b/packages/core/src/modules/discover-features/__tests__/FeatureRegistry.test.ts new file mode 100644 index 0000000000..f353b36752 --- /dev/null +++ b/packages/core/src/modules/discover-features/__tests__/FeatureRegistry.test.ts @@ -0,0 +1,53 @@ +import { FeatureRegistry } from '../../../agent/FeatureRegistry' +import { Feature, GoalCode, Protocol } from '../../../agent/models' +import { JsonTransformer } from '../../../utils/JsonTransformer' + +describe('Feature Registry', () => { + test('register goal codes', () => { + const featureRegistry = new FeatureRegistry() + + const goalCode = new GoalCode({ id: 'aries.vc.issue' }) + + expect(JsonTransformer.toJSON(goalCode)).toMatchObject({ id: 'aries.vc.issue', 'feature-type': 'goal-code' }) + + featureRegistry.register(goalCode) + const found = featureRegistry.query({ featureType: GoalCode.type, match: 'aries.*' }) + + expect(found.map((t) => t.toJSON())).toStrictEqual([{ id: 'aries.vc.issue', 'feature-type': 'goal-code' }]) + }) + + test('register generic feature', () => { + const featureRegistry = new FeatureRegistry() + + class GenericFeature extends Feature { + public customFieldString: string + public customFieldNumber: number + public constructor(id: string, customFieldString: string, customFieldNumber: number) { + super({ id, type: 'generic' }) + this.customFieldString = customFieldString + this.customFieldNumber = customFieldNumber + } + } + featureRegistry.register(new GenericFeature('myId', 'myString', 42)) + const found = featureRegistry.query({ featureType: 'generic', match: '*' }) + + expect(found.map((t) => t.toJSON())).toStrictEqual([ + { id: 'myId', 'feature-type': 'generic', customFieldString: 'myString', customFieldNumber: 42 }, + ]) + }) + + test('register combined features', () => { + const featureRegistry = new FeatureRegistry() + + featureRegistry.register( + new Protocol({ id: 'https://didcomm.org/dummy/1.0', roles: ['requester'] }), + new Protocol({ id: 'https://didcomm.org/dummy/1.0', roles: ['responder'] }), + new Protocol({ id: 'https://didcomm.org/dummy/1.0', roles: ['responder'] }) + ) + const found = featureRegistry.query({ featureType: Protocol.type, match: 'https://didcomm.org/dummy/1.0' }) + + expect(found.map((t) => t.toJSON())).toStrictEqual([ + { id: 'https://didcomm.org/dummy/1.0', 'feature-type': 'protocol', roles: ['requester', 'responder'] }, + ]) + }) +}) diff --git a/packages/core/src/modules/discover-features/__tests__/helpers.ts b/packages/core/src/modules/discover-features/__tests__/helpers.ts new file mode 100644 index 0000000000..209bf83b05 --- /dev/null +++ b/packages/core/src/modules/discover-features/__tests__/helpers.ts @@ -0,0 +1,41 @@ +import type { + DiscoverFeaturesDisclosureReceivedEvent, + DiscoverFeaturesQueryReceivedEvent, +} from '../DiscoverFeaturesEvents' +import type { Observable } from 'rxjs' + +import { map, catchError, timeout, firstValueFrom, ReplaySubject } from 'rxjs' + +export function waitForDisclosureSubject( + subject: ReplaySubject | Observable, + { timeoutMs = 10000 }: { timeoutMs: number } +) { + const observable = subject instanceof ReplaySubject ? subject.asObservable() : subject + + return firstValueFrom( + observable.pipe( + timeout(timeoutMs), + catchError(() => { + throw new Error(`DiscoverFeaturesDisclosureReceivedEvent event not emitted within specified timeout`) + }), + map((e) => e.payload) + ) + ) +} + +export function waitForQuerySubject( + subject: ReplaySubject | Observable, + { timeoutMs = 10000 }: { timeoutMs: number } +) { + const observable = subject instanceof ReplaySubject ? subject.asObservable() : subject + + return firstValueFrom( + observable.pipe( + timeout(timeoutMs), + catchError(() => { + throw new Error(`DiscoverFeaturesQueryReceivedEvent event not emitted within specified timeout`) + }), + map((e) => e.payload) + ) + ) +} diff --git a/packages/core/src/modules/discover-features/__tests__/v1-discover-features.e2e.test.ts b/packages/core/src/modules/discover-features/__tests__/v1-discover-features.e2e.test.ts new file mode 100644 index 0000000000..258eb4f543 --- /dev/null +++ b/packages/core/src/modules/discover-features/__tests__/v1-discover-features.e2e.test.ts @@ -0,0 +1,106 @@ +import type { SubjectMessage } from '../../../../../../tests/transport/SubjectInboundTransport' +import type { ConnectionRecord } from '../../connections' +import type { + DiscoverFeaturesDisclosureReceivedEvent, + DiscoverFeaturesQueryReceivedEvent, +} from '../DiscoverFeaturesEvents' + +import { ReplaySubject, Subject } from 'rxjs' + +import { SubjectInboundTransport } from '../../../../../../tests/transport/SubjectInboundTransport' +import { SubjectOutboundTransport } from '../../../../../../tests/transport/SubjectOutboundTransport' +import { getBaseConfig, makeConnection } from '../../../../tests/helpers' +import { Agent } from '../../../agent/Agent' +import { DiscoverFeaturesEventTypes } from '../DiscoverFeaturesEvents' + +import { waitForDisclosureSubject, waitForQuerySubject } from './helpers' + +describe('v1 discover features', () => { + let faberAgent: Agent + let aliceAgent: Agent + let faberConnection: ConnectionRecord + + beforeAll(async () => { + const faberMessages = new Subject() + const aliceMessages = new Subject() + const subjectMap = { + 'rxjs:faber': faberMessages, + 'rxjs:alice': aliceMessages, + } + const faberConfig = getBaseConfig('Faber Discover Features V1 E2E', { + endpoints: ['rxjs:faber'], + }) + + const aliceConfig = getBaseConfig('Alice Discover Features V1 E2E', { + endpoints: ['rxjs:alice'], + }) + faberAgent = new Agent(faberConfig.config, faberConfig.agentDependencies) + faberAgent.registerInboundTransport(new SubjectInboundTransport(faberMessages)) + faberAgent.registerOutboundTransport(new SubjectOutboundTransport(subjectMap)) + await faberAgent.initialize() + + aliceAgent = new Agent(aliceConfig.config, aliceConfig.agentDependencies) + aliceAgent.registerInboundTransport(new SubjectInboundTransport(aliceMessages)) + aliceAgent.registerOutboundTransport(new SubjectOutboundTransport(subjectMap)) + await aliceAgent.initialize() + ;[faberConnection] = await makeConnection(faberAgent, aliceAgent) + }) + + afterAll(async () => { + await faberAgent.shutdown() + await faberAgent.wallet.delete() + await aliceAgent.shutdown() + await aliceAgent.wallet.delete() + }) + + test('Faber asks Alice for issue credential protocol support', async () => { + const faberReplay = new ReplaySubject() + const aliceReplay = new ReplaySubject() + + faberAgent.events + .observable(DiscoverFeaturesEventTypes.DisclosureReceived) + .subscribe(faberReplay) + aliceAgent.events + .observable(DiscoverFeaturesEventTypes.QueryReceived) + .subscribe(aliceReplay) + + await faberAgent.discovery.queryFeatures({ + connectionId: faberConnection.id, + protocolVersion: 'v1', + queries: [{ featureType: 'protocol', match: 'https://didcomm.org/issue-credential/*' }], + }) + + const query = await waitForQuerySubject(aliceReplay, { timeoutMs: 10000 }) + + expect(query).toMatchObject({ + protocolVersion: 'v1', + queries: [{ featureType: 'protocol', match: 'https://didcomm.org/issue-credential/*' }], + }) + + const disclosure = await waitForDisclosureSubject(faberReplay, { timeoutMs: 10000 }) + + expect(disclosure).toMatchObject({ + protocolVersion: 'v1', + disclosures: [ + { type: 'protocol', id: 'https://didcomm.org/issue-credential/1.0', roles: ['holder', 'issuer'] }, + { type: 'protocol', id: 'https://didcomm.org/issue-credential/2.0', roles: ['holder', 'issuer'] }, + ], + }) + }) + + test('Faber asks Alice for issue credential protocol support synchronously', async () => { + const matchingFeatures = await faberAgent.discovery.queryFeatures({ + connectionId: faberConnection.id, + protocolVersion: 'v1', + queries: [{ featureType: 'protocol', match: 'https://didcomm.org/issue-credential/*' }], + awaitDisclosures: true, + }) + + expect(matchingFeatures).toMatchObject({ + features: [ + { type: 'protocol', id: 'https://didcomm.org/issue-credential/1.0', roles: ['holder', 'issuer'] }, + { type: 'protocol', id: 'https://didcomm.org/issue-credential/2.0', roles: ['holder', 'issuer'] }, + ], + }) + }) +}) diff --git a/packages/core/src/modules/discover-features/__tests__/v2-discover-features.e2e.test.ts b/packages/core/src/modules/discover-features/__tests__/v2-discover-features.e2e.test.ts new file mode 100644 index 0000000000..f014f5b6a6 --- /dev/null +++ b/packages/core/src/modules/discover-features/__tests__/v2-discover-features.e2e.test.ts @@ -0,0 +1,234 @@ +import type { SubjectMessage } from '../../../../../../tests/transport/SubjectInboundTransport' +import type { ConnectionRecord } from '../../connections' +import type { + DiscoverFeaturesDisclosureReceivedEvent, + DiscoverFeaturesQueryReceivedEvent, +} from '../DiscoverFeaturesEvents' + +import { ReplaySubject, Subject } from 'rxjs' + +import { SubjectInboundTransport } from '../../../../../../tests/transport/SubjectInboundTransport' +import { SubjectOutboundTransport } from '../../../../../../tests/transport/SubjectOutboundTransport' +import { getBaseConfig, makeConnection } from '../../../../tests/helpers' +import { Agent } from '../../../agent/Agent' +import { GoalCode, Feature } from '../../../agent/models' +import { DiscoverFeaturesEventTypes } from '../DiscoverFeaturesEvents' + +import { waitForDisclosureSubject, waitForQuerySubject } from './helpers' + +describe('v2 discover features', () => { + let faberAgent: Agent + let aliceAgent: Agent + let aliceConnection: ConnectionRecord + let faberConnection: ConnectionRecord + + beforeAll(async () => { + const faberMessages = new Subject() + const aliceMessages = new Subject() + const subjectMap = { + 'rxjs:faber': faberMessages, + 'rxjs:alice': aliceMessages, + } + const faberConfig = getBaseConfig('Faber Discover Features V2 E2E', { + endpoints: ['rxjs:faber'], + }) + + const aliceConfig = getBaseConfig('Alice Discover Features V2 E2E', { + endpoints: ['rxjs:alice'], + }) + faberAgent = new Agent(faberConfig.config, faberConfig.agentDependencies) + faberAgent.registerInboundTransport(new SubjectInboundTransport(faberMessages)) + faberAgent.registerOutboundTransport(new SubjectOutboundTransport(subjectMap)) + await faberAgent.initialize() + + aliceAgent = new Agent(aliceConfig.config, aliceConfig.agentDependencies) + aliceAgent.registerInboundTransport(new SubjectInboundTransport(aliceMessages)) + aliceAgent.registerOutboundTransport(new SubjectOutboundTransport(subjectMap)) + await aliceAgent.initialize() + ;[faberConnection, aliceConnection] = await makeConnection(faberAgent, aliceAgent) + }) + + afterAll(async () => { + await faberAgent.shutdown() + await faberAgent.wallet.delete() + await aliceAgent.shutdown() + await aliceAgent.wallet.delete() + }) + + test('Faber asks Alice for issue credential protocol support', async () => { + const faberReplay = new ReplaySubject() + const aliceReplay = new ReplaySubject() + + faberAgent.discovery.config.autoAcceptQueries + faberAgent.events + .observable(DiscoverFeaturesEventTypes.DisclosureReceived) + .subscribe(faberReplay) + aliceAgent.events + .observable(DiscoverFeaturesEventTypes.QueryReceived) + .subscribe(aliceReplay) + + await faberAgent.discovery.queryFeatures({ + connectionId: faberConnection.id, + protocolVersion: 'v2', + queries: [{ featureType: 'protocol', match: 'https://didcomm.org/issue-credential/*' }], + }) + + const query = await waitForQuerySubject(aliceReplay, { timeoutMs: 10000 }) + + expect(query).toMatchObject({ + protocolVersion: 'v2', + queries: [{ featureType: 'protocol', match: 'https://didcomm.org/issue-credential/*' }], + }) + + const disclosure = await waitForDisclosureSubject(faberReplay, { timeoutMs: 10000 }) + + expect(disclosure).toMatchObject({ + protocolVersion: 'v2', + disclosures: [ + { type: 'protocol', id: 'https://didcomm.org/issue-credential/1.0', roles: ['holder', 'issuer'] }, + { type: 'protocol', id: 'https://didcomm.org/issue-credential/2.0', roles: ['holder', 'issuer'] }, + ], + }) + }) + + test('Faber defines a supported goal code and Alice queries', async () => { + const faberReplay = new ReplaySubject() + const aliceReplay = new ReplaySubject() + + aliceAgent.events + .observable(DiscoverFeaturesEventTypes.DisclosureReceived) + .subscribe(aliceReplay) + faberAgent.events + .observable(DiscoverFeaturesEventTypes.QueryReceived) + .subscribe(faberReplay) + + // Register some goal codes + faberAgent.features.register(new GoalCode({ id: 'faber.vc.issuance' }), new GoalCode({ id: 'faber.vc.query' })) + + await aliceAgent.discovery.queryFeatures({ + connectionId: aliceConnection.id, + protocolVersion: 'v2', + queries: [{ featureType: 'goal-code', match: '*' }], + }) + + const query = await waitForQuerySubject(faberReplay, { timeoutMs: 10000 }) + + expect(query).toMatchObject({ + protocolVersion: 'v2', + queries: [{ featureType: 'goal-code', match: '*' }], + }) + + const disclosure = await waitForDisclosureSubject(aliceReplay, { timeoutMs: 10000 }) + + expect(disclosure).toMatchObject({ + protocolVersion: 'v2', + disclosures: [ + { type: 'goal-code', id: 'faber.vc.issuance' }, + { type: 'goal-code', id: 'faber.vc.query' }, + ], + }) + }) + + test('Faber defines a custom feature and Alice queries', async () => { + const faberReplay = new ReplaySubject() + const aliceReplay = new ReplaySubject() + + aliceAgent.events + .observable(DiscoverFeaturesEventTypes.DisclosureReceived) + .subscribe(aliceReplay) + faberAgent.events + .observable(DiscoverFeaturesEventTypes.QueryReceived) + .subscribe(faberReplay) + + // Define a custom feature type + class GenericFeature extends Feature { + public 'generic-field'!: string + + public constructor(options: { id: string; genericField: string }) { + super({ id: options.id, type: 'generic' }) + this['generic-field'] = options.genericField + } + } + + // Register a custom feature + faberAgent.features.register(new GenericFeature({ id: 'custom-feature', genericField: 'custom-field' })) + + await aliceAgent.discovery.queryFeatures({ + connectionId: aliceConnection.id, + protocolVersion: 'v2', + queries: [{ featureType: 'generic', match: 'custom-feature' }], + }) + + const query = await waitForQuerySubject(faberReplay, { timeoutMs: 10000 }) + + expect(query).toMatchObject({ + protocolVersion: 'v2', + queries: [{ featureType: 'generic', match: 'custom-feature' }], + }) + + const disclosure = await waitForDisclosureSubject(aliceReplay, { timeoutMs: 10000 }) + + expect(disclosure).toMatchObject({ + protocolVersion: 'v2', + disclosures: [ + { + type: 'generic', + id: 'custom-feature', + 'generic-field': 'custom-field', + }, + ], + }) + }) + + test('Faber proactively sends a set of features to Alice', async () => { + const faberReplay = new ReplaySubject() + const aliceReplay = new ReplaySubject() + + aliceAgent.events + .observable(DiscoverFeaturesEventTypes.DisclosureReceived) + .subscribe(aliceReplay) + faberAgent.events + .observable(DiscoverFeaturesEventTypes.QueryReceived) + .subscribe(faberReplay) + + // Register a custom feature + faberAgent.features.register( + new Feature({ id: 'AIP2.0', type: 'aip' }), + new Feature({ id: 'AIP2.0/INDYCRED', type: 'aip' }), + new Feature({ id: 'AIP2.0/MEDIATE', type: 'aip' }) + ) + + await faberAgent.discovery.discloseFeatures({ + connectionId: faberConnection.id, + protocolVersion: 'v2', + disclosureQueries: [{ featureType: 'aip', match: '*' }], + }) + + const disclosure = await waitForDisclosureSubject(aliceReplay, { timeoutMs: 10000 }) + + expect(disclosure).toMatchObject({ + protocolVersion: 'v2', + disclosures: [ + { type: 'aip', id: 'AIP2.0' }, + { type: 'aip', id: 'AIP2.0/INDYCRED' }, + { type: 'aip', id: 'AIP2.0/MEDIATE' }, + ], + }) + }) + + test('Faber asks Alice for issue credential protocol support synchronously', async () => { + const matchingFeatures = await faberAgent.discovery.queryFeatures({ + connectionId: faberConnection.id, + protocolVersion: 'v2', + queries: [{ featureType: 'protocol', match: 'https://didcomm.org/issue-credential/*' }], + awaitDisclosures: true, + }) + + expect(matchingFeatures).toMatchObject({ + features: [ + { type: 'protocol', id: 'https://didcomm.org/issue-credential/1.0', roles: ['holder', 'issuer'] }, + { type: 'protocol', id: 'https://didcomm.org/issue-credential/2.0', roles: ['holder', 'issuer'] }, + ], + }) + }) +}) diff --git a/packages/core/src/modules/discover-features/handlers/DiscloseMessageHandler.ts b/packages/core/src/modules/discover-features/handlers/DiscloseMessageHandler.ts deleted file mode 100644 index 2bf24a4822..0000000000 --- a/packages/core/src/modules/discover-features/handlers/DiscloseMessageHandler.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' - -import { DiscloseMessage } from '../messages' - -export class DiscloseMessageHandler implements Handler { - public supportedMessages = [DiscloseMessage] - - public async handle(inboundMessage: HandlerInboundMessage) { - // We don't really need to do anything with this at the moment - // The result can be hooked into through the generic message processed event - inboundMessage.assertReadyConnection() - } -} diff --git a/packages/core/src/modules/discover-features/handlers/QueryMessageHandler.ts b/packages/core/src/modules/discover-features/handlers/QueryMessageHandler.ts deleted file mode 100644 index 4e343b9b52..0000000000 --- a/packages/core/src/modules/discover-features/handlers/QueryMessageHandler.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' -import type { DiscoverFeaturesService } from '../services/DiscoverFeaturesService' - -import { createOutboundMessage } from '../../../agent/helpers' -import { QueryMessage } from '../messages' - -export class QueryMessageHandler implements Handler { - private discoverFeaturesService: DiscoverFeaturesService - public supportedMessages = [QueryMessage] - - public constructor(discoverFeaturesService: DiscoverFeaturesService) { - this.discoverFeaturesService = discoverFeaturesService - } - - public async handle(inboundMessage: HandlerInboundMessage) { - const connection = inboundMessage.assertReadyConnection() - - const discloseMessage = await this.discoverFeaturesService.createDisclose(inboundMessage.message) - - return createOutboundMessage(connection, discloseMessage) - } -} diff --git a/packages/core/src/modules/discover-features/handlers/index.ts b/packages/core/src/modules/discover-features/handlers/index.ts deleted file mode 100644 index 6ae21a8989..0000000000 --- a/packages/core/src/modules/discover-features/handlers/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './DiscloseMessageHandler' -export * from './QueryMessageHandler' diff --git a/packages/core/src/modules/discover-features/index.ts b/packages/core/src/modules/discover-features/index.ts index f2ab347e4d..cfebfc79c6 100644 --- a/packages/core/src/modules/discover-features/index.ts +++ b/packages/core/src/modules/discover-features/index.ts @@ -1,5 +1,3 @@ export * from './DiscoverFeaturesApi' -export * from './handlers' -export * from './messages' -export * from './services' export * from './DiscoverFeaturesModule' +export * from './protocol' diff --git a/packages/core/src/modules/discover-features/protocol/index.ts b/packages/core/src/modules/discover-features/protocol/index.ts new file mode 100644 index 0000000000..4d9da63573 --- /dev/null +++ b/packages/core/src/modules/discover-features/protocol/index.ts @@ -0,0 +1,2 @@ +export * from './v1' +export * from './v2' diff --git a/packages/core/src/modules/discover-features/protocol/v1/V1DiscoverFeaturesService.ts b/packages/core/src/modules/discover-features/protocol/v1/V1DiscoverFeaturesService.ts new file mode 100644 index 0000000000..e60a9e7d0e --- /dev/null +++ b/packages/core/src/modules/discover-features/protocol/v1/V1DiscoverFeaturesService.ts @@ -0,0 +1,142 @@ +import type { AgentMessage } from '../../../../agent/AgentMessage' +import type { InboundMessageContext } from '../../../../agent/models/InboundMessageContext' +import type { + DiscoverFeaturesDisclosureReceivedEvent, + DiscoverFeaturesQueryReceivedEvent, +} from '../../DiscoverFeaturesEvents' +import type { + CreateDisclosureOptions, + CreateQueryOptions, + DiscoverFeaturesProtocolMsgReturnType, +} from '../../DiscoverFeaturesServiceOptions' + +import { Dispatcher } from '../../../../agent/Dispatcher' +import { EventEmitter } from '../../../../agent/EventEmitter' +import { FeatureRegistry } from '../../../../agent/FeatureRegistry' +import { Protocol } from '../../../../agent/models' +import { InjectionSymbols } from '../../../../constants' +import { AriesFrameworkError } from '../../../../error' +import { Logger } from '../../../../logger' +import { inject, injectable } from '../../../../plugins' +import { DiscoverFeaturesEventTypes } from '../../DiscoverFeaturesEvents' +import { DiscoverFeaturesModuleConfig } from '../../DiscoverFeaturesModuleConfig' +import { DiscoverFeaturesService } from '../../services' + +import { V1DiscloseMessageHandler, V1QueryMessageHandler } from './handlers' +import { V1QueryMessage, V1DiscloseMessage, DiscloseProtocol } from './messages' + +@injectable() +export class V1DiscoverFeaturesService extends DiscoverFeaturesService { + public constructor( + featureRegistry: FeatureRegistry, + eventEmitter: EventEmitter, + dispatcher: Dispatcher, + @inject(InjectionSymbols.Logger) logger: Logger, + discoverFeaturesConfig: DiscoverFeaturesModuleConfig + ) { + super(featureRegistry, eventEmitter, dispatcher, logger, discoverFeaturesConfig) + + this.registerHandlers(dispatcher) + } + + /** + * The version of the discover features protocol this service supports + */ + public readonly version = 'v1' + + private registerHandlers(dispatcher: Dispatcher) { + dispatcher.registerHandler(new V1DiscloseMessageHandler(this)) + dispatcher.registerHandler(new V1QueryMessageHandler(this)) + } + + public async createQuery( + options: CreateQueryOptions + ): Promise> { + if (options.queries.length > 1) { + throw new AriesFrameworkError('Discover Features V1 only supports a single query') + } + + if (options.queries[0].featureType !== 'protocol') { + throw new AriesFrameworkError('Discover Features V1 only supports querying for protocol support') + } + + const queryMessage = new V1QueryMessage({ + query: options.queries[0].match, + comment: options.comment, + }) + + return { message: queryMessage } + } + + public async processQuery( + messageContext: InboundMessageContext + ): Promise | void> { + const { query, threadId } = messageContext.message + + const connection = messageContext.assertReadyConnection() + + this.eventEmitter.emit(messageContext.agentContext, { + type: DiscoverFeaturesEventTypes.QueryReceived, + payload: { + message: messageContext.message, + connection, + queries: [{ featureType: 'protocol', match: query }], + protocolVersion: this.version, + threadId, + }, + }) + + // Process query and send responde automatically if configured to do so, otherwise + // just send the event and let controller decide + if (this.discoverFeaturesModuleConfig.autoAcceptQueries) { + return await this.createDisclosure({ + threadId, + disclosureQueries: [{ featureType: 'protocol', match: query }], + }) + } + } + + public async createDisclosure( + options: CreateDisclosureOptions + ): Promise> { + if (options.disclosureQueries.some((item) => item.featureType !== 'protocol')) { + throw new AriesFrameworkError('Discover Features V1 only supports protocols') + } + + if (!options.threadId) { + throw new AriesFrameworkError('Thread Id is required for Discover Features V1 disclosure') + } + + const matches = this.featureRegistry.query(...options.disclosureQueries) + + const discloseMessage = new V1DiscloseMessage({ + threadId: options.threadId, + protocols: matches.map( + (item) => + new DiscloseProtocol({ + protocolId: (item as Protocol).id, + roles: (item as Protocol).roles, + }) + ), + }) + + return { message: discloseMessage } + } + + public async processDisclosure(messageContext: InboundMessageContext): Promise { + const { protocols, threadId } = messageContext.message + + const connection = messageContext.assertReadyConnection() + + this.eventEmitter.emit(messageContext.agentContext, { + type: DiscoverFeaturesEventTypes.DisclosureReceived, + payload: { + message: messageContext.message, + connection, + disclosures: protocols.map((item) => new Protocol({ id: item.protocolId, roles: item.roles })), + protocolVersion: this.version, + threadId, + }, + }) + } +} diff --git a/packages/core/src/modules/discover-features/protocol/v1/__tests__/V1DiscoverFeaturesService.test.ts b/packages/core/src/modules/discover-features/protocol/v1/__tests__/V1DiscoverFeaturesService.test.ts new file mode 100644 index 0000000000..133a2b3442 --- /dev/null +++ b/packages/core/src/modules/discover-features/protocol/v1/__tests__/V1DiscoverFeaturesService.test.ts @@ -0,0 +1,277 @@ +import type { + DiscoverFeaturesDisclosureReceivedEvent, + DiscoverFeaturesQueryReceivedEvent, +} from '../../../DiscoverFeaturesEvents' +import type { DiscoverFeaturesProtocolMsgReturnType } from '../../../DiscoverFeaturesServiceOptions' + +import { Subject } from 'rxjs' + +import { agentDependencies, getAgentContext, getMockConnection } from '../../../../../../tests/helpers' +import { Dispatcher } from '../../../../../agent/Dispatcher' +import { EventEmitter } from '../../../../../agent/EventEmitter' +import { FeatureRegistry } from '../../../../../agent/FeatureRegistry' +import { Protocol } from '../../../../../agent/models' +import { InboundMessageContext } from '../../../../../agent/models/InboundMessageContext' +import { ConsoleLogger } from '../../../../../logger/ConsoleLogger' +import { DidExchangeState } from '../../../../../modules/connections' +import { DiscoverFeaturesEventTypes } from '../../../DiscoverFeaturesEvents' +import { DiscoverFeaturesModuleConfig } from '../../../DiscoverFeaturesModuleConfig' +import { V1DiscoverFeaturesService } from '../V1DiscoverFeaturesService' +import { V1DiscloseMessage, V1QueryMessage } from '../messages' + +jest.mock('../../../../../agent/Dispatcher') +const DispatcherMock = Dispatcher as jest.Mock +const eventEmitter = new EventEmitter(agentDependencies, new Subject()) +const featureRegistry = new FeatureRegistry() +featureRegistry.register(new Protocol({ id: 'https://didcomm.org/connections/1.0' })) +featureRegistry.register(new Protocol({ id: 'https://didcomm.org/notification/1.0', roles: ['role-1', 'role-2'] })) +featureRegistry.register(new Protocol({ id: 'https://didcomm.org/issue-credential/1.0' })) + +jest.mock('../../../../../logger/Logger') +const LoggerMock = ConsoleLogger as jest.Mock + +describe('V1DiscoverFeaturesService - auto accept queries', () => { + const discoverFeaturesModuleConfig = new DiscoverFeaturesModuleConfig({ autoAcceptQueries: true }) + + const discoverFeaturesService = new V1DiscoverFeaturesService( + featureRegistry, + eventEmitter, + new DispatcherMock(), + new LoggerMock(), + discoverFeaturesModuleConfig + ) + describe('createDisclosure', () => { + it('should return all protocols when query is *', async () => { + const queryMessage = new V1QueryMessage({ + query: '*', + }) + + const { message } = await discoverFeaturesService.createDisclosure({ + disclosureQueries: [{ featureType: 'protocol', match: queryMessage.query }], + threadId: queryMessage.threadId, + }) + + expect(message.protocols.map((p) => p.protocolId)).toStrictEqual([ + 'https://didcomm.org/connections/1.0', + 'https://didcomm.org/notification/1.0', + 'https://didcomm.org/issue-credential/1.0', + ]) + }) + + it('should return only one protocol if the query specifies a specific protocol', async () => { + const queryMessage = new V1QueryMessage({ + query: 'https://didcomm.org/connections/1.0', + }) + + const { message } = await discoverFeaturesService.createDisclosure({ + disclosureQueries: [{ featureType: 'protocol', match: queryMessage.query }], + threadId: queryMessage.threadId, + }) + + expect(message.protocols.map((p) => p.protocolId)).toStrictEqual(['https://didcomm.org/connections/1.0']) + }) + + it('should respect a wild card at the end of the query', async () => { + const queryMessage = new V1QueryMessage({ + query: 'https://didcomm.org/connections/*', + }) + + const { message } = await discoverFeaturesService.createDisclosure({ + disclosureQueries: [{ featureType: 'protocol', match: queryMessage.query }], + threadId: queryMessage.threadId, + }) + + expect(message.protocols.map((p) => p.protocolId)).toStrictEqual(['https://didcomm.org/connections/1.0']) + }) + + it('should send an empty array if no feature matches query', async () => { + const queryMessage = new V1QueryMessage({ + query: 'not-supported', + }) + + const { message } = await discoverFeaturesService.createDisclosure({ + disclosureQueries: [{ featureType: 'protocol', match: queryMessage.query }], + threadId: queryMessage.threadId, + }) + + expect(message.protocols.map((p) => p.protocolId)).toStrictEqual([]) + }) + + it('should throw error if features other than protocols are disclosed', async () => { + expect( + discoverFeaturesService.createDisclosure({ + disclosureQueries: [ + { featureType: 'protocol', match: '1' }, + { featureType: 'goal-code', match: '2' }, + ], + threadId: '1234', + }) + ).rejects.toThrow('Discover Features V1 only supports protocols') + }) + + it('should throw error if no thread id is provided', async () => { + expect( + discoverFeaturesService.createDisclosure({ + disclosureQueries: [{ featureType: 'protocol', match: '1' }], + }) + ).rejects.toThrow('Thread Id is required for Discover Features V1 disclosure') + }) + }) + + describe('createQuery', () => { + it('should return a query message with the query and comment', async () => { + const { message } = await discoverFeaturesService.createQuery({ + queries: [{ featureType: 'protocol', match: '*' }], + comment: 'Hello', + }) + + expect(message.query).toBe('*') + expect(message.comment).toBe('Hello') + }) + + it('should throw error if multiple features are queried', async () => { + expect( + discoverFeaturesService.createQuery({ + queries: [ + { featureType: 'protocol', match: '1' }, + { featureType: 'protocol', match: '2' }, + ], + }) + ).rejects.toThrow('Discover Features V1 only supports a single query') + }) + + it('should throw error if a feature other than protocol is queried', async () => { + expect( + discoverFeaturesService.createQuery({ + queries: [{ featureType: 'goal-code', match: '1' }], + }) + ).rejects.toThrow('Discover Features V1 only supports querying for protocol support') + }) + }) + + describe('processQuery', () => { + it('should emit event and create disclosure message', async () => { + const eventListenerMock = jest.fn() + eventEmitter.on(DiscoverFeaturesEventTypes.QueryReceived, eventListenerMock) + + const queryMessage = new V1QueryMessage({ query: '*' }) + + const connection = getMockConnection({ state: DidExchangeState.Completed }) + const messageContext = new InboundMessageContext(queryMessage, { + agentContext: getAgentContext(), + connection, + }) + const outboundMessage = await discoverFeaturesService.processQuery(messageContext) + + eventEmitter.off(DiscoverFeaturesEventTypes.QueryReceived, eventListenerMock) + + expect(eventListenerMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: DiscoverFeaturesEventTypes.QueryReceived, + payload: expect.objectContaining({ + connection, + protocolVersion: 'v1', + queries: [{ featureType: 'protocol', match: queryMessage.query }], + threadId: queryMessage.threadId, + }), + }) + ) + expect(outboundMessage).toBeDefined() + expect( + (outboundMessage as DiscoverFeaturesProtocolMsgReturnType).message.protocols.map( + (p) => p.protocolId + ) + ).toStrictEqual([ + 'https://didcomm.org/connections/1.0', + 'https://didcomm.org/notification/1.0', + 'https://didcomm.org/issue-credential/1.0', + ]) + }) + }) + + describe('processDisclosure', () => { + it('should emit event', async () => { + const eventListenerMock = jest.fn() + eventEmitter.on( + DiscoverFeaturesEventTypes.DisclosureReceived, + eventListenerMock + ) + + const discloseMessage = new V1DiscloseMessage({ + protocols: [{ protocolId: 'prot1', roles: ['role1', 'role2'] }, { protocolId: 'prot2' }], + threadId: '1234', + }) + + const connection = getMockConnection({ state: DidExchangeState.Completed }) + const messageContext = new InboundMessageContext(discloseMessage, { + agentContext: getAgentContext(), + connection, + }) + await discoverFeaturesService.processDisclosure(messageContext) + + eventEmitter.off( + DiscoverFeaturesEventTypes.DisclosureReceived, + eventListenerMock + ) + + expect(eventListenerMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: DiscoverFeaturesEventTypes.DisclosureReceived, + payload: expect.objectContaining({ + connection, + protocolVersion: 'v1', + disclosures: [ + { type: 'protocol', id: 'prot1', roles: ['role1', 'role2'] }, + { type: 'protocol', id: 'prot2' }, + ], + + threadId: discloseMessage.threadId, + }), + }) + ) + }) + }) +}) + +describe('V1DiscoverFeaturesService - auto accept disabled', () => { + const discoverFeaturesModuleConfig = new DiscoverFeaturesModuleConfig({ autoAcceptQueries: false }) + + const discoverFeaturesService = new V1DiscoverFeaturesService( + featureRegistry, + eventEmitter, + new DispatcherMock(), + new LoggerMock(), + discoverFeaturesModuleConfig + ) + + describe('processQuery', () => { + it('should emit event and not send any message', async () => { + const eventListenerMock = jest.fn() + eventEmitter.on(DiscoverFeaturesEventTypes.QueryReceived, eventListenerMock) + + const queryMessage = new V1QueryMessage({ query: '*' }) + + const connection = getMockConnection({ state: DidExchangeState.Completed }) + const messageContext = new InboundMessageContext(queryMessage, { + agentContext: getAgentContext(), + connection, + }) + const outboundMessage = await discoverFeaturesService.processQuery(messageContext) + + eventEmitter.off(DiscoverFeaturesEventTypes.QueryReceived, eventListenerMock) + + expect(eventListenerMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: DiscoverFeaturesEventTypes.QueryReceived, + payload: expect.objectContaining({ + connection, + protocolVersion: 'v1', + queries: [{ featureType: 'protocol', match: queryMessage.query }], + threadId: queryMessage.threadId, + }), + }) + ) + expect(outboundMessage).toBeUndefined() + }) + }) +}) diff --git a/packages/core/src/modules/discover-features/protocol/v1/handlers/V1DiscloseMessageHandler.ts b/packages/core/src/modules/discover-features/protocol/v1/handlers/V1DiscloseMessageHandler.ts new file mode 100644 index 0000000000..e7a47da870 --- /dev/null +++ b/packages/core/src/modules/discover-features/protocol/v1/handlers/V1DiscloseMessageHandler.ts @@ -0,0 +1,17 @@ +import type { Handler, HandlerInboundMessage } from '../../../../../agent/Handler' +import type { V1DiscoverFeaturesService } from '../V1DiscoverFeaturesService' + +import { V1DiscloseMessage } from '../messages' + +export class V1DiscloseMessageHandler implements Handler { + public supportedMessages = [V1DiscloseMessage] + private discoverFeaturesService: V1DiscoverFeaturesService + + public constructor(discoverFeaturesService: V1DiscoverFeaturesService) { + this.discoverFeaturesService = discoverFeaturesService + } + + public async handle(inboundMessage: HandlerInboundMessage) { + await this.discoverFeaturesService.processDisclosure(inboundMessage) + } +} diff --git a/packages/core/src/modules/discover-features/protocol/v1/handlers/V1QueryMessageHandler.ts b/packages/core/src/modules/discover-features/protocol/v1/handlers/V1QueryMessageHandler.ts new file mode 100644 index 0000000000..3ef5a66231 --- /dev/null +++ b/packages/core/src/modules/discover-features/protocol/v1/handlers/V1QueryMessageHandler.ts @@ -0,0 +1,24 @@ +import type { Handler, HandlerInboundMessage } from '../../../../../agent/Handler' +import type { V1DiscoverFeaturesService } from '../V1DiscoverFeaturesService' + +import { createOutboundMessage } from '../../../../../agent/helpers' +import { V1QueryMessage } from '../messages' + +export class V1QueryMessageHandler implements Handler { + private discoverFeaturesService: V1DiscoverFeaturesService + public supportedMessages = [V1QueryMessage] + + public constructor(discoverFeaturesService: V1DiscoverFeaturesService) { + this.discoverFeaturesService = discoverFeaturesService + } + + public async handle(inboundMessage: HandlerInboundMessage) { + const connection = inboundMessage.assertReadyConnection() + + const discloseMessage = await this.discoverFeaturesService.processQuery(inboundMessage) + + if (discloseMessage) { + return createOutboundMessage(connection, discloseMessage.message) + } + } +} diff --git a/packages/core/src/modules/discover-features/protocol/v1/handlers/index.ts b/packages/core/src/modules/discover-features/protocol/v1/handlers/index.ts new file mode 100644 index 0000000000..73f3391154 --- /dev/null +++ b/packages/core/src/modules/discover-features/protocol/v1/handlers/index.ts @@ -0,0 +1,2 @@ +export * from './V1DiscloseMessageHandler' +export * from './V1QueryMessageHandler' diff --git a/packages/core/src/modules/discover-features/protocol/v1/index.ts b/packages/core/src/modules/discover-features/protocol/v1/index.ts new file mode 100644 index 0000000000..e13fec27de --- /dev/null +++ b/packages/core/src/modules/discover-features/protocol/v1/index.ts @@ -0,0 +1,2 @@ +export * from './V1DiscoverFeaturesService' +export * from './messages' diff --git a/packages/core/src/modules/discover-features/messages/DiscloseMessage.ts b/packages/core/src/modules/discover-features/protocol/v1/messages/DiscloseMessage.ts similarity index 79% rename from packages/core/src/modules/discover-features/messages/DiscloseMessage.ts rename to packages/core/src/modules/discover-features/protocol/v1/messages/DiscloseMessage.ts index 82dbe9451e..800900424b 100644 --- a/packages/core/src/modules/discover-features/messages/DiscloseMessage.ts +++ b/packages/core/src/modules/discover-features/protocol/v1/messages/DiscloseMessage.ts @@ -1,8 +1,8 @@ import { Expose, Type } from 'class-transformer' import { IsInstance, IsOptional, IsString } from 'class-validator' -import { AgentMessage } from '../../../agent/AgentMessage' -import { IsValidMessageType, parseMessageType } from '../../../utils/messageType' +import { AgentMessage } from '../../../../../agent/AgentMessage' +import { IsValidMessageType, parseMessageType } from '../../../../../utils/messageType' export interface DiscloseProtocolOptions { protocolId: string @@ -32,7 +32,7 @@ export interface DiscoverFeaturesDiscloseMessageOptions { protocols: DiscloseProtocolOptions[] } -export class DiscloseMessage extends AgentMessage { +export class V1DiscloseMessage extends AgentMessage { public constructor(options: DiscoverFeaturesDiscloseMessageOptions) { super() @@ -45,8 +45,8 @@ export class DiscloseMessage extends AgentMessage { } } - @IsValidMessageType(DiscloseMessage.type) - public readonly type = DiscloseMessage.type.messageTypeUri + @IsValidMessageType(V1DiscloseMessage.type) + public readonly type = V1DiscloseMessage.type.messageTypeUri public static readonly type = parseMessageType('https://didcomm.org/discover-features/1.0/disclose') @IsInstance(DiscloseProtocol, { each: true }) diff --git a/packages/core/src/modules/discover-features/messages/QueryMessage.ts b/packages/core/src/modules/discover-features/protocol/v1/messages/QueryMessage.ts similarity index 65% rename from packages/core/src/modules/discover-features/messages/QueryMessage.ts rename to packages/core/src/modules/discover-features/protocol/v1/messages/QueryMessage.ts index 35f635ccd5..7b8d5e26b4 100644 --- a/packages/core/src/modules/discover-features/messages/QueryMessage.ts +++ b/packages/core/src/modules/discover-features/protocol/v1/messages/QueryMessage.ts @@ -1,7 +1,7 @@ import { IsOptional, IsString } from 'class-validator' -import { AgentMessage } from '../../../agent/AgentMessage' -import { IsValidMessageType, parseMessageType } from '../../../utils/messageType' +import { AgentMessage } from '../../../../../agent/AgentMessage' +import { IsValidMessageType, parseMessageType } from '../../../../../utils/messageType' export interface DiscoverFeaturesQueryMessageOptions { id?: string @@ -9,7 +9,7 @@ export interface DiscoverFeaturesQueryMessageOptions { comment?: string } -export class QueryMessage extends AgentMessage { +export class V1QueryMessage extends AgentMessage { public constructor(options: DiscoverFeaturesQueryMessageOptions) { super() @@ -20,8 +20,8 @@ export class QueryMessage extends AgentMessage { } } - @IsValidMessageType(QueryMessage.type) - public readonly type = QueryMessage.type.messageTypeUri + @IsValidMessageType(V1QueryMessage.type) + public readonly type = V1QueryMessage.type.messageTypeUri public static readonly type = parseMessageType('https://didcomm.org/discover-features/1.0/query') @IsString() diff --git a/packages/core/src/modules/discover-features/messages/index.ts b/packages/core/src/modules/discover-features/protocol/v1/messages/index.ts similarity index 100% rename from packages/core/src/modules/discover-features/messages/index.ts rename to packages/core/src/modules/discover-features/protocol/v1/messages/index.ts diff --git a/packages/core/src/modules/discover-features/protocol/v2/V2DiscoverFeaturesService.ts b/packages/core/src/modules/discover-features/protocol/v2/V2DiscoverFeaturesService.ts new file mode 100644 index 0000000000..99ca5a948b --- /dev/null +++ b/packages/core/src/modules/discover-features/protocol/v2/V2DiscoverFeaturesService.ts @@ -0,0 +1,113 @@ +import type { InboundMessageContext } from '../../../../agent/models/InboundMessageContext' +import type { + DiscoverFeaturesDisclosureReceivedEvent, + DiscoverFeaturesQueryReceivedEvent, +} from '../../DiscoverFeaturesEvents' +import type { + CreateQueryOptions, + DiscoverFeaturesProtocolMsgReturnType, + CreateDisclosureOptions, +} from '../../DiscoverFeaturesServiceOptions' + +import { Dispatcher } from '../../../../agent/Dispatcher' +import { EventEmitter } from '../../../../agent/EventEmitter' +import { FeatureRegistry } from '../../../../agent/FeatureRegistry' +import { InjectionSymbols } from '../../../../constants' +import { Logger } from '../../../../logger' +import { inject, injectable } from '../../../../plugins' +import { DiscoverFeaturesEventTypes } from '../../DiscoverFeaturesEvents' +import { DiscoverFeaturesModuleConfig } from '../../DiscoverFeaturesModuleConfig' +import { DiscoverFeaturesService } from '../../services' + +import { V2DisclosuresMessageHandler, V2QueriesMessageHandler } from './handlers' +import { V2QueriesMessage, V2DisclosuresMessage } from './messages' + +@injectable() +export class V2DiscoverFeaturesService extends DiscoverFeaturesService { + public constructor( + featureRegistry: FeatureRegistry, + eventEmitter: EventEmitter, + dispatcher: Dispatcher, + @inject(InjectionSymbols.Logger) logger: Logger, + discoverFeaturesModuleConfig: DiscoverFeaturesModuleConfig + ) { + super(featureRegistry, eventEmitter, dispatcher, logger, discoverFeaturesModuleConfig) + this.registerHandlers(dispatcher) + } + + /** + * The version of the discover features protocol this service supports + */ + public readonly version = 'v2' + + private registerHandlers(dispatcher: Dispatcher) { + dispatcher.registerHandler(new V2DisclosuresMessageHandler(this)) + dispatcher.registerHandler(new V2QueriesMessageHandler(this)) + } + + public async createQuery( + options: CreateQueryOptions + ): Promise> { + const queryMessage = new V2QueriesMessage({ queries: options.queries }) + + return { message: queryMessage } + } + + public async processQuery( + messageContext: InboundMessageContext + ): Promise | void> { + const { queries, threadId } = messageContext.message + + const connection = messageContext.assertReadyConnection() + + this.eventEmitter.emit(messageContext.agentContext, { + type: DiscoverFeaturesEventTypes.QueryReceived, + payload: { + message: messageContext.message, + connection, + queries, + protocolVersion: this.version, + threadId, + }, + }) + + // Process query and send responde automatically if configured to do so, otherwise + // just send the event and let controller decide + if (this.discoverFeaturesModuleConfig.autoAcceptQueries) { + return await this.createDisclosure({ + threadId, + disclosureQueries: queries, + }) + } + } + + public async createDisclosure( + options: CreateDisclosureOptions + ): Promise> { + const matches = this.featureRegistry.query(...options.disclosureQueries) + + const discloseMessage = new V2DisclosuresMessage({ + threadId: options.threadId, + features: matches, + }) + + return { message: discloseMessage } + } + + public async processDisclosure(messageContext: InboundMessageContext): Promise { + const { disclosures, threadId } = messageContext.message + + const connection = messageContext.assertReadyConnection() + + this.eventEmitter.emit(messageContext.agentContext, { + type: DiscoverFeaturesEventTypes.DisclosureReceived, + payload: { + message: messageContext.message, + connection, + disclosures, + protocolVersion: this.version, + threadId, + }, + }) + } +} diff --git a/packages/core/src/modules/discover-features/protocol/v2/__tests__/V2DiscoverFeaturesService.test.ts b/packages/core/src/modules/discover-features/protocol/v2/__tests__/V2DiscoverFeaturesService.test.ts new file mode 100644 index 0000000000..9669c9a63f --- /dev/null +++ b/packages/core/src/modules/discover-features/protocol/v2/__tests__/V2DiscoverFeaturesService.test.ts @@ -0,0 +1,288 @@ +import type { + DiscoverFeaturesDisclosureReceivedEvent, + DiscoverFeaturesQueryReceivedEvent, +} from '../../../DiscoverFeaturesEvents' +import type { DiscoverFeaturesProtocolMsgReturnType } from '../../../DiscoverFeaturesServiceOptions' + +import { Subject } from 'rxjs' + +import { agentDependencies, getAgentContext, getMockConnection } from '../../../../../../tests/helpers' +import { Dispatcher } from '../../../../../agent/Dispatcher' +import { EventEmitter } from '../../../../../agent/EventEmitter' +import { FeatureRegistry } from '../../../../../agent/FeatureRegistry' +import { InboundMessageContext, Protocol, GoalCode } from '../../../../../agent/models' +import { ConsoleLogger } from '../../../../../logger/ConsoleLogger' +import { DidExchangeState } from '../../../../connections' +import { DiscoverFeaturesEventTypes } from '../../../DiscoverFeaturesEvents' +import { DiscoverFeaturesModuleConfig } from '../../../DiscoverFeaturesModuleConfig' +import { V2DiscoverFeaturesService } from '../V2DiscoverFeaturesService' +import { V2DisclosuresMessage, V2QueriesMessage } from '../messages' + +jest.mock('../../../../../agent/Dispatcher') +const DispatcherMock = Dispatcher as jest.Mock +const eventEmitter = new EventEmitter(agentDependencies, new Subject()) +const featureRegistry = new FeatureRegistry() +featureRegistry.register(new Protocol({ id: 'https://didcomm.org/connections/1.0' })) +featureRegistry.register(new Protocol({ id: 'https://didcomm.org/notification/1.0', roles: ['role-1', 'role-2'] })) +featureRegistry.register(new Protocol({ id: 'https://didcomm.org/issue-credential/1.0' })) +featureRegistry.register(new GoalCode({ id: 'aries.vc.1' })) +featureRegistry.register(new GoalCode({ id: 'aries.vc.2' })) +featureRegistry.register(new GoalCode({ id: 'caries.vc.3' })) + +jest.mock('../../../../../logger/Logger') +const LoggerMock = ConsoleLogger as jest.Mock + +describe('V2DiscoverFeaturesService - auto accept queries', () => { + const discoverFeaturesModuleConfig = new DiscoverFeaturesModuleConfig({ autoAcceptQueries: true }) + + const discoverFeaturesService = new V2DiscoverFeaturesService( + featureRegistry, + eventEmitter, + new DispatcherMock(), + new LoggerMock(), + discoverFeaturesModuleConfig + ) + describe('createDisclosure', () => { + it('should return all items when query is *', async () => { + const queryMessage = new V2QueriesMessage({ + queries: [ + { featureType: Protocol.type, match: '*' }, + { featureType: GoalCode.type, match: '*' }, + ], + }) + + const { message } = await discoverFeaturesService.createDisclosure({ + disclosureQueries: queryMessage.queries, + threadId: queryMessage.threadId, + }) + + expect(message.disclosures.map((p) => p.id)).toStrictEqual([ + 'https://didcomm.org/connections/1.0', + 'https://didcomm.org/notification/1.0', + 'https://didcomm.org/issue-credential/1.0', + 'aries.vc.1', + 'aries.vc.2', + 'caries.vc.3', + ]) + }) + + it('should return only one protocol if the query specifies a specific protocol', async () => { + const queryMessage = new V2QueriesMessage({ + queries: [{ featureType: 'protocol', match: 'https://didcomm.org/connections/1.0' }], + }) + + const { message } = await discoverFeaturesService.createDisclosure({ + disclosureQueries: queryMessage.queries, + threadId: queryMessage.threadId, + }) + + expect(message.disclosures).toEqual([{ type: 'protocol', id: 'https://didcomm.org/connections/1.0' }]) + }) + + it('should respect a wild card at the end of the query', async () => { + const queryMessage = new V2QueriesMessage({ + queries: [ + { featureType: 'protocol', match: 'https://didcomm.org/connections/*' }, + { featureType: 'goal-code', match: 'aries*' }, + ], + }) + + const { message } = await discoverFeaturesService.createDisclosure({ + disclosureQueries: queryMessage.queries, + threadId: queryMessage.threadId, + }) + + expect(message.disclosures.map((p) => p.id)).toStrictEqual([ + 'https://didcomm.org/connections/1.0', + 'aries.vc.1', + 'aries.vc.2', + ]) + }) + + it('should send an empty array if no feature matches query', async () => { + const queryMessage = new V2QueriesMessage({ + queries: [{ featureType: 'anything', match: 'not-supported' }], + }) + + const { message } = await discoverFeaturesService.createDisclosure({ + disclosureQueries: queryMessage.queries, + threadId: queryMessage.threadId, + }) + + expect(message.disclosures).toStrictEqual([]) + }) + + it('should accept an empty queries object', async () => { + const { message } = await discoverFeaturesService.createDisclosure({ + disclosureQueries: [], + threadId: '1234', + }) + + expect(message.disclosures).toStrictEqual([]) + }) + + it('should accept no thread Id', async () => { + const { message } = await discoverFeaturesService.createDisclosure({ + disclosureQueries: [{ featureType: 'goal-code', match: 'caries*' }], + }) + + expect(message.disclosures).toEqual([ + { + type: 'goal-code', + id: 'caries.vc.3', + }, + ]) + expect(message.threadId).toEqual(message.id) + }) + }) + + describe('createQuery', () => { + it('should return a queries message with the query and comment', async () => { + const { message } = await discoverFeaturesService.createQuery({ + queries: [{ featureType: 'protocol', match: '*' }], + }) + + expect(message.queries).toEqual([{ featureType: 'protocol', match: '*' }]) + }) + + it('should accept multiple features', async () => { + const { message } = await discoverFeaturesService.createQuery({ + queries: [ + { featureType: 'protocol', match: '1' }, + { featureType: 'anything', match: '2' }, + ], + }) + + expect(message.queries).toEqual([ + { featureType: 'protocol', match: '1' }, + { featureType: 'anything', match: '2' }, + ]) + }) + }) + + describe('processQuery', () => { + it('should emit event and create disclosure message', async () => { + const eventListenerMock = jest.fn() + eventEmitter.on(DiscoverFeaturesEventTypes.QueryReceived, eventListenerMock) + + const queryMessage = new V2QueriesMessage({ queries: [{ featureType: 'protocol', match: '*' }] }) + + const connection = getMockConnection({ state: DidExchangeState.Completed }) + const messageContext = new InboundMessageContext(queryMessage, { + agentContext: getAgentContext(), + connection, + }) + const outboundMessage = await discoverFeaturesService.processQuery(messageContext) + + eventEmitter.off(DiscoverFeaturesEventTypes.QueryReceived, eventListenerMock) + + expect(eventListenerMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: DiscoverFeaturesEventTypes.QueryReceived, + payload: expect.objectContaining({ + connection, + protocolVersion: 'v2', + queries: queryMessage.queries, + threadId: queryMessage.threadId, + }), + }) + ) + expect(outboundMessage).toBeDefined() + expect( + (outboundMessage as DiscoverFeaturesProtocolMsgReturnType).message.disclosures.map( + (p) => p.id + ) + ).toStrictEqual([ + 'https://didcomm.org/connections/1.0', + 'https://didcomm.org/notification/1.0', + 'https://didcomm.org/issue-credential/1.0', + ]) + }) + }) + + describe('processDisclosure', () => { + it('should emit event', async () => { + const eventListenerMock = jest.fn() + eventEmitter.on( + DiscoverFeaturesEventTypes.DisclosureReceived, + eventListenerMock + ) + + const discloseMessage = new V2DisclosuresMessage({ + features: [new Protocol({ id: 'prot1', roles: ['role1', 'role2'] }), new Protocol({ id: 'prot2' })], + threadId: '1234', + }) + + const connection = getMockConnection({ state: DidExchangeState.Completed }) + const messageContext = new InboundMessageContext(discloseMessage, { + agentContext: getAgentContext(), + connection, + }) + await discoverFeaturesService.processDisclosure(messageContext) + + eventEmitter.off( + DiscoverFeaturesEventTypes.DisclosureReceived, + eventListenerMock + ) + + expect(eventListenerMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: DiscoverFeaturesEventTypes.DisclosureReceived, + payload: expect.objectContaining({ + connection, + protocolVersion: 'v2', + disclosures: [ + { type: 'protocol', id: 'prot1', roles: ['role1', 'role2'] }, + { type: 'protocol', id: 'prot2' }, + ], + + threadId: discloseMessage.threadId, + }), + }) + ) + }) + }) +}) + +describe('V2DiscoverFeaturesService - auto accept disabled', () => { + const discoverFeaturesModuleConfig = new DiscoverFeaturesModuleConfig({ autoAcceptQueries: false }) + + const discoverFeaturesService = new V2DiscoverFeaturesService( + featureRegistry, + eventEmitter, + new DispatcherMock(), + new LoggerMock(), + discoverFeaturesModuleConfig + ) + + describe('processQuery', () => { + it('should emit event and not send any message', async () => { + const eventListenerMock = jest.fn() + eventEmitter.on(DiscoverFeaturesEventTypes.QueryReceived, eventListenerMock) + + const queryMessage = new V2QueriesMessage({ queries: [{ featureType: 'protocol', match: '*' }] }) + + const connection = getMockConnection({ state: DidExchangeState.Completed }) + const messageContext = new InboundMessageContext(queryMessage, { + agentContext: getAgentContext(), + connection, + }) + const outboundMessage = await discoverFeaturesService.processQuery(messageContext) + + eventEmitter.off(DiscoverFeaturesEventTypes.QueryReceived, eventListenerMock) + + expect(eventListenerMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: DiscoverFeaturesEventTypes.QueryReceived, + payload: expect.objectContaining({ + connection, + protocolVersion: 'v2', + queries: queryMessage.queries, + threadId: queryMessage.threadId, + }), + }) + ) + expect(outboundMessage).toBeUndefined() + }) + }) +}) diff --git a/packages/core/src/modules/discover-features/protocol/v2/handlers/V2DisclosuresMessageHandler.ts b/packages/core/src/modules/discover-features/protocol/v2/handlers/V2DisclosuresMessageHandler.ts new file mode 100644 index 0000000000..7bf631f92c --- /dev/null +++ b/packages/core/src/modules/discover-features/protocol/v2/handlers/V2DisclosuresMessageHandler.ts @@ -0,0 +1,17 @@ +import type { Handler, HandlerInboundMessage } from '../../../../../agent/Handler' +import type { V2DiscoverFeaturesService } from '../V2DiscoverFeaturesService' + +import { V2DisclosuresMessage } from '../messages' + +export class V2DisclosuresMessageHandler implements Handler { + private discoverFeaturesService: V2DiscoverFeaturesService + public supportedMessages = [V2DisclosuresMessage] + + public constructor(discoverFeaturesService: V2DiscoverFeaturesService) { + this.discoverFeaturesService = discoverFeaturesService + } + + public async handle(inboundMessage: HandlerInboundMessage) { + await this.discoverFeaturesService.processDisclosure(inboundMessage) + } +} diff --git a/packages/core/src/modules/discover-features/protocol/v2/handlers/V2QueriesMessageHandler.ts b/packages/core/src/modules/discover-features/protocol/v2/handlers/V2QueriesMessageHandler.ts new file mode 100644 index 0000000000..d637bf2bc7 --- /dev/null +++ b/packages/core/src/modules/discover-features/protocol/v2/handlers/V2QueriesMessageHandler.ts @@ -0,0 +1,24 @@ +import type { Handler, HandlerInboundMessage } from '../../../../../agent/Handler' +import type { V2DiscoverFeaturesService } from '../V2DiscoverFeaturesService' + +import { createOutboundMessage } from '../../../../../agent/helpers' +import { V2QueriesMessage } from '../messages' + +export class V2QueriesMessageHandler implements Handler { + private discoverFeaturesService: V2DiscoverFeaturesService + public supportedMessages = [V2QueriesMessage] + + public constructor(discoverFeaturesService: V2DiscoverFeaturesService) { + this.discoverFeaturesService = discoverFeaturesService + } + + public async handle(inboundMessage: HandlerInboundMessage) { + const connection = inboundMessage.assertReadyConnection() + + const discloseMessage = await this.discoverFeaturesService.processQuery(inboundMessage) + + if (discloseMessage) { + return createOutboundMessage(connection, discloseMessage.message) + } + } +} diff --git a/packages/core/src/modules/discover-features/protocol/v2/handlers/index.ts b/packages/core/src/modules/discover-features/protocol/v2/handlers/index.ts new file mode 100644 index 0000000000..e4e6ce65a4 --- /dev/null +++ b/packages/core/src/modules/discover-features/protocol/v2/handlers/index.ts @@ -0,0 +1,2 @@ +export * from './V2DisclosuresMessageHandler' +export * from './V2QueriesMessageHandler' diff --git a/packages/core/src/modules/discover-features/protocol/v2/index.ts b/packages/core/src/modules/discover-features/protocol/v2/index.ts new file mode 100644 index 0000000000..f3bc1281ae --- /dev/null +++ b/packages/core/src/modules/discover-features/protocol/v2/index.ts @@ -0,0 +1,2 @@ +export * from './V2DiscoverFeaturesService' +export * from './messages' diff --git a/packages/core/src/modules/discover-features/protocol/v2/messages/V2DisclosuresMessage.ts b/packages/core/src/modules/discover-features/protocol/v2/messages/V2DisclosuresMessage.ts new file mode 100644 index 0000000000..de029d7b29 --- /dev/null +++ b/packages/core/src/modules/discover-features/protocol/v2/messages/V2DisclosuresMessage.ts @@ -0,0 +1,36 @@ +import { Type } from 'class-transformer' +import { IsInstance } from 'class-validator' + +import { AgentMessage } from '../../../../../agent/AgentMessage' +import { Feature } from '../../../../../agent/models' +import { IsValidMessageType, parseMessageType } from '../../../../../utils/messageType' + +export interface V2DisclosuresMessageOptions { + id?: string + threadId?: string + features?: Feature[] +} + +export class V2DisclosuresMessage extends AgentMessage { + public constructor(options: V2DisclosuresMessageOptions) { + super() + + if (options) { + this.id = options.id ?? this.generateId() + this.disclosures = options.features ?? [] + if (options.threadId) { + this.setThread({ + threadId: options.threadId, + }) + } + } + } + + @IsValidMessageType(V2DisclosuresMessage.type) + public readonly type = V2DisclosuresMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/discover-features/2.0/disclosures') + + @IsInstance(Feature, { each: true }) + @Type(() => Feature) + public disclosures!: Feature[] +} diff --git a/packages/core/src/modules/discover-features/protocol/v2/messages/V2QueriesMessage.ts b/packages/core/src/modules/discover-features/protocol/v2/messages/V2QueriesMessage.ts new file mode 100644 index 0000000000..b5de37fa20 --- /dev/null +++ b/packages/core/src/modules/discover-features/protocol/v2/messages/V2QueriesMessage.ts @@ -0,0 +1,34 @@ +import type { FeatureQueryOptions } from '../../../../../agent/models' + +import { Type } from 'class-transformer' +import { ArrayNotEmpty, IsInstance } from 'class-validator' + +import { AgentMessage } from '../../../../../agent/AgentMessage' +import { FeatureQuery } from '../../../../../agent/models' +import { IsValidMessageType, parseMessageType } from '../../../../../utils/messageType' + +export interface V2DiscoverFeaturesQueriesMessageOptions { + id?: string + queries: FeatureQueryOptions[] + comment?: string +} + +export class V2QueriesMessage extends AgentMessage { + public constructor(options: V2DiscoverFeaturesQueriesMessageOptions) { + super() + + if (options) { + this.id = options.id ?? this.generateId() + this.queries = options.queries.map((q) => new FeatureQuery(q)) + } + } + + @IsValidMessageType(V2QueriesMessage.type) + public readonly type = V2QueriesMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/discover-features/2.0/queries') + + @IsInstance(FeatureQuery, { each: true }) + @Type(() => FeatureQuery) + @ArrayNotEmpty() + public queries!: FeatureQuery[] +} diff --git a/packages/core/src/modules/discover-features/protocol/v2/messages/index.ts b/packages/core/src/modules/discover-features/protocol/v2/messages/index.ts new file mode 100644 index 0000000000..ec88209bce --- /dev/null +++ b/packages/core/src/modules/discover-features/protocol/v2/messages/index.ts @@ -0,0 +1,2 @@ +export * from './V2DisclosuresMessage' +export * from './V2QueriesMessage' diff --git a/packages/core/src/modules/discover-features/services/DiscoverFeaturesService.ts b/packages/core/src/modules/discover-features/services/DiscoverFeaturesService.ts index 433792070c..0720c8747a 100644 --- a/packages/core/src/modules/discover-features/services/DiscoverFeaturesService.ts +++ b/packages/core/src/modules/discover-features/services/DiscoverFeaturesService.ts @@ -1,42 +1,46 @@ -import { Dispatcher } from '../../../agent/Dispatcher' -import { injectable } from '../../../plugins' -import { QueryMessage, DiscloseMessage } from '../messages' - -@injectable() -export class DiscoverFeaturesService { - private dispatcher: Dispatcher - - public constructor(dispatcher: Dispatcher) { +import type { AgentMessage } from '../../../agent/AgentMessage' +import type { Dispatcher } from '../../../agent/Dispatcher' +import type { EventEmitter } from '../../../agent/EventEmitter' +import type { FeatureRegistry } from '../../../agent/FeatureRegistry' +import type { InboundMessageContext } from '../../../agent/models/InboundMessageContext' +import type { Logger } from '../../../logger' +import type { DiscoverFeaturesModuleConfig } from '../DiscoverFeaturesModuleConfig' +import type { + CreateDisclosureOptions, + CreateQueryOptions, + DiscoverFeaturesProtocolMsgReturnType, +} from '../DiscoverFeaturesServiceOptions' + +export abstract class DiscoverFeaturesService { + protected featureRegistry: FeatureRegistry + protected eventEmitter: EventEmitter + protected dispatcher: Dispatcher + protected logger: Logger + protected discoverFeaturesModuleConfig: DiscoverFeaturesModuleConfig + + public constructor( + featureRegistry: FeatureRegistry, + eventEmitter: EventEmitter, + dispatcher: Dispatcher, + logger: Logger, + discoverFeaturesModuleConfig: DiscoverFeaturesModuleConfig + ) { + this.featureRegistry = featureRegistry + this.eventEmitter = eventEmitter this.dispatcher = dispatcher + this.logger = logger + this.discoverFeaturesModuleConfig = discoverFeaturesModuleConfig } - public async createQuery(options: { query: string; comment?: string }) { - const queryMessage = new QueryMessage(options) - - return queryMessage - } - - public async createDisclose(queryMessage: QueryMessage) { - const { query } = queryMessage + abstract readonly version: string - const messageFamilies = this.dispatcher.supportedProtocols + abstract createQuery(options: CreateQueryOptions): Promise> + abstract processQuery( + messageContext: InboundMessageContext + ): Promise | void> - let protocols: string[] = [] - - if (query === '*') { - protocols = messageFamilies - } else if (query.endsWith('*')) { - const match = query.slice(0, -1) - protocols = messageFamilies.filter((m) => m.startsWith(match)) - } else if (messageFamilies.includes(query)) { - protocols = [query] - } - - const discloseMessage = new DiscloseMessage({ - threadId: queryMessage.threadId, - protocols: protocols.map((protocolId) => ({ protocolId })), - }) - - return discloseMessage - } + abstract createDisclosure( + options: CreateDisclosureOptions + ): Promise> + abstract processDisclosure(messageContext: InboundMessageContext): Promise } diff --git a/packages/core/src/modules/oob/OutOfBandApi.ts b/packages/core/src/modules/oob/OutOfBandApi.ts index a5edd589c0..7408417bd7 100644 --- a/packages/core/src/modules/oob/OutOfBandApi.ts +++ b/packages/core/src/modules/oob/OutOfBandApi.ts @@ -12,8 +12,10 @@ import { AgentContext } from '../../agent' import { Dispatcher } from '../../agent/Dispatcher' import { EventEmitter } from '../../agent/EventEmitter' import { filterContextCorrelationId, AgentEventTypes } from '../../agent/Events' +import { FeatureRegistry } from '../../agent/FeatureRegistry' import { MessageSender } from '../../agent/MessageSender' import { createOutboundMessage } from '../../agent/helpers' +import { FeatureQuery } from '../../agent/models' import { InjectionSymbols } from '../../constants' import { ServiceDecorator } from '../../decorators/service/ServiceDecorator' import { AriesFrameworkError } from '../../error' @@ -83,6 +85,7 @@ export class OutOfBandApi { private connectionsApi: ConnectionsApi private didCommMessageRepository: DidCommMessageRepository private dispatcher: Dispatcher + private featureRegistry: FeatureRegistry private messageSender: MessageSender private eventEmitter: EventEmitter private agentContext: AgentContext @@ -90,6 +93,7 @@ export class OutOfBandApi { public constructor( dispatcher: Dispatcher, + featureRegistry: FeatureRegistry, outOfBandService: OutOfBandService, routingService: RoutingService, connectionsApi: ConnectionsApi, @@ -100,6 +104,7 @@ export class OutOfBandApi { agentContext: AgentContext ) { this.dispatcher = dispatcher + this.featureRegistry = featureRegistry this.agentContext = agentContext this.logger = logger this.outOfBandService = outOfBandService diff --git a/packages/core/src/modules/oob/OutOfBandModule.ts b/packages/core/src/modules/oob/OutOfBandModule.ts index 3b31876a48..6cf1cebdd0 100644 --- a/packages/core/src/modules/oob/OutOfBandModule.ts +++ b/packages/core/src/modules/oob/OutOfBandModule.ts @@ -1,5 +1,8 @@ +import type { FeatureRegistry } from '../../agent/FeatureRegistry' import type { DependencyManager, Module } from '../../plugins' +import { Protocol } from '../../agent/models' + import { OutOfBandApi } from './OutOfBandApi' import { OutOfBandService } from './OutOfBandService' import { OutOfBandRepository } from './repository' @@ -8,7 +11,7 @@ export class OutOfBandModule implements Module { /** * Registers the dependencies of the ot of band module on the dependency manager. */ - public register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager, featureRegistry: FeatureRegistry) { // Api dependencyManager.registerContextScoped(OutOfBandApi) @@ -17,5 +20,13 @@ export class OutOfBandModule implements Module { // Repositories dependencyManager.registerSingleton(OutOfBandRepository) + + // Features + featureRegistry.register( + new Protocol({ + id: 'https://didcomm.org/out-of-band/1.1', + roles: ['sender', 'receiver'], + }) + ) } } diff --git a/packages/core/src/modules/oob/__tests__/OutOfBandModule.test.ts b/packages/core/src/modules/oob/__tests__/OutOfBandModule.test.ts index 6613250092..b1c9337335 100644 --- a/packages/core/src/modules/oob/__tests__/OutOfBandModule.test.ts +++ b/packages/core/src/modules/oob/__tests__/OutOfBandModule.test.ts @@ -1,3 +1,4 @@ +import { FeatureRegistry } from '../../../agent/FeatureRegistry' import { DependencyManager } from '../../../plugins/DependencyManager' import { OutOfBandApi } from '../OutOfBandApi' import { OutOfBandModule } from '../OutOfBandModule' @@ -9,9 +10,13 @@ const DependencyManagerMock = DependencyManager as jest.Mock const dependencyManager = new DependencyManagerMock() +jest.mock('../../../agent/FeatureRegistry') +const FeatureRegistryMock = FeatureRegistry as jest.Mock + +const featureRegistry = new FeatureRegistryMock() describe('OutOfBandModule', () => { test('registers dependencies on the dependency manager', () => { - new OutOfBandModule().register(dependencyManager) + new OutOfBandModule().register(dependencyManager, featureRegistry) expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(OutOfBandApi) diff --git a/packages/core/src/modules/proofs/ProofsModule.ts b/packages/core/src/modules/proofs/ProofsModule.ts index 329c1c17e4..81fe915fd8 100644 --- a/packages/core/src/modules/proofs/ProofsModule.ts +++ b/packages/core/src/modules/proofs/ProofsModule.ts @@ -1,6 +1,9 @@ +import type { FeatureRegistry } from '../../agent/FeatureRegistry' import type { DependencyManager, Module } from '../../plugins' import type { ProofsModuleConfigOptions } from './ProofsModuleConfig' +import { Protocol } from '../../agent/models' + import { ProofsApi } from './ProofsApi' import { ProofsModuleConfig } from './ProofsModuleConfig' import { IndyProofFormatService } from './formats/indy/IndyProofFormatService' @@ -18,7 +21,7 @@ export class ProofsModule implements Module { /** * Registers the dependencies of the proofs module on the dependency manager. */ - public register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager, featureRegistry: FeatureRegistry) { // Api dependencyManager.registerContextScoped(ProofsApi) @@ -34,5 +37,13 @@ export class ProofsModule implements Module { // Proof Formats dependencyManager.registerSingleton(IndyProofFormatService) + + // Features + featureRegistry.register( + new Protocol({ + id: 'https://didcomm.org/present-proof/1.0', + roles: ['verifier', 'prover'], + }) + ) } } diff --git a/packages/core/src/modules/proofs/__tests__/ProofsModule.test.ts b/packages/core/src/modules/proofs/__tests__/ProofsModule.test.ts index 6bba3fd99e..8af9a5b2c2 100644 --- a/packages/core/src/modules/proofs/__tests__/ProofsModule.test.ts +++ b/packages/core/src/modules/proofs/__tests__/ProofsModule.test.ts @@ -1,3 +1,4 @@ +import { FeatureRegistry } from '../../../agent/FeatureRegistry' import { DependencyManager } from '../../../plugins/DependencyManager' import { ProofsApi } from '../ProofsApi' import { ProofsModule } from '../ProofsModule' @@ -11,9 +12,14 @@ const DependencyManagerMock = DependencyManager as jest.Mock const dependencyManager = new DependencyManagerMock() +jest.mock('../../../agent/FeatureRegistry') +const FeatureRegistryMock = FeatureRegistry as jest.Mock + +const featureRegistry = new FeatureRegistryMock() + describe('ProofsModule', () => { test('registers dependencies on the dependency manager', () => { - new ProofsModule().register(dependencyManager) + new ProofsModule().register(dependencyManager, featureRegistry) expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(ProofsApi) diff --git a/packages/core/src/modules/question-answer/QuestionAnswerModule.ts b/packages/core/src/modules/question-answer/QuestionAnswerModule.ts index 9fcea50803..0a353a4f1d 100644 --- a/packages/core/src/modules/question-answer/QuestionAnswerModule.ts +++ b/packages/core/src/modules/question-answer/QuestionAnswerModule.ts @@ -1,6 +1,10 @@ +import type { FeatureRegistry } from '../../agent/FeatureRegistry' import type { DependencyManager, Module } from '../../plugins' +import { Protocol } from '../../agent/models' + import { QuestionAnswerApi } from './QuestionAnswerApi' +import { QuestionAnswerRole } from './QuestionAnswerRole' import { QuestionAnswerRepository } from './repository' import { QuestionAnswerService } from './services' @@ -8,7 +12,7 @@ export class QuestionAnswerModule implements Module { /** * Registers the dependencies of the question answer module on the dependency manager. */ - public register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager, featureRegistry: FeatureRegistry) { // Api dependencyManager.registerContextScoped(QuestionAnswerApi) @@ -17,5 +21,13 @@ export class QuestionAnswerModule implements Module { // Repositories dependencyManager.registerSingleton(QuestionAnswerRepository) + + // Feature Registry + featureRegistry.register( + new Protocol({ + id: 'https://didcomm.org/questionanswer/1.0', + roles: [QuestionAnswerRole.Questioner, QuestionAnswerRole.Responder], + }) + ) } } diff --git a/packages/core/src/modules/question-answer/__tests__/QuestionAnswerModule.test.ts b/packages/core/src/modules/question-answer/__tests__/QuestionAnswerModule.test.ts index a285e5898a..19d46a9cb0 100644 --- a/packages/core/src/modules/question-answer/__tests__/QuestionAnswerModule.test.ts +++ b/packages/core/src/modules/question-answer/__tests__/QuestionAnswerModule.test.ts @@ -1,3 +1,4 @@ +import { FeatureRegistry } from '../../../agent/FeatureRegistry' import { DependencyManager } from '../../../plugins/DependencyManager' import { QuestionAnswerApi } from '../QuestionAnswerApi' import { QuestionAnswerModule } from '../QuestionAnswerModule' @@ -9,9 +10,14 @@ const DependencyManagerMock = DependencyManager as jest.Mock const dependencyManager = new DependencyManagerMock() +jest.mock('../../../agent/FeatureRegistry') +const FeatureRegistryMock = FeatureRegistry as jest.Mock + +const featureRegistry = new FeatureRegistryMock() + describe('QuestionAnswerModule', () => { test('registers dependencies on the dependency manager', () => { - new QuestionAnswerModule().register(dependencyManager) + new QuestionAnswerModule().register(dependencyManager, featureRegistry) expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(QuestionAnswerApi) diff --git a/packages/core/src/modules/routing/MediatorModule.ts b/packages/core/src/modules/routing/MediatorModule.ts index 97aa521934..348e23aca4 100644 --- a/packages/core/src/modules/routing/MediatorModule.ts +++ b/packages/core/src/modules/routing/MediatorModule.ts @@ -1,8 +1,12 @@ +import type { FeatureRegistry } from '../../agent/FeatureRegistry' import type { DependencyManager, Module } from '../../plugins' import type { MediatorModuleConfigOptions } from './MediatorModuleConfig' +import { Protocol } from '../../agent/models' + import { MediatorApi } from './MediatorApi' import { MediatorModuleConfig } from './MediatorModuleConfig' +import { MediationRole } from './models' import { MessagePickupService, V2MessagePickupService } from './protocol' import { MediationRepository, MediatorRoutingRepository } from './repository' import { MediatorService } from './services' @@ -17,7 +21,7 @@ export class MediatorModule implements Module { /** * Registers the dependencies of the question answer module on the dependency manager. */ - public register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager, featureRegistry: FeatureRegistry) { // Api dependencyManager.registerContextScoped(MediatorApi) @@ -32,5 +36,21 @@ export class MediatorModule implements Module { // Repositories dependencyManager.registerSingleton(MediationRepository) dependencyManager.registerSingleton(MediatorRoutingRepository) + + // Features + featureRegistry.register( + new Protocol({ + id: 'https://didcomm.org/coordinate-mediation/1.0', + roles: [MediationRole.Mediator], + }), + new Protocol({ + id: 'https://didcomm.org/messagepickup/1.0', + roles: ['message_holder', 'recipient', 'batch_sender', 'batch_recipient'], + }), + new Protocol({ + id: 'https://didcomm.org/messagepickup/2.0', + roles: ['mediator', 'recipient'], + }) + ) } } diff --git a/packages/core/src/modules/routing/RecipientApi.ts b/packages/core/src/modules/routing/RecipientApi.ts index 036ff2ed1d..293208a4a5 100644 --- a/packages/core/src/modules/routing/RecipientApi.ts +++ b/packages/core/src/modules/routing/RecipientApi.ts @@ -30,7 +30,7 @@ import { KeylistUpdateResponseHandler } from './handlers/KeylistUpdateResponseHa import { MediationDenyHandler } from './handlers/MediationDenyHandler' import { MediationGrantHandler } from './handlers/MediationGrantHandler' import { MediationState } from './models/MediationState' -import { StatusRequestMessage, BatchPickupMessage } from './protocol' +import { StatusRequestMessage, BatchPickupMessage, StatusMessage } from './protocol' import { StatusHandler, MessageDeliveryHandler } from './protocol/pickup/v2/handlers' import { MediationRepository } from './repository' import { MediationRecipientService } from './services/MediationRecipientService' @@ -248,20 +248,26 @@ export class RecipientApi { // If mediator pickup strategy is not configured we try to query if batch pickup // is supported through the discover features protocol if (!mediatorPickupStrategy) { - const isPickUpV2Supported = await this.discoverFeaturesApi.isProtocolSupported( - mediator.connectionId, - StatusRequestMessage - ) - if (isPickUpV2Supported) { + const discloseForPickupV2 = await this.discoverFeaturesApi.queryFeatures({ + connectionId: mediator.connectionId, + protocolVersion: 'v1', + queries: [{ featureType: 'protocol', match: StatusMessage.type.protocolUri }], + awaitDisclosures: true, + }) + + if (discloseForPickupV2.features?.find((item) => item.id === StatusMessage.type.protocolUri)) { mediatorPickupStrategy = MediatorPickupStrategy.PickUpV2 } else { - const isBatchPickupSupported = await this.discoverFeaturesApi.isProtocolSupported( - mediator.connectionId, - BatchPickupMessage - ) - + const discloseForPickupV1 = await this.discoverFeaturesApi.queryFeatures({ + connectionId: mediator.connectionId, + protocolVersion: 'v1', + queries: [{ featureType: 'protocol', match: BatchPickupMessage.type.protocolUri }], + awaitDisclosures: true, + }) // Use explicit pickup strategy - mediatorPickupStrategy = isBatchPickupSupported + mediatorPickupStrategy = discloseForPickupV1.features?.find( + (item) => item.id === BatchPickupMessage.type.protocolUri + ) ? MediatorPickupStrategy.PickUpV1 : MediatorPickupStrategy.Implicit } diff --git a/packages/core/src/modules/routing/RecipientModule.ts b/packages/core/src/modules/routing/RecipientModule.ts index 8233b2aacf..068f1f43af 100644 --- a/packages/core/src/modules/routing/RecipientModule.ts +++ b/packages/core/src/modules/routing/RecipientModule.ts @@ -1,8 +1,12 @@ +import type { FeatureRegistry } from '../../agent/FeatureRegistry' import type { DependencyManager, Module } from '../../plugins' import type { RecipientModuleConfigOptions } from './RecipientModuleConfig' +import { Protocol } from '../../agent/models' + import { RecipientApi } from './RecipientApi' import { RecipientModuleConfig } from './RecipientModuleConfig' +import { MediationRole } from './models' import { MediationRepository } from './repository' import { MediationRecipientService, RoutingService } from './services' @@ -16,7 +20,7 @@ export class RecipientModule implements Module { /** * Registers the dependencies of the mediator recipient module on the dependency manager. */ - public register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager, featureRegistry: FeatureRegistry) { // Api dependencyManager.registerContextScoped(RecipientApi) @@ -29,5 +33,13 @@ export class RecipientModule implements Module { // Repositories dependencyManager.registerSingleton(MediationRepository) + + // Features + featureRegistry.register( + new Protocol({ + id: 'https://didcomm.org/coordinate-mediation/1.0', + roles: [MediationRole.Recipient], + }) + ) } } diff --git a/packages/core/src/modules/routing/__tests__/MediatorModule.test.ts b/packages/core/src/modules/routing/__tests__/MediatorModule.test.ts index 096e83cfad..5835103180 100644 --- a/packages/core/src/modules/routing/__tests__/MediatorModule.test.ts +++ b/packages/core/src/modules/routing/__tests__/MediatorModule.test.ts @@ -1,3 +1,4 @@ +import { FeatureRegistry } from '../../../agent/FeatureRegistry' import { DependencyManager } from '../../../plugins/DependencyManager' import { MediatorApi } from '../MediatorApi' import { MediatorModule } from '../MediatorModule' @@ -10,9 +11,13 @@ const DependencyManagerMock = DependencyManager as jest.Mock const dependencyManager = new DependencyManagerMock() +jest.mock('../../../agent/FeatureRegistry') +const FeatureRegistryMock = FeatureRegistry as jest.Mock + +const featureRegistry = new FeatureRegistryMock() describe('MediatorModule', () => { test('registers dependencies on the dependency manager', () => { - new MediatorModule().register(dependencyManager) + new MediatorModule().register(dependencyManager, featureRegistry) expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(MediatorApi) diff --git a/packages/core/src/modules/routing/__tests__/RecipientModule.test.ts b/packages/core/src/modules/routing/__tests__/RecipientModule.test.ts index 916840344d..0008d36f8d 100644 --- a/packages/core/src/modules/routing/__tests__/RecipientModule.test.ts +++ b/packages/core/src/modules/routing/__tests__/RecipientModule.test.ts @@ -1,3 +1,4 @@ +import { FeatureRegistry } from '../../../agent/FeatureRegistry' import { DependencyManager } from '../../../plugins/DependencyManager' import { RecipientApi } from '../RecipientApi' import { RecipientModule } from '../RecipientModule' @@ -9,9 +10,14 @@ const DependencyManagerMock = DependencyManager as jest.Mock const dependencyManager = new DependencyManagerMock() +jest.mock('../../../agent/FeatureRegistry') +const FeatureRegistryMock = FeatureRegistry as jest.Mock + +const featureRegistry = new FeatureRegistryMock() + describe('RecipientModule', () => { test('registers dependencies on the dependency manager', () => { - new RecipientModule().register(dependencyManager) + new RecipientModule().register(dependencyManager, featureRegistry) expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(RecipientApi) diff --git a/packages/core/src/plugins/DependencyManager.ts b/packages/core/src/plugins/DependencyManager.ts index a785ccf1e1..fe662670fc 100644 --- a/packages/core/src/plugins/DependencyManager.ts +++ b/packages/core/src/plugins/DependencyManager.ts @@ -4,6 +4,8 @@ import type { DependencyContainer } from 'tsyringe' import { container as rootContainer, InjectionToken, Lifecycle } from 'tsyringe' +import { FeatureRegistry } from '../agent/FeatureRegistry' + export { InjectionToken } export class DependencyManager { @@ -14,7 +16,8 @@ export class DependencyManager { } public registerModules(...modules: Module[]) { - modules.forEach((module) => module.register(this)) + const featureRegistry = this.resolve(FeatureRegistry) + modules.forEach((module) => module.register(this, featureRegistry)) } public registerSingleton(from: InjectionToken, to: InjectionToken): void diff --git a/packages/core/src/plugins/Module.ts b/packages/core/src/plugins/Module.ts index 5210e2d9c4..68fa680855 100644 --- a/packages/core/src/plugins/Module.ts +++ b/packages/core/src/plugins/Module.ts @@ -1,8 +1,9 @@ +import type { FeatureRegistry } from '../agent/FeatureRegistry' import type { Constructor } from '../utils/mixins' import type { DependencyManager } from './DependencyManager' export interface Module { - register(dependencyManager: DependencyManager): void + register(dependencyManager: DependencyManager, featureRegistry: FeatureRegistry): void } /** diff --git a/packages/core/src/plugins/__tests__/DependencyManager.test.ts b/packages/core/src/plugins/__tests__/DependencyManager.test.ts index 0991324abe..f576ad4811 100644 --- a/packages/core/src/plugins/__tests__/DependencyManager.test.ts +++ b/packages/core/src/plugins/__tests__/DependencyManager.test.ts @@ -2,6 +2,7 @@ import type { Module } from '../Module' import { container as rootContainer, injectable, Lifecycle } from 'tsyringe' +import { FeatureRegistry } from '../../agent/FeatureRegistry' import { DependencyManager } from '../DependencyManager' class Instance { @@ -11,6 +12,7 @@ const instance = new Instance() const container = rootContainer.createChildContainer() const dependencyManager = new DependencyManager(container) +const featureRegistry = container.resolve(FeatureRegistry) describe('DependencyManager', () => { afterEach(() => { @@ -35,10 +37,10 @@ describe('DependencyManager', () => { dependencyManager.registerModules(module1, module2) expect(module1.register).toHaveBeenCalledTimes(1) - expect(module1.register).toHaveBeenLastCalledWith(dependencyManager) + expect(module1.register).toHaveBeenLastCalledWith(dependencyManager, featureRegistry) expect(module2.register).toHaveBeenCalledTimes(1) - expect(module2.register).toHaveBeenLastCalledWith(dependencyManager) + expect(module2.register).toHaveBeenLastCalledWith(dependencyManager, featureRegistry) }) }) diff --git a/samples/extension-module/dummy/DummyModule.ts b/samples/extension-module/dummy/DummyModule.ts index 9f0f50f99a..44374596ba 100644 --- a/samples/extension-module/dummy/DummyModule.ts +++ b/samples/extension-module/dummy/DummyModule.ts @@ -1,14 +1,24 @@ -import type { DependencyManager, Module } from '@aries-framework/core' +import type { DependencyManager, FeatureRegistry, Module } from '@aries-framework/core' + +import { Protocol } from '@aries-framework/core' import { DummyRepository } from './repository' import { DummyService } from './services' export class DummyModule implements Module { - public register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager, featureRegistry: FeatureRegistry) { // Api dependencyManager.registerContextScoped(DummyModule) dependencyManager.registerSingleton(DummyRepository) dependencyManager.registerSingleton(DummyService) + + // Features + featureRegistry.register( + new Protocol({ + id: 'https://didcomm.org/dummy/1.0', + roles: ['requester', 'responder'], + }) + ) } }