From 7cbccb1ce9dae2cb1e4887220898f2f74cca8dbe Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Tue, 19 Jul 2022 11:49:24 +0200 Subject: [PATCH] refactor!: module to api and module config (#943) Signed-off-by: Timo Glastra BREAKING CHANGE: All module api classes have been renamed from `XXXModule` to `XXXApi`. A module now represents a module plugin, and is separate from the API of a module. If you previously imported e.g. the `CredentialsModule` class, you should now import the `CredentialsApi` class --- packages/core/src/agent/Agent.ts | 89 ++- packages/core/src/agent/AgentConfig.ts | 45 ++ packages/core/src/agent/BaseAgent.ts | 78 +- .../core/src/agent/__tests__/Agent.test.ts | 42 +- packages/core/src/index.ts | 2 +- .../basic-messages/BasicMessagesApi.ts | 49 ++ .../basic-messages/BasicMessagesModule.ts | 55 +- .../__tests__/BasicMessagesModule.test.ts | 23 + .../core/src/modules/basic-messages/index.ts | 3 +- .../src/modules/connections/ConnectionsApi.ts | 312 ++++++++ .../modules/connections/ConnectionsModule.ts | 313 +------- .../connections/ConnectionsModuleConfig.ts | 26 + .../__tests__/ConnectionsModule.test.ts | 31 + .../__tests__/ConnectionsModuleConfig.test.ts | 17 + .../handlers/ConnectionRequestHandler.ts | 8 +- .../handlers/ConnectionResponseHandler.ts | 8 +- .../handlers/DidExchangeRequestHandler.ts | 8 +- .../handlers/DidExchangeResponseHandler.ts | 8 +- .../core/src/modules/connections/index.ts | 4 +- .../src/modules/credentials/CredentialsApi.ts | 634 ++++++++++++++++ ...uleOptions.ts => CredentialsApiOptions.ts} | 20 +- .../modules/credentials/CredentialsModule.ts | 642 +--------------- .../credentials/CredentialsModuleConfig.ts | 27 + .../__tests__/CredentialsModule.test.ts | 33 + .../__tests__/CredentialsModuleConfig.test.ts | 18 + .../formats/indy/IndyCredentialFormat.ts | 10 +- .../core/src/modules/credentials/index.ts | 7 +- .../models/CredentialAutoAcceptType.ts | 6 +- .../protocol/v1/V1CredentialService.ts | 16 +- .../__tests__/V1CredentialServiceCred.test.ts | 4 +- .../V1CredentialServiceProposeOffer.test.ts | 4 +- .../v1-connectionless-credentials.e2e.test.ts | 2 +- .../v1-credentials-auto-accept.e2e.test.ts | 2 +- .../v1/handlers/V1IssueCredentialHandler.ts | 4 +- .../v1/handlers/V1OfferCredentialHandler.ts | 4 +- .../v1/handlers/V1ProposeCredentialHandler.ts | 4 +- .../v1/handlers/V1RequestCredentialHandler.ts | 4 +- .../protocol/v2/V2CredentialService.ts | 14 +- .../__tests__/V2CredentialServiceCred.test.ts | 4 +- .../V2CredentialServiceOffer.test.ts | 4 +- .../v2-connectionless-credentials.e2e.test.ts | 2 +- .../v2-credentials-auto-accept.e2e.test.ts | 2 +- .../v2/handlers/V2IssueCredentialHandler.ts | 4 +- .../v2/handlers/V2OfferCredentialHandler.ts | 4 +- .../v2/handlers/V2ProposeCredentialHandler.ts | 4 +- .../v2/handlers/V2RequestCredentialHandler.ts | 4 +- packages/core/src/modules/dids/DidsApi.ts | 37 + packages/core/src/modules/dids/DidsModule.ts | 44 +- .../modules/dids/__tests__/DidsModule.test.ts | 23 + packages/core/src/modules/dids/index.ts | 3 +- .../discover-features/DiscoverFeaturesApi.ts | 101 +++ .../DiscoverFeaturesModule.ts | 107 +-- .../__tests__/DiscoverFeaturesModule.test.ts | 21 + .../src/modules/discover-features/index.ts | 3 +- .../generic-records/GenericRecordsApi.ts | 89 +++ .../generic-records/GenericRecordsModule.ts | 97 +-- .../__tests__/GenericRecordsModule.test.ts | 23 + .../core/src/modules/generic-records/index.ts | 2 + .../GenericRecordService.ts | 0 .../modules/indy/{module.ts => IndyModule.ts} | 9 +- .../modules/indy/__tests__/IndyModule.test.ts | 27 + packages/core/src/modules/indy/index.ts | 1 + packages/core/src/modules/ledger/LedgerApi.ts | 166 ++++ .../core/src/modules/ledger/LedgerModule.ts | 175 +---- .../src/modules/ledger/LedgerModuleConfig.ts | 43 ++ .../ledger/__tests__/LedgerApi.test.ts | 368 +++++++++ .../ledger/__tests__/LedgerModule.test.ts | 377 +-------- packages/core/src/modules/ledger/index.ts | 3 +- packages/core/src/modules/oob/OutOfBandApi.ts | 709 +++++++++++++++++ .../core/src/modules/oob/OutOfBandModule.ts | 714 +----------------- .../oob/__tests__/OutOfBandModule.test.ts | 23 + packages/core/src/modules/oob/index.ts | 3 +- .../proofs/ProofResponseCoordinator.ts | 12 +- packages/core/src/modules/proofs/ProofsApi.ts | 541 +++++++++++++ .../core/src/modules/proofs/ProofsModule.ts | 552 +------------- .../src/modules/proofs/ProofsModuleConfig.ts | 27 + .../proofs/__tests__/ProofsModule.test.ts | 23 + .../proofs/handlers/PresentationHandler.ts | 4 +- .../handlers/ProposePresentationHandler.ts | 4 +- .../handlers/RequestPresentationHandler.ts | 4 +- packages/core/src/modules/proofs/index.ts | 3 +- .../question-answer/QuestionAnswerApi.ts | 105 +++ .../question-answer/QuestionAnswerModule.ts | 111 +-- .../__tests__/QuestionAnswerModule.test.ts | 23 + .../core/src/modules/question-answer/index.ts | 3 +- .../core/src/modules/routing/MediatorApi.ts | 91 +++ .../src/modules/routing/MediatorModule.ts | 101 +-- .../modules/routing/MediatorModuleConfig.ts | 25 + .../core/src/modules/routing/RecipientApi.ts | 405 ++++++++++ .../src/modules/routing/RecipientModule.ts | 412 +--------- .../modules/routing/RecipientModuleConfig.ts | 74 ++ .../routing/__tests__/MediatorModule.test.ts | 27 + .../routing/__tests__/RecipientModule.test.ts | 24 + .../handlers/MediationRequestHandler.ts | 7 +- packages/core/src/modules/routing/index.ts | 4 +- .../services/MediationRecipientService.ts | 8 +- .../MediationRecipientService.test.ts | 4 +- .../modules/vc/{module.ts => W3cVcModule.ts} | 8 +- .../modules/vc/__tests__/W3cVcModule.test.ts | 46 ++ packages/core/src/modules/vc/index.ts | 1 + packages/core/src/plugins/Module.ts | 5 +- .../__tests__/DependencyManager.test.ts | 26 +- .../src/storage/migration/UpdateAssistant.ts | 13 + packages/core/src/wallet/WalletApi.ts | 108 +++ packages/core/src/wallet/WalletModule.ts | 117 +-- .../src/wallet/__tests__/WalletModule.test.ts | 17 + packages/core/src/wallet/index.ts | 2 + packages/module-tenants/src/TenantsModule.ts | 20 +- .../module-tenants/src/TenantsModuleConfig.ts | 42 ++ .../src/__tests__/TenantsModule.test.ts | 7 +- .../src/__tests__/TenantsModuleConfig.test.ts | 20 + .../src/context/TenantSessionCoordinator.ts | 27 +- .../TenantSessionCoordinator.test.ts | 10 +- packages/module-tenants/src/index.ts | 1 + .../tests/tenant-sessions.e2e.test.ts | 4 +- .../module-tenants/tests/tenants.e2e.test.ts | 4 +- samples/extension-module/README.md | 2 +- .../dummy/{module.ts => DummyModule.ts} | 9 +- samples/extension-module/dummy/index.ts | 2 +- samples/extension-module/requester.ts | 2 +- samples/extension-module/responder.ts | 2 +- 121 files changed, 4941 insertions(+), 3920 deletions(-) create mode 100644 packages/core/src/modules/basic-messages/BasicMessagesApi.ts create mode 100644 packages/core/src/modules/basic-messages/__tests__/BasicMessagesModule.test.ts create mode 100644 packages/core/src/modules/connections/ConnectionsApi.ts create mode 100644 packages/core/src/modules/connections/ConnectionsModuleConfig.ts create mode 100644 packages/core/src/modules/connections/__tests__/ConnectionsModule.test.ts create mode 100644 packages/core/src/modules/connections/__tests__/ConnectionsModuleConfig.test.ts create mode 100644 packages/core/src/modules/credentials/CredentialsApi.ts rename packages/core/src/modules/credentials/{CredentialsModuleOptions.ts => CredentialsApiOptions.ts} (84%) create mode 100644 packages/core/src/modules/credentials/CredentialsModuleConfig.ts create mode 100644 packages/core/src/modules/credentials/__tests__/CredentialsModule.test.ts create mode 100644 packages/core/src/modules/credentials/__tests__/CredentialsModuleConfig.test.ts create mode 100644 packages/core/src/modules/dids/DidsApi.ts create mode 100644 packages/core/src/modules/dids/__tests__/DidsModule.test.ts create mode 100644 packages/core/src/modules/discover-features/DiscoverFeaturesApi.ts create mode 100644 packages/core/src/modules/discover-features/__tests__/DiscoverFeaturesModule.test.ts create mode 100644 packages/core/src/modules/generic-records/GenericRecordsApi.ts create mode 100644 packages/core/src/modules/generic-records/__tests__/GenericRecordsModule.test.ts create mode 100644 packages/core/src/modules/generic-records/index.ts rename packages/core/src/modules/generic-records/{service => services}/GenericRecordService.ts (100%) rename packages/core/src/modules/indy/{module.ts => IndyModule.ts} (74%) create mode 100644 packages/core/src/modules/indy/__tests__/IndyModule.test.ts create mode 100644 packages/core/src/modules/ledger/LedgerApi.ts create mode 100644 packages/core/src/modules/ledger/LedgerModuleConfig.ts create mode 100644 packages/core/src/modules/ledger/__tests__/LedgerApi.test.ts create mode 100644 packages/core/src/modules/oob/OutOfBandApi.ts create mode 100644 packages/core/src/modules/oob/__tests__/OutOfBandModule.test.ts create mode 100644 packages/core/src/modules/proofs/ProofsApi.ts create mode 100644 packages/core/src/modules/proofs/ProofsModuleConfig.ts create mode 100644 packages/core/src/modules/proofs/__tests__/ProofsModule.test.ts create mode 100644 packages/core/src/modules/question-answer/QuestionAnswerApi.ts create mode 100644 packages/core/src/modules/question-answer/__tests__/QuestionAnswerModule.test.ts create mode 100644 packages/core/src/modules/routing/MediatorApi.ts create mode 100644 packages/core/src/modules/routing/MediatorModuleConfig.ts create mode 100644 packages/core/src/modules/routing/RecipientApi.ts create mode 100644 packages/core/src/modules/routing/RecipientModuleConfig.ts create mode 100644 packages/core/src/modules/routing/__tests__/MediatorModule.test.ts create mode 100644 packages/core/src/modules/routing/__tests__/RecipientModule.test.ts rename packages/core/src/modules/vc/{module.ts => W3cVcModule.ts} (87%) create mode 100644 packages/core/src/modules/vc/__tests__/W3cVcModule.test.ts create mode 100644 packages/core/src/wallet/WalletApi.ts create mode 100644 packages/core/src/wallet/__tests__/WalletModule.test.ts create mode 100644 packages/module-tenants/src/TenantsModuleConfig.ts create mode 100644 packages/module-tenants/src/__tests__/TenantsModuleConfig.test.ts rename samples/extension-module/dummy/{module.ts => DummyModule.ts} (56%) diff --git a/packages/core/src/agent/Agent.ts b/packages/core/src/agent/Agent.ts index a6c9601e7d..f0944078ff 100644 --- a/packages/core/src/agent/Agent.ts +++ b/packages/core/src/agent/Agent.ts @@ -12,26 +12,25 @@ import { CacheRepository } from '../cache' import { InjectionSymbols } from '../constants' import { JwsService } from '../crypto/JwsService' import { AriesFrameworkError } from '../error' -import { BasicMessagesModule } from '../modules/basic-messages/BasicMessagesModule' -import { ConnectionsModule } from '../modules/connections/ConnectionsModule' -import { CredentialsModule } from '../modules/credentials/CredentialsModule' -import { DidsModule } from '../modules/dids/DidsModule' +import { BasicMessagesModule } from '../modules/basic-messages' +import { ConnectionsModule } from '../modules/connections' +import { CredentialsModule } from '../modules/credentials' +import { DidsModule } from '../modules/dids' import { DiscoverFeaturesModule } from '../modules/discover-features' -import { GenericRecordsModule } from '../modules/generic-records/GenericRecordsModule' -import { IndyModule } from '../modules/indy/module' -import { LedgerModule } from '../modules/ledger/LedgerModule' -import { OutOfBandModule } from '../modules/oob/OutOfBandModule' -import { ProofsModule } from '../modules/proofs/ProofsModule' -import { QuestionAnswerModule } from '../modules/question-answer/QuestionAnswerModule' -import { MediatorModule } from '../modules/routing/MediatorModule' -import { RecipientModule } from '../modules/routing/RecipientModule' -import { W3cVcModule } from '../modules/vc/module' +import { GenericRecordsModule } from '../modules/generic-records' +import { IndyModule } from '../modules/indy' +import { LedgerModule } from '../modules/ledger' +import { OutOfBandModule } from '../modules/oob' +import { ProofsModule } from '../modules/proofs' +import { QuestionAnswerModule } from '../modules/question-answer' +import { MediatorModule, RecipientModule } from '../modules/routing' +import { W3cVcModule } from '../modules/vc' import { DependencyManager } from '../plugins' import { DidCommMessageRepository, StorageUpdateService, StorageVersionRepository } from '../storage' import { InMemoryMessageRepository } from '../storage/InMemoryMessageRepository' import { IndyStorageService } from '../storage/IndyStorageService' +import { WalletModule } from '../wallet' import { IndyWallet } from '../wallet/IndyWallet' -import { WalletModule } from '../wallet/WalletModule' import { AgentConfig } from './AgentConfig' import { BaseAgent } from './BaseAgent' @@ -94,14 +93,12 @@ export class Agent extends BaseAgent { } public async initialize() { - const { connectToIndyLedgersOnStartup, mediatorConnectionsInvite } = this.agentConfig - await super.initialize() // set the pools on the ledger. - this.ledger.setPools(this.agentContext.config.indyLedgers) + this.ledger.setPools(this.ledger.config.indyLedgers) // As long as value isn't false we will async connect to all genesis pools on startup - if (connectToIndyLedgersOnStartup) { + if (this.ledger.config.connectToIndyLedgersOnStartup) { this.ledger.connectToPools().catch((error) => { this.logger.warn('Error connecting to ledger, will try to reconnect when needed.', { error }) }) @@ -118,9 +115,13 @@ export class Agent extends BaseAgent { // Connect to mediator through provided invitation if provided in config // Also requests mediation ans sets as default mediator // Because this requires the connections module, we do this in the agent constructor - if (mediatorConnectionsInvite) { - this.logger.debug('Provision mediation with invitation', { mediatorConnectionsInvite }) - const mediationConnection = await this.getMediationConnection(mediatorConnectionsInvite) + if (this.mediationRecipient.config.mediatorInvitationUrl) { + this.logger.debug('Provision mediation with invitation', { + mediatorInvitationUrl: this.mediationRecipient.config.mediatorInvitationUrl, + }) + const mediationConnection = await this.getMediationConnection( + this.mediationRecipient.config.mediatorInvitationUrl + ) await this.mediationRecipient.provision(mediationConnection) } await this.mediator.initialize() @@ -182,21 +183,37 @@ export class Agent extends BaseAgent { // Register all modules dependencyManager.registerModules( - ConnectionsModule, - CredentialsModule, - ProofsModule, - MediatorModule, - RecipientModule, - BasicMessagesModule, - QuestionAnswerModule, - GenericRecordsModule, - LedgerModule, - DiscoverFeaturesModule, - DidsModule, - WalletModule, - OutOfBandModule, - IndyModule, - W3cVcModule + new ConnectionsModule({ + autoAcceptConnections: this.agentConfig.autoAcceptConnections, + }), + new CredentialsModule({ + autoAcceptCredentials: this.agentConfig.autoAcceptCredentials, + }), + new ProofsModule({ + autoAcceptProofs: this.agentConfig.autoAcceptProofs, + }), + new MediatorModule({ + autoAcceptMediationRequests: this.agentConfig.autoAcceptMediationRequests, + }), + new RecipientModule({ + maximumMessagePickup: this.agentConfig.maximumMessagePickup, + mediatorInvitationUrl: this.agentConfig.mediatorConnectionsInvite, + mediatorPickupStrategy: this.agentConfig.mediatorPickupStrategy, + mediatorPollingInterval: this.agentConfig.mediatorPollingInterval, + }), + new BasicMessagesModule(), + new QuestionAnswerModule(), + new GenericRecordsModule(), + new LedgerModule({ + connectToIndyLedgersOnStartup: this.agentConfig.connectToIndyLedgersOnStartup, + indyLedgers: this.agentConfig.indyLedgers, + }), + new DiscoverFeaturesModule(), + new DidsModule(), + new WalletModule(), + new OutOfBandModule(), + new IndyModule(), + new W3cVcModule() ) // TODO: contextCorrelationId for base wallet diff --git a/packages/core/src/agent/AgentConfig.ts b/packages/core/src/agent/AgentConfig.ts index df12417754..1f391d481d 100644 --- a/packages/core/src/agent/AgentConfig.ts +++ b/packages/core/src/agent/AgentConfig.ts @@ -31,30 +31,51 @@ export class AgentConfig { } } + /** + * @deprecated use connectToIndyLedgersOnStartup from the `LedgerModuleConfig` class + */ public get connectToIndyLedgersOnStartup() { return this.initConfig.connectToIndyLedgersOnStartup ?? true } + /** + * @todo remove once did registrar module is available + */ public get publicDidSeed() { return this.initConfig.publicDidSeed } + /** + * @deprecated use indyLedgers from the `LedgerModuleConfig` class + */ public get indyLedgers() { return this.initConfig.indyLedgers ?? [] } + /** + * @todo move to context configuration + */ public get walletConfig() { return this.initConfig.walletConfig } + /** + * @deprecated use autoAcceptConnections from the `ConnectionsModuleConfig` class + */ public get autoAcceptConnections() { return this.initConfig.autoAcceptConnections ?? false } + /** + * @deprecated use autoAcceptProofs from the `ProofsModuleConfig` class + */ public get autoAcceptProofs() { return this.initConfig.autoAcceptProofs ?? AutoAcceptProof.Never } + /** + * @deprecated use autoAcceptCredentials from the `CredentialsModuleConfig` class + */ public get autoAcceptCredentials() { return this.initConfig.autoAcceptCredentials ?? AutoAcceptCredential.Never } @@ -63,14 +84,23 @@ export class AgentConfig { return this.initConfig.didCommMimeType ?? DidCommMimeType.V0 } + /** + * @deprecated use mediatorPollingInterval from the `RecipientModuleConfig` class + */ public get mediatorPollingInterval() { return this.initConfig.mediatorPollingInterval ?? 5000 } + /** + * @deprecated use mediatorPickupStrategy from the `RecipientModuleConfig` class + */ public get mediatorPickupStrategy() { return this.initConfig.mediatorPickupStrategy } + /** + * @deprecated use maximumMessagePickup from the `RecipientModuleConfig` class + */ public get maximumMessagePickup() { return this.initConfig.maximumMessagePickup ?? 10 } @@ -85,18 +115,30 @@ export class AgentConfig { return this.initConfig.endpoints as [string, ...string[]] } + /** + * @deprecated use mediatorInvitationUrl from the `RecipientModuleConfig` class + */ public get mediatorConnectionsInvite() { return this.initConfig.mediatorConnectionsInvite } + /** + * @deprecated use autoAcceptMediationRequests from the `MediatorModuleConfig` class + */ public get autoAcceptMediationRequests() { return this.initConfig.autoAcceptMediationRequests ?? false } + /** + * @deprecated you can use `RecipientApi.setDefaultMediator` to set the default mediator. + */ public get defaultMediatorId() { return this.initConfig.defaultMediatorId } + /** + * @deprecated you can set the `default` tag to `false` (or remove it completely) to clear the default mediator. + */ public get clearDefaultMediator() { return this.initConfig.clearDefaultMediator ?? false } @@ -105,6 +147,9 @@ export class AgentConfig { return this.initConfig.useLegacyDidSovPrefix ?? false } + /** + * @todo move to context configuration + */ public get connectionImageUrl() { return this.initConfig.connectionImageUrl } diff --git a/packages/core/src/agent/BaseAgent.ts b/packages/core/src/agent/BaseAgent.ts index 34115dc2a5..2a895f777b 100644 --- a/packages/core/src/agent/BaseAgent.ts +++ b/packages/core/src/agent/BaseAgent.ts @@ -4,22 +4,22 @@ import type { AgentConfig } from './AgentConfig' import type { TransportSession } from './TransportService' import { AriesFrameworkError } from '../error' -import { BasicMessagesModule } from '../modules/basic-messages/BasicMessagesModule' -import { ConnectionsModule } from '../modules/connections/ConnectionsModule' -import { CredentialsModule } from '../modules/credentials/CredentialsModule' -import { DidsModule } from '../modules/dids/DidsModule' -import { DiscoverFeaturesModule } from '../modules/discover-features' -import { GenericRecordsModule } from '../modules/generic-records/GenericRecordsModule' -import { LedgerModule } from '../modules/ledger/LedgerModule' -import { OutOfBandModule } from '../modules/oob/OutOfBandModule' -import { ProofsModule } from '../modules/proofs/ProofsModule' -import { QuestionAnswerModule } from '../modules/question-answer/QuestionAnswerModule' -import { MediatorModule } from '../modules/routing/MediatorModule' -import { RecipientModule } from '../modules/routing/RecipientModule' +import { BasicMessagesApi } from '../modules/basic-messages/BasicMessagesApi' +import { ConnectionsApi } from '../modules/connections/ConnectionsApi' +import { CredentialsApi } from '../modules/credentials/CredentialsApi' +import { DidsApi } from '../modules/dids/DidsApi' +import { DiscoverFeaturesApi } from '../modules/discover-features' +import { GenericRecordsApi } from '../modules/generic-records/GenericRecordsApi' +import { LedgerApi } from '../modules/ledger/LedgerApi' +import { OutOfBandApi } from '../modules/oob/OutOfBandApi' +import { ProofsApi } from '../modules/proofs/ProofsApi' +import { QuestionAnswerApi } from '../modules/question-answer/QuestionAnswerApi' +import { MediatorApi } from '../modules/routing/MediatorApi' +import { RecipientApi } from '../modules/routing/RecipientApi' import { StorageUpdateService } from '../storage' import { UpdateAssistant } from '../storage/migration/UpdateAssistant' import { DEFAULT_UPDATE_CONFIG } from '../storage/migration/updates' -import { WalletModule } from '../wallet/WalletModule' +import { WalletApi } from '../wallet/WalletApi' import { WalletError } from '../wallet/error' import { EventEmitter } from './EventEmitter' @@ -39,19 +39,19 @@ export abstract class BaseAgent { protected _isInitialized = false protected agentContext: AgentContext - public readonly connections: ConnectionsModule - public readonly proofs: ProofsModule - public readonly basicMessages: BasicMessagesModule - public readonly genericRecords: GenericRecordsModule - public readonly ledger: LedgerModule - public readonly questionAnswer!: QuestionAnswerModule - public readonly credentials: CredentialsModule - public readonly mediationRecipient: RecipientModule - public readonly mediator: MediatorModule - public readonly discovery: DiscoverFeaturesModule - public readonly dids: DidsModule - public readonly wallet: WalletModule - public readonly oob: OutOfBandModule + public readonly connections: ConnectionsApi + public readonly proofs: ProofsApi + public readonly basicMessages: BasicMessagesApi + public readonly genericRecords: GenericRecordsApi + public readonly ledger: LedgerApi + public readonly questionAnswer!: QuestionAnswerApi + public readonly credentials: CredentialsApi + public readonly mediationRecipient: RecipientApi + public readonly mediator: MediatorApi + public readonly discovery: DiscoverFeaturesApi + public readonly dids: DidsApi + public readonly wallet: WalletApi + public readonly oob: OutOfBandApi public constructor(agentConfig: AgentConfig, dependencyManager: DependencyManager) { this.dependencyManager = dependencyManager @@ -81,19 +81,19 @@ export abstract class BaseAgent { this.agentContext = this.dependencyManager.resolve(AgentContext) // We set the modules in the constructor because that allows to set them as read-only - this.connections = this.dependencyManager.resolve(ConnectionsModule) - this.credentials = this.dependencyManager.resolve(CredentialsModule) as CredentialsModule - this.proofs = this.dependencyManager.resolve(ProofsModule) - this.mediator = this.dependencyManager.resolve(MediatorModule) - this.mediationRecipient = this.dependencyManager.resolve(RecipientModule) - this.basicMessages = this.dependencyManager.resolve(BasicMessagesModule) - this.questionAnswer = this.dependencyManager.resolve(QuestionAnswerModule) - this.genericRecords = this.dependencyManager.resolve(GenericRecordsModule) - this.ledger = this.dependencyManager.resolve(LedgerModule) - this.discovery = this.dependencyManager.resolve(DiscoverFeaturesModule) - this.dids = this.dependencyManager.resolve(DidsModule) - this.wallet = this.dependencyManager.resolve(WalletModule) - this.oob = this.dependencyManager.resolve(OutOfBandModule) + this.connections = this.dependencyManager.resolve(ConnectionsApi) + this.credentials = this.dependencyManager.resolve(CredentialsApi) as CredentialsApi + this.proofs = this.dependencyManager.resolve(ProofsApi) + this.mediator = this.dependencyManager.resolve(MediatorApi) + this.mediationRecipient = this.dependencyManager.resolve(RecipientApi) + this.basicMessages = this.dependencyManager.resolve(BasicMessagesApi) + this.questionAnswer = this.dependencyManager.resolve(QuestionAnswerApi) + this.genericRecords = this.dependencyManager.resolve(GenericRecordsApi) + this.ledger = this.dependencyManager.resolve(LedgerApi) + this.discovery = this.dependencyManager.resolve(DiscoverFeaturesApi) + this.dids = this.dependencyManager.resolve(DidsApi) + this.wallet = this.dependencyManager.resolve(WalletApi) + this.oob = this.dependencyManager.resolve(OutOfBandApi) } public get isInitialized() { diff --git a/packages/core/src/agent/__tests__/Agent.test.ts b/packages/core/src/agent/__tests__/Agent.test.ts index 558f267e14..7c92573fa4 100644 --- a/packages/core/src/agent/__tests__/Agent.test.ts +++ b/packages/core/src/agent/__tests__/Agent.test.ts @@ -1,20 +1,20 @@ import { getBaseConfig } from '../../../tests/helpers' import { InjectionSymbols } from '../../constants' import { BasicMessageRepository, BasicMessageService } from '../../modules/basic-messages' -import { BasicMessagesModule } from '../../modules/basic-messages/BasicMessagesModule' -import { ConnectionsModule } from '../../modules/connections/ConnectionsModule' +import { BasicMessagesApi } from '../../modules/basic-messages/BasicMessagesApi' +import { ConnectionsApi } from '../../modules/connections/ConnectionsApi' import { ConnectionRepository } from '../../modules/connections/repository/ConnectionRepository' import { ConnectionService } from '../../modules/connections/services/ConnectionService' import { TrustPingService } from '../../modules/connections/services/TrustPingService' import { CredentialRepository } from '../../modules/credentials' -import { CredentialsModule } from '../../modules/credentials/CredentialsModule' +import { CredentialsApi } from '../../modules/credentials/CredentialsApi' import { IndyLedgerService } from '../../modules/ledger' -import { LedgerModule } from '../../modules/ledger/LedgerModule' +import { LedgerApi } from '../../modules/ledger/LedgerApi' import { ProofRepository, ProofService } from '../../modules/proofs' -import { ProofsModule } from '../../modules/proofs/ProofsModule' +import { ProofsApi } from '../../modules/proofs/ProofsApi' import { - MediatorModule, - RecipientModule, + MediatorApi, + RecipientApi, MediationRepository, MediatorService, MediationRecipientService, @@ -110,29 +110,29 @@ describe('Agent', () => { const container = agent.dependencyManager // Modules - expect(container.resolve(ConnectionsModule)).toBeInstanceOf(ConnectionsModule) + expect(container.resolve(ConnectionsApi)).toBeInstanceOf(ConnectionsApi) expect(container.resolve(ConnectionService)).toBeInstanceOf(ConnectionService) expect(container.resolve(ConnectionRepository)).toBeInstanceOf(ConnectionRepository) expect(container.resolve(TrustPingService)).toBeInstanceOf(TrustPingService) - expect(container.resolve(ProofsModule)).toBeInstanceOf(ProofsModule) + expect(container.resolve(ProofsApi)).toBeInstanceOf(ProofsApi) expect(container.resolve(ProofService)).toBeInstanceOf(ProofService) expect(container.resolve(ProofRepository)).toBeInstanceOf(ProofRepository) - expect(container.resolve(CredentialsModule)).toBeInstanceOf(CredentialsModule) + expect(container.resolve(CredentialsApi)).toBeInstanceOf(CredentialsApi) expect(container.resolve(CredentialRepository)).toBeInstanceOf(CredentialRepository) - expect(container.resolve(BasicMessagesModule)).toBeInstanceOf(BasicMessagesModule) + expect(container.resolve(BasicMessagesApi)).toBeInstanceOf(BasicMessagesApi) expect(container.resolve(BasicMessageService)).toBeInstanceOf(BasicMessageService) expect(container.resolve(BasicMessageRepository)).toBeInstanceOf(BasicMessageRepository) - expect(container.resolve(MediatorModule)).toBeInstanceOf(MediatorModule) - expect(container.resolve(RecipientModule)).toBeInstanceOf(RecipientModule) + expect(container.resolve(MediatorApi)).toBeInstanceOf(MediatorApi) + expect(container.resolve(RecipientApi)).toBeInstanceOf(RecipientApi) expect(container.resolve(MediationRepository)).toBeInstanceOf(MediationRepository) expect(container.resolve(MediatorService)).toBeInstanceOf(MediatorService) expect(container.resolve(MediationRecipientService)).toBeInstanceOf(MediationRecipientService) - expect(container.resolve(LedgerModule)).toBeInstanceOf(LedgerModule) + expect(container.resolve(LedgerApi)).toBeInstanceOf(LedgerApi) expect(container.resolve(IndyLedgerService)).toBeInstanceOf(IndyLedgerService) // Symbols, interface based @@ -152,29 +152,29 @@ describe('Agent', () => { const container = agent.dependencyManager // Modules - expect(container.resolve(ConnectionsModule)).toBe(container.resolve(ConnectionsModule)) + expect(container.resolve(ConnectionsApi)).toBe(container.resolve(ConnectionsApi)) expect(container.resolve(ConnectionService)).toBe(container.resolve(ConnectionService)) expect(container.resolve(ConnectionRepository)).toBe(container.resolve(ConnectionRepository)) expect(container.resolve(TrustPingService)).toBe(container.resolve(TrustPingService)) - expect(container.resolve(ProofsModule)).toBe(container.resolve(ProofsModule)) + expect(container.resolve(ProofsApi)).toBe(container.resolve(ProofsApi)) expect(container.resolve(ProofService)).toBe(container.resolve(ProofService)) expect(container.resolve(ProofRepository)).toBe(container.resolve(ProofRepository)) - expect(container.resolve(CredentialsModule)).toBe(container.resolve(CredentialsModule)) + expect(container.resolve(CredentialsApi)).toBe(container.resolve(CredentialsApi)) expect(container.resolve(CredentialRepository)).toBe(container.resolve(CredentialRepository)) - expect(container.resolve(BasicMessagesModule)).toBe(container.resolve(BasicMessagesModule)) + expect(container.resolve(BasicMessagesApi)).toBe(container.resolve(BasicMessagesApi)) expect(container.resolve(BasicMessageService)).toBe(container.resolve(BasicMessageService)) expect(container.resolve(BasicMessageRepository)).toBe(container.resolve(BasicMessageRepository)) - expect(container.resolve(MediatorModule)).toBe(container.resolve(MediatorModule)) - expect(container.resolve(RecipientModule)).toBe(container.resolve(RecipientModule)) + expect(container.resolve(MediatorApi)).toBe(container.resolve(MediatorApi)) + expect(container.resolve(RecipientApi)).toBe(container.resolve(RecipientApi)) expect(container.resolve(MediationRepository)).toBe(container.resolve(MediationRepository)) expect(container.resolve(MediatorService)).toBe(container.resolve(MediatorService)) expect(container.resolve(MediationRecipientService)).toBe(container.resolve(MediationRecipientService)) - expect(container.resolve(LedgerModule)).toBe(container.resolve(LedgerModule)) + expect(container.resolve(LedgerApi)).toBe(container.resolve(LedgerApi)) expect(container.resolve(IndyLedgerService)).toBe(container.resolve(IndyLedgerService)) // Symbols, interface based diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e1967d1ef3..afadf847a1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -42,7 +42,7 @@ export * from './modules/ledger' export * from './modules/routing' export * from './modules/question-answer' export * from './modules/oob' -export * from './wallet/WalletModule' +export * from './wallet/WalletApi' export * from './modules/dids' export { JsonEncoder, JsonTransformer, isJsonObject, isValidJweStructure } from './utils' export * from './logger' diff --git a/packages/core/src/modules/basic-messages/BasicMessagesApi.ts b/packages/core/src/modules/basic-messages/BasicMessagesApi.ts new file mode 100644 index 0000000000..9de00e5e2b --- /dev/null +++ b/packages/core/src/modules/basic-messages/BasicMessagesApi.ts @@ -0,0 +1,49 @@ +import type { BasicMessageTags } from './repository/BasicMessageRecord' + +import { AgentContext } from '../../agent' +import { Dispatcher } from '../../agent/Dispatcher' +import { MessageSender } from '../../agent/MessageSender' +import { createOutboundMessage } from '../../agent/helpers' +import { injectable } from '../../plugins' +import { ConnectionService } from '../connections' + +import { BasicMessageHandler } from './handlers' +import { BasicMessageService } from './services' + +@injectable() +export class BasicMessagesApi { + private basicMessageService: BasicMessageService + private messageSender: MessageSender + private connectionService: ConnectionService + private agentContext: AgentContext + + public constructor( + dispatcher: Dispatcher, + basicMessageService: BasicMessageService, + messageSender: MessageSender, + connectionService: ConnectionService, + agentContext: AgentContext + ) { + this.basicMessageService = basicMessageService + this.messageSender = messageSender + this.connectionService = connectionService + this.agentContext = agentContext + this.registerHandlers(dispatcher) + } + + public async sendMessage(connectionId: string, message: string) { + const connection = await this.connectionService.getById(this.agentContext, connectionId) + + const basicMessage = await this.basicMessageService.createMessage(this.agentContext, message, connection) + const outboundMessage = createOutboundMessage(connection, basicMessage) + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + } + + public async findAllByQuery(query: Partial) { + return this.basicMessageService.findAllByQuery(this.agentContext, query) + } + + private registerHandlers(dispatcher: Dispatcher) { + dispatcher.registerHandler(new BasicMessageHandler(this.basicMessageService)) + } +} diff --git a/packages/core/src/modules/basic-messages/BasicMessagesModule.ts b/packages/core/src/modules/basic-messages/BasicMessagesModule.ts index 796ffa2334..03da109ff4 100644 --- a/packages/core/src/modules/basic-messages/BasicMessagesModule.ts +++ b/packages/core/src/modules/basic-messages/BasicMessagesModule.ts @@ -1,61 +1,16 @@ -import type { DependencyManager } from '../../plugins' -import type { BasicMessageTags } from './repository/BasicMessageRecord' +import type { DependencyManager, Module } from '../../plugins' -import { AgentContext } from '../../agent' -import { Dispatcher } from '../../agent/Dispatcher' -import { MessageSender } from '../../agent/MessageSender' -import { createOutboundMessage } from '../../agent/helpers' -import { injectable, module } from '../../plugins' -import { ConnectionService } from '../connections' - -import { BasicMessageHandler } from './handlers' +import { BasicMessagesApi } from './BasicMessagesApi' import { BasicMessageRepository } from './repository' import { BasicMessageService } from './services' -@module() -@injectable() -export class BasicMessagesModule { - private basicMessageService: BasicMessageService - private messageSender: MessageSender - private connectionService: ConnectionService - private agentContext: AgentContext - - public constructor( - dispatcher: Dispatcher, - basicMessageService: BasicMessageService, - messageSender: MessageSender, - connectionService: ConnectionService, - agentContext: AgentContext - ) { - this.basicMessageService = basicMessageService - this.messageSender = messageSender - this.connectionService = connectionService - this.agentContext = agentContext - this.registerHandlers(dispatcher) - } - - public async sendMessage(connectionId: string, message: string) { - const connection = await this.connectionService.getById(this.agentContext, connectionId) - - const basicMessage = await this.basicMessageService.createMessage(this.agentContext, message, connection) - const outboundMessage = createOutboundMessage(connection, basicMessage) - await this.messageSender.sendMessage(this.agentContext, outboundMessage) - } - - public async findAllByQuery(query: Partial) { - return this.basicMessageService.findAllByQuery(this.agentContext, query) - } - - private registerHandlers(dispatcher: Dispatcher) { - dispatcher.registerHandler(new BasicMessageHandler(this.basicMessageService)) - } - +export class BasicMessagesModule implements Module { /** * Registers the dependencies of the basic message module on the dependency manager. */ - public static register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager) { // Api - dependencyManager.registerContextScoped(BasicMessagesModule) + dependencyManager.registerContextScoped(BasicMessagesApi) // Services dependencyManager.registerSingleton(BasicMessageService) diff --git a/packages/core/src/modules/basic-messages/__tests__/BasicMessagesModule.test.ts b/packages/core/src/modules/basic-messages/__tests__/BasicMessagesModule.test.ts new file mode 100644 index 0000000000..caaaedae00 --- /dev/null +++ b/packages/core/src/modules/basic-messages/__tests__/BasicMessagesModule.test.ts @@ -0,0 +1,23 @@ +import { DependencyManager } from '../../../plugins/DependencyManager' +import { BasicMessagesApi } from '../BasicMessagesApi' +import { BasicMessagesModule } from '../BasicMessagesModule' +import { BasicMessageRepository } from '../repository' +import { BasicMessageService } from '../services' + +jest.mock('../../../plugins/DependencyManager') +const DependencyManagerMock = DependencyManager as jest.Mock + +const dependencyManager = new DependencyManagerMock() + +describe('BasicMessagesModule', () => { + test('registers dependencies on the dependency manager', () => { + new BasicMessagesModule().register(dependencyManager) + + expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) + expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(BasicMessagesApi) + + expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(2) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(BasicMessageService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(BasicMessageRepository) + }) +}) diff --git a/packages/core/src/modules/basic-messages/index.ts b/packages/core/src/modules/basic-messages/index.ts index eed8753ff2..e0ca5207d1 100644 --- a/packages/core/src/modules/basic-messages/index.ts +++ b/packages/core/src/modules/basic-messages/index.ts @@ -2,5 +2,6 @@ export * from './messages' export * from './services' export * from './repository' export * from './BasicMessageEvents' -export * from './BasicMessagesModule' +export * from './BasicMessagesApi' export * from './BasicMessageRole' +export * from './BasicMessagesModule' diff --git a/packages/core/src/modules/connections/ConnectionsApi.ts b/packages/core/src/modules/connections/ConnectionsApi.ts new file mode 100644 index 0000000000..cc4154eb08 --- /dev/null +++ b/packages/core/src/modules/connections/ConnectionsApi.ts @@ -0,0 +1,312 @@ +import type { OutOfBandRecord } from '../oob/repository' +import type { ConnectionRecord } from './repository/ConnectionRecord' +import type { Routing } from './services' + +import { AgentContext } from '../../agent' +import { Dispatcher } from '../../agent/Dispatcher' +import { MessageSender } from '../../agent/MessageSender' +import { createOutboundMessage } from '../../agent/helpers' +import { ReturnRouteTypes } from '../../decorators/transport/TransportDecorator' +import { AriesFrameworkError } from '../../error' +import { injectable } from '../../plugins' +import { DidResolverService } from '../dids' +import { DidRepository } from '../dids/repository' +import { OutOfBandService } from '../oob/OutOfBandService' +import { RoutingService } from '../routing/services/RoutingService' + +import { ConnectionsModuleConfig } from './ConnectionsModuleConfig' +import { DidExchangeProtocol } from './DidExchangeProtocol' +import { + AckMessageHandler, + ConnectionRequestHandler, + ConnectionResponseHandler, + DidExchangeCompleteHandler, + DidExchangeRequestHandler, + DidExchangeResponseHandler, + TrustPingMessageHandler, + TrustPingResponseMessageHandler, +} from './handlers' +import { HandshakeProtocol } from './models' +import { ConnectionService } from './services/ConnectionService' +import { TrustPingService } from './services/TrustPingService' + +@injectable() +export class ConnectionsApi { + /** + * Configuration for the connections module + */ + public readonly config: ConnectionsModuleConfig + + private didExchangeProtocol: DidExchangeProtocol + private connectionService: ConnectionService + private outOfBandService: OutOfBandService + private messageSender: MessageSender + private trustPingService: TrustPingService + private routingService: RoutingService + private didRepository: DidRepository + private didResolverService: DidResolverService + private agentContext: AgentContext + + public constructor( + dispatcher: Dispatcher, + didExchangeProtocol: DidExchangeProtocol, + connectionService: ConnectionService, + outOfBandService: OutOfBandService, + trustPingService: TrustPingService, + routingService: RoutingService, + didRepository: DidRepository, + didResolverService: DidResolverService, + messageSender: MessageSender, + agentContext: AgentContext, + connectionsModuleConfig: ConnectionsModuleConfig + ) { + this.didExchangeProtocol = didExchangeProtocol + this.connectionService = connectionService + this.outOfBandService = outOfBandService + this.trustPingService = trustPingService + this.routingService = routingService + this.didRepository = didRepository + this.messageSender = messageSender + this.didResolverService = didResolverService + this.agentContext = agentContext + this.config = connectionsModuleConfig + + this.registerHandlers(dispatcher) + } + + public async acceptOutOfBandInvitation( + outOfBandRecord: OutOfBandRecord, + config: { + autoAcceptConnection?: boolean + label?: string + alias?: string + imageUrl?: string + protocol: HandshakeProtocol + routing?: Routing + } + ) { + const { protocol, label, alias, imageUrl, autoAcceptConnection } = config + + const routing = + config.routing || + (await this.routingService.getRouting(this.agentContext, { mediatorId: outOfBandRecord.mediatorId })) + + let result + if (protocol === HandshakeProtocol.DidExchange) { + result = await this.didExchangeProtocol.createRequest(this.agentContext, outOfBandRecord, { + label, + alias, + routing, + autoAcceptConnection, + }) + } else if (protocol === HandshakeProtocol.Connections) { + result = await this.connectionService.createRequest(this.agentContext, outOfBandRecord, { + label, + alias, + imageUrl, + routing, + autoAcceptConnection, + }) + } else { + throw new AriesFrameworkError(`Unsupported handshake protocol ${protocol}.`) + } + + const { message, connectionRecord } = result + const outboundMessage = createOutboundMessage(connectionRecord, message, outOfBandRecord) + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + return connectionRecord + } + + /** + * Accept a connection request as inviter (by sending a connection response message) for the connection with the specified connection id. + * This is not needed when auto accepting of connection is enabled. + * + * @param connectionId the id of the connection for which to accept the request + * @returns connection record + */ + public async acceptRequest(connectionId: string): Promise { + const connectionRecord = await this.connectionService.findById(this.agentContext, connectionId) + if (!connectionRecord) { + throw new AriesFrameworkError(`Connection record ${connectionId} not found.`) + } + if (!connectionRecord.outOfBandId) { + throw new AriesFrameworkError(`Connection record ${connectionId} does not have out-of-band record.`) + } + + const outOfBandRecord = await this.outOfBandService.findById(this.agentContext, connectionRecord.outOfBandId) + if (!outOfBandRecord) { + throw new AriesFrameworkError(`Out-of-band record ${connectionRecord.outOfBandId} not found.`) + } + + let outboundMessage + if (connectionRecord.protocol === HandshakeProtocol.DidExchange) { + const message = await this.didExchangeProtocol.createResponse( + this.agentContext, + connectionRecord, + outOfBandRecord + ) + outboundMessage = createOutboundMessage(connectionRecord, message) + } else { + const { message } = await this.connectionService.createResponse( + this.agentContext, + connectionRecord, + outOfBandRecord + ) + outboundMessage = createOutboundMessage(connectionRecord, message) + } + + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + return connectionRecord + } + + /** + * Accept a connection response as invitee (by sending a trust ping message) for the connection with the specified connection id. + * This is not needed when auto accepting of connection is enabled. + * + * @param connectionId the id of the connection for which to accept the response + * @returns connection record + */ + public async acceptResponse(connectionId: string): Promise { + const connectionRecord = await this.connectionService.getById(this.agentContext, connectionId) + + let outboundMessage + if (connectionRecord.protocol === HandshakeProtocol.DidExchange) { + if (!connectionRecord.outOfBandId) { + throw new AriesFrameworkError(`Connection ${connectionRecord.id} does not have outOfBandId!`) + } + const outOfBandRecord = await this.outOfBandService.findById(this.agentContext, connectionRecord.outOfBandId) + if (!outOfBandRecord) { + throw new AriesFrameworkError( + `OutOfBand record for connection ${connectionRecord.id} with outOfBandId ${connectionRecord.outOfBandId} not found!` + ) + } + const message = await this.didExchangeProtocol.createComplete( + this.agentContext, + connectionRecord, + outOfBandRecord + ) + // Disable return routing as we don't want to receive a response for this message over the same channel + // This has led to long timeouts as not all clients actually close an http socket if there is no response message + message.setReturnRouting(ReturnRouteTypes.none) + outboundMessage = createOutboundMessage(connectionRecord, message) + } else { + const { message } = await this.connectionService.createTrustPing(this.agentContext, connectionRecord, { + responseRequested: false, + }) + // Disable return routing as we don't want to receive a response for this message over the same channel + // This has led to long timeouts as not all clients actually close an http socket if there is no response message + message.setReturnRouting(ReturnRouteTypes.none) + outboundMessage = createOutboundMessage(connectionRecord, message) + } + + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + return connectionRecord + } + + public async returnWhenIsConnected(connectionId: string, options?: { timeoutMs: number }): Promise { + return this.connectionService.returnWhenIsConnected(this.agentContext, connectionId, options?.timeoutMs) + } + + /** + * Retrieve all connections records + * + * @returns List containing all connection records + */ + public getAll() { + return this.connectionService.getAll(this.agentContext) + } + + /** + * Retrieve a connection record by id + * + * @param connectionId The connection record id + * @throws {RecordNotFoundError} If no record is found + * @return The connection record + * + */ + public getById(connectionId: string): Promise { + return this.connectionService.getById(this.agentContext, connectionId) + } + + /** + * Find a connection record by id + * + * @param connectionId the connection record id + * @returns The connection record or null if not found + */ + public findById(connectionId: string): Promise { + return this.connectionService.findById(this.agentContext, connectionId) + } + + /** + * Delete a connection record by id + * + * @param connectionId the connection record id + */ + public async deleteById(connectionId: string) { + return this.connectionService.deleteById(this.agentContext, connectionId) + } + + public async findAllByOutOfBandId(outOfBandId: string) { + return this.connectionService.findAllByOutOfBandId(this.agentContext, outOfBandId) + } + + /** + * Retrieve a connection record by thread id + * + * @param threadId The thread id + * @throws {RecordNotFoundError} If no record is found + * @throws {RecordDuplicateError} If multiple records are found + * @returns The connection record + */ + public getByThreadId(threadId: string): Promise { + return this.connectionService.getByThreadId(this.agentContext, threadId) + } + + public async findByDid(did: string): Promise { + return this.connectionService.findByTheirDid(this.agentContext, did) + } + + public async findByInvitationDid(invitationDid: string): Promise { + return this.connectionService.findByInvitationDid(this.agentContext, invitationDid) + } + + private registerHandlers(dispatcher: Dispatcher) { + dispatcher.registerHandler( + new ConnectionRequestHandler( + this.connectionService, + this.outOfBandService, + this.routingService, + this.didRepository, + this.config + ) + ) + dispatcher.registerHandler( + new ConnectionResponseHandler(this.connectionService, this.outOfBandService, this.didResolverService, this.config) + ) + dispatcher.registerHandler(new AckMessageHandler(this.connectionService)) + dispatcher.registerHandler(new TrustPingMessageHandler(this.trustPingService, this.connectionService)) + dispatcher.registerHandler(new TrustPingResponseMessageHandler(this.trustPingService)) + + dispatcher.registerHandler( + new DidExchangeRequestHandler( + this.didExchangeProtocol, + this.outOfBandService, + this.routingService, + this.didRepository, + this.config + ) + ) + + dispatcher.registerHandler( + new DidExchangeResponseHandler( + this.didExchangeProtocol, + this.outOfBandService, + this.connectionService, + this.didResolverService, + this.config + ) + ) + dispatcher.registerHandler(new DidExchangeCompleteHandler(this.didExchangeProtocol, this.outOfBandService)) + } +} diff --git a/packages/core/src/modules/connections/ConnectionsModule.ts b/packages/core/src/modules/connections/ConnectionsModule.ts index d8a4094c6e..5e6c98ee0a 100644 --- a/packages/core/src/modules/connections/ConnectionsModule.ts +++ b/packages/core/src/modules/connections/ConnectionsModule.ts @@ -1,313 +1,28 @@ -import type { DependencyManager } from '../../plugins' -import type { OutOfBandRecord } from '../oob/repository' -import type { ConnectionRecord } from './repository/ConnectionRecord' -import type { Routing } from './services' - -import { AgentContext } from '../../agent' -import { Dispatcher } from '../../agent/Dispatcher' -import { MessageSender } from '../../agent/MessageSender' -import { createOutboundMessage } from '../../agent/helpers' -import { ReturnRouteTypes } from '../../decorators/transport/TransportDecorator' -import { AriesFrameworkError } from '../../error' -import { injectable, module } from '../../plugins' -import { DidResolverService } from '../dids' -import { DidRepository } from '../dids/repository' -import { OutOfBandService } from '../oob/OutOfBandService' -import { RoutingService } from '../routing/services/RoutingService' +import type { DependencyManager, Module } from '../../plugins' +import type { ConnectionsModuleConfigOptions } from './ConnectionsModuleConfig' +import { ConnectionsApi } from './ConnectionsApi' +import { ConnectionsModuleConfig } from './ConnectionsModuleConfig' import { DidExchangeProtocol } from './DidExchangeProtocol' -import { - AckMessageHandler, - ConnectionRequestHandler, - ConnectionResponseHandler, - DidExchangeCompleteHandler, - DidExchangeRequestHandler, - DidExchangeResponseHandler, - TrustPingMessageHandler, - TrustPingResponseMessageHandler, -} from './handlers' -import { HandshakeProtocol } from './models' import { ConnectionRepository } from './repository' -import { ConnectionService } from './services/ConnectionService' -import { TrustPingService } from './services/TrustPingService' - -@module() -@injectable() -export class ConnectionsModule { - private didExchangeProtocol: DidExchangeProtocol - private connectionService: ConnectionService - private outOfBandService: OutOfBandService - private messageSender: MessageSender - private trustPingService: TrustPingService - private routingService: RoutingService - private didRepository: DidRepository - private didResolverService: DidResolverService - private agentContext: AgentContext - - public constructor( - dispatcher: Dispatcher, - didExchangeProtocol: DidExchangeProtocol, - connectionService: ConnectionService, - outOfBandService: OutOfBandService, - trustPingService: TrustPingService, - routingService: RoutingService, - didRepository: DidRepository, - didResolverService: DidResolverService, - messageSender: MessageSender, - agentContext: AgentContext - ) { - this.didExchangeProtocol = didExchangeProtocol - this.connectionService = connectionService - this.outOfBandService = outOfBandService - this.trustPingService = trustPingService - this.routingService = routingService - this.didRepository = didRepository - this.messageSender = messageSender - this.didResolverService = didResolverService - this.agentContext = agentContext - - this.registerHandlers(dispatcher) - } - - public async acceptOutOfBandInvitation( - outOfBandRecord: OutOfBandRecord, - config: { - autoAcceptConnection?: boolean - label?: string - alias?: string - imageUrl?: string - protocol: HandshakeProtocol - routing?: Routing - } - ) { - const { protocol, label, alias, imageUrl, autoAcceptConnection } = config - - const routing = - config.routing || - (await this.routingService.getRouting(this.agentContext, { mediatorId: outOfBandRecord.mediatorId })) - - let result - if (protocol === HandshakeProtocol.DidExchange) { - result = await this.didExchangeProtocol.createRequest(this.agentContext, outOfBandRecord, { - label, - alias, - routing, - autoAcceptConnection, - }) - } else if (protocol === HandshakeProtocol.Connections) { - result = await this.connectionService.createRequest(this.agentContext, outOfBandRecord, { - label, - alias, - imageUrl, - routing, - autoAcceptConnection, - }) - } else { - throw new AriesFrameworkError(`Unsupported handshake protocol ${protocol}.`) - } - - const { message, connectionRecord } = result - const outboundMessage = createOutboundMessage(connectionRecord, message, outOfBandRecord) - await this.messageSender.sendMessage(this.agentContext, outboundMessage) - return connectionRecord - } - - /** - * Accept a connection request as inviter (by sending a connection response message) for the connection with the specified connection id. - * This is not needed when auto accepting of connection is enabled. - * - * @param connectionId the id of the connection for which to accept the request - * @returns connection record - */ - public async acceptRequest(connectionId: string): Promise { - const connectionRecord = await this.connectionService.findById(this.agentContext, connectionId) - if (!connectionRecord) { - throw new AriesFrameworkError(`Connection record ${connectionId} not found.`) - } - if (!connectionRecord.outOfBandId) { - throw new AriesFrameworkError(`Connection record ${connectionId} does not have out-of-band record.`) - } - - const outOfBandRecord = await this.outOfBandService.findById(this.agentContext, connectionRecord.outOfBandId) - if (!outOfBandRecord) { - throw new AriesFrameworkError(`Out-of-band record ${connectionRecord.outOfBandId} not found.`) - } +import { ConnectionService, TrustPingService } from './services' - let outboundMessage - if (connectionRecord.protocol === HandshakeProtocol.DidExchange) { - const message = await this.didExchangeProtocol.createResponse( - this.agentContext, - connectionRecord, - outOfBandRecord - ) - outboundMessage = createOutboundMessage(connectionRecord, message) - } else { - const { message } = await this.connectionService.createResponse( - this.agentContext, - connectionRecord, - outOfBandRecord - ) - outboundMessage = createOutboundMessage(connectionRecord, message) - } +export class ConnectionsModule implements Module { + public readonly config: ConnectionsModuleConfig - await this.messageSender.sendMessage(this.agentContext, outboundMessage) - return connectionRecord - } - - /** - * Accept a connection response as invitee (by sending a trust ping message) for the connection with the specified connection id. - * This is not needed when auto accepting of connection is enabled. - * - * @param connectionId the id of the connection for which to accept the response - * @returns connection record - */ - public async acceptResponse(connectionId: string): Promise { - const connectionRecord = await this.connectionService.getById(this.agentContext, connectionId) - - let outboundMessage - if (connectionRecord.protocol === HandshakeProtocol.DidExchange) { - if (!connectionRecord.outOfBandId) { - throw new AriesFrameworkError(`Connection ${connectionRecord.id} does not have outOfBandId!`) - } - const outOfBandRecord = await this.outOfBandService.findById(this.agentContext, connectionRecord.outOfBandId) - if (!outOfBandRecord) { - throw new AriesFrameworkError( - `OutOfBand record for connection ${connectionRecord.id} with outOfBandId ${connectionRecord.outOfBandId} not found!` - ) - } - const message = await this.didExchangeProtocol.createComplete( - this.agentContext, - connectionRecord, - outOfBandRecord - ) - // Disable return routing as we don't want to receive a response for this message over the same channel - // This has led to long timeouts as not all clients actually close an http socket if there is no response message - message.setReturnRouting(ReturnRouteTypes.none) - outboundMessage = createOutboundMessage(connectionRecord, message) - } else { - const { message } = await this.connectionService.createTrustPing(this.agentContext, connectionRecord, { - responseRequested: false, - }) - // Disable return routing as we don't want to receive a response for this message over the same channel - // This has led to long timeouts as not all clients actually close an http socket if there is no response message - message.setReturnRouting(ReturnRouteTypes.none) - outboundMessage = createOutboundMessage(connectionRecord, message) - } - - await this.messageSender.sendMessage(this.agentContext, outboundMessage) - return connectionRecord - } - - public async returnWhenIsConnected(connectionId: string, options?: { timeoutMs: number }): Promise { - return this.connectionService.returnWhenIsConnected(this.agentContext, connectionId, options?.timeoutMs) - } - - /** - * Retrieve all connections records - * - * @returns List containing all connection records - */ - public getAll() { - return this.connectionService.getAll(this.agentContext) - } - - /** - * Retrieve a connection record by id - * - * @param connectionId The connection record id - * @throws {RecordNotFoundError} If no record is found - * @return The connection record - * - */ - public getById(connectionId: string): Promise { - return this.connectionService.getById(this.agentContext, connectionId) - } - - /** - * Find a connection record by id - * - * @param connectionId the connection record id - * @returns The connection record or null if not found - */ - public findById(connectionId: string): Promise { - return this.connectionService.findById(this.agentContext, connectionId) - } - - /** - * Delete a connection record by id - * - * @param connectionId the connection record id - */ - public async deleteById(connectionId: string) { - return this.connectionService.deleteById(this.agentContext, connectionId) - } - - public async findAllByOutOfBandId(outOfBandId: string) { - return this.connectionService.findAllByOutOfBandId(this.agentContext, outOfBandId) - } - - /** - * Retrieve a connection record by thread id - * - * @param threadId The thread id - * @throws {RecordNotFoundError} If no record is found - * @throws {RecordDuplicateError} If multiple records are found - * @returns The connection record - */ - public getByThreadId(threadId: string): Promise { - return this.connectionService.getByThreadId(this.agentContext, threadId) - } - - public async findByDid(did: string): Promise { - return this.connectionService.findByTheirDid(this.agentContext, did) - } - - public async findByInvitationDid(invitationDid: string): Promise { - return this.connectionService.findByInvitationDid(this.agentContext, invitationDid) - } - - private registerHandlers(dispatcher: Dispatcher) { - dispatcher.registerHandler( - new ConnectionRequestHandler( - this.connectionService, - this.outOfBandService, - this.routingService, - this.didRepository - ) - ) - dispatcher.registerHandler( - new ConnectionResponseHandler(this.connectionService, this.outOfBandService, this.didResolverService) - ) - dispatcher.registerHandler(new AckMessageHandler(this.connectionService)) - dispatcher.registerHandler(new TrustPingMessageHandler(this.trustPingService, this.connectionService)) - dispatcher.registerHandler(new TrustPingResponseMessageHandler(this.trustPingService)) - - dispatcher.registerHandler( - new DidExchangeRequestHandler( - this.didExchangeProtocol, - this.outOfBandService, - this.routingService, - this.didRepository - ) - ) - - dispatcher.registerHandler( - new DidExchangeResponseHandler( - this.didExchangeProtocol, - this.outOfBandService, - this.connectionService, - this.didResolverService - ) - ) - dispatcher.registerHandler(new DidExchangeCompleteHandler(this.didExchangeProtocol, this.outOfBandService)) + public constructor(config?: ConnectionsModuleConfigOptions) { + this.config = new ConnectionsModuleConfig(config) } /** * Registers the dependencies of the connections module on the dependency manager. */ - public static register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager) { // Api - dependencyManager.registerContextScoped(ConnectionsModule) + dependencyManager.registerContextScoped(ConnectionsApi) + + // Config + dependencyManager.registerInstance(ConnectionsModuleConfig, this.config) // Services dependencyManager.registerSingleton(ConnectionService) diff --git a/packages/core/src/modules/connections/ConnectionsModuleConfig.ts b/packages/core/src/modules/connections/ConnectionsModuleConfig.ts new file mode 100644 index 0000000000..b4b69edacf --- /dev/null +++ b/packages/core/src/modules/connections/ConnectionsModuleConfig.ts @@ -0,0 +1,26 @@ +/** + * ConnectionsModuleConfigOptions defines the interface for the options of the ConnectionsModuleConfig class. + * This can contain optional parameters that have default values in the config class itself. + */ +export interface ConnectionsModuleConfigOptions { + /** + * Whether to automatically accept connection messages. Applies to both the connection protocol (RFC 0160) + * and the DID exchange protocol (RFC 0023). + * + * @default false + */ + autoAcceptConnections?: boolean +} + +export class ConnectionsModuleConfig { + private options: ConnectionsModuleConfigOptions + + public constructor(options?: ConnectionsModuleConfigOptions) { + this.options = options ?? {} + } + + /** See {@link ConnectionsModuleConfigOptions.autoAcceptConnections} */ + public get autoAcceptConnections() { + return this.options.autoAcceptConnections ?? false + } +} diff --git a/packages/core/src/modules/connections/__tests__/ConnectionsModule.test.ts b/packages/core/src/modules/connections/__tests__/ConnectionsModule.test.ts new file mode 100644 index 0000000000..2231f69b07 --- /dev/null +++ b/packages/core/src/modules/connections/__tests__/ConnectionsModule.test.ts @@ -0,0 +1,31 @@ +import { DependencyManager } from '../../../plugins/DependencyManager' +import { ConnectionsApi } from '../ConnectionsApi' +import { ConnectionsModule } from '../ConnectionsModule' +import { ConnectionsModuleConfig } from '../ConnectionsModuleConfig' +import { DidExchangeProtocol } from '../DidExchangeProtocol' +import { ConnectionRepository } from '../repository' +import { ConnectionService, TrustPingService } from '../services' + +jest.mock('../../../plugins/DependencyManager') +const DependencyManagerMock = DependencyManager as jest.Mock + +const dependencyManager = new DependencyManagerMock() + +describe('ConnectionsModule', () => { + test('registers dependencies on the dependency manager', () => { + const connectionsModule = new ConnectionsModule() + connectionsModule.register(dependencyManager) + + expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) + expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(ConnectionsApi) + + expect(dependencyManager.registerInstance).toHaveBeenCalledTimes(1) + expect(dependencyManager.registerInstance).toHaveBeenCalledWith(ConnectionsModuleConfig, connectionsModule.config) + + expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(4) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(ConnectionService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(DidExchangeProtocol) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(TrustPingService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(ConnectionRepository) + }) +}) diff --git a/packages/core/src/modules/connections/__tests__/ConnectionsModuleConfig.test.ts b/packages/core/src/modules/connections/__tests__/ConnectionsModuleConfig.test.ts new file mode 100644 index 0000000000..bc4c0b29bb --- /dev/null +++ b/packages/core/src/modules/connections/__tests__/ConnectionsModuleConfig.test.ts @@ -0,0 +1,17 @@ +import { ConnectionsModuleConfig } from '../ConnectionsModuleConfig' + +describe('ConnectionsModuleConfig', () => { + test('sets default values', () => { + const config = new ConnectionsModuleConfig() + + expect(config.autoAcceptConnections).toBe(false) + }) + + test('sets values', () => { + const config = new ConnectionsModuleConfig({ + autoAcceptConnections: true, + }) + + expect(config.autoAcceptConnections).toBe(true) + }) +}) diff --git a/packages/core/src/modules/connections/handlers/ConnectionRequestHandler.ts b/packages/core/src/modules/connections/handlers/ConnectionRequestHandler.ts index 1f55bea49a..1291546616 100644 --- a/packages/core/src/modules/connections/handlers/ConnectionRequestHandler.ts +++ b/packages/core/src/modules/connections/handlers/ConnectionRequestHandler.ts @@ -2,6 +2,7 @@ import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' import type { DidRepository } from '../../dids/repository' import type { OutOfBandService } from '../../oob/OutOfBandService' import type { RoutingService } from '../../routing/services/RoutingService' +import type { ConnectionsModuleConfig } from '../ConnectionsModuleConfig' import type { ConnectionService } from '../services/ConnectionService' import { createOutboundMessage } from '../../../agent/helpers' @@ -13,18 +14,21 @@ export class ConnectionRequestHandler implements Handler { private outOfBandService: OutOfBandService private routingService: RoutingService private didRepository: DidRepository + private connectionsModuleConfig: ConnectionsModuleConfig public supportedMessages = [ConnectionRequestMessage] public constructor( connectionService: ConnectionService, outOfBandService: OutOfBandService, routingService: RoutingService, - didRepository: DidRepository + didRepository: DidRepository, + connectionsModuleConfig: ConnectionsModuleConfig ) { this.connectionService = connectionService this.outOfBandService = outOfBandService this.routingService = routingService this.didRepository = didRepository + this.connectionsModuleConfig = connectionsModuleConfig } public async handle(messageContext: HandlerInboundMessage) { @@ -53,7 +57,7 @@ export class ConnectionRequestHandler implements Handler { const connectionRecord = await this.connectionService.processRequest(messageContext, outOfBandRecord) - if (connectionRecord?.autoAcceptConnection ?? messageContext.agentContext.config.autoAcceptConnections) { + if (connectionRecord?.autoAcceptConnection ?? this.connectionsModuleConfig.autoAcceptConnections) { // TODO: Allow rotation of keys used in the invitation for new ones not only when out-of-band is reusable const routing = outOfBandRecord.reusable ? await this.routingService.getRouting(messageContext.agentContext) diff --git a/packages/core/src/modules/connections/handlers/ConnectionResponseHandler.ts b/packages/core/src/modules/connections/handlers/ConnectionResponseHandler.ts index 3d36029345..4b53e220d9 100644 --- a/packages/core/src/modules/connections/handlers/ConnectionResponseHandler.ts +++ b/packages/core/src/modules/connections/handlers/ConnectionResponseHandler.ts @@ -1,6 +1,7 @@ import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' import type { DidResolverService } from '../../dids' import type { OutOfBandService } from '../../oob/OutOfBandService' +import type { ConnectionsModuleConfig } from '../ConnectionsModuleConfig' import type { ConnectionService } from '../services/ConnectionService' import { createOutboundMessage } from '../../../agent/helpers' @@ -12,17 +13,20 @@ export class ConnectionResponseHandler implements Handler { private connectionService: ConnectionService private outOfBandService: OutOfBandService private didResolverService: DidResolverService + private connectionsModuleConfig: ConnectionsModuleConfig public supportedMessages = [ConnectionResponseMessage] public constructor( connectionService: ConnectionService, outOfBandService: OutOfBandService, - didResolverService: DidResolverService + didResolverService: DidResolverService, + connectionsModuleConfig: ConnectionsModuleConfig ) { this.connectionService = connectionService this.outOfBandService = outOfBandService this.didResolverService = didResolverService + this.connectionsModuleConfig = connectionsModuleConfig } public async handle(messageContext: HandlerInboundMessage) { @@ -72,7 +76,7 @@ export class ConnectionResponseHandler implements Handler { // TODO: should we only send ping message in case of autoAcceptConnection or always? // In AATH we have a separate step to send the ping. So for now we'll only do it // if auto accept is enable - if (connection.autoAcceptConnection ?? messageContext.agentContext.config.autoAcceptConnections) { + if (connection.autoAcceptConnection ?? this.connectionsModuleConfig.autoAcceptConnections) { const { message } = await this.connectionService.createTrustPing(messageContext.agentContext, connection, { responseRequested: false, }) diff --git a/packages/core/src/modules/connections/handlers/DidExchangeRequestHandler.ts b/packages/core/src/modules/connections/handlers/DidExchangeRequestHandler.ts index 20fa4437ec..f9d46cec7b 100644 --- a/packages/core/src/modules/connections/handlers/DidExchangeRequestHandler.ts +++ b/packages/core/src/modules/connections/handlers/DidExchangeRequestHandler.ts @@ -2,6 +2,7 @@ import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' import type { DidRepository } from '../../dids/repository' import type { OutOfBandService } from '../../oob/OutOfBandService' import type { RoutingService } from '../../routing/services/RoutingService' +import type { ConnectionsModuleConfig } from '../ConnectionsModuleConfig' import type { DidExchangeProtocol } from '../DidExchangeProtocol' import { createOutboundMessage } from '../../../agent/helpers' @@ -14,18 +15,21 @@ export class DidExchangeRequestHandler implements Handler { private outOfBandService: OutOfBandService private routingService: RoutingService private didRepository: DidRepository + private connectionsModuleConfig: ConnectionsModuleConfig public supportedMessages = [DidExchangeRequestMessage] public constructor( didExchangeProtocol: DidExchangeProtocol, outOfBandService: OutOfBandService, routingService: RoutingService, - didRepository: DidRepository + didRepository: DidRepository, + connectionsModuleConfig: ConnectionsModuleConfig ) { this.didExchangeProtocol = didExchangeProtocol this.outOfBandService = outOfBandService this.routingService = routingService this.didRepository = didRepository + this.connectionsModuleConfig = connectionsModuleConfig } public async handle(messageContext: HandlerInboundMessage) { @@ -68,7 +72,7 @@ export class DidExchangeRequestHandler implements Handler { const connectionRecord = await this.didExchangeProtocol.processRequest(messageContext, outOfBandRecord) - if (connectionRecord.autoAcceptConnection ?? messageContext.agentContext.config.autoAcceptConnections) { + if (connectionRecord.autoAcceptConnection ?? this.connectionsModuleConfig.autoAcceptConnections) { // TODO We should add an option to not pass routing and therefore do not rotate keys and use the keys from the invitation // TODO: Allow rotation of keys used in the invitation for new ones not only when out-of-band is reusable const routing = outOfBandRecord.reusable diff --git a/packages/core/src/modules/connections/handlers/DidExchangeResponseHandler.ts b/packages/core/src/modules/connections/handlers/DidExchangeResponseHandler.ts index c43ca94a79..f2b40697ea 100644 --- a/packages/core/src/modules/connections/handlers/DidExchangeResponseHandler.ts +++ b/packages/core/src/modules/connections/handlers/DidExchangeResponseHandler.ts @@ -1,6 +1,7 @@ import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' import type { DidResolverService } from '../../dids' import type { OutOfBandService } from '../../oob/OutOfBandService' +import type { ConnectionsModuleConfig } from '../ConnectionsModuleConfig' import type { DidExchangeProtocol } from '../DidExchangeProtocol' import type { ConnectionService } from '../services' @@ -16,18 +17,21 @@ export class DidExchangeResponseHandler implements Handler { private outOfBandService: OutOfBandService private connectionService: ConnectionService private didResolverService: DidResolverService + private connectionsModuleConfig: ConnectionsModuleConfig public supportedMessages = [DidExchangeResponseMessage] public constructor( didExchangeProtocol: DidExchangeProtocol, outOfBandService: OutOfBandService, connectionService: ConnectionService, - didResolverService: DidResolverService + didResolverService: DidResolverService, + connectionsModuleConfig: ConnectionsModuleConfig ) { this.didExchangeProtocol = didExchangeProtocol this.outOfBandService = outOfBandService this.connectionService = connectionService this.didResolverService = didResolverService + this.connectionsModuleConfig = connectionsModuleConfig } public async handle(messageContext: HandlerInboundMessage) { @@ -98,7 +102,7 @@ export class DidExchangeResponseHandler implements Handler { // TODO: should we only send complete message in case of autoAcceptConnection or always? // In AATH we have a separate step to send the complete. So for now we'll only do it // if auto accept is enabled - if (connection.autoAcceptConnection ?? messageContext.agentContext.config.autoAcceptConnections) { + if (connection.autoAcceptConnection ?? this.connectionsModuleConfig.autoAcceptConnections) { const message = await this.didExchangeProtocol.createComplete( messageContext.agentContext, connection, diff --git a/packages/core/src/modules/connections/index.ts b/packages/core/src/modules/connections/index.ts index f384af2d2a..52fe834617 100644 --- a/packages/core/src/modules/connections/index.ts +++ b/packages/core/src/modules/connections/index.ts @@ -3,5 +3,7 @@ export * from './models' export * from './repository' export * from './services' export * from './ConnectionEvents' -export * from './ConnectionsModule' +export * from './ConnectionsApi' export * from './DidExchangeProtocol' +export * from './ConnectionsModuleConfig' +export * from './ConnectionsModule' diff --git a/packages/core/src/modules/credentials/CredentialsApi.ts b/packages/core/src/modules/credentials/CredentialsApi.ts new file mode 100644 index 0000000000..658f723ba8 --- /dev/null +++ b/packages/core/src/modules/credentials/CredentialsApi.ts @@ -0,0 +1,634 @@ +import type { AgentMessage } from '../../agent/AgentMessage' +import type { DeleteCredentialOptions } from './CredentialServiceOptions' +import type { + AcceptCredentialOptions, + AcceptOfferOptions, + AcceptProposalOptions, + AcceptRequestOptions, + CreateOfferOptions, + FindCredentialMessageReturn, + FindOfferMessageReturn, + FindProposalMessageReturn, + FindRequestMessageReturn, + GetFormatDataReturn, + NegotiateOfferOptions, + NegotiateProposalOptions, + OfferCredentialOptions, + ProposeCredentialOptions, + SendProblemReportOptions, + ServiceMap, +} from './CredentialsApiOptions' +import type { CredentialFormat } from './formats' +import type { IndyCredentialFormat } from './formats/indy/IndyCredentialFormat' +import type { CredentialExchangeRecord } from './repository/CredentialExchangeRecord' +import type { CredentialService } from './services/CredentialService' + +import { AgentContext } from '../../agent' +import { MessageSender } from '../../agent/MessageSender' +import { createOutboundMessage } from '../../agent/helpers' +import { InjectionSymbols } from '../../constants' +import { ServiceDecorator } from '../../decorators/service/ServiceDecorator' +import { AriesFrameworkError } from '../../error' +import { Logger } from '../../logger' +import { inject, injectable } from '../../plugins' +import { DidCommMessageRole } from '../../storage' +import { DidCommMessageRepository } from '../../storage/didcomm/DidCommMessageRepository' +import { ConnectionService } from '../connections/services' +import { RoutingService } from '../routing/services/RoutingService' + +import { CredentialsModuleConfig } from './CredentialsModuleConfig' +import { CredentialState } from './models/CredentialState' +import { RevocationNotificationService } from './protocol/revocation-notification/services' +import { V1CredentialService } from './protocol/v1/V1CredentialService' +import { V2CredentialService } from './protocol/v2/V2CredentialService' +import { CredentialRepository } from './repository/CredentialRepository' + +export interface CredentialsApi[]> { + // Proposal methods + proposeCredential(options: ProposeCredentialOptions): Promise + acceptProposal(options: AcceptProposalOptions): Promise + negotiateProposal(options: NegotiateProposalOptions): Promise + + // Offer methods + offerCredential(options: OfferCredentialOptions): Promise + acceptOffer(options: AcceptOfferOptions): Promise + declineOffer(credentialRecordId: string): Promise + negotiateOffer(options: NegotiateOfferOptions): Promise + // out of band + createOffer(options: CreateOfferOptions): Promise<{ + message: AgentMessage + credentialRecord: CredentialExchangeRecord + }> + // Request + // This is for beginning the exchange with a request (no proposal or offer). Only possible + // (currently) with W3C. We will not implement this in phase I + // requestCredential(credentialOptions: RequestCredentialOptions): Promise + + // when the issuer accepts the request he issues the credential to the holder + acceptRequest(options: AcceptRequestOptions): Promise + + // Credential + acceptCredential(options: AcceptCredentialOptions): Promise + sendProblemReport(options: SendProblemReportOptions): Promise + + // Record Methods + getAll(): Promise + getById(credentialRecordId: string): Promise + findById(credentialRecordId: string): Promise + deleteById(credentialRecordId: string, options?: DeleteCredentialOptions): Promise + getFormatData(credentialRecordId: string): Promise> + + // DidComm Message Records + findProposalMessage(credentialExchangeId: string): Promise> + findOfferMessage(credentialExchangeId: string): Promise> + findRequestMessage(credentialExchangeId: string): Promise> + findCredentialMessage(credentialExchangeId: string): Promise> +} + +@injectable() +export class CredentialsApi< + CFs extends CredentialFormat[] = [IndyCredentialFormat], + CSs extends CredentialService[] = [V1CredentialService, V2CredentialService] +> implements CredentialsApi +{ + /** + * Configuration for the connections module + */ + public readonly config: CredentialsModuleConfig + + private connectionService: ConnectionService + private messageSender: MessageSender + private credentialRepository: CredentialRepository + private agentContext: AgentContext + private didCommMessageRepository: DidCommMessageRepository + private routingService: RoutingService + private logger: Logger + private serviceMap: ServiceMap + + public constructor( + messageSender: MessageSender, + connectionService: ConnectionService, + agentContext: AgentContext, + @inject(InjectionSymbols.Logger) logger: Logger, + credentialRepository: CredentialRepository, + mediationRecipientService: RoutingService, + didCommMessageRepository: DidCommMessageRepository, + v1Service: V1CredentialService, + v2Service: V2CredentialService, + // only injected so the handlers will be registered + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _revocationNotificationService: RevocationNotificationService, + config: CredentialsModuleConfig + ) { + this.messageSender = messageSender + this.connectionService = connectionService + this.credentialRepository = credentialRepository + this.routingService = mediationRecipientService + this.agentContext = agentContext + this.didCommMessageRepository = didCommMessageRepository + this.logger = logger + 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 + + this.logger.debug(`Initializing Credentials Module for agent ${this.agentContext.config.label}`) + } + + public getService(protocolVersion: PVT): CredentialService { + if (!this.serviceMap[protocolVersion]) { + throw new AriesFrameworkError(`No credential service registered for protocol version ${protocolVersion}`) + } + + return this.serviceMap[protocolVersion] + } + + /** + * Initiate a new credential exchange as holder by sending a credential proposal message + * to the connection with the specified credential options + * + * @param options configuration to use for the proposal + * @returns Credential exchange record associated with the sent proposal message + */ + + public async proposeCredential(options: ProposeCredentialOptions): Promise { + const service = this.getService(options.protocolVersion) + + this.logger.debug(`Got a CredentialService object for version ${options.protocolVersion}`) + + const connection = await this.connectionService.getById(this.agentContext, options.connectionId) + + // will get back a credential record -> map to Credential Exchange Record + const { credentialRecord, message } = await service.createProposal(this.agentContext, { + connection, + credentialFormats: options.credentialFormats, + comment: options.comment, + autoAcceptCredential: options.autoAcceptCredential, + }) + + this.logger.debug('We have a message (sending outbound): ', message) + + // send the message here + const outbound = createOutboundMessage(connection, message) + + this.logger.debug('In proposeCredential: Send Proposal to Issuer') + await this.messageSender.sendMessage(this.agentContext, outbound) + return credentialRecord + } + + /** + * Accept a credential proposal as issuer (by sending a credential offer message) to the connection + * associated with the credential record. + * + * @param options config object for accepting the proposal + * @returns Credential exchange record associated with the credential offer + * + */ + public async acceptProposal(options: AcceptProposalOptions): Promise { + const credentialRecord = await this.getById(options.credentialRecordId) + + if (!credentialRecord.connectionId) { + throw new AriesFrameworkError( + `No connectionId found for credential record '${credentialRecord.id}'. Connection-less issuance does not support credential proposal or negotiation.` + ) + } + + // with version we can get the Service + const service = this.getService(credentialRecord.protocolVersion) + + // will get back a credential record -> map to Credential Exchange Record + const { message } = await service.acceptProposal(this.agentContext, { + credentialRecord, + credentialFormats: options.credentialFormats, + comment: options.comment, + autoAcceptCredential: options.autoAcceptCredential, + }) + + // send the message + const connection = await this.connectionService.getById(this.agentContext, credentialRecord.connectionId) + const outbound = createOutboundMessage(connection, message) + await this.messageSender.sendMessage(this.agentContext, outbound) + + return credentialRecord + } + + /** + * Negotiate a credential proposal as issuer (by sending a credential offer message) to the connection + * associated with the credential record. + * + * @param options configuration for the offer see {@link NegotiateProposalOptions} + * @returns Credential exchange record associated with the credential offer + * + */ + public async negotiateProposal(options: NegotiateProposalOptions): Promise { + const credentialRecord = await this.getById(options.credentialRecordId) + + if (!credentialRecord.connectionId) { + throw new AriesFrameworkError( + `No connection id for credential record ${credentialRecord.id} not found. Connection-less issuance does not support negotiation` + ) + } + + // with version we can get the Service + const service = this.getService(credentialRecord.protocolVersion) + + const { message } = await service.negotiateProposal(this.agentContext, { + credentialRecord, + credentialFormats: options.credentialFormats, + comment: options.comment, + autoAcceptCredential: options.autoAcceptCredential, + }) + + const connection = await this.connectionService.getById(this.agentContext, credentialRecord.connectionId) + const outboundMessage = createOutboundMessage(connection, message) + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + + return credentialRecord + } + + /** + * Initiate a new credential exchange as issuer by sending a credential offer message + * to the connection with the specified connection id. + * + * @param options config options for the credential offer + * @returns Credential exchange record associated with the sent credential offer message + */ + public async offerCredential(options: OfferCredentialOptions): Promise { + const connection = await this.connectionService.getById(this.agentContext, options.connectionId) + const service = this.getService(options.protocolVersion) + + this.logger.debug(`Got a CredentialService object for version ${options.protocolVersion}`) + + const { message, credentialRecord } = await service.createOffer(this.agentContext, { + credentialFormats: options.credentialFormats, + autoAcceptCredential: options.autoAcceptCredential, + comment: options.comment, + connection, + }) + + this.logger.debug('Offer Message successfully created; message= ', message) + const outboundMessage = createOutboundMessage(connection, message) + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + + return credentialRecord + } + + /** + * Accept a credential offer as holder (by sending a credential request message) to the connection + * associated with the credential record. + * + * @param options The object containing config options of the offer to be accepted + * @returns Object containing offer associated credential record + */ + public async acceptOffer(options: AcceptOfferOptions): Promise { + const credentialRecord = await this.getById(options.credentialRecordId) + + const service = this.getService(credentialRecord.protocolVersion) + + this.logger.debug(`Got a CredentialService object for this version; version = ${service.version}`) + const offerMessage = await service.findOfferMessage(this.agentContext, credentialRecord.id) + + // Use connection if present + if (credentialRecord.connectionId) { + const connection = await this.connectionService.getById(this.agentContext, credentialRecord.connectionId) + + const { message } = await service.acceptOffer(this.agentContext, { + credentialRecord, + credentialFormats: options.credentialFormats, + comment: options.comment, + autoAcceptCredential: options.autoAcceptCredential, + }) + + const outboundMessage = createOutboundMessage(connection, message) + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + + return credentialRecord + } + // Use ~service decorator otherwise + else if (offerMessage?.service) { + // Create ~service decorator + const routing = await this.routingService.getRouting(this.agentContext) + const ourService = new ServiceDecorator({ + serviceEndpoint: routing.endpoints[0], + recipientKeys: [routing.recipientKey.publicKeyBase58], + routingKeys: routing.routingKeys.map((key) => key.publicKeyBase58), + }) + const recipientService = offerMessage.service + + const { message } = await service.acceptOffer(this.agentContext, { + credentialRecord, + credentialFormats: options.credentialFormats, + comment: options.comment, + autoAcceptCredential: options.autoAcceptCredential, + }) + + // Set and save ~service decorator to record (to remember our verkey) + message.service = ourService + await this.didCommMessageRepository.saveOrUpdateAgentMessage(this.agentContext, { + agentMessage: message, + role: DidCommMessageRole.Sender, + associatedRecordId: credentialRecord.id, + }) + + await this.messageSender.sendMessageToService(this.agentContext, { + message, + service: recipientService.resolvedDidCommService, + senderKey: ourService.resolvedDidCommService.recipientKeys[0], + returnRoute: true, + }) + + return credentialRecord + } + // Cannot send message without connectionId or ~service decorator + else { + throw new AriesFrameworkError( + `Cannot accept offer for credential record without connectionId or ~service decorator on credential offer.` + ) + } + } + + public async declineOffer(credentialRecordId: string): Promise { + const credentialRecord = await this.getById(credentialRecordId) + credentialRecord.assertState(CredentialState.OfferReceived) + + // with version we can get the Service + const service = this.getService(credentialRecord.protocolVersion) + await service.updateState(this.agentContext, credentialRecord, CredentialState.Declined) + + return credentialRecord + } + + public async negotiateOffer(options: NegotiateOfferOptions): Promise { + const credentialRecord = await this.getById(options.credentialRecordId) + + const service = this.getService(credentialRecord.protocolVersion) + const { message } = await service.negotiateOffer(this.agentContext, { + credentialFormats: options.credentialFormats, + credentialRecord, + comment: options.comment, + autoAcceptCredential: options.autoAcceptCredential, + }) + + if (!credentialRecord.connectionId) { + throw new AriesFrameworkError( + `No connection id for credential record ${credentialRecord.id} not found. Connection-less issuance does not support negotiation` + ) + } + + const connection = await this.connectionService.getById(this.agentContext, credentialRecord.connectionId) + const outboundMessage = createOutboundMessage(connection, message) + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + + return credentialRecord + } + + /** + * Initiate a new credential exchange as issuer by creating a credential offer + * not bound to any connection. The offer must be delivered out-of-band to the holder + * @param options The credential options to use for the offer + * @returns The credential record and credential offer message + */ + public async createOffer(options: CreateOfferOptions): Promise<{ + message: AgentMessage + credentialRecord: CredentialExchangeRecord + }> { + const service = this.getService(options.protocolVersion) + + this.logger.debug(`Got a CredentialService object for version ${options.protocolVersion}`) + const { message, credentialRecord } = await service.createOffer(this.agentContext, { + credentialFormats: options.credentialFormats, + comment: options.comment, + autoAcceptCredential: options.autoAcceptCredential, + }) + + this.logger.debug('Offer Message successfully created; message= ', message) + + return { message, credentialRecord } + } + + /** + * Accept a credential request as holder (by sending a credential request message) to the connection + * associated with the credential record. + * + * @param options The object containing config options of the request + * @returns CredentialExchangeRecord updated with information pertaining to this request + */ + public async acceptRequest(options: AcceptRequestOptions): Promise { + const credentialRecord = await this.getById(options.credentialRecordId) + + // with version we can get the Service + const service = this.getService(credentialRecord.protocolVersion) + + this.logger.debug(`Got a CredentialService object for version ${credentialRecord.protocolVersion}`) + + const { message } = await service.acceptRequest(this.agentContext, { + credentialRecord, + credentialFormats: options.credentialFormats, + comment: options.comment, + autoAcceptCredential: options.autoAcceptCredential, + }) + this.logger.debug('We have a credential message (sending outbound): ', message) + + const requestMessage = await service.findRequestMessage(this.agentContext, credentialRecord.id) + const offerMessage = await service.findOfferMessage(this.agentContext, credentialRecord.id) + + // Use connection if present + if (credentialRecord.connectionId) { + const connection = await this.connectionService.getById(this.agentContext, credentialRecord.connectionId) + const outboundMessage = createOutboundMessage(connection, message) + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + + return credentialRecord + } + // Use ~service decorator otherwise + else if (requestMessage?.service && offerMessage?.service) { + const recipientService = requestMessage.service + const ourService = offerMessage.service + + message.service = ourService + await this.didCommMessageRepository.saveOrUpdateAgentMessage(this.agentContext, { + agentMessage: message, + role: DidCommMessageRole.Sender, + associatedRecordId: credentialRecord.id, + }) + + await this.messageSender.sendMessageToService(this.agentContext, { + message, + service: recipientService.resolvedDidCommService, + senderKey: ourService.resolvedDidCommService.recipientKeys[0], + returnRoute: true, + }) + + return credentialRecord + } + // Cannot send message without connectionId or ~service decorator + else { + throw new AriesFrameworkError( + `Cannot accept request for credential record without connectionId or ~service decorator on credential offer / request.` + ) + } + } + + /** + * Accept a credential as holder (by sending a credential acknowledgement message) to the connection + * associated with the credential record. + * + * @param credentialRecordId The id of the credential record for which to accept the credential + * @returns credential exchange record associated with the sent credential acknowledgement message + * + */ + public async acceptCredential(options: AcceptCredentialOptions): Promise { + const credentialRecord = await this.getById(options.credentialRecordId) + + // with version we can get the Service + const service = this.getService(credentialRecord.protocolVersion) + + this.logger.debug(`Got a CredentialService object for version ${credentialRecord.protocolVersion}`) + + const { message } = await service.acceptCredential(this.agentContext, { + credentialRecord, + }) + + const requestMessage = await service.findRequestMessage(this.agentContext, credentialRecord.id) + const credentialMessage = await service.findCredentialMessage(this.agentContext, credentialRecord.id) + + if (credentialRecord.connectionId) { + const connection = await this.connectionService.getById(this.agentContext, credentialRecord.connectionId) + const outboundMessage = createOutboundMessage(connection, message) + + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + + return credentialRecord + } + // Use ~service decorator otherwise + else if (credentialMessage?.service && requestMessage?.service) { + const recipientService = credentialMessage.service + const ourService = requestMessage.service + + await this.messageSender.sendMessageToService(this.agentContext, { + message, + service: recipientService.resolvedDidCommService, + senderKey: ourService.resolvedDidCommService.recipientKeys[0], + returnRoute: true, + }) + + return credentialRecord + } + // Cannot send message without connectionId or ~service decorator + else { + throw new AriesFrameworkError( + `Cannot accept credential without connectionId or ~service decorator on credential message.` + ) + } + } + + /** + * Send problem report message for a credential record + * @param credentialRecordId The id of the credential record for which to send problem report + * @param message message to send + * @returns credential record associated with the credential problem report message + */ + public async sendProblemReport(options: SendProblemReportOptions) { + const credentialRecord = await this.getById(options.credentialRecordId) + if (!credentialRecord.connectionId) { + throw new AriesFrameworkError(`No connectionId found for credential record '${credentialRecord.id}'.`) + } + const connection = await this.connectionService.getById(this.agentContext, credentialRecord.connectionId) + + const service = this.getService(credentialRecord.protocolVersion) + const problemReportMessage = service.createProblemReport(this.agentContext, { message: options.message }) + problemReportMessage.setThread({ + threadId: credentialRecord.threadId, + }) + const outboundMessage = createOutboundMessage(connection, problemReportMessage) + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + + return credentialRecord + } + + public async getFormatData(credentialRecordId: string): Promise> { + const credentialRecord = await this.getById(credentialRecordId) + const service = this.getService(credentialRecord.protocolVersion) + + return service.getFormatData(this.agentContext, credentialRecordId) + } + + /** + * Retrieve a credential record by id + * + * @param credentialRecordId The credential record id + * @throws {RecordNotFoundError} If no record is found + * @return The credential record + * + */ + public getById(credentialRecordId: string): Promise { + return this.credentialRepository.getById(this.agentContext, credentialRecordId) + } + + /** + * Retrieve all credential records + * + * @returns List containing all credential records + */ + public getAll(): Promise { + return this.credentialRepository.getAll(this.agentContext) + } + + /** + * Find a credential record by id + * + * @param credentialRecordId the credential record id + * @returns The credential record or null if not found + */ + public findById(credentialRecordId: string): Promise { + return this.credentialRepository.findById(this.agentContext, credentialRecordId) + } + + /** + * Delete a credential record by id, also calls service to delete from wallet + * + * @param credentialId the credential record id + * @param options the delete credential options for the delete operation + */ + public async deleteById(credentialId: string, options?: DeleteCredentialOptions) { + const credentialRecord = await this.getById(credentialId) + const service = this.getService(credentialRecord.protocolVersion) + return service.delete(this.agentContext, credentialRecord, options) + } + + public async findProposalMessage(credentialExchangeId: string): Promise> { + const service = await this.getServiceForCredentialExchangeId(credentialExchangeId) + + return service.findProposalMessage(this.agentContext, credentialExchangeId) + } + + public async findOfferMessage(credentialExchangeId: string): Promise> { + const service = await this.getServiceForCredentialExchangeId(credentialExchangeId) + + return service.findOfferMessage(this.agentContext, credentialExchangeId) + } + + public async findRequestMessage(credentialExchangeId: string): Promise> { + const service = await this.getServiceForCredentialExchangeId(credentialExchangeId) + + return service.findRequestMessage(this.agentContext, credentialExchangeId) + } + + public async findCredentialMessage(credentialExchangeId: string): Promise> { + const service = await this.getServiceForCredentialExchangeId(credentialExchangeId) + + return service.findCredentialMessage(this.agentContext, credentialExchangeId) + } + + private async getServiceForCredentialExchangeId(credentialExchangeId: string) { + const credentialExchangeRecord = await this.getById(credentialExchangeId) + + return this.getService(credentialExchangeRecord.protocolVersion) + } +} diff --git a/packages/core/src/modules/credentials/CredentialsModuleOptions.ts b/packages/core/src/modules/credentials/CredentialsApiOptions.ts similarity index 84% rename from packages/core/src/modules/credentials/CredentialsModuleOptions.ts rename to packages/core/src/modules/credentials/CredentialsApiOptions.ts index 3224f6d1ca..8dc345bcae 100644 --- a/packages/core/src/modules/credentials/CredentialsModuleOptions.ts +++ b/packages/core/src/modules/credentials/CredentialsApiOptions.ts @@ -41,7 +41,7 @@ interface BaseOptions { } /** - * Interface for CredentialsModule.proposeCredential. Will send a proposal. + * Interface for CredentialsApi.proposeCredential. Will send a proposal. */ export interface ProposeCredentialOptions< CFs extends CredentialFormat[] = CredentialFormat[], @@ -53,7 +53,7 @@ export interface ProposeCredentialOptions< } /** - * Interface for CredentialsModule.acceptProposal. Will send an offer + * Interface for CredentialsApi.acceptProposal. Will send an offer * * credentialFormats is optional because this is an accept method */ @@ -63,7 +63,7 @@ export interface AcceptProposalOptions extends BaseOptions { credentialRecordId: string @@ -71,7 +71,7 @@ export interface NegotiateProposalOptions extends BaseOptions { credentialRecordId: string @@ -111,7 +111,7 @@ export interface NegotiateOfferOptions[]> { - // Proposal methods - proposeCredential(options: ProposeCredentialOptions): Promise - acceptProposal(options: AcceptProposalOptions): Promise - negotiateProposal(options: NegotiateProposalOptions): Promise - - // Offer methods - offerCredential(options: OfferCredentialOptions): Promise - acceptOffer(options: AcceptOfferOptions): Promise - declineOffer(credentialRecordId: string): Promise - negotiateOffer(options: NegotiateOfferOptions): Promise - // out of band - createOffer(options: CreateOfferOptions): Promise<{ - message: AgentMessage - credentialRecord: CredentialExchangeRecord - }> - // Request - // This is for beginning the exchange with a request (no proposal or offer). Only possible - // (currently) with W3C. We will not implement this in phase I - // requestCredential(credentialOptions: RequestCredentialOptions): Promise - - // when the issuer accepts the request he issues the credential to the holder - acceptRequest(options: AcceptRequestOptions): Promise - - // Credential - acceptCredential(options: AcceptCredentialOptions): Promise - sendProblemReport(options: SendProblemReportOptions): Promise - - // Record Methods - getAll(): Promise - getById(credentialRecordId: string): Promise - findById(credentialRecordId: string): Promise - deleteById(credentialRecordId: string, options?: DeleteCredentialOptions): Promise - getFormatData(credentialRecordId: string): Promise> - - // DidComm Message Records - findProposalMessage(credentialExchangeId: string): Promise> - findOfferMessage(credentialExchangeId: string): Promise> - findRequestMessage(credentialExchangeId: string): Promise> - findCredentialMessage(credentialExchangeId: string): Promise> -} - -@module() -@injectable() -export class CredentialsModule< - CFs extends CredentialFormat[] = [IndyCredentialFormat], - CSs extends CredentialService[] = [V1CredentialService, V2CredentialService] -> implements CredentialsModule -{ - private connectionService: ConnectionService - private messageSender: MessageSender - private credentialRepository: CredentialRepository - private agentContext: AgentContext - private didCommMessageRepo: DidCommMessageRepository - private routingService: RoutingService - private logger: Logger - private serviceMap: ServiceMap - - public constructor( - messageSender: MessageSender, - connectionService: ConnectionService, - agentContext: AgentContext, - @inject(InjectionSymbols.Logger) logger: Logger, - credentialRepository: CredentialRepository, - mediationRecipientService: RoutingService, - didCommMessageRepository: DidCommMessageRepository, - v1Service: V1CredentialService, - v2Service: V2CredentialService, - // only injected so the handlers will be registered - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _revocationNotificationService: RevocationNotificationService - ) { - this.messageSender = messageSender - this.connectionService = connectionService - this.credentialRepository = credentialRepository - this.routingService = mediationRecipientService - this.agentContext = agentContext - this.didCommMessageRepo = didCommMessageRepository - this.logger = logger - - // 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 - - this.logger.debug(`Initializing Credentials Module for agent ${this.agentContext.config.label}`) - } - - public getService(protocolVersion: PVT): CredentialService { - if (!this.serviceMap[protocolVersion]) { - throw new AriesFrameworkError(`No credential service registered for protocol version ${protocolVersion}`) - } - - return this.serviceMap[protocolVersion] - } - - /** - * Initiate a new credential exchange as holder by sending a credential proposal message - * to the connection with the specified credential options - * - * @param options configuration to use for the proposal - * @returns Credential exchange record associated with the sent proposal message - */ - - public async proposeCredential(options: ProposeCredentialOptions): Promise { - const service = this.getService(options.protocolVersion) - - this.logger.debug(`Got a CredentialService object for version ${options.protocolVersion}`) - - const connection = await this.connectionService.getById(this.agentContext, options.connectionId) - - // will get back a credential record -> map to Credential Exchange Record - const { credentialRecord, message } = await service.createProposal(this.agentContext, { - connection, - credentialFormats: options.credentialFormats, - comment: options.comment, - autoAcceptCredential: options.autoAcceptCredential, - }) - - this.logger.debug('We have a message (sending outbound): ', message) - - // send the message here - const outbound = createOutboundMessage(connection, message) - - this.logger.debug('In proposeCredential: Send Proposal to Issuer') - await this.messageSender.sendMessage(this.agentContext, outbound) - return credentialRecord - } - - /** - * Accept a credential proposal as issuer (by sending a credential offer message) to the connection - * associated with the credential record. - * - * @param options config object for accepting the proposal - * @returns Credential exchange record associated with the credential offer - * - */ - public async acceptProposal(options: AcceptProposalOptions): Promise { - const credentialRecord = await this.getById(options.credentialRecordId) - - if (!credentialRecord.connectionId) { - throw new AriesFrameworkError( - `No connectionId found for credential record '${credentialRecord.id}'. Connection-less issuance does not support credential proposal or negotiation.` - ) - } - - // with version we can get the Service - const service = this.getService(credentialRecord.protocolVersion) - - // will get back a credential record -> map to Credential Exchange Record - const { message } = await service.acceptProposal(this.agentContext, { - credentialRecord, - credentialFormats: options.credentialFormats, - comment: options.comment, - autoAcceptCredential: options.autoAcceptCredential, - }) - - // send the message - const connection = await this.connectionService.getById(this.agentContext, credentialRecord.connectionId) - const outbound = createOutboundMessage(connection, message) - await this.messageSender.sendMessage(this.agentContext, outbound) - - return credentialRecord - } - - /** - * Negotiate a credential proposal as issuer (by sending a credential offer message) to the connection - * associated with the credential record. - * - * @param options configuration for the offer see {@link NegotiateProposalOptions} - * @returns Credential exchange record associated with the credential offer - * - */ - public async negotiateProposal(options: NegotiateProposalOptions): Promise { - const credentialRecord = await this.getById(options.credentialRecordId) - - if (!credentialRecord.connectionId) { - throw new AriesFrameworkError( - `No connection id for credential record ${credentialRecord.id} not found. Connection-less issuance does not support negotiation` - ) - } - - // with version we can get the Service - const service = this.getService(credentialRecord.protocolVersion) - - const { message } = await service.negotiateProposal(this.agentContext, { - credentialRecord, - credentialFormats: options.credentialFormats, - comment: options.comment, - autoAcceptCredential: options.autoAcceptCredential, - }) - - const connection = await this.connectionService.getById(this.agentContext, credentialRecord.connectionId) - const outboundMessage = createOutboundMessage(connection, message) - await this.messageSender.sendMessage(this.agentContext, outboundMessage) - - return credentialRecord - } - - /** - * Initiate a new credential exchange as issuer by sending a credential offer message - * to the connection with the specified connection id. - * - * @param options config options for the credential offer - * @returns Credential exchange record associated with the sent credential offer message - */ - public async offerCredential(options: OfferCredentialOptions): Promise { - const connection = await this.connectionService.getById(this.agentContext, options.connectionId) - const service = this.getService(options.protocolVersion) - - this.logger.debug(`Got a CredentialService object for version ${options.protocolVersion}`) - - const { message, credentialRecord } = await service.createOffer(this.agentContext, { - credentialFormats: options.credentialFormats, - autoAcceptCredential: options.autoAcceptCredential, - comment: options.comment, - connection, - }) - - this.logger.debug('Offer Message successfully created; message= ', message) - const outboundMessage = createOutboundMessage(connection, message) - await this.messageSender.sendMessage(this.agentContext, outboundMessage) - - return credentialRecord - } - - /** - * Accept a credential offer as holder (by sending a credential request message) to the connection - * associated with the credential record. - * - * @param options The object containing config options of the offer to be accepted - * @returns Object containing offer associated credential record - */ - public async acceptOffer(options: AcceptOfferOptions): Promise { - const credentialRecord = await this.getById(options.credentialRecordId) - - const service = this.getService(credentialRecord.protocolVersion) - - this.logger.debug(`Got a CredentialService object for this version; version = ${service.version}`) - const offerMessage = await service.findOfferMessage(this.agentContext, credentialRecord.id) - - // Use connection if present - if (credentialRecord.connectionId) { - const connection = await this.connectionService.getById(this.agentContext, credentialRecord.connectionId) - - const { message } = await service.acceptOffer(this.agentContext, { - credentialRecord, - credentialFormats: options.credentialFormats, - comment: options.comment, - autoAcceptCredential: options.autoAcceptCredential, - }) - - const outboundMessage = createOutboundMessage(connection, message) - await this.messageSender.sendMessage(this.agentContext, outboundMessage) - - return credentialRecord - } - // Use ~service decorator otherwise - else if (offerMessage?.service) { - // Create ~service decorator - const routing = await this.routingService.getRouting(this.agentContext) - const ourService = new ServiceDecorator({ - serviceEndpoint: routing.endpoints[0], - recipientKeys: [routing.recipientKey.publicKeyBase58], - routingKeys: routing.routingKeys.map((key) => key.publicKeyBase58), - }) - const recipientService = offerMessage.service - - const { message } = await service.acceptOffer(this.agentContext, { - credentialRecord, - credentialFormats: options.credentialFormats, - comment: options.comment, - autoAcceptCredential: options.autoAcceptCredential, - }) - - // Set and save ~service decorator to record (to remember our verkey) - message.service = ourService - await this.didCommMessageRepo.saveOrUpdateAgentMessage(this.agentContext, { - agentMessage: message, - role: DidCommMessageRole.Sender, - associatedRecordId: credentialRecord.id, - }) - - await this.messageSender.sendMessageToService(this.agentContext, { - message, - service: recipientService.resolvedDidCommService, - senderKey: ourService.resolvedDidCommService.recipientKeys[0], - returnRoute: true, - }) - - return credentialRecord - } - // Cannot send message without connectionId or ~service decorator - else { - throw new AriesFrameworkError( - `Cannot accept offer for credential record without connectionId or ~service decorator on credential offer.` - ) - } - } - - public async declineOffer(credentialRecordId: string): Promise { - const credentialRecord = await this.getById(credentialRecordId) - credentialRecord.assertState(CredentialState.OfferReceived) - - // with version we can get the Service - const service = this.getService(credentialRecord.protocolVersion) - await service.updateState(this.agentContext, credentialRecord, CredentialState.Declined) - - return credentialRecord - } - - public async negotiateOffer(options: NegotiateOfferOptions): Promise { - const credentialRecord = await this.getById(options.credentialRecordId) - - const service = this.getService(credentialRecord.protocolVersion) - const { message } = await service.negotiateOffer(this.agentContext, { - credentialFormats: options.credentialFormats, - credentialRecord, - comment: options.comment, - autoAcceptCredential: options.autoAcceptCredential, - }) - - if (!credentialRecord.connectionId) { - throw new AriesFrameworkError( - `No connection id for credential record ${credentialRecord.id} not found. Connection-less issuance does not support negotiation` - ) - } +import { V1CredentialService } from './protocol/v1' +import { V2CredentialService } from './protocol/v2' +import { CredentialRepository } from './repository' - const connection = await this.connectionService.getById(this.agentContext, credentialRecord.connectionId) - const outboundMessage = createOutboundMessage(connection, message) - await this.messageSender.sendMessage(this.agentContext, outboundMessage) +export class CredentialsModule implements Module { + public readonly config: CredentialsModuleConfig - return credentialRecord - } - - /** - * Initiate a new credential exchange as issuer by creating a credential offer - * not bound to any connection. The offer must be delivered out-of-band to the holder - * @param options The credential options to use for the offer - * @returns The credential record and credential offer message - */ - public async createOffer(options: CreateOfferOptions): Promise<{ - message: AgentMessage - credentialRecord: CredentialExchangeRecord - }> { - const service = this.getService(options.protocolVersion) - - this.logger.debug(`Got a CredentialService object for version ${options.protocolVersion}`) - const { message, credentialRecord } = await service.createOffer(this.agentContext, { - credentialFormats: options.credentialFormats, - comment: options.comment, - autoAcceptCredential: options.autoAcceptCredential, - }) - - this.logger.debug('Offer Message successfully created; message= ', message) - - return { message, credentialRecord } - } - - /** - * Accept a credential request as holder (by sending a credential request message) to the connection - * associated with the credential record. - * - * @param options The object containing config options of the request - * @returns CredentialExchangeRecord updated with information pertaining to this request - */ - public async acceptRequest(options: AcceptRequestOptions): Promise { - const credentialRecord = await this.getById(options.credentialRecordId) - - // with version we can get the Service - const service = this.getService(credentialRecord.protocolVersion) - - this.logger.debug(`Got a CredentialService object for version ${credentialRecord.protocolVersion}`) - - const { message } = await service.acceptRequest(this.agentContext, { - credentialRecord, - credentialFormats: options.credentialFormats, - comment: options.comment, - autoAcceptCredential: options.autoAcceptCredential, - }) - this.logger.debug('We have a credential message (sending outbound): ', message) - - const requestMessage = await service.findRequestMessage(this.agentContext, credentialRecord.id) - const offerMessage = await service.findOfferMessage(this.agentContext, credentialRecord.id) - - // Use connection if present - if (credentialRecord.connectionId) { - const connection = await this.connectionService.getById(this.agentContext, credentialRecord.connectionId) - const outboundMessage = createOutboundMessage(connection, message) - await this.messageSender.sendMessage(this.agentContext, outboundMessage) - - return credentialRecord - } - // Use ~service decorator otherwise - else if (requestMessage?.service && offerMessage?.service) { - const recipientService = requestMessage.service - const ourService = offerMessage.service - - message.service = ourService - await this.didCommMessageRepo.saveOrUpdateAgentMessage(this.agentContext, { - agentMessage: message, - role: DidCommMessageRole.Sender, - associatedRecordId: credentialRecord.id, - }) - - await this.messageSender.sendMessageToService(this.agentContext, { - message, - service: recipientService.resolvedDidCommService, - senderKey: ourService.resolvedDidCommService.recipientKeys[0], - returnRoute: true, - }) - - return credentialRecord - } - // Cannot send message without connectionId or ~service decorator - else { - throw new AriesFrameworkError( - `Cannot accept request for credential record without connectionId or ~service decorator on credential offer / request.` - ) - } - } - - /** - * Accept a credential as holder (by sending a credential acknowledgement message) to the connection - * associated with the credential record. - * - * @param credentialRecordId The id of the credential record for which to accept the credential - * @returns credential exchange record associated with the sent credential acknowledgement message - * - */ - public async acceptCredential(options: AcceptCredentialOptions): Promise { - const credentialRecord = await this.getById(options.credentialRecordId) - - // with version we can get the Service - const service = this.getService(credentialRecord.protocolVersion) - - this.logger.debug(`Got a CredentialService object for version ${credentialRecord.protocolVersion}`) - - const { message } = await service.acceptCredential(this.agentContext, { - credentialRecord, - }) - - const requestMessage = await service.findRequestMessage(this.agentContext, credentialRecord.id) - const credentialMessage = await service.findCredentialMessage(this.agentContext, credentialRecord.id) - - if (credentialRecord.connectionId) { - const connection = await this.connectionService.getById(this.agentContext, credentialRecord.connectionId) - const outboundMessage = createOutboundMessage(connection, message) - - await this.messageSender.sendMessage(this.agentContext, outboundMessage) - - return credentialRecord - } - // Use ~service decorator otherwise - else if (credentialMessage?.service && requestMessage?.service) { - const recipientService = credentialMessage.service - const ourService = requestMessage.service - - await this.messageSender.sendMessageToService(this.agentContext, { - message, - service: recipientService.resolvedDidCommService, - senderKey: ourService.resolvedDidCommService.recipientKeys[0], - returnRoute: true, - }) - - return credentialRecord - } - // Cannot send message without connectionId or ~service decorator - else { - throw new AriesFrameworkError( - `Cannot accept credential without connectionId or ~service decorator on credential message.` - ) - } - } - - /** - * Send problem report message for a credential record - * @param credentialRecordId The id of the credential record for which to send problem report - * @param message message to send - * @returns credential record associated with the credential problem report message - */ - public async sendProblemReport(options: SendProblemReportOptions) { - const credentialRecord = await this.getById(options.credentialRecordId) - if (!credentialRecord.connectionId) { - throw new AriesFrameworkError(`No connectionId found for credential record '${credentialRecord.id}'.`) - } - const connection = await this.connectionService.getById(this.agentContext, credentialRecord.connectionId) - - const service = this.getService(credentialRecord.protocolVersion) - const problemReportMessage = service.createProblemReport(this.agentContext, { message: options.message }) - problemReportMessage.setThread({ - threadId: credentialRecord.threadId, - }) - const outboundMessage = createOutboundMessage(connection, problemReportMessage) - await this.messageSender.sendMessage(this.agentContext, outboundMessage) - - return credentialRecord - } - - public async getFormatData(credentialRecordId: string): Promise> { - const credentialRecord = await this.getById(credentialRecordId) - const service = this.getService(credentialRecord.protocolVersion) - - return service.getFormatData(this.agentContext, credentialRecordId) - } - - /** - * Retrieve a credential record by id - * - * @param credentialRecordId The credential record id - * @throws {RecordNotFoundError} If no record is found - * @return The credential record - * - */ - public getById(credentialRecordId: string): Promise { - return this.credentialRepository.getById(this.agentContext, credentialRecordId) - } - - /** - * Retrieve all credential records - * - * @returns List containing all credential records - */ - public getAll(): Promise { - return this.credentialRepository.getAll(this.agentContext) - } - - /** - * Find a credential record by id - * - * @param credentialRecordId the credential record id - * @returns The credential record or null if not found - */ - public findById(credentialRecordId: string): Promise { - return this.credentialRepository.findById(this.agentContext, credentialRecordId) - } - - /** - * Delete a credential record by id, also calls service to delete from wallet - * - * @param credentialId the credential record id - * @param options the delete credential options for the delete operation - */ - public async deleteById(credentialId: string, options?: DeleteCredentialOptions) { - const credentialRecord = await this.getById(credentialId) - const service = this.getService(credentialRecord.protocolVersion) - return service.delete(this.agentContext, credentialRecord, options) - } - - public async findProposalMessage(credentialExchangeId: string): Promise> { - const service = await this.getServiceForCredentialExchangeId(credentialExchangeId) - - return service.findProposalMessage(this.agentContext, credentialExchangeId) - } - - public async findOfferMessage(credentialExchangeId: string): Promise> { - const service = await this.getServiceForCredentialExchangeId(credentialExchangeId) - - return service.findOfferMessage(this.agentContext, credentialExchangeId) - } - - public async findRequestMessage(credentialExchangeId: string): Promise> { - const service = await this.getServiceForCredentialExchangeId(credentialExchangeId) - - return service.findRequestMessage(this.agentContext, credentialExchangeId) - } - - public async findCredentialMessage(credentialExchangeId: string): Promise> { - const service = await this.getServiceForCredentialExchangeId(credentialExchangeId) - - return service.findCredentialMessage(this.agentContext, credentialExchangeId) - } - - private async getServiceForCredentialExchangeId(credentialExchangeId: string) { - const credentialExchangeRecord = await this.getById(credentialExchangeId) - - return this.getService(credentialExchangeRecord.protocolVersion) + public constructor(config?: CredentialsModuleConfigOptions) { + this.config = new CredentialsModuleConfig(config) } /** * Registers the dependencies of the credentials module on the dependency manager. */ - public static register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager) { // Api - dependencyManager.registerContextScoped(CredentialsModule) + dependencyManager.registerContextScoped(CredentialsApi) + + // Config + dependencyManager.registerInstance(CredentialsModuleConfig, this.config) // Services dependencyManager.registerSingleton(V1CredentialService) diff --git a/packages/core/src/modules/credentials/CredentialsModuleConfig.ts b/packages/core/src/modules/credentials/CredentialsModuleConfig.ts new file mode 100644 index 0000000000..7bce4095f4 --- /dev/null +++ b/packages/core/src/modules/credentials/CredentialsModuleConfig.ts @@ -0,0 +1,27 @@ +import { AutoAcceptCredential } from './models' + +/** + * CredentialsModuleConfigOptions defines the interface for the options of the CredentialsModuleConfig class. + * This can contain optional parameters that have default values in the config class itself. + */ +export interface CredentialsModuleConfigOptions { + /** + * Whether to automatically accept credential messages. Applies to all issue credential protocol versions. + * + * @default {@link AutoAcceptCredential.Never} + */ + autoAcceptCredentials?: AutoAcceptCredential +} + +export class CredentialsModuleConfig { + private options: CredentialsModuleConfigOptions + + public constructor(options?: CredentialsModuleConfigOptions) { + this.options = options ?? {} + } + + /** See {@link CredentialsModuleConfigOptions.autoAcceptCredentials} */ + public get autoAcceptCredentials() { + return this.options.autoAcceptCredentials ?? AutoAcceptCredential.Never + } +} diff --git a/packages/core/src/modules/credentials/__tests__/CredentialsModule.test.ts b/packages/core/src/modules/credentials/__tests__/CredentialsModule.test.ts new file mode 100644 index 0000000000..b430d90d9c --- /dev/null +++ b/packages/core/src/modules/credentials/__tests__/CredentialsModule.test.ts @@ -0,0 +1,33 @@ +import { DependencyManager } from '../../../plugins/DependencyManager' +import { CredentialsApi } from '../CredentialsApi' +import { CredentialsModule } from '../CredentialsModule' +import { CredentialsModuleConfig } from '../CredentialsModuleConfig' +import { IndyCredentialFormatService } from '../formats' +import { V1CredentialService, V2CredentialService } from '../protocol' +import { RevocationNotificationService } from '../protocol/revocation-notification/services' +import { CredentialRepository } from '../repository' + +jest.mock('../../../plugins/DependencyManager') +const DependencyManagerMock = DependencyManager as jest.Mock + +const dependencyManager = new DependencyManagerMock() + +describe('CredentialsModule', () => { + test('registers dependencies on the dependency manager', () => { + const credentialsModule = new CredentialsModule() + credentialsModule.register(dependencyManager) + + expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) + expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(CredentialsApi) + + expect(dependencyManager.registerInstance).toHaveBeenCalledTimes(1) + expect(dependencyManager.registerInstance).toHaveBeenCalledWith(CredentialsModuleConfig, credentialsModule.config) + + expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(5) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(V1CredentialService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(V2CredentialService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(RevocationNotificationService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(CredentialRepository) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(IndyCredentialFormatService) + }) +}) diff --git a/packages/core/src/modules/credentials/__tests__/CredentialsModuleConfig.test.ts b/packages/core/src/modules/credentials/__tests__/CredentialsModuleConfig.test.ts new file mode 100644 index 0000000000..5725cd7378 --- /dev/null +++ b/packages/core/src/modules/credentials/__tests__/CredentialsModuleConfig.test.ts @@ -0,0 +1,18 @@ +import { CredentialsModuleConfig } from '../CredentialsModuleConfig' +import { AutoAcceptCredential } from '../models' + +describe('CredentialsModuleConfig', () => { + test('sets default values', () => { + const config = new CredentialsModuleConfig() + + expect(config.autoAcceptCredentials).toBe(AutoAcceptCredential.Never) + }) + + test('sets values', () => { + const config = new CredentialsModuleConfig({ + autoAcceptCredentials: AutoAcceptCredential.Always, + }) + + expect(config.autoAcceptCredentials).toBe(AutoAcceptCredential.Always) + }) +}) diff --git a/packages/core/src/modules/credentials/formats/indy/IndyCredentialFormat.ts b/packages/core/src/modules/credentials/formats/indy/IndyCredentialFormat.ts index 527e37518e..9f15c1152f 100644 --- a/packages/core/src/modules/credentials/formats/indy/IndyCredentialFormat.ts +++ b/packages/core/src/modules/credentials/formats/indy/IndyCredentialFormat.ts @@ -5,8 +5,8 @@ import type { IndyCredProposeOptions } from './models/IndyCredPropose' import type { Cred, CredOffer, CredReq } from 'indy-sdk' /** - * This defines the module payload for calling CredentialsModule.createProposal - * or CredentialsModule.negotiateOffer + * This defines the module payload for calling CredentialsApi.createProposal + * or CredentialsApi.negotiateOffer */ export interface IndyProposeCredentialFormat extends IndyCredProposeOptions { attributes?: CredentialPreviewAttributeOptions[] @@ -14,7 +14,7 @@ export interface IndyProposeCredentialFormat extends IndyCredProposeOptions { } /** - * This defines the module payload for calling CredentialsModule.acceptProposal + * This defines the module payload for calling CredentialsApi.acceptProposal */ export interface IndyAcceptProposalFormat { credentialDefinitionId?: string @@ -27,8 +27,8 @@ export interface IndyAcceptOfferFormat { } /** - * This defines the module payload for calling CredentialsModule.offerCredential - * or CredentialsModule.negotiateProposal + * This defines the module payload for calling CredentialsApi.offerCredential + * or CredentialsApi.negotiateProposal */ export interface IndyOfferCredentialFormat { credentialDefinitionId: string diff --git a/packages/core/src/modules/credentials/index.ts b/packages/core/src/modules/credentials/index.ts index 8b24da2371..d34680afe1 100644 --- a/packages/core/src/modules/credentials/index.ts +++ b/packages/core/src/modules/credentials/index.ts @@ -1,8 +1,9 @@ -export * from './CredentialsModule' +export * from './CredentialsApi' +export * from './CredentialsApiOptions' export * from './repository' - export * from './CredentialEvents' -export * from './CredentialsModuleOptions' export * from './models' export * from './formats' export * from './protocol' +export * from './CredentialsModule' +export * from './CredentialsModuleConfig' diff --git a/packages/core/src/modules/credentials/models/CredentialAutoAcceptType.ts b/packages/core/src/modules/credentials/models/CredentialAutoAcceptType.ts index 79d11568e7..397e1ff70a 100644 --- a/packages/core/src/modules/credentials/models/CredentialAutoAcceptType.ts +++ b/packages/core/src/modules/credentials/models/CredentialAutoAcceptType.ts @@ -2,12 +2,12 @@ * Typing of the state for auto acceptance */ export enum AutoAcceptCredential { - // Always auto accepts the credential no matter if it changed in subsequent steps + /** Always auto accepts the credential no matter if it changed in subsequent steps */ Always = 'always', - // Needs one acceptation and the rest will be automated if nothing changes + /** Needs one acceptation and the rest will be automated if nothing changes */ ContentApproved = 'contentApproved', - // Never auto accept a credential + /** Never auto accept a credential */ Never = 'never', } diff --git a/packages/core/src/modules/credentials/protocol/v1/V1CredentialService.ts b/packages/core/src/modules/credentials/protocol/v1/V1CredentialService.ts index 938dbf7dd6..c9bba83a18 100644 --- a/packages/core/src/modules/credentials/protocol/v1/V1CredentialService.ts +++ b/packages/core/src/modules/credentials/protocol/v1/V1CredentialService.ts @@ -14,7 +14,7 @@ import type { NegotiateOfferOptions, NegotiateProposalOptions, } from '../../CredentialServiceOptions' -import type { GetFormatDataReturn } from '../../CredentialsModuleOptions' +import type { GetFormatDataReturn } from '../../CredentialsApiOptions' import type { CredentialFormat } from '../../formats' import type { IndyCredentialFormat } from '../../formats/indy/IndyCredentialFormat' @@ -32,6 +32,7 @@ import { uuid } from '../../../../utils/uuid' import { AckStatus } from '../../../common' import { ConnectionService } from '../../../connections/services' import { RoutingService } from '../../../routing/services/RoutingService' +import { CredentialsModuleConfig } from '../../CredentialsModuleConfig' import { CredentialProblemReportReason } from '../../errors' import { IndyCredentialFormatService } from '../../formats/indy/IndyCredentialFormatService' import { IndyCredPropose } from '../../formats/indy/models' @@ -68,6 +69,7 @@ export class V1CredentialService extends CredentialService<[IndyCredentialFormat private connectionService: ConnectionService private formatService: IndyCredentialFormatService private routingService: RoutingService + private credentialsModuleConfig: CredentialsModuleConfig public constructor( connectionService: ConnectionService, @@ -77,12 +79,14 @@ export class V1CredentialService extends CredentialService<[IndyCredentialFormat dispatcher: Dispatcher, eventEmitter: EventEmitter, credentialRepository: CredentialRepository, - formatService: IndyCredentialFormatService + formatService: IndyCredentialFormatService, + credentialsModuleConfig: CredentialsModuleConfig ) { super(credentialRepository, didCommMessageRepository, eventEmitter, dispatcher, logger) this.connectionService = connectionService this.formatService = formatService this.routingService = routingService + this.credentialsModuleConfig = credentialsModuleConfig this.registerHandlers() } @@ -963,7 +967,7 @@ export class V1CredentialService extends CredentialService<[IndyCredentialFormat const { credentialRecord, proposalMessage } = options const autoAccept = composeAutoAccept( credentialRecord.autoAcceptCredential, - agentContext.config.autoAcceptCredentials + this.credentialsModuleConfig.autoAcceptCredentials ) // Handle always / never cases @@ -999,7 +1003,7 @@ export class V1CredentialService extends CredentialService<[IndyCredentialFormat const { credentialRecord, offerMessage } = options const autoAccept = composeAutoAccept( credentialRecord.autoAcceptCredential, - agentContext.config.autoAcceptCredentials + this.credentialsModuleConfig.autoAcceptCredentials ) // Handle always / never cases @@ -1035,7 +1039,7 @@ export class V1CredentialService extends CredentialService<[IndyCredentialFormat const { credentialRecord, requestMessage } = options const autoAccept = composeAutoAccept( credentialRecord.autoAcceptCredential, - agentContext.config.autoAcceptCredentials + this.credentialsModuleConfig.autoAcceptCredentials ) // Handle always / never cases @@ -1067,7 +1071,7 @@ export class V1CredentialService extends CredentialService<[IndyCredentialFormat const { credentialRecord, credentialMessage } = options const autoAccept = composeAutoAccept( credentialRecord.autoAcceptCredential, - agentContext.config.autoAcceptCredentials + this.credentialsModuleConfig.autoAcceptCredentials ) // Handle always / never cases diff --git a/packages/core/src/modules/credentials/protocol/v1/__tests__/V1CredentialServiceCred.test.ts b/packages/core/src/modules/credentials/protocol/v1/__tests__/V1CredentialServiceCred.test.ts index 343751b4d5..3e68856131 100644 --- a/packages/core/src/modules/credentials/protocol/v1/__tests__/V1CredentialServiceCred.test.ts +++ b/packages/core/src/modules/credentials/protocol/v1/__tests__/V1CredentialServiceCred.test.ts @@ -24,6 +24,7 @@ import { DidExchangeState } from '../../../../connections' import { ConnectionService } from '../../../../connections/services/ConnectionService' import { RoutingService } from '../../../../routing/services/RoutingService' import { CredentialEventTypes } from '../../../CredentialEvents' +import { CredentialsModuleConfig } from '../../../CredentialsModuleConfig' import { credDef, credReq } from '../../../__tests__/fixtures' import { CredentialProblemReportReason } from '../../../errors/CredentialProblemReportReason' import { IndyCredentialFormatService } from '../../../formats/indy/IndyCredentialFormatService' @@ -248,7 +249,8 @@ describe('V1CredentialService', () => { dispatcher, eventEmitter, credentialRepository, - indyCredentialFormatService + indyCredentialFormatService, + new CredentialsModuleConfig() ) }) diff --git a/packages/core/src/modules/credentials/protocol/v1/__tests__/V1CredentialServiceProposeOffer.test.ts b/packages/core/src/modules/credentials/protocol/v1/__tests__/V1CredentialServiceProposeOffer.test.ts index 3fd459ece5..3d22169e93 100644 --- a/packages/core/src/modules/credentials/protocol/v1/__tests__/V1CredentialServiceProposeOffer.test.ts +++ b/packages/core/src/modules/credentials/protocol/v1/__tests__/V1CredentialServiceProposeOffer.test.ts @@ -16,6 +16,7 @@ import { ConnectionService } from '../../../../connections/services/ConnectionSe import { IndyLedgerService } from '../../../../ledger/services' import { RoutingService } from '../../../../routing/services/RoutingService' import { CredentialEventTypes } from '../../../CredentialEvents' +import { CredentialsModuleConfig } from '../../../CredentialsModuleConfig' import { schema, credDef } from '../../../__tests__/fixtures' import { IndyCredentialFormatService } from '../../../formats' import { CredentialFormatSpec } from '../../../models' @@ -114,7 +115,8 @@ describe('V1CredentialServiceProposeOffer', () => { dispatcher, eventEmitter, credentialRepository, - indyCredentialFormatService + indyCredentialFormatService, + new CredentialsModuleConfig() ) }) diff --git a/packages/core/src/modules/credentials/protocol/v1/__tests__/v1-connectionless-credentials.e2e.test.ts b/packages/core/src/modules/credentials/protocol/v1/__tests__/v1-connectionless-credentials.e2e.test.ts index a8cf883879..3f8c508400 100644 --- a/packages/core/src/modules/credentials/protocol/v1/__tests__/v1-connectionless-credentials.e2e.test.ts +++ b/packages/core/src/modules/credentials/protocol/v1/__tests__/v1-connectionless-credentials.e2e.test.ts @@ -1,6 +1,6 @@ import type { SubjectMessage } from '../../../../../../../../tests/transport/SubjectInboundTransport' import type { CredentialStateChangedEvent } from '../../../CredentialEvents' -import type { AcceptOfferOptions, AcceptRequestOptions, CreateOfferOptions } from '../../../CredentialsModuleOptions' +import type { AcceptOfferOptions, AcceptRequestOptions, CreateOfferOptions } from '../../../CredentialsApiOptions' import { ReplaySubject, Subject } from 'rxjs' diff --git a/packages/core/src/modules/credentials/protocol/v1/__tests__/v1-credentials-auto-accept.e2e.test.ts b/packages/core/src/modules/credentials/protocol/v1/__tests__/v1-credentials-auto-accept.e2e.test.ts index e0f8ef7fdc..75cbdcae29 100644 --- a/packages/core/src/modules/credentials/protocol/v1/__tests__/v1-credentials-auto-accept.e2e.test.ts +++ b/packages/core/src/modules/credentials/protocol/v1/__tests__/v1-credentials-auto-accept.e2e.test.ts @@ -1,6 +1,6 @@ import type { Agent } from '../../../../../agent/Agent' import type { ConnectionRecord } from '../../../../connections' -import type { AcceptOfferOptions, AcceptProposalOptions } from '../../../CredentialsModuleOptions' +import type { AcceptOfferOptions, AcceptProposalOptions } from '../../../CredentialsApiOptions' import type { Schema } from 'indy-sdk' import { setupCredentialTests, waitForCredentialRecord } from '../../../../../../tests/helpers' diff --git a/packages/core/src/modules/credentials/protocol/v1/handlers/V1IssueCredentialHandler.ts b/packages/core/src/modules/credentials/protocol/v1/handlers/V1IssueCredentialHandler.ts index bf12db449a..c8b986d97e 100644 --- a/packages/core/src/modules/credentials/protocol/v1/handlers/V1IssueCredentialHandler.ts +++ b/packages/core/src/modules/credentials/protocol/v1/handlers/V1IssueCredentialHandler.ts @@ -40,9 +40,7 @@ export class V1IssueCredentialHandler implements Handler { credentialRecord: CredentialExchangeRecord, messageContext: HandlerInboundMessage ) { - this.logger.info( - `Automatically sending acknowledgement with autoAccept on ${messageContext.agentContext.config.autoAcceptCredentials}` - ) + this.logger.info(`Automatically sending acknowledgement with autoAccept`) const { message } = await this.credentialService.acceptCredential(messageContext.agentContext, { credentialRecord, }) diff --git a/packages/core/src/modules/credentials/protocol/v1/handlers/V1OfferCredentialHandler.ts b/packages/core/src/modules/credentials/protocol/v1/handlers/V1OfferCredentialHandler.ts index 207cbff379..510c5de434 100644 --- a/packages/core/src/modules/credentials/protocol/v1/handlers/V1OfferCredentialHandler.ts +++ b/packages/core/src/modules/credentials/protocol/v1/handlers/V1OfferCredentialHandler.ts @@ -46,9 +46,7 @@ export class V1OfferCredentialHandler implements Handler { credentialRecord: CredentialExchangeRecord, messageContext: HandlerInboundMessage ) { - this.logger.info( - `Automatically sending request with autoAccept on ${messageContext.agentContext.config.autoAcceptCredentials}` - ) + this.logger.info(`Automatically sending request with autoAccept`) if (messageContext.connection) { const { message } = await this.credentialService.acceptOffer(messageContext.agentContext, { credentialRecord }) diff --git a/packages/core/src/modules/credentials/protocol/v1/handlers/V1ProposeCredentialHandler.ts b/packages/core/src/modules/credentials/protocol/v1/handlers/V1ProposeCredentialHandler.ts index 38c32018d7..05dc7371af 100644 --- a/packages/core/src/modules/credentials/protocol/v1/handlers/V1ProposeCredentialHandler.ts +++ b/packages/core/src/modules/credentials/protocol/v1/handlers/V1ProposeCredentialHandler.ts @@ -36,9 +36,7 @@ export class V1ProposeCredentialHandler implements Handler { credentialRecord: CredentialExchangeRecord, messageContext: HandlerInboundMessage ) { - this.logger.info( - `Automatically sending offer with autoAccept on ${messageContext.agentContext.config.autoAcceptCredentials}` - ) + this.logger.info(`Automatically sending offer with autoAccept`) if (!messageContext.connection) { this.logger.error('No connection on the messageContext, aborting auto accept') diff --git a/packages/core/src/modules/credentials/protocol/v1/handlers/V1RequestCredentialHandler.ts b/packages/core/src/modules/credentials/protocol/v1/handlers/V1RequestCredentialHandler.ts index a5eb94ad41..f155001022 100644 --- a/packages/core/src/modules/credentials/protocol/v1/handlers/V1RequestCredentialHandler.ts +++ b/packages/core/src/modules/credentials/protocol/v1/handlers/V1RequestCredentialHandler.ts @@ -41,9 +41,7 @@ export class V1RequestCredentialHandler implements Handler { credentialRecord: CredentialExchangeRecord, messageContext: HandlerInboundMessage ) { - this.logger.info( - `Automatically sending credential with autoAccept on ${messageContext.agentContext.config.autoAcceptCredentials}` - ) + this.logger.info(`Automatically sending credential with autoAccept`) const offerMessage = await this.credentialService.findOfferMessage(messageContext.agentContext, credentialRecord.id) diff --git a/packages/core/src/modules/credentials/protocol/v2/V2CredentialService.ts b/packages/core/src/modules/credentials/protocol/v2/V2CredentialService.ts index 75023e3ddc..177af825f4 100644 --- a/packages/core/src/modules/credentials/protocol/v2/V2CredentialService.ts +++ b/packages/core/src/modules/credentials/protocol/v2/V2CredentialService.ts @@ -37,6 +37,7 @@ import { uuid } from '../../../../utils/uuid' import { AckStatus } from '../../../common' import { ConnectionService } from '../../../connections' import { RoutingService } from '../../../routing/services/RoutingService' +import { CredentialsModuleConfig } from '../../CredentialsModuleConfig' import { CredentialProblemReportReason } from '../../errors' import { IndyCredentialFormatService } from '../../formats/indy/IndyCredentialFormatService' import { AutoAcceptCredential, CredentialState } from '../../models' @@ -68,6 +69,7 @@ export class V2CredentialService private routingService: RoutingService + private credentialsModuleConfig: CredentialsModuleConfig private formatServiceMap: { [key: string]: CredentialFormatService } public constructor( @@ -78,12 +80,14 @@ export class V2CredentialService { eventEmitter, credentialRepository, indyCredentialFormatService, - agentConfig.logger + agentConfig.logger, + new CredentialsModuleConfig() ) }) diff --git a/packages/core/src/modules/credentials/protocol/v2/__tests__/V2CredentialServiceOffer.test.ts b/packages/core/src/modules/credentials/protocol/v2/__tests__/V2CredentialServiceOffer.test.ts index 4bb5ba769d..45c987a818 100644 --- a/packages/core/src/modules/credentials/protocol/v2/__tests__/V2CredentialServiceOffer.test.ts +++ b/packages/core/src/modules/credentials/protocol/v2/__tests__/V2CredentialServiceOffer.test.ts @@ -16,6 +16,7 @@ import { ConnectionService } from '../../../../connections/services/ConnectionSe import { IndyLedgerService } from '../../../../ledger/services' import { RoutingService } from '../../../../routing/services/RoutingService' import { CredentialEventTypes } from '../../../CredentialEvents' +import { CredentialsModuleConfig } from '../../../CredentialsModuleConfig' import { credDef, schema } from '../../../__tests__/fixtures' import { IndyCredentialFormatService } from '../../../formats/indy/IndyCredentialFormatService' import { CredentialFormatSpec } from '../../../models' @@ -103,7 +104,8 @@ describe('V2CredentialServiceOffer', () => { eventEmitter, credentialRepository, indyCredentialFormatService, - agentConfig.logger + agentConfig.logger, + new CredentialsModuleConfig() ) }) diff --git a/packages/core/src/modules/credentials/protocol/v2/__tests__/v2-connectionless-credentials.e2e.test.ts b/packages/core/src/modules/credentials/protocol/v2/__tests__/v2-connectionless-credentials.e2e.test.ts index 157948df9e..ced9f4f195 100644 --- a/packages/core/src/modules/credentials/protocol/v2/__tests__/v2-connectionless-credentials.e2e.test.ts +++ b/packages/core/src/modules/credentials/protocol/v2/__tests__/v2-connectionless-credentials.e2e.test.ts @@ -1,6 +1,6 @@ import type { SubjectMessage } from '../../../../../../../../tests/transport/SubjectInboundTransport' import type { CredentialStateChangedEvent } from '../../../CredentialEvents' -import type { AcceptOfferOptions, AcceptRequestOptions, CreateOfferOptions } from '../../../CredentialsModuleOptions' +import type { AcceptOfferOptions, AcceptRequestOptions, CreateOfferOptions } from '../../../CredentialsApiOptions' import { ReplaySubject, Subject } from 'rxjs' diff --git a/packages/core/src/modules/credentials/protocol/v2/__tests__/v2-credentials-auto-accept.e2e.test.ts b/packages/core/src/modules/credentials/protocol/v2/__tests__/v2-credentials-auto-accept.e2e.test.ts index 5036232d4a..6486f445d4 100644 --- a/packages/core/src/modules/credentials/protocol/v2/__tests__/v2-credentials-auto-accept.e2e.test.ts +++ b/packages/core/src/modules/credentials/protocol/v2/__tests__/v2-credentials-auto-accept.e2e.test.ts @@ -1,6 +1,6 @@ import type { Agent } from '../../../../../agent/Agent' import type { ConnectionRecord } from '../../../../connections' -import type { AcceptOfferOptions, AcceptProposalOptions } from '../../../CredentialsModuleOptions' +import type { AcceptOfferOptions, AcceptProposalOptions } from '../../../CredentialsApiOptions' import type { Schema } from 'indy-sdk' import { setupCredentialTests, waitForCredentialRecord } from '../../../../../../tests/helpers' diff --git a/packages/core/src/modules/credentials/protocol/v2/handlers/V2IssueCredentialHandler.ts b/packages/core/src/modules/credentials/protocol/v2/handlers/V2IssueCredentialHandler.ts index 9329fa298a..d2279dd013 100644 --- a/packages/core/src/modules/credentials/protocol/v2/handlers/V2IssueCredentialHandler.ts +++ b/packages/core/src/modules/credentials/protocol/v2/handlers/V2IssueCredentialHandler.ts @@ -43,9 +43,7 @@ export class V2IssueCredentialHandler implements Handler { credentialRecord: CredentialExchangeRecord, messageContext: HandlerInboundMessage ) { - this.logger.info( - `Automatically sending acknowledgement with autoAccept on ${messageContext.agentContext.config.autoAcceptCredentials}` - ) + this.logger.info(`Automatically sending acknowledgement with autoAccept`) const requestMessage = await this.didCommMessageRepository.findAgentMessage(messageContext.agentContext, { associatedRecordId: credentialRecord.id, diff --git a/packages/core/src/modules/credentials/protocol/v2/handlers/V2OfferCredentialHandler.ts b/packages/core/src/modules/credentials/protocol/v2/handlers/V2OfferCredentialHandler.ts index 7d3c3b6419..f656ae8351 100644 --- a/packages/core/src/modules/credentials/protocol/v2/handlers/V2OfferCredentialHandler.ts +++ b/packages/core/src/modules/credentials/protocol/v2/handlers/V2OfferCredentialHandler.ts @@ -49,9 +49,7 @@ export class V2OfferCredentialHandler implements Handler { messageContext: HandlerInboundMessage, offerMessage?: V2OfferCredentialMessage ) { - this.logger.info( - `Automatically sending request with autoAccept on ${messageContext.agentContext.config.autoAcceptCredentials}` - ) + this.logger.info(`Automatically sending request with autoAccept`) if (messageContext.connection) { const { message } = await this.credentialService.acceptOffer(messageContext.agentContext, { diff --git a/packages/core/src/modules/credentials/protocol/v2/handlers/V2ProposeCredentialHandler.ts b/packages/core/src/modules/credentials/protocol/v2/handlers/V2ProposeCredentialHandler.ts index 9c63943302..c005480617 100644 --- a/packages/core/src/modules/credentials/protocol/v2/handlers/V2ProposeCredentialHandler.ts +++ b/packages/core/src/modules/credentials/protocol/v2/handlers/V2ProposeCredentialHandler.ts @@ -35,9 +35,7 @@ export class V2ProposeCredentialHandler implements Handler { credentialRecord: CredentialExchangeRecord, messageContext: HandlerInboundMessage ) { - this.logger.info( - `Automatically sending offer with autoAccept on ${messageContext.agentContext.config.autoAcceptCredentials}` - ) + this.logger.info(`Automatically sending offer with autoAccept`) if (!messageContext.connection) { this.logger.error('No connection on the messageContext, aborting auto accept') diff --git a/packages/core/src/modules/credentials/protocol/v2/handlers/V2RequestCredentialHandler.ts b/packages/core/src/modules/credentials/protocol/v2/handlers/V2RequestCredentialHandler.ts index 6f5145dedd..b8076c9a7b 100644 --- a/packages/core/src/modules/credentials/protocol/v2/handlers/V2RequestCredentialHandler.ts +++ b/packages/core/src/modules/credentials/protocol/v2/handlers/V2RequestCredentialHandler.ts @@ -44,9 +44,7 @@ export class V2RequestCredentialHandler implements Handler { credentialRecord: CredentialExchangeRecord, messageContext: InboundMessageContext ) { - this.logger.info( - `Automatically sending credential with autoAccept on ${messageContext.agentContext.config.autoAcceptCredentials}` - ) + this.logger.info(`Automatically sending credential with autoAccept`) const offerMessage = await this.didCommMessageRepository.findAgentMessage(messageContext.agentContext, { associatedRecordId: credentialRecord.id, diff --git a/packages/core/src/modules/dids/DidsApi.ts b/packages/core/src/modules/dids/DidsApi.ts new file mode 100644 index 0000000000..7599be95cb --- /dev/null +++ b/packages/core/src/modules/dids/DidsApi.ts @@ -0,0 +1,37 @@ +import type { Key } from '../../crypto' +import type { DidResolutionOptions } from './types' + +import { AgentContext } from '../../agent' +import { injectable } from '../../plugins' + +import { DidRepository } from './repository' +import { DidResolverService } from './services/DidResolverService' + +@injectable() +export class DidsApi { + private resolverService: DidResolverService + private didRepository: DidRepository + private agentContext: AgentContext + + public constructor(resolverService: DidResolverService, didRepository: DidRepository, agentContext: AgentContext) { + this.resolverService = resolverService + this.didRepository = didRepository + this.agentContext = agentContext + } + + public resolve(didUrl: string, options?: DidResolutionOptions) { + return this.resolverService.resolve(this.agentContext, didUrl, options) + } + + public resolveDidDocument(didUrl: string) { + return this.resolverService.resolveDidDocument(this.agentContext, didUrl) + } + + public findByRecipientKey(recipientKey: Key) { + return this.didRepository.findByRecipientKey(this.agentContext, recipientKey) + } + + public findAllByRecipientKey(recipientKey: Key) { + return this.didRepository.findAllByRecipientKey(this.agentContext, recipientKey) + } +} diff --git a/packages/core/src/modules/dids/DidsModule.ts b/packages/core/src/modules/dids/DidsModule.ts index d10ff463f1..5cc570ec48 100644 --- a/packages/core/src/modules/dids/DidsModule.ts +++ b/packages/core/src/modules/dids/DidsModule.ts @@ -1,48 +1,16 @@ -import type { Key } from '../../crypto' -import type { DependencyManager } from '../../plugins' -import type { DidResolutionOptions } from './types' - -import { AgentContext } from '../../agent' -import { injectable, module } from '../../plugins' +import type { DependencyManager, Module } from '../../plugins' +import { DidsApi } from './DidsApi' import { DidRepository } from './repository' -import { DidResolverService } from './services/DidResolverService' - -@module() -@injectable() -export class DidsModule { - private resolverService: DidResolverService - private didRepository: DidRepository - private agentContext: AgentContext - - public constructor(resolverService: DidResolverService, didRepository: DidRepository, agentContext: AgentContext) { - this.resolverService = resolverService - this.didRepository = didRepository - this.agentContext = agentContext - } - - public resolve(didUrl: string, options?: DidResolutionOptions) { - return this.resolverService.resolve(this.agentContext, didUrl, options) - } - - public resolveDidDocument(didUrl: string) { - return this.resolverService.resolveDidDocument(this.agentContext, didUrl) - } - - public findByRecipientKey(recipientKey: Key) { - return this.didRepository.findByRecipientKey(this.agentContext, recipientKey) - } - - public findAllByRecipientKey(recipientKey: Key) { - return this.didRepository.findAllByRecipientKey(this.agentContext, recipientKey) - } +import { DidResolverService } from './services' +export class DidsModule implements Module { /** * Registers the dependencies of the dids module module on the dependency manager. */ - public static register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager) { // Api - dependencyManager.registerContextScoped(DidsModule) + dependencyManager.registerContextScoped(DidsApi) // Services dependencyManager.registerSingleton(DidResolverService) diff --git a/packages/core/src/modules/dids/__tests__/DidsModule.test.ts b/packages/core/src/modules/dids/__tests__/DidsModule.test.ts new file mode 100644 index 0000000000..00926a9ace --- /dev/null +++ b/packages/core/src/modules/dids/__tests__/DidsModule.test.ts @@ -0,0 +1,23 @@ +import { DependencyManager } from '../../../plugins/DependencyManager' +import { DidsApi } from '../DidsApi' +import { DidsModule } from '../DidsModule' +import { DidRepository } from '../repository' +import { DidResolverService } from '../services' + +jest.mock('../../../plugins/DependencyManager') +const DependencyManagerMock = DependencyManager as jest.Mock + +const dependencyManager = new DependencyManagerMock() + +describe('DidsModule', () => { + test('registers dependencies on the dependency manager', () => { + new DidsModule().register(dependencyManager) + + expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) + expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(DidsApi) + + expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(2) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(DidResolverService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(DidRepository) + }) +}) diff --git a/packages/core/src/modules/dids/index.ts b/packages/core/src/modules/dids/index.ts index b34160b8de..d9473ea73f 100644 --- a/packages/core/src/modules/dids/index.ts +++ b/packages/core/src/modules/dids/index.ts @@ -1,6 +1,7 @@ export * from './types' export * from './domain' -export * from './DidsModule' +export * from './DidsApi' export * from './repository' export * from './services' +export * from './DidsModule' export { DidKey } from './methods/key/DidKey' diff --git a/packages/core/src/modules/discover-features/DiscoverFeaturesApi.ts b/packages/core/src/modules/discover-features/DiscoverFeaturesApi.ts new file mode 100644 index 0000000000..5ab8193324 --- /dev/null +++ b/packages/core/src/modules/discover-features/DiscoverFeaturesApi.ts @@ -0,0 +1,101 @@ +import type { AgentMessageProcessedEvent } from '../../agent/Events' +import type { ParsedMessageType } from '../../utils/messageType' + +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 { 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' + +@injectable() +export class DiscoverFeaturesApi { + private connectionService: ConnectionService + private messageSender: MessageSender + private discoverFeaturesService: DiscoverFeaturesService + private eventEmitter: EventEmitter + private stop$: Subject + private agentContext: AgentContext + + public constructor( + dispatcher: Dispatcher, + connectionService: ConnectionService, + messageSender: MessageSender, + discoverFeaturesService: DiscoverFeaturesService, + eventEmitter: EventEmitter, + @inject(InjectionSymbols.Stop$) stop$: Subject, + agentContext: AgentContext + ) { + this.connectionService = connectionService + this.messageSender = messageSender + this.discoverFeaturesService = discoverFeaturesService + this.registerHandlers(dispatcher) + this.eventEmitter = eventEmitter + this.stop$ = stop$ + this.agentContext = agentContext + } + + 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', + }) + + const isProtocolSupported = await firstValueFrom(replaySubject) + return isProtocolSupported + } + + public async queryFeatures(connectionId: string, options: { query: string; comment?: string }) { + const connection = await this.connectionService.getById(this.agentContext, connectionId) + + const queryMessage = await this.discoverFeaturesService.createQuery(options) + + const outbound = createOutboundMessage(connection, queryMessage) + await this.messageSender.sendMessage(this.agentContext, outbound) + } + + private registerHandlers(dispatcher: Dispatcher) { + dispatcher.registerHandler(new DiscloseMessageHandler()) + dispatcher.registerHandler(new QueryMessageHandler(this.discoverFeaturesService)) + } +} diff --git a/packages/core/src/modules/discover-features/DiscoverFeaturesModule.ts b/packages/core/src/modules/discover-features/DiscoverFeaturesModule.ts index 76b288e846..8490fc88b7 100644 --- a/packages/core/src/modules/discover-features/DiscoverFeaturesModule.ts +++ b/packages/core/src/modules/discover-features/DiscoverFeaturesModule.ts @@ -1,112 +1,15 @@ -import type { AgentMessageProcessedEvent } from '../../agent/Events' -import type { DependencyManager } from '../../plugins' -import type { ParsedMessageType } from '../../utils/messageType' +import type { DependencyManager, Module } from '../../plugins' -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 { inject, injectable, module } from '../../plugins' -import { canHandleMessageType, parseMessageType } from '../../utils/messageType' -import { ConnectionService } from '../connections/services' - -import { DiscloseMessageHandler, QueryMessageHandler } from './handlers' -import { DiscloseMessage } from './messages' +import { DiscoverFeaturesApi } from './DiscoverFeaturesApi' import { DiscoverFeaturesService } from './services' -@module() -@injectable() -export class DiscoverFeaturesModule { - private connectionService: ConnectionService - private messageSender: MessageSender - private discoverFeaturesService: DiscoverFeaturesService - private eventEmitter: EventEmitter - private stop$: Subject - private agentContext: AgentContext - - public constructor( - dispatcher: Dispatcher, - connectionService: ConnectionService, - messageSender: MessageSender, - discoverFeaturesService: DiscoverFeaturesService, - eventEmitter: EventEmitter, - @inject(InjectionSymbols.Stop$) stop$: Subject, - agentContext: AgentContext - ) { - this.connectionService = connectionService - this.messageSender = messageSender - this.discoverFeaturesService = discoverFeaturesService - this.registerHandlers(dispatcher) - this.eventEmitter = eventEmitter - this.stop$ = stop$ - this.agentContext = agentContext - } - - 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', - }) - - const isProtocolSupported = await firstValueFrom(replaySubject) - return isProtocolSupported - } - - public async queryFeatures(connectionId: string, options: { query: string; comment?: string }) { - const connection = await this.connectionService.getById(this.agentContext, connectionId) - - const queryMessage = await this.discoverFeaturesService.createQuery(options) - - const outbound = createOutboundMessage(connection, queryMessage) - await this.messageSender.sendMessage(this.agentContext, outbound) - } - - private registerHandlers(dispatcher: Dispatcher) { - dispatcher.registerHandler(new DiscloseMessageHandler()) - dispatcher.registerHandler(new QueryMessageHandler(this.discoverFeaturesService)) - } - +export class DiscoverFeaturesModule implements Module { /** * Registers the dependencies of the discover features module on the dependency manager. */ - public static register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager) { // Api - dependencyManager.registerContextScoped(DiscoverFeaturesModule) + dependencyManager.registerContextScoped(DiscoverFeaturesApi) // Services dependencyManager.registerSingleton(DiscoverFeaturesService) diff --git a/packages/core/src/modules/discover-features/__tests__/DiscoverFeaturesModule.test.ts b/packages/core/src/modules/discover-features/__tests__/DiscoverFeaturesModule.test.ts new file mode 100644 index 0000000000..c47b85bb36 --- /dev/null +++ b/packages/core/src/modules/discover-features/__tests__/DiscoverFeaturesModule.test.ts @@ -0,0 +1,21 @@ +import { DependencyManager } from '../../../plugins/DependencyManager' +import { DiscoverFeaturesApi } from '../DiscoverFeaturesApi' +import { DiscoverFeaturesModule } from '../DiscoverFeaturesModule' +import { DiscoverFeaturesService } from '../services' + +jest.mock('../../../plugins/DependencyManager') +const DependencyManagerMock = DependencyManager as jest.Mock + +const dependencyManager = new DependencyManagerMock() + +describe('DiscoverFeaturesModule', () => { + test('registers dependencies on the dependency manager', () => { + new DiscoverFeaturesModule().register(dependencyManager) + + expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) + expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(DiscoverFeaturesApi) + + expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(1) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(DiscoverFeaturesService) + }) +}) diff --git a/packages/core/src/modules/discover-features/index.ts b/packages/core/src/modules/discover-features/index.ts index b4bbee6b99..f2ab347e4d 100644 --- a/packages/core/src/modules/discover-features/index.ts +++ b/packages/core/src/modules/discover-features/index.ts @@ -1,4 +1,5 @@ -export * from './DiscoverFeaturesModule' +export * from './DiscoverFeaturesApi' export * from './handlers' export * from './messages' export * from './services' +export * from './DiscoverFeaturesModule' diff --git a/packages/core/src/modules/generic-records/GenericRecordsApi.ts b/packages/core/src/modules/generic-records/GenericRecordsApi.ts new file mode 100644 index 0000000000..feae3a9758 --- /dev/null +++ b/packages/core/src/modules/generic-records/GenericRecordsApi.ts @@ -0,0 +1,89 @@ +import type { GenericRecord, GenericRecordTags, SaveGenericRecordOption } from './repository/GenericRecord' + +import { AgentContext } from '../../agent' +import { InjectionSymbols } from '../../constants' +import { Logger } from '../../logger' +import { inject, injectable } from '../../plugins' + +import { GenericRecordService } from './services/GenericRecordService' + +export type ContentType = { + content: string +} + +@injectable() +export class GenericRecordsApi { + private genericRecordsService: GenericRecordService + private logger: Logger + private agentContext: AgentContext + + public constructor( + genericRecordsService: GenericRecordService, + @inject(InjectionSymbols.Logger) logger: Logger, + agentContext: AgentContext + ) { + this.genericRecordsService = genericRecordsService + this.logger = logger + this.agentContext = agentContext + } + + public async save({ content, tags, id }: SaveGenericRecordOption) { + try { + const record = await this.genericRecordsService.save(this.agentContext, { + id, + content: content, + tags: tags, + }) + return record + } catch (error) { + this.logger.error('Error while saving generic-record', { + error, + content, + errorMessage: error instanceof Error ? error.message : error, + }) + throw error + } + } + + public async delete(record: GenericRecord): Promise { + try { + await this.genericRecordsService.delete(this.agentContext, record) + } catch (error) { + this.logger.error('Error while saving generic-record', { + error, + content: record.content, + errorMessage: error instanceof Error ? error.message : error, + }) + throw error + } + } + + public async deleteById(id: string): Promise { + await this.genericRecordsService.deleteById(this.agentContext, id) + } + + public async update(record: GenericRecord): Promise { + try { + await this.genericRecordsService.update(this.agentContext, record) + } catch (error) { + this.logger.error('Error while update generic-record', { + error, + content: record.content, + errorMessage: error instanceof Error ? error.message : error, + }) + throw error + } + } + + public async findById(id: string) { + return this.genericRecordsService.findById(this.agentContext, id) + } + + public async findAllByQuery(query: Partial): Promise { + return this.genericRecordsService.findAllByQuery(this.agentContext, query) + } + + public async getAll(): Promise { + return this.genericRecordsService.getAll(this.agentContext) + } +} diff --git a/packages/core/src/modules/generic-records/GenericRecordsModule.ts b/packages/core/src/modules/generic-records/GenericRecordsModule.ts index 9ce523adde..0171374197 100644 --- a/packages/core/src/modules/generic-records/GenericRecordsModule.ts +++ b/packages/core/src/modules/generic-records/GenericRecordsModule.ts @@ -1,101 +1,16 @@ -import type { DependencyManager } from '../../plugins' -import type { GenericRecord, GenericRecordTags, SaveGenericRecordOption } from './repository/GenericRecord' - -import { AgentContext } from '../../agent' -import { InjectionSymbols } from '../../constants' -import { Logger } from '../../logger' -import { inject, injectable, module } from '../../plugins' +import type { DependencyManager, Module } from '../../plugins' +import { GenericRecordsApi } from './GenericRecordsApi' import { GenericRecordsRepository } from './repository/GenericRecordsRepository' -import { GenericRecordService } from './service/GenericRecordService' - -export type ContentType = { - content: string -} - -@module() -@injectable() -export class GenericRecordsModule { - private genericRecordsService: GenericRecordService - private logger: Logger - private agentContext: AgentContext - - public constructor( - genericRecordsService: GenericRecordService, - @inject(InjectionSymbols.Logger) logger: Logger, - agentContext: AgentContext - ) { - this.genericRecordsService = genericRecordsService - this.logger = logger - this.agentContext = agentContext - } - - public async save({ content, tags, id }: SaveGenericRecordOption) { - try { - const record = await this.genericRecordsService.save(this.agentContext, { - id, - content, - tags, - }) - return record - } catch (error) { - this.logger.error('Error while saving generic-record', { - error, - content, - errorMessage: error instanceof Error ? error.message : error, - }) - throw error - } - } - - public async delete(record: GenericRecord): Promise { - try { - await this.genericRecordsService.delete(this.agentContext, record) - } catch (error) { - this.logger.error('Error while saving generic-record', { - error, - content: record.content, - errorMessage: error instanceof Error ? error.message : error, - }) - throw error - } - } - - public async deleteById(id: string): Promise { - await this.genericRecordsService.deleteById(this.agentContext, id) - } - - public async update(record: GenericRecord): Promise { - try { - await this.genericRecordsService.update(this.agentContext, record) - } catch (error) { - this.logger.error('Error while update generic-record', { - error, - content: record.content, - errorMessage: error instanceof Error ? error.message : error, - }) - throw error - } - } - - public async findById(id: string) { - return this.genericRecordsService.findById(this.agentContext, id) - } - - public async findAllByQuery(query: Partial): Promise { - return this.genericRecordsService.findAllByQuery(this.agentContext, query) - } - - public async getAll(): Promise { - return this.genericRecordsService.getAll(this.agentContext) - } +import { GenericRecordService } from './services/GenericRecordService' +export class GenericRecordsModule implements Module { /** * Registers the dependencies of the generic records module on the dependency manager. */ - public static register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager) { // Api - dependencyManager.registerContextScoped(GenericRecordsModule) + dependencyManager.registerContextScoped(GenericRecordsApi) // Services dependencyManager.registerSingleton(GenericRecordService) diff --git a/packages/core/src/modules/generic-records/__tests__/GenericRecordsModule.test.ts b/packages/core/src/modules/generic-records/__tests__/GenericRecordsModule.test.ts new file mode 100644 index 0000000000..498c7f6fc2 --- /dev/null +++ b/packages/core/src/modules/generic-records/__tests__/GenericRecordsModule.test.ts @@ -0,0 +1,23 @@ +import { DependencyManager } from '../../../plugins/DependencyManager' +import { GenericRecordsApi } from '../GenericRecordsApi' +import { GenericRecordsModule } from '../GenericRecordsModule' +import { GenericRecordsRepository } from '../repository/GenericRecordsRepository' +import { GenericRecordService } from '../services/GenericRecordService' + +jest.mock('../../../plugins/DependencyManager') +const DependencyManagerMock = DependencyManager as jest.Mock + +const dependencyManager = new DependencyManagerMock() + +describe('GenericRecordsModule', () => { + test('registers dependencies on the dependency manager', () => { + new GenericRecordsModule().register(dependencyManager) + + expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) + expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(GenericRecordsApi) + + expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(2) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(GenericRecordService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(GenericRecordsRepository) + }) +}) diff --git a/packages/core/src/modules/generic-records/index.ts b/packages/core/src/modules/generic-records/index.ts new file mode 100644 index 0000000000..48d68d003f --- /dev/null +++ b/packages/core/src/modules/generic-records/index.ts @@ -0,0 +1,2 @@ +export * from './GenericRecordsApi' +export * from './GenericRecordsModule' diff --git a/packages/core/src/modules/generic-records/service/GenericRecordService.ts b/packages/core/src/modules/generic-records/services/GenericRecordService.ts similarity index 100% rename from packages/core/src/modules/generic-records/service/GenericRecordService.ts rename to packages/core/src/modules/generic-records/services/GenericRecordService.ts diff --git a/packages/core/src/modules/indy/module.ts b/packages/core/src/modules/indy/IndyModule.ts similarity index 74% rename from packages/core/src/modules/indy/module.ts rename to packages/core/src/modules/indy/IndyModule.ts index f18441cac9..563a853874 100644 --- a/packages/core/src/modules/indy/module.ts +++ b/packages/core/src/modules/indy/IndyModule.ts @@ -1,15 +1,12 @@ -import type { DependencyManager } from '../../plugins' - -import { module } from '../../plugins' +import type { DependencyManager, Module } from '../../plugins' import { IndyRevocationService, IndyUtilitiesService } from './services' import { IndyHolderService } from './services/IndyHolderService' import { IndyIssuerService } from './services/IndyIssuerService' import { IndyVerifierService } from './services/IndyVerifierService' -@module() -export class IndyModule { - public static register(dependencyManager: DependencyManager) { +export class IndyModule implements Module { + public register(dependencyManager: DependencyManager) { dependencyManager.registerSingleton(IndyIssuerService) dependencyManager.registerSingleton(IndyHolderService) dependencyManager.registerSingleton(IndyVerifierService) diff --git a/packages/core/src/modules/indy/__tests__/IndyModule.test.ts b/packages/core/src/modules/indy/__tests__/IndyModule.test.ts new file mode 100644 index 0000000000..edad08f2d6 --- /dev/null +++ b/packages/core/src/modules/indy/__tests__/IndyModule.test.ts @@ -0,0 +1,27 @@ +import { DependencyManager } from '../../../plugins/DependencyManager' +import { IndyModule } from '../IndyModule' +import { + IndyHolderService, + IndyIssuerService, + IndyVerifierService, + IndyRevocationService, + IndyUtilitiesService, +} from '../services' + +jest.mock('../../../plugins/DependencyManager') +const DependencyManagerMock = DependencyManager as jest.Mock + +const dependencyManager = new DependencyManagerMock() + +describe('IndyModule', () => { + test('registers dependencies on the dependency manager', () => { + new IndyModule().register(dependencyManager) + + expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(5) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(IndyHolderService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(IndyIssuerService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(IndyRevocationService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(IndyVerifierService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(IndyUtilitiesService) + }) +}) diff --git a/packages/core/src/modules/indy/index.ts b/packages/core/src/modules/indy/index.ts index ef147a3621..5b289c3de8 100644 --- a/packages/core/src/modules/indy/index.ts +++ b/packages/core/src/modules/indy/index.ts @@ -1 +1,2 @@ export * from './services' +export * from './IndyModule' diff --git a/packages/core/src/modules/ledger/LedgerApi.ts b/packages/core/src/modules/ledger/LedgerApi.ts new file mode 100644 index 0000000000..07a535ff47 --- /dev/null +++ b/packages/core/src/modules/ledger/LedgerApi.ts @@ -0,0 +1,166 @@ +import type { IndyPoolConfig } from './IndyPool' +import type { SchemaTemplate, CredentialDefinitionTemplate } from './services' +import type { CredDef, NymRole, Schema } from 'indy-sdk' + +import { AgentContext } from '../../agent' +import { AriesFrameworkError } from '../../error' +import { IndySdkError } from '../../error/IndySdkError' +import { injectable } from '../../plugins' +import { isIndyError } from '../../utils/indyError' +import { AnonCredsCredentialDefinitionRepository } from '../indy/repository/AnonCredsCredentialDefinitionRepository' +import { AnonCredsSchemaRepository } from '../indy/repository/AnonCredsSchemaRepository' + +import { LedgerModuleConfig } from './LedgerModuleConfig' +import { generateCredentialDefinitionId, generateSchemaId } from './ledgerUtil' +import { IndyLedgerService } from './services' + +@injectable() +export class LedgerApi { + public config: LedgerModuleConfig + + private ledgerService: IndyLedgerService + private agentContext: AgentContext + private anonCredsCredentialDefinitionRepository: AnonCredsCredentialDefinitionRepository + private anonCredsSchemaRepository: AnonCredsSchemaRepository + + public constructor( + ledgerService: IndyLedgerService, + agentContext: AgentContext, + anonCredsCredentialDefinitionRepository: AnonCredsCredentialDefinitionRepository, + anonCredsSchemaRepository: AnonCredsSchemaRepository, + config: LedgerModuleConfig + ) { + this.ledgerService = ledgerService + this.agentContext = agentContext + this.anonCredsCredentialDefinitionRepository = anonCredsCredentialDefinitionRepository + this.anonCredsSchemaRepository = anonCredsSchemaRepository + this.config = config + } + + public setPools(poolConfigs: IndyPoolConfig[]) { + return this.ledgerService.setPools(poolConfigs) + } + + /** + * Connect to all the ledger pools + */ + public async connectToPools() { + await this.ledgerService.connectToPools() + } + + public async registerPublicDid(did: string, verkey: string, alias: string, role?: NymRole) { + const myPublicDid = this.agentContext.wallet.publicDid?.did + + if (!myPublicDid) { + throw new AriesFrameworkError('Agent has no public DID.') + } + + return this.ledgerService.registerPublicDid(this.agentContext, myPublicDid, did, verkey, alias, role) + } + + public async getPublicDid(did: string) { + return this.ledgerService.getPublicDid(this.agentContext, did) + } + + public async getSchema(id: string) { + return this.ledgerService.getSchema(this.agentContext, id) + } + + public async registerSchema(schema: SchemaTemplate): Promise { + const did = this.agentContext.wallet.publicDid?.did + + if (!did) { + throw new AriesFrameworkError('Agent has no public DID.') + } + + const schemaId = generateSchemaId(did, schema.name, schema.version) + + // Try find the schema in the wallet + const schemaRecord = await this.anonCredsSchemaRepository.findBySchemaId(this.agentContext, schemaId) + // Schema in wallet + if (schemaRecord) return schemaRecord.schema + + const schemaFromLedger = await this.findBySchemaIdOnLedger(schemaId) + if (schemaFromLedger) return schemaFromLedger + return this.ledgerService.registerSchema(this.agentContext, did, schema) + } + + private async findBySchemaIdOnLedger(schemaId: string) { + try { + return await this.ledgerService.getSchema(this.agentContext, schemaId) + } catch (e) { + if (e instanceof IndySdkError && isIndyError(e.cause, 'LedgerNotFound')) return null + + throw e + } + } + + private async findByCredentialDefinitionIdOnLedger(credentialDefinitionId: string): Promise { + try { + return await this.ledgerService.getCredentialDefinition(this.agentContext, credentialDefinitionId) + } catch (e) { + if (e instanceof IndySdkError && isIndyError(e.cause, 'LedgerNotFound')) return null + + throw e + } + } + + public async registerCredentialDefinition( + credentialDefinitionTemplate: Omit + ) { + const did = this.agentContext.wallet.publicDid?.did + + if (!did) { + throw new AriesFrameworkError('Agent has no public DID.') + } + + // Construct credential definition ID + const credentialDefinitionId = generateCredentialDefinitionId( + did, + credentialDefinitionTemplate.schema.seqNo, + credentialDefinitionTemplate.tag + ) + + // Check if the credential exists in wallet. If so, return it + const credentialDefinitionRecord = await this.anonCredsCredentialDefinitionRepository.findByCredentialDefinitionId( + this.agentContext, + credentialDefinitionId + ) + if (credentialDefinitionRecord) return credentialDefinitionRecord.credentialDefinition + + // Check for the credential on the ledger. + const credentialDefinitionOnLedger = await this.findByCredentialDefinitionIdOnLedger(credentialDefinitionId) + if (credentialDefinitionOnLedger) { + throw new AriesFrameworkError( + `No credential definition record found and credential definition ${credentialDefinitionId} already exists on the ledger.` + ) + } + + // Register the credential + return await this.ledgerService.registerCredentialDefinition(this.agentContext, did, { + ...credentialDefinitionTemplate, + signatureType: 'CL', + }) + } + + public async getCredentialDefinition(id: string) { + return this.ledgerService.getCredentialDefinition(this.agentContext, id) + } + + public async getRevocationRegistryDefinition(revocationRegistryDefinitionId: string) { + return this.ledgerService.getRevocationRegistryDefinition(this.agentContext, revocationRegistryDefinitionId) + } + + public async getRevocationRegistryDelta( + revocationRegistryDefinitionId: string, + fromSeconds = 0, + toSeconds = new Date().getTime() + ) { + return this.ledgerService.getRevocationRegistryDelta( + this.agentContext, + revocationRegistryDefinitionId, + fromSeconds, + toSeconds + ) + } +} diff --git a/packages/core/src/modules/ledger/LedgerModule.ts b/packages/core/src/modules/ledger/LedgerModule.ts index b6fb73e8ca..eb501dac91 100644 --- a/packages/core/src/modules/ledger/LedgerModule.ts +++ b/packages/core/src/modules/ledger/LedgerModule.ts @@ -1,175 +1,36 @@ -import type { DependencyManager } from '../../plugins' -import type { IndyPoolConfig } from './IndyPool' -import type { SchemaTemplate, CredentialDefinitionTemplate } from './services' -import type { CredDef, NymRole, Schema } from 'indy-sdk' +import type { DependencyManager, Module } from '../../plugins' +import type { LedgerModuleConfigOptions } from './LedgerModuleConfig' -import { AgentContext } from '../../agent' -import { AriesFrameworkError } from '../../error' -import { IndySdkError } from '../../error/IndySdkError' -import { injectable, module } from '../../plugins' -import { isIndyError } from '../../utils/indyError' import { AnonCredsCredentialDefinitionRepository } from '../indy/repository/AnonCredsCredentialDefinitionRepository' import { AnonCredsSchemaRepository } from '../indy/repository/AnonCredsSchemaRepository' -import { generateCredentialDefinitionId, generateSchemaId } from './ledgerUtil' -import { IndyPoolService, IndyLedgerService } from './services' +import { LedgerApi } from './LedgerApi' +import { LedgerModuleConfig } from './LedgerModuleConfig' +import { IndyLedgerService, IndyPoolService } from './services' -@module() -@injectable() -export class LedgerModule { - private ledgerService: IndyLedgerService - private agentContext: AgentContext - private anonCredsCredentialDefinitionRepository: AnonCredsCredentialDefinitionRepository - private anonCredsSchemaRepository: AnonCredsSchemaRepository +export class LedgerModule implements Module { + public readonly config: LedgerModuleConfig - public constructor( - ledgerService: IndyLedgerService, - agentContext: AgentContext, - anonCredsCredentialDefinitionRepository: AnonCredsCredentialDefinitionRepository, - anonCredsSchemaRepository: AnonCredsSchemaRepository - ) { - this.ledgerService = ledgerService - this.agentContext = agentContext - this.anonCredsCredentialDefinitionRepository = anonCredsCredentialDefinitionRepository - this.anonCredsSchemaRepository = anonCredsSchemaRepository - } - - public setPools(poolConfigs: IndyPoolConfig[]) { - return this.ledgerService.setPools(poolConfigs) - } - - /** - * Connect to all the ledger pools - */ - public async connectToPools() { - await this.ledgerService.connectToPools() - } - - public async registerPublicDid(did: string, verkey: string, alias: string, role?: NymRole) { - const myPublicDid = this.agentContext.wallet.publicDid?.did - - if (!myPublicDid) { - throw new AriesFrameworkError('Agent has no public DID.') - } - - return this.ledgerService.registerPublicDid(this.agentContext, myPublicDid, did, verkey, alias, role) - } - - public async getPublicDid(did: string) { - return this.ledgerService.getPublicDid(this.agentContext, did) - } - - public async getSchema(id: string) { - return this.ledgerService.getSchema(this.agentContext, id) - } - - public async registerSchema(schema: SchemaTemplate): Promise { - const did = this.agentContext.wallet.publicDid?.did - - if (!did) { - throw new AriesFrameworkError('Agent has no public DID.') - } - - const schemaId = generateSchemaId(did, schema.name, schema.version) - - // Try find the schema in the wallet - const schemaRecord = await this.anonCredsSchemaRepository.findBySchemaId(this.agentContext, schemaId) - // Schema in wallet - if (schemaRecord) return schemaRecord.schema - - const schemaFromLedger = await this.findBySchemaIdOnLedger(schemaId) - if (schemaFromLedger) return schemaFromLedger - return this.ledgerService.registerSchema(this.agentContext, did, schema) - } - - private async findBySchemaIdOnLedger(schemaId: string) { - try { - return await this.ledgerService.getSchema(this.agentContext, schemaId) - } catch (e) { - if (e instanceof IndySdkError && isIndyError(e.cause, 'LedgerNotFound')) return null - - throw e - } - } - - private async findByCredentialDefinitionIdOnLedger(credentialDefinitionId: string): Promise { - try { - return await this.ledgerService.getCredentialDefinition(this.agentContext, credentialDefinitionId) - } catch (e) { - if (e instanceof IndySdkError && isIndyError(e.cause, 'LedgerNotFound')) return null - - throw e - } - } - - public async registerCredentialDefinition( - credentialDefinitionTemplate: Omit - ) { - const did = this.agentContext.wallet.publicDid?.did - - if (!did) { - throw new AriesFrameworkError('Agent has no public DID.') - } - - // Construct credential definition ID - const credentialDefinitionId = generateCredentialDefinitionId( - did, - credentialDefinitionTemplate.schema.seqNo, - credentialDefinitionTemplate.tag - ) - - // Check if the credential exists in wallet. If so, return it - const credentialDefinitionRecord = await this.anonCredsCredentialDefinitionRepository.findByCredentialDefinitionId( - this.agentContext, - credentialDefinitionId - ) - if (credentialDefinitionRecord) return credentialDefinitionRecord.credentialDefinition - - // Check for the credential on the ledger. - const credentialDefinitionOnLedger = await this.findByCredentialDefinitionIdOnLedger(credentialDefinitionId) - if (credentialDefinitionOnLedger) { - throw new AriesFrameworkError( - `No credential definition record found and credential definition ${credentialDefinitionId} already exists on the ledger.` - ) - } - - // Register the credential - return await this.ledgerService.registerCredentialDefinition(this.agentContext, did, { - ...credentialDefinitionTemplate, - signatureType: 'CL', - }) - } - - public async getCredentialDefinition(id: string) { - return this.ledgerService.getCredentialDefinition(this.agentContext, id) - } - - public async getRevocationRegistryDefinition(revocationRegistryDefinitionId: string) { - return this.ledgerService.getRevocationRegistryDefinition(this.agentContext, revocationRegistryDefinitionId) - } - - public async getRevocationRegistryDelta( - revocationRegistryDefinitionId: string, - fromSeconds = 0, - toSeconds = new Date().getTime() - ) { - return this.ledgerService.getRevocationRegistryDelta( - this.agentContext, - revocationRegistryDefinitionId, - fromSeconds, - toSeconds - ) + public constructor(config?: LedgerModuleConfigOptions) { + this.config = new LedgerModuleConfig(config) } /** * Registers the dependencies of the ledger module on the dependency manager. */ - public static register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager) { // Api - dependencyManager.registerContextScoped(LedgerModule) + dependencyManager.registerContextScoped(LedgerApi) + + // Config + dependencyManager.registerInstance(LedgerModuleConfig, this.config) // Services dependencyManager.registerSingleton(IndyLedgerService) dependencyManager.registerSingleton(IndyPoolService) + + // Repositories + dependencyManager.registerSingleton(AnonCredsCredentialDefinitionRepository) + dependencyManager.registerSingleton(AnonCredsSchemaRepository) } } diff --git a/packages/core/src/modules/ledger/LedgerModuleConfig.ts b/packages/core/src/modules/ledger/LedgerModuleConfig.ts new file mode 100644 index 0000000000..12c9d99fc0 --- /dev/null +++ b/packages/core/src/modules/ledger/LedgerModuleConfig.ts @@ -0,0 +1,43 @@ +import type { IndyPoolConfig } from './IndyPool' + +/** + * LedgerModuleConfigOptions defines the interface for the options of the RecipientModuleConfig class. + * This can contain optional parameters that have default values in the config class itself. + */ +export interface LedgerModuleConfigOptions { + /** + * Whether to automatically connect to all {@link LedgerModuleConfigOptions.indyLedgers} on startup. + * This will be done asynchronously, so the initialization of the agent won't be impacted. However, + * this does mean there may be unneeded connections to the ledger. + * + * @default true + */ + connectToIndyLedgersOnStartup?: boolean + + /** + * Array of configurations of indy ledgers to connect to. Each item in the list must include either the `genesisPath` or `genesisTransactions` property. + * + * The first ledger in the list will be used for writing transactions to the ledger. + * + * @default [] + */ + indyLedgers?: IndyPoolConfig[] +} + +export class LedgerModuleConfig { + private options: LedgerModuleConfigOptions + + public constructor(options?: LedgerModuleConfigOptions) { + this.options = options ?? {} + } + + /** See {@link LedgerModuleConfigOptions.connectToIndyLedgersOnStartup} */ + public get connectToIndyLedgersOnStartup() { + return this.options.connectToIndyLedgersOnStartup ?? true + } + + /** See {@link LedgerModuleConfigOptions.indyLedgers} */ + public get indyLedgers() { + return this.options.indyLedgers ?? [] + } +} diff --git a/packages/core/src/modules/ledger/__tests__/LedgerApi.test.ts b/packages/core/src/modules/ledger/__tests__/LedgerApi.test.ts new file mode 100644 index 0000000000..1df7a5c120 --- /dev/null +++ b/packages/core/src/modules/ledger/__tests__/LedgerApi.test.ts @@ -0,0 +1,368 @@ +import type { AgentContext } from '../../../agent/context/AgentContext' +import type { IndyPoolConfig } from '../IndyPool' +import type { CredentialDefinitionTemplate } from '../services/IndyLedgerService' +import type * as Indy from 'indy-sdk' + +import { getAgentConfig, getAgentContext, mockFunction, mockProperty } from '../../../../tests/helpers' +import { SigningProviderRegistry } from '../../../crypto/signing-provider' +import { AriesFrameworkError } from '../../../error/AriesFrameworkError' +import { IndyWallet } from '../../../wallet/IndyWallet' +import { AnonCredsCredentialDefinitionRecord } from '../../indy/repository/AnonCredsCredentialDefinitionRecord' +import { AnonCredsCredentialDefinitionRepository } from '../../indy/repository/AnonCredsCredentialDefinitionRepository' +import { AnonCredsSchemaRecord } from '../../indy/repository/AnonCredsSchemaRecord' +import { AnonCredsSchemaRepository } from '../../indy/repository/AnonCredsSchemaRepository' +import { LedgerApi } from '../LedgerApi' +import { LedgerModuleConfig } from '../LedgerModuleConfig' +import { generateCredentialDefinitionId, generateSchemaId } from '../ledgerUtil' +import { IndyLedgerService } from '../services/IndyLedgerService' + +jest.mock('../services/IndyLedgerService') +const IndyLedgerServiceMock = IndyLedgerService as jest.Mock + +jest.mock('../../indy/repository/AnonCredsCredentialDefinitionRepository') +const AnonCredsCredentialDefinitionRepositoryMock = + AnonCredsCredentialDefinitionRepository as jest.Mock +jest.mock('../../indy/repository/AnonCredsSchemaRepository') +const AnonCredsSchemaRepositoryMock = AnonCredsSchemaRepository as jest.Mock + +const did = 'Y5bj4SjCiTM9PgeheKAiXx' + +const schemaId = 'abcd' + +const schema: Indy.Schema = { + id: schemaId, + attrNames: ['hello', 'world'], + name: 'awesomeSchema', + version: '1', + ver: '1', + seqNo: 99, +} + +const credentialDefinition = { + schema: 'abcde', + tag: 'someTag', + signatureType: 'CL', + supportRevocation: true, +} + +const credDef: Indy.CredDef = { + id: 'abcde', + schemaId: schema.id, + type: 'CL', + tag: 'someTag', + value: { + primary: credentialDefinition as Record, + revocation: true, + }, + ver: '1', +} + +const credentialDefinitionTemplate: Omit = { + schema: schema, + tag: 'someTag', + supportRevocation: true, +} + +const revocRegDef: Indy.RevocRegDef = { + id: 'abcde', + revocDefType: 'CL_ACCUM', + tag: 'someTag', + credDefId: 'abcde', + value: { + issuanceType: 'ISSUANCE_BY_DEFAULT', + maxCredNum: 3, + tailsHash: 'abcde', + tailsLocation: 'xyz', + publicKeys: ['abcde', 'fghijk'], + }, + ver: 'abcde', +} + +const schemaIdGenerated = generateSchemaId(did, schema.name, schema.version) + +const credentialDefinitionId = generateCredentialDefinitionId( + did, + credentialDefinitionTemplate.schema.seqNo, + credentialDefinitionTemplate.tag +) + +const pools: IndyPoolConfig[] = [ + { + id: 'sovrinMain', + isProduction: true, + genesisTransactions: 'xxx', + transactionAuthorAgreement: { version: '1', acceptanceMechanism: 'accept' }, + }, +] + +describe('LedgerApi', () => { + let wallet: IndyWallet + let ledgerService: IndyLedgerService + let anonCredsCredentialDefinitionRepository: AnonCredsCredentialDefinitionRepository + let anonCredsSchemaRepository: AnonCredsSchemaRepository + let ledgerApi: LedgerApi + let agentContext: AgentContext + + const contextCorrelationId = 'mock' + const agentConfig = getAgentConfig('LedgerApiTest', { + indyLedgers: pools, + }) + + beforeEach(async () => { + wallet = new IndyWallet(agentConfig.agentDependencies, agentConfig.logger, new SigningProviderRegistry([])) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await wallet.createAndOpen(agentConfig.walletConfig!) + }) + + afterEach(async () => { + await wallet.delete() + }) + + beforeEach(async () => { + ledgerService = new IndyLedgerServiceMock() + + agentContext = getAgentContext({ + wallet, + agentConfig, + contextCorrelationId, + }) + + anonCredsCredentialDefinitionRepository = new AnonCredsCredentialDefinitionRepositoryMock() + anonCredsSchemaRepository = new AnonCredsSchemaRepositoryMock() + + ledgerApi = new LedgerApi( + ledgerService, + agentContext, + anonCredsCredentialDefinitionRepository, + anonCredsSchemaRepository, + new LedgerModuleConfig() + ) + }) + + describe('LedgerApi', () => { + // Connect to pools + describe('connectToPools', () => { + it('should connect to all pools', async () => { + mockFunction(ledgerService.connectToPools).mockResolvedValue([1, 2, 4]) + await expect(ledgerApi.connectToPools()).resolves.toBeUndefined() + expect(ledgerService.connectToPools).toHaveBeenCalled() + }) + }) + + // Register public did + describe('registerPublicDid', () => { + it('should register a public DID', async () => { + mockFunction(ledgerService.registerPublicDid).mockResolvedValueOnce(did) + mockProperty(wallet, 'publicDid', { did: did, verkey: 'abcde' }) + await expect(ledgerApi.registerPublicDid(did, 'abcde', 'someAlias')).resolves.toEqual(did) + expect(ledgerService.registerPublicDid).toHaveBeenCalledWith( + agentContext, + did, + did, + 'abcde', + 'someAlias', + undefined + ) + }) + + it('should throw an error if the DID cannot be registered because there is no public did', async () => { + const did = 'Y5bj4SjCiTM9PgeheKAiXx' + mockProperty(wallet, 'publicDid', undefined) + await expect(ledgerApi.registerPublicDid(did, 'abcde', 'someAlias')).rejects.toThrowError(AriesFrameworkError) + }) + }) + + // Get public DID + describe('getPublicDid', () => { + it('should return the public DID if there is one', async () => { + const nymResponse: Indy.GetNymResponse = { did: 'Y5bj4SjCiTM9PgeheKAiXx', verkey: 'abcde', role: 'STEWARD' } + mockProperty(wallet, 'publicDid', { did: nymResponse.did, verkey: nymResponse.verkey }) + mockFunction(ledgerService.getPublicDid).mockResolvedValueOnce(nymResponse) + await expect(ledgerApi.getPublicDid(nymResponse.did)).resolves.toEqual(nymResponse) + expect(ledgerService.getPublicDid).toHaveBeenCalledWith(agentContext, nymResponse.did) + }) + }) + + // Get schema + describe('getSchema', () => { + it('should return the schema by id if there is one', async () => { + mockFunction(ledgerService.getSchema).mockResolvedValueOnce(schema) + await expect(ledgerApi.getSchema(schemaId)).resolves.toEqual(schema) + expect(ledgerService.getSchema).toHaveBeenCalledWith(agentContext, schemaId) + }) + + it('should throw an error if no schema for the id exists', async () => { + mockFunction(ledgerService.getSchema).mockRejectedValueOnce( + new AriesFrameworkError('Error retrieving schema abcd from ledger 1') + ) + await expect(ledgerApi.getSchema(schemaId)).rejects.toThrowError(AriesFrameworkError) + expect(ledgerService.getSchema).toHaveBeenCalledWith(agentContext, schemaId) + }) + }) + + describe('registerSchema', () => { + it('should throw an error if there is no public DID', async () => { + mockProperty(wallet, 'publicDid', undefined) + await expect(ledgerApi.registerSchema({ ...schema, attributes: ['hello', 'world'] })).rejects.toThrowError( + AriesFrameworkError + ) + }) + + it('should return the schema from anonCreds when it already exists', async () => { + mockProperty(wallet, 'publicDid', { did: did, verkey: 'abcde' }) + mockFunction(anonCredsSchemaRepository.findBySchemaId).mockResolvedValueOnce( + new AnonCredsSchemaRecord({ schema: schema }) + ) + await expect(ledgerApi.registerSchema({ ...schema, attributes: ['hello', 'world'] })).resolves.toEqual(schema) + expect(anonCredsSchemaRepository.findBySchemaId).toHaveBeenCalledWith(agentContext, schemaIdGenerated) + }) + + it('should return the schema from the ledger when it already exists', async () => { + mockProperty(wallet, 'publicDid', { did: did, verkey: 'abcde' }) + jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(LedgerApi.prototype as any, 'findBySchemaIdOnLedger') + .mockResolvedValueOnce(new AnonCredsSchemaRecord({ schema: schema })) + await expect(ledgerApi.registerSchema({ ...schema, attributes: ['hello', 'world'] })).resolves.toHaveProperty( + 'schema', + schema + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(jest.spyOn(LedgerApi.prototype as any, 'findBySchemaIdOnLedger')).toHaveBeenCalledWith(schemaIdGenerated) + }) + + it('should return the schema after registering it', async () => { + mockProperty(wallet, 'publicDid', { did: did, verkey: 'abcde' }) + mockFunction(ledgerService.registerSchema).mockResolvedValueOnce(schema) + await expect(ledgerApi.registerSchema({ ...schema, attributes: ['hello', 'world'] })).resolves.toEqual(schema) + expect(ledgerService.registerSchema).toHaveBeenCalledWith(agentContext, did, { + ...schema, + attributes: ['hello', 'world'], + }) + }) + }) + + describe('registerCredentialDefinition', () => { + it('should throw an error if there si no public DID', async () => { + mockProperty(wallet, 'publicDid', undefined) + await expect(ledgerApi.registerCredentialDefinition(credentialDefinitionTemplate)).rejects.toThrowError( + AriesFrameworkError + ) + }) + + it('should return the credential definition from the wallet if it already exists', async () => { + mockProperty(wallet, 'publicDid', { did: did, verkey: 'abcde' }) + const anonCredsCredentialDefinitionRecord: AnonCredsCredentialDefinitionRecord = + new AnonCredsCredentialDefinitionRecord({ + credentialDefinition: credDef, + }) + mockFunction(anonCredsCredentialDefinitionRepository.findByCredentialDefinitionId).mockResolvedValueOnce( + anonCredsCredentialDefinitionRecord + ) + await expect(ledgerApi.registerCredentialDefinition(credentialDefinitionTemplate)).resolves.toHaveProperty( + 'value.primary', + credentialDefinition + ) + expect(anonCredsCredentialDefinitionRepository.findByCredentialDefinitionId).toHaveBeenCalledWith( + agentContext, + credentialDefinitionId + ) + }) + + it('should throw an exception if the definition already exists on the ledger', async () => { + mockProperty(wallet, 'publicDid', { did: did, verkey: 'abcde' }) + jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(LedgerApi.prototype as any, 'findByCredentialDefinitionIdOnLedger') + .mockResolvedValueOnce({ credentialDefinition: credentialDefinition }) + await expect(ledgerApi.registerCredentialDefinition(credentialDefinitionTemplate)).rejects.toThrowError( + AriesFrameworkError + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(jest.spyOn(LedgerApi.prototype as any, 'findByCredentialDefinitionIdOnLedger')).toHaveBeenCalledWith( + credentialDefinitionId + ) + }) + + it('should register the credential successfully if it is neither in the wallet and neither on the ledger', async () => { + mockProperty(wallet, 'publicDid', { did: did, verkey: 'abcde' }) + mockFunction(ledgerService.registerCredentialDefinition).mockResolvedValueOnce(credDef) + await expect(ledgerApi.registerCredentialDefinition(credentialDefinitionTemplate)).resolves.toEqual(credDef) + expect(ledgerService.registerCredentialDefinition).toHaveBeenCalledWith(agentContext, did, { + ...credentialDefinitionTemplate, + signatureType: 'CL', + }) + }) + }) + + describe('getCredentialDefinition', () => { + it('should return the credential definition given the id', async () => { + mockProperty(wallet, 'publicDid', { did: did, verkey: 'abcde' }) + mockFunction(ledgerService.getCredentialDefinition).mockResolvedValue(credDef) + await expect(ledgerApi.getCredentialDefinition(credDef.id)).resolves.toEqual(credDef) + expect(ledgerService.getCredentialDefinition).toHaveBeenCalledWith(agentContext, credDef.id) + }) + + it('should throw an error if there is no credential definition for the given id', async () => { + mockProperty(wallet, 'publicDid', { did: did, verkey: 'abcde' }) + mockFunction(ledgerService.getCredentialDefinition).mockRejectedValueOnce(new AriesFrameworkError('')) + await expect(ledgerApi.getCredentialDefinition(credDef.id)).rejects.toThrowError(AriesFrameworkError) + expect(ledgerService.getCredentialDefinition).toHaveBeenCalledWith(agentContext, credDef.id) + }) + }) + + describe('getRevocationRegistryDefinition', () => { + it('should return the ParseRevocationRegistryDefinitionTemplate for a valid revocationRegistryDefinitionId', async () => { + const parseRevocationRegistryDefinitionTemplate = { + revocationRegistryDefinition: revocRegDef, + revocationRegistryDefinitionTxnTime: 12345678, + } + mockFunction(ledgerService.getRevocationRegistryDefinition).mockResolvedValue( + parseRevocationRegistryDefinitionTemplate + ) + await expect(ledgerApi.getRevocationRegistryDefinition(revocRegDef.id)).resolves.toBe( + parseRevocationRegistryDefinitionTemplate + ) + expect(ledgerService.getRevocationRegistryDefinition).toHaveBeenLastCalledWith(agentContext, revocRegDef.id) + }) + + it('should throw an error if the ParseRevocationRegistryDefinitionTemplate does not exists', async () => { + mockFunction(ledgerService.getRevocationRegistryDefinition).mockRejectedValueOnce(new AriesFrameworkError('')) + await expect(ledgerApi.getRevocationRegistryDefinition('abcde')).rejects.toThrowError(AriesFrameworkError) + expect(ledgerService.getRevocationRegistryDefinition).toHaveBeenCalledWith(agentContext, revocRegDef.id) + }) + }) + + describe('getRevocationRegistryDelta', () => { + it('should return the ParseRevocationRegistryDeltaTemplate', async () => { + const revocRegDelta = { + value: { + prevAccum: 'prev', + accum: 'accum', + issued: [1, 2, 3], + revoked: [4, 5, 6], + }, + ver: 'ver', + } + const parseRevocationRegistryDeltaTemplate = { + revocationRegistryDelta: revocRegDelta, + deltaTimestamp: 12345678, + } + + mockFunction(ledgerService.getRevocationRegistryDelta).mockResolvedValueOnce( + parseRevocationRegistryDeltaTemplate + ) + await expect(ledgerApi.getRevocationRegistryDelta('12345')).resolves.toEqual( + parseRevocationRegistryDeltaTemplate + ) + expect(ledgerService.getRevocationRegistryDelta).toHaveBeenCalledTimes(1) + }) + + it('should throw an error if the delta cannot be obtained', async () => { + mockFunction(ledgerService.getRevocationRegistryDelta).mockRejectedValueOnce(new AriesFrameworkError('')) + await expect(ledgerApi.getRevocationRegistryDelta('abcde1234')).rejects.toThrowError(AriesFrameworkError) + expect(ledgerService.getRevocationRegistryDelta).toHaveBeenCalledTimes(1) + }) + }) + }) +}) diff --git a/packages/core/src/modules/ledger/__tests__/LedgerModule.test.ts b/packages/core/src/modules/ledger/__tests__/LedgerModule.test.ts index 9be7b6167a..b258bd5416 100644 --- a/packages/core/src/modules/ledger/__tests__/LedgerModule.test.ts +++ b/packages/core/src/modules/ledger/__tests__/LedgerModule.test.ts @@ -1,373 +1,26 @@ -import type { AgentContext } from '../../../agent/context/AgentContext' -import type { IndyPoolConfig } from '../IndyPool' -import type { CredentialDefinitionTemplate } from '../services/IndyLedgerService' -import type * as Indy from 'indy-sdk' - -import { getAgentConfig, getAgentContext, mockFunction, mockProperty } from '../../../../tests/helpers' -import { SigningProviderRegistry } from '../../../crypto/signing-provider' -import { AriesFrameworkError } from '../../../error/AriesFrameworkError' -import { IndyWallet } from '../../../wallet/IndyWallet' -import { AnonCredsCredentialDefinitionRecord } from '../../indy/repository/AnonCredsCredentialDefinitionRecord' +import { DependencyManager } from '../../../plugins/DependencyManager' import { AnonCredsCredentialDefinitionRepository } from '../../indy/repository/AnonCredsCredentialDefinitionRepository' -import { AnonCredsSchemaRecord } from '../../indy/repository/AnonCredsSchemaRecord' import { AnonCredsSchemaRepository } from '../../indy/repository/AnonCredsSchemaRepository' +import { LedgerApi } from '../LedgerApi' import { LedgerModule } from '../LedgerModule' -import { generateCredentialDefinitionId, generateSchemaId } from '../ledgerUtil' -import { IndyLedgerService } from '../services/IndyLedgerService' - -jest.mock('../services/IndyLedgerService') -const IndyLedgerServiceMock = IndyLedgerService as jest.Mock - -jest.mock('../../indy/repository/AnonCredsCredentialDefinitionRepository') -const AnonCredsCredentialDefinitionRepositoryMock = - AnonCredsCredentialDefinitionRepository as jest.Mock -jest.mock('../../indy/repository/AnonCredsSchemaRepository') -const AnonCredsSchemaRepositoryMock = AnonCredsSchemaRepository as jest.Mock - -const did = 'Y5bj4SjCiTM9PgeheKAiXx' - -const schemaId = 'abcd' - -const schema: Indy.Schema = { - id: schemaId, - attrNames: ['hello', 'world'], - name: 'awesomeSchema', - version: '1', - ver: '1', - seqNo: 99, -} - -const credentialDefinition = { - schema: 'abcde', - tag: 'someTag', - signatureType: 'CL', - supportRevocation: true, -} - -const credDef: Indy.CredDef = { - id: 'abcde', - schemaId: schema.id, - type: 'CL', - tag: 'someTag', - value: { - primary: credentialDefinition as Record, - revocation: true, - }, - ver: '1', -} - -const credentialDefinitionTemplate: Omit = { - schema: schema, - tag: 'someTag', - supportRevocation: true, -} - -const revocRegDef: Indy.RevocRegDef = { - id: 'abcde', - revocDefType: 'CL_ACCUM', - tag: 'someTag', - credDefId: 'abcde', - value: { - issuanceType: 'ISSUANCE_BY_DEFAULT', - maxCredNum: 3, - tailsHash: 'abcde', - tailsLocation: 'xyz', - publicKeys: ['abcde', 'fghijk'], - }, - ver: 'abcde', -} - -const schemaIdGenerated = generateSchemaId(did, schema.name, schema.version) +import { IndyLedgerService, IndyPoolService } from '../services' -const credentialDefinitionId = generateCredentialDefinitionId( - did, - credentialDefinitionTemplate.schema.seqNo, - credentialDefinitionTemplate.tag -) +jest.mock('../../../plugins/DependencyManager') +const DependencyManagerMock = DependencyManager as jest.Mock -const pools: IndyPoolConfig[] = [ - { - id: 'sovrinMain', - isProduction: true, - genesisTransactions: 'xxx', - transactionAuthorAgreement: { version: '1', acceptanceMechanism: 'accept' }, - }, -] +const dependencyManager = new DependencyManagerMock() describe('LedgerModule', () => { - let wallet: IndyWallet - let ledgerService: IndyLedgerService - let anonCredsCredentialDefinitionRepository: AnonCredsCredentialDefinitionRepository - let anonCredsSchemaRepository: AnonCredsSchemaRepository - let ledgerModule: LedgerModule - let agentContext: AgentContext - - const contextCorrelationId = 'mock' - const agentConfig = getAgentConfig('LedgerModuleTest', { - indyLedgers: pools, - }) - - beforeEach(async () => { - wallet = new IndyWallet(agentConfig.agentDependencies, agentConfig.logger, new SigningProviderRegistry([])) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await wallet.createAndOpen(agentConfig.walletConfig!) - }) - - afterEach(async () => { - await wallet.delete() - }) - - beforeEach(async () => { - ledgerService = new IndyLedgerServiceMock() - - agentContext = getAgentContext({ - wallet, - agentConfig, - contextCorrelationId, - }) - - anonCredsCredentialDefinitionRepository = new AnonCredsCredentialDefinitionRepositoryMock() - anonCredsSchemaRepository = new AnonCredsSchemaRepositoryMock() - - ledgerModule = new LedgerModule( - ledgerService, - agentContext, - anonCredsCredentialDefinitionRepository, - anonCredsSchemaRepository - ) - }) - - describe('LedgerModule', () => { - // Connect to pools - describe('connectToPools', () => { - it('should connect to all pools', async () => { - mockFunction(ledgerService.connectToPools).mockResolvedValue([1, 2, 4]) - await expect(ledgerModule.connectToPools()).resolves - expect(ledgerService.connectToPools).toHaveBeenCalled() - }) - }) - - // Register public did - describe('registerPublicDid', () => { - it('should register a public DID', async () => { - mockFunction(ledgerService.registerPublicDid).mockResolvedValueOnce(did) - mockProperty(wallet, 'publicDid', { did: did, verkey: 'abcde' }) - await expect(ledgerModule.registerPublicDid(did, 'abcde', 'someAlias')).resolves.toEqual(did) - expect(ledgerService.registerPublicDid).toHaveBeenCalledWith( - agentContext, - did, - did, - 'abcde', - 'someAlias', - undefined - ) - }) - - it('should throw an error if the DID cannot be registered because there is no public did', async () => { - const did = 'Y5bj4SjCiTM9PgeheKAiXx' - mockProperty(wallet, 'publicDid', undefined) - await expect(ledgerModule.registerPublicDid(did, 'abcde', 'someAlias')).rejects.toThrowError( - AriesFrameworkError - ) - }) - }) - - // Get public DID - describe('getPublicDid', () => { - it('should return the public DID if there is one', async () => { - const nymResponse: Indy.GetNymResponse = { did: 'Y5bj4SjCiTM9PgeheKAiXx', verkey: 'abcde', role: 'STEWARD' } - mockProperty(wallet, 'publicDid', { did: nymResponse.did, verkey: nymResponse.verkey }) - mockFunction(ledgerService.getPublicDid).mockResolvedValueOnce(nymResponse) - await expect(ledgerModule.getPublicDid(nymResponse.did)).resolves.toEqual(nymResponse) - expect(ledgerService.getPublicDid).toHaveBeenCalledWith(agentContext, nymResponse.did) - }) - }) - - // Get schema - describe('getSchema', () => { - it('should return the schema by id if there is one', async () => { - mockFunction(ledgerService.getSchema).mockResolvedValueOnce(schema) - await expect(ledgerModule.getSchema(schemaId)).resolves.toEqual(schema) - expect(ledgerService.getSchema).toHaveBeenCalledWith(agentContext, schemaId) - }) - - it('should throw an error if no schema for the id exists', async () => { - mockFunction(ledgerService.getSchema).mockRejectedValueOnce( - new AriesFrameworkError('Error retrieving schema abcd from ledger 1') - ) - await expect(ledgerModule.getSchema(schemaId)).rejects.toThrowError(AriesFrameworkError) - expect(ledgerService.getSchema).toHaveBeenCalledWith(agentContext, schemaId) - }) - }) - - describe('registerSchema', () => { - it('should throw an error if there is no public DID', async () => { - mockProperty(wallet, 'publicDid', undefined) - await expect(ledgerModule.registerSchema({ ...schema, attributes: ['hello', 'world'] })).rejects.toThrowError( - AriesFrameworkError - ) - }) - - it('should return the schema from anonCreds when it already exists', async () => { - mockProperty(wallet, 'publicDid', { did: did, verkey: 'abcde' }) - mockFunction(anonCredsSchemaRepository.findBySchemaId).mockResolvedValueOnce( - new AnonCredsSchemaRecord({ schema: schema }) - ) - await expect(ledgerModule.registerSchema({ ...schema, attributes: ['hello', 'world'] })).resolves.toEqual( - schema - ) - expect(anonCredsSchemaRepository.findBySchemaId).toHaveBeenCalledWith(agentContext, schemaIdGenerated) - }) - - it('should return the schema from the ledger when it already exists', async () => { - mockProperty(wallet, 'publicDid', { did: did, verkey: 'abcde' }) - jest - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .spyOn(LedgerModule.prototype as any, 'findBySchemaIdOnLedger') - .mockResolvedValueOnce(new AnonCredsSchemaRecord({ schema: schema })) - await expect( - ledgerModule.registerSchema({ ...schema, attributes: ['hello', 'world'] }) - ).resolves.toHaveProperty('schema', schema) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect(jest.spyOn(LedgerModule.prototype as any, 'findBySchemaIdOnLedger')).toHaveBeenCalledWith( - schemaIdGenerated - ) - }) - - it('should return the schema after registering it', async () => { - mockProperty(wallet, 'publicDid', { did: did, verkey: 'abcde' }) - mockFunction(ledgerService.registerSchema).mockResolvedValueOnce(schema) - await expect(ledgerModule.registerSchema({ ...schema, attributes: ['hello', 'world'] })).resolves.toEqual( - schema - ) - expect(ledgerService.registerSchema).toHaveBeenCalledWith(agentContext, did, { - ...schema, - attributes: ['hello', 'world'], - }) - }) - }) - - describe('registerCredentialDefinition', () => { - it('should throw an error if there si no public DID', async () => { - mockProperty(wallet, 'publicDid', undefined) - await expect(ledgerModule.registerCredentialDefinition(credentialDefinitionTemplate)).rejects.toThrowError( - AriesFrameworkError - ) - }) - - it('should return the credential definition from the wallet if it already exists', async () => { - mockProperty(wallet, 'publicDid', { did: did, verkey: 'abcde' }) - const anonCredsCredentialDefinitionRecord: AnonCredsCredentialDefinitionRecord = - new AnonCredsCredentialDefinitionRecord({ - credentialDefinition: credDef, - }) - mockFunction(anonCredsCredentialDefinitionRepository.findByCredentialDefinitionId).mockResolvedValueOnce( - anonCredsCredentialDefinitionRecord - ) - await expect(ledgerModule.registerCredentialDefinition(credentialDefinitionTemplate)).resolves.toHaveProperty( - 'value.primary', - credentialDefinition - ) - expect(anonCredsCredentialDefinitionRepository.findByCredentialDefinitionId).toHaveBeenCalledWith( - agentContext, - credentialDefinitionId - ) - }) - - it('should throw an exception if the definition already exists on the ledger', async () => { - mockProperty(wallet, 'publicDid', { did: did, verkey: 'abcde' }) - jest - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .spyOn(LedgerModule.prototype as any, 'findByCredentialDefinitionIdOnLedger') - .mockResolvedValueOnce({ credentialDefinition: credentialDefinition }) - await expect(ledgerModule.registerCredentialDefinition(credentialDefinitionTemplate)).rejects.toThrowError( - AriesFrameworkError - ) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect(jest.spyOn(LedgerModule.prototype as any, 'findByCredentialDefinitionIdOnLedger')).toHaveBeenCalledWith( - credentialDefinitionId - ) - }) - - it('should register the credential successfully if it is neither in the wallet and neither on the ledger', async () => { - mockProperty(wallet, 'publicDid', { did: did, verkey: 'abcde' }) - mockFunction(ledgerService.registerCredentialDefinition).mockResolvedValueOnce(credDef) - await expect(ledgerModule.registerCredentialDefinition(credentialDefinitionTemplate)).resolves.toEqual(credDef) - expect(ledgerService.registerCredentialDefinition).toHaveBeenCalledWith(agentContext, did, { - ...credentialDefinitionTemplate, - signatureType: 'CL', - }) - }) - }) - - describe('getCredentialDefinition', () => { - it('should return the credential definition given the id', async () => { - mockProperty(wallet, 'publicDid', { did: did, verkey: 'abcde' }) - mockFunction(ledgerService.getCredentialDefinition).mockResolvedValue(credDef) - await expect(ledgerModule.getCredentialDefinition(credDef.id)).resolves.toEqual(credDef) - expect(ledgerService.getCredentialDefinition).toHaveBeenCalledWith(agentContext, credDef.id) - }) - - it('should throw an error if there is no credential definition for the given id', async () => { - mockProperty(wallet, 'publicDid', { did: did, verkey: 'abcde' }) - mockFunction(ledgerService.getCredentialDefinition).mockRejectedValueOnce(new AriesFrameworkError('')) - await expect(ledgerModule.getCredentialDefinition(credDef.id)).rejects.toThrowError(AriesFrameworkError) - expect(ledgerService.getCredentialDefinition).toHaveBeenCalledWith(agentContext, credDef.id) - }) - }) - - describe('getRevocationRegistryDefinition', () => { - it('should return the ParseRevocationRegistryDefinitionTemplate for a valid revocationRegistryDefinitionId', async () => { - const parseRevocationRegistryDefinitionTemplate = { - revocationRegistryDefinition: revocRegDef, - revocationRegistryDefinitionTxnTime: 12345678, - } - mockFunction(ledgerService.getRevocationRegistryDefinition).mockResolvedValue( - parseRevocationRegistryDefinitionTemplate - ) - await expect(ledgerModule.getRevocationRegistryDefinition(revocRegDef.id)).resolves.toBe( - parseRevocationRegistryDefinitionTemplate - ) - expect(ledgerService.getRevocationRegistryDefinition).toHaveBeenLastCalledWith(agentContext, revocRegDef.id) - }) - - it('should throw an error if the ParseRevocationRegistryDefinitionTemplate does not exists', async () => { - mockFunction(ledgerService.getRevocationRegistryDefinition).mockRejectedValueOnce(new AriesFrameworkError('')) - await expect(ledgerModule.getRevocationRegistryDefinition('abcde')).rejects.toThrowError(AriesFrameworkError) - expect(ledgerService.getRevocationRegistryDefinition).toHaveBeenCalledWith(agentContext, revocRegDef.id) - }) - }) - - describe('getRevocationRegistryDelta', () => { - it('should return the ParseRevocationRegistryDeltaTemplate', async () => { - const revocRegDelta = { - value: { - prevAccum: 'prev', - accum: 'accum', - issued: [1, 2, 3], - revoked: [4, 5, 6], - }, - ver: 'ver', - } - const parseRevocationRegistryDeltaTemplate = { - revocationRegistryDelta: revocRegDelta, - deltaTimestamp: 12345678, - } + test('registers dependencies on the dependency manager', () => { + new LedgerModule().register(dependencyManager) - mockFunction(ledgerService.getRevocationRegistryDelta).mockResolvedValueOnce( - parseRevocationRegistryDeltaTemplate - ) - await expect(ledgerModule.getRevocationRegistryDelta('12345')).resolves.toEqual( - parseRevocationRegistryDeltaTemplate - ) - expect(ledgerService.getRevocationRegistryDelta).toHaveBeenCalledTimes(1) - }) + expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) + expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(LedgerApi) - it('should throw an error if the delta cannot be obtained', async () => { - mockFunction(ledgerService.getRevocationRegistryDelta).mockRejectedValueOnce(new AriesFrameworkError('')) - await expect(ledgerModule.getRevocationRegistryDelta('abcde1234')).rejects.toThrowError(AriesFrameworkError) - expect(ledgerService.getRevocationRegistryDelta).toHaveBeenCalledTimes(1) - }) - }) + expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(4) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(IndyLedgerService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(IndyPoolService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(AnonCredsCredentialDefinitionRepository) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(AnonCredsSchemaRepository) }) }) diff --git a/packages/core/src/modules/ledger/index.ts b/packages/core/src/modules/ledger/index.ts index 1168480d3c..fc65f390db 100644 --- a/packages/core/src/modules/ledger/index.ts +++ b/packages/core/src/modules/ledger/index.ts @@ -1,3 +1,4 @@ export * from './services' -export * from './LedgerModule' +export * from './LedgerApi' export * from './IndyPool' +export * from './LedgerModule' diff --git a/packages/core/src/modules/oob/OutOfBandApi.ts b/packages/core/src/modules/oob/OutOfBandApi.ts new file mode 100644 index 0000000000..a5edd589c0 --- /dev/null +++ b/packages/core/src/modules/oob/OutOfBandApi.ts @@ -0,0 +1,709 @@ +import type { AgentMessage } from '../../agent/AgentMessage' +import type { AgentMessageReceivedEvent } from '../../agent/Events' +import type { Key } from '../../crypto' +import type { Attachment } from '../../decorators/attachment/Attachment' +import type { PlaintextMessage } from '../../types' +import type { ConnectionInvitationMessage, ConnectionRecord, Routing } from '../connections' +import type { HandshakeReusedEvent } from './domain/OutOfBandEvents' + +import { catchError, EmptyError, first, firstValueFrom, map, of, timeout } from 'rxjs' + +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 { ServiceDecorator } from '../../decorators/service/ServiceDecorator' +import { AriesFrameworkError } from '../../error' +import { Logger } from '../../logger' +import { inject, injectable } from '../../plugins' +import { DidCommMessageRepository, DidCommMessageRole } from '../../storage' +import { JsonEncoder, JsonTransformer } from '../../utils' +import { parseMessageType, supportsIncomingMessageType } from '../../utils/messageType' +import { parseInvitationUrl, parseInvitationShortUrl } from '../../utils/parseInvitation' +import { ConnectionsApi, DidExchangeState, HandshakeProtocol } from '../connections' +import { DidKey } from '../dids' +import { didKeyToVerkey } from '../dids/helpers' +import { outOfBandServiceToNumAlgo2Did } from '../dids/methods/peer/peerDidNumAlgo2' +import { RoutingService } from '../routing/services/RoutingService' + +import { OutOfBandService } from './OutOfBandService' +import { OutOfBandDidCommService } from './domain/OutOfBandDidCommService' +import { OutOfBandEventTypes } from './domain/OutOfBandEvents' +import { OutOfBandRole } from './domain/OutOfBandRole' +import { OutOfBandState } from './domain/OutOfBandState' +import { HandshakeReuseHandler } from './handlers' +import { HandshakeReuseAcceptedHandler } from './handlers/HandshakeReuseAcceptedHandler' +import { convertToNewInvitation, convertToOldInvitation } from './helpers' +import { OutOfBandInvitation } from './messages' +import { OutOfBandRecord } from './repository/OutOfBandRecord' + +const didCommProfiles = ['didcomm/aip1', 'didcomm/aip2;env=rfc19'] + +export interface CreateOutOfBandInvitationConfig { + label?: string + alias?: string + imageUrl?: string + goalCode?: string + goal?: string + handshake?: boolean + handshakeProtocols?: HandshakeProtocol[] + messages?: AgentMessage[] + multiUseInvitation?: boolean + autoAcceptConnection?: boolean + routing?: Routing + appendedAttachments?: Attachment[] +} + +export interface CreateLegacyInvitationConfig { + label?: string + alias?: string + imageUrl?: string + multiUseInvitation?: boolean + autoAcceptConnection?: boolean + routing?: Routing +} + +export interface ReceiveOutOfBandInvitationConfig { + label?: string + alias?: string + imageUrl?: string + autoAcceptInvitation?: boolean + autoAcceptConnection?: boolean + reuseConnection?: boolean + routing?: Routing +} + +@injectable() +export class OutOfBandApi { + private outOfBandService: OutOfBandService + private routingService: RoutingService + private connectionsApi: ConnectionsApi + private didCommMessageRepository: DidCommMessageRepository + private dispatcher: Dispatcher + private messageSender: MessageSender + private eventEmitter: EventEmitter + private agentContext: AgentContext + private logger: Logger + + public constructor( + dispatcher: Dispatcher, + outOfBandService: OutOfBandService, + routingService: RoutingService, + connectionsApi: ConnectionsApi, + didCommMessageRepository: DidCommMessageRepository, + messageSender: MessageSender, + eventEmitter: EventEmitter, + @inject(InjectionSymbols.Logger) logger: Logger, + agentContext: AgentContext + ) { + this.dispatcher = dispatcher + this.agentContext = agentContext + this.logger = logger + this.outOfBandService = outOfBandService + this.routingService = routingService + this.connectionsApi = connectionsApi + this.didCommMessageRepository = didCommMessageRepository + this.messageSender = messageSender + this.eventEmitter = eventEmitter + this.registerHandlers(dispatcher) + } + + /** + * Creates an outbound out-of-band record containing out-of-band invitation message defined in + * Aries RFC 0434: Out-of-Band Protocol 1.1. + * + * It automatically adds all supported handshake protocols by agent to `handshake_protocols`. You + * can modify this by setting `handshakeProtocols` in `config` parameter. If you want to create + * invitation without handshake, you can set `handshake` to `false`. + * + * If `config` parameter contains `messages` it adds them to `requests~attach` attribute. + * + * Agent role: sender (inviter) + * + * @param config configuration of how out-of-band invitation should be created + * @returns out-of-band record + */ + public async createInvitation(config: CreateOutOfBandInvitationConfig = {}): Promise { + const multiUseInvitation = config.multiUseInvitation ?? false + const handshake = config.handshake ?? true + const customHandshakeProtocols = config.handshakeProtocols + const autoAcceptConnection = config.autoAcceptConnection ?? this.connectionsApi.config.autoAcceptConnections + // We don't want to treat an empty array as messages being provided + const messages = config.messages && config.messages.length > 0 ? config.messages : undefined + const label = config.label ?? this.agentContext.config.label + const imageUrl = config.imageUrl ?? this.agentContext.config.connectionImageUrl + const appendedAttachments = + config.appendedAttachments && config.appendedAttachments.length > 0 ? config.appendedAttachments : undefined + + if (!handshake && !messages) { + throw new AriesFrameworkError( + 'One or both of handshake_protocols and requests~attach MUST be included in the message.' + ) + } + + if (!handshake && customHandshakeProtocols) { + throw new AriesFrameworkError(`Attribute 'handshake' can not be 'false' when 'handshakeProtocols' is defined.`) + } + + // For now we disallow creating multi-use invitation with attachments. This would mean we need multi-use + // credential and presentation exchanges. + if (messages && multiUseInvitation) { + throw new AriesFrameworkError("Attribute 'multiUseInvitation' can not be 'true' when 'messages' is defined.") + } + + let handshakeProtocols + if (handshake) { + // Find supported handshake protocol preserving the order of handshake protocols defined + // by agent + if (customHandshakeProtocols) { + this.assertHandshakeProtocols(customHandshakeProtocols) + handshakeProtocols = customHandshakeProtocols + } else { + handshakeProtocols = this.getSupportedHandshakeProtocols() + } + } + + const routing = config.routing ?? (await this.routingService.getRouting(this.agentContext, {})) + + const services = routing.endpoints.map((endpoint, index) => { + return new OutOfBandDidCommService({ + id: `#inline-${index}`, + serviceEndpoint: endpoint, + recipientKeys: [routing.recipientKey].map((key) => new DidKey(key).did), + routingKeys: routing.routingKeys.map((key) => new DidKey(key).did), + }) + }) + + const options = { + label, + goal: config.goal, + goalCode: config.goalCode, + imageUrl, + accept: didCommProfiles, + services, + handshakeProtocols, + appendedAttachments, + } + const outOfBandInvitation = new OutOfBandInvitation(options) + + if (messages) { + messages.forEach((message) => { + if (message.service) { + // We can remove `~service` attribute from message. Newer OOB messages have `services` attribute instead. + message.service = undefined + } + outOfBandInvitation.addRequest(message) + }) + } + + const outOfBandRecord = new OutOfBandRecord({ + mediatorId: routing.mediatorId, + role: OutOfBandRole.Sender, + state: OutOfBandState.AwaitResponse, + outOfBandInvitation: outOfBandInvitation, + reusable: multiUseInvitation, + autoAcceptConnection, + }) + + await this.outOfBandService.save(this.agentContext, outOfBandRecord) + this.outOfBandService.emitStateChangedEvent(this.agentContext, outOfBandRecord, null) + + return outOfBandRecord + } + + /** + * Creates an outbound out-of-band record in the same way how `createInvitation` method does it, + * but it also converts out-of-band invitation message to an "legacy" invitation message defined + * in RFC 0160: Connection Protocol and returns it together with out-of-band record. + * + * Agent role: sender (inviter) + * + * @param config configuration of how a connection invitation should be created + * @returns out-of-band record and connection invitation + */ + public async createLegacyInvitation(config: CreateLegacyInvitationConfig = {}) { + const outOfBandRecord = await this.createInvitation({ + ...config, + handshakeProtocols: [HandshakeProtocol.Connections], + }) + return { outOfBandRecord, invitation: convertToOldInvitation(outOfBandRecord.outOfBandInvitation) } + } + + public async createLegacyConnectionlessInvitation(config: { + recordId: string + message: Message + domain: string + }): Promise<{ message: Message; invitationUrl: string }> { + // Create keys (and optionally register them at the mediator) + const routing = await this.routingService.getRouting(this.agentContext) + + // Set the service on the message + config.message.service = new ServiceDecorator({ + serviceEndpoint: routing.endpoints[0], + recipientKeys: [routing.recipientKey].map((key) => key.publicKeyBase58), + routingKeys: routing.routingKeys.map((key) => key.publicKeyBase58), + }) + + // We need to update the message with the new service, so we can + // retrieve it from storage later on. + await this.didCommMessageRepository.saveOrUpdateAgentMessage(this.agentContext, { + agentMessage: config.message, + associatedRecordId: config.recordId, + role: DidCommMessageRole.Sender, + }) + + return { + message: config.message, + invitationUrl: `${config.domain}?d_m=${JsonEncoder.toBase64URL(JsonTransformer.toJSON(config.message))}`, + } + } + + /** + * Parses URL, decodes invitation and calls `receiveMessage` with parsed invitation message. + * + * Agent role: receiver (invitee) + * + * @param invitationUrl url containing a base64 encoded invitation to receive + * @param config configuration of how out-of-band invitation should be processed + * @returns out-of-band record and connection record if one has been created + */ + public async receiveInvitationFromUrl(invitationUrl: string, config: ReceiveOutOfBandInvitationConfig = {}) { + const message = await this.parseInvitationShortUrl(invitationUrl) + + return this.receiveInvitation(message, config) + } + + /** + * Parses URL containing encoded invitation and returns invitation message. + * + * @param invitationUrl URL containing encoded invitation + * + * @returns OutOfBandInvitation + */ + public parseInvitation(invitationUrl: string): OutOfBandInvitation { + return parseInvitationUrl(invitationUrl) + } + + /** + * Parses URL containing encoded invitation and returns invitation message. Compatible with + * parsing shortened URLs + * + * @param invitationUrl URL containing encoded invitation + * + * @returns OutOfBandInvitation + */ + public async parseInvitationShortUrl(invitation: string): Promise { + return await parseInvitationShortUrl(invitation, this.agentContext.config.agentDependencies) + } + + /** + * Creates inbound out-of-band record and assigns out-of-band invitation message to it if the + * message is valid. It automatically passes out-of-band invitation for further processing to + * `acceptInvitation` method. If you don't want to do that you can set `autoAcceptInvitation` + * attribute in `config` parameter to `false` and accept the message later by calling + * `acceptInvitation`. + * + * It supports both OOB (Aries RFC 0434: Out-of-Band Protocol 1.1) and Connection Invitation + * (0160: Connection Protocol). + * + * Agent role: receiver (invitee) + * + * @param invitation either OutOfBandInvitation or ConnectionInvitationMessage + * @param config config for handling of invitation + * + * @returns out-of-band record and connection record if one has been created. + */ + public async receiveInvitation( + invitation: OutOfBandInvitation | ConnectionInvitationMessage, + config: ReceiveOutOfBandInvitationConfig = {} + ): Promise<{ outOfBandRecord: OutOfBandRecord; connectionRecord?: ConnectionRecord }> { + // Convert to out of band invitation if needed + const outOfBandInvitation = + invitation instanceof OutOfBandInvitation ? invitation : convertToNewInvitation(invitation) + + const { handshakeProtocols } = outOfBandInvitation + const { routing } = config + + const autoAcceptInvitation = config.autoAcceptInvitation ?? true + const autoAcceptConnection = config.autoAcceptConnection ?? true + const reuseConnection = config.reuseConnection ?? false + const label = config.label ?? this.agentContext.config.label + const alias = config.alias + const imageUrl = config.imageUrl ?? this.agentContext.config.connectionImageUrl + + const messages = outOfBandInvitation.getRequests() + + if ((!handshakeProtocols || handshakeProtocols.length === 0) && (!messages || messages?.length === 0)) { + throw new AriesFrameworkError( + 'One or both of handshake_protocols and requests~attach MUST be included in the message.' + ) + } + + // Make sure we haven't processed this invitation before. + let outOfBandRecord = await this.findByInvitationId(outOfBandInvitation.id) + if (outOfBandRecord) { + throw new AriesFrameworkError( + `An out of band record with invitation ${outOfBandInvitation.id} already exists. Invitations should have a unique id.` + ) + } + + outOfBandRecord = new OutOfBandRecord({ + role: OutOfBandRole.Receiver, + state: OutOfBandState.Initial, + outOfBandInvitation: outOfBandInvitation, + autoAcceptConnection, + }) + await this.outOfBandService.save(this.agentContext, outOfBandRecord) + this.outOfBandService.emitStateChangedEvent(this.agentContext, outOfBandRecord, null) + + if (autoAcceptInvitation) { + return await this.acceptInvitation(outOfBandRecord.id, { + label, + alias, + imageUrl, + autoAcceptConnection, + reuseConnection, + routing, + }) + } + + return { outOfBandRecord } + } + + /** + * Creates a connection if the out-of-band invitation message contains `handshake_protocols` + * attribute, except for the case when connection already exists and `reuseConnection` is enabled. + * + * It passes first supported message from `requests~attach` attribute to the agent, except for the + * case reuse of connection is applied when it just sends `handshake-reuse` message to existing + * connection. + * + * Agent role: receiver (invitee) + * + * @param outOfBandId + * @param config + * @returns out-of-band record and connection record if one has been created. + */ + public async acceptInvitation( + outOfBandId: string, + config: { + autoAcceptConnection?: boolean + reuseConnection?: boolean + label?: string + alias?: string + imageUrl?: string + mediatorId?: string + routing?: Routing + } + ) { + const outOfBandRecord = await this.outOfBandService.getById(this.agentContext, outOfBandId) + + const { outOfBandInvitation } = outOfBandRecord + const { label, alias, imageUrl, autoAcceptConnection, reuseConnection, routing } = config + const { handshakeProtocols, services } = outOfBandInvitation + const messages = outOfBandInvitation.getRequests() + + const existingConnection = await this.findExistingConnection(services) + + await this.outOfBandService.updateState(this.agentContext, outOfBandRecord, OutOfBandState.PrepareResponse) + + if (handshakeProtocols) { + this.logger.debug('Out of band message contains handshake protocols.') + + let connectionRecord + if (existingConnection && reuseConnection) { + this.logger.debug( + `Connection already exists and reuse is enabled. Reusing an existing connection with ID ${existingConnection.id}.` + ) + + if (!messages) { + this.logger.debug('Out of band message does not contain any request messages.') + const isHandshakeReuseSuccessful = await this.handleHandshakeReuse(outOfBandRecord, existingConnection) + + // Handshake reuse was successful + if (isHandshakeReuseSuccessful) { + this.logger.debug(`Handshake reuse successful. Reusing existing connection ${existingConnection.id}.`) + connectionRecord = existingConnection + } else { + // Handshake reuse failed. Not setting connection record + this.logger.debug(`Handshake reuse failed. Not using existing connection ${existingConnection.id}.`) + } + } else { + // Handshake reuse because we found a connection and we can respond directly to the message + this.logger.debug(`Reusing existing connection ${existingConnection.id}.`) + connectionRecord = existingConnection + } + } + + // If no existing connection was found, reuseConnection is false, or we didn't receive a + // handshake-reuse-accepted message we create a new connection + if (!connectionRecord) { + this.logger.debug('Connection does not exist or reuse is disabled. Creating a new connection.') + // Find first supported handshake protocol preserving the order of handshake protocols + // defined by `handshake_protocols` attribute in the invitation message + const handshakeProtocol = this.getFirstSupportedProtocol(handshakeProtocols) + connectionRecord = await this.connectionsApi.acceptOutOfBandInvitation(outOfBandRecord, { + label, + alias, + imageUrl, + autoAcceptConnection, + protocol: handshakeProtocol, + routing, + }) + } + + if (messages) { + this.logger.debug('Out of band message contains request messages.') + if (connectionRecord.isReady) { + await this.emitWithConnection(connectionRecord, messages) + } else { + // Wait until the connection is ready and then pass the messages to the agent for further processing + this.connectionsApi + .returnWhenIsConnected(connectionRecord.id) + .then((connectionRecord) => this.emitWithConnection(connectionRecord, messages)) + .catch((error) => { + if (error instanceof EmptyError) { + this.logger.warn( + `Agent unsubscribed before connection got into ${DidExchangeState.Completed} state`, + error + ) + } else { + this.logger.error('Promise waiting for the connection to be complete failed.', error) + } + }) + } + } + return { outOfBandRecord, connectionRecord } + } else if (messages) { + this.logger.debug('Out of band message contains only request messages.') + if (existingConnection) { + this.logger.debug('Connection already exists.', { connectionId: existingConnection.id }) + await this.emitWithConnection(existingConnection, messages) + } else { + await this.emitWithServices(services, messages) + } + } + return { outOfBandRecord } + } + + public async findByRecipientKey(recipientKey: Key) { + return this.outOfBandService.findByRecipientKey(this.agentContext, recipientKey) + } + + public async findByInvitationId(invitationId: string) { + return this.outOfBandService.findByInvitationId(this.agentContext, invitationId) + } + + /** + * Retrieve all out of bands records + * + * @returns List containing all out of band records + */ + public getAll() { + return this.outOfBandService.getAll(this.agentContext) + } + + /** + * Retrieve a out of band record by id + * + * @param outOfBandId The out of band record id + * @throws {RecordNotFoundError} If no record is found + * @return The out of band record + * + */ + public getById(outOfBandId: string): Promise { + return this.outOfBandService.getById(this.agentContext, outOfBandId) + } + + /** + * Find an out of band record by id + * + * @param outOfBandId the out of band record id + * @returns The out of band record or null if not found + */ + public findById(outOfBandId: string): Promise { + return this.outOfBandService.findById(this.agentContext, outOfBandId) + } + + /** + * Delete an out of band record by id + * + * @param outOfBandId the out of band record id + */ + public async deleteById(outOfBandId: string) { + return this.outOfBandService.deleteById(this.agentContext, outOfBandId) + } + + private assertHandshakeProtocols(handshakeProtocols: HandshakeProtocol[]) { + if (!this.areHandshakeProtocolsSupported(handshakeProtocols)) { + const supportedProtocols = this.getSupportedHandshakeProtocols() + throw new AriesFrameworkError( + `Handshake protocols [${handshakeProtocols}] are not supported. Supported protocols are [${supportedProtocols}]` + ) + } + } + + private areHandshakeProtocolsSupported(handshakeProtocols: HandshakeProtocol[]) { + const supportedProtocols = this.getSupportedHandshakeProtocols() + return handshakeProtocols.every((p) => supportedProtocols.includes(p)) + } + + private getSupportedHandshakeProtocols(): HandshakeProtocol[] { + const handshakeMessageFamilies = ['https://didcomm.org/didexchange', 'https://didcomm.org/connections'] + const handshakeProtocols = this.dispatcher.filterSupportedProtocolsByMessageFamilies(handshakeMessageFamilies) + + if (handshakeProtocols.length === 0) { + throw new AriesFrameworkError('There is no handshake protocol supported. Agent can not create a connection.') + } + + // Order protocols according to `handshakeMessageFamilies` array + const orderedProtocols = handshakeMessageFamilies + .map((messageFamily) => handshakeProtocols.find((p) => p.startsWith(messageFamily))) + .filter((item): item is string => !!item) + + return orderedProtocols as HandshakeProtocol[] + } + + private getFirstSupportedProtocol(handshakeProtocols: HandshakeProtocol[]) { + const supportedProtocols = this.getSupportedHandshakeProtocols() + const handshakeProtocol = handshakeProtocols.find((p) => supportedProtocols.includes(p)) + if (!handshakeProtocol) { + throw new AriesFrameworkError( + `Handshake protocols [${handshakeProtocols}] are not supported. Supported protocols are [${supportedProtocols}]` + ) + } + return handshakeProtocol + } + + private async findExistingConnection(services: Array) { + this.logger.debug('Searching for an existing connection for out-of-band invitation services.', { services }) + + // TODO: for each did we should look for a connection with the invitation did OR a connection with theirDid that matches the service did + for (const didOrService of services) { + // We need to check if the service is an instance of string because of limitations from class-validator + if (typeof didOrService === 'string' || didOrService instanceof String) { + // TODO await this.connectionsApi.findByTheirDid() + throw new AriesFrameworkError('Dids are not currently supported in out-of-band invitation services attribute.') + } + + const did = outOfBandServiceToNumAlgo2Did(didOrService) + const connections = await this.connectionsApi.findByInvitationDid(did) + this.logger.debug(`Retrieved ${connections.length} connections for invitation did ${did}`) + + if (connections.length === 1) { + const [firstConnection] = connections + return firstConnection + } else if (connections.length > 1) { + this.logger.warn(`There is more than one connection created from invitationDid ${did}. Taking the first one.`) + const [firstConnection] = connections + return firstConnection + } + return null + } + } + + private async emitWithConnection(connectionRecord: ConnectionRecord, messages: PlaintextMessage[]) { + const supportedMessageTypes = this.dispatcher.supportedMessageTypes + const plaintextMessage = messages.find((message) => { + const parsedMessageType = parseMessageType(message['@type']) + return supportedMessageTypes.find((type) => supportsIncomingMessageType(parsedMessageType, type)) + }) + + if (!plaintextMessage) { + throw new AriesFrameworkError('There is no message in requests~attach supported by agent.') + } + + this.logger.debug(`Message with type ${plaintextMessage['@type']} can be processed.`) + + this.eventEmitter.emit(this.agentContext, { + type: AgentEventTypes.AgentMessageReceived, + payload: { + message: plaintextMessage, + connection: connectionRecord, + contextCorrelationId: this.agentContext.contextCorrelationId, + }, + }) + } + + private async emitWithServices(services: Array, messages: PlaintextMessage[]) { + if (!services || services.length === 0) { + throw new AriesFrameworkError(`There are no services. We can not emit messages`) + } + + const supportedMessageTypes = this.dispatcher.supportedMessageTypes + const plaintextMessage = messages.find((message) => { + const parsedMessageType = parseMessageType(message['@type']) + return supportedMessageTypes.find((type) => supportsIncomingMessageType(parsedMessageType, type)) + }) + + if (!plaintextMessage) { + throw new AriesFrameworkError('There is no message in requests~attach supported by agent.') + } + + this.logger.debug(`Message with type ${plaintextMessage['@type']} can be processed.`) + + // The framework currently supports only older OOB messages with `~service` decorator. + // TODO: support receiving messages with other services so we don't have to transform the service + // to ~service decorator + const [service] = services + + if (typeof service === 'string') { + throw new AriesFrameworkError('Dids are not currently supported in out-of-band invitation services attribute.') + } + + const serviceDecorator = new ServiceDecorator({ + recipientKeys: service.recipientKeys.map(didKeyToVerkey), + routingKeys: service.routingKeys?.map(didKeyToVerkey) || [], + serviceEndpoint: service.serviceEndpoint, + }) + + plaintextMessage['~service'] = JsonTransformer.toJSON(serviceDecorator) + this.eventEmitter.emit(this.agentContext, { + type: AgentEventTypes.AgentMessageReceived, + payload: { + message: plaintextMessage, + contextCorrelationId: this.agentContext.contextCorrelationId, + }, + }) + } + + private async handleHandshakeReuse(outOfBandRecord: OutOfBandRecord, connectionRecord: ConnectionRecord) { + const reuseMessage = await this.outOfBandService.createHandShakeReuse( + this.agentContext, + outOfBandRecord, + connectionRecord + ) + + const reuseAcceptedEventPromise = firstValueFrom( + this.eventEmitter.observable(OutOfBandEventTypes.HandshakeReused).pipe( + filterContextCorrelationId(this.agentContext.contextCorrelationId), + // Find the first reuse event where the handshake reuse accepted matches the reuse message thread + // TODO: Should we store the reuse state? Maybe we can keep it in memory for now + first( + (event) => + event.payload.reuseThreadId === reuseMessage.threadId && + event.payload.outOfBandRecord.id === outOfBandRecord.id && + event.payload.connectionRecord.id === connectionRecord.id + ), + // If the event is found, we return the value true + map(() => true), + timeout(15000), + // If timeout is reached, we return false + catchError(() => of(false)) + ) + ) + + const outbound = createOutboundMessage(connectionRecord, reuseMessage) + await this.messageSender.sendMessage(this.agentContext, outbound) + + return reuseAcceptedEventPromise + } + + private registerHandlers(dispatcher: Dispatcher) { + dispatcher.registerHandler(new HandshakeReuseHandler(this.outOfBandService)) + dispatcher.registerHandler(new HandshakeReuseAcceptedHandler(this.outOfBandService)) + } +} diff --git a/packages/core/src/modules/oob/OutOfBandModule.ts b/packages/core/src/modules/oob/OutOfBandModule.ts index c2f365ae07..3b31876a48 100644 --- a/packages/core/src/modules/oob/OutOfBandModule.ts +++ b/packages/core/src/modules/oob/OutOfBandModule.ts @@ -1,720 +1,16 @@ -import type { AgentMessage } from '../../agent/AgentMessage' -import type { AgentMessageReceivedEvent } from '../../agent/Events' -import type { Key } from '../../crypto' -import type { Attachment } from '../../decorators/attachment/Attachment' -import type { ConnectionInvitationMessage, ConnectionRecord, Routing } from '../../modules/connections' -import type { DependencyManager } from '../../plugins' -import type { PlaintextMessage } from '../../types' -import type { HandshakeReusedEvent } from './domain/OutOfBandEvents' - -import { catchError, EmptyError, first, firstValueFrom, map, of, timeout } from 'rxjs' - -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 { ServiceDecorator } from '../../decorators/service/ServiceDecorator' -import { AriesFrameworkError } from '../../error' -import { Logger } from '../../logger' -import { ConnectionsModule, DidExchangeState, HandshakeProtocol } from '../../modules/connections' -import { inject, injectable, module } from '../../plugins' -import { DidCommMessageRepository, DidCommMessageRole } from '../../storage' -import { JsonEncoder, JsonTransformer } from '../../utils' -import { parseMessageType, supportsIncomingMessageType } from '../../utils/messageType' -import { parseInvitationUrl, parseInvitationShortUrl } from '../../utils/parseInvitation' -import { DidKey } from '../dids' -import { didKeyToVerkey } from '../dids/helpers' -import { outOfBandServiceToNumAlgo2Did } from '../dids/methods/peer/peerDidNumAlgo2' -import { RoutingService } from '../routing/services/RoutingService' +import type { DependencyManager, Module } from '../../plugins' +import { OutOfBandApi } from './OutOfBandApi' import { OutOfBandService } from './OutOfBandService' -import { OutOfBandDidCommService } from './domain/OutOfBandDidCommService' -import { OutOfBandEventTypes } from './domain/OutOfBandEvents' -import { OutOfBandRole } from './domain/OutOfBandRole' -import { OutOfBandState } from './domain/OutOfBandState' -import { HandshakeReuseHandler } from './handlers' -import { HandshakeReuseAcceptedHandler } from './handlers/HandshakeReuseAcceptedHandler' -import { convertToNewInvitation, convertToOldInvitation } from './helpers' -import { OutOfBandInvitation } from './messages' import { OutOfBandRepository } from './repository' -import { OutOfBandRecord } from './repository/OutOfBandRecord' - -const didCommProfiles = ['didcomm/aip1', 'didcomm/aip2;env=rfc19'] - -export interface CreateOutOfBandInvitationConfig { - label?: string - alias?: string - imageUrl?: string - goalCode?: string - goal?: string - handshake?: boolean - handshakeProtocols?: HandshakeProtocol[] - messages?: AgentMessage[] - multiUseInvitation?: boolean - autoAcceptConnection?: boolean - routing?: Routing - appendedAttachments?: Attachment[] -} - -export interface CreateLegacyInvitationConfig { - label?: string - alias?: string - imageUrl?: string - multiUseInvitation?: boolean - autoAcceptConnection?: boolean - routing?: Routing -} - -export interface ReceiveOutOfBandInvitationConfig { - label?: string - alias?: string - imageUrl?: string - autoAcceptInvitation?: boolean - autoAcceptConnection?: boolean - reuseConnection?: boolean - routing?: Routing -} - -@module() -@injectable() -export class OutOfBandModule { - private outOfBandService: OutOfBandService - private routingService: RoutingService - private connectionsModule: ConnectionsModule - private didCommMessageRepository: DidCommMessageRepository - private dispatcher: Dispatcher - private messageSender: MessageSender - private eventEmitter: EventEmitter - private agentContext: AgentContext - private logger: Logger - - public constructor( - dispatcher: Dispatcher, - outOfBandService: OutOfBandService, - routingService: RoutingService, - connectionsModule: ConnectionsModule, - didCommMessageRepository: DidCommMessageRepository, - messageSender: MessageSender, - eventEmitter: EventEmitter, - @inject(InjectionSymbols.Logger) logger: Logger, - agentContext: AgentContext - ) { - this.dispatcher = dispatcher - this.agentContext = agentContext - this.logger = logger - this.outOfBandService = outOfBandService - this.routingService = routingService - this.connectionsModule = connectionsModule - this.didCommMessageRepository = didCommMessageRepository - this.messageSender = messageSender - this.eventEmitter = eventEmitter - this.registerHandlers(dispatcher) - } - - /** - * Creates an outbound out-of-band record containing out-of-band invitation message defined in - * Aries RFC 0434: Out-of-Band Protocol 1.1. - * - * It automatically adds all supported handshake protocols by agent to `handshake_protocols`. You - * can modify this by setting `handshakeProtocols` in `config` parameter. If you want to create - * invitation without handshake, you can set `handshake` to `false`. - * - * If `config` parameter contains `messages` it adds them to `requests~attach` attribute. - * - * Agent role: sender (inviter) - * - * @param config configuration of how out-of-band invitation should be created - * @returns out-of-band record - */ - public async createInvitation(config: CreateOutOfBandInvitationConfig = {}): Promise { - const multiUseInvitation = config.multiUseInvitation ?? false - const handshake = config.handshake ?? true - const customHandshakeProtocols = config.handshakeProtocols - const autoAcceptConnection = config.autoAcceptConnection ?? this.agentContext.config.autoAcceptConnections - // We don't want to treat an empty array as messages being provided - const messages = config.messages && config.messages.length > 0 ? config.messages : undefined - const label = config.label ?? this.agentContext.config.label - const imageUrl = config.imageUrl ?? this.agentContext.config.connectionImageUrl - const appendedAttachments = - config.appendedAttachments && config.appendedAttachments.length > 0 ? config.appendedAttachments : undefined - - if (!handshake && !messages) { - throw new AriesFrameworkError( - 'One or both of handshake_protocols and requests~attach MUST be included in the message.' - ) - } - - if (!handshake && customHandshakeProtocols) { - throw new AriesFrameworkError(`Attribute 'handshake' can not be 'false' when 'handshakeProtocols' is defined.`) - } - - // For now we disallow creating multi-use invitation with attachments. This would mean we need multi-use - // credential and presentation exchanges. - if (messages && multiUseInvitation) { - throw new AriesFrameworkError("Attribute 'multiUseInvitation' can not be 'true' when 'messages' is defined.") - } - - let handshakeProtocols - if (handshake) { - // Find supported handshake protocol preserving the order of handshake protocols defined - // by agent - if (customHandshakeProtocols) { - this.assertHandshakeProtocols(customHandshakeProtocols) - handshakeProtocols = customHandshakeProtocols - } else { - handshakeProtocols = this.getSupportedHandshakeProtocols() - } - } - - const routing = config.routing ?? (await this.routingService.getRouting(this.agentContext, {})) - - const services = routing.endpoints.map((endpoint, index) => { - return new OutOfBandDidCommService({ - id: `#inline-${index}`, - serviceEndpoint: endpoint, - recipientKeys: [routing.recipientKey].map((key) => new DidKey(key).did), - routingKeys: routing.routingKeys.map((key) => new DidKey(key).did), - }) - }) - - const options = { - label, - goal: config.goal, - goalCode: config.goalCode, - imageUrl, - accept: didCommProfiles, - services, - handshakeProtocols, - appendedAttachments, - } - const outOfBandInvitation = new OutOfBandInvitation(options) - - if (messages) { - messages.forEach((message) => { - if (message.service) { - // We can remove `~service` attribute from message. Newer OOB messages have `services` attribute instead. - message.service = undefined - } - outOfBandInvitation.addRequest(message) - }) - } - - const outOfBandRecord = new OutOfBandRecord({ - mediatorId: routing.mediatorId, - role: OutOfBandRole.Sender, - state: OutOfBandState.AwaitResponse, - outOfBandInvitation: outOfBandInvitation, - reusable: multiUseInvitation, - autoAcceptConnection, - }) - - await this.outOfBandService.save(this.agentContext, outOfBandRecord) - this.outOfBandService.emitStateChangedEvent(this.agentContext, outOfBandRecord, null) - - return outOfBandRecord - } - - /** - * Creates an outbound out-of-band record in the same way how `createInvitation` method does it, - * but it also converts out-of-band invitation message to an "legacy" invitation message defined - * in RFC 0160: Connection Protocol and returns it together with out-of-band record. - * - * Agent role: sender (inviter) - * - * @param config configuration of how a connection invitation should be created - * @returns out-of-band record and connection invitation - */ - public async createLegacyInvitation(config: CreateLegacyInvitationConfig = {}) { - const outOfBandRecord = await this.createInvitation({ - ...config, - handshakeProtocols: [HandshakeProtocol.Connections], - }) - return { outOfBandRecord, invitation: convertToOldInvitation(outOfBandRecord.outOfBandInvitation) } - } - - public async createLegacyConnectionlessInvitation(config: { - recordId: string - message: Message - domain: string - }): Promise<{ message: Message; invitationUrl: string }> { - // Create keys (and optionally register them at the mediator) - const routing = await this.routingService.getRouting(this.agentContext) - - // Set the service on the message - config.message.service = new ServiceDecorator({ - serviceEndpoint: routing.endpoints[0], - recipientKeys: [routing.recipientKey].map((key) => key.publicKeyBase58), - routingKeys: routing.routingKeys.map((key) => key.publicKeyBase58), - }) - - // We need to update the message with the new service, so we can - // retrieve it from storage later on. - await this.didCommMessageRepository.saveOrUpdateAgentMessage(this.agentContext, { - agentMessage: config.message, - associatedRecordId: config.recordId, - role: DidCommMessageRole.Sender, - }) - - return { - message: config.message, - invitationUrl: `${config.domain}?d_m=${JsonEncoder.toBase64URL(JsonTransformer.toJSON(config.message))}`, - } - } - - /** - * Parses URL, decodes invitation and calls `receiveMessage` with parsed invitation message. - * - * Agent role: receiver (invitee) - * - * @param invitationUrl url containing a base64 encoded invitation to receive - * @param config configuration of how out-of-band invitation should be processed - * @returns out-of-band record and connection record if one has been created - */ - public async receiveInvitationFromUrl(invitationUrl: string, config: ReceiveOutOfBandInvitationConfig = {}) { - const message = await this.parseInvitationShortUrl(invitationUrl) - return this.receiveInvitation(message, config) - } - - /** - * Parses URL containing encoded invitation and returns invitation message. - * - * @param invitationUrl URL containing encoded invitation - * - * @returns OutOfBandInvitation - */ - public parseInvitation(invitationUrl: string): OutOfBandInvitation { - return parseInvitationUrl(invitationUrl) - } - - /** - * Parses URL containing encoded invitation and returns invitation message. Compatible with - * parsing shortened URLs - * - * @param invitationUrl URL containing encoded invitation - * - * @returns OutOfBandInvitation - */ - public async parseInvitationShortUrl(invitation: string): Promise { - return await parseInvitationShortUrl(invitation, this.agentContext.config.agentDependencies) - } - - /** - * Creates inbound out-of-band record and assigns out-of-band invitation message to it if the - * message is valid. It automatically passes out-of-band invitation for further processing to - * `acceptInvitation` method. If you don't want to do that you can set `autoAcceptInvitation` - * attribute in `config` parameter to `false` and accept the message later by calling - * `acceptInvitation`. - * - * It supports both OOB (Aries RFC 0434: Out-of-Band Protocol 1.1) and Connection Invitation - * (0160: Connection Protocol). - * - * Agent role: receiver (invitee) - * - * @param invitation either OutOfBandInvitation or ConnectionInvitationMessage - * @param config config for handling of invitation - * - * @returns out-of-band record and connection record if one has been created. - */ - public async receiveInvitation( - invitation: OutOfBandInvitation | ConnectionInvitationMessage, - config: ReceiveOutOfBandInvitationConfig = {} - ): Promise<{ outOfBandRecord: OutOfBandRecord; connectionRecord?: ConnectionRecord }> { - // Convert to out of band invitation if needed - const outOfBandInvitation = - invitation instanceof OutOfBandInvitation ? invitation : convertToNewInvitation(invitation) - - const { handshakeProtocols } = outOfBandInvitation - const { routing } = config - - const autoAcceptInvitation = config.autoAcceptInvitation ?? true - const autoAcceptConnection = config.autoAcceptConnection ?? true - const reuseConnection = config.reuseConnection ?? false - const label = config.label ?? this.agentContext.config.label - const alias = config.alias - const imageUrl = config.imageUrl ?? this.agentContext.config.connectionImageUrl - - const messages = outOfBandInvitation.getRequests() - - if ((!handshakeProtocols || handshakeProtocols.length === 0) && (!messages || messages?.length === 0)) { - throw new AriesFrameworkError( - 'One or both of handshake_protocols and requests~attach MUST be included in the message.' - ) - } - - // Make sure we haven't processed this invitation before. - let outOfBandRecord = await this.findByInvitationId(outOfBandInvitation.id) - if (outOfBandRecord) { - throw new AriesFrameworkError( - `An out of band record with invitation ${outOfBandInvitation.id} already exists. Invitations should have a unique id.` - ) - } - - outOfBandRecord = new OutOfBandRecord({ - role: OutOfBandRole.Receiver, - state: OutOfBandState.Initial, - outOfBandInvitation: outOfBandInvitation, - autoAcceptConnection, - }) - await this.outOfBandService.save(this.agentContext, outOfBandRecord) - this.outOfBandService.emitStateChangedEvent(this.agentContext, outOfBandRecord, null) - - if (autoAcceptInvitation) { - return await this.acceptInvitation(outOfBandRecord.id, { - label, - alias, - imageUrl, - autoAcceptConnection, - reuseConnection, - routing, - }) - } - - return { outOfBandRecord } - } - - /** - * Creates a connection if the out-of-band invitation message contains `handshake_protocols` - * attribute, except for the case when connection already exists and `reuseConnection` is enabled. - * - * It passes first supported message from `requests~attach` attribute to the agent, except for the - * case reuse of connection is applied when it just sends `handshake-reuse` message to existing - * connection. - * - * Agent role: receiver (invitee) - * - * @param outOfBandId - * @param config - * @returns out-of-band record and connection record if one has been created. - */ - public async acceptInvitation( - outOfBandId: string, - config: { - autoAcceptConnection?: boolean - reuseConnection?: boolean - label?: string - alias?: string - imageUrl?: string - mediatorId?: string - routing?: Routing - } - ) { - const outOfBandRecord = await this.outOfBandService.getById(this.agentContext, outOfBandId) - - const { outOfBandInvitation } = outOfBandRecord - const { label, alias, imageUrl, autoAcceptConnection, reuseConnection, routing } = config - const { handshakeProtocols, services } = outOfBandInvitation - const messages = outOfBandInvitation.getRequests() - - const existingConnection = await this.findExistingConnection(services) - - await this.outOfBandService.updateState(this.agentContext, outOfBandRecord, OutOfBandState.PrepareResponse) - - if (handshakeProtocols) { - this.logger.debug('Out of band message contains handshake protocols.') - - let connectionRecord - if (existingConnection && reuseConnection) { - this.logger.debug( - `Connection already exists and reuse is enabled. Reusing an existing connection with ID ${existingConnection.id}.` - ) - - if (!messages) { - this.logger.debug('Out of band message does not contain any request messages.') - const isHandshakeReuseSuccessful = await this.handleHandshakeReuse(outOfBandRecord, existingConnection) - - // Handshake reuse was successful - if (isHandshakeReuseSuccessful) { - this.logger.debug(`Handshake reuse successful. Reusing existing connection ${existingConnection.id}.`) - connectionRecord = existingConnection - } else { - // Handshake reuse failed. Not setting connection record - this.logger.debug(`Handshake reuse failed. Not using existing connection ${existingConnection.id}.`) - } - } else { - // Handshake reuse because we found a connection and we can respond directly to the message - this.logger.debug(`Reusing existing connection ${existingConnection.id}.`) - connectionRecord = existingConnection - } - } - - // If no existing connection was found, reuseConnection is false, or we didn't receive a - // handshake-reuse-accepted message we create a new connection - if (!connectionRecord) { - this.logger.debug('Connection does not exist or reuse is disabled. Creating a new connection.') - // Find first supported handshake protocol preserving the order of handshake protocols - // defined by `handshake_protocols` attribute in the invitation message - const handshakeProtocol = this.getFirstSupportedProtocol(handshakeProtocols) - connectionRecord = await this.connectionsModule.acceptOutOfBandInvitation(outOfBandRecord, { - label, - alias, - imageUrl, - autoAcceptConnection, - protocol: handshakeProtocol, - routing, - }) - } - - if (messages) { - this.logger.debug('Out of band message contains request messages.') - if (connectionRecord.isReady) { - await this.emitWithConnection(connectionRecord, messages) - } else { - // Wait until the connection is ready and then pass the messages to the agent for further processing - this.connectionsModule - .returnWhenIsConnected(connectionRecord.id) - .then((connectionRecord) => this.emitWithConnection(connectionRecord, messages)) - .catch((error) => { - if (error instanceof EmptyError) { - this.logger.warn( - `Agent unsubscribed before connection got into ${DidExchangeState.Completed} state`, - error - ) - } else { - this.logger.error('Promise waiting for the connection to be complete failed.', error) - } - }) - } - } - return { outOfBandRecord, connectionRecord } - } else if (messages) { - this.logger.debug('Out of band message contains only request messages.') - if (existingConnection) { - this.logger.debug('Connection already exists.', { connectionId: existingConnection.id }) - await this.emitWithConnection(existingConnection, messages) - } else { - await this.emitWithServices(services, messages) - } - } - return { outOfBandRecord } - } - - public async findByRecipientKey(recipientKey: Key) { - return this.outOfBandService.findByRecipientKey(this.agentContext, recipientKey) - } - - public async findByInvitationId(invitationId: string) { - return this.outOfBandService.findByInvitationId(this.agentContext, invitationId) - } - - /** - * Retrieve all out of bands records - * - * @returns List containing all out of band records - */ - public getAll() { - return this.outOfBandService.getAll(this.agentContext) - } - - /** - * Retrieve a out of band record by id - * - * @param outOfBandId The out of band record id - * @throws {RecordNotFoundError} If no record is found - * @return The out of band record - * - */ - public getById(outOfBandId: string): Promise { - return this.outOfBandService.getById(this.agentContext, outOfBandId) - } - - /** - * Find an out of band record by id - * - * @param outOfBandId the out of band record id - * @returns The out of band record or null if not found - */ - public findById(outOfBandId: string): Promise { - return this.outOfBandService.findById(this.agentContext, outOfBandId) - } - - /** - * Delete an out of band record by id - * - * @param outOfBandId the out of band record id - */ - public async deleteById(outOfBandId: string) { - return this.outOfBandService.deleteById(this.agentContext, outOfBandId) - } - - private assertHandshakeProtocols(handshakeProtocols: HandshakeProtocol[]) { - if (!this.areHandshakeProtocolsSupported(handshakeProtocols)) { - const supportedProtocols = this.getSupportedHandshakeProtocols() - throw new AriesFrameworkError( - `Handshake protocols [${handshakeProtocols}] are not supported. Supported protocols are [${supportedProtocols}]` - ) - } - } - - private areHandshakeProtocolsSupported(handshakeProtocols: HandshakeProtocol[]) { - const supportedProtocols = this.getSupportedHandshakeProtocols() - return handshakeProtocols.every((p) => supportedProtocols.includes(p)) - } - - private getSupportedHandshakeProtocols(): HandshakeProtocol[] { - const handshakeMessageFamilies = ['https://didcomm.org/didexchange', 'https://didcomm.org/connections'] - const handshakeProtocols = this.dispatcher.filterSupportedProtocolsByMessageFamilies(handshakeMessageFamilies) - - if (handshakeProtocols.length === 0) { - throw new AriesFrameworkError('There is no handshake protocol supported. Agent can not create a connection.') - } - - // Order protocols according to `handshakeMessageFamilies` array - const orderedProtocols = handshakeMessageFamilies - .map((messageFamily) => handshakeProtocols.find((p) => p.startsWith(messageFamily))) - .filter((item): item is string => !!item) - - return orderedProtocols as HandshakeProtocol[] - } - - private getFirstSupportedProtocol(handshakeProtocols: HandshakeProtocol[]) { - const supportedProtocols = this.getSupportedHandshakeProtocols() - const handshakeProtocol = handshakeProtocols.find((p) => supportedProtocols.includes(p)) - if (!handshakeProtocol) { - throw new AriesFrameworkError( - `Handshake protocols [${handshakeProtocols}] are not supported. Supported protocols are [${supportedProtocols}]` - ) - } - return handshakeProtocol - } - - private async findExistingConnection(services: Array) { - this.logger.debug('Searching for an existing connection for out-of-band invitation services.', { services }) - - // TODO: for each did we should look for a connection with the invitation did OR a connection with theirDid that matches the service did - for (const didOrService of services) { - // We need to check if the service is an instance of string because of limitations from class-validator - if (typeof didOrService === 'string' || didOrService instanceof String) { - // TODO await this.connectionsModule.findByTheirDid() - throw new AriesFrameworkError('Dids are not currently supported in out-of-band invitation services attribute.') - } - - const did = outOfBandServiceToNumAlgo2Did(didOrService) - const connections = await this.connectionsModule.findByInvitationDid(did) - this.logger.debug(`Retrieved ${connections.length} connections for invitation did ${did}`) - - if (connections.length === 1) { - const [firstConnection] = connections - return firstConnection - } else if (connections.length > 1) { - this.logger.warn(`There is more than one connection created from invitationDid ${did}. Taking the first one.`) - const [firstConnection] = connections - return firstConnection - } - return null - } - } - - private async emitWithConnection(connectionRecord: ConnectionRecord, messages: PlaintextMessage[]) { - const supportedMessageTypes = this.dispatcher.supportedMessageTypes - const plaintextMessage = messages.find((message) => { - const parsedMessageType = parseMessageType(message['@type']) - return supportedMessageTypes.find((type) => supportsIncomingMessageType(parsedMessageType, type)) - }) - - if (!plaintextMessage) { - throw new AriesFrameworkError('There is no message in requests~attach supported by agent.') - } - - this.logger.debug(`Message with type ${plaintextMessage['@type']} can be processed.`) - - this.eventEmitter.emit(this.agentContext, { - type: AgentEventTypes.AgentMessageReceived, - payload: { - message: plaintextMessage, - connection: connectionRecord, - contextCorrelationId: this.agentContext.contextCorrelationId, - }, - }) - } - - private async emitWithServices(services: Array, messages: PlaintextMessage[]) { - if (!services || services.length === 0) { - throw new AriesFrameworkError(`There are no services. We can not emit messages`) - } - - const supportedMessageTypes = this.dispatcher.supportedMessageTypes - const plaintextMessage = messages.find((message) => { - const parsedMessageType = parseMessageType(message['@type']) - return supportedMessageTypes.find((type) => supportsIncomingMessageType(parsedMessageType, type)) - }) - - if (!plaintextMessage) { - throw new AriesFrameworkError('There is no message in requests~attach supported by agent.') - } - - this.logger.debug(`Message with type ${plaintextMessage['@type']} can be processed.`) - - // The framework currently supports only older OOB messages with `~service` decorator. - // TODO: support receiving messages with other services so we don't have to transform the service - // to ~service decorator - const [service] = services - - if (typeof service === 'string') { - throw new AriesFrameworkError('Dids are not currently supported in out-of-band invitation services attribute.') - } - - const serviceDecorator = new ServiceDecorator({ - recipientKeys: service.recipientKeys.map(didKeyToVerkey), - routingKeys: service.routingKeys?.map(didKeyToVerkey) || [], - serviceEndpoint: service.serviceEndpoint, - }) - - plaintextMessage['~service'] = JsonTransformer.toJSON(serviceDecorator) - this.eventEmitter.emit(this.agentContext, { - type: AgentEventTypes.AgentMessageReceived, - payload: { - message: plaintextMessage, - contextCorrelationId: this.agentContext.contextCorrelationId, - }, - }) - } - - private async handleHandshakeReuse(outOfBandRecord: OutOfBandRecord, connectionRecord: ConnectionRecord) { - const reuseMessage = await this.outOfBandService.createHandShakeReuse( - this.agentContext, - outOfBandRecord, - connectionRecord - ) - - const reuseAcceptedEventPromise = firstValueFrom( - this.eventEmitter.observable(OutOfBandEventTypes.HandshakeReused).pipe( - filterContextCorrelationId(this.agentContext.contextCorrelationId), - // Find the first reuse event where the handshake reuse accepted matches the reuse message thread - // TODO: Should we store the reuse state? Maybe we can keep it in memory for now - first( - (event) => - event.payload.reuseThreadId === reuseMessage.threadId && - event.payload.outOfBandRecord.id === outOfBandRecord.id && - event.payload.connectionRecord.id === connectionRecord.id - ), - // If the event is found, we return the value true - map(() => true), - timeout(15000), - // If timeout is reached, we return false - catchError(() => of(false)) - ) - ) - - const outbound = createOutboundMessage(connectionRecord, reuseMessage) - await this.messageSender.sendMessage(this.agentContext, outbound) - - return reuseAcceptedEventPromise - } - - private registerHandlers(dispatcher: Dispatcher) { - dispatcher.registerHandler(new HandshakeReuseHandler(this.outOfBandService)) - dispatcher.registerHandler(new HandshakeReuseAcceptedHandler(this.outOfBandService)) - } +export class OutOfBandModule implements Module { /** * Registers the dependencies of the ot of band module on the dependency manager. */ - public static register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager) { // Api - dependencyManager.registerContextScoped(OutOfBandModule) + dependencyManager.registerContextScoped(OutOfBandApi) // Services dependencyManager.registerSingleton(OutOfBandService) diff --git a/packages/core/src/modules/oob/__tests__/OutOfBandModule.test.ts b/packages/core/src/modules/oob/__tests__/OutOfBandModule.test.ts new file mode 100644 index 0000000000..6613250092 --- /dev/null +++ b/packages/core/src/modules/oob/__tests__/OutOfBandModule.test.ts @@ -0,0 +1,23 @@ +import { DependencyManager } from '../../../plugins/DependencyManager' +import { OutOfBandApi } from '../OutOfBandApi' +import { OutOfBandModule } from '../OutOfBandModule' +import { OutOfBandService } from '../OutOfBandService' +import { OutOfBandRepository } from '../repository/OutOfBandRepository' + +jest.mock('../../../plugins/DependencyManager') +const DependencyManagerMock = DependencyManager as jest.Mock + +const dependencyManager = new DependencyManagerMock() + +describe('OutOfBandModule', () => { + test('registers dependencies on the dependency manager', () => { + new OutOfBandModule().register(dependencyManager) + + expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) + expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(OutOfBandApi) + + expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(2) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(OutOfBandService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(OutOfBandRepository) + }) +}) diff --git a/packages/core/src/modules/oob/index.ts b/packages/core/src/modules/oob/index.ts index 2216f42770..b0c593951b 100644 --- a/packages/core/src/modules/oob/index.ts +++ b/packages/core/src/modules/oob/index.ts @@ -1,5 +1,6 @@ export * from './messages' export * from './repository' -export * from './OutOfBandModule' +export * from './OutOfBandApi' export * from './OutOfBandService' +export * from './OutOfBandModule' export * from './domain' diff --git a/packages/core/src/modules/proofs/ProofResponseCoordinator.ts b/packages/core/src/modules/proofs/ProofResponseCoordinator.ts index 26f1d6b795..e101bd003d 100644 --- a/packages/core/src/modules/proofs/ProofResponseCoordinator.ts +++ b/packages/core/src/modules/proofs/ProofResponseCoordinator.ts @@ -4,6 +4,7 @@ import type { ProofRecord } from './repository' import { injectable } from '../../plugins' import { AutoAcceptProof } from './ProofAutoAcceptType' +import { ProofsModuleConfig } from './ProofsModuleConfig' /** * This class handles all the automation with all the messages in the present proof protocol @@ -11,6 +12,11 @@ import { AutoAcceptProof } from './ProofAutoAcceptType' */ @injectable() export class ProofResponseCoordinator { + private proofsModuleConfig: ProofsModuleConfig + + public constructor(proofsModuleConfig: ProofsModuleConfig) { + this.proofsModuleConfig = proofsModuleConfig + } /** * Returns the proof auto accept config based on priority: * - The record config takes first priority @@ -30,7 +36,7 @@ export class ProofResponseCoordinator { public shouldAutoRespondToProposal(agentContext: AgentContext, proofRecord: ProofRecord) { const autoAccept = ProofResponseCoordinator.composeAutoAccept( proofRecord.autoAcceptProof, - agentContext.config.autoAcceptProofs + this.proofsModuleConfig.autoAcceptProofs ) if (autoAccept === AutoAcceptProof.Always) { @@ -45,7 +51,7 @@ export class ProofResponseCoordinator { public shouldAutoRespondToRequest(agentContext: AgentContext, proofRecord: ProofRecord) { const autoAccept = ProofResponseCoordinator.composeAutoAccept( proofRecord.autoAcceptProof, - agentContext.config.autoAcceptProofs + this.proofsModuleConfig.autoAcceptProofs ) if ( @@ -64,7 +70,7 @@ export class ProofResponseCoordinator { public shouldAutoRespondToPresentation(agentContext: AgentContext, proofRecord: ProofRecord) { const autoAccept = ProofResponseCoordinator.composeAutoAccept( proofRecord.autoAcceptProof, - agentContext.config.autoAcceptProofs + this.proofsModuleConfig.autoAcceptProofs ) if ( diff --git a/packages/core/src/modules/proofs/ProofsApi.ts b/packages/core/src/modules/proofs/ProofsApi.ts new file mode 100644 index 0000000000..b1a7075957 --- /dev/null +++ b/packages/core/src/modules/proofs/ProofsApi.ts @@ -0,0 +1,541 @@ +import type { AutoAcceptProof } from './ProofAutoAcceptType' +import type { PresentationPreview, RequestPresentationMessage } from './messages' +import type { RequestedCredentials, RetrievedCredentials } from './models' +import type { ProofRequestOptions } from './models/ProofRequest' +import type { ProofRecord } from './repository/ProofRecord' + +import { AgentContext } from '../../agent' +import { Dispatcher } from '../../agent/Dispatcher' +import { MessageSender } from '../../agent/MessageSender' +import { createOutboundMessage } from '../../agent/helpers' +import { InjectionSymbols } from '../../constants' +import { ServiceDecorator } from '../../decorators/service/ServiceDecorator' +import { AriesFrameworkError } from '../../error' +import { Logger } from '../../logger' +import { inject, injectable } from '../../plugins' +import { ConnectionService } from '../connections/services/ConnectionService' +import { RoutingService } from '../routing/services/RoutingService' + +import { ProofResponseCoordinator } from './ProofResponseCoordinator' +import { PresentationProblemReportReason } from './errors' +import { + PresentationAckHandler, + PresentationHandler, + PresentationProblemReportHandler, + ProposePresentationHandler, + RequestPresentationHandler, +} from './handlers' +import { PresentationProblemReportMessage } from './messages/PresentationProblemReportMessage' +import { ProofRequest } from './models/ProofRequest' +import { ProofService } from './services' + +@injectable() +export class ProofsApi { + private proofService: ProofService + private connectionService: ConnectionService + private messageSender: MessageSender + private routingService: RoutingService + private agentContext: AgentContext + private proofResponseCoordinator: ProofResponseCoordinator + private logger: Logger + + public constructor( + dispatcher: Dispatcher, + proofService: ProofService, + connectionService: ConnectionService, + routingService: RoutingService, + agentContext: AgentContext, + messageSender: MessageSender, + proofResponseCoordinator: ProofResponseCoordinator, + @inject(InjectionSymbols.Logger) logger: Logger + ) { + this.proofService = proofService + this.connectionService = connectionService + this.messageSender = messageSender + this.routingService = routingService + this.agentContext = agentContext + this.proofResponseCoordinator = proofResponseCoordinator + this.logger = logger + this.registerHandlers(dispatcher) + } + + /** + * Initiate a new presentation exchange as prover by sending a presentation proposal message + * to the connection with the specified connection id. + * + * @param connectionId The connection to send the proof proposal to + * @param presentationProposal The presentation proposal to include in the message + * @param config Additional configuration to use for the proposal + * @returns Proof record associated with the sent proposal message + * + */ + public async proposeProof( + connectionId: string, + presentationProposal: PresentationPreview, + config?: { + comment?: string + autoAcceptProof?: AutoAcceptProof + parentThreadId?: string + } + ): Promise { + const connection = await this.connectionService.getById(this.agentContext, connectionId) + + const { message, proofRecord } = await this.proofService.createProposal( + this.agentContext, + connection, + presentationProposal, + config + ) + + const outbound = createOutboundMessage(connection, message) + await this.messageSender.sendMessage(this.agentContext, outbound) + + return proofRecord + } + + /** + * Accept a presentation proposal as verifier (by sending a presentation request message) to the connection + * associated with the proof record. + * + * @param proofRecordId The id of the proof record for which to accept the proposal + * @param config Additional configuration to use for the request + * @returns Proof record associated with the presentation request + * + */ + public async acceptProposal( + proofRecordId: string, + config?: { + request?: { + name?: string + version?: string + nonce?: string + } + comment?: string + } + ): Promise { + const proofRecord = await this.proofService.getById(this.agentContext, proofRecordId) + + if (!proofRecord.connectionId) { + throw new AriesFrameworkError( + `No connectionId found for credential record '${proofRecord.id}'. Connection-less issuance does not support presentation proposal or negotiation.` + ) + } + + const connection = await this.connectionService.getById(this.agentContext, proofRecord.connectionId) + + const presentationProposal = proofRecord.proposalMessage?.presentationProposal + if (!presentationProposal) { + throw new AriesFrameworkError(`Proof record with id ${proofRecordId} is missing required presentation proposal`) + } + + const proofRequest = await this.proofService.createProofRequestFromProposal( + this.agentContext, + presentationProposal, + { + name: config?.request?.name ?? 'proof-request', + version: config?.request?.version ?? '1.0', + nonce: config?.request?.nonce, + } + ) + + const { message } = await this.proofService.createRequestAsResponse(this.agentContext, proofRecord, proofRequest, { + comment: config?.comment, + }) + + const outboundMessage = createOutboundMessage(connection, message) + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + + return proofRecord + } + + /** + * Initiate a new presentation exchange as verifier by sending a presentation request message + * to the connection with the specified connection id + * + * @param connectionId The connection to send the proof request to + * @param proofRequestOptions Options to build the proof request + * @returns Proof record associated with the sent request message + * + */ + public async requestProof( + connectionId: string, + proofRequestOptions: CreateProofRequestOptions, + config?: ProofRequestConfig + ): Promise { + const connection = await this.connectionService.getById(this.agentContext, connectionId) + + const nonce = proofRequestOptions.nonce ?? (await this.proofService.generateProofRequestNonce(this.agentContext)) + + const proofRequest = new ProofRequest({ + name: proofRequestOptions.name ?? 'proof-request', + version: proofRequestOptions.name ?? '1.0', + nonce, + requestedAttributes: proofRequestOptions.requestedAttributes, + requestedPredicates: proofRequestOptions.requestedPredicates, + }) + + const { message, proofRecord } = await this.proofService.createRequest( + this.agentContext, + proofRequest, + connection, + config + ) + + const outboundMessage = createOutboundMessage(connection, message) + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + + return proofRecord + } + + /** + * Initiate a new presentation exchange as verifier by creating a presentation request + * not bound to any connection. The request must be delivered out-of-band to the holder + * + * @param proofRequestOptions Options to build the proof request + * @returns The proof record and proof request message + * + */ + public async createOutOfBandRequest( + proofRequestOptions: CreateProofRequestOptions, + config?: ProofRequestConfig + ): Promise<{ + requestMessage: RequestPresentationMessage + proofRecord: ProofRecord + }> { + const nonce = proofRequestOptions.nonce ?? (await this.proofService.generateProofRequestNonce(this.agentContext)) + + const proofRequest = new ProofRequest({ + name: proofRequestOptions.name ?? 'proof-request', + version: proofRequestOptions.name ?? '1.0', + nonce, + requestedAttributes: proofRequestOptions.requestedAttributes, + requestedPredicates: proofRequestOptions.requestedPredicates, + }) + + const { message, proofRecord } = await this.proofService.createRequest( + this.agentContext, + proofRequest, + undefined, + config + ) + + // Create and set ~service decorator + const routing = await this.routingService.getRouting(this.agentContext) + message.service = new ServiceDecorator({ + serviceEndpoint: routing.endpoints[0], + recipientKeys: [routing.recipientKey.publicKeyBase58], + routingKeys: routing.routingKeys.map((key) => key.publicKeyBase58), + }) + + // Save ~service decorator to record (to remember our verkey) + proofRecord.requestMessage = message + await this.proofService.update(this.agentContext, proofRecord) + + return { proofRecord, requestMessage: message } + } + + /** + * Accept a presentation request as prover (by sending a presentation message) to the connection + * associated with the proof record. + * + * @param proofRecordId The id of the proof record for which to accept the request + * @param requestedCredentials The requested credentials object specifying which credentials to use for the proof + * @param config Additional configuration to use for the presentation + * @returns Proof record associated with the sent presentation message + * + */ + public async acceptRequest( + proofRecordId: string, + requestedCredentials: RequestedCredentials, + config?: { + comment?: string + } + ): Promise { + const record = await this.proofService.getById(this.agentContext, proofRecordId) + const { message, proofRecord } = await this.proofService.createPresentation( + this.agentContext, + record, + requestedCredentials, + config + ) + + // Use connection if present + if (proofRecord.connectionId) { + const connection = await this.connectionService.getById(this.agentContext, proofRecord.connectionId) + + const outboundMessage = createOutboundMessage(connection, message) + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + + return proofRecord + } + // Use ~service decorator otherwise + else if (proofRecord.requestMessage?.service) { + // Create ~service decorator + const routing = await this.routingService.getRouting(this.agentContext) + const ourService = new ServiceDecorator({ + serviceEndpoint: routing.endpoints[0], + recipientKeys: [routing.recipientKey.publicKeyBase58], + routingKeys: routing.routingKeys.map((key) => key.publicKeyBase58), + }) + + const recipientService = proofRecord.requestMessage.service + + // Set and save ~service decorator to record (to remember our verkey) + message.service = ourService + proofRecord.presentationMessage = message + await this.proofService.update(this.agentContext, proofRecord) + + await this.messageSender.sendMessageToService(this.agentContext, { + message, + service: recipientService.resolvedDidCommService, + senderKey: ourService.resolvedDidCommService.recipientKeys[0], + returnRoute: true, + }) + + return proofRecord + } + // Cannot send message without connectionId or ~service decorator + else { + throw new AriesFrameworkError( + `Cannot accept presentation request without connectionId or ~service decorator on presentation request.` + ) + } + } + + /** + * Declines a proof request as holder + * @param proofRecordId the id of the proof request to be declined + * @returns proof record that was declined + */ + public async declineRequest(proofRecordId: string) { + const proofRecord = await this.proofService.getById(this.agentContext, proofRecordId) + await this.proofService.declineRequest(this.agentContext, proofRecord) + return proofRecord + } + + /** + * Accept a presentation as prover (by sending a presentation acknowledgement message) to the connection + * associated with the proof record. + * + * @param proofRecordId The id of the proof record for which to accept the presentation + * @returns Proof record associated with the sent presentation acknowledgement message + * + */ + public async acceptPresentation(proofRecordId: string): Promise { + const record = await this.proofService.getById(this.agentContext, proofRecordId) + const { message, proofRecord } = await this.proofService.createAck(this.agentContext, record) + + // Use connection if present + if (proofRecord.connectionId) { + const connection = await this.connectionService.getById(this.agentContext, proofRecord.connectionId) + const outboundMessage = createOutboundMessage(connection, message) + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + } + // Use ~service decorator otherwise + else if (proofRecord.requestMessage?.service && proofRecord.presentationMessage?.service) { + const recipientService = proofRecord.presentationMessage?.service + const ourService = proofRecord.requestMessage.service + + await this.messageSender.sendMessageToService(this.agentContext, { + message, + service: recipientService.resolvedDidCommService, + senderKey: ourService.resolvedDidCommService.recipientKeys[0], + returnRoute: true, + }) + } + + // Cannot send message without credentialId or ~service decorator + else { + throw new AriesFrameworkError( + `Cannot accept presentation without connectionId or ~service decorator on presentation message.` + ) + } + + return proofRecord + } + + /** + * Create a {@link RetrievedCredentials} object. Given input proof request and presentation proposal, + * use credentials in the wallet to build indy requested credentials object for input to proof creation. + * If restrictions allow, self attested attributes will be used. + * + * + * @param proofRecordId the id of the proof request to get the matching credentials for + * @param config optional configuration for credential selection process. Use `filterByPresentationPreview` (default `true`) to only include + * credentials that match the presentation preview from the presentation proposal (if available). + + * @returns RetrievedCredentials object + */ + public async getRequestedCredentialsForProofRequest( + proofRecordId: string, + config?: GetRequestedCredentialsConfig + ): Promise { + const proofRecord = await this.proofService.getById(this.agentContext, proofRecordId) + + const indyProofRequest = proofRecord.requestMessage?.indyProofRequest + const presentationPreview = config?.filterByPresentationPreview + ? proofRecord.proposalMessage?.presentationProposal + : undefined + + if (!indyProofRequest) { + throw new AriesFrameworkError( + 'Unable to get requested credentials for proof request. No proof request message was found or the proof request message does not contain an indy proof request.' + ) + } + + return this.proofService.getRequestedCredentialsForProofRequest(this.agentContext, indyProofRequest, { + presentationProposal: presentationPreview, + filterByNonRevocationRequirements: config?.filterByNonRevocationRequirements ?? true, + }) + } + + /** + * Takes a RetrievedCredentials object and auto selects credentials in a RequestedCredentials object + * + * Use the return value of this method as input to {@link ProofService.createPresentation} to + * automatically accept a received presentation request. + * + * @param retrievedCredentials The retrieved credentials object to get credentials from + * + * @returns RequestedCredentials + */ + public autoSelectCredentialsForProofRequest(retrievedCredentials: RetrievedCredentials): RequestedCredentials { + return this.proofService.autoSelectCredentialsForProofRequest(retrievedCredentials) + } + + /** + * Send problem report message for a proof record + * @param proofRecordId The id of the proof record for which to send problem report + * @param message message to send + * @returns proof record associated with the proof problem report message + */ + public async sendProblemReport(proofRecordId: string, message: string) { + const record = await this.proofService.getById(this.agentContext, proofRecordId) + if (!record.connectionId) { + throw new AriesFrameworkError(`No connectionId found for proof record '${record.id}'.`) + } + const connection = await this.connectionService.getById(this.agentContext, record.connectionId) + const presentationProblemReportMessage = new PresentationProblemReportMessage({ + description: { + en: message, + code: PresentationProblemReportReason.Abandoned, + }, + }) + presentationProblemReportMessage.setThread({ + threadId: record.threadId, + parentThreadId: record.parentThreadId, + }) + const outboundMessage = createOutboundMessage(connection, presentationProblemReportMessage) + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + + return record + } + + /** + * Retrieve all proof records + * + * @returns List containing all proof records + */ + public getAll(): Promise { + return this.proofService.getAll(this.agentContext) + } + + /** + * Retrieve a proof record by id + * + * @param proofRecordId The proof record id + * @throws {RecordNotFoundError} If no record is found + * @throws {RecordDuplicateError} If multiple records are found + * @return The proof record + * + */ + public async getById(proofRecordId: string): Promise { + return this.proofService.getById(this.agentContext, proofRecordId) + } + + /** + * Retrieve a proof record by id + * + * @param proofRecordId The proof record id + * @return The proof record or null if not found + * + */ + public async findById(proofRecordId: string): Promise { + return this.proofService.findById(this.agentContext, proofRecordId) + } + + /** + * Delete a proof record by id + * + * @param proofId the proof record id + */ + public async deleteById(proofId: string) { + return this.proofService.deleteById(this.agentContext, proofId) + } + + /** + * Retrieve a proof record by connection id and thread id + * + * @param connectionId The connection id + * @param threadId The thread id + * @throws {RecordNotFoundError} If no record is found + * @throws {RecordDuplicateError} If multiple records are found + * @returns The proof record + */ + public async getByThreadAndConnectionId(threadId: string, connectionId?: string): Promise { + return this.proofService.getByThreadAndConnectionId(this.agentContext, threadId, connectionId) + } + + /** + * Retrieve proof records by connection id and parent thread id + * + * @param connectionId The connection id + * @param parentThreadId The parent thread id + * @returns List containing all proof records matching the given query + */ + public async getByParentThreadAndConnectionId(parentThreadId: string, connectionId?: string): Promise { + return this.proofService.getByParentThreadAndConnectionId(this.agentContext, parentThreadId, connectionId) + } + + private registerHandlers(dispatcher: Dispatcher) { + dispatcher.registerHandler( + new ProposePresentationHandler(this.proofService, this.proofResponseCoordinator, this.logger) + ) + dispatcher.registerHandler( + new RequestPresentationHandler(this.proofService, this.proofResponseCoordinator, this.routingService, this.logger) + ) + dispatcher.registerHandler(new PresentationHandler(this.proofService, this.proofResponseCoordinator, this.logger)) + dispatcher.registerHandler(new PresentationAckHandler(this.proofService)) + dispatcher.registerHandler(new PresentationProblemReportHandler(this.proofService)) + } +} + +export type CreateProofRequestOptions = Partial< + Pick +> + +export interface ProofRequestConfig { + comment?: string + autoAcceptProof?: AutoAcceptProof + parentThreadId?: string +} + +export interface GetRequestedCredentialsConfig { + /** + * Whether to filter the retrieved credentials using the presentation preview. + * This configuration will only have effect if a presentation proposal message is available + * containing a presentation preview. + * + * @default false + */ + filterByPresentationPreview?: boolean + + /** + * Whether to filter the retrieved credentials using the non-revocation request in the proof request. + * This configuration will only have effect if the proof request requires proof on non-revocation of any kind. + * Default to true + * + * @default true + */ + filterByNonRevocationRequirements?: boolean +} diff --git a/packages/core/src/modules/proofs/ProofsModule.ts b/packages/core/src/modules/proofs/ProofsModule.ts index 9594c44108..829db07281 100644 --- a/packages/core/src/modules/proofs/ProofsModule.ts +++ b/packages/core/src/modules/proofs/ProofsModule.ts @@ -1,523 +1,27 @@ -import type { DependencyManager } from '../../plugins' -import type { AutoAcceptProof } from './ProofAutoAcceptType' -import type { PresentationPreview, RequestPresentationMessage } from './messages' -import type { RequestedCredentials, RetrievedCredentials } from './models' -import type { ProofRequestOptions } from './models/ProofRequest' -import type { ProofRecord } from './repository/ProofRecord' +import type { DependencyManager, Module } from '../../plugins' +import type { ProofsModuleConfigOptions } from './ProofsModuleConfig' -import { AgentContext } from '../../agent' -import { Dispatcher } from '../../agent/Dispatcher' -import { MessageSender } from '../../agent/MessageSender' -import { createOutboundMessage } from '../../agent/helpers' -import { InjectionSymbols } from '../../constants' -import { ServiceDecorator } from '../../decorators/service/ServiceDecorator' -import { AriesFrameworkError } from '../../error' -import { Logger } from '../../logger' -import { inject, injectable, module } from '../../plugins' -import { ConnectionService } from '../connections/services/ConnectionService' -import { RoutingService } from '../routing/services/RoutingService' - -import { ProofResponseCoordinator } from './ProofResponseCoordinator' -import { PresentationProblemReportReason } from './errors' -import { - PresentationAckHandler, - PresentationHandler, - PresentationProblemReportHandler, - ProposePresentationHandler, - RequestPresentationHandler, -} from './handlers' -import { PresentationProblemReportMessage } from './messages/PresentationProblemReportMessage' -import { ProofRequest } from './models/ProofRequest' +import { ProofsApi } from './ProofsApi' +import { ProofsModuleConfig } from './ProofsModuleConfig' import { ProofRepository } from './repository' import { ProofService } from './services' -@module() -@injectable() -export class ProofsModule { - private proofService: ProofService - private connectionService: ConnectionService - private messageSender: MessageSender - private routingService: RoutingService - private agentContext: AgentContext - private proofResponseCoordinator: ProofResponseCoordinator - private logger: Logger - - public constructor( - dispatcher: Dispatcher, - proofService: ProofService, - connectionService: ConnectionService, - routingService: RoutingService, - agentContext: AgentContext, - messageSender: MessageSender, - proofResponseCoordinator: ProofResponseCoordinator, - @inject(InjectionSymbols.Logger) logger: Logger - ) { - this.proofService = proofService - this.connectionService = connectionService - this.messageSender = messageSender - this.routingService = routingService - this.agentContext = agentContext - this.proofResponseCoordinator = proofResponseCoordinator - this.logger = logger - this.registerHandlers(dispatcher) - } - - /** - * Initiate a new presentation exchange as prover by sending a presentation proposal message - * to the connection with the specified connection id. - * - * @param connectionId The connection to send the proof proposal to - * @param presentationProposal The presentation proposal to include in the message - * @param config Additional configuration to use for the proposal - * @returns Proof record associated with the sent proposal message - * - */ - public async proposeProof( - connectionId: string, - presentationProposal: PresentationPreview, - config?: { - comment?: string - autoAcceptProof?: AutoAcceptProof - parentThreadId?: string - } - ): Promise { - const connection = await this.connectionService.getById(this.agentContext, connectionId) - - const { message, proofRecord } = await this.proofService.createProposal( - this.agentContext, - connection, - presentationProposal, - config - ) - - const outbound = createOutboundMessage(connection, message) - await this.messageSender.sendMessage(this.agentContext, outbound) - - return proofRecord - } - - /** - * Accept a presentation proposal as verifier (by sending a presentation request message) to the connection - * associated with the proof record. - * - * @param proofRecordId The id of the proof record for which to accept the proposal - * @param config Additional configuration to use for the request - * @returns Proof record associated with the presentation request - * - */ - public async acceptProposal( - proofRecordId: string, - config?: { - request?: { - name?: string - version?: string - nonce?: string - } - comment?: string - } - ): Promise { - const proofRecord = await this.proofService.getById(this.agentContext, proofRecordId) - - if (!proofRecord.connectionId) { - throw new AriesFrameworkError( - `No connectionId found for credential record '${proofRecord.id}'. Connection-less issuance does not support presentation proposal or negotiation.` - ) - } - - const connection = await this.connectionService.getById(this.agentContext, proofRecord.connectionId) - - const presentationProposal = proofRecord.proposalMessage?.presentationProposal - if (!presentationProposal) { - throw new AriesFrameworkError(`Proof record with id ${proofRecordId} is missing required presentation proposal`) - } - - const proofRequest = await this.proofService.createProofRequestFromProposal( - this.agentContext, - presentationProposal, - { - name: config?.request?.name ?? 'proof-request', - version: config?.request?.version ?? '1.0', - nonce: config?.request?.nonce, - } - ) - - const { message } = await this.proofService.createRequestAsResponse(this.agentContext, proofRecord, proofRequest, { - comment: config?.comment, - }) - - const outboundMessage = createOutboundMessage(connection, message) - await this.messageSender.sendMessage(this.agentContext, outboundMessage) - - return proofRecord - } - - /** - * Initiate a new presentation exchange as verifier by sending a presentation request message - * to the connection with the specified connection id - * - * @param connectionId The connection to send the proof request to - * @param proofRequestOptions Options to build the proof request - * @returns Proof record associated with the sent request message - * - */ - public async requestProof( - connectionId: string, - proofRequestOptions: CreateProofRequestOptions, - config?: ProofRequestConfig - ): Promise { - const connection = await this.connectionService.getById(this.agentContext, connectionId) - - const nonce = proofRequestOptions.nonce ?? (await this.proofService.generateProofRequestNonce(this.agentContext)) - - const proofRequest = new ProofRequest({ - name: proofRequestOptions.name ?? 'proof-request', - version: proofRequestOptions.name ?? '1.0', - nonce, - requestedAttributes: proofRequestOptions.requestedAttributes, - requestedPredicates: proofRequestOptions.requestedPredicates, - }) - - const { message, proofRecord } = await this.proofService.createRequest( - this.agentContext, - proofRequest, - connection, - config - ) - - const outboundMessage = createOutboundMessage(connection, message) - await this.messageSender.sendMessage(this.agentContext, outboundMessage) - - return proofRecord - } - - /** - * Initiate a new presentation exchange as verifier by creating a presentation request - * not bound to any connection. The request must be delivered out-of-band to the holder - * - * @param proofRequestOptions Options to build the proof request - * @returns The proof record and proof request message - * - */ - public async createOutOfBandRequest( - proofRequestOptions: CreateProofRequestOptions, - config?: ProofRequestConfig - ): Promise<{ - requestMessage: RequestPresentationMessage - proofRecord: ProofRecord - }> { - const nonce = proofRequestOptions.nonce ?? (await this.proofService.generateProofRequestNonce(this.agentContext)) - - const proofRequest = new ProofRequest({ - name: proofRequestOptions.name ?? 'proof-request', - version: proofRequestOptions.name ?? '1.0', - nonce, - requestedAttributes: proofRequestOptions.requestedAttributes, - requestedPredicates: proofRequestOptions.requestedPredicates, - }) - - const { message, proofRecord } = await this.proofService.createRequest( - this.agentContext, - proofRequest, - undefined, - config - ) - - // Create and set ~service decorator - const routing = await this.routingService.getRouting(this.agentContext) - message.service = new ServiceDecorator({ - serviceEndpoint: routing.endpoints[0], - recipientKeys: [routing.recipientKey.publicKeyBase58], - routingKeys: routing.routingKeys.map((key) => key.publicKeyBase58), - }) - - // Save ~service decorator to record (to remember our verkey) - proofRecord.requestMessage = message - await this.proofService.update(this.agentContext, proofRecord) - - return { proofRecord, requestMessage: message } - } - - /** - * Accept a presentation request as prover (by sending a presentation message) to the connection - * associated with the proof record. - * - * @param proofRecordId The id of the proof record for which to accept the request - * @param requestedCredentials The requested credentials object specifying which credentials to use for the proof - * @param config Additional configuration to use for the presentation - * @returns Proof record associated with the sent presentation message - * - */ - public async acceptRequest( - proofRecordId: string, - requestedCredentials: RequestedCredentials, - config?: { - comment?: string - } - ): Promise { - const record = await this.proofService.getById(this.agentContext, proofRecordId) - const { message, proofRecord } = await this.proofService.createPresentation( - this.agentContext, - record, - requestedCredentials, - config - ) - - // Use connection if present - if (proofRecord.connectionId) { - const connection = await this.connectionService.getById(this.agentContext, proofRecord.connectionId) - - const outboundMessage = createOutboundMessage(connection, message) - await this.messageSender.sendMessage(this.agentContext, outboundMessage) - - return proofRecord - } - // Use ~service decorator otherwise - else if (proofRecord.requestMessage?.service) { - // Create ~service decorator - const routing = await this.routingService.getRouting(this.agentContext) - const ourService = new ServiceDecorator({ - serviceEndpoint: routing.endpoints[0], - recipientKeys: [routing.recipientKey.publicKeyBase58], - routingKeys: routing.routingKeys.map((key) => key.publicKeyBase58), - }) - - const recipientService = proofRecord.requestMessage.service - - // Set and save ~service decorator to record (to remember our verkey) - message.service = ourService - proofRecord.presentationMessage = message - await this.proofService.update(this.agentContext, proofRecord) - - await this.messageSender.sendMessageToService(this.agentContext, { - message, - service: recipientService.resolvedDidCommService, - senderKey: ourService.resolvedDidCommService.recipientKeys[0], - returnRoute: true, - }) - - return proofRecord - } - // Cannot send message without connectionId or ~service decorator - else { - throw new AriesFrameworkError( - `Cannot accept presentation request without connectionId or ~service decorator on presentation request.` - ) - } - } - - /** - * Declines a proof request as holder - * @param proofRecordId the id of the proof request to be declined - * @returns proof record that was declined - */ - public async declineRequest(proofRecordId: string) { - const proofRecord = await this.proofService.getById(this.agentContext, proofRecordId) - await this.proofService.declineRequest(this.agentContext, proofRecord) - return proofRecord - } - - /** - * Accept a presentation as prover (by sending a presentation acknowledgement message) to the connection - * associated with the proof record. - * - * @param proofRecordId The id of the proof record for which to accept the presentation - * @returns Proof record associated with the sent presentation acknowledgement message - * - */ - public async acceptPresentation(proofRecordId: string): Promise { - const record = await this.proofService.getById(this.agentContext, proofRecordId) - const { message, proofRecord } = await this.proofService.createAck(this.agentContext, record) - - // Use connection if present - if (proofRecord.connectionId) { - const connection = await this.connectionService.getById(this.agentContext, proofRecord.connectionId) - const outboundMessage = createOutboundMessage(connection, message) - await this.messageSender.sendMessage(this.agentContext, outboundMessage) - } - // Use ~service decorator otherwise - else if (proofRecord.requestMessage?.service && proofRecord.presentationMessage?.service) { - const recipientService = proofRecord.presentationMessage?.service - const ourService = proofRecord.requestMessage.service - - await this.messageSender.sendMessageToService(this.agentContext, { - message, - service: recipientService.resolvedDidCommService, - senderKey: ourService.resolvedDidCommService.recipientKeys[0], - returnRoute: true, - }) - } - - // Cannot send message without credentialId or ~service decorator - else { - throw new AriesFrameworkError( - `Cannot accept presentation without connectionId or ~service decorator on presentation message.` - ) - } - - return proofRecord - } - - /** - * Create a {@link RetrievedCredentials} object. Given input proof request and presentation proposal, - * use credentials in the wallet to build indy requested credentials object for input to proof creation. - * If restrictions allow, self attested attributes will be used. - * - * - * @param proofRecordId the id of the proof request to get the matching credentials for - * @param config optional configuration for credential selection process. Use `filterByPresentationPreview` (default `true`) to only include - * credentials that match the presentation preview from the presentation proposal (if available). +export class ProofsModule implements Module { + public readonly config: ProofsModuleConfig - * @returns RetrievedCredentials object - */ - public async getRequestedCredentialsForProofRequest( - proofRecordId: string, - config?: GetRequestedCredentialsConfig - ): Promise { - const proofRecord = await this.proofService.getById(this.agentContext, proofRecordId) - - const indyProofRequest = proofRecord.requestMessage?.indyProofRequest - const presentationPreview = config?.filterByPresentationPreview - ? proofRecord.proposalMessage?.presentationProposal - : undefined - - if (!indyProofRequest) { - throw new AriesFrameworkError( - 'Unable to get requested credentials for proof request. No proof request message was found or the proof request message does not contain an indy proof request.' - ) - } - - return this.proofService.getRequestedCredentialsForProofRequest(this.agentContext, indyProofRequest, { - presentationProposal: presentationPreview, - filterByNonRevocationRequirements: config?.filterByNonRevocationRequirements ?? true, - }) - } - - /** - * Takes a RetrievedCredentials object and auto selects credentials in a RequestedCredentials object - * - * Use the return value of this method as input to {@link ProofService.createPresentation} to - * automatically accept a received presentation request. - * - * @param retrievedCredentials The retrieved credentials object to get credentials from - * - * @returns RequestedCredentials - */ - public autoSelectCredentialsForProofRequest(retrievedCredentials: RetrievedCredentials): RequestedCredentials { - return this.proofService.autoSelectCredentialsForProofRequest(retrievedCredentials) - } - - /** - * Send problem report message for a proof record - * @param proofRecordId The id of the proof record for which to send problem report - * @param message message to send - * @returns proof record associated with the proof problem report message - */ - public async sendProblemReport(proofRecordId: string, message: string) { - const record = await this.proofService.getById(this.agentContext, proofRecordId) - if (!record.connectionId) { - throw new AriesFrameworkError(`No connectionId found for proof record '${record.id}'.`) - } - const connection = await this.connectionService.getById(this.agentContext, record.connectionId) - const presentationProblemReportMessage = new PresentationProblemReportMessage({ - description: { - en: message, - code: PresentationProblemReportReason.Abandoned, - }, - }) - presentationProblemReportMessage.setThread({ - threadId: record.threadId, - parentThreadId: record.parentThreadId, - }) - const outboundMessage = createOutboundMessage(connection, presentationProblemReportMessage) - await this.messageSender.sendMessage(this.agentContext, outboundMessage) - - return record - } - - /** - * Retrieve all proof records - * - * @returns List containing all proof records - */ - public getAll(): Promise { - return this.proofService.getAll(this.agentContext) - } - - /** - * Retrieve a proof record by id - * - * @param proofRecordId The proof record id - * @throws {RecordNotFoundError} If no record is found - * @throws {RecordDuplicateError} If multiple records are found - * @return The proof record - * - */ - public async getById(proofRecordId: string): Promise { - return this.proofService.getById(this.agentContext, proofRecordId) - } - - /** - * Retrieve a proof record by id - * - * @param proofRecordId The proof record id - * @return The proof record or null if not found - * - */ - public async findById(proofRecordId: string): Promise { - return this.proofService.findById(this.agentContext, proofRecordId) - } - - /** - * Delete a proof record by id - * - * @param proofId the proof record id - */ - public async deleteById(proofId: string) { - return this.proofService.deleteById(this.agentContext, proofId) - } - - /** - * Retrieve a proof record by connection id and thread id - * - * @param connectionId The connection id - * @param threadId The thread id - * @throws {RecordNotFoundError} If no record is found - * @throws {RecordDuplicateError} If multiple records are found - * @returns The proof record - */ - public async getByThreadAndConnectionId(threadId: string, connectionId?: string): Promise { - return this.proofService.getByThreadAndConnectionId(this.agentContext, threadId, connectionId) - } - - /** - * Retrieve proof records by connection id and parent thread id - * - * @param connectionId The connection id - * @param parentThreadId The parent thread id - * @returns List containing all proof records matching the given query - */ - public async getByParentThreadAndConnectionId(parentThreadId: string, connectionId?: string): Promise { - return this.proofService.getByParentThreadAndConnectionId(this.agentContext, parentThreadId, connectionId) - } - - private registerHandlers(dispatcher: Dispatcher) { - dispatcher.registerHandler( - new ProposePresentationHandler(this.proofService, this.proofResponseCoordinator, this.logger) - ) - dispatcher.registerHandler( - new RequestPresentationHandler(this.proofService, this.proofResponseCoordinator, this.routingService, this.logger) - ) - dispatcher.registerHandler(new PresentationHandler(this.proofService, this.proofResponseCoordinator, this.logger)) - dispatcher.registerHandler(new PresentationAckHandler(this.proofService)) - dispatcher.registerHandler(new PresentationProblemReportHandler(this.proofService)) + public constructor(config?: ProofsModuleConfigOptions) { + this.config = new ProofsModuleConfig(config) } /** * Registers the dependencies of the proofs module on the dependency manager. */ - public static register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager) { // Api - dependencyManager.registerContextScoped(ProofsModule) + dependencyManager.registerContextScoped(ProofsApi) + + // Config + dependencyManager.registerInstance(ProofsModuleConfig, this.config) // Services dependencyManager.registerSingleton(ProofService) @@ -526,33 +30,3 @@ export class ProofsModule { dependencyManager.registerSingleton(ProofRepository) } } - -export type CreateProofRequestOptions = Partial< - Pick -> - -export interface ProofRequestConfig { - comment?: string - autoAcceptProof?: AutoAcceptProof - parentThreadId?: string -} - -export interface GetRequestedCredentialsConfig { - /** - * Whether to filter the retrieved credentials using the presentation preview. - * This configuration will only have effect if a presentation proposal message is available - * containing a presentation preview. - * - * @default false - */ - filterByPresentationPreview?: boolean - - /** - * Whether to filter the retrieved credentials using the non-revocation request in the proof request. - * This configuration will only have effect if the proof request requires proof on non-revocation of any kind. - * Default to true - * - * @default true - */ - filterByNonRevocationRequirements?: boolean -} diff --git a/packages/core/src/modules/proofs/ProofsModuleConfig.ts b/packages/core/src/modules/proofs/ProofsModuleConfig.ts new file mode 100644 index 0000000000..88fd470c0b --- /dev/null +++ b/packages/core/src/modules/proofs/ProofsModuleConfig.ts @@ -0,0 +1,27 @@ +import { AutoAcceptProof } from './ProofAutoAcceptType' + +/** + * ProofsModuleConfigOptions defines the interface for the options of the ProofsModuleConfig class. + * This can contain optional parameters that have default values in the config class itself. + */ +export interface ProofsModuleConfigOptions { + /** + * Whether to automatically accept proof messages. Applies to all present proof protocol versions. + * + * @default {@link AutoAcceptProof.Never} + */ + autoAcceptProofs?: AutoAcceptProof +} + +export class ProofsModuleConfig { + private options: ProofsModuleConfigOptions + + public constructor(options?: ProofsModuleConfigOptions) { + this.options = options ?? {} + } + + /** See {@link ProofsModuleConfigOptions.autoAcceptProofs} */ + public get autoAcceptProofs() { + return this.options.autoAcceptProofs ?? AutoAcceptProof.Never + } +} diff --git a/packages/core/src/modules/proofs/__tests__/ProofsModule.test.ts b/packages/core/src/modules/proofs/__tests__/ProofsModule.test.ts new file mode 100644 index 0000000000..98ab74e7bc --- /dev/null +++ b/packages/core/src/modules/proofs/__tests__/ProofsModule.test.ts @@ -0,0 +1,23 @@ +import { DependencyManager } from '../../../plugins/DependencyManager' +import { ProofsApi } from '../ProofsApi' +import { ProofsModule } from '../ProofsModule' +import { ProofRepository } from '../repository' +import { ProofService } from '../services' + +jest.mock('../../../plugins/DependencyManager') +const DependencyManagerMock = DependencyManager as jest.Mock + +const dependencyManager = new DependencyManagerMock() + +describe('ProofsModule', () => { + test('registers dependencies on the dependency manager', () => { + new ProofsModule().register(dependencyManager) + + expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) + expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(ProofsApi) + + expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(2) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(ProofService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(ProofRepository) + }) +}) diff --git a/packages/core/src/modules/proofs/handlers/PresentationHandler.ts b/packages/core/src/modules/proofs/handlers/PresentationHandler.ts index 991a3a550d..8f651a9562 100644 --- a/packages/core/src/modules/proofs/handlers/PresentationHandler.ts +++ b/packages/core/src/modules/proofs/handlers/PresentationHandler.ts @@ -28,9 +28,7 @@ export class PresentationHandler implements Handler { } private async createAck(record: ProofRecord, messageContext: HandlerInboundMessage) { - this.logger.info( - `Automatically sending acknowledgement with autoAccept on ${messageContext.agentContext.config.autoAcceptProofs}` - ) + this.logger.info(`Automatically sending acknowledgement with autoAccept`) const { message, proofRecord } = await this.proofService.createAck(messageContext.agentContext, record) diff --git a/packages/core/src/modules/proofs/handlers/ProposePresentationHandler.ts b/packages/core/src/modules/proofs/handlers/ProposePresentationHandler.ts index 6ab1879fdb..3e07ebfc60 100644 --- a/packages/core/src/modules/proofs/handlers/ProposePresentationHandler.ts +++ b/packages/core/src/modules/proofs/handlers/ProposePresentationHandler.ts @@ -31,9 +31,7 @@ export class ProposePresentationHandler implements Handler { proofRecord: ProofRecord, messageContext: HandlerInboundMessage ) { - this.logger.info( - `Automatically sending request with autoAccept on ${messageContext.agentContext.config.autoAcceptProofs}` - ) + this.logger.info(`Automatically sending request with autoAccept`) if (!messageContext.connection) { this.logger.error('No connection on the messageContext') diff --git a/packages/core/src/modules/proofs/handlers/RequestPresentationHandler.ts b/packages/core/src/modules/proofs/handlers/RequestPresentationHandler.ts index 87b1445d94..e2839783c8 100644 --- a/packages/core/src/modules/proofs/handlers/RequestPresentationHandler.ts +++ b/packages/core/src/modules/proofs/handlers/RequestPresentationHandler.ts @@ -43,9 +43,7 @@ export class RequestPresentationHandler implements Handler { const indyProofRequest = record.requestMessage?.indyProofRequest const presentationProposal = record.proposalMessage?.presentationProposal - this.logger.info( - `Automatically sending presentation with autoAccept on ${messageContext.agentContext.config.autoAcceptProofs}` - ) + this.logger.info(`Automatically sending presentation with autoAccept`) if (!indyProofRequest) { this.logger.error('Proof request is undefined.') diff --git a/packages/core/src/modules/proofs/index.ts b/packages/core/src/modules/proofs/index.ts index a4e5d95714..44efac8eba 100644 --- a/packages/core/src/modules/proofs/index.ts +++ b/packages/core/src/modules/proofs/index.ts @@ -4,5 +4,6 @@ export * from './services' export * from './ProofState' export * from './repository' export * from './ProofEvents' -export * from './ProofsModule' +export * from './ProofsApi' export * from './ProofAutoAcceptType' +export * from './ProofsModule' diff --git a/packages/core/src/modules/question-answer/QuestionAnswerApi.ts b/packages/core/src/modules/question-answer/QuestionAnswerApi.ts new file mode 100644 index 0000000000..3b19628fb9 --- /dev/null +++ b/packages/core/src/modules/question-answer/QuestionAnswerApi.ts @@ -0,0 +1,105 @@ +import type { ValidResponse } from './models' + +import { AgentContext } from '../../agent' +import { Dispatcher } from '../../agent/Dispatcher' +import { MessageSender } from '../../agent/MessageSender' +import { createOutboundMessage } from '../../agent/helpers' +import { injectable } from '../../plugins' +import { ConnectionService } from '../connections' + +import { AnswerMessageHandler, QuestionMessageHandler } from './handlers' +import { QuestionAnswerService } from './services' + +@injectable() +export class QuestionAnswerApi { + private questionAnswerService: QuestionAnswerService + private messageSender: MessageSender + private connectionService: ConnectionService + private agentContext: AgentContext + + public constructor( + dispatcher: Dispatcher, + questionAnswerService: QuestionAnswerService, + messageSender: MessageSender, + connectionService: ConnectionService, + agentContext: AgentContext + ) { + this.questionAnswerService = questionAnswerService + this.messageSender = messageSender + this.connectionService = connectionService + this.agentContext = agentContext + this.registerHandlers(dispatcher) + } + + /** + * Create a question message with possible valid responses, then send message to the + * holder + * + * @param connectionId connection to send the question message to + * @param config config for creating question message + * @returns QuestionAnswer record + */ + public async sendQuestion( + connectionId: string, + config: { + question: string + validResponses: ValidResponse[] + detail?: string + } + ) { + const connection = await this.connectionService.getById(this.agentContext, connectionId) + connection.assertReady() + + const { questionMessage, questionAnswerRecord } = await this.questionAnswerService.createQuestion( + this.agentContext, + connectionId, + { + question: config.question, + validResponses: config.validResponses, + detail: config?.detail, + } + ) + const outboundMessage = createOutboundMessage(connection, questionMessage) + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + + return questionAnswerRecord + } + + /** + * Create an answer message as the holder and send it in response to a question message + * + * @param questionRecordId the id of the questionAnswer record + * @param response response included in the answer message + * @returns QuestionAnswer record + */ + public async sendAnswer(questionRecordId: string, response: string) { + const questionRecord = await this.questionAnswerService.getById(this.agentContext, questionRecordId) + + const { answerMessage, questionAnswerRecord } = await this.questionAnswerService.createAnswer( + this.agentContext, + questionRecord, + response + ) + + const connection = await this.connectionService.getById(this.agentContext, questionRecord.connectionId) + + const outboundMessage = createOutboundMessage(connection, answerMessage) + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + + return questionAnswerRecord + } + + /** + * Get all QuestionAnswer records + * + * @returns list containing all QuestionAnswer records + */ + public getAll() { + return this.questionAnswerService.getAll(this.agentContext) + } + + private registerHandlers(dispatcher: Dispatcher) { + dispatcher.registerHandler(new QuestionMessageHandler(this.questionAnswerService)) + dispatcher.registerHandler(new AnswerMessageHandler(this.questionAnswerService)) + } +} diff --git a/packages/core/src/modules/question-answer/QuestionAnswerModule.ts b/packages/core/src/modules/question-answer/QuestionAnswerModule.ts index bc17fe8ada..9fcea50803 100644 --- a/packages/core/src/modules/question-answer/QuestionAnswerModule.ts +++ b/packages/core/src/modules/question-answer/QuestionAnswerModule.ts @@ -1,117 +1,16 @@ -import type { DependencyManager } from '../../plugins' -import type { ValidResponse } from './models' +import type { DependencyManager, Module } from '../../plugins' -import { AgentContext } from '../../agent' -import { Dispatcher } from '../../agent/Dispatcher' -import { MessageSender } from '../../agent/MessageSender' -import { createOutboundMessage } from '../../agent/helpers' -import { injectable, module } from '../../plugins' -import { ConnectionService } from '../connections' - -import { AnswerMessageHandler, QuestionMessageHandler } from './handlers' +import { QuestionAnswerApi } from './QuestionAnswerApi' import { QuestionAnswerRepository } from './repository' import { QuestionAnswerService } from './services' -@module() -@injectable() -export class QuestionAnswerModule { - private questionAnswerService: QuestionAnswerService - private messageSender: MessageSender - private connectionService: ConnectionService - private agentContext: AgentContext - - public constructor( - dispatcher: Dispatcher, - questionAnswerService: QuestionAnswerService, - messageSender: MessageSender, - connectionService: ConnectionService, - agentContext: AgentContext - ) { - this.questionAnswerService = questionAnswerService - this.messageSender = messageSender - this.connectionService = connectionService - this.agentContext = agentContext - this.registerHandlers(dispatcher) - } - - /** - * Create a question message with possible valid responses, then send message to the - * holder - * - * @param connectionId connection to send the question message to - * @param config config for creating question message - * @returns QuestionAnswer record - */ - public async sendQuestion( - connectionId: string, - config: { - question: string - validResponses: ValidResponse[] - detail?: string - } - ) { - const connection = await this.connectionService.getById(this.agentContext, connectionId) - connection.assertReady() - - const { questionMessage, questionAnswerRecord } = await this.questionAnswerService.createQuestion( - this.agentContext, - connectionId, - { - question: config.question, - validResponses: config.validResponses, - detail: config?.detail, - } - ) - const outboundMessage = createOutboundMessage(connection, questionMessage) - await this.messageSender.sendMessage(this.agentContext, outboundMessage) - - return questionAnswerRecord - } - - /** - * Create an answer message as the holder and send it in response to a question message - * - * @param questionRecordId the id of the questionAnswer record - * @param response response included in the answer message - * @returns QuestionAnswer record - */ - public async sendAnswer(questionRecordId: string, response: string) { - const questionRecord = await this.questionAnswerService.getById(this.agentContext, questionRecordId) - - const { answerMessage, questionAnswerRecord } = await this.questionAnswerService.createAnswer( - this.agentContext, - questionRecord, - response - ) - - const connection = await this.connectionService.getById(this.agentContext, questionRecord.connectionId) - - const outboundMessage = createOutboundMessage(connection, answerMessage) - await this.messageSender.sendMessage(this.agentContext, outboundMessage) - - return questionAnswerRecord - } - - /** - * Get all QuestionAnswer records - * - * @returns list containing all QuestionAnswer records - */ - public getAll() { - return this.questionAnswerService.getAll(this.agentContext) - } - - private registerHandlers(dispatcher: Dispatcher) { - dispatcher.registerHandler(new QuestionMessageHandler(this.questionAnswerService)) - dispatcher.registerHandler(new AnswerMessageHandler(this.questionAnswerService)) - } - +export class QuestionAnswerModule implements Module { /** * Registers the dependencies of the question answer module on the dependency manager. */ - public static register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager) { // Api - dependencyManager.registerContextScoped(QuestionAnswerModule) + dependencyManager.registerContextScoped(QuestionAnswerApi) // Services dependencyManager.registerSingleton(QuestionAnswerService) diff --git a/packages/core/src/modules/question-answer/__tests__/QuestionAnswerModule.test.ts b/packages/core/src/modules/question-answer/__tests__/QuestionAnswerModule.test.ts new file mode 100644 index 0000000000..a285e5898a --- /dev/null +++ b/packages/core/src/modules/question-answer/__tests__/QuestionAnswerModule.test.ts @@ -0,0 +1,23 @@ +import { DependencyManager } from '../../../plugins/DependencyManager' +import { QuestionAnswerApi } from '../QuestionAnswerApi' +import { QuestionAnswerModule } from '../QuestionAnswerModule' +import { QuestionAnswerRepository } from '../repository/QuestionAnswerRepository' +import { QuestionAnswerService } from '../services' + +jest.mock('../../../plugins/DependencyManager') +const DependencyManagerMock = DependencyManager as jest.Mock + +const dependencyManager = new DependencyManagerMock() + +describe('QuestionAnswerModule', () => { + test('registers dependencies on the dependency manager', () => { + new QuestionAnswerModule().register(dependencyManager) + + expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) + expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(QuestionAnswerApi) + + expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(2) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(QuestionAnswerService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(QuestionAnswerRepository) + }) +}) diff --git a/packages/core/src/modules/question-answer/index.ts b/packages/core/src/modules/question-answer/index.ts index 9c5a336fbb..e4b16be20f 100644 --- a/packages/core/src/modules/question-answer/index.ts +++ b/packages/core/src/modules/question-answer/index.ts @@ -3,5 +3,6 @@ export * from './models' export * from './services' export * from './repository' export * from './QuestionAnswerEvents' -export * from './QuestionAnswerModule' +export * from './QuestionAnswerApi' export * from './QuestionAnswerRole' +export * from './QuestionAnswerModule' diff --git a/packages/core/src/modules/routing/MediatorApi.ts b/packages/core/src/modules/routing/MediatorApi.ts new file mode 100644 index 0000000000..a75d0ec999 --- /dev/null +++ b/packages/core/src/modules/routing/MediatorApi.ts @@ -0,0 +1,91 @@ +import type { EncryptedMessage } from '../../types' +import type { MediationRecord } from './repository' + +import { AgentContext } from '../../agent' +import { Dispatcher } from '../../agent/Dispatcher' +import { EventEmitter } from '../../agent/EventEmitter' +import { MessageSender } from '../../agent/MessageSender' +import { createOutboundMessage } from '../../agent/helpers' +import { injectable } from '../../plugins' +import { ConnectionService } from '../connections/services' + +import { MediatorModuleConfig } from './MediatorModuleConfig' +import { ForwardHandler, KeylistUpdateHandler } from './handlers' +import { MediationRequestHandler } from './handlers/MediationRequestHandler' +import { MessagePickupService, V2MessagePickupService } from './protocol' +import { BatchHandler, BatchPickupHandler } from './protocol/pickup/v1/handlers' +import { MediatorService } from './services/MediatorService' + +@injectable() +export class MediatorApi { + public config: MediatorModuleConfig + + private mediatorService: MediatorService + private messagePickupService: MessagePickupService + private messageSender: MessageSender + private eventEmitter: EventEmitter + private agentContext: AgentContext + private connectionService: ConnectionService + + public constructor( + dispatcher: Dispatcher, + mediationService: MediatorService, + messagePickupService: MessagePickupService, + // Only imported so it is injected and handlers are registered + v2MessagePickupService: V2MessagePickupService, + messageSender: MessageSender, + eventEmitter: EventEmitter, + agentContext: AgentContext, + connectionService: ConnectionService, + config: MediatorModuleConfig + ) { + this.mediatorService = mediationService + this.messagePickupService = messagePickupService + this.messageSender = messageSender + this.eventEmitter = eventEmitter + this.connectionService = connectionService + this.agentContext = agentContext + this.config = config + this.registerHandlers(dispatcher) + } + + public async initialize() { + this.agentContext.config.logger.debug('Mediator routing record not loaded yet, retrieving from storage') + const routingRecord = await this.mediatorService.findMediatorRoutingRecord(this.agentContext) + + // If we don't have a routing record yet for this tenant, create it + if (!routingRecord) { + this.agentContext.config.logger.debug( + 'Mediator routing record does not exist yet, creating routing keys and record' + ) + await this.mediatorService.createMediatorRoutingRecord(this.agentContext) + } + } + + public async grantRequestedMediation(mediatorId: string): Promise { + const record = await this.mediatorService.getById(this.agentContext, mediatorId) + const connectionRecord = await this.connectionService.getById(this.agentContext, record.connectionId) + + const { message, mediationRecord } = await this.mediatorService.createGrantMediationMessage( + this.agentContext, + record + ) + const outboundMessage = createOutboundMessage(connectionRecord, message) + + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + + return mediationRecord + } + + public queueMessage(connectionId: string, message: EncryptedMessage) { + return this.messagePickupService.queueMessage(connectionId, message) + } + + private registerHandlers(dispatcher: Dispatcher) { + dispatcher.registerHandler(new KeylistUpdateHandler(this.mediatorService)) + dispatcher.registerHandler(new ForwardHandler(this.mediatorService, this.connectionService, this.messageSender)) + dispatcher.registerHandler(new BatchPickupHandler(this.messagePickupService)) + dispatcher.registerHandler(new BatchHandler(this.eventEmitter)) + dispatcher.registerHandler(new MediationRequestHandler(this.mediatorService, this.config)) + } +} diff --git a/packages/core/src/modules/routing/MediatorModule.ts b/packages/core/src/modules/routing/MediatorModule.ts index daf43e65d4..97aa521934 100644 --- a/packages/core/src/modules/routing/MediatorModule.ts +++ b/packages/core/src/modules/routing/MediatorModule.ts @@ -1,99 +1,36 @@ -import type { DependencyManager } from '../../plugins' -import type { EncryptedMessage } from '../../types' -import type { MediationRecord } from './repository' +import type { DependencyManager, Module } from '../../plugins' +import type { MediatorModuleConfigOptions } from './MediatorModuleConfig' -import { AgentContext } from '../../agent' -import { Dispatcher } from '../../agent/Dispatcher' -import { EventEmitter } from '../../agent/EventEmitter' -import { MessageSender } from '../../agent/MessageSender' -import { createOutboundMessage } from '../../agent/helpers' -import { injectable, module } from '../../plugins' -import { ConnectionService } from '../connections/services' - -import { KeylistUpdateHandler, ForwardHandler } from './handlers' -import { MediationRequestHandler } from './handlers/MediationRequestHandler' +import { MediatorApi } from './MediatorApi' +import { MediatorModuleConfig } from './MediatorModuleConfig' import { MessagePickupService, V2MessagePickupService } from './protocol' -import { MediatorService } from './services/MediatorService' - -@module() -@injectable() -export class MediatorModule { - private mediatorService: MediatorService - private messagePickupService: MessagePickupService - private messageSender: MessageSender - public eventEmitter: EventEmitter - public agentContext: AgentContext - public connectionService: ConnectionService - - public constructor( - dispatcher: Dispatcher, - mediationService: MediatorService, - messagePickupService: MessagePickupService, - messageSender: MessageSender, - eventEmitter: EventEmitter, - agentContext: AgentContext, - connectionService: ConnectionService - ) { - this.mediatorService = mediationService - this.messagePickupService = messagePickupService - this.messageSender = messageSender - this.eventEmitter = eventEmitter - this.connectionService = connectionService - this.agentContext = agentContext - this.registerHandlers(dispatcher) - } - - public async initialize() { - this.agentContext.config.logger.debug('Mediator routing record not loaded yet, retrieving from storage') - const routingRecord = await this.mediatorService.findMediatorRoutingRecord(this.agentContext) - - // If we don't have a routing record yet for this tenant, create it - if (!routingRecord) { - this.agentContext.config.logger.debug( - 'Mediator routing record does not exist yet, creating routing keys and record' - ) - await this.mediatorService.createMediatorRoutingRecord(this.agentContext) - } - } +import { MediationRepository, MediatorRoutingRepository } from './repository' +import { MediatorService } from './services' - public async grantRequestedMediation(mediatorId: string): Promise { - const record = await this.mediatorService.getById(this.agentContext, mediatorId) - const connectionRecord = await this.connectionService.getById(this.agentContext, record.connectionId) +export class MediatorModule implements Module { + public readonly config: MediatorModuleConfig - const { message, mediationRecord } = await this.mediatorService.createGrantMediationMessage( - this.agentContext, - record - ) - const outboundMessage = createOutboundMessage(connectionRecord, message) - - await this.messageSender.sendMessage(this.agentContext, outboundMessage) - - return mediationRecord - } - - public queueMessage(connectionId: string, message: EncryptedMessage) { - return this.messagePickupService.queueMessage(connectionId, message) - } - - private registerHandlers(dispatcher: Dispatcher) { - dispatcher.registerHandler(new KeylistUpdateHandler(this.mediatorService)) - dispatcher.registerHandler(new ForwardHandler(this.mediatorService, this.connectionService, this.messageSender)) - dispatcher.registerHandler(new MediationRequestHandler(this.mediatorService)) + public constructor(config?: MediatorModuleConfigOptions) { + this.config = new MediatorModuleConfig(config) } /** - * Registers the dependencies of the mediator module on the dependency manager. + * Registers the dependencies of the question answer module on the dependency manager. */ - public static register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager) { // Api - dependencyManager.registerContextScoped(MediatorModule) + dependencyManager.registerContextScoped(MediatorApi) + + // Config + dependencyManager.registerInstance(MediatorModuleConfig, this.config) // Services dependencyManager.registerSingleton(MediatorService) dependencyManager.registerSingleton(MessagePickupService) dependencyManager.registerSingleton(V2MessagePickupService) - // FIXME: Inject in constructor - dependencyManager.resolve(V2MessagePickupService) + // Repositories + dependencyManager.registerSingleton(MediationRepository) + dependencyManager.registerSingleton(MediatorRoutingRepository) } } diff --git a/packages/core/src/modules/routing/MediatorModuleConfig.ts b/packages/core/src/modules/routing/MediatorModuleConfig.ts new file mode 100644 index 0000000000..2e781fbc85 --- /dev/null +++ b/packages/core/src/modules/routing/MediatorModuleConfig.ts @@ -0,0 +1,25 @@ +/** + * MediatorModuleConfigOptions defines the interface for the options of the RecipientModuleConfig class. + * This can contain optional parameters that have default values in the config class itself. + */ +export interface MediatorModuleConfigOptions { + /** + * Whether to automatically accept and grant incoming mediation requests. + * + * @default false + */ + autoAcceptMediationRequests?: boolean +} + +export class MediatorModuleConfig { + private options: MediatorModuleConfigOptions + + public constructor(options?: MediatorModuleConfigOptions) { + this.options = options ?? {} + } + + /** See {@link RecipientModuleConfigOptions.autoAcceptMediationRequests} */ + public get autoAcceptMediationRequests() { + return this.options.autoAcceptMediationRequests ?? false + } +} diff --git a/packages/core/src/modules/routing/RecipientApi.ts b/packages/core/src/modules/routing/RecipientApi.ts new file mode 100644 index 0000000000..036ff2ed1d --- /dev/null +++ b/packages/core/src/modules/routing/RecipientApi.ts @@ -0,0 +1,405 @@ +import type { OutboundWebSocketClosedEvent } from '../../transport' +import type { OutboundMessage } from '../../types' +import type { ConnectionRecord } from '../connections' +import type { MediationStateChangedEvent } from './RoutingEvents' +import type { MediationRecord } from './repository' +import type { GetRoutingOptions } from './services/RoutingService' + +import { firstValueFrom, interval, ReplaySubject, Subject, timer } from 'rxjs' +import { delayWhen, filter, first, takeUntil, tap, throttleTime, timeout } from 'rxjs/operators' + +import { AgentContext } from '../../agent' +import { Dispatcher } from '../../agent/Dispatcher' +import { EventEmitter } from '../../agent/EventEmitter' +import { filterContextCorrelationId } from '../../agent/Events' +import { MessageSender } from '../../agent/MessageSender' +import { createOutboundMessage } from '../../agent/helpers' +import { InjectionSymbols } from '../../constants' +import { AriesFrameworkError } from '../../error' +import { Logger } from '../../logger' +import { inject, injectable } from '../../plugins' +import { TransportEventTypes } from '../../transport' +import { ConnectionService } from '../connections/services' +import { DidsApi } from '../dids' +import { DiscoverFeaturesApi } from '../discover-features' + +import { MediatorPickupStrategy } from './MediatorPickupStrategy' +import { RecipientModuleConfig } from './RecipientModuleConfig' +import { RoutingEventTypes } from './RoutingEvents' +import { KeylistUpdateResponseHandler } from './handlers/KeylistUpdateResponseHandler' +import { MediationDenyHandler } from './handlers/MediationDenyHandler' +import { MediationGrantHandler } from './handlers/MediationGrantHandler' +import { MediationState } from './models/MediationState' +import { StatusRequestMessage, BatchPickupMessage } from './protocol' +import { StatusHandler, MessageDeliveryHandler } from './protocol/pickup/v2/handlers' +import { MediationRepository } from './repository' +import { MediationRecipientService } from './services/MediationRecipientService' +import { RoutingService } from './services/RoutingService' + +@injectable() +export class RecipientApi { + public config: RecipientModuleConfig + + private mediationRecipientService: MediationRecipientService + private connectionService: ConnectionService + private dids: DidsApi + private messageSender: MessageSender + private eventEmitter: EventEmitter + private logger: Logger + private discoverFeaturesApi: DiscoverFeaturesApi + private mediationRepository: MediationRepository + private routingService: RoutingService + private agentContext: AgentContext + private stop$: Subject + + public constructor( + dispatcher: Dispatcher, + mediationRecipientService: MediationRecipientService, + connectionService: ConnectionService, + dids: DidsApi, + messageSender: MessageSender, + eventEmitter: EventEmitter, + discoverFeaturesApi: DiscoverFeaturesApi, + mediationRepository: MediationRepository, + routingService: RoutingService, + @inject(InjectionSymbols.Logger) logger: Logger, + agentContext: AgentContext, + @inject(InjectionSymbols.Stop$) stop$: Subject, + recipientModuleConfig: RecipientModuleConfig + ) { + this.connectionService = connectionService + this.dids = dids + this.mediationRecipientService = mediationRecipientService + this.messageSender = messageSender + this.eventEmitter = eventEmitter + this.logger = logger + this.discoverFeaturesApi = discoverFeaturesApi + this.mediationRepository = mediationRepository + this.routingService = routingService + this.agentContext = agentContext + this.stop$ = stop$ + this.config = recipientModuleConfig + this.registerHandlers(dispatcher) + } + + public async initialize() { + const { defaultMediatorId, clearDefaultMediator } = this.agentContext.config + + // Set default mediator by id + if (defaultMediatorId) { + const mediatorRecord = await this.mediationRecipientService.getById(this.agentContext, defaultMediatorId) + await this.mediationRecipientService.setDefaultMediator(this.agentContext, mediatorRecord) + } + // Clear the stored default mediator + else if (clearDefaultMediator) { + await this.mediationRecipientService.clearDefaultMediator(this.agentContext) + } + + // Poll for messages from mediator + const defaultMediator = await this.findDefaultMediator() + if (defaultMediator) { + await this.initiateMessagePickup(defaultMediator) + } + } + + private async sendMessage(outboundMessage: OutboundMessage, pickupStrategy?: MediatorPickupStrategy) { + const mediatorPickupStrategy = pickupStrategy ?? this.config.mediatorPickupStrategy + const transportPriority = + mediatorPickupStrategy === MediatorPickupStrategy.Implicit + ? { schemes: ['wss', 'ws'], restrictive: true } + : undefined + + await this.messageSender.sendMessage(this.agentContext, outboundMessage, { + transportPriority, + // TODO: add keepAlive: true to enforce through the public api + // we need to keep the socket alive. It already works this way, but would + // be good to make more explicit from the public facing API. + // This would also make it easier to change the internal API later on. + // keepAlive: true, + }) + } + + private async openMediationWebSocket(mediator: MediationRecord) { + const connection = await this.connectionService.getById(this.agentContext, mediator.connectionId) + const { message, connectionRecord } = await this.connectionService.createTrustPing(this.agentContext, connection, { + responseRequested: false, + }) + + const websocketSchemes = ['ws', 'wss'] + const didDocument = connectionRecord.theirDid && (await this.dids.resolveDidDocument(connectionRecord.theirDid)) + const services = didDocument && didDocument?.didCommServices + const hasWebSocketTransport = services && services.some((s) => websocketSchemes.includes(s.protocolScheme)) + + if (!hasWebSocketTransport) { + throw new AriesFrameworkError('Cannot open websocket to connection without websocket service endpoint') + } + + await this.messageSender.sendMessage(this.agentContext, createOutboundMessage(connectionRecord, message), { + transportPriority: { + schemes: websocketSchemes, + restrictive: true, + // TODO: add keepAlive: true to enforce through the public api + // we need to keep the socket alive. It already works this way, but would + // be good to make more explicit from the public facing API. + // This would also make it easier to change the internal API later on. + // keepAlive: true, + }, + }) + } + + private async openWebSocketAndPickUp(mediator: MediationRecord, pickupStrategy: MediatorPickupStrategy) { + let interval = 50 + + // FIXME: this won't work for tenant agents created by the tenants module as the agent context session + // could be closed. I'm not sure we want to support this as you probably don't want different tenants opening + // various websocket connections to mediators. However we should look at throwing an error or making sure + // it is not possible to use the mediation module with tenant agents. + + // Listens to Outbound websocket closed events and will reopen the websocket connection + // in a recursive back off strategy if it matches the following criteria: + // - Agent is not shutdown + // - Socket was for current mediator connection id + this.eventEmitter + .observable(TransportEventTypes.OutboundWebSocketClosedEvent) + .pipe( + // Stop when the agent shuts down + takeUntil(this.stop$), + filter((e) => e.payload.connectionId === mediator.connectionId), + // Make sure we're not reconnecting multiple times + throttleTime(interval), + // Increase the interval (recursive back-off) + tap(() => (interval *= 2)), + // Wait for interval time before reconnecting + delayWhen(() => timer(interval)) + ) + .subscribe(async () => { + this.logger.debug( + `Websocket connection to mediator with connectionId '${mediator.connectionId}' is closed, attempting to reconnect...` + ) + try { + if (pickupStrategy === MediatorPickupStrategy.PickUpV2) { + // Start Pickup v2 protocol to receive messages received while websocket offline + await this.sendStatusRequest({ mediatorId: mediator.id }) + } else { + await this.openMediationWebSocket(mediator) + } + } catch (error) { + this.logger.warn('Unable to re-open websocket connection to mediator', { error }) + } + }) + try { + if (pickupStrategy === MediatorPickupStrategy.Implicit) { + await this.openMediationWebSocket(mediator) + } + } catch (error) { + this.logger.warn('Unable to open websocket connection to mediator', { error }) + } + } + + public async initiateMessagePickup(mediator: MediationRecord) { + const { mediatorPollingInterval } = this.config + const mediatorPickupStrategy = await this.getPickupStrategyForMediator(mediator) + const mediatorConnection = await this.connectionService.getById(this.agentContext, mediator.connectionId) + + switch (mediatorPickupStrategy) { + case MediatorPickupStrategy.PickUpV2: + this.logger.info(`Starting pickup of messages from mediator '${mediator.id}'`) + await this.openWebSocketAndPickUp(mediator, mediatorPickupStrategy) + await this.sendStatusRequest({ mediatorId: mediator.id }) + break + case MediatorPickupStrategy.PickUpV1: { + // Explicit means polling every X seconds with batch message + this.logger.info(`Starting explicit (batch) pickup of messages from mediator '${mediator.id}'`) + const subscription = interval(mediatorPollingInterval) + .pipe(takeUntil(this.stop$)) + .subscribe(async () => { + await this.pickupMessages(mediatorConnection) + }) + return subscription + } + case MediatorPickupStrategy.Implicit: + // Implicit means sending ping once and keeping connection open. This requires a long-lived transport + // such as WebSockets to work + this.logger.info(`Starting implicit pickup of messages from mediator '${mediator.id}'`) + await this.openWebSocketAndPickUp(mediator, mediatorPickupStrategy) + break + default: + this.logger.info(`Skipping pickup of messages from mediator '${mediator.id}' due to pickup strategy none`) + } + } + + private async sendStatusRequest(config: { mediatorId: string; recipientKey?: string }) { + const mediationRecord = await this.mediationRecipientService.getById(this.agentContext, config.mediatorId) + + const statusRequestMessage = await this.mediationRecipientService.createStatusRequest(mediationRecord, { + recipientKey: config.recipientKey, + }) + + const mediatorConnection = await this.connectionService.getById(this.agentContext, mediationRecord.connectionId) + return this.messageSender.sendMessage( + this.agentContext, + createOutboundMessage(mediatorConnection, statusRequestMessage) + ) + } + + private async getPickupStrategyForMediator(mediator: MediationRecord) { + let mediatorPickupStrategy = mediator.pickupStrategy ?? this.config.mediatorPickupStrategy + + // 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) { + mediatorPickupStrategy = MediatorPickupStrategy.PickUpV2 + } else { + const isBatchPickupSupported = await this.discoverFeaturesApi.isProtocolSupported( + mediator.connectionId, + BatchPickupMessage + ) + + // Use explicit pickup strategy + mediatorPickupStrategy = isBatchPickupSupported + ? MediatorPickupStrategy.PickUpV1 + : MediatorPickupStrategy.Implicit + } + + // Store the result so it can be reused next time + mediator.pickupStrategy = mediatorPickupStrategy + await this.mediationRepository.update(this.agentContext, mediator) + } + + return mediatorPickupStrategy + } + + public async discoverMediation() { + return this.mediationRecipientService.discoverMediation(this.agentContext) + } + + public async pickupMessages(mediatorConnection: ConnectionRecord, pickupStrategy?: MediatorPickupStrategy) { + mediatorConnection.assertReady() + + const pickupMessage = + pickupStrategy === MediatorPickupStrategy.PickUpV2 + ? new StatusRequestMessage({}) + : new BatchPickupMessage({ batchSize: 10 }) + const outboundMessage = createOutboundMessage(mediatorConnection, pickupMessage) + await this.sendMessage(outboundMessage, pickupStrategy) + } + + public async setDefaultMediator(mediatorRecord: MediationRecord) { + return this.mediationRecipientService.setDefaultMediator(this.agentContext, mediatorRecord) + } + + public async requestMediation(connection: ConnectionRecord): Promise { + const { mediationRecord, message } = await this.mediationRecipientService.createRequest( + this.agentContext, + connection + ) + const outboundMessage = createOutboundMessage(connection, message) + + await this.sendMessage(outboundMessage) + return mediationRecord + } + + public async notifyKeylistUpdate(connection: ConnectionRecord, verkey: string) { + const message = this.mediationRecipientService.createKeylistUpdateMessage(verkey) + const outboundMessage = createOutboundMessage(connection, message) + await this.sendMessage(outboundMessage) + } + + public async findByConnectionId(connectionId: string) { + return await this.mediationRecipientService.findByConnectionId(this.agentContext, connectionId) + } + + public async getMediators() { + return await this.mediationRecipientService.getMediators(this.agentContext) + } + + public async findDefaultMediator(): Promise { + return this.mediationRecipientService.findDefaultMediator(this.agentContext) + } + + public async findDefaultMediatorConnection(): Promise { + const mediatorRecord = await this.findDefaultMediator() + + if (mediatorRecord) { + return this.connectionService.getById(this.agentContext, mediatorRecord.connectionId) + } + + return null + } + + public async requestAndAwaitGrant(connection: ConnectionRecord, timeoutMs = 10000): Promise { + const { mediationRecord, message } = await this.mediationRecipientService.createRequest( + this.agentContext, + connection + ) + + // Create observable for event + const observable = this.eventEmitter.observable(RoutingEventTypes.MediationStateChanged) + const subject = new ReplaySubject(1) + + // Apply required filters to observable stream subscribe to replay subject + observable + .pipe( + filterContextCorrelationId(this.agentContext.contextCorrelationId), + // Only take event for current mediation record + filter((event) => event.payload.mediationRecord.id === mediationRecord.id), + // Only take event for previous state requested, current state granted + filter((event) => event.payload.previousState === MediationState.Requested), + filter((event) => event.payload.mediationRecord.state === MediationState.Granted), + // Only wait for first event that matches the criteria + first(), + // Do not wait for longer than specified timeout + timeout(timeoutMs) + ) + .subscribe(subject) + + // Send mediation request message + const outboundMessage = createOutboundMessage(connection, message) + await this.sendMessage(outboundMessage) + + const event = await firstValueFrom(subject) + return event.payload.mediationRecord + } + + /** + * Requests mediation for a given connection and sets that as default mediator. + * + * @param connection connection record which will be used for mediation + * @returns mediation record + */ + public async provision(connection: ConnectionRecord) { + this.logger.debug('Connection completed, requesting mediation') + + let mediation = await this.findByConnectionId(connection.id) + if (!mediation) { + this.logger.info(`Requesting mediation for connection ${connection.id}`) + mediation = await this.requestAndAwaitGrant(connection, 60000) // TODO: put timeout as a config parameter + this.logger.debug('Mediation granted, setting as default mediator') + await this.setDefaultMediator(mediation) + this.logger.debug('Default mediator set') + } else { + this.logger.debug(`Mediator invitation has already been ${mediation.isReady ? 'granted' : 'requested'}`) + } + + return mediation + } + + public async getRouting(options: GetRoutingOptions) { + return this.routingService.getRouting(this.agentContext, options) + } + + // Register handlers for the several messages for the mediator. + private registerHandlers(dispatcher: Dispatcher) { + dispatcher.registerHandler(new KeylistUpdateResponseHandler(this.mediationRecipientService)) + dispatcher.registerHandler(new MediationGrantHandler(this.mediationRecipientService)) + dispatcher.registerHandler(new MediationDenyHandler(this.mediationRecipientService)) + dispatcher.registerHandler(new StatusHandler(this.mediationRecipientService)) + dispatcher.registerHandler(new MessageDeliveryHandler(this.mediationRecipientService)) + //dispatcher.registerHandler(new KeylistListHandler(this.mediationRecipientService)) // TODO: write this + } +} diff --git a/packages/core/src/modules/routing/RecipientModule.ts b/packages/core/src/modules/routing/RecipientModule.ts index 76115a165c..8233b2aacf 100644 --- a/packages/core/src/modules/routing/RecipientModule.ts +++ b/packages/core/src/modules/routing/RecipientModule.ts @@ -1,408 +1,27 @@ -import type { DependencyManager } from '../../plugins' -import type { OutboundWebSocketClosedEvent } from '../../transport' -import type { OutboundMessage } from '../../types' -import type { ConnectionRecord } from '../connections' -import type { MediationStateChangedEvent } from './RoutingEvents' -import type { MediationRecord } from './index' -import type { GetRoutingOptions } from './services/RoutingService' +import type { DependencyManager, Module } from '../../plugins' +import type { RecipientModuleConfigOptions } from './RecipientModuleConfig' -import { firstValueFrom, interval, ReplaySubject, Subject, timer } from 'rxjs' -import { delayWhen, filter, first, takeUntil, tap, throttleTime, timeout } from 'rxjs/operators' +import { RecipientApi } from './RecipientApi' +import { RecipientModuleConfig } from './RecipientModuleConfig' +import { MediationRepository } from './repository' +import { MediationRecipientService, RoutingService } from './services' -import { AgentContext } from '../../agent' -import { Dispatcher } from '../../agent/Dispatcher' -import { EventEmitter } from '../../agent/EventEmitter' -import { filterContextCorrelationId } from '../../agent/Events' -import { MessageSender } from '../../agent/MessageSender' -import { createOutboundMessage } from '../../agent/helpers' -import { InjectionSymbols } from '../../constants' -import { AriesFrameworkError } from '../../error' -import { Logger } from '../../logger' -import { inject, injectable, module } from '../../plugins' -import { TransportEventTypes } from '../../transport' -import { ConnectionService } from '../connections/services' -import { DidsModule } from '../dids' -import { DiscoverFeaturesModule } from '../discover-features' +export class RecipientModule implements Module { + public readonly config: RecipientModuleConfig -import { MediatorPickupStrategy } from './MediatorPickupStrategy' -import { RoutingEventTypes } from './RoutingEvents' -import { KeylistUpdateResponseHandler } from './handlers/KeylistUpdateResponseHandler' -import { MediationDenyHandler } from './handlers/MediationDenyHandler' -import { MediationGrantHandler } from './handlers/MediationGrantHandler' -import { MediationState } from './models/MediationState' -import { BatchPickupMessage, StatusRequestMessage } from './protocol' -import { MediationRepository, MediatorRoutingRepository } from './repository' -import { MediationRecipientService } from './services/MediationRecipientService' -import { RoutingService } from './services/RoutingService' - -@module() -@injectable() -export class RecipientModule { - private mediationRecipientService: MediationRecipientService - private connectionService: ConnectionService - private dids: DidsModule - private messageSender: MessageSender - private eventEmitter: EventEmitter - private logger: Logger - private discoverFeaturesModule: DiscoverFeaturesModule - private mediationRepository: MediationRepository - private routingService: RoutingService - private agentContext: AgentContext - private stop$: Subject - - public constructor( - dispatcher: Dispatcher, - mediationRecipientService: MediationRecipientService, - connectionService: ConnectionService, - dids: DidsModule, - messageSender: MessageSender, - eventEmitter: EventEmitter, - discoverFeaturesModule: DiscoverFeaturesModule, - mediationRepository: MediationRepository, - routingService: RoutingService, - @inject(InjectionSymbols.Logger) logger: Logger, - agentContext: AgentContext, - @inject(InjectionSymbols.Stop$) stop$: Subject - ) { - this.connectionService = connectionService - this.dids = dids - this.mediationRecipientService = mediationRecipientService - this.messageSender = messageSender - this.eventEmitter = eventEmitter - this.logger = logger - this.discoverFeaturesModule = discoverFeaturesModule - this.mediationRepository = mediationRepository - this.routingService = routingService - this.agentContext = agentContext - this.stop$ = stop$ - this.registerHandlers(dispatcher) - } - - public async initialize() { - const { defaultMediatorId, clearDefaultMediator } = this.agentContext.config - - // Set default mediator by id - if (defaultMediatorId) { - const mediatorRecord = await this.mediationRecipientService.getById(this.agentContext, defaultMediatorId) - await this.mediationRecipientService.setDefaultMediator(this.agentContext, mediatorRecord) - } - // Clear the stored default mediator - else if (clearDefaultMediator) { - await this.mediationRecipientService.clearDefaultMediator(this.agentContext) - } - - // Poll for messages from mediator - const defaultMediator = await this.findDefaultMediator() - if (defaultMediator) { - await this.initiateMessagePickup(defaultMediator) - } - } - - private async sendMessage(outboundMessage: OutboundMessage, pickupStrategy?: MediatorPickupStrategy) { - const mediatorPickupStrategy = pickupStrategy ?? this.agentContext.config.mediatorPickupStrategy - const transportPriority = - mediatorPickupStrategy === MediatorPickupStrategy.Implicit - ? { schemes: ['wss', 'ws'], restrictive: true } - : undefined - - await this.messageSender.sendMessage(this.agentContext, outboundMessage, { - transportPriority, - // TODO: add keepAlive: true to enforce through the public api - // we need to keep the socket alive. It already works this way, but would - // be good to make more explicit from the public facing API. - // This would also make it easier to change the internal API later on. - // keepAlive: true, - }) - } - - private async openMediationWebSocket(mediator: MediationRecord) { - const connection = await this.connectionService.getById(this.agentContext, mediator.connectionId) - const { message, connectionRecord } = await this.connectionService.createTrustPing(this.agentContext, connection, { - responseRequested: false, - }) - - const websocketSchemes = ['ws', 'wss'] - const didDocument = connectionRecord.theirDid && (await this.dids.resolveDidDocument(connectionRecord.theirDid)) - const services = didDocument && didDocument?.didCommServices - const hasWebSocketTransport = services && services.some((s) => websocketSchemes.includes(s.protocolScheme)) - - if (!hasWebSocketTransport) { - throw new AriesFrameworkError('Cannot open websocket to connection without websocket service endpoint') - } - - await this.messageSender.sendMessage(this.agentContext, createOutboundMessage(connectionRecord, message), { - transportPriority: { - schemes: websocketSchemes, - restrictive: true, - // TODO: add keepAlive: true to enforce through the public api - // we need to keep the socket alive. It already works this way, but would - // be good to make more explicit from the public facing API. - // This would also make it easier to change the internal API later on. - // keepAlive: true, - }, - }) - } - - private async openWebSocketAndPickUp(mediator: MediationRecord, pickupStrategy: MediatorPickupStrategy) { - let interval = 50 - - // FIXME: this won't work for tenant agents created by the tenants module as the agent context session - // could be closed. I'm not sure we want to support this as you probably don't want different tenants opening - // various websocket connections to mediators. However we should look at throwing an error or making sure - // it is not possible to use the mediation module with tenant agents. - - // Listens to Outbound websocket closed events and will reopen the websocket connection - // in a recursive back off strategy if it matches the following criteria: - // - Agent is not shutdown - // - Socket was for current mediator connection id - this.eventEmitter - .observable(TransportEventTypes.OutboundWebSocketClosedEvent) - .pipe( - // Stop when the agent shuts down - takeUntil(this.stop$), - filter((e) => e.payload.connectionId === mediator.connectionId), - // Make sure we're not reconnecting multiple times - throttleTime(interval), - // Increase the interval (recursive back-off) - tap(() => (interval *= 2)), - // Wait for interval time before reconnecting - delayWhen(() => timer(interval)) - ) - .subscribe(async () => { - this.logger.debug( - `Websocket connection to mediator with connectionId '${mediator.connectionId}' is closed, attempting to reconnect...` - ) - try { - if (pickupStrategy === MediatorPickupStrategy.PickUpV2) { - // Start Pickup v2 protocol to receive messages received while websocket offline - await this.sendStatusRequest({ mediatorId: mediator.id }) - } else { - await this.openMediationWebSocket(mediator) - } - } catch (error) { - this.logger.warn('Unable to re-open websocket connection to mediator', { error }) - } - }) - try { - if (pickupStrategy === MediatorPickupStrategy.Implicit) { - await this.openMediationWebSocket(mediator) - } - } catch (error) { - this.logger.warn('Unable to open websocket connection to mediator', { error }) - } - } - - public async initiateMessagePickup(mediator: MediationRecord) { - const { mediatorPollingInterval } = this.agentContext.config - const mediatorPickupStrategy = await this.getPickupStrategyForMediator(mediator) - const mediatorConnection = await this.connectionService.getById(this.agentContext, mediator.connectionId) - - switch (mediatorPickupStrategy) { - case MediatorPickupStrategy.PickUpV2: - this.logger.info(`Starting pickup of messages from mediator '${mediator.id}'`) - await this.openWebSocketAndPickUp(mediator, mediatorPickupStrategy) - await this.sendStatusRequest({ mediatorId: mediator.id }) - break - case MediatorPickupStrategy.PickUpV1: { - // Explicit means polling every X seconds with batch message - this.logger.info(`Starting explicit (batch) pickup of messages from mediator '${mediator.id}'`) - const subscription = interval(mediatorPollingInterval) - .pipe(takeUntil(this.stop$)) - .subscribe(async () => { - await this.pickupMessages(mediatorConnection) - }) - return subscription - } - case MediatorPickupStrategy.Implicit: - // Implicit means sending ping once and keeping connection open. This requires a long-lived transport - // such as WebSockets to work - this.logger.info(`Starting implicit pickup of messages from mediator '${mediator.id}'`) - await this.openWebSocketAndPickUp(mediator, mediatorPickupStrategy) - break - default: - this.logger.info(`Skipping pickup of messages from mediator '${mediator.id}' due to pickup strategy none`) - } - } - - private async sendStatusRequest(config: { mediatorId: string; recipientKey?: string }) { - const mediationRecord = await this.mediationRecipientService.getById(this.agentContext, config.mediatorId) - - const statusRequestMessage = await this.mediationRecipientService.createStatusRequest(mediationRecord, { - recipientKey: config.recipientKey, - }) - - const mediatorConnection = await this.connectionService.getById(this.agentContext, mediationRecord.connectionId) - return this.messageSender.sendMessage( - this.agentContext, - createOutboundMessage(mediatorConnection, statusRequestMessage) - ) - } - - private async getPickupStrategyForMediator(mediator: MediationRecord) { - let mediatorPickupStrategy = mediator.pickupStrategy ?? this.agentContext.config.mediatorPickupStrategy - - // 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.discoverFeaturesModule.isProtocolSupported( - mediator.connectionId, - StatusRequestMessage - ) - if (isPickUpV2Supported) { - mediatorPickupStrategy = MediatorPickupStrategy.PickUpV2 - } else { - const isBatchPickupSupported = await this.discoverFeaturesModule.isProtocolSupported( - mediator.connectionId, - BatchPickupMessage - ) - - // Use explicit pickup strategy - mediatorPickupStrategy = isBatchPickupSupported - ? MediatorPickupStrategy.PickUpV1 - : MediatorPickupStrategy.Implicit - } - - // Store the result so it can be reused next time - mediator.pickupStrategy = mediatorPickupStrategy - await this.mediationRepository.update(this.agentContext, mediator) - } - - return mediatorPickupStrategy - } - - public async discoverMediation() { - return this.mediationRecipientService.discoverMediation(this.agentContext) - } - - public async pickupMessages(mediatorConnection: ConnectionRecord, pickupStrategy?: MediatorPickupStrategy) { - mediatorConnection.assertReady() - - const pickupMessage = - pickupStrategy === MediatorPickupStrategy.PickUpV2 - ? new StatusRequestMessage({}) - : new BatchPickupMessage({ batchSize: 10 }) - const outboundMessage = createOutboundMessage(mediatorConnection, pickupMessage) - await this.sendMessage(outboundMessage, pickupStrategy) - } - - public async setDefaultMediator(mediatorRecord: MediationRecord) { - return this.mediationRecipientService.setDefaultMediator(this.agentContext, mediatorRecord) - } - - public async requestMediation(connection: ConnectionRecord): Promise { - const { mediationRecord, message } = await this.mediationRecipientService.createRequest( - this.agentContext, - connection - ) - const outboundMessage = createOutboundMessage(connection, message) - - await this.sendMessage(outboundMessage) - return mediationRecord - } - - public async notifyKeylistUpdate(connection: ConnectionRecord, verkey: string) { - const message = this.mediationRecipientService.createKeylistUpdateMessage(verkey) - const outboundMessage = createOutboundMessage(connection, message) - await this.sendMessage(outboundMessage) - } - - public async findByConnectionId(connectionId: string) { - return await this.mediationRecipientService.findByConnectionId(this.agentContext, connectionId) - } - - public async getMediators() { - return await this.mediationRecipientService.getMediators(this.agentContext) - } - - public async findDefaultMediator(): Promise { - return this.mediationRecipientService.findDefaultMediator(this.agentContext) - } - - public async findDefaultMediatorConnection(): Promise { - const mediatorRecord = await this.findDefaultMediator() - - if (mediatorRecord) { - return this.connectionService.getById(this.agentContext, mediatorRecord.connectionId) - } - - return null - } - - public async requestAndAwaitGrant(connection: ConnectionRecord, timeoutMs = 10000): Promise { - const { mediationRecord, message } = await this.mediationRecipientService.createRequest( - this.agentContext, - connection - ) - - // Create observable for event - const observable = this.eventEmitter.observable(RoutingEventTypes.MediationStateChanged) - const subject = new ReplaySubject(1) - - // Apply required filters to observable stream subscribe to replay subject - observable - .pipe( - filterContextCorrelationId(this.agentContext.contextCorrelationId), - // Only take event for current mediation record - filter((event) => event.payload.mediationRecord.id === mediationRecord.id), - // Only take event for previous state requested, current state granted - filter((event) => event.payload.previousState === MediationState.Requested), - filter((event) => event.payload.mediationRecord.state === MediationState.Granted), - // Only wait for first event that matches the criteria - first(), - // Do not wait for longer than specified timeout - timeout(timeoutMs) - ) - .subscribe(subject) - - // Send mediation request message - const outboundMessage = createOutboundMessage(connection, message) - await this.sendMessage(outboundMessage) - - const event = await firstValueFrom(subject) - return event.payload.mediationRecord - } - - /** - * Requests mediation for a given connection and sets that as default mediator. - * - * @param connection connection record which will be used for mediation - * @returns mediation record - */ - public async provision(connection: ConnectionRecord) { - this.logger.debug('Connection completed, requesting mediation') - - let mediation = await this.findByConnectionId(connection.id) - if (!mediation) { - this.logger.info(`Requesting mediation for connection ${connection.id}`) - mediation = await this.requestAndAwaitGrant(connection, 60000) // TODO: put timeout as a config parameter - this.logger.debug('Mediation granted, setting as default mediator') - await this.setDefaultMediator(mediation) - this.logger.debug('Default mediator set') - } else { - this.logger.debug(`Mediator invitation has already been ${mediation.isReady ? 'granted' : 'requested'}`) - } - - return mediation - } - - public async getRouting(options: GetRoutingOptions) { - return this.routingService.getRouting(this.agentContext, options) - } - - // Register handlers for the several messages for the mediator. - private registerHandlers(dispatcher: Dispatcher) { - dispatcher.registerHandler(new KeylistUpdateResponseHandler(this.mediationRecipientService)) - dispatcher.registerHandler(new MediationGrantHandler(this.mediationRecipientService)) - dispatcher.registerHandler(new MediationDenyHandler(this.mediationRecipientService)) - //dispatcher.registerHandler(new KeylistListHandler(this.mediationRecipientService)) // TODO: write this + public constructor(config?: RecipientModuleConfigOptions) { + this.config = new RecipientModuleConfig(config) } /** * Registers the dependencies of the mediator recipient module on the dependency manager. */ - public static register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager) { // Api - dependencyManager.registerContextScoped(RecipientModule) + dependencyManager.registerContextScoped(RecipientApi) + + // Config + dependencyManager.registerInstance(RecipientModuleConfig, this.config) // Services dependencyManager.registerSingleton(MediationRecipientService) @@ -410,6 +29,5 @@ export class RecipientModule { // Repositories dependencyManager.registerSingleton(MediationRepository) - dependencyManager.registerSingleton(MediatorRoutingRepository) } } diff --git a/packages/core/src/modules/routing/RecipientModuleConfig.ts b/packages/core/src/modules/routing/RecipientModuleConfig.ts new file mode 100644 index 0000000000..d8679c4fad --- /dev/null +++ b/packages/core/src/modules/routing/RecipientModuleConfig.ts @@ -0,0 +1,74 @@ +import type { MediatorPickupStrategy } from './MediatorPickupStrategy' + +/** + * RecipientModuleConfigOptions defines the interface for the options of the RecipientModuleConfig class. + * This can contain optional parameters that have default values in the config class itself. + */ +export interface RecipientModuleConfigOptions { + /** + * Strategy to use for picking up messages from the mediator. If no strategy is provided, the agent will use the discover + * features protocol to determine the best strategy. + * + * + * - `MediatorPickupStrategy.PickUpV1` - explicitly pick up messages from the mediator according to [RFC 0212 Pickup Protocol](https://github.com/hyperledger/aries-rfcs/blob/main/features/0212-pickup/README.md) + * - `MediatorPickupStrategy.PickUpV2` - pick up messages from the mediator according to [RFC 0685 Pickup V2 Protocol](https://github.com/hyperledger/aries-rfcs/tree/main/features/0685-pickup-v2/README.md). + * - `MediatorPickupStrategy.Implicit` - Open a WebSocket with the mediator to implicitly receive messages. (currently used by Aries Cloud Agent Python) + * - `MediatorPickupStrategy.None` - Do not retrieve messages from the mediator. + * + * @default undefined + */ + mediatorPickupStrategy?: MediatorPickupStrategy + + /** + * Interval in milliseconds between picking up message from the mediator. This is only applicable when the pickup protocol v1 + * is used. + * + * @default 5000 + */ + mediatorPollingInterval?: number + + /** + * Maximum number of messages to retrieve from the mediator in a single batch. This is only applicable when the pickup protocol v2 + * is used. + * + * @todo integrate with pickup protocol v1 + * @default 10 + */ + maximumMessagePickup?: number + + /** + * Invitation url for connection to a mediator. If provided, a connection to the mediator will be made, and the mediator will be set as default. + * This is meant as the simplest form of connecting to a mediator, if more control is desired the api should be used. + * + * Supports both RFC 0434 Out Of Band v1 and RFC 0160 Connections v1 invitations. + */ + mediatorInvitationUrl?: string +} + +export class RecipientModuleConfig { + private options: RecipientModuleConfigOptions + + public constructor(options?: RecipientModuleConfigOptions) { + this.options = options ?? {} + } + + /** See {@link RecipientModuleConfigOptions.mediatorPollingInterval} */ + public get mediatorPollingInterval() { + return this.options.mediatorPollingInterval ?? 5000 + } + + /** See {@link RecipientModuleConfigOptions.mediatorPickupStrategy} */ + public get mediatorPickupStrategy() { + return this.options.mediatorPickupStrategy + } + + /** See {@link RecipientModuleConfigOptions.maximumMessagePickup} */ + public get maximumMessagePickup() { + return this.options.maximumMessagePickup ?? 10 + } + + /** See {@link RecipientModuleConfigOptions.mediatorInvitationUrl} */ + public get mediatorInvitationUrl() { + return this.options.mediatorInvitationUrl + } +} diff --git a/packages/core/src/modules/routing/__tests__/MediatorModule.test.ts b/packages/core/src/modules/routing/__tests__/MediatorModule.test.ts new file mode 100644 index 0000000000..096e83cfad --- /dev/null +++ b/packages/core/src/modules/routing/__tests__/MediatorModule.test.ts @@ -0,0 +1,27 @@ +import { DependencyManager } from '../../../plugins/DependencyManager' +import { MediatorApi } from '../MediatorApi' +import { MediatorModule } from '../MediatorModule' +import { MessagePickupService, V2MessagePickupService } from '../protocol' +import { MediationRepository, MediatorRoutingRepository } from '../repository' +import { MediatorService } from '../services' + +jest.mock('../../../plugins/DependencyManager') +const DependencyManagerMock = DependencyManager as jest.Mock + +const dependencyManager = new DependencyManagerMock() + +describe('MediatorModule', () => { + test('registers dependencies on the dependency manager', () => { + new MediatorModule().register(dependencyManager) + + expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) + expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(MediatorApi) + + expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(5) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(MediatorService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(MessagePickupService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(V2MessagePickupService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(MediationRepository) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(MediatorRoutingRepository) + }) +}) diff --git a/packages/core/src/modules/routing/__tests__/RecipientModule.test.ts b/packages/core/src/modules/routing/__tests__/RecipientModule.test.ts new file mode 100644 index 0000000000..916840344d --- /dev/null +++ b/packages/core/src/modules/routing/__tests__/RecipientModule.test.ts @@ -0,0 +1,24 @@ +import { DependencyManager } from '../../../plugins/DependencyManager' +import { RecipientApi } from '../RecipientApi' +import { RecipientModule } from '../RecipientModule' +import { MediationRepository } from '../repository' +import { MediationRecipientService, RoutingService } from '../services' + +jest.mock('../../../plugins/DependencyManager') +const DependencyManagerMock = DependencyManager as jest.Mock + +const dependencyManager = new DependencyManagerMock() + +describe('RecipientModule', () => { + test('registers dependencies on the dependency manager', () => { + new RecipientModule().register(dependencyManager) + + expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) + expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(RecipientApi) + + expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(3) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(MediationRecipientService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(RoutingService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(MediationRepository) + }) +}) diff --git a/packages/core/src/modules/routing/handlers/MediationRequestHandler.ts b/packages/core/src/modules/routing/handlers/MediationRequestHandler.ts index 2cc2944668..2c6f7be994 100644 --- a/packages/core/src/modules/routing/handlers/MediationRequestHandler.ts +++ b/packages/core/src/modules/routing/handlers/MediationRequestHandler.ts @@ -1,4 +1,5 @@ import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' +import type { MediatorModuleConfig } from '../MediatorModuleConfig' import type { MediatorService } from '../services/MediatorService' import { createOutboundMessage } from '../../../agent/helpers' @@ -6,10 +7,12 @@ import { MediationRequestMessage } from '../messages/MediationRequestMessage' export class MediationRequestHandler implements Handler { private mediatorService: MediatorService + private mediatorModuleConfig: MediatorModuleConfig public supportedMessages = [MediationRequestMessage] - public constructor(mediatorService: MediatorService) { + public constructor(mediatorService: MediatorService, mediatorModuleConfig: MediatorModuleConfig) { this.mediatorService = mediatorService + this.mediatorModuleConfig = mediatorModuleConfig } public async handle(messageContext: HandlerInboundMessage) { @@ -17,7 +20,7 @@ export class MediationRequestHandler implements Handler { const mediationRecord = await this.mediatorService.processMediationRequest(messageContext) - if (messageContext.agentContext.config.autoAcceptMediationRequests) { + if (this.mediatorModuleConfig.autoAcceptMediationRequests) { const { message } = await this.mediatorService.createGrantMediationMessage( messageContext.agentContext, mediationRecord diff --git a/packages/core/src/modules/routing/index.ts b/packages/core/src/modules/routing/index.ts index f3bf782a20..6032d26ecd 100644 --- a/packages/core/src/modules/routing/index.ts +++ b/packages/core/src/modules/routing/index.ts @@ -4,6 +4,8 @@ export * from './protocol' export * from './repository' export * from './models' export * from './RoutingEvents' +export * from './MediatorApi' +export * from './RecipientApi' +export * from './MediatorPickupStrategy' export * from './MediatorModule' export * from './RecipientModule' -export * from './MediatorPickupStrategy' diff --git a/packages/core/src/modules/routing/services/MediationRecipientService.ts b/packages/core/src/modules/routing/services/MediationRecipientService.ts index 2258afe845..ab90eeba66 100644 --- a/packages/core/src/modules/routing/services/MediationRecipientService.ts +++ b/packages/core/src/modules/routing/services/MediationRecipientService.ts @@ -24,6 +24,7 @@ import { JsonTransformer } from '../../../utils' import { ConnectionService } from '../../connections/services/ConnectionService' import { didKeyToVerkey } from '../../dids/helpers' import { ProblemReportError } from '../../problem-reports' +import { RecipientModuleConfig } from '../RecipientModuleConfig' import { RoutingEventTypes } from '../RoutingEvents' import { RoutingProblemReportReason } from '../error' import { KeylistUpdateAction, MediationRequestMessage } from '../messages' @@ -39,17 +40,20 @@ export class MediationRecipientService { private eventEmitter: EventEmitter private connectionService: ConnectionService private messageSender: MessageSender + private recipientModuleConfig: RecipientModuleConfig public constructor( connectionService: ConnectionService, messageSender: MessageSender, mediatorRepository: MediationRepository, - eventEmitter: EventEmitter + eventEmitter: EventEmitter, + recipientModuleConfig: RecipientModuleConfig ) { this.mediationRepository = mediatorRepository this.eventEmitter = eventEmitter this.connectionService = connectionService this.messageSender = messageSender + this.recipientModuleConfig = recipientModuleConfig } public async createStatusRequest( @@ -272,7 +276,7 @@ export class MediationRecipientService { return null } - const { maximumMessagePickup } = messageContext.agentContext.config + const { maximumMessagePickup } = this.recipientModuleConfig const limit = messageCount < maximumMessagePickup ? messageCount : maximumMessagePickup const deliveryRequestMessage = new DeliveryRequestMessage({ diff --git a/packages/core/src/modules/routing/services/__tests__/MediationRecipientService.test.ts b/packages/core/src/modules/routing/services/__tests__/MediationRecipientService.test.ts index 76c51345f1..210fac58d3 100644 --- a/packages/core/src/modules/routing/services/__tests__/MediationRecipientService.test.ts +++ b/packages/core/src/modules/routing/services/__tests__/MediationRecipientService.test.ts @@ -17,6 +17,7 @@ import { DidExchangeState } from '../../../connections' import { ConnectionRepository } from '../../../connections/repository/ConnectionRepository' import { ConnectionService } from '../../../connections/services/ConnectionService' import { DidRepository } from '../../../dids/repository/DidRepository' +import { RecipientModuleConfig } from '../../RecipientModuleConfig' import { MediationGrantMessage } from '../../messages' import { MediationRole, MediationState } from '../../models' import { DeliveryRequestMessage, MessageDeliveryMessage, MessagesReceivedMessage, StatusMessage } from '../../protocol' @@ -96,7 +97,8 @@ describe('MediationRecipientService', () => { connectionService, messageSender, mediationRepository, - eventEmitter + eventEmitter, + new RecipientModuleConfig() ) }) diff --git a/packages/core/src/modules/vc/module.ts b/packages/core/src/modules/vc/W3cVcModule.ts similarity index 87% rename from packages/core/src/modules/vc/module.ts rename to packages/core/src/modules/vc/W3cVcModule.ts index f9102217e3..30d5eb3be9 100644 --- a/packages/core/src/modules/vc/module.ts +++ b/packages/core/src/modules/vc/W3cVcModule.ts @@ -1,7 +1,6 @@ -import type { DependencyManager } from '../../plugins' +import type { DependencyManager, Module } from '../../plugins' import { KeyType } from '../../crypto' -import { module } from '../../plugins' import { SignatureSuiteRegistry, SignatureSuiteToken } from './SignatureSuiteRegistry' import { W3cCredentialService } from './W3cCredentialService' @@ -9,9 +8,8 @@ import { W3cCredentialRepository } from './repository/W3cCredentialRepository' import { Ed25519Signature2018 } from './signature-suites' import { BbsBlsSignature2020, BbsBlsSignatureProof2020 } from './signature-suites/bbs' -@module() -export class W3cVcModule { - public static register(dependencyManager: DependencyManager) { +export class W3cVcModule implements Module { + public register(dependencyManager: DependencyManager) { dependencyManager.registerSingleton(W3cCredentialService) dependencyManager.registerSingleton(W3cCredentialRepository) diff --git a/packages/core/src/modules/vc/__tests__/W3cVcModule.test.ts b/packages/core/src/modules/vc/__tests__/W3cVcModule.test.ts new file mode 100644 index 0000000000..e60807c1b6 --- /dev/null +++ b/packages/core/src/modules/vc/__tests__/W3cVcModule.test.ts @@ -0,0 +1,46 @@ +import { KeyType } from '../../../crypto' +import { DependencyManager } from '../../../plugins/DependencyManager' +import { SignatureSuiteRegistry, SignatureSuiteToken } from '../SignatureSuiteRegistry' +import { W3cCredentialService } from '../W3cCredentialService' +import { W3cVcModule } from '../W3cVcModule' +import { W3cCredentialRepository } from '../repository' +import { Ed25519Signature2018 } from '../signature-suites' +import { BbsBlsSignature2020, BbsBlsSignatureProof2020 } from '../signature-suites/bbs' + +jest.mock('../../../plugins/DependencyManager') +const DependencyManagerMock = DependencyManager as jest.Mock + +const dependencyManager = new DependencyManagerMock() + +describe('W3cVcModule', () => { + test('registers dependencies on the dependency manager', () => { + new W3cVcModule().register(dependencyManager) + + expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(3) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(W3cCredentialService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(W3cCredentialRepository) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(SignatureSuiteRegistry) + + expect(dependencyManager.registerInstance).toHaveBeenCalledTimes(3) + expect(dependencyManager.registerInstance).toHaveBeenCalledWith(SignatureSuiteToken, { + suiteClass: Ed25519Signature2018, + proofType: 'Ed25519Signature2018', + requiredKeyType: 'Ed25519VerificationKey2018', + keyType: KeyType.Ed25519, + }) + + expect(dependencyManager.registerInstance).toHaveBeenCalledWith(SignatureSuiteToken, { + suiteClass: BbsBlsSignature2020, + proofType: 'BbsBlsSignature2020', + requiredKeyType: 'BbsBlsSignatureProof2020', + keyType: KeyType.Bls12381g2, + }) + + expect(dependencyManager.registerInstance).toHaveBeenCalledWith(SignatureSuiteToken, { + suiteClass: BbsBlsSignatureProof2020, + proofType: 'BbsBlsSignatureProof2020', + requiredKeyType: 'BbsBlsSignatureProof2020', + keyType: KeyType.Bls12381g2, + }) + }) +}) diff --git a/packages/core/src/modules/vc/index.ts b/packages/core/src/modules/vc/index.ts index 6aa4d6b1d6..8a4149599f 100644 --- a/packages/core/src/modules/vc/index.ts +++ b/packages/core/src/modules/vc/index.ts @@ -1,2 +1,3 @@ export * from './W3cCredentialService' export * from './repository/W3cCredentialRecord' +export * from './W3cVcModule' diff --git a/packages/core/src/plugins/Module.ts b/packages/core/src/plugins/Module.ts index c209e26021..5210e2d9c4 100644 --- a/packages/core/src/plugins/Module.ts +++ b/packages/core/src/plugins/Module.ts @@ -1,8 +1,7 @@ +import type { Constructor } from '../utils/mixins' import type { DependencyManager } from './DependencyManager' export interface Module { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - new (...args: any[]): any register(dependencyManager: DependencyManager): void } @@ -11,5 +10,5 @@ export interface Module { * on the class declaration. */ export function module() { - return (constructor: U) => constructor + return >(constructor: U) => constructor } diff --git a/packages/core/src/plugins/__tests__/DependencyManager.test.ts b/packages/core/src/plugins/__tests__/DependencyManager.test.ts index e2ffd2ca3c..0991324abe 100644 --- a/packages/core/src/plugins/__tests__/DependencyManager.test.ts +++ b/packages/core/src/plugins/__tests__/DependencyManager.test.ts @@ -1,7 +1,8 @@ +import type { Module } from '../Module' + import { container as rootContainer, injectable, Lifecycle } from 'tsyringe' import { DependencyManager } from '../DependencyManager' -import { module } from '../Module' class Instance { public random = Math.random() @@ -19,24 +20,25 @@ describe('DependencyManager', () => { describe('registerModules', () => { it('calls the register method for all module plugins', () => { - @module() @injectable() - class Module1 { - public static register = jest.fn() + class Module1 implements Module { + public register = jest.fn() } - @module() @injectable() - class Module2 { - public static register = jest.fn() + class Module2 implements Module { + public register = jest.fn() } - dependencyManager.registerModules(Module1, Module2) - expect(Module1.register).toHaveBeenCalledTimes(1) - expect(Module1.register).toHaveBeenLastCalledWith(dependencyManager) + const module1 = new Module1() + const module2 = new Module2() + + dependencyManager.registerModules(module1, module2) + expect(module1.register).toHaveBeenCalledTimes(1) + expect(module1.register).toHaveBeenLastCalledWith(dependencyManager) - expect(Module2.register).toHaveBeenCalledTimes(1) - expect(Module2.register).toHaveBeenLastCalledWith(dependencyManager) + expect(module2.register).toHaveBeenCalledTimes(1) + expect(module2.register).toHaveBeenLastCalledWith(dependencyManager) }) }) diff --git a/packages/core/src/storage/migration/UpdateAssistant.ts b/packages/core/src/storage/migration/UpdateAssistant.ts index 3cca35ab59..8cf30b461b 100644 --- a/packages/core/src/storage/migration/UpdateAssistant.ts +++ b/packages/core/src/storage/migration/UpdateAssistant.ts @@ -4,6 +4,7 @@ import type { UpdateConfig } from './updates' import { InjectionSymbols } from '../../constants' import { AriesFrameworkError } from '../../error' +import { isIndyError } from '../../utils/indyError' import { isFirstVersionHigherThanSecond, parseVersionString } from '../../utils/version' import { WalletError } from '../../wallet/error/WalletError' @@ -125,6 +126,18 @@ export class UpdateAssistant { throw error } } catch (error) { + // Backup already exists at path + if (error instanceof AriesFrameworkError && isIndyError(error.cause, 'CommonIOError')) { + const backupPath = this.getBackupPath(updateIdentifier) + const errorMessage = `Error updating storage with updateIdentifier ${updateIdentifier} because of an IO error. This is probably because the backup at path ${backupPath} already exists` + this.agent.config.logger.fatal(errorMessage, { + error, + updateIdentifier, + backupPath, + }) + throw new StorageUpdateError(errorMessage, { cause: error }) + } + this.agent.config.logger.error(`Error updating storage (updateIdentifier: ${updateIdentifier})`, { cause: error, }) diff --git a/packages/core/src/wallet/WalletApi.ts b/packages/core/src/wallet/WalletApi.ts new file mode 100644 index 0000000000..c59cd8fb54 --- /dev/null +++ b/packages/core/src/wallet/WalletApi.ts @@ -0,0 +1,108 @@ +import type { WalletConfig, WalletConfigRekey, WalletExportImportConfig } from '../types' +import type { Wallet } from './Wallet' + +import { AgentContext } from '../agent' +import { InjectionSymbols } from '../constants' +import { Logger } from '../logger' +import { inject, injectable } from '../plugins' +import { StorageUpdateService } from '../storage' +import { CURRENT_FRAMEWORK_STORAGE_VERSION } from '../storage/migration/updates' + +import { WalletError } from './error/WalletError' +import { WalletNotFoundError } from './error/WalletNotFoundError' + +@injectable() +export class WalletApi { + private agentContext: AgentContext + private wallet: Wallet + private storageUpdateService: StorageUpdateService + private logger: Logger + private _walletConfig?: WalletConfig + + public constructor( + storageUpdateService: StorageUpdateService, + agentContext: AgentContext, + @inject(InjectionSymbols.Logger) logger: Logger + ) { + this.storageUpdateService = storageUpdateService + this.logger = logger + this.wallet = agentContext.wallet + this.agentContext = agentContext + } + + public get isInitialized() { + return this.wallet.isInitialized + } + + public get isProvisioned() { + return this.wallet.isProvisioned + } + + public get walletConfig() { + return this._walletConfig + } + + public async initialize(walletConfig: WalletConfig): Promise { + this.logger.info(`Initializing wallet '${walletConfig.id}'`, walletConfig) + + if (this.isInitialized) { + throw new WalletError( + 'Wallet instance already initialized. Close the currently opened wallet before re-initializing the wallet' + ) + } + + // Open wallet, creating if it doesn't exist yet + try { + await this.open(walletConfig) + } catch (error) { + // If the wallet does not exist yet, create it and try to open again + if (error instanceof WalletNotFoundError) { + // Keep the wallet open after creating it, this saves an extra round trip of closing/opening + // the wallet, which can save quite some time. + await this.createAndOpen(walletConfig) + } else { + throw error + } + } + } + + public async createAndOpen(walletConfig: WalletConfig): Promise { + // Always keep the wallet open, as we still need to store the storage version in the wallet. + await this.wallet.createAndOpen(walletConfig) + + this._walletConfig = walletConfig + + // Store the storage version in the wallet + await this.storageUpdateService.setCurrentStorageVersion(this.agentContext, CURRENT_FRAMEWORK_STORAGE_VERSION) + } + + public async create(walletConfig: WalletConfig): Promise { + await this.createAndOpen(walletConfig) + await this.close() + } + + public async open(walletConfig: WalletConfig): Promise { + await this.wallet.open(walletConfig) + this._walletConfig = walletConfig + } + + public async close(): Promise { + await this.wallet.close() + } + + public async rotateKey(walletConfig: WalletConfigRekey): Promise { + await this.wallet.rotateKey(walletConfig) + } + + public async delete(): Promise { + await this.wallet.delete() + } + + public async export(exportConfig: WalletExportImportConfig): Promise { + await this.wallet.export(exportConfig) + } + + public async import(walletConfig: WalletConfig, importConfig: WalletExportImportConfig): Promise { + await this.wallet.import(walletConfig, importConfig) + } +} diff --git a/packages/core/src/wallet/WalletModule.ts b/packages/core/src/wallet/WalletModule.ts index 2c318a23c8..002dda6b2f 100644 --- a/packages/core/src/wallet/WalletModule.ts +++ b/packages/core/src/wallet/WalletModule.ts @@ -1,120 +1,17 @@ -import type { DependencyManager } from '../plugins' -import type { WalletConfig, WalletConfigRekey, WalletExportImportConfig } from '../types' -import type { Wallet } from './Wallet' +import type { DependencyManager, Module } from '../plugins' -import { AgentContext } from '../agent' -import { InjectionSymbols } from '../constants' -import { Bls12381g2SigningProvider, SigningProviderToken } from '../crypto/signing-provider' -import { Logger } from '../logger' -import { inject, injectable, module } from '../plugins' -import { StorageUpdateService } from '../storage' -import { CURRENT_FRAMEWORK_STORAGE_VERSION } from '../storage/migration/updates' +import { SigningProviderToken, Bls12381g2SigningProvider } from '../crypto/signing-provider' -import { WalletError } from './error/WalletError' -import { WalletNotFoundError } from './error/WalletNotFoundError' - -@module() -@injectable() -export class WalletModule { - private agentContext: AgentContext - private wallet: Wallet - private storageUpdateService: StorageUpdateService - private logger: Logger - private _walletConfig?: WalletConfig - - public constructor( - storageUpdateService: StorageUpdateService, - agentContext: AgentContext, - @inject(InjectionSymbols.Logger) logger: Logger - ) { - this.storageUpdateService = storageUpdateService - this.logger = logger - this.wallet = agentContext.wallet - this.agentContext = agentContext - } - - public get isInitialized() { - return this.wallet.isInitialized - } - - public get isProvisioned() { - return this.wallet.isProvisioned - } - - public get walletConfig() { - return this._walletConfig - } - - public async initialize(walletConfig: WalletConfig): Promise { - this.logger.info(`Initializing wallet '${walletConfig.id}'`, walletConfig) - - if (this.isInitialized) { - throw new WalletError( - 'Wallet instance already initialized. Close the currently opened wallet before re-initializing the wallet' - ) - } - - // Open wallet, creating if it doesn't exist yet - try { - await this.open(walletConfig) - } catch (error) { - // If the wallet does not exist yet, create it and try to open again - if (error instanceof WalletNotFoundError) { - // Keep the wallet open after creating it, this saves an extra round trip of closing/opening - // the wallet, which can save quite some time. - await this.createAndOpen(walletConfig) - } else { - throw error - } - } - } - - public async createAndOpen(walletConfig: WalletConfig): Promise { - // Always keep the wallet open, as we still need to store the storage version in the wallet. - await this.wallet.createAndOpen(walletConfig) - - this._walletConfig = walletConfig - - // Store the storage version in the wallet - await this.storageUpdateService.setCurrentStorageVersion(this.agentContext, CURRENT_FRAMEWORK_STORAGE_VERSION) - } - - public async create(walletConfig: WalletConfig): Promise { - await this.createAndOpen(walletConfig) - await this.close() - } - - public async open(walletConfig: WalletConfig): Promise { - await this.wallet.open(walletConfig) - this._walletConfig = walletConfig - } - - public async close(): Promise { - await this.wallet.close() - } - - public async rotateKey(walletConfig: WalletConfigRekey): Promise { - await this.wallet.rotateKey(walletConfig) - } - - public async delete(): Promise { - await this.wallet.delete() - } - - public async export(exportConfig: WalletExportImportConfig): Promise { - await this.wallet.export(exportConfig) - } - - public async import(walletConfig: WalletConfig, importConfig: WalletExportImportConfig): Promise { - await this.wallet.import(walletConfig, importConfig) - } +import { WalletApi } from './WalletApi' +// TODO: this should be moved into the modules directory +export class WalletModule implements Module { /** * Registers the dependencies of the wallet module on the injection dependencyManager. */ - public static register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager) { // Api - dependencyManager.registerContextScoped(WalletModule) + dependencyManager.registerContextScoped(WalletApi) // Signing providers. dependencyManager.registerSingleton(SigningProviderToken, Bls12381g2SigningProvider) diff --git a/packages/core/src/wallet/__tests__/WalletModule.test.ts b/packages/core/src/wallet/__tests__/WalletModule.test.ts new file mode 100644 index 0000000000..894c911d58 --- /dev/null +++ b/packages/core/src/wallet/__tests__/WalletModule.test.ts @@ -0,0 +1,17 @@ +import { DependencyManager } from '../../plugins/DependencyManager' +import { WalletApi } from '../WalletApi' +import { WalletModule } from '../WalletModule' + +jest.mock('../../plugins/DependencyManager') +const DependencyManagerMock = DependencyManager as jest.Mock + +const dependencyManager = new DependencyManagerMock() + +describe('WalletModule', () => { + test('registers dependencies on the dependency manager', () => { + new WalletModule().register(dependencyManager) + + expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) + expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(WalletApi) + }) +}) diff --git a/packages/core/src/wallet/index.ts b/packages/core/src/wallet/index.ts index c9f6729d0c..e60dcfdb68 100644 --- a/packages/core/src/wallet/index.ts +++ b/packages/core/src/wallet/index.ts @@ -1 +1,3 @@ export * from './Wallet' +export * from './WalletApi' +export * from './WalletModule' diff --git a/packages/module-tenants/src/TenantsModule.ts b/packages/module-tenants/src/TenantsModule.ts index 5dac760487..190f165c3e 100644 --- a/packages/module-tenants/src/TenantsModule.ts +++ b/packages/module-tenants/src/TenantsModule.ts @@ -1,23 +1,33 @@ -import type { DependencyManager } from '@aries-framework/core' +import type { TenantsModuleConfigOptions } from './TenantsModuleConfig' +import type { DependencyManager, Module } from '@aries-framework/core' -import { InjectionSymbols, module } from '@aries-framework/core' +import { InjectionSymbols } from '@aries-framework/core' import { TenantsApi } from './TenantsApi' +import { TenantsModuleConfig } from './TenantsModuleConfig' import { TenantAgentContextProvider } from './context/TenantAgentContextProvider' import { TenantSessionCoordinator } from './context/TenantSessionCoordinator' import { TenantRepository, TenantRoutingRepository } from './repository' import { TenantRecordService } from './services' -@module() -export class TenantsModule { +export class TenantsModule implements Module { + public readonly config: TenantsModuleConfig + + public constructor(config?: TenantsModuleConfigOptions) { + this.config = new TenantsModuleConfig(config) + } + /** * Registers the dependencies of the tenants module on the dependency manager. */ - public static register(dependencyManager: DependencyManager) { + public register(dependencyManager: DependencyManager) { // Api // NOTE: this is a singleton because tenants can't have their own tenants. This makes sure the tenants api is always used in the root agent context. dependencyManager.registerSingleton(TenantsApi) + // Config + dependencyManager.registerInstance(TenantsModuleConfig, this.config) + // Services dependencyManager.registerSingleton(TenantRecordService) diff --git a/packages/module-tenants/src/TenantsModuleConfig.ts b/packages/module-tenants/src/TenantsModuleConfig.ts new file mode 100644 index 0000000000..7fc8b7c84c --- /dev/null +++ b/packages/module-tenants/src/TenantsModuleConfig.ts @@ -0,0 +1,42 @@ +/** + * TenantsModuleConfigOptions defines the interface for the options of the TenantsModuleConfig class. + * This can contain optional parameters that have default values in the config class itself. + */ +export interface TenantsModuleConfigOptions { + /** + * Maximum number of concurrent tenant sessions that can be active at the same time. Defaults to + * 100 concurrent sessions. The default is low on purpose, to make sure deployments determine their own + * session limit based on the hardware and usage of the tenants module. Use `Infinity` to allow unlimited + * concurrent sessions. + * + * @default 100 + */ + sessionLimit?: number + + /** + * Timeout in milliseconds for acquiring a tenant session. If the {@link TenantsModuleConfigOptions.maxNumberOfSessions} is reached and + * a tenant sessions couldn't be acquired within the specified timeout, an error will be thrown and the session creation will be aborted. + * Use `Infinity` to disable the timeout. + * + * @default 1000 + */ + sessionAcquireTimeout?: number +} + +export class TenantsModuleConfig { + private options: TenantsModuleConfigOptions + + public constructor(options?: TenantsModuleConfigOptions) { + this.options = options ?? {} + } + + /** See {@link TenantsModuleConfigOptions.sessionLimit} */ + public get sessionLimit(): number { + return this.options.sessionLimit ?? 100 + } + + /** See {@link TenantsModuleConfigOptions.sessionAcquireTimeout} */ + public get sessionAcquireTimeout(): number { + return this.options.sessionAcquireTimeout ?? 1000 + } +} diff --git a/packages/module-tenants/src/__tests__/TenantsModule.test.ts b/packages/module-tenants/src/__tests__/TenantsModule.test.ts index 23529cb7f3..fb0ab20231 100644 --- a/packages/module-tenants/src/__tests__/TenantsModule.test.ts +++ b/packages/module-tenants/src/__tests__/TenantsModule.test.ts @@ -3,6 +3,7 @@ import { InjectionSymbols } from '@aries-framework/core' import { DependencyManager } from '../../../core/src/plugins/DependencyManager' import { TenantsApi } from '../TenantsApi' import { TenantsModule } from '../TenantsModule' +import { TenantsModuleConfig } from '../TenantsModuleConfig' import { TenantAgentContextProvider } from '../context/TenantAgentContextProvider' import { TenantSessionCoordinator } from '../context/TenantSessionCoordinator' import { TenantRepository, TenantRoutingRepository } from '../repository' @@ -15,7 +16,8 @@ const dependencyManager = new DependencyManagerMock() describe('TenantsModule', () => { test('registers dependencies on the dependency manager', () => { - TenantsModule.register(dependencyManager) + const tenantsModule = new TenantsModule() + tenantsModule.register(dependencyManager) expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(6) expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(TenantsApi) @@ -27,5 +29,8 @@ describe('TenantsModule', () => { TenantAgentContextProvider ) expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(TenantSessionCoordinator) + + expect(dependencyManager.registerInstance).toHaveBeenCalledTimes(1) + expect(dependencyManager.registerInstance).toHaveBeenCalledWith(TenantsModuleConfig, tenantsModule.config) }) }) diff --git a/packages/module-tenants/src/__tests__/TenantsModuleConfig.test.ts b/packages/module-tenants/src/__tests__/TenantsModuleConfig.test.ts new file mode 100644 index 0000000000..b942434bad --- /dev/null +++ b/packages/module-tenants/src/__tests__/TenantsModuleConfig.test.ts @@ -0,0 +1,20 @@ +import { TenantsModuleConfig } from '../TenantsModuleConfig' + +describe('TenantsModuleConfig', () => { + test('sets default values', () => { + const config = new TenantsModuleConfig() + + expect(config.sessionLimit).toBe(100) + expect(config.sessionAcquireTimeout).toBe(1000) + }) + + test('sets values', () => { + const config = new TenantsModuleConfig({ + sessionAcquireTimeout: 12, + sessionLimit: 42, + }) + + expect(config.sessionAcquireTimeout).toBe(12) + expect(config.sessionLimit).toBe(42) + }) +}) diff --git a/packages/module-tenants/src/context/TenantSessionCoordinator.ts b/packages/module-tenants/src/context/TenantSessionCoordinator.ts index fc4816afb0..cdc7428a86 100644 --- a/packages/module-tenants/src/context/TenantSessionCoordinator.ts +++ b/packages/module-tenants/src/context/TenantSessionCoordinator.ts @@ -9,10 +9,12 @@ import { injectable, InjectionSymbols, Logger, - WalletModule, + WalletApi, } from '@aries-framework/core' import { Mutex, withTimeout } from 'async-mutex' +import { TenantsModuleConfig } from '../TenantsModuleConfig' + import { TenantSessionMutex } from './TenantSessionMutex' /** @@ -32,14 +34,24 @@ export class TenantSessionCoordinator { private logger: Logger private tenantAgentContextMapping: TenantAgentContextMapping = {} private sessionMutex: TenantSessionMutex + private tenantsModuleConfig: TenantsModuleConfig - public constructor(rootAgentContext: AgentContext, @inject(InjectionSymbols.Logger) logger: Logger) { + public constructor( + rootAgentContext: AgentContext, + @inject(InjectionSymbols.Logger) logger: Logger, + tenantsModuleConfig: TenantsModuleConfig + ) { this.rootAgentContext = rootAgentContext this.logger = logger + this.tenantsModuleConfig = tenantsModuleConfig // TODO: we should make the timeout and the session limit configurable, but until we have the modularization in place with // module specific config, it's not easy to do so. Keeping it hardcoded for now - this.sessionMutex = new TenantSessionMutex(this.logger, 10000, 1000) + this.sessionMutex = new TenantSessionMutex( + this.logger, + this.tenantsModuleConfig.sessionLimit, + this.tenantsModuleConfig.sessionAcquireTimeout + ) } /** @@ -146,11 +158,10 @@ export class TenantSessionCoordinator { sessionCount: 0, mutex: withTimeout( new Mutex(), - // TODO: we should make the timeout configurable. // NOTE: It can take a while to create an indy wallet. We're using RAW key derivation which should // be fast enough to not cause a problem. This wil also only be problem when the wallet is being created // for the first time or being acquired while wallet initialization is in progress. - 1000, + this.tenantsModuleConfig.sessionAcquireTimeout, new AriesFrameworkError( `Error acquiring lock for tenant ${tenantId}. Wallet initialization or shutdown took too long.` ) @@ -179,11 +190,11 @@ export class TenantSessionCoordinator { tenantDependencyManager.registerInstance(AgentContext, agentContext) tenantDependencyManager.registerInstance(AgentConfig, tenantConfig) - // NOTE: we're using the wallet module here because that correctly handle creating if it doesn't exist yet + // NOTE: we're using the wallet api here because that correctly handle creating if it doesn't exist yet // and will also write the storage version to the storage, which is needed by the update assistant. We either // need to move this out of the module, or just keep using the module here. - const walletModule = agentContext.dependencyManager.resolve(WalletModule) - await walletModule.initialize(tenantRecord.config.walletConfig) + const walletApi = agentContext.dependencyManager.resolve(WalletApi) + await walletApi.initialize(tenantRecord.config.walletConfig) return agentContext } diff --git a/packages/module-tenants/src/context/__tests__/TenantSessionCoordinator.test.ts b/packages/module-tenants/src/context/__tests__/TenantSessionCoordinator.test.ts index f3766cfcc7..dd659db44c 100644 --- a/packages/module-tenants/src/context/__tests__/TenantSessionCoordinator.test.ts +++ b/packages/module-tenants/src/context/__tests__/TenantSessionCoordinator.test.ts @@ -1,11 +1,12 @@ import type { TenantAgentContextMapping } from '../TenantSessionCoordinator' import type { DependencyManager } from '@aries-framework/core' -import { WalletModule, AgentContext, AgentConfig } from '@aries-framework/core' +import { AgentContext, AgentConfig, WalletApi } from '@aries-framework/core' import { Mutex, withTimeout } from 'async-mutex' import { getAgentConfig, getAgentContext, mockFunction } from '../../../../core/tests/helpers' import testLogger from '../../../../core/tests/logger' +import { TenantsModuleConfig } from '../../TenantsModuleConfig' import { TenantRecord } from '../../repository' import { TenantSessionCoordinator } from '../TenantSessionCoordinator' import { TenantSessionMutex } from '../TenantSessionMutex' @@ -21,16 +22,17 @@ type PublicTenantAgentContextMapping = Omit { const numberOfSessions = 5 const tenantRecordPromises = [] - for (let tenantNo = 0; tenantNo <= numberOfTenants; tenantNo++) { + for (let tenantNo = 0; tenantNo < numberOfTenants; tenantNo++) { const tenantRecord = agentTenantsApi.createTenant({ config: { label: 'Agent 1 Tenant 1', diff --git a/packages/module-tenants/tests/tenants.e2e.test.ts b/packages/module-tenants/tests/tenants.e2e.test.ts index c0ca618892..a8b24a88f1 100644 --- a/packages/module-tenants/tests/tenants.e2e.test.ts +++ b/packages/module-tenants/tests/tenants.e2e.test.ts @@ -36,10 +36,10 @@ const agent2Config: InitConfig = { // and register all plugins before initializing the agent. Later, we can add the module registration // to the agent constructor. const agent1DependencyManager = new DependencyManager() -agent1DependencyManager.registerModules(TenantsModule) +agent1DependencyManager.registerModules(new TenantsModule()) const agent2DependencyManager = new DependencyManager() -agent2DependencyManager.registerModules(TenantsModule) +agent2DependencyManager.registerModules(new TenantsModule()) // Create multi-tenant agents const agent1 = new Agent(agent1Config, agentDependencies, agent1DependencyManager) diff --git a/samples/extension-module/README.md b/samples/extension-module/README.md index 95af31e4fe..952ea962f5 100644 --- a/samples/extension-module/README.md +++ b/samples/extension-module/README.md @@ -28,7 +28,7 @@ import { DummyModule, DummyApi } from './dummy' const agent = new Agent(/** agent config... */) // Register the module with it's dependencies -agent.dependencyManager.registerModules(DummyModule) +agent.dependencyManager.registerModules(new DummyModule()) const dummyApi = agent.dependencyManager.resolve(DummyApi) diff --git a/samples/extension-module/dummy/module.ts b/samples/extension-module/dummy/DummyModule.ts similarity index 56% rename from samples/extension-module/dummy/module.ts rename to samples/extension-module/dummy/DummyModule.ts index 4f23539679..9f0f50f99a 100644 --- a/samples/extension-module/dummy/module.ts +++ b/samples/extension-module/dummy/DummyModule.ts @@ -1,13 +1,10 @@ -import type { DependencyManager } from '@aries-framework/core' - -import { module } from '@aries-framework/core' +import type { DependencyManager, Module } from '@aries-framework/core' import { DummyRepository } from './repository' import { DummyService } from './services' -@module() -export class DummyModule { - public static register(dependencyManager: DependencyManager) { +export class DummyModule implements Module { + public register(dependencyManager: DependencyManager) { // Api dependencyManager.registerContextScoped(DummyModule) diff --git a/samples/extension-module/dummy/index.ts b/samples/extension-module/dummy/index.ts index 281d382e3f..3849e17339 100644 --- a/samples/extension-module/dummy/index.ts +++ b/samples/extension-module/dummy/index.ts @@ -3,4 +3,4 @@ export * from './handlers' export * from './messages' export * from './services' export * from './repository' -export * from './module' +export * from './DummyModule' diff --git a/samples/extension-module/requester.ts b/samples/extension-module/requester.ts index 7128004130..4eaf2c9e7b 100644 --- a/samples/extension-module/requester.ts +++ b/samples/extension-module/requester.ts @@ -26,7 +26,7 @@ const run = async () => { ) // Register the DummyModule - agent.dependencyManager.registerModules(DummyModule) + agent.dependencyManager.registerModules(new DummyModule()) // Register transports agent.registerOutboundTransport(wsOutboundTransport) diff --git a/samples/extension-module/responder.ts b/samples/extension-module/responder.ts index 065dc49232..8d09540a3e 100644 --- a/samples/extension-module/responder.ts +++ b/samples/extension-module/responder.ts @@ -34,7 +34,7 @@ const run = async () => { ) // Register the DummyModule - agent.dependencyManager.registerModules(DummyModule) + agent.dependencyManager.registerModules(new DummyModule()) // Register transports agent.registerInboundTransport(httpInboundTransport)