diff --git a/CHANGELOG.md b/CHANGELOG.md index 02b95e4682..f28623e4f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,39 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.2.4](https://github.com/hyperledger/aries-framework-javascript/compare/v0.2.3...v0.2.4) (2022-09-10) + +### Bug Fixes + +- avoid crash when an unexpected message arrives ([#1019](https://github.com/hyperledger/aries-framework-javascript/issues/1019)) ([2cfadd9](https://github.com/hyperledger/aries-framework-javascript/commit/2cfadd9167438a9446d26b933aa64521d8be75e7)) +- **ledger:** check taa version instad of aml version ([#1013](https://github.com/hyperledger/aries-framework-javascript/issues/1013)) ([4ca56f6](https://github.com/hyperledger/aries-framework-javascript/commit/4ca56f6b677f45aa96c91b5c5ee8df210722609e)) +- **ledger:** remove poolConnected on pool close ([#1011](https://github.com/hyperledger/aries-framework-javascript/issues/1011)) ([f0ca8b6](https://github.com/hyperledger/aries-framework-javascript/commit/f0ca8b6346385fc8c4811fbd531aa25a386fcf30)) +- **question-answer:** question answer protocol state/role check ([#1001](https://github.com/hyperledger/aries-framework-javascript/issues/1001)) ([4b90e87](https://github.com/hyperledger/aries-framework-javascript/commit/4b90e876cc8377e7518e05445beb1a6b524840c4)) + +### Features + +- Action Menu protocol (Aries RFC 0509) implementation ([#974](https://github.com/hyperledger/aries-framework-javascript/issues/974)) ([60a8091](https://github.com/hyperledger/aries-framework-javascript/commit/60a8091d6431c98f764b2b94bff13ee97187b915)) +- **routing:** add settings to control back off strategy on mediator reconnection ([#1017](https://github.com/hyperledger/aries-framework-javascript/issues/1017)) ([543437c](https://github.com/hyperledger/aries-framework-javascript/commit/543437cd94d3023139b259ee04d6ad51cf653794)) + +## [0.2.3](https://github.com/hyperledger/aries-framework-javascript/compare/v0.2.2...v0.2.3) (2022-08-30) + +### Bug Fixes + +- export the KeyDerivationMethod ([#958](https://github.com/hyperledger/aries-framework-javascript/issues/958)) ([04ab1cc](https://github.com/hyperledger/aries-framework-javascript/commit/04ab1cca853284d144fd64d35e26e9dfe77d4a1b)) +- expose oob domain ([#990](https://github.com/hyperledger/aries-framework-javascript/issues/990)) ([dad975d](https://github.com/hyperledger/aries-framework-javascript/commit/dad975d9d9b658c6b37749ece2a91381e2a314c9)) +- **generic-records:** support custom id property ([#964](https://github.com/hyperledger/aries-framework-javascript/issues/964)) ([0f690a0](https://github.com/hyperledger/aries-framework-javascript/commit/0f690a0564a25204cacfae7cd958f660f777567e)) + +### Features + +- always initialize mediator ([#985](https://github.com/hyperledger/aries-framework-javascript/issues/985)) ([b699977](https://github.com/hyperledger/aries-framework-javascript/commit/b69997744ac9e30ffba22daac7789216d2683e36)) +- delete by record id ([#983](https://github.com/hyperledger/aries-framework-javascript/issues/983)) ([d8a30d9](https://github.com/hyperledger/aries-framework-javascript/commit/d8a30d94d336cf3417c2cd00a8110185dde6a106)) +- **ledger:** handle REQNACK response for write request ([#967](https://github.com/hyperledger/aries-framework-javascript/issues/967)) ([6468a93](https://github.com/hyperledger/aries-framework-javascript/commit/6468a9311c8458615871e1e85ba3f3b560453715)) +- OOB public did ([#930](https://github.com/hyperledger/aries-framework-javascript/issues/930)) ([c99f3c9](https://github.com/hyperledger/aries-framework-javascript/commit/c99f3c9152a79ca6a0a24fdc93e7f3bebbb9d084)) +- **proofs:** present proof as nested protocol ([#972](https://github.com/hyperledger/aries-framework-javascript/issues/972)) ([52247d9](https://github.com/hyperledger/aries-framework-javascript/commit/52247d997c5910924d3099c736dd2e20ec86a214)) +- **routing:** manual mediator pickup lifecycle management ([#989](https://github.com/hyperledger/aries-framework-javascript/issues/989)) ([69d4906](https://github.com/hyperledger/aries-framework-javascript/commit/69d4906a0ceb8a311ca6bdad5ed6d2048335109a)) +- **routing:** pickup v2 mediator role basic implementation ([#975](https://github.com/hyperledger/aries-framework-javascript/issues/975)) ([a989556](https://github.com/hyperledger/aries-framework-javascript/commit/a98955666853471d504f8a5c8c4623e18ba8c8ed)) +- **routing:** support promise in message repo ([#959](https://github.com/hyperledger/aries-framework-javascript/issues/959)) ([79c5d8d](https://github.com/hyperledger/aries-framework-javascript/commit/79c5d8d76512b641167bce46e82f34cf22bc285e)) + ## [0.2.2](https://github.com/hyperledger/aries-framework-javascript/compare/v0.2.1...v0.2.2) (2022-07-15) ### Bug Fixes diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 1d626e75a5..ff84db7f6e 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -10,3 +10,4 @@ | Karim Stekelenburg | [@karimStekelenburg](https://github.com/karimStekelenburg) | ssi_karim#3505 | | Timo Glastra | [@TimoGlastra](https://github.com/TimoGlastra) | TimoGlastra#2988 | | Ariel Gentile | [@genaris](https://github.com/genaris) | GenAris#4962 | +| Jan Rietveld | [@janrtvld](https://github.com/janrtvld) | janrtvld#3868 | diff --git a/demo/src/Alice.ts b/demo/src/Alice.ts index 54eae3b019..67b100f8c6 100644 --- a/demo/src/Alice.ts +++ b/demo/src/Alice.ts @@ -1,18 +1,11 @@ -import type { - ConnectionRecord, - ConnectionStateChangedEvent, - CredentialExchangeRecord, - ProofRecord, -} from '@aries-framework/core' - -import { ConnectionEventTypes } from '@aries-framework/core' +import type { ConnectionRecord, CredentialExchangeRecord, ProofRecord } from '@aries-framework/core' import { BaseAgent } from './BaseAgent' import { greenText, Output, redText } from './OutputClass' export class Alice extends BaseAgent { - public outOfBandId?: string public connected: boolean + public connectionRecordFaberId?: string public constructor(port: number, name: string) { super(port, name) @@ -26,74 +19,30 @@ export class Alice extends BaseAgent { } private async getConnectionRecord() { - if (!this.outOfBandId) { - throw Error(redText(Output.MissingConnectionRecord)) - } - - const [connection] = await this.agent.connections.findAllByOutOfBandId(this.outOfBandId) - - if (!connection) { + if (!this.connectionRecordFaberId) { throw Error(redText(Output.MissingConnectionRecord)) } - - return connection + return await this.agent.connections.getById(this.connectionRecordFaberId) } - private async printConnectionInvite() { - const outOfBand = await this.agent.oob.createInvitation() - this.outOfBandId = outOfBand.id - - console.log( - Output.ConnectionLink, - outOfBand.outOfBandInvitation.toUrl({ domain: `http://localhost:${this.port}` }), - '\n' - ) - } - - private async waitForConnection() { - if (!this.outOfBandId) { - throw new Error(redText(Output.MissingConnectionRecord)) + private async receiveConnectionRequest(invitationUrl: string) { + const { connectionRecord } = await this.agent.oob.receiveInvitationFromUrl(invitationUrl) + if (!connectionRecord) { + throw new Error(redText(Output.NoConnectionRecordFromOutOfBand)) } + return connectionRecord + } - console.log('Waiting for Faber to finish connection...') - - const getConnectionRecord = (outOfBandId: string) => - new Promise((resolve, reject) => { - // Timeout of 20 seconds - const timeoutId = setTimeout(() => reject(new Error(redText(Output.MissingConnectionRecord))), 20000) - - // Start listener - this.agent.events.on(ConnectionEventTypes.ConnectionStateChanged, (e) => { - if (e.payload.connectionRecord.outOfBandId !== outOfBandId) return - - clearTimeout(timeoutId) - resolve(e.payload.connectionRecord) - }) - - // Also retrieve the connection record by invitation if the event has already fired - void this.agent.connections.findAllByOutOfBandId(outOfBandId).then(([connectionRecord]) => { - if (connectionRecord) { - clearTimeout(timeoutId) - resolve(connectionRecord) - } - }) - }) - - const connectionRecord = await getConnectionRecord(this.outOfBandId) - - try { - await this.agent.connections.returnWhenIsConnected(connectionRecord.id) - } catch (e) { - console.log(redText(`\nTimeout of 20 seconds reached.. Returning to home screen.\n`)) - return - } - console.log(greenText(Output.ConnectionEstablished)) + private async waitForConnection(connectionRecord: ConnectionRecord) { + connectionRecord = await this.agent.connections.returnWhenIsConnected(connectionRecord.id) this.connected = true + console.log(greenText(Output.ConnectionEstablished)) + return connectionRecord.id } - public async setupConnection() { - await this.printConnectionInvite() - await this.waitForConnection() + public async acceptConnection(invitation_url: string) { + const connectionRecord = await this.receiveConnectionRequest(invitation_url) + this.connectionRecordFaberId = await this.waitForConnection(connectionRecord) } public async acceptCredentialOffer(credentialRecord: CredentialExchangeRecord) { diff --git a/demo/src/AliceInquirer.ts b/demo/src/AliceInquirer.ts index 9f82717246..457d33b528 100644 --- a/demo/src/AliceInquirer.ts +++ b/demo/src/AliceInquirer.ts @@ -17,7 +17,7 @@ export const runAlice = async () => { } enum PromptOptions { - CreateConnection = 'Create connection invitation', + ReceiveConnectionUrl = 'Receive connection invitation', SendMessage = 'Send message', Exit = 'Exit', Restart = 'Restart', @@ -42,9 +42,9 @@ export class AliceInquirer extends BaseInquirer { } private async getPromptChoice() { - if (this.alice.outOfBandId) return inquirer.prompt([this.inquireOptions(this.promptOptionsString)]) + if (this.alice.connectionRecordFaberId) return inquirer.prompt([this.inquireOptions(this.promptOptionsString)]) - const reducedOption = [PromptOptions.CreateConnection, PromptOptions.Exit, PromptOptions.Restart] + const reducedOption = [PromptOptions.ReceiveConnectionUrl, PromptOptions.Exit, PromptOptions.Restart] return inquirer.prompt([this.inquireOptions(reducedOption)]) } @@ -53,7 +53,7 @@ export class AliceInquirer extends BaseInquirer { if (this.listener.on) return switch (choice.options) { - case PromptOptions.CreateConnection: + case PromptOptions.ReceiveConnectionUrl: await this.connection() break case PromptOptions.SendMessage: @@ -88,7 +88,9 @@ export class AliceInquirer extends BaseInquirer { } public async connection() { - await this.alice.setupConnection() + const title = Title.InvitationTitle + const getUrl = await inquirer.prompt([this.inquireInput(title)]) + await this.alice.acceptConnection(getUrl.input) if (!this.alice.connected) return this.listener.credentialOfferListener(this.alice, this) diff --git a/demo/src/Faber.ts b/demo/src/Faber.ts index e94b3a922b..8d127c1a43 100644 --- a/demo/src/Faber.ts +++ b/demo/src/Faber.ts @@ -1,4 +1,4 @@ -import type { ConnectionRecord } from '@aries-framework/core' +import type { ConnectionRecord, ConnectionStateChangedEvent } from '@aries-framework/core' import type { CredDef, Schema } from 'indy-sdk' import type BottomBar from 'inquirer/lib/ui/bottom-bar' @@ -8,6 +8,7 @@ import { ProofProtocolVersion, utils, V1CredentialPreview, + ConnectionEventTypes, } from '@aries-framework/core' import { ui } from 'inquirer' @@ -15,7 +16,7 @@ import { BaseAgent } from './BaseAgent' import { Color, greenText, Output, purpleText, redText } from './OutputClass' export class Faber extends BaseAgent { - public connectionRecordAliceId?: string + public outOfBandId?: string public credentialDefinition?: CredDef public ui: BottomBar @@ -31,29 +32,73 @@ export class Faber extends BaseAgent { } private async getConnectionRecord() { - if (!this.connectionRecordAliceId) { + if (!this.outOfBandId) { throw Error(redText(Output.MissingConnectionRecord)) } - return await this.agent.connections.getById(this.connectionRecordAliceId) - } - private async receiveConnectionRequest(invitationUrl: string) { - const { connectionRecord } = await this.agent.oob.receiveInvitationFromUrl(invitationUrl) - if (!connectionRecord) { - throw new Error(redText(Output.NoConnectionRecordFromOutOfBand)) + const [connection] = await this.agent.connections.findAllByOutOfBandId(this.outOfBandId) + + if (!connection) { + throw Error(redText(Output.MissingConnectionRecord)) } - return connectionRecord + + return connection } - private async waitForConnection(connectionRecord: ConnectionRecord) { - connectionRecord = await this.agent.connections.returnWhenIsConnected(connectionRecord.id) + private async printConnectionInvite() { + const outOfBand = await this.agent.oob.createInvitation() + this.outOfBandId = outOfBand.id + + console.log( + Output.ConnectionLink, + outOfBand.outOfBandInvitation.toUrl({ domain: `http://localhost:${this.port}` }), + '\n' + ) + } + + private async waitForConnection() { + if (!this.outOfBandId) { + throw new Error(redText(Output.MissingConnectionRecord)) + } + + console.log('Waiting for Alice to finish connection...') + + const getConnectionRecord = (outOfBandId: string) => + new Promise((resolve, reject) => { + // Timeout of 20 seconds + const timeoutId = setTimeout(() => reject(new Error(redText(Output.MissingConnectionRecord))), 20000) + + // Start listener + this.agent.events.on(ConnectionEventTypes.ConnectionStateChanged, (e) => { + if (e.payload.connectionRecord.outOfBandId !== outOfBandId) return + + clearTimeout(timeoutId) + resolve(e.payload.connectionRecord) + }) + + // Also retrieve the connection record by invitation if the event has already fired + void this.agent.connections.findAllByOutOfBandId(outOfBandId).then(([connectionRecord]) => { + if (connectionRecord) { + clearTimeout(timeoutId) + resolve(connectionRecord) + } + }) + }) + + const connectionRecord = await getConnectionRecord(this.outOfBandId) + + try { + await this.agent.connections.returnWhenIsConnected(connectionRecord.id) + } catch (e) { + console.log(redText(`\nTimeout of 20 seconds reached.. Returning to home screen.\n`)) + return + } console.log(greenText(Output.ConnectionEstablished)) - return connectionRecord.id } - public async acceptConnection(invitation_url: string) { - const connectionRecord = await this.receiveConnectionRequest(invitation_url) - this.connectionRecordAliceId = await this.waitForConnection(connectionRecord) + public async setupConnection() { + await this.printConnectionInvite() + await this.waitForConnection() } private printSchema(name: string, version: string, attributes: string[]) { diff --git a/demo/src/FaberInquirer.ts b/demo/src/FaberInquirer.ts index a61ec60175..98c1ccabb6 100644 --- a/demo/src/FaberInquirer.ts +++ b/demo/src/FaberInquirer.ts @@ -15,7 +15,7 @@ export const runFaber = async () => { } enum PromptOptions { - ReceiveConnectionUrl = 'Receive connection invitation', + CreateConnection = 'Create connection invitation', OfferCredential = 'Offer credential', RequestProof = 'Request proof', SendMessage = 'Send message', @@ -42,9 +42,9 @@ export class FaberInquirer extends BaseInquirer { } private async getPromptChoice() { - if (this.faber.connectionRecordAliceId) return inquirer.prompt([this.inquireOptions(this.promptOptionsString)]) + if (this.faber.outOfBandId) return inquirer.prompt([this.inquireOptions(this.promptOptionsString)]) - const reducedOption = [PromptOptions.ReceiveConnectionUrl, PromptOptions.Exit, PromptOptions.Restart] + const reducedOption = [PromptOptions.CreateConnection, PromptOptions.Exit, PromptOptions.Restart] return inquirer.prompt([this.inquireOptions(reducedOption)]) } @@ -53,7 +53,7 @@ export class FaberInquirer extends BaseInquirer { if (this.listener.on) return switch (choice.options) { - case PromptOptions.ReceiveConnectionUrl: + case PromptOptions.CreateConnection: await this.connection() break case PromptOptions.OfferCredential: @@ -76,9 +76,7 @@ export class FaberInquirer extends BaseInquirer { } public async connection() { - const title = Title.InvitationTitle - const getUrl = await inquirer.prompt([this.inquireInput(title)]) - await this.faber.acceptConnection(getUrl.input) + await this.faber.setupConnection() } public async exitUseCase(title: string) { @@ -104,7 +102,7 @@ export class FaberInquirer extends BaseInquirer { public async message() { const message = await this.inquireMessage() - if (message) return + if (!message) return await this.faber.sendMessage(message) } diff --git a/demo/src/OutputClass.ts b/demo/src/OutputClass.ts index 3d7b9ebff3..b9e69c72f0 100644 --- a/demo/src/OutputClass.ts +++ b/demo/src/OutputClass.ts @@ -9,7 +9,7 @@ export enum Output { NoConnectionRecordFromOutOfBand = `\nNo connectionRecord has been created from invitation\n`, ConnectionEstablished = `\nConnection established!`, MissingConnectionRecord = `\nNo connectionRecord ID has been set yet\n`, - ConnectionLink = `\nRun 'Receive connection invitation' in Faber and paste this invitation link:\n\n`, + ConnectionLink = `\nRun 'Receive connection invitation' in Alice and paste this invitation link:\n\n`, Exit = 'Shutting down agent...\nExiting...', } diff --git a/lerna.json b/lerna.json index 6e0c665e15..86f806459b 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "packages": ["packages/*"], - "version": "0.2.2", + "version": "0.2.4", "useWorkspaces": true, "npmClient": "yarn", "command": { diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index dc994f8cc0..20c7b345be 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -3,6 +3,39 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.2.4](https://github.com/hyperledger/aries-framework-javascript/compare/v0.2.3...v0.2.4) (2022-09-10) + +### Bug Fixes + +- avoid crash when an unexpected message arrives ([#1019](https://github.com/hyperledger/aries-framework-javascript/issues/1019)) ([2cfadd9](https://github.com/hyperledger/aries-framework-javascript/commit/2cfadd9167438a9446d26b933aa64521d8be75e7)) +- **ledger:** check taa version instad of aml version ([#1013](https://github.com/hyperledger/aries-framework-javascript/issues/1013)) ([4ca56f6](https://github.com/hyperledger/aries-framework-javascript/commit/4ca56f6b677f45aa96c91b5c5ee8df210722609e)) +- **ledger:** remove poolConnected on pool close ([#1011](https://github.com/hyperledger/aries-framework-javascript/issues/1011)) ([f0ca8b6](https://github.com/hyperledger/aries-framework-javascript/commit/f0ca8b6346385fc8c4811fbd531aa25a386fcf30)) +- **question-answer:** question answer protocol state/role check ([#1001](https://github.com/hyperledger/aries-framework-javascript/issues/1001)) ([4b90e87](https://github.com/hyperledger/aries-framework-javascript/commit/4b90e876cc8377e7518e05445beb1a6b524840c4)) + +### Features + +- Action Menu protocol (Aries RFC 0509) implementation ([#974](https://github.com/hyperledger/aries-framework-javascript/issues/974)) ([60a8091](https://github.com/hyperledger/aries-framework-javascript/commit/60a8091d6431c98f764b2b94bff13ee97187b915)) +- **routing:** add settings to control back off strategy on mediator reconnection ([#1017](https://github.com/hyperledger/aries-framework-javascript/issues/1017)) ([543437c](https://github.com/hyperledger/aries-framework-javascript/commit/543437cd94d3023139b259ee04d6ad51cf653794)) + +## [0.2.3](https://github.com/hyperledger/aries-framework-javascript/compare/v0.2.2...v0.2.3) (2022-08-30) + +### Bug Fixes + +- export the KeyDerivationMethod ([#958](https://github.com/hyperledger/aries-framework-javascript/issues/958)) ([04ab1cc](https://github.com/hyperledger/aries-framework-javascript/commit/04ab1cca853284d144fd64d35e26e9dfe77d4a1b)) +- expose oob domain ([#990](https://github.com/hyperledger/aries-framework-javascript/issues/990)) ([dad975d](https://github.com/hyperledger/aries-framework-javascript/commit/dad975d9d9b658c6b37749ece2a91381e2a314c9)) +- **generic-records:** support custom id property ([#964](https://github.com/hyperledger/aries-framework-javascript/issues/964)) ([0f690a0](https://github.com/hyperledger/aries-framework-javascript/commit/0f690a0564a25204cacfae7cd958f660f777567e)) + +### Features + +- always initialize mediator ([#985](https://github.com/hyperledger/aries-framework-javascript/issues/985)) ([b699977](https://github.com/hyperledger/aries-framework-javascript/commit/b69997744ac9e30ffba22daac7789216d2683e36)) +- delete by record id ([#983](https://github.com/hyperledger/aries-framework-javascript/issues/983)) ([d8a30d9](https://github.com/hyperledger/aries-framework-javascript/commit/d8a30d94d336cf3417c2cd00a8110185dde6a106)) +- **ledger:** handle REQNACK response for write request ([#967](https://github.com/hyperledger/aries-framework-javascript/issues/967)) ([6468a93](https://github.com/hyperledger/aries-framework-javascript/commit/6468a9311c8458615871e1e85ba3f3b560453715)) +- OOB public did ([#930](https://github.com/hyperledger/aries-framework-javascript/issues/930)) ([c99f3c9](https://github.com/hyperledger/aries-framework-javascript/commit/c99f3c9152a79ca6a0a24fdc93e7f3bebbb9d084)) +- **proofs:** present proof as nested protocol ([#972](https://github.com/hyperledger/aries-framework-javascript/issues/972)) ([52247d9](https://github.com/hyperledger/aries-framework-javascript/commit/52247d997c5910924d3099c736dd2e20ec86a214)) +- **routing:** manual mediator pickup lifecycle management ([#989](https://github.com/hyperledger/aries-framework-javascript/issues/989)) ([69d4906](https://github.com/hyperledger/aries-framework-javascript/commit/69d4906a0ceb8a311ca6bdad5ed6d2048335109a)) +- **routing:** pickup v2 mediator role basic implementation ([#975](https://github.com/hyperledger/aries-framework-javascript/issues/975)) ([a989556](https://github.com/hyperledger/aries-framework-javascript/commit/a98955666853471d504f8a5c8c4623e18ba8c8ed)) +- **routing:** support promise in message repo ([#959](https://github.com/hyperledger/aries-framework-javascript/issues/959)) ([79c5d8d](https://github.com/hyperledger/aries-framework-javascript/commit/79c5d8d76512b641167bce46e82f34cf22bc285e)) + ## [0.2.2](https://github.com/hyperledger/aries-framework-javascript/compare/v0.2.1...v0.2.2) (2022-07-15) ### Bug Fixes diff --git a/packages/core/package.json b/packages/core/package.json index 3858bbcac0..6d6add7046 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -2,7 +2,7 @@ "name": "@aries-framework/core", "main": "build/index", "types": "build/index", - "version": "0.2.2", + "version": "0.2.4", "files": [ "build" ], diff --git a/packages/core/src/agent/Agent.ts b/packages/core/src/agent/Agent.ts index 09c2e6f89f..1d57029d15 100644 --- a/packages/core/src/agent/Agent.ts +++ b/packages/core/src/agent/Agent.ts @@ -114,10 +114,14 @@ export class Agent extends .pipe( takeUntil(stop$), concatMap((e) => - this.messageReceiver.receiveMessage(e.payload.message, { - connection: e.payload.connection, - contextCorrelationId: e.payload.contextCorrelationId, - }) + this.messageReceiver + .receiveMessage(e.payload.message, { + connection: e.payload.connection, + contextCorrelationId: e.payload.contextCorrelationId, + }) + .catch((error) => { + this.logger.error('Failed to process message', { error }) + }) ) ) .subscribe() diff --git a/packages/core/src/agent/AgentConfig.ts b/packages/core/src/agent/AgentConfig.ts index be90bdf17b..10d56e61da 100644 --- a/packages/core/src/agent/AgentConfig.ts +++ b/packages/core/src/agent/AgentConfig.ts @@ -105,6 +105,24 @@ export class AgentConfig { return this.initConfig.maximumMessagePickup ?? 10 } + public get baseMediatorReconnectionIntervalMs() { + return this.initConfig.baseMediatorReconnectionIntervalMs ?? 100 + } + + public get maximumMediatorReconnectionIntervalMs() { + return this.initConfig.maximumMediatorReconnectionIntervalMs ?? Number.POSITIVE_INFINITY + } + + /** + * Encode keys in did:key format instead of 'naked' keys, as stated in Aries RFC 0360. + * + * This setting will not be taken into account if the other party has previously used naked keys + * in a given protocol (i.e. it does not support Aries RFC 0360). + */ + public get useDidKeyInProtocols() { + return this.initConfig.useDidKeyInProtocols ?? false + } + public get endpoints(): [string, ...string[]] { // if endpoints is not set, return queue endpoint // https://github.com/hyperledger/aries-rfcs/issues/405#issuecomment-582612875 diff --git a/packages/core/src/agent/AgentModules.ts b/packages/core/src/agent/AgentModules.ts index 05055ebf97..5a0ed49d24 100644 --- a/packages/core/src/agent/AgentModules.ts +++ b/packages/core/src/agent/AgentModules.ts @@ -2,6 +2,7 @@ import type { Module, DependencyManager } from '../plugins' import type { Constructor } from '../utils/mixins' import type { AgentConfig } from './AgentConfig' +import { ActionMenuModule } from '../modules/action-menu' import { BasicMessagesModule } from '../modules/basic-messages' import { ConnectionsModule } from '../modules/connections' import { CredentialsModule } from '../modules/credentials' @@ -116,6 +117,7 @@ function getDefaultAgentModules(agentConfig: AgentConfig) { mediatorPollingInterval: agentConfig.mediatorPollingInterval, }), basicMessages: () => new BasicMessagesModule(), + actionMenu: () => new ActionMenuModule(), genericRecords: () => new GenericRecordsModule(), ledger: () => new LedgerModule({ diff --git a/packages/core/src/agent/BaseAgent.ts b/packages/core/src/agent/BaseAgent.ts index 00477a6b5c..847e8ffe57 100644 --- a/packages/core/src/agent/BaseAgent.ts +++ b/packages/core/src/agent/BaseAgent.ts @@ -5,6 +5,7 @@ import type { AgentApi, EmptyModuleMap, ModulesMap, WithoutDefaultModules } from import type { TransportSession } from './TransportService' import { AriesFrameworkError } from '../error' +import { ActionMenuApi } from '../modules/action-menu' import { BasicMessagesApi } from '../modules/basic-messages' import { ConnectionsApi } from '../modules/connections' import { CredentialsApi } from '../modules/credentials' @@ -47,6 +48,7 @@ export abstract class BaseAgent - keyReferenceToKey(didDocument, recipientKey) - ) - - // DidCommV1Service has keys encoded as key references - didCommServices.push({ - id: didCommService.id, - recipientKeys, - routingKeys, - serviceEndpoint: didCommService.serviceEndpoint, - }) - } - } - - return didCommServices - } - private async retrieveServicesByConnection( agentContext: AgentContext, connection: ConnectionRecord, @@ -417,14 +378,15 @@ export class MessageSender { if (connection.theirDid) { this.logger.debug(`Resolving services for connection theirDid ${connection.theirDid}.`) - didCommServices = await this.retrieveServicesFromDid(agentContext, connection.theirDid) + didCommServices = await this.didCommDocumentService.resolveServicesFromDid(agentContext, connection.theirDid) } else if (outOfBand) { - this.logger.debug(`Resolving services from out-of-band record ${outOfBand?.id}.`) + this.logger.debug(`Resolving services from out-of-band record ${outOfBand.id}.`) if (connection.isRequester) { - for (const service of outOfBand.outOfBandInvitation.services) { + for (const service of outOfBand.outOfBandInvitation.getServices()) { // Resolve dids to DIDDocs to retrieve services if (typeof service === 'string') { - didCommServices = await this.retrieveServicesFromDid(agentContext, service) + this.logger.debug(`Resolving services for did ${service}.`) + didCommServices.push(...(await this.didCommDocumentService.resolveServicesFromDid(agentContext, service))) } else { // Out of band inline service contains keys encoded as did:key references didCommServices.push({ diff --git a/packages/core/src/agent/__tests__/Agent.test.ts b/packages/core/src/agent/__tests__/Agent.test.ts index 2eca3350be..abfc426a33 100644 --- a/packages/core/src/agent/__tests__/Agent.test.ts +++ b/packages/core/src/agent/__tests__/Agent.test.ts @@ -251,6 +251,7 @@ describe('Agent', () => { expect(protocols).toEqual( expect.arrayContaining([ + 'https://didcomm.org/action-menu/1.0', 'https://didcomm.org/basicmessage/1.0', 'https://didcomm.org/connections/1.0', 'https://didcomm.org/coordinate-mediation/1.0', @@ -267,6 +268,6 @@ describe('Agent', () => { 'https://didcomm.org/revocation_notification/2.0', ]) ) - expect(protocols.length).toEqual(14) + expect(protocols.length).toEqual(15) }) }) diff --git a/packages/core/src/agent/__tests__/AgentModules.test.ts b/packages/core/src/agent/__tests__/AgentModules.test.ts index cfb88ab7b0..ba632aeca8 100644 --- a/packages/core/src/agent/__tests__/AgentModules.test.ts +++ b/packages/core/src/agent/__tests__/AgentModules.test.ts @@ -1,6 +1,7 @@ import type { Module } from '../../plugins' import { getAgentConfig } from '../../../tests/helpers' +import { ActionMenuModule } from '../../modules/action-menu' import { BasicMessagesModule } from '../../modules/basic-messages' import { ConnectionsModule } from '../../modules/connections' import { CredentialsModule } from '../../modules/credentials' @@ -64,6 +65,7 @@ describe('AgentModules', () => { mediator: expect.any(MediatorModule), mediationRecipient: expect.any(RecipientModule), basicMessages: expect.any(BasicMessagesModule), + actionMenu: expect.any(ActionMenuModule), genericRecords: expect.any(GenericRecordsModule), ledger: expect.any(LedgerModule), discovery: expect.any(DiscoverFeaturesModule), @@ -88,6 +90,7 @@ describe('AgentModules', () => { mediator: expect.any(MediatorModule), mediationRecipient: expect.any(RecipientModule), basicMessages: expect.any(BasicMessagesModule), + actionMenu: expect.any(ActionMenuModule), genericRecords: expect.any(GenericRecordsModule), ledger: expect.any(LedgerModule), discovery: expect.any(DiscoverFeaturesModule), @@ -115,6 +118,7 @@ describe('AgentModules', () => { mediator: expect.any(MediatorModule), mediationRecipient: expect.any(RecipientModule), basicMessages: expect.any(BasicMessagesModule), + actionMenu: expect.any(ActionMenuModule), genericRecords: expect.any(GenericRecordsModule), ledger: expect.any(LedgerModule), discovery: expect.any(DiscoverFeaturesModule), diff --git a/packages/core/src/agent/__tests__/MessageSender.test.ts b/packages/core/src/agent/__tests__/MessageSender.test.ts index 96adad3bdd..7776df1ea8 100644 --- a/packages/core/src/agent/__tests__/MessageSender.test.ts +++ b/packages/core/src/agent/__tests__/MessageSender.test.ts @@ -1,18 +1,20 @@ import type { ConnectionRecord } from '../../modules/connections' +import type { ResolvedDidCommService } from '../../modules/didcomm' import type { DidDocumentService } from '../../modules/dids' import type { MessageRepository } from '../../storage/MessageRepository' import type { OutboundTransport } from '../../transport' import type { OutboundMessage, EncryptedMessage } from '../../types' -import type { ResolvedDidCommService } from '../MessageSender' import { TestMessage } from '../../../tests/TestMessage' import { getAgentConfig, getAgentContext, getMockConnection, mockFunction } from '../../../tests/helpers' import testLogger from '../../../tests/logger' import { Key, KeyType } from '../../crypto' import { ReturnRouteTypes } from '../../decorators/transport/TransportDecorator' -import { DidDocument, VerificationMethod } from '../../modules/dids' +import { DidCommDocumentService } from '../../modules/didcomm' +import { DidResolverService, DidDocument, VerificationMethod } from '../../modules/dids' import { DidCommV1Service } from '../../modules/dids/domain/service/DidCommV1Service' -import { DidResolverService } from '../../modules/dids/services/DidResolverService' +import { verkeyToInstanceOfKey } from '../../modules/dids/helpers' +import { OutOfBandRepository } from '../../modules/oob' import { InMemoryMessageRepository } from '../../storage/InMemoryMessageRepository' import { EnvelopeService as EnvelopeServiceImpl } from '../EnvelopeService' import { MessageSender } from '../MessageSender' @@ -24,11 +26,15 @@ import { DummyTransportSession } from './stubs' jest.mock('../TransportService') jest.mock('../EnvelopeService') jest.mock('../../modules/dids/services/DidResolverService') +jest.mock('../../modules/didcomm/services/DidCommDocumentService') +jest.mock('../../modules/oob/repository/OutOfBandRepository') const logger = testLogger const TransportServiceMock = TransportService as jest.MockedClass const DidResolverServiceMock = DidResolverService as jest.Mock +const DidCommDocumentServiceMock = DidCommDocumentService as jest.Mock +const OutOfBandRepositoryMock = OutOfBandRepository as jest.Mock class DummyHttpOutboundTransport implements OutboundTransport { public start(): Promise { @@ -76,7 +82,10 @@ describe('MessageSender', () => { const envelopeServicePackMessageMock = mockFunction(enveloperService.packMessage) const didResolverService = new DidResolverServiceMock() + const didCommDocumentService = new DidCommDocumentServiceMock() + const outOfBandRepository = new OutOfBandRepositoryMock() const didResolverServiceResolveMock = mockFunction(didResolverService.resolveDidDocument) + const didResolverServiceResolveDidServicesMock = mockFunction(didCommDocumentService.resolveServicesFromDid) const inboundMessage = new TestMessage() inboundMessage.setReturnRouting(ReturnRouteTypes.all) @@ -132,7 +141,9 @@ describe('MessageSender', () => { transportService, messageRepository, logger, - didResolverService + didResolverService, + didCommDocumentService, + outOfBandRepository ) connection = getMockConnection({ id: 'test-123', @@ -149,6 +160,10 @@ describe('MessageSender', () => { service: [firstDidCommService, secondDidCommService], }) didResolverServiceResolveMock.mockResolvedValue(didDocumentInstance) + didResolverServiceResolveDidServicesMock.mockResolvedValue([ + getMockResolvedDidService(firstDidCommService), + getMockResolvedDidService(secondDidCommService), + ]) }) afterEach(() => { @@ -165,6 +180,7 @@ describe('MessageSender', () => { messageSender.registerOutboundTransport(outboundTransport) didResolverServiceResolveMock.mockResolvedValue(getMockDidDocument({ service: [] })) + didResolverServiceResolveDidServicesMock.mockResolvedValue([]) await expect(messageSender.sendMessage(agentContext, outboundMessage)).rejects.toThrow( `Message is undeliverable to connection test-123 (Test 123)` @@ -190,14 +206,14 @@ describe('MessageSender', () => { expect(sendMessageSpy).toHaveBeenCalledTimes(1) }) - test("resolves the did document using the did resolver if connection.theirDid starts with 'did:'", async () => { + test("resolves the did service using the did resolver if connection.theirDid starts with 'did:'", async () => { messageSender.registerOutboundTransport(outboundTransport) const sendMessageSpy = jest.spyOn(outboundTransport, 'sendMessage') await messageSender.sendMessage(agentContext, outboundMessage) - expect(didResolverServiceResolveMock).toHaveBeenCalledWith(agentContext, connection.theirDid) + expect(didResolverServiceResolveDidServicesMock).toHaveBeenCalledWith(agentContext, connection.theirDid) expect(sendMessageSpy).toHaveBeenCalledWith({ connectionId: 'test-123', payload: encryptedMessage, @@ -332,7 +348,9 @@ describe('MessageSender', () => { transportService, new InMemoryMessageRepository(agentConfig.logger), logger, - didResolverService + didResolverService, + didCommDocumentService, + outOfBandRepository ) envelopeServicePackMessageMock.mockReturnValue(Promise.resolve(encryptedMessage)) @@ -412,7 +430,9 @@ describe('MessageSender', () => { transportService, messageRepository, logger, - didResolverService + didResolverService, + didCommDocumentService, + outOfBandRepository ) connection = getMockConnection() @@ -460,3 +480,12 @@ function getMockDidDocument({ service }: { service: DidDocumentService[] }) { ], }) } + +function getMockResolvedDidService(service: DidDocumentService): ResolvedDidCommService { + return { + id: service.id, + serviceEndpoint: service.serviceEndpoint, + recipientKeys: [verkeyToInstanceOfKey('EoGusetSxDJktp493VCyh981nUnzMamTRjvBaHZAy68d')], + routingKeys: [], + } +} diff --git a/packages/core/src/agent/helpers.ts b/packages/core/src/agent/helpers.ts index 8bce437d96..fcfb906220 100644 --- a/packages/core/src/agent/helpers.ts +++ b/packages/core/src/agent/helpers.ts @@ -1,9 +1,9 @@ import type { Key } from '../crypto' import type { ConnectionRecord } from '../modules/connections' +import type { ResolvedDidCommService } from '../modules/didcomm' import type { OutOfBandRecord } from '../modules/oob/repository' import type { OutboundMessage, OutboundServiceMessage } from '../types' import type { AgentMessage } from './AgentMessage' -import type { ResolvedDidCommService } from './MessageSender' export function createOutboundMessage( connection: ConnectionRecord, diff --git a/packages/core/src/decorators/service/ServiceDecorator.ts b/packages/core/src/decorators/service/ServiceDecorator.ts index 72ee1226fe..0a105c4831 100644 --- a/packages/core/src/decorators/service/ServiceDecorator.ts +++ b/packages/core/src/decorators/service/ServiceDecorator.ts @@ -1,4 +1,4 @@ -import type { ResolvedDidCommService } from '../../agent/MessageSender' +import type { ResolvedDidCommService } from '../../modules/didcomm' import { IsArray, IsOptional, IsString } from 'class-validator' diff --git a/packages/core/src/error/MessageSendingError.ts b/packages/core/src/error/MessageSendingError.ts new file mode 100644 index 0000000000..6ebc95a23d --- /dev/null +++ b/packages/core/src/error/MessageSendingError.ts @@ -0,0 +1,11 @@ +import type { OutboundMessage } from '../types' + +import { AriesFrameworkError } from './AriesFrameworkError' + +export class MessageSendingError extends AriesFrameworkError { + public outboundMessage: OutboundMessage + public constructor(message: string, { outboundMessage, cause }: { outboundMessage: OutboundMessage; cause?: Error }) { + super(message, { cause }) + this.outboundMessage = outboundMessage + } +} diff --git a/packages/core/src/error/index.ts b/packages/core/src/error/index.ts index 5098161d50..7122734300 100644 --- a/packages/core/src/error/index.ts +++ b/packages/core/src/error/index.ts @@ -3,3 +3,4 @@ export * from './RecordNotFoundError' export * from './RecordDuplicateError' export * from './IndySdkError' export * from './ClassValidationError' +export * from './MessageSendingError' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ddd93f3269..9475848a1a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -31,7 +31,7 @@ export * from './storage/BaseRecord' export { InMemoryMessageRepository } from './storage/InMemoryMessageRepository' export { Repository } from './storage/Repository' export * from './storage/RepositoryEvents' -export { StorageService } from './storage/StorageService' +export { StorageService, Query } from './storage/StorageService' export { getDirFromFilePath } from './utils/path' export { InjectionSymbols } from './constants' export * from './wallet' @@ -41,6 +41,7 @@ export { Attachment } from './decorators/attachment/Attachment' export * from './plugins' export * from './transport' +export * from './modules/action-menu' export * from './modules/basic-messages' export * from './modules/common' export * from './modules/credentials' diff --git a/packages/core/src/modules/action-menu/ActionMenuApi.ts b/packages/core/src/modules/action-menu/ActionMenuApi.ts new file mode 100644 index 0000000000..54ff506c56 --- /dev/null +++ b/packages/core/src/modules/action-menu/ActionMenuApi.ts @@ -0,0 +1,150 @@ +import type { + ClearActiveMenuOptions, + FindActiveMenuOptions, + PerformActionOptions, + RequestMenuOptions, + SendMenuOptions, +} from './ActionMenuApiOptions' + +import { AgentContext } from '../../agent' +import { Dispatcher } from '../../agent/Dispatcher' +import { MessageSender } from '../../agent/MessageSender' +import { createOutboundMessage } from '../../agent/helpers' +import { AriesFrameworkError } from '../../error' +import { injectable } from '../../plugins' +import { ConnectionService } from '../connections/services' + +import { ActionMenuRole } from './ActionMenuRole' +import { + ActionMenuProblemReportHandler, + MenuMessageHandler, + MenuRequestMessageHandler, + PerformMessageHandler, +} from './handlers' +import { ActionMenuService } from './services' + +@injectable() +export class ActionMenuApi { + private connectionService: ConnectionService + private messageSender: MessageSender + private actionMenuService: ActionMenuService + private agentContext: AgentContext + + public constructor( + dispatcher: Dispatcher, + connectionService: ConnectionService, + messageSender: MessageSender, + actionMenuService: ActionMenuService, + agentContext: AgentContext + ) { + this.connectionService = connectionService + this.messageSender = messageSender + this.actionMenuService = actionMenuService + this.agentContext = agentContext + this.registerHandlers(dispatcher) + } + + /** + * Start Action Menu protocol as requester, asking for root menu. Any active menu will be cleared. + * + * @param options options for requesting menu + * @returns Action Menu record associated to this new request + */ + public async requestMenu(options: RequestMenuOptions) { + const connection = await this.connectionService.getById(this.agentContext, options.connectionId) + + const { message, record } = await this.actionMenuService.createRequest(this.agentContext, { + connection, + }) + + const outboundMessage = createOutboundMessage(connection, message) + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + + return record + } + + /** + * Send a new Action Menu as responder. This menu will be sent as response if there is an + * existing menu thread. + * + * @param options options for sending menu + * @returns Action Menu record associated to this action + */ + public async sendMenu(options: SendMenuOptions) { + const connection = await this.connectionService.getById(this.agentContext, options.connectionId) + + const { message, record } = await this.actionMenuService.createMenu(this.agentContext, { + connection, + menu: options.menu, + }) + + const outboundMessage = createOutboundMessage(connection, message) + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + + return record + } + + /** + * Perform action in active Action Menu, as a requester. The related + * menu will be closed. + * + * @param options options for requesting menu + * @returns Action Menu record associated to this selection + */ + public async performAction(options: PerformActionOptions) { + const connection = await this.connectionService.getById(this.agentContext, options.connectionId) + + const actionMenuRecord = await this.actionMenuService.find(this.agentContext, { + connectionId: connection.id, + role: ActionMenuRole.Requester, + }) + if (!actionMenuRecord) { + throw new AriesFrameworkError(`No active menu found for connection id ${options.connectionId}`) + } + + const { message, record } = await this.actionMenuService.createPerform(this.agentContext, { + actionMenuRecord, + performedAction: options.performedAction, + }) + + const outboundMessage = createOutboundMessage(connection, message) + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + + return record + } + + /** + * Find the current active menu for a given connection and the specified role. + * + * @param options options for requesting active menu + * @returns Active Action Menu record, or null if no active menu found + */ + public async findActiveMenu(options: FindActiveMenuOptions) { + return this.actionMenuService.find(this.agentContext, { + connectionId: options.connectionId, + role: options.role, + }) + } + + /** + * Clears the current active menu for a given connection and the specified role. + * + * @param options options for clearing active menu + * @returns Active Action Menu record, or null if no active menu record found + */ + public async clearActiveMenu(options: ClearActiveMenuOptions) { + const actionMenuRecord = await this.actionMenuService.find(this.agentContext, { + connectionId: options.connectionId, + role: options.role, + }) + + return actionMenuRecord ? await this.actionMenuService.clearMenu(this.agentContext, { actionMenuRecord }) : null + } + + private registerHandlers(dispatcher: Dispatcher) { + dispatcher.registerHandler(new ActionMenuProblemReportHandler(this.actionMenuService)) + dispatcher.registerHandler(new MenuMessageHandler(this.actionMenuService)) + dispatcher.registerHandler(new MenuRequestMessageHandler(this.actionMenuService)) + dispatcher.registerHandler(new PerformMessageHandler(this.actionMenuService)) + } +} diff --git a/packages/core/src/modules/action-menu/ActionMenuApiOptions.ts b/packages/core/src/modules/action-menu/ActionMenuApiOptions.ts new file mode 100644 index 0000000000..2ad9fcdd54 --- /dev/null +++ b/packages/core/src/modules/action-menu/ActionMenuApiOptions.ts @@ -0,0 +1,27 @@ +import type { ActionMenuRole } from './ActionMenuRole' +import type { ActionMenu } from './models/ActionMenu' +import type { ActionMenuSelection } from './models/ActionMenuSelection' + +export interface FindActiveMenuOptions { + connectionId: string + role: ActionMenuRole +} + +export interface ClearActiveMenuOptions { + connectionId: string + role: ActionMenuRole +} + +export interface RequestMenuOptions { + connectionId: string +} + +export interface SendMenuOptions { + connectionId: string + menu: ActionMenu +} + +export interface PerformActionOptions { + connectionId: string + performedAction: ActionMenuSelection +} diff --git a/packages/core/src/modules/action-menu/ActionMenuEvents.ts b/packages/core/src/modules/action-menu/ActionMenuEvents.ts new file mode 100644 index 0000000000..78733fafb7 --- /dev/null +++ b/packages/core/src/modules/action-menu/ActionMenuEvents.ts @@ -0,0 +1,14 @@ +import type { BaseEvent } from '../../agent/Events' +import type { ActionMenuState } from './ActionMenuState' +import type { ActionMenuRecord } from './repository' + +export enum ActionMenuEventTypes { + ActionMenuStateChanged = 'ActionMenuStateChanged', +} +export interface ActionMenuStateChangedEvent extends BaseEvent { + type: typeof ActionMenuEventTypes.ActionMenuStateChanged + payload: { + actionMenuRecord: ActionMenuRecord + previousState: ActionMenuState | null + } +} diff --git a/packages/core/src/modules/action-menu/ActionMenuModule.ts b/packages/core/src/modules/action-menu/ActionMenuModule.ts new file mode 100644 index 0000000000..330d87afd1 --- /dev/null +++ b/packages/core/src/modules/action-menu/ActionMenuModule.ts @@ -0,0 +1,35 @@ +import type { FeatureRegistry } from '../../agent/FeatureRegistry' +import type { DependencyManager, Module } from '../../plugins' + +import { Protocol } from '../../agent/models' + +import { ActionMenuApi } from './ActionMenuApi' +import { ActionMenuRole } from './ActionMenuRole' +import { ActionMenuRepository } from './repository' +import { ActionMenuService } from './services' + +export class ActionMenuModule implements Module { + public readonly api = ActionMenuApi + + /** + * Registers the dependencies of the question answer module on the dependency manager. + */ + public register(dependencyManager: DependencyManager, featureRegistry: FeatureRegistry) { + // Api + dependencyManager.registerContextScoped(ActionMenuApi) + + // Services + dependencyManager.registerSingleton(ActionMenuService) + + // Repositories + dependencyManager.registerSingleton(ActionMenuRepository) + + // Feature Registry + featureRegistry.register( + new Protocol({ + id: 'https://didcomm.org/action-menu/1.0', + roles: [ActionMenuRole.Requester, ActionMenuRole.Responder], + }) + ) + } +} diff --git a/packages/core/src/modules/action-menu/ActionMenuRole.ts b/packages/core/src/modules/action-menu/ActionMenuRole.ts new file mode 100644 index 0000000000..f4ef73f56c --- /dev/null +++ b/packages/core/src/modules/action-menu/ActionMenuRole.ts @@ -0,0 +1,9 @@ +/** + * Action Menu roles based on the flow defined in RFC 0509. + * + * @see https://github.com/hyperledger/aries-rfcs/tree/main/features/0509-action-menu#roles + */ +export enum ActionMenuRole { + Requester = 'requester', + Responder = 'responder', +} diff --git a/packages/core/src/modules/action-menu/ActionMenuState.ts b/packages/core/src/modules/action-menu/ActionMenuState.ts new file mode 100644 index 0000000000..bf158c9b26 --- /dev/null +++ b/packages/core/src/modules/action-menu/ActionMenuState.ts @@ -0,0 +1,13 @@ +/** + * Action Menu states based on the flow defined in RFC 0509. + * + * @see https://github.com/hyperledger/aries-rfcs/tree/main/features/0509-action-menu#states + */ +export enum ActionMenuState { + Null = 'null', + AwaitingRootMenu = 'awaiting-root-menu', + PreparingRootMenu = 'preparing-root-menu', + PreparingSelection = 'preparing-selection', + AwaitingSelection = 'awaiting-selection', + Done = 'done', +} diff --git a/packages/core/src/modules/action-menu/__tests__/action-menu.e2e.test.ts b/packages/core/src/modules/action-menu/__tests__/action-menu.e2e.test.ts new file mode 100644 index 0000000000..7003f5cc3e --- /dev/null +++ b/packages/core/src/modules/action-menu/__tests__/action-menu.e2e.test.ts @@ -0,0 +1,334 @@ +import type { ConnectionRecord } from '../../..' +import type { SubjectMessage } from '../../../../../../tests/transport/SubjectInboundTransport' + +import { Subject } from 'rxjs' + +import { Agent } from '../../..' +import { SubjectInboundTransport } from '../../../../../../tests/transport/SubjectInboundTransport' +import { SubjectOutboundTransport } from '../../../../../../tests/transport/SubjectOutboundTransport' +import { getAgentOptions, makeConnection } from '../../../../tests/helpers' +import testLogger from '../../../../tests/logger' +import { ActionMenuRole } from '../ActionMenuRole' +import { ActionMenuState } from '../ActionMenuState' +import { ActionMenu } from '../models' +import { ActionMenuRecord } from '../repository' + +import { waitForActionMenuRecord } from './helpers' + +const faberAgentOptions = getAgentOptions('Faber Action Menu', { + endpoints: ['rxjs:faber'], +}) + +const aliceAgentOptions = getAgentOptions('Alice Action Menu', { + endpoints: ['rxjs:alice'], +}) + +describe('Action Menu', () => { + let faberAgent: Agent + let aliceAgent: Agent + let faberConnection: ConnectionRecord + let aliceConnection: ConnectionRecord + + const rootMenu = new ActionMenu({ + title: 'Welcome', + description: 'This is the root menu', + options: [ + { + name: 'option-1', + description: 'Option 1 description', + title: 'Option 1', + }, + { + name: 'option-2', + description: 'Option 2 description', + title: 'Option 2', + }, + ], + }) + + const submenu1 = new ActionMenu({ + title: 'Menu 1', + description: 'This is first submenu', + options: [ + { + name: 'option-1-1', + description: '1-1 desc', + title: '1-1 title', + }, + { + name: 'option-1-2', + description: '1-1 desc', + title: '1-1 title', + }, + ], + }) + + beforeEach(async () => { + const faberMessages = new Subject() + const aliceMessages = new Subject() + const subjectMap = { + 'rxjs:faber': faberMessages, + 'rxjs:alice': aliceMessages, + } + + faberAgent = new Agent(faberAgentOptions) + faberAgent.registerInboundTransport(new SubjectInboundTransport(faberMessages)) + faberAgent.registerOutboundTransport(new SubjectOutboundTransport(subjectMap)) + await faberAgent.initialize() + + aliceAgent = new Agent(aliceAgentOptions) + aliceAgent.registerInboundTransport(new SubjectInboundTransport(aliceMessages)) + aliceAgent.registerOutboundTransport(new SubjectOutboundTransport(subjectMap)) + await aliceAgent.initialize() + ;[aliceConnection, faberConnection] = await makeConnection(aliceAgent, faberAgent) + }) + + afterEach(async () => { + await faberAgent.shutdown() + await faberAgent.wallet.delete() + await aliceAgent.shutdown() + await aliceAgent.wallet.delete() + }) + + test('Alice requests menu to Faber and selects an option once received', async () => { + testLogger.test('Alice sends menu request to Faber') + let aliceActionMenuRecord = await aliceAgent.actionMenu.requestMenu({ connectionId: aliceConnection.id }) + + testLogger.test('Faber waits for menu request from Alice') + await waitForActionMenuRecord(faberAgent, { + state: ActionMenuState.PreparingRootMenu, + }) + + testLogger.test('Faber sends root menu to Alice') + await faberAgent.actionMenu.sendMenu({ connectionId: faberConnection.id, menu: rootMenu }) + + testLogger.test('Alice waits until she receives menu') + aliceActionMenuRecord = await waitForActionMenuRecord(aliceAgent, { + state: ActionMenuState.PreparingSelection, + }) + + expect(aliceActionMenuRecord.menu).toEqual(rootMenu) + const faberActiveMenu = await faberAgent.actionMenu.findActiveMenu({ + connectionId: faberConnection.id, + role: ActionMenuRole.Responder, + }) + expect(faberActiveMenu).toBeInstanceOf(ActionMenuRecord) + expect(faberActiveMenu?.state).toBe(ActionMenuState.AwaitingSelection) + + testLogger.test('Alice selects menu item') + await aliceAgent.actionMenu.performAction({ + connectionId: aliceConnection.id, + performedAction: { name: 'option-1' }, + }) + + testLogger.test('Faber waits for menu selection from Alice') + await waitForActionMenuRecord(faberAgent, { + state: ActionMenuState.Done, + }) + + // As Alice has responded, menu should be closed (done state) + const aliceActiveMenu = await aliceAgent.actionMenu.findActiveMenu({ + connectionId: aliceConnection.id, + role: ActionMenuRole.Requester, + }) + expect(aliceActiveMenu).toBeInstanceOf(ActionMenuRecord) + expect(aliceActiveMenu?.state).toBe(ActionMenuState.Done) + }) + + test('Faber sends root menu and Alice selects an option', async () => { + testLogger.test('Faber sends root menu to Alice') + await faberAgent.actionMenu.sendMenu({ connectionId: faberConnection.id, menu: rootMenu }) + + testLogger.test('Alice waits until she receives menu') + const aliceActionMenuRecord = await waitForActionMenuRecord(aliceAgent, { + state: ActionMenuState.PreparingSelection, + }) + + expect(aliceActionMenuRecord.menu).toEqual(rootMenu) + const faberActiveMenu = await faberAgent.actionMenu.findActiveMenu({ + connectionId: faberConnection.id, + role: ActionMenuRole.Responder, + }) + expect(faberActiveMenu).toBeInstanceOf(ActionMenuRecord) + expect(faberActiveMenu?.state).toBe(ActionMenuState.AwaitingSelection) + + testLogger.test('Alice selects menu item') + await aliceAgent.actionMenu.performAction({ + connectionId: aliceConnection.id, + performedAction: { name: 'option-1' }, + }) + + testLogger.test('Faber waits for menu selection from Alice') + await waitForActionMenuRecord(faberAgent, { + state: ActionMenuState.Done, + }) + + // As Alice has responded, menu should be closed (done state) + const aliceActiveMenu = await aliceAgent.actionMenu.findActiveMenu({ + connectionId: aliceConnection.id, + role: ActionMenuRole.Requester, + }) + expect(aliceActiveMenu).toBeInstanceOf(ActionMenuRecord) + expect(aliceActiveMenu?.state).toBe(ActionMenuState.Done) + }) + + test('Menu navigation', async () => { + testLogger.test('Faber sends root menu ') + let faberActionMenuRecord = await faberAgent.actionMenu.sendMenu({ + connectionId: faberConnection.id, + menu: rootMenu, + }) + + const rootThreadId = faberActionMenuRecord.threadId + + testLogger.test('Alice waits until she receives menu') + let aliceActionMenuRecord = await waitForActionMenuRecord(aliceAgent, { + state: ActionMenuState.PreparingSelection, + }) + + expect(aliceActionMenuRecord.menu).toEqual(rootMenu) + expect(aliceActionMenuRecord.threadId).toEqual(rootThreadId) + + testLogger.test('Alice selects menu item 1') + await aliceAgent.actionMenu.performAction({ + connectionId: aliceConnection.id, + performedAction: { name: 'option-1' }, + }) + + testLogger.test('Faber waits for menu selection from Alice') + faberActionMenuRecord = await waitForActionMenuRecord(faberAgent, { + state: ActionMenuState.Done, + }) + + // As Alice has responded, menu should be closed (done state) + let aliceActiveMenu = await aliceAgent.actionMenu.findActiveMenu({ + connectionId: aliceConnection.id, + role: ActionMenuRole.Requester, + }) + expect(aliceActiveMenu).toBeInstanceOf(ActionMenuRecord) + expect(aliceActiveMenu?.state).toBe(ActionMenuState.Done) + expect(aliceActiveMenu?.threadId).toEqual(rootThreadId) + + testLogger.test('Faber sends submenu to Alice') + faberActionMenuRecord = await faberAgent.actionMenu.sendMenu({ + connectionId: faberConnection.id, + menu: submenu1, + }) + + testLogger.test('Alice waits until she receives submenu') + aliceActionMenuRecord = await waitForActionMenuRecord(aliceAgent, { + state: ActionMenuState.PreparingSelection, + }) + + expect(aliceActionMenuRecord.menu).toEqual(submenu1) + expect(aliceActionMenuRecord.threadId).toEqual(rootThreadId) + + testLogger.test('Alice selects menu item 1-1') + await aliceAgent.actionMenu.performAction({ + connectionId: aliceConnection.id, + performedAction: { name: 'option-1-1' }, + }) + + testLogger.test('Faber waits for menu selection from Alice') + faberActionMenuRecord = await waitForActionMenuRecord(faberAgent, { + state: ActionMenuState.Done, + }) + + // As Alice has responded, menu should be closed (done state) + aliceActiveMenu = await aliceAgent.actionMenu.findActiveMenu({ + connectionId: aliceConnection.id, + role: ActionMenuRole.Requester, + }) + expect(aliceActiveMenu).toBeInstanceOf(ActionMenuRecord) + expect(aliceActiveMenu?.state).toBe(ActionMenuState.Done) + expect(aliceActiveMenu?.threadId).toEqual(rootThreadId) + + testLogger.test('Alice sends menu request to Faber') + aliceActionMenuRecord = await aliceAgent.actionMenu.requestMenu({ connectionId: aliceConnection.id }) + + testLogger.test('Faber waits for menu request from Alice') + faberActionMenuRecord = await waitForActionMenuRecord(faberAgent, { + state: ActionMenuState.PreparingRootMenu, + }) + + testLogger.test('This new menu request must have a different thread Id') + expect(faberActionMenuRecord.menu).toBeUndefined() + expect(aliceActionMenuRecord.threadId).not.toEqual(rootThreadId) + expect(faberActionMenuRecord.threadId).toEqual(aliceActionMenuRecord.threadId) + }) + + test('Menu clearing', async () => { + testLogger.test('Faber sends root menu to Alice') + await faberAgent.actionMenu.sendMenu({ connectionId: faberConnection.id, menu: rootMenu }) + + testLogger.test('Alice waits until she receives menu') + let aliceActionMenuRecord = await waitForActionMenuRecord(aliceAgent, { + state: ActionMenuState.PreparingSelection, + }) + + expect(aliceActionMenuRecord.menu).toEqual(rootMenu) + let faberActiveMenu = await faberAgent.actionMenu.findActiveMenu({ + connectionId: faberConnection.id, + role: ActionMenuRole.Responder, + }) + expect(faberActiveMenu).toBeInstanceOf(ActionMenuRecord) + expect(faberActiveMenu?.state).toBe(ActionMenuState.AwaitingSelection) + + await faberAgent.actionMenu.clearActiveMenu({ + connectionId: faberConnection.id, + role: ActionMenuRole.Responder, + }) + + testLogger.test('Alice selects menu item') + await aliceAgent.actionMenu.performAction({ + connectionId: aliceConnection.id, + performedAction: { name: 'option-1' }, + }) + + // Exception + + testLogger.test('Faber rejects selection, as menu has been cleared') + // Faber sends error report to Alice, meaning that her Menu flow will be cleared + aliceActionMenuRecord = await waitForActionMenuRecord(aliceAgent, { + state: ActionMenuState.Null, + role: ActionMenuRole.Requester, + }) + + testLogger.test('Alice request a new menu') + await aliceAgent.actionMenu.requestMenu({ + connectionId: aliceConnection.id, + }) + + testLogger.test('Faber waits for menu request from Alice') + await waitForActionMenuRecord(faberAgent, { + state: ActionMenuState.PreparingRootMenu, + }) + + testLogger.test('Faber sends root menu to Alice') + await faberAgent.actionMenu.sendMenu({ connectionId: faberConnection.id, menu: rootMenu }) + + testLogger.test('Alice waits until she receives menu') + aliceActionMenuRecord = await waitForActionMenuRecord(aliceAgent, { + state: ActionMenuState.PreparingSelection, + }) + + expect(aliceActionMenuRecord.menu).toEqual(rootMenu) + faberActiveMenu = await faberAgent.actionMenu.findActiveMenu({ + connectionId: faberConnection.id, + role: ActionMenuRole.Responder, + }) + expect(faberActiveMenu).toBeInstanceOf(ActionMenuRecord) + expect(faberActiveMenu?.state).toBe(ActionMenuState.AwaitingSelection) + + /*testLogger.test('Alice selects menu item') + await aliceAgent.actionMenu.performAction({ + connectionId: aliceConnection.id, + performedAction: { name: 'option-1' }, + }) + + testLogger.test('Faber waits for menu selection from Alice') + await waitForActionMenuRecord(faberAgent, { + state: ActionMenuState.Done, + })*/ + }) +}) diff --git a/packages/core/src/modules/action-menu/__tests__/helpers.ts b/packages/core/src/modules/action-menu/__tests__/helpers.ts new file mode 100644 index 0000000000..8d0c6c48d6 --- /dev/null +++ b/packages/core/src/modules/action-menu/__tests__/helpers.ts @@ -0,0 +1,62 @@ +import type { Agent } from '../../../agent/Agent' +import type { ActionMenuStateChangedEvent } from '../ActionMenuEvents' +import type { ActionMenuRole } from '../ActionMenuRole' +import type { ActionMenuState } from '../ActionMenuState' +import type { Observable } from 'rxjs' + +import { catchError, filter, firstValueFrom, map, ReplaySubject, timeout } from 'rxjs' + +import { ActionMenuEventTypes } from '../ActionMenuEvents' + +export async function waitForActionMenuRecord( + agent: Agent, + options: { + threadId?: string + role?: ActionMenuRole + state?: ActionMenuState + previousState?: ActionMenuState | null + timeoutMs?: number + } +) { + const observable = agent.events.observable(ActionMenuEventTypes.ActionMenuStateChanged) + + return waitForActionMenuRecordSubject(observable, options) +} + +export function waitForActionMenuRecordSubject( + subject: ReplaySubject | Observable, + { + threadId, + role, + state, + previousState, + timeoutMs = 10000, + }: { + threadId?: string + role?: ActionMenuRole + state?: ActionMenuState + previousState?: ActionMenuState | null + timeoutMs?: number + } +) { + const observable = subject instanceof ReplaySubject ? subject.asObservable() : subject + return firstValueFrom( + observable.pipe( + filter((e) => previousState === undefined || e.payload.previousState === previousState), + filter((e) => threadId === undefined || e.payload.actionMenuRecord.threadId === threadId), + filter((e) => role === undefined || e.payload.actionMenuRecord.role === role), + filter((e) => state === undefined || e.payload.actionMenuRecord.state === state), + timeout(timeoutMs), + catchError(() => { + throw new Error( + `ActionMenuStateChangedEvent event not emitted within specified timeout: { + previousState: ${previousState}, + threadId: ${threadId}, + state: ${state} + }` + ) + }), + map((e) => e.payload.actionMenuRecord) + ) + ) +} diff --git a/packages/core/src/modules/action-menu/errors/ActionMenuProblemReportError.ts b/packages/core/src/modules/action-menu/errors/ActionMenuProblemReportError.ts new file mode 100644 index 0000000000..2dcd8162e7 --- /dev/null +++ b/packages/core/src/modules/action-menu/errors/ActionMenuProblemReportError.ts @@ -0,0 +1,22 @@ +import type { ProblemReportErrorOptions } from '../../problem-reports' +import type { ActionMenuProblemReportReason } from './ActionMenuProblemReportReason' + +import { ProblemReportError } from '../../problem-reports' +import { ActionMenuProblemReportMessage } from '../messages' + +interface ActionMenuProblemReportErrorOptions extends ProblemReportErrorOptions { + problemCode: ActionMenuProblemReportReason +} +export class ActionMenuProblemReportError extends ProblemReportError { + public problemReport: ActionMenuProblemReportMessage + + public constructor(public message: string, { problemCode }: ActionMenuProblemReportErrorOptions) { + super(message, { problemCode }) + this.problemReport = new ActionMenuProblemReportMessage({ + description: { + en: message, + code: problemCode, + }, + }) + } +} diff --git a/packages/core/src/modules/action-menu/errors/ActionMenuProblemReportReason.ts b/packages/core/src/modules/action-menu/errors/ActionMenuProblemReportReason.ts new file mode 100644 index 0000000000..97e18b9245 --- /dev/null +++ b/packages/core/src/modules/action-menu/errors/ActionMenuProblemReportReason.ts @@ -0,0 +1,8 @@ +/** + * Action Menu errors discussed in RFC 0509. + * + * @see https://github.com/hyperledger/aries-rfcs/tree/main/features/0509-action-menu#unresolved-questions + */ +export enum ActionMenuProblemReportReason { + Timeout = 'timeout', +} diff --git a/packages/core/src/modules/action-menu/handlers/ActionMenuProblemReportHandler.ts b/packages/core/src/modules/action-menu/handlers/ActionMenuProblemReportHandler.ts new file mode 100644 index 0000000000..023ffc5cc1 --- /dev/null +++ b/packages/core/src/modules/action-menu/handlers/ActionMenuProblemReportHandler.ts @@ -0,0 +1,17 @@ +import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' +import type { ActionMenuService } from '../services' + +import { ActionMenuProblemReportMessage } from '../messages' + +export class ActionMenuProblemReportHandler implements Handler { + private actionMenuService: ActionMenuService + public supportedMessages = [ActionMenuProblemReportMessage] + + public constructor(actionMenuService: ActionMenuService) { + this.actionMenuService = actionMenuService + } + + public async handle(messageContext: HandlerInboundMessage) { + await this.actionMenuService.processProblemReport(messageContext) + } +} diff --git a/packages/core/src/modules/action-menu/handlers/MenuMessageHandler.ts b/packages/core/src/modules/action-menu/handlers/MenuMessageHandler.ts new file mode 100644 index 0000000000..0e81788525 --- /dev/null +++ b/packages/core/src/modules/action-menu/handlers/MenuMessageHandler.ts @@ -0,0 +1,19 @@ +import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' +import type { ActionMenuService } from '../services' + +import { MenuMessage } from '../messages' + +export class MenuMessageHandler implements Handler { + private actionMenuService: ActionMenuService + public supportedMessages = [MenuMessage] + + public constructor(actionMenuService: ActionMenuService) { + this.actionMenuService = actionMenuService + } + + public async handle(inboundMessage: HandlerInboundMessage) { + inboundMessage.assertReadyConnection() + + await this.actionMenuService.processMenu(inboundMessage) + } +} diff --git a/packages/core/src/modules/action-menu/handlers/MenuRequestMessageHandler.ts b/packages/core/src/modules/action-menu/handlers/MenuRequestMessageHandler.ts new file mode 100644 index 0000000000..33277d2510 --- /dev/null +++ b/packages/core/src/modules/action-menu/handlers/MenuRequestMessageHandler.ts @@ -0,0 +1,19 @@ +import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' +import type { ActionMenuService } from '../services' + +import { MenuRequestMessage } from '../messages' + +export class MenuRequestMessageHandler implements Handler { + private actionMenuService: ActionMenuService + public supportedMessages = [MenuRequestMessage] + + public constructor(actionMenuService: ActionMenuService) { + this.actionMenuService = actionMenuService + } + + public async handle(inboundMessage: HandlerInboundMessage) { + inboundMessage.assertReadyConnection() + + await this.actionMenuService.processRequest(inboundMessage) + } +} diff --git a/packages/core/src/modules/action-menu/handlers/PerformMessageHandler.ts b/packages/core/src/modules/action-menu/handlers/PerformMessageHandler.ts new file mode 100644 index 0000000000..65de15dcb0 --- /dev/null +++ b/packages/core/src/modules/action-menu/handlers/PerformMessageHandler.ts @@ -0,0 +1,19 @@ +import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' +import type { ActionMenuService } from '../services' + +import { PerformMessage } from '../messages' + +export class PerformMessageHandler implements Handler { + private actionMenuService: ActionMenuService + public supportedMessages = [PerformMessage] + + public constructor(actionMenuService: ActionMenuService) { + this.actionMenuService = actionMenuService + } + + public async handle(inboundMessage: HandlerInboundMessage) { + inboundMessage.assertReadyConnection() + + await this.actionMenuService.processPerform(inboundMessage) + } +} diff --git a/packages/core/src/modules/action-menu/handlers/index.ts b/packages/core/src/modules/action-menu/handlers/index.ts new file mode 100644 index 0000000000..b7ba3b7117 --- /dev/null +++ b/packages/core/src/modules/action-menu/handlers/index.ts @@ -0,0 +1,4 @@ +export * from './ActionMenuProblemReportHandler' +export * from './MenuMessageHandler' +export * from './MenuRequestMessageHandler' +export * from './PerformMessageHandler' diff --git a/packages/core/src/modules/action-menu/index.ts b/packages/core/src/modules/action-menu/index.ts new file mode 100644 index 0000000000..3183ffd412 --- /dev/null +++ b/packages/core/src/modules/action-menu/index.ts @@ -0,0 +1,10 @@ +export * from './ActionMenuApi' +export * from './ActionMenuApiOptions' +export * from './ActionMenuModule' +export * from './ActionMenuEvents' +export * from './ActionMenuRole' +export * from './ActionMenuState' +export * from './messages' +export * from './models' +export * from './repository' +export * from './services' diff --git a/packages/core/src/modules/action-menu/messages/ActionMenuProblemReportMessage.ts b/packages/core/src/modules/action-menu/messages/ActionMenuProblemReportMessage.ts new file mode 100644 index 0000000000..cfff53ca65 --- /dev/null +++ b/packages/core/src/modules/action-menu/messages/ActionMenuProblemReportMessage.ts @@ -0,0 +1,23 @@ +import type { ProblemReportMessageOptions } from '../../problem-reports/messages/ProblemReportMessage' + +import { IsValidMessageType, parseMessageType } from '../../../utils/messageType' +import { ProblemReportMessage } from '../../problem-reports/messages/ProblemReportMessage' + +export type ActionMenuProblemReportMessageOptions = ProblemReportMessageOptions + +/** + * @see https://github.com/hyperledger/aries-rfcs/blob/main/features/0035-report-problem/README.md + */ +export class ActionMenuProblemReportMessage extends ProblemReportMessage { + /** + * Create new ConnectionProblemReportMessage instance. + * @param options + */ + public constructor(options: ActionMenuProblemReportMessageOptions) { + super(options) + } + + @IsValidMessageType(ActionMenuProblemReportMessage.type) + public readonly type = ActionMenuProblemReportMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/action-menu/1.0/problem-report') +} diff --git a/packages/core/src/modules/action-menu/messages/MenuMessage.ts b/packages/core/src/modules/action-menu/messages/MenuMessage.ts new file mode 100644 index 0000000000..d1c87dcebe --- /dev/null +++ b/packages/core/src/modules/action-menu/messages/MenuMessage.ts @@ -0,0 +1,55 @@ +import type { ActionMenuOptionOptions } from '../models' + +import { Expose, Type } from 'class-transformer' +import { IsInstance, IsOptional, IsString } from 'class-validator' + +import { AgentMessage } from '../../../agent/AgentMessage' +import { IsValidMessageType, parseMessageType } from '../../../utils/messageType' +import { ActionMenuOption } from '../models' + +export interface MenuMessageOptions { + id?: string + title: string + description: string + errorMessage?: string + options: ActionMenuOptionOptions[] + threadId?: string +} + +export class MenuMessage extends AgentMessage { + public constructor(options: MenuMessageOptions) { + super() + + if (options) { + this.id = options.id ?? this.generateId() + this.title = options.title + this.description = options.description + this.errorMessage = options.errorMessage + this.options = options.options.map((p) => new ActionMenuOption(p)) + if (options.threadId) { + this.setThread({ + threadId: options.threadId, + }) + } + } + } + + @IsValidMessageType(MenuMessage.type) + public readonly type = MenuMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/action-menu/1.0/menu') + + @IsString() + public title!: string + + @IsString() + public description!: string + + @Expose({ name: 'errormsg' }) + @IsString() + @IsOptional() + public errorMessage?: string + + @IsInstance(ActionMenuOption, { each: true }) + @Type(() => ActionMenuOption) + public options!: ActionMenuOption[] +} diff --git a/packages/core/src/modules/action-menu/messages/MenuRequestMessage.ts b/packages/core/src/modules/action-menu/messages/MenuRequestMessage.ts new file mode 100644 index 0000000000..d4961553c6 --- /dev/null +++ b/packages/core/src/modules/action-menu/messages/MenuRequestMessage.ts @@ -0,0 +1,20 @@ +import { AgentMessage } from '../../../agent/AgentMessage' +import { IsValidMessageType, parseMessageType } from '../../../utils/messageType' + +export interface MenuRequestMessageOptions { + id?: string +} + +export class MenuRequestMessage extends AgentMessage { + public constructor(options: MenuRequestMessageOptions) { + super() + + if (options) { + this.id = options.id ?? this.generateId() + } + } + + @IsValidMessageType(MenuRequestMessage.type) + public readonly type = MenuRequestMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/action-menu/1.0/menu-request') +} diff --git a/packages/core/src/modules/action-menu/messages/PerformMessage.ts b/packages/core/src/modules/action-menu/messages/PerformMessage.ts new file mode 100644 index 0000000000..75f03f02f7 --- /dev/null +++ b/packages/core/src/modules/action-menu/messages/PerformMessage.ts @@ -0,0 +1,37 @@ +import { IsOptional, IsString } from 'class-validator' + +import { AgentMessage } from '../../../agent/AgentMessage' +import { IsValidMessageType, parseMessageType } from '../../../utils/messageType' + +export interface PerformMessageOptions { + id?: string + name: string + params?: Record + threadId: string +} + +export class PerformMessage extends AgentMessage { + public constructor(options: PerformMessageOptions) { + super() + + if (options) { + this.id = options.id ?? this.generateId() + this.name = options.name + this.params = options.params + this.setThread({ + threadId: options.threadId, + }) + } + } + + @IsValidMessageType(PerformMessage.type) + public readonly type = PerformMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/action-menu/1.0/perform') + + @IsString() + public name!: string + + @IsString({ each: true }) + @IsOptional() + public params?: Record +} diff --git a/packages/core/src/modules/action-menu/messages/index.ts b/packages/core/src/modules/action-menu/messages/index.ts new file mode 100644 index 0000000000..ecf085a0cb --- /dev/null +++ b/packages/core/src/modules/action-menu/messages/index.ts @@ -0,0 +1,4 @@ +export * from './ActionMenuProblemReportMessage' +export * from './MenuMessage' +export * from './MenuRequestMessage' +export * from './PerformMessage' diff --git a/packages/core/src/modules/action-menu/models/ActionMenu.ts b/packages/core/src/modules/action-menu/models/ActionMenu.ts new file mode 100644 index 0000000000..1123394796 --- /dev/null +++ b/packages/core/src/modules/action-menu/models/ActionMenu.ts @@ -0,0 +1,32 @@ +import type { ActionMenuOptionOptions } from './ActionMenuOption' + +import { Type } from 'class-transformer' +import { IsInstance, IsString } from 'class-validator' + +import { ActionMenuOption } from './ActionMenuOption' + +export interface ActionMenuOptions { + title: string + description: string + options: ActionMenuOptionOptions[] +} + +export class ActionMenu { + public constructor(options: ActionMenuOptions) { + if (options) { + this.title = options.title + this.description = options.description + this.options = options.options.map((p) => new ActionMenuOption(p)) + } + } + + @IsString() + public title!: string + + @IsString() + public description!: string + + @IsInstance(ActionMenuOption, { each: true }) + @Type(() => ActionMenuOption) + public options!: ActionMenuOption[] +} diff --git a/packages/core/src/modules/action-menu/models/ActionMenuOption.ts b/packages/core/src/modules/action-menu/models/ActionMenuOption.ts new file mode 100644 index 0000000000..1418c61e6c --- /dev/null +++ b/packages/core/src/modules/action-menu/models/ActionMenuOption.ts @@ -0,0 +1,46 @@ +import type { ActionMenuFormOptions } from './ActionMenuOptionForm' + +import { Type } from 'class-transformer' +import { IsBoolean, IsInstance, IsOptional, IsString } from 'class-validator' + +import { ActionMenuForm } from './ActionMenuOptionForm' + +export interface ActionMenuOptionOptions { + name: string + title: string + description: string + disabled?: boolean + form?: ActionMenuFormOptions +} + +export class ActionMenuOption { + public constructor(options: ActionMenuOptionOptions) { + if (options) { + this.name = options.name + this.title = options.title + this.description = options.description + this.disabled = options.disabled + if (options.form) { + this.form = new ActionMenuForm(options.form) + } + } + } + + @IsString() + public name!: string + + @IsString() + public title!: string + + @IsString() + public description!: string + + @IsBoolean() + @IsOptional() + public disabled?: boolean + + @IsInstance(ActionMenuForm) + @Type(() => ActionMenuForm) + @IsOptional() + public form?: ActionMenuForm +} diff --git a/packages/core/src/modules/action-menu/models/ActionMenuOptionForm.ts b/packages/core/src/modules/action-menu/models/ActionMenuOptionForm.ts new file mode 100644 index 0000000000..07a027a0a1 --- /dev/null +++ b/packages/core/src/modules/action-menu/models/ActionMenuOptionForm.ts @@ -0,0 +1,33 @@ +import type { ActionMenuFormParameterOptions } from './ActionMenuOptionFormParameter' + +import { Expose, Type } from 'class-transformer' +import { IsInstance, IsString } from 'class-validator' + +import { ActionMenuFormParameter } from './ActionMenuOptionFormParameter' + +export interface ActionMenuFormOptions { + description: string + params: ActionMenuFormParameterOptions[] + submitLabel: string +} + +export class ActionMenuForm { + public constructor(options: ActionMenuFormOptions) { + if (options) { + this.description = options.description + this.params = options.params.map((p) => new ActionMenuFormParameter(p)) + this.submitLabel = options.submitLabel + } + } + + @IsString() + public description!: string + + @Expose({ name: 'submit-label' }) + @IsString() + public submitLabel!: string + + @IsInstance(ActionMenuFormParameter, { each: true }) + @Type(() => ActionMenuFormParameter) + public params!: ActionMenuFormParameter[] +} diff --git a/packages/core/src/modules/action-menu/models/ActionMenuOptionFormParameter.ts b/packages/core/src/modules/action-menu/models/ActionMenuOptionFormParameter.ts new file mode 100644 index 0000000000..2c66ac39dc --- /dev/null +++ b/packages/core/src/modules/action-menu/models/ActionMenuOptionFormParameter.ts @@ -0,0 +1,48 @@ +import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator' + +export enum ActionMenuFormInputType { + Text = 'text', +} + +export interface ActionMenuFormParameterOptions { + name: string + title: string + default?: string + description: string + required?: boolean + type?: ActionMenuFormInputType +} + +export class ActionMenuFormParameter { + public constructor(options: ActionMenuFormParameterOptions) { + if (options) { + this.name = options.name + this.title = options.title + this.default = options.default + this.description = options.description + this.required = options.required + this.type = options.type + } + } + + @IsString() + public name!: string + + @IsString() + public title!: string + + @IsString() + @IsOptional() + public default?: string + + @IsString() + public description!: string + + @IsBoolean() + @IsOptional() + public required?: boolean + + @IsEnum(ActionMenuFormInputType) + @IsOptional() + public type?: ActionMenuFormInputType +} diff --git a/packages/core/src/modules/action-menu/models/ActionMenuSelection.ts b/packages/core/src/modules/action-menu/models/ActionMenuSelection.ts new file mode 100644 index 0000000000..ff4299da6d --- /dev/null +++ b/packages/core/src/modules/action-menu/models/ActionMenuSelection.ts @@ -0,0 +1,22 @@ +import { IsOptional, IsString } from 'class-validator' + +export interface ActionMenuSelectionOptions { + name: string + params?: Record +} + +export class ActionMenuSelection { + public constructor(options: ActionMenuSelectionOptions) { + if (options) { + this.name = options.name + this.params = options.params + } + } + + @IsString() + public name!: string + + @IsString({ each: true }) + @IsOptional() + public params?: Record +} diff --git a/packages/core/src/modules/action-menu/models/index.ts b/packages/core/src/modules/action-menu/models/index.ts new file mode 100644 index 0000000000..15c8673f52 --- /dev/null +++ b/packages/core/src/modules/action-menu/models/index.ts @@ -0,0 +1,5 @@ +export * from './ActionMenu' +export * from './ActionMenuOption' +export * from './ActionMenuOptionForm' +export * from './ActionMenuOptionFormParameter' +export * from './ActionMenuSelection' diff --git a/packages/core/src/modules/action-menu/repository/ActionMenuRecord.ts b/packages/core/src/modules/action-menu/repository/ActionMenuRecord.ts new file mode 100644 index 0000000000..a5eb125fc0 --- /dev/null +++ b/packages/core/src/modules/action-menu/repository/ActionMenuRecord.ts @@ -0,0 +1,92 @@ +import type { TagsBase } from '../../../storage/BaseRecord' +import type { ActionMenuRole } from '../ActionMenuRole' +import type { ActionMenuState } from '../ActionMenuState' + +import { Type } from 'class-transformer' + +import { AriesFrameworkError } from '../../../error' +import { BaseRecord } from '../../../storage/BaseRecord' +import { uuid } from '../../../utils/uuid' +import { ActionMenuSelection, ActionMenu } from '../models' + +export interface ActionMenuRecordProps { + id?: string + state: ActionMenuState + role: ActionMenuRole + createdAt?: Date + connectionId: string + threadId: string + menu?: ActionMenu + performedAction?: ActionMenuSelection + tags?: CustomActionMenuTags +} + +export type CustomActionMenuTags = TagsBase + +export type DefaultActionMenuTags = { + role: ActionMenuRole + connectionId: string + threadId: string +} + +export class ActionMenuRecord + extends BaseRecord + implements ActionMenuRecordProps +{ + public state!: ActionMenuState + public role!: ActionMenuRole + public connectionId!: string + public threadId!: string + + @Type(() => ActionMenu) + public menu?: ActionMenu + + @Type(() => ActionMenuSelection) + public performedAction?: ActionMenuSelection + + public static readonly type = 'ActionMenuRecord' + public readonly type = ActionMenuRecord.type + + public constructor(props: ActionMenuRecordProps) { + super() + + if (props) { + this.id = props.id ?? uuid() + this.createdAt = props.createdAt ?? new Date() + this.connectionId = props.connectionId + this.threadId = props.threadId + this.state = props.state + this.role = props.role + this.menu = props.menu + this.performedAction = props.performedAction + this._tags = props.tags ?? {} + } + } + + public getTags() { + return { + ...this._tags, + role: this.role, + connectionId: this.connectionId, + threadId: this.threadId, + } + } + + public assertState(expectedStates: ActionMenuState | ActionMenuState[]) { + if (!Array.isArray(expectedStates)) { + expectedStates = [expectedStates] + } + + if (!expectedStates.includes(this.state)) { + throw new AriesFrameworkError( + `Action Menu record is in invalid state ${this.state}. Valid states are: ${expectedStates.join(', ')}.` + ) + } + } + + public assertRole(expectedRole: ActionMenuRole) { + if (this.role !== expectedRole) { + throw new AriesFrameworkError(`Action Menu record has invalid role ${this.role}. Expected role ${expectedRole}.`) + } + } +} diff --git a/packages/core/src/modules/action-menu/repository/ActionMenuRepository.ts b/packages/core/src/modules/action-menu/repository/ActionMenuRepository.ts new file mode 100644 index 0000000000..e22f014ec7 --- /dev/null +++ b/packages/core/src/modules/action-menu/repository/ActionMenuRepository.ts @@ -0,0 +1,17 @@ +import { EventEmitter } from '../../../agent/EventEmitter' +import { InjectionSymbols } from '../../../constants' +import { inject, injectable } from '../../../plugins' +import { Repository } from '../../../storage/Repository' +import { StorageService } from '../../../storage/StorageService' + +import { ActionMenuRecord } from './ActionMenuRecord' + +@injectable() +export class ActionMenuRepository extends Repository { + public constructor( + @inject(InjectionSymbols.StorageService) storageService: StorageService, + eventEmitter: EventEmitter + ) { + super(ActionMenuRecord, storageService, eventEmitter) + } +} diff --git a/packages/core/src/modules/action-menu/repository/index.ts b/packages/core/src/modules/action-menu/repository/index.ts new file mode 100644 index 0000000000..2c34741daf --- /dev/null +++ b/packages/core/src/modules/action-menu/repository/index.ts @@ -0,0 +1,2 @@ +export * from './ActionMenuRepository' +export * from './ActionMenuRecord' diff --git a/packages/core/src/modules/action-menu/services/ActionMenuService.ts b/packages/core/src/modules/action-menu/services/ActionMenuService.ts new file mode 100644 index 0000000000..2db0d2a1db --- /dev/null +++ b/packages/core/src/modules/action-menu/services/ActionMenuService.ts @@ -0,0 +1,370 @@ +import type { AgentContext } from '../../../agent' +import type { InboundMessageContext } from '../../../agent/models/InboundMessageContext' +import type { Logger } from '../../../logger' +import type { Query } from '../../../storage/StorageService' +import type { ActionMenuStateChangedEvent } from '../ActionMenuEvents' +import type { ActionMenuProblemReportMessage } from '../messages' +import type { + ClearMenuOptions, + CreateMenuOptions, + CreatePerformOptions, + CreateRequestOptions, + FindMenuOptions, +} from './ActionMenuServiceOptions' + +import { AgentConfig } from '../../../agent/AgentConfig' +import { EventEmitter } from '../../../agent/EventEmitter' +import { AriesFrameworkError } from '../../../error' +import { injectable } from '../../../plugins' +import { JsonTransformer } from '../../../utils' +import { ActionMenuEventTypes } from '../ActionMenuEvents' +import { ActionMenuRole } from '../ActionMenuRole' +import { ActionMenuState } from '../ActionMenuState' +import { ActionMenuProblemReportError } from '../errors/ActionMenuProblemReportError' +import { ActionMenuProblemReportReason } from '../errors/ActionMenuProblemReportReason' +import { PerformMessage, MenuMessage, MenuRequestMessage } from '../messages' +import { ActionMenuSelection, ActionMenu } from '../models' +import { ActionMenuRepository, ActionMenuRecord } from '../repository' + +@injectable() +export class ActionMenuService { + private actionMenuRepository: ActionMenuRepository + private eventEmitter: EventEmitter + private logger: Logger + + public constructor(actionMenuRepository: ActionMenuRepository, agentConfig: AgentConfig, eventEmitter: EventEmitter) { + this.actionMenuRepository = actionMenuRepository + this.eventEmitter = eventEmitter + this.logger = agentConfig.logger + } + + public async createRequest(agentContext: AgentContext, options: CreateRequestOptions) { + // Assert + options.connection.assertReady() + + // Create message + const menuRequestMessage = new MenuRequestMessage({}) + + // Create record if not existant for connection/role + let actionMenuRecord = await this.find(agentContext, { + connectionId: options.connection.id, + role: ActionMenuRole.Requester, + }) + + if (actionMenuRecord) { + // Protocol will be restarted and menu cleared + const previousState = actionMenuRecord.state + actionMenuRecord.state = ActionMenuState.AwaitingRootMenu + actionMenuRecord.threadId = menuRequestMessage.id + actionMenuRecord.menu = undefined + actionMenuRecord.performedAction = undefined + + await this.actionMenuRepository.update(agentContext, actionMenuRecord) + this.emitStateChangedEvent(agentContext, actionMenuRecord, previousState) + } else { + actionMenuRecord = new ActionMenuRecord({ + connectionId: options.connection.id, + role: ActionMenuRole.Requester, + state: ActionMenuState.AwaitingRootMenu, + threadId: menuRequestMessage.id, + }) + + await this.actionMenuRepository.save(agentContext, actionMenuRecord) + this.emitStateChangedEvent(agentContext, actionMenuRecord, null) + } + + return { message: menuRequestMessage, record: actionMenuRecord } + } + + public async processRequest(messageContext: InboundMessageContext) { + const { message: menuRequestMessage, agentContext } = messageContext + + this.logger.debug(`Processing menu request with id ${menuRequestMessage.id}`) + + // Assert + const connection = messageContext.assertReadyConnection() + + let actionMenuRecord = await this.find(agentContext, { + connectionId: connection.id, + role: ActionMenuRole.Responder, + }) + + if (actionMenuRecord) { + // Protocol will be restarted and menu cleared + const previousState = actionMenuRecord.state + actionMenuRecord.state = ActionMenuState.PreparingRootMenu + actionMenuRecord.threadId = menuRequestMessage.id + actionMenuRecord.menu = undefined + actionMenuRecord.performedAction = undefined + + await this.actionMenuRepository.update(agentContext, actionMenuRecord) + this.emitStateChangedEvent(agentContext, actionMenuRecord, previousState) + } else { + // Create record + actionMenuRecord = new ActionMenuRecord({ + connectionId: connection.id, + role: ActionMenuRole.Responder, + state: ActionMenuState.PreparingRootMenu, + threadId: menuRequestMessage.id, + }) + + await this.actionMenuRepository.save(agentContext, actionMenuRecord) + this.emitStateChangedEvent(agentContext, actionMenuRecord, null) + } + + return actionMenuRecord + } + + public async createMenu(agentContext: AgentContext, options: CreateMenuOptions) { + // Assert connection ready + options.connection.assertReady() + + const uniqueNames = new Set(options.menu.options.map((v) => v.name)) + if (uniqueNames.size < options.menu.options.length) { + throw new AriesFrameworkError('Action Menu contains duplicated options') + } + + // Create message + const menuMessage = new MenuMessage({ + title: options.menu.title, + description: options.menu.description, + options: options.menu.options, + }) + + // Check if there is an existing menu for this connection and role + let actionMenuRecord = await this.find(agentContext, { + connectionId: options.connection.id, + role: ActionMenuRole.Responder, + }) + + // If so, continue existing flow + if (actionMenuRecord) { + actionMenuRecord.assertState([ActionMenuState.Null, ActionMenuState.PreparingRootMenu, ActionMenuState.Done]) + // The new menu will be bound to the existing thread + // unless it is in null state (protocol reset) + if (actionMenuRecord.state !== ActionMenuState.Null) { + menuMessage.setThread({ threadId: actionMenuRecord.threadId }) + } + + const previousState = actionMenuRecord.state + actionMenuRecord.menu = options.menu + actionMenuRecord.state = ActionMenuState.AwaitingSelection + actionMenuRecord.threadId = menuMessage.threadId + + await this.actionMenuRepository.update(agentContext, actionMenuRecord) + this.emitStateChangedEvent(agentContext, actionMenuRecord, previousState) + } else { + // Create record + actionMenuRecord = new ActionMenuRecord({ + connectionId: options.connection.id, + role: ActionMenuRole.Responder, + state: ActionMenuState.AwaitingSelection, + menu: options.menu, + threadId: menuMessage.id, + }) + + await this.actionMenuRepository.save(agentContext, actionMenuRecord) + this.emitStateChangedEvent(agentContext, actionMenuRecord, null) + } + + return { message: menuMessage, record: actionMenuRecord } + } + + public async processMenu(messageContext: InboundMessageContext) { + const { message: menuMessage, agentContext } = messageContext + + this.logger.debug(`Processing action menu with id ${menuMessage.id}`) + + // Assert + const connection = messageContext.assertReadyConnection() + + // Check if there is an existing menu for this connection and role + const record = await this.find(agentContext, { + connectionId: connection.id, + role: ActionMenuRole.Requester, + }) + + if (record) { + // Record found: update with menu details + const previousState = record.state + + record.state = ActionMenuState.PreparingSelection + record.menu = new ActionMenu({ + title: menuMessage.title, + description: menuMessage.description, + options: menuMessage.options, + }) + record.threadId = menuMessage.threadId + record.performedAction = undefined + + await this.actionMenuRepository.update(agentContext, record) + + this.emitStateChangedEvent(agentContext, record, previousState) + } else { + // Record not found: create it + const actionMenuRecord = new ActionMenuRecord({ + connectionId: connection.id, + role: ActionMenuRole.Requester, + state: ActionMenuState.PreparingSelection, + threadId: menuMessage.id, + menu: new ActionMenu({ + title: menuMessage.title, + description: menuMessage.description, + options: menuMessage.options, + }), + }) + + await this.actionMenuRepository.save(agentContext, actionMenuRecord) + + this.emitStateChangedEvent(agentContext, actionMenuRecord, null) + } + } + + public async createPerform(agentContext: AgentContext, options: CreatePerformOptions) { + const { actionMenuRecord: record, performedAction: performedSelection } = options + + // Assert + record.assertRole(ActionMenuRole.Requester) + record.assertState([ActionMenuState.PreparingSelection]) + + const validSelection = record.menu?.options.some((item) => item.name === performedSelection.name) + if (!validSelection) { + throw new AriesFrameworkError('Selection does not match valid actions') + } + + const previousState = record.state + + // Create message + const menuMessage = new PerformMessage({ + name: performedSelection.name, + params: performedSelection.params, + threadId: record.threadId, + }) + + // Update record + record.performedAction = options.performedAction + record.state = ActionMenuState.Done + + await this.actionMenuRepository.update(agentContext, record) + + this.emitStateChangedEvent(agentContext, record, previousState) + + return { message: menuMessage, record } + } + + public async processPerform(messageContext: InboundMessageContext) { + const { message: performMessage, agentContext } = messageContext + + this.logger.debug(`Processing action menu perform with id ${performMessage.id}`) + + const connection = messageContext.assertReadyConnection() + + // Check if there is an existing menu for this connection and role + const record = await this.find(agentContext, { + connectionId: connection.id, + role: ActionMenuRole.Responder, + threadId: performMessage.threadId, + }) + + if (record) { + // Record found: check state and update with menu details + + // A Null state means that menu has been cleared by the responder. + // Requester should be informed in order to request another menu + if (record.state === ActionMenuState.Null) { + throw new ActionMenuProblemReportError('Action Menu has been cleared by the responder', { + problemCode: ActionMenuProblemReportReason.Timeout, + }) + } + record.assertState([ActionMenuState.AwaitingSelection]) + + const validSelection = record.menu?.options.some((item) => item.name === performMessage.name) + if (!validSelection) { + throw new AriesFrameworkError('Selection does not match valid actions') + } + + const previousState = record.state + + record.state = ActionMenuState.Done + record.performedAction = new ActionMenuSelection({ name: performMessage.name, params: performMessage.params }) + + await this.actionMenuRepository.update(agentContext, record) + + this.emitStateChangedEvent(agentContext, record, previousState) + } else { + throw new AriesFrameworkError(`No Action Menu found with thread id ${messageContext.message.threadId}`) + } + } + + public async clearMenu(agentContext: AgentContext, options: ClearMenuOptions) { + const { actionMenuRecord: record } = options + + const previousState = record.state + + // Update record + record.state = ActionMenuState.Null + record.menu = undefined + record.performedAction = undefined + + await this.actionMenuRepository.update(agentContext, record) + + this.emitStateChangedEvent(agentContext, record, previousState) + + return record + } + + public async processProblemReport( + messageContext: InboundMessageContext + ): Promise { + const { message: actionMenuProblemReportMessage, agentContext } = messageContext + + const connection = messageContext.assertReadyConnection() + + this.logger.debug(`Processing problem report with id ${actionMenuProblemReportMessage.id}`) + + const actionMenuRecord = await this.find(agentContext, { + role: ActionMenuRole.Requester, + connectionId: connection.id, + }) + + if (!actionMenuRecord) { + throw new AriesFrameworkError( + `Unable to process action menu problem: record not found for connection id ${connection.id}` + ) + } + // Clear menu to restart flow + return await this.clearMenu(agentContext, { actionMenuRecord }) + } + + public async findById(agentContext: AgentContext, actionMenuRecordId: string) { + return await this.actionMenuRepository.findById(agentContext, actionMenuRecordId) + } + + public async find(agentContext: AgentContext, options: FindMenuOptions) { + return await this.actionMenuRepository.findSingleByQuery(agentContext, { + connectionId: options.connectionId, + role: options.role, + threadId: options.threadId, + }) + } + + public async findAllByQuery(agentContext: AgentContext, options: Query) { + return await this.actionMenuRepository.findByQuery(agentContext, options) + } + + private emitStateChangedEvent( + agentContext: AgentContext, + actionMenuRecord: ActionMenuRecord, + previousState: ActionMenuState | null + ) { + const clonedRecord = JsonTransformer.clone(actionMenuRecord) + + this.eventEmitter.emit(agentContext, { + type: ActionMenuEventTypes.ActionMenuStateChanged, + payload: { + actionMenuRecord: clonedRecord, + previousState: previousState, + }, + }) + } +} diff --git a/packages/core/src/modules/action-menu/services/ActionMenuServiceOptions.ts b/packages/core/src/modules/action-menu/services/ActionMenuServiceOptions.ts new file mode 100644 index 0000000000..733a6d0c76 --- /dev/null +++ b/packages/core/src/modules/action-menu/services/ActionMenuServiceOptions.ts @@ -0,0 +1,29 @@ +import type { ConnectionRecord } from '../../connections' +import type { ActionMenuRole } from '../ActionMenuRole' +import type { ActionMenuSelection } from '../models' +import type { ActionMenu } from '../models/ActionMenu' +import type { ActionMenuRecord } from '../repository' + +export interface CreateRequestOptions { + connection: ConnectionRecord +} + +export interface CreateMenuOptions { + connection: ConnectionRecord + menu: ActionMenu +} + +export interface CreatePerformOptions { + actionMenuRecord: ActionMenuRecord + performedAction: ActionMenuSelection +} + +export interface ClearMenuOptions { + actionMenuRecord: ActionMenuRecord +} + +export interface FindMenuOptions { + connectionId: string + role: ActionMenuRole + threadId?: string +} diff --git a/packages/core/src/modules/action-menu/services/__tests__/ActionMenuService.test.ts b/packages/core/src/modules/action-menu/services/__tests__/ActionMenuService.test.ts new file mode 100644 index 0000000000..d8ade2a3ee --- /dev/null +++ b/packages/core/src/modules/action-menu/services/__tests__/ActionMenuService.test.ts @@ -0,0 +1,894 @@ +import type { AgentContext } from '../../../../agent' +import type { AgentConfig } from '../../../../agent/AgentConfig' +import type { Repository } from '../../../../storage/Repository' +import type { ActionMenuStateChangedEvent } from '../../ActionMenuEvents' +import type { ActionMenuSelection } from '../../models' + +import { Subject } from 'rxjs' + +import { + agentDependencies, + getAgentConfig, + getAgentContext, + getMockConnection, + mockFunction, +} from '../../../../../tests/helpers' +import { EventEmitter } from '../../../../agent/EventEmitter' +import { InboundMessageContext } from '../../../../agent/models/InboundMessageContext' +import { DidExchangeState } from '../../../connections' +import { ActionMenuEventTypes } from '../../ActionMenuEvents' +import { ActionMenuRole } from '../../ActionMenuRole' +import { ActionMenuState } from '../../ActionMenuState' +import { ActionMenuProblemReportError } from '../../errors/ActionMenuProblemReportError' +import { ActionMenuProblemReportReason } from '../../errors/ActionMenuProblemReportReason' +import { MenuMessage, MenuRequestMessage, PerformMessage } from '../../messages' +import { ActionMenu } from '../../models' +import { ActionMenuRecord, ActionMenuRepository } from '../../repository' +import { ActionMenuService } from '../ActionMenuService' + +jest.mock('../../repository/ActionMenuRepository') +const ActionMenuRepositoryMock = ActionMenuRepository as jest.Mock + +describe('ActionMenuService', () => { + const mockConnectionRecord = getMockConnection({ + id: 'd3849ac3-c981-455b-a1aa-a10bea6cead8', + did: 'did:sov:C2SsBf5QUQpqSAQfhu3sd2', + state: DidExchangeState.Completed, + }) + + let actionMenuRepository: Repository + let actionMenuService: ActionMenuService + let eventEmitter: EventEmitter + let agentConfig: AgentConfig + let agentContext: AgentContext + + const mockActionMenuRecord = (options: { + connectionId: string + role: ActionMenuRole + state: ActionMenuState + threadId: string + menu?: ActionMenu + performedAction?: ActionMenuSelection + }) => { + return new ActionMenuRecord({ + connectionId: options.connectionId, + role: options.role, + state: options.state, + threadId: options.threadId, + menu: options.menu, + performedAction: options.performedAction, + }) + } + + beforeAll(async () => { + agentConfig = getAgentConfig('ActionMenuServiceTest') + agentContext = getAgentContext() + }) + + beforeEach(async () => { + actionMenuRepository = new ActionMenuRepositoryMock() + eventEmitter = new EventEmitter(agentDependencies, new Subject()) + actionMenuService = new ActionMenuService(actionMenuRepository, agentConfig, eventEmitter) + }) + + describe('createMenu', () => { + let testMenu: ActionMenu + + beforeAll(() => { + testMenu = new ActionMenu({ + description: 'menu-description', + title: 'menu-title', + options: [{ name: 'opt1', title: 'opt1-title', description: 'opt1-desc' }], + }) + }) + + it(`throws an error when duplicated options are specified`, async () => { + expect( + actionMenuService.createMenu(agentContext, { + connection: mockConnectionRecord, + menu: { + title: 'menu-title', + description: 'menu-description', + options: [ + { name: 'opt1', description: 'desc1', title: 'title1' }, + { name: 'opt2', description: 'desc2', title: 'title2' }, + { name: 'opt1', description: 'desc3', title: 'title3' }, + { name: 'opt4', description: 'desc4', title: 'title4' }, + ], + }, + }) + ).rejects.toThrowError('Action Menu contains duplicated options') + }) + + it(`no previous menu: emits a menu with title, description and options`, async () => { + // No previous menu + mockFunction(actionMenuRepository.findSingleByQuery).mockReturnValue(Promise.resolve(null)) + + const eventListenerMock = jest.fn() + eventEmitter.on(ActionMenuEventTypes.ActionMenuStateChanged, eventListenerMock) + + await actionMenuService.createMenu(agentContext, { + connection: mockConnectionRecord, + menu: testMenu, + }) + + expect(eventListenerMock).toHaveBeenCalledWith({ + type: ActionMenuEventTypes.ActionMenuStateChanged, + metadata: { + contextCorrelationId: 'mock', + }, + payload: { + previousState: null, + actionMenuRecord: expect.objectContaining({ + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Responder, + state: ActionMenuState.AwaitingSelection, + menu: expect.objectContaining({ + description: 'menu-description', + title: 'menu-title', + options: [{ name: 'opt1', title: 'opt1-title', description: 'opt1-desc' }], + }), + }), + }, + }) + }) + + it(`existing menu: emits a menu with title, description, options and thread`, async () => { + // Previous menu is in Done state + const previousMenuDone = mockActionMenuRecord({ + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Responder, + state: ActionMenuState.Done, + threadId: 'threadId-1', + }) + + mockFunction(actionMenuRepository.findSingleByQuery).mockReturnValue(Promise.resolve(previousMenuDone)) + + const eventListenerMock = jest.fn() + eventEmitter.on(ActionMenuEventTypes.ActionMenuStateChanged, eventListenerMock) + + await actionMenuService.createMenu(agentContext, { + connection: mockConnectionRecord, + menu: testMenu, + }) + + expect(eventListenerMock).toHaveBeenCalledWith({ + type: ActionMenuEventTypes.ActionMenuStateChanged, + metadata: { + contextCorrelationId: 'mock', + }, + payload: { + previousState: ActionMenuState.Done, + actionMenuRecord: expect.objectContaining({ + connectionId: mockConnectionRecord.id, + threadId: 'threadId-1', + role: ActionMenuRole.Responder, + state: ActionMenuState.AwaitingSelection, + menu: expect.objectContaining({ + description: 'menu-description', + title: 'menu-title', + options: [{ name: 'opt1', title: 'opt1-title', description: 'opt1-desc' }], + }), + }), + }, + }) + }) + + it(`existing menu, cleared: emits a menu with title, description, options and new thread`, async () => { + // Previous menu is in Done state + const previousMenuClear = mockActionMenuRecord({ + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Responder, + state: ActionMenuState.Null, + threadId: 'threadId-1', + }) + + mockFunction(actionMenuRepository.findSingleByQuery).mockReturnValue(Promise.resolve(previousMenuClear)) + + const eventListenerMock = jest.fn() + eventEmitter.on(ActionMenuEventTypes.ActionMenuStateChanged, eventListenerMock) + + await actionMenuService.createMenu(agentContext, { + connection: mockConnectionRecord, + menu: testMenu, + }) + + expect(eventListenerMock).toHaveBeenCalledWith({ + type: ActionMenuEventTypes.ActionMenuStateChanged, + metadata: { + contextCorrelationId: 'mock', + }, + payload: { + previousState: ActionMenuState.Null, + actionMenuRecord: expect.objectContaining({ + connectionId: mockConnectionRecord.id, + threadId: expect.not.stringMatching('threadId-1'), + role: ActionMenuRole.Responder, + state: ActionMenuState.AwaitingSelection, + menu: expect.objectContaining({ + description: 'menu-description', + title: 'menu-title', + options: [{ name: 'opt1', title: 'opt1-title', description: 'opt1-desc' }], + }), + }), + }, + }) + }) + }) + + describe('createPerform', () => { + let mockRecord: ActionMenuRecord + + beforeEach(() => { + const testMenu = new ActionMenu({ + description: 'menu-description', + title: 'menu-title', + options: [ + { name: 'opt1', title: 'opt1-title', description: 'opt1-desc' }, + { name: 'opt2', title: 'opt2-title', description: 'opt2-desc' }, + ], + }) + + mockRecord = mockActionMenuRecord({ + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Requester, + state: ActionMenuState.PreparingSelection, + threadId: '123', + menu: testMenu, + }) + }) + + it(`throws an error when invalid selection is provided`, async () => { + expect( + actionMenuService.createPerform(agentContext, { + actionMenuRecord: mockRecord, + performedAction: { name: 'fake' }, + }) + ).rejects.toThrowError('Selection does not match valid actions') + }) + + it(`throws an error when state is not preparing-selection`, async () => { + for (const state of Object.values(ActionMenuState).filter( + (state) => state !== ActionMenuState.PreparingSelection + )) { + mockRecord.state = state + expect( + actionMenuService.createPerform(agentContext, { + actionMenuRecord: mockRecord, + performedAction: { name: 'opt1' }, + }) + ).rejects.toThrowError( + `Action Menu record is in invalid state ${state}. Valid states are: preparing-selection.` + ) + } + }) + + it(`emits a menu with a valid selection and action menu record`, async () => { + const eventListenerMock = jest.fn() + eventEmitter.on(ActionMenuEventTypes.ActionMenuStateChanged, eventListenerMock) + + mockFunction(actionMenuRepository.findSingleByQuery).mockReturnValue(Promise.resolve(mockRecord)) + + await actionMenuService.createPerform(agentContext, { + actionMenuRecord: mockRecord, + performedAction: { name: 'opt2' }, + }) + + expect(eventListenerMock).toHaveBeenCalledWith({ + type: ActionMenuEventTypes.ActionMenuStateChanged, + metadata: { + contextCorrelationId: 'mock', + }, + payload: { + previousState: ActionMenuState.PreparingSelection, + actionMenuRecord: expect.objectContaining({ + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Requester, + state: ActionMenuState.Done, + performedAction: { name: 'opt2' }, + }), + }, + }) + }) + }) + + describe('createRequest', () => { + let mockRecord: ActionMenuRecord + + beforeEach(() => { + const testMenu = new ActionMenu({ + description: 'menu-description', + title: 'menu-title', + options: [ + { name: 'opt1', title: 'opt1-title', description: 'opt1-desc' }, + { name: 'opt2', title: 'opt2-title', description: 'opt2-desc' }, + ], + }) + + mockRecord = mockActionMenuRecord({ + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Requester, + state: ActionMenuState.PreparingSelection, + threadId: '123', + menu: testMenu, + }) + }) + + it(`no existing record: emits event and creates new request and record`, async () => { + mockFunction(actionMenuRepository.findSingleByQuery).mockReturnValue(Promise.resolve(null)) + + const eventListenerMock = jest.fn() + eventEmitter.on(ActionMenuEventTypes.ActionMenuStateChanged, eventListenerMock) + + const { message, record } = await actionMenuService.createRequest(agentContext, { + connection: mockConnectionRecord, + }) + + const expectedRecord = { + id: expect.any(String), + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Requester, + threadId: message.threadId, + state: ActionMenuState.AwaitingRootMenu, + menu: undefined, + performedAction: undefined, + } + expect(record).toMatchObject(expectedRecord) + + expect(actionMenuRepository.save).toHaveBeenCalledWith(agentContext, expect.objectContaining(expectedRecord)) + expect(actionMenuRepository.update).not.toHaveBeenCalled() + + expect(eventListenerMock).toHaveBeenCalledWith({ + type: ActionMenuEventTypes.ActionMenuStateChanged, + metadata: { + contextCorrelationId: 'mock', + }, + payload: { + previousState: null, + actionMenuRecord: expect.objectContaining(expectedRecord), + }, + }) + }) + + it(`already existing record: emits event, creates new request and updates record`, async () => { + mockFunction(actionMenuRepository.findSingleByQuery).mockReturnValue(Promise.resolve(mockRecord)) + + const previousState = mockRecord.state + + const eventListenerMock = jest.fn() + eventEmitter.on(ActionMenuEventTypes.ActionMenuStateChanged, eventListenerMock) + + const { message, record } = await actionMenuService.createRequest(agentContext, { + connection: mockConnectionRecord, + }) + + const expectedRecord = { + id: expect.any(String), + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Requester, + threadId: message.threadId, + state: ActionMenuState.AwaitingRootMenu, + menu: undefined, + performedAction: undefined, + } + expect(record).toMatchObject(expectedRecord) + + expect(actionMenuRepository.update).toHaveBeenCalledWith(agentContext, expect.objectContaining(expectedRecord)) + expect(actionMenuRepository.save).not.toHaveBeenCalled() + + expect(eventListenerMock).toHaveBeenCalledWith({ + type: ActionMenuEventTypes.ActionMenuStateChanged, + metadata: { + contextCorrelationId: 'mock', + }, + payload: { + previousState, + actionMenuRecord: expect.objectContaining(expectedRecord), + }, + }) + }) + }) + + describe('clearMenu', () => { + let mockRecord: ActionMenuRecord + + beforeEach(() => { + const testMenu = new ActionMenu({ + description: 'menu-description', + title: 'menu-title', + options: [ + { name: 'opt1', title: 'opt1-title', description: 'opt1-desc' }, + { name: 'opt2', title: 'opt2-title', description: 'opt2-desc' }, + ], + }) + + mockRecord = mockActionMenuRecord({ + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Requester, + state: ActionMenuState.PreparingSelection, + threadId: '123', + menu: testMenu, + performedAction: { name: 'opt1' }, + }) + }) + + it(`requester role: emits a cleared menu`, async () => { + const eventListenerMock = jest.fn() + eventEmitter.on(ActionMenuEventTypes.ActionMenuStateChanged, eventListenerMock) + + mockRecord.role = ActionMenuRole.Requester + mockFunction(actionMenuRepository.findSingleByQuery).mockReturnValue(Promise.resolve(mockRecord)) + + await actionMenuService.clearMenu(agentContext, { + actionMenuRecord: mockRecord, + }) + + expect(eventListenerMock).toHaveBeenCalledWith({ + type: ActionMenuEventTypes.ActionMenuStateChanged, + metadata: { + contextCorrelationId: 'mock', + }, + payload: { + previousState: ActionMenuState.PreparingSelection, + actionMenuRecord: expect.objectContaining({ + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Requester, + state: ActionMenuState.Null, + menu: undefined, + performedAction: undefined, + }), + }, + }) + }) + + it(`responder role: emits a cleared menu`, async () => { + const eventListenerMock = jest.fn() + eventEmitter.on(ActionMenuEventTypes.ActionMenuStateChanged, eventListenerMock) + + mockRecord.state = ActionMenuState.AwaitingSelection + mockRecord.role = ActionMenuRole.Responder + mockFunction(actionMenuRepository.findSingleByQuery).mockReturnValue(Promise.resolve(mockRecord)) + + await actionMenuService.clearMenu(agentContext, { + actionMenuRecord: mockRecord, + }) + + expect(eventListenerMock).toHaveBeenCalledWith({ + type: ActionMenuEventTypes.ActionMenuStateChanged, + metadata: { + contextCorrelationId: 'mock', + }, + payload: { + previousState: ActionMenuState.AwaitingSelection, + actionMenuRecord: expect.objectContaining({ + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Responder, + state: ActionMenuState.Null, + menu: undefined, + performedAction: undefined, + }), + }, + }) + }) + }) + + describe('processMenu', () => { + let mockRecord: ActionMenuRecord + let mockMenuMessage: MenuMessage + + beforeEach(() => { + mockRecord = mockActionMenuRecord({ + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Requester, + state: ActionMenuState.PreparingSelection, + threadId: '123', + menu: new ActionMenu({ + description: 'menu-description', + title: 'menu-title', + options: [ + { name: 'opt1', title: 'opt1-title', description: 'opt1-desc' }, + { name: 'opt2', title: 'opt2-title', description: 'opt2-desc' }, + ], + }), + performedAction: { name: 'opt1' }, + }) + + mockMenuMessage = new MenuMessage({ + title: 'incoming title', + description: 'incoming description', + options: [ + { + title: 'incoming option 1 title', + description: 'incoming option 1 description', + name: 'incoming option 1 name', + }, + ], + }) + }) + + it(`emits event and creates record when no previous record`, async () => { + const messageContext = new InboundMessageContext(mockMenuMessage, { + agentContext, + connection: mockConnectionRecord, + }) + + const eventListenerMock = jest.fn() + eventEmitter.on(ActionMenuEventTypes.ActionMenuStateChanged, eventListenerMock) + + mockFunction(actionMenuRepository.findSingleByQuery).mockReturnValue(Promise.resolve(null)) + + await actionMenuService.processMenu(messageContext) + + const expectedRecord = { + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Requester, + state: ActionMenuState.PreparingSelection, + threadId: messageContext.message.threadId, + menu: expect.objectContaining({ + title: 'incoming title', + description: 'incoming description', + options: [ + { + title: 'incoming option 1 title', + description: 'incoming option 1 description', + name: 'incoming option 1 name', + }, + ], + }), + performedAction: undefined, + } + + expect(actionMenuRepository.save).toHaveBeenCalledWith(agentContext, expect.objectContaining(expectedRecord)) + expect(actionMenuRepository.update).not.toHaveBeenCalled() + + expect(eventListenerMock).toHaveBeenCalledWith({ + type: ActionMenuEventTypes.ActionMenuStateChanged, + metadata: { + contextCorrelationId: 'mock', + }, + payload: { + previousState: null, + actionMenuRecord: expect.objectContaining(expectedRecord), + }, + }) + }) + + it(`emits event and updates record when existing record`, async () => { + const messageContext = new InboundMessageContext(mockMenuMessage, { + agentContext, + connection: mockConnectionRecord, + }) + + const eventListenerMock = jest.fn() + eventEmitter.on(ActionMenuEventTypes.ActionMenuStateChanged, eventListenerMock) + + // It should accept any previous state + for (const state of Object.values(ActionMenuState)) { + mockRecord.state = state + const previousState = state + mockFunction(actionMenuRepository.findSingleByQuery).mockReturnValue(Promise.resolve(mockRecord)) + + await actionMenuService.processMenu(messageContext) + + const expectedRecord = { + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Requester, + state: ActionMenuState.PreparingSelection, + threadId: messageContext.message.threadId, + menu: expect.objectContaining({ + title: 'incoming title', + description: 'incoming description', + options: [ + { + title: 'incoming option 1 title', + description: 'incoming option 1 description', + name: 'incoming option 1 name', + }, + ], + }), + performedAction: undefined, + } + + expect(actionMenuRepository.update).toHaveBeenCalledWith(agentContext, expect.objectContaining(expectedRecord)) + expect(actionMenuRepository.save).not.toHaveBeenCalled() + + expect(eventListenerMock).toHaveBeenCalledWith({ + type: ActionMenuEventTypes.ActionMenuStateChanged, + metadata: { + contextCorrelationId: 'mock', + }, + payload: { + previousState, + actionMenuRecord: expect.objectContaining(expectedRecord), + }, + }) + } + }) + }) + + describe('processPerform', () => { + let mockRecord: ActionMenuRecord + + beforeEach(() => { + mockRecord = mockActionMenuRecord({ + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Responder, + state: ActionMenuState.AwaitingSelection, + threadId: '123', + menu: new ActionMenu({ + description: 'menu-description', + title: 'menu-title', + options: [ + { name: 'opt1', title: 'opt1-title', description: 'opt1-desc' }, + { name: 'opt2', title: 'opt2-title', description: 'opt2-desc' }, + ], + }), + }) + }) + + it(`emits event and saves record when valid selection and thread Id`, async () => { + const mockPerformMessage = new PerformMessage({ + name: 'opt1', + threadId: '123', + }) + + const messageContext = new InboundMessageContext(mockPerformMessage, { + agentContext, + connection: mockConnectionRecord, + }) + + const eventListenerMock = jest.fn() + eventEmitter.on(ActionMenuEventTypes.ActionMenuStateChanged, eventListenerMock) + + mockFunction(actionMenuRepository.findSingleByQuery).mockReturnValue(Promise.resolve(mockRecord)) + + await actionMenuService.processPerform(messageContext) + + const expectedRecord = { + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Responder, + state: ActionMenuState.Done, + threadId: messageContext.message.threadId, + menu: expect.objectContaining({ + description: 'menu-description', + title: 'menu-title', + options: [ + { name: 'opt1', title: 'opt1-title', description: 'opt1-desc' }, + { name: 'opt2', title: 'opt2-title', description: 'opt2-desc' }, + ], + }), + performedAction: { name: 'opt1' }, + } + + expect(actionMenuRepository.findSingleByQuery).toHaveBeenCalledWith( + agentContext, + expect.objectContaining({ + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Responder, + threadId: messageContext.message.threadId, + }) + ) + expect(actionMenuRepository.update).toHaveBeenCalledWith(agentContext, expect.objectContaining(expectedRecord)) + expect(actionMenuRepository.save).not.toHaveBeenCalled() + + expect(eventListenerMock).toHaveBeenCalledWith({ + type: ActionMenuEventTypes.ActionMenuStateChanged, + metadata: { + contextCorrelationId: 'mock', + }, + payload: { + previousState: ActionMenuState.AwaitingSelection, + actionMenuRecord: expect.objectContaining(expectedRecord), + }, + }) + }) + + it(`throws error when invalid selection`, async () => { + const mockPerformMessage = new PerformMessage({ + name: 'fake', + threadId: '123', + }) + + const messageContext = new InboundMessageContext(mockPerformMessage, { + agentContext, + connection: mockConnectionRecord, + }) + + const eventListenerMock = jest.fn() + eventEmitter.on(ActionMenuEventTypes.ActionMenuStateChanged, eventListenerMock) + + mockFunction(actionMenuRepository.findSingleByQuery).mockReturnValue(Promise.resolve(mockRecord)) + + expect(actionMenuService.processPerform(messageContext)).rejects.toThrowError( + 'Selection does not match valid actions' + ) + + expect(actionMenuRepository.update).not.toHaveBeenCalled() + expect(actionMenuRepository.save).not.toHaveBeenCalled() + expect(eventListenerMock).not.toHaveBeenCalled() + }) + + it(`throws error when record not found`, async () => { + const mockPerformMessage = new PerformMessage({ + name: 'opt1', + threadId: '122', + }) + + const messageContext = new InboundMessageContext(mockPerformMessage, { + agentContext, + connection: mockConnectionRecord, + }) + + const eventListenerMock = jest.fn() + eventEmitter.on(ActionMenuEventTypes.ActionMenuStateChanged, eventListenerMock) + + mockFunction(actionMenuRepository.findSingleByQuery).mockReturnValue(Promise.resolve(null)) + + expect(actionMenuService.processPerform(messageContext)).rejects.toThrowError( + `No Action Menu found with thread id ${mockPerformMessage.threadId}` + ) + + expect(actionMenuRepository.update).not.toHaveBeenCalled() + expect(actionMenuRepository.save).not.toHaveBeenCalled() + expect(eventListenerMock).not.toHaveBeenCalled() + }) + + it(`throws error when invalid state`, async () => { + const mockPerformMessage = new PerformMessage({ + name: 'opt1', + threadId: '123', + }) + + const messageContext = new InboundMessageContext(mockPerformMessage, { + agentContext, + connection: mockConnectionRecord, + }) + + const eventListenerMock = jest.fn() + eventEmitter.on(ActionMenuEventTypes.ActionMenuStateChanged, eventListenerMock) + + mockRecord.state = ActionMenuState.Done + mockFunction(actionMenuRepository.findSingleByQuery).mockReturnValue(Promise.resolve(mockRecord)) + + expect(actionMenuService.processPerform(messageContext)).rejects.toThrowError( + `Action Menu record is in invalid state ${mockRecord.state}. Valid states are: ${ActionMenuState.AwaitingSelection}.` + ) + + expect(actionMenuRepository.update).not.toHaveBeenCalled() + expect(actionMenuRepository.save).not.toHaveBeenCalled() + expect(eventListenerMock).not.toHaveBeenCalled() + }) + + it(`throws problem report error when menu has been cleared`, async () => { + const mockPerformMessage = new PerformMessage({ + name: 'opt1', + threadId: '123', + }) + + const messageContext = new InboundMessageContext(mockPerformMessage, { + agentContext, + connection: mockConnectionRecord, + }) + + const eventListenerMock = jest.fn() + eventEmitter.on(ActionMenuEventTypes.ActionMenuStateChanged, eventListenerMock) + + mockRecord.state = ActionMenuState.Null + mockFunction(actionMenuRepository.findSingleByQuery).mockReturnValue(Promise.resolve(mockRecord)) + + expect(actionMenuService.processPerform(messageContext)).rejects.toThrowError( + new ActionMenuProblemReportError('Action Menu has been cleared by the responder', { + problemCode: ActionMenuProblemReportReason.Timeout, + }) + ) + + expect(actionMenuRepository.update).not.toHaveBeenCalled() + expect(actionMenuRepository.save).not.toHaveBeenCalled() + expect(eventListenerMock).not.toHaveBeenCalled() + }) + }) + + describe('processRequest', () => { + let mockRecord: ActionMenuRecord + let mockMenuRequestMessage: MenuRequestMessage + + beforeEach(() => { + mockRecord = mockActionMenuRecord({ + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Responder, + state: ActionMenuState.PreparingRootMenu, + threadId: '123', + menu: new ActionMenu({ + description: 'menu-description', + title: 'menu-title', + options: [ + { name: 'opt1', title: 'opt1-title', description: 'opt1-desc' }, + { name: 'opt2', title: 'opt2-title', description: 'opt2-desc' }, + ], + }), + performedAction: { name: 'opt1' }, + }) + + mockMenuRequestMessage = new MenuRequestMessage({}) + }) + + it(`emits event and creates record when no previous record`, async () => { + const messageContext = new InboundMessageContext(mockMenuRequestMessage, { + agentContext, + connection: mockConnectionRecord, + }) + + const eventListenerMock = jest.fn() + eventEmitter.on(ActionMenuEventTypes.ActionMenuStateChanged, eventListenerMock) + + mockFunction(actionMenuRepository.findSingleByQuery).mockReturnValue(Promise.resolve(null)) + + await actionMenuService.processRequest(messageContext) + + const expectedRecord = { + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Responder, + state: ActionMenuState.PreparingRootMenu, + threadId: messageContext.message.threadId, + menu: undefined, + performedAction: undefined, + } + + expect(actionMenuRepository.save).toHaveBeenCalledWith(agentContext, expect.objectContaining(expectedRecord)) + expect(actionMenuRepository.update).not.toHaveBeenCalled() + + expect(eventListenerMock).toHaveBeenCalledWith({ + type: ActionMenuEventTypes.ActionMenuStateChanged, + metadata: { + contextCorrelationId: 'mock', + }, + payload: { + previousState: null, + actionMenuRecord: expect.objectContaining(expectedRecord), + }, + }) + }) + + it(`emits event and updates record when existing record`, async () => { + const messageContext = new InboundMessageContext(mockMenuRequestMessage, { + agentContext, + connection: mockConnectionRecord, + }) + + const eventListenerMock = jest.fn() + eventEmitter.on(ActionMenuEventTypes.ActionMenuStateChanged, eventListenerMock) + + // It should accept any previous state + for (const state of Object.values(ActionMenuState)) { + mockRecord.state = state + const previousState = state + mockFunction(actionMenuRepository.findSingleByQuery).mockReturnValue(Promise.resolve(mockRecord)) + + await actionMenuService.processRequest(messageContext) + + const expectedRecord = { + connectionId: mockConnectionRecord.id, + role: ActionMenuRole.Responder, + state: ActionMenuState.PreparingRootMenu, + threadId: messageContext.message.threadId, + menu: undefined, + performedAction: undefined, + } + + expect(actionMenuRepository.update).toHaveBeenCalledWith(agentContext, expect.objectContaining(expectedRecord)) + expect(actionMenuRepository.save).not.toHaveBeenCalled() + + expect(eventListenerMock).toHaveBeenCalledWith({ + type: ActionMenuEventTypes.ActionMenuStateChanged, + metadata: { + contextCorrelationId: 'mock', + }, + payload: { + previousState, + actionMenuRecord: expect.objectContaining(expectedRecord), + }, + }) + } + }) + }) +}) diff --git a/packages/core/src/modules/action-menu/services/index.ts b/packages/core/src/modules/action-menu/services/index.ts new file mode 100644 index 0000000000..83362466e7 --- /dev/null +++ b/packages/core/src/modules/action-menu/services/index.ts @@ -0,0 +1,2 @@ +export * from './ActionMenuService' +export * from './ActionMenuServiceOptions' diff --git a/packages/core/src/modules/basic-messages/BasicMessagesApi.ts b/packages/core/src/modules/basic-messages/BasicMessagesApi.ts index 9de00e5e2b..fbebfc30c2 100644 --- a/packages/core/src/modules/basic-messages/BasicMessagesApi.ts +++ b/packages/core/src/modules/basic-messages/BasicMessagesApi.ts @@ -1,4 +1,5 @@ -import type { BasicMessageTags } from './repository/BasicMessageRecord' +import type { Query } from '../../storage/StorageService' +import type { BasicMessageRecord } from './repository/BasicMessageRecord' import { AgentContext } from '../../agent' import { Dispatcher } from '../../agent/Dispatcher' @@ -31,18 +32,62 @@ export class BasicMessagesApi { this.registerHandlers(dispatcher) } + /** + * Send a message to an active connection + * + * @param connectionId Connection Id + * @param message Message contents + * @throws {RecordNotFoundError} If connection is not found + * @throws {MessageSendingError} If message is undeliverable + * @returns the created record + */ 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 { message: basicMessage, record: basicMessageRecord } = await this.basicMessageService.createMessage( + this.agentContext, + message, + connection + ) const outboundMessage = createOutboundMessage(connection, basicMessage) + outboundMessage.associatedRecord = basicMessageRecord + await this.messageSender.sendMessage(this.agentContext, outboundMessage) + return basicMessageRecord } - public async findAllByQuery(query: Partial) { + /** + * Retrieve all basic messages matching a given query + * + * @param query The query + * @returns array containing all matching records + */ + public async findAllByQuery(query: Query) { return this.basicMessageService.findAllByQuery(this.agentContext, query) } + /** + * Retrieve a basic message record by id + * + * @param basicMessageRecordId The basic message record id + * @throws {RecordNotFoundError} If no record is found + * @return The basic message record + * + */ + public async getById(basicMessageRecordId: string) { + return this.basicMessageService.getById(this.agentContext, basicMessageRecordId) + } + + /** + * Delete a basic message record by id + * + * @param connectionId the basic message record id + * @throws {RecordNotFoundError} If no record is found + */ + public async deleteById(basicMessageRecordId: string) { + await this.basicMessageService.deleteById(this.agentContext, basicMessageRecordId) + } + private registerHandlers(dispatcher: Dispatcher) { dispatcher.registerHandler(new BasicMessageHandler(this.basicMessageService)) } diff --git a/packages/core/src/modules/basic-messages/__tests__/BasicMessageService.test.ts b/packages/core/src/modules/basic-messages/__tests__/BasicMessageService.test.ts index ad2fbfa547..83dd0c4c01 100644 --- a/packages/core/src/modules/basic-messages/__tests__/BasicMessageService.test.ts +++ b/packages/core/src/modules/basic-messages/__tests__/BasicMessageService.test.ts @@ -30,7 +30,7 @@ describe('BasicMessageService', () => { describe('createMessage', () => { it(`creates message and record, and emits message and basic message record`, async () => { - const message = await basicMessageService.createMessage(agentContext, 'hello', mockConnectionRecord) + const { message } = await basicMessageService.createMessage(agentContext, 'hello', mockConnectionRecord) expect(message.content).toBe('hello') diff --git a/packages/core/src/modules/basic-messages/__tests__/basic-messages.e2e.test.ts b/packages/core/src/modules/basic-messages/__tests__/basic-messages.e2e.test.ts new file mode 100644 index 0000000000..4f3b7205f1 --- /dev/null +++ b/packages/core/src/modules/basic-messages/__tests__/basic-messages.e2e.test.ts @@ -0,0 +1,110 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { SubjectMessage } from '../../../../../../tests/transport/SubjectInboundTransport' +import type { ConnectionRecord } from '../../../modules/connections' + +import { Subject } from 'rxjs' + +import { SubjectInboundTransport } from '../../../../../../tests/transport/SubjectInboundTransport' +import { SubjectOutboundTransport } from '../../../../../../tests/transport/SubjectOutboundTransport' +import { getAgentOptions, makeConnection, waitForBasicMessage } from '../../../../tests/helpers' +import testLogger from '../../../../tests/logger' +import { Agent } from '../../../agent/Agent' +import { MessageSendingError, RecordNotFoundError } from '../../../error' +import { BasicMessage } from '../messages' +import { BasicMessageRecord } from '../repository' + +const faberConfig = getAgentOptions('Faber Basic Messages', { + endpoints: ['rxjs:faber'], +}) + +const aliceConfig = getAgentOptions('Alice Basic Messages', { + endpoints: ['rxjs:alice'], +}) + +describe('Basic Messages E2E', () => { + let faberAgent: Agent + let aliceAgent: Agent + let faberConnection: ConnectionRecord + let aliceConnection: ConnectionRecord + + beforeEach(async () => { + const faberMessages = new Subject() + const aliceMessages = new Subject() + const subjectMap = { + 'rxjs:faber': faberMessages, + 'rxjs:alice': aliceMessages, + } + + faberAgent = new Agent(faberConfig) + faberAgent.registerInboundTransport(new SubjectInboundTransport(faberMessages)) + faberAgent.registerOutboundTransport(new SubjectOutboundTransport(subjectMap)) + await faberAgent.initialize() + + aliceAgent = new Agent(aliceConfig) + aliceAgent.registerInboundTransport(new SubjectInboundTransport(aliceMessages)) + aliceAgent.registerOutboundTransport(new SubjectOutboundTransport(subjectMap)) + await aliceAgent.initialize() + ;[aliceConnection, faberConnection] = await makeConnection(aliceAgent, faberAgent) + }) + + afterEach(async () => { + await faberAgent.shutdown() + await faberAgent.wallet.delete() + await aliceAgent.shutdown() + await aliceAgent.wallet.delete() + }) + + test('Alice and Faber exchange messages', async () => { + testLogger.test('Alice sends message to Faber') + const helloRecord = await aliceAgent.basicMessages.sendMessage(aliceConnection.id, 'Hello') + + expect(helloRecord.content).toBe('Hello') + + testLogger.test('Faber waits for message from Alice') + await waitForBasicMessage(faberAgent, { + content: 'Hello', + }) + + testLogger.test('Faber sends message to Alice') + const replyRecord = await faberAgent.basicMessages.sendMessage(faberConnection.id, 'How are you?') + expect(replyRecord.content).toBe('How are you?') + + testLogger.test('Alice waits until she receives message from faber') + await waitForBasicMessage(aliceAgent, { + content: 'How are you?', + }) + }) + + test('Alice is unable to send a message', async () => { + testLogger.test('Alice sends message to Faber that is undeliverable') + + const spy = jest.spyOn(aliceAgent.outboundTransports[0], 'sendMessage').mockRejectedValue(new Error('any error')) + + await expect(aliceAgent.basicMessages.sendMessage(aliceConnection.id, 'Hello')).rejects.toThrowError( + MessageSendingError + ) + try { + await aliceAgent.basicMessages.sendMessage(aliceConnection.id, 'Hello undeliverable') + } catch (error) { + const thrownError = error as MessageSendingError + expect(thrownError.message).toEqual( + `Message is undeliverable to connection ${aliceConnection.id} (${aliceConnection.theirLabel})` + ) + testLogger.test('Error thrown includes the outbound message and recently created record id') + expect(thrownError.outboundMessage.associatedRecord).toBeInstanceOf(BasicMessageRecord) + expect(thrownError.outboundMessage.payload).toBeInstanceOf(BasicMessage) + expect((thrownError.outboundMessage.payload as BasicMessage).content).toBe('Hello undeliverable') + + testLogger.test('Created record can be found and deleted by id') + const storedRecord = await aliceAgent.basicMessages.getById(thrownError.outboundMessage.associatedRecord!.id) + expect(storedRecord).toBeInstanceOf(BasicMessageRecord) + expect(storedRecord.content).toBe('Hello undeliverable') + + await aliceAgent.basicMessages.deleteById(storedRecord.id) + await expect( + aliceAgent.basicMessages.getById(thrownError.outboundMessage.associatedRecord!.id) + ).rejects.toThrowError(RecordNotFoundError) + } + spy.mockClear() + }) +}) diff --git a/packages/core/src/modules/basic-messages/services/BasicMessageService.ts b/packages/core/src/modules/basic-messages/services/BasicMessageService.ts index dff23b0f7e..eb79ae3f32 100644 --- a/packages/core/src/modules/basic-messages/services/BasicMessageService.ts +++ b/packages/core/src/modules/basic-messages/services/BasicMessageService.ts @@ -1,8 +1,8 @@ import type { AgentContext } from '../../../agent' import type { InboundMessageContext } from '../../../agent/models/InboundMessageContext' +import type { Query } from '../../../storage/StorageService' import type { ConnectionRecord } from '../../connections/repository/ConnectionRecord' import type { BasicMessageStateChangedEvent } from '../BasicMessageEvents' -import type { BasicMessageTags } from '../repository' import { EventEmitter } from '../../../agent/EventEmitter' import { injectable } from '../../../plugins' @@ -35,7 +35,7 @@ export class BasicMessageService { await this.basicMessageRepository.save(agentContext, basicMessageRecord) this.emitStateChangedEvent(agentContext, basicMessageRecord, basicMessage) - return basicMessage + return { message: basicMessage, record: basicMessageRecord } } /** @@ -65,7 +65,16 @@ export class BasicMessageService { }) } - public async findAllByQuery(agentContext: AgentContext, query: Partial) { + public async findAllByQuery(agentContext: AgentContext, query: Query) { return this.basicMessageRepository.findByQuery(agentContext, query) } + + public async getById(agentContext: AgentContext, basicMessageRecordId: string) { + return this.basicMessageRepository.getById(agentContext, basicMessageRecordId) + } + + public async deleteById(agentContext: AgentContext, basicMessageRecordId: string) { + const basicMessageRecord = await this.getById(agentContext, basicMessageRecordId) + return this.basicMessageRepository.delete(agentContext, basicMessageRecord) + } } diff --git a/packages/core/src/modules/connections/ConnectionsApi.ts b/packages/core/src/modules/connections/ConnectionsApi.ts index cc4154eb08..1b4207be31 100644 --- a/packages/core/src/modules/connections/ConnectionsApi.ts +++ b/packages/core/src/modules/connections/ConnectionsApi.ts @@ -1,4 +1,6 @@ +import type { Query } from '../../storage/StorageService' import type { OutOfBandRecord } from '../oob/repository' +import type { ConnectionType } from './models' import type { ConnectionRecord } from './repository/ConnectionRecord' import type { Routing } from './services' @@ -216,6 +218,68 @@ export class ConnectionsApi { return this.connectionService.getAll(this.agentContext) } + /** + * Retrieve all connections records by specified query params + * + * @returns List containing all connection records matching specified query paramaters + */ + public findAllByQuery(query: Query) { + return this.connectionService.findAllByQuery(this.agentContext, query) + } + + /** + * Allows for the addition of connectionType to the record. + * Either updates or creates an array of string conection types + * @param connectionId + * @param type + * @throws {RecordNotFoundError} If no record is found + */ + public async addConnectionType(connectionId: string, type: ConnectionType | string) { + const record = await this.getById(connectionId) + + const tags = (record.getTag('connectionType') as string[]) || ([] as string[]) + record.setTag('connectionType', [type, ...tags]) + await this.connectionService.update(this.agentContext, record) + } + /** + * Removes the given tag from the given record found by connectionId, if the tag exists otherwise does nothing + * @param connectionId + * @param type + * @throws {RecordNotFoundError} If no record is found + */ + public async removeConnectionType(connectionId: string, type: ConnectionType | string) { + const record = await this.getById(connectionId) + + const tags = (record.getTag('connectionType') as string[]) || ([] as string[]) + + const newTags = tags.filter((value: string) => { + if (value != type) return value + }) + record.setTag('connectionType', [...newTags]) + + await this.connectionService.update(this.agentContext, record) + } + /** + * Gets the known connection types for the record matching the given connectionId + * @param connectionId + * @returns An array of known connection types or null if none exist + * @throws {RecordNotFoundError} If no record is found + */ + public async getConnectionTypes(connectionId: string) { + const record = await this.getById(connectionId) + const tags = record.getTag('connectionType') as string[] + return tags || null + } + + /** + * + * @param connectionTypes An array of connection types to query for a match for + * @returns a promise of ab array of connection records + */ + public async findAllByConnectionType(connectionTypes: [ConnectionType | string]) { + return this.connectionService.findAllByConnectionType(this.agentContext, connectionTypes) + } + /** * Retrieve a connection record by id * diff --git a/packages/core/src/modules/connections/DidExchangeProtocol.ts b/packages/core/src/modules/connections/DidExchangeProtocol.ts index 6b308adced..9e5fa64b62 100644 --- a/packages/core/src/modules/connections/DidExchangeProtocol.ts +++ b/packages/core/src/modules/connections/DidExchangeProtocol.ts @@ -1,9 +1,8 @@ import type { AgentContext } from '../../agent' -import type { ResolvedDidCommService } from '../../agent/MessageSender' import type { InboundMessageContext } from '../../agent/models/InboundMessageContext' import type { ParsedMessageType } from '../../utils/messageType' +import type { ResolvedDidCommService } from '../didcomm' import type { PeerDidCreateOptions } from '../dids' -import type { OutOfBandDidCommService } from '../oob/domain/OutOfBandDidCommService' import type { OutOfBandRecord } from '../oob/repository' import type { ConnectionRecord } from './repository' import type { Routing } from './services/ConnectionService' @@ -201,6 +200,7 @@ export class DidExchangeProtocol { protocol: HandshakeProtocol.DidExchange, role: DidExchangeRole.Responder, state: DidExchangeState.RequestReceived, + alias: outOfBandRecord.alias, theirDid: message.did, theirLabel: message.label, threadId: message.threadId, @@ -233,10 +233,7 @@ export class DidExchangeProtocol { if (routing) { services = this.routingToServices(routing) } else if (outOfBandRecord) { - const inlineServices = outOfBandRecord.outOfBandInvitation.services.filter( - (service) => typeof service !== 'string' - ) as OutOfBandDidCommService[] - + const inlineServices = outOfBandRecord.outOfBandInvitation.getInlineServices() services = inlineServices.map((service) => ({ id: service.id, serviceEndpoint: service.serviceEndpoint, @@ -314,7 +311,9 @@ export class DidExchangeProtocol { const didDocument = await this.extractDidDocument( messageContext.agentContext, message, - outOfBandRecord.outOfBandInvitation.getRecipientKeys().map((key) => key.publicKeyBase58) + outOfBandRecord + .getTags() + .recipientKeyFingerprints.map((fingerprint) => Key.fromFingerprint(fingerprint).publicKeyBase58) ) const didRecord = new DidRecord({ id: message.did, diff --git a/packages/core/src/modules/connections/__tests__/ConnectionService.test.ts b/packages/core/src/modules/connections/__tests__/ConnectionService.test.ts index e4520b6528..9299352fe6 100644 --- a/packages/core/src/modules/connections/__tests__/ConnectionService.test.ts +++ b/packages/core/src/modules/connections/__tests__/ConnectionService.test.ts @@ -953,5 +953,19 @@ describe('ConnectionService', () => { expect(result).toEqual(expect.arrayContaining(expected)) }) + + it('findAllByQuery should return value from connectionRepository.findByQuery', async () => { + const expected = [getMockConnection(), getMockConnection()] + + mockFunction(connectionRepository.findByQuery).mockReturnValue(Promise.resolve(expected)) + const result = await connectionService.findAllByQuery(agentContext, { + state: DidExchangeState.InvitationReceived, + }) + expect(connectionRepository.findByQuery).toBeCalledWith(agentContext, { + state: DidExchangeState.InvitationReceived, + }) + + expect(result).toEqual(expect.arrayContaining(expected)) + }) }) }) diff --git a/packages/core/src/modules/connections/handlers/ConnectionResponseHandler.ts b/packages/core/src/modules/connections/handlers/ConnectionResponseHandler.ts index 4b53e220d9..9e28621fb0 100644 --- a/packages/core/src/modules/connections/handlers/ConnectionResponseHandler.ts +++ b/packages/core/src/modules/connections/handlers/ConnectionResponseHandler.ts @@ -70,7 +70,6 @@ export class ConnectionResponseHandler implements Handler { } messageContext.connection = connectionRecord - // The presence of outOfBandRecord is not mandatory when the old connection invitation is used const connection = await this.connectionService.processResponse(messageContext, outOfBandRecord) // TODO: should we only send ping message in case of autoAcceptConnection or always? diff --git a/packages/core/src/modules/connections/models/ConnectionType.ts b/packages/core/src/modules/connections/models/ConnectionType.ts new file mode 100644 index 0000000000..85e6a5dbf9 --- /dev/null +++ b/packages/core/src/modules/connections/models/ConnectionType.ts @@ -0,0 +1,3 @@ +export enum ConnectionType { + Mediator = 'mediator', +} diff --git a/packages/core/src/modules/connections/models/index.ts b/packages/core/src/modules/connections/models/index.ts index 0c8dd1b360..69752df9c7 100644 --- a/packages/core/src/modules/connections/models/index.ts +++ b/packages/core/src/modules/connections/models/index.ts @@ -5,3 +5,4 @@ export * from './DidExchangeState' export * from './DidExchangeRole' export * from './HandshakeProtocol' export * from './did' +export * from './ConnectionType' diff --git a/packages/core/src/modules/connections/repository/ConnectionMetadataTypes.ts b/packages/core/src/modules/connections/repository/ConnectionMetadataTypes.ts new file mode 100644 index 0000000000..9609097515 --- /dev/null +++ b/packages/core/src/modules/connections/repository/ConnectionMetadataTypes.ts @@ -0,0 +1,9 @@ +export enum ConnectionMetadataKeys { + UseDidKeysForProtocol = '_internal/useDidKeysForProtocol', +} + +export type ConnectionMetadata = { + [ConnectionMetadataKeys.UseDidKeysForProtocol]: { + [protocolUri: string]: boolean + } +} diff --git a/packages/core/src/modules/connections/repository/ConnectionRecord.ts b/packages/core/src/modules/connections/repository/ConnectionRecord.ts index dca03cd576..db9512e5fc 100644 --- a/packages/core/src/modules/connections/repository/ConnectionRecord.ts +++ b/packages/core/src/modules/connections/repository/ConnectionRecord.ts @@ -1,5 +1,7 @@ import type { TagsBase } from '../../../storage/BaseRecord' import type { HandshakeProtocol } from '../models' +import type { ConnectionType } from '../models/ConnectionType' +import type { ConnectionMetadata } from './ConnectionMetadataTypes' import { AriesFrameworkError } from '../../../error' import { BaseRecord } from '../../../storage/BaseRecord' @@ -36,10 +38,11 @@ export type DefaultConnectionTags = { theirDid?: string outOfBandId?: string invitationDid?: string + connectionType?: [ConnectionType | string] } export class ConnectionRecord - extends BaseRecord + extends BaseRecord implements ConnectionRecordProps { public state!: DidExchangeState diff --git a/packages/core/src/modules/connections/services/ConnectionService.ts b/packages/core/src/modules/connections/services/ConnectionService.ts index aa2960dbe0..ab3f5e6121 100644 --- a/packages/core/src/modules/connections/services/ConnectionService.ts +++ b/packages/core/src/modules/connections/services/ConnectionService.ts @@ -1,12 +1,13 @@ import type { AgentContext } from '../../../agent' import type { AgentMessage } from '../../../agent/AgentMessage' import type { InboundMessageContext } from '../../../agent/models/InboundMessageContext' +import type { Query } from '../../../storage/StorageService' import type { AckMessage } from '../../common' -import type { PeerDidCreateOptions } from '../../dids/methods/peer/PeerDidRegistrar' import type { OutOfBandDidCommService } from '../../oob/domain/OutOfBandDidCommService' import type { OutOfBandRecord } from '../../oob/repository' import type { ConnectionStateChangedEvent } from '../ConnectionEvents' import type { ConnectionProblemReportMessage } from '../messages' +import type { ConnectionType } from '../models' import type { ConnectionRecordProps } from '../repository/ConnectionRecord' import { firstValueFrom, ReplaySubject } from 'rxjs' @@ -25,7 +26,6 @@ import { indyDidFromPublicKeyBase58 } from '../../../utils/did' import { DidKey, DidRegistrarService, IndyAgentService } from '../../dids' import { DidDocumentRole } from '../../dids/domain/DidDocumentRole' import { didKeyToVerkey } from '../../dids/helpers' -import { PeerDidNumAlgo } from '../../dids/methods/peer/didPeer' import { didDocumentJsonToNumAlgo1Did } from '../../dids/methods/peer/peerDidNumAlgo1' import { DidRecord, DidRepository } from '../../dids/repository' import { DidRecordMetadataKeys } from '../../dids/repository/didRecordMetadataTypes' @@ -106,7 +106,23 @@ export class ConnectionService { // We take just the first one for now. const [invitationDid] = outOfBandInvitation.invitationDids - const didDocument = await this.registerCreatedPeerDidDocument(agentContext, didDoc) + const { did: peerDid } = await this.createDid(agentContext, { + role: DidDocumentRole.Created, + didDoc, + }) + + const { label, imageUrl } = config + const connectionRequest = new ConnectionRequestMessage({ + label: label ?? agentContext.config.label, + did: didDoc.id, + didDoc, + imageUrl: imageUrl ?? agentContext.config.connectionImageUrl, + }) + + connectionRequest.setThread({ + threadId: connectionRequest.id, + parentThreadId: outOfBandInvitation.id, + }) const connectionRecord = await this.createConnection(agentContext, { protocol: HandshakeProtocol.Connections, @@ -114,28 +130,15 @@ export class ConnectionService { state: DidExchangeState.InvitationReceived, theirLabel: outOfBandInvitation.label, alias: config?.alias, - did: didDocument.id, + did: peerDid, mediatorId, autoAcceptConnection: config?.autoAcceptConnection, outOfBandId: outOfBandRecord.id, invitationDid, imageUrl: outOfBandInvitation.imageUrl, + threadId: connectionRequest.id, }) - const { label, imageUrl, autoAcceptConnection } = config - - const connectionRequest = new ConnectionRequestMessage({ - label: label ?? agentContext.config.label, - did: didDoc.id, - didDoc, - imageUrl: imageUrl ?? agentContext.config.connectionImageUrl, - }) - - if (autoAcceptConnection !== undefined || autoAcceptConnection !== null) { - connectionRecord.autoAcceptConnection = config?.autoAcceptConnection - } - - connectionRecord.threadId = connectionRequest.id await this.updateState(agentContext, connectionRecord, DidExchangeState.RequestSent) return { @@ -163,16 +166,20 @@ export class ConnectionService { }) } - const didDocument = await this.storeReceivedPeerDidDocument(messageContext.agentContext, message.connection.didDoc) + const { did: peerDid } = await this.createDid(messageContext.agentContext, { + role: DidDocumentRole.Received, + didDoc: message.connection.didDoc, + }) const connectionRecord = await this.createConnection(messageContext.agentContext, { protocol: HandshakeProtocol.Connections, role: DidExchangeRole.Responder, state: DidExchangeState.RequestReceived, + alias: outOfBandRecord.alias, theirLabel: message.label, imageUrl: message.imageUrl, outOfBandId: outOfBandRecord.id, - theirDid: didDocument.id, + theirDid: peerDid, threadId: message.threadId, mediatorId: outOfBandRecord.mediatorId, autoAcceptConnection: outOfBandRecord.autoAcceptConnection, @@ -203,13 +210,12 @@ export class ConnectionService { const didDoc = routing ? this.createDidDoc(routing) - : this.createDidDocFromOutOfBandDidCommServices( - outOfBandRecord.outOfBandInvitation.services.filter( - (s): s is OutOfBandDidCommService => typeof s !== 'string' - ) - ) + : this.createDidDocFromOutOfBandDidCommServices(outOfBandRecord.outOfBandInvitation.getInlineServices()) - const didDocument = await this.registerCreatedPeerDidDocument(agentContext, didDoc) + const { did: peerDid } = await this.createDid(agentContext, { + role: DidDocumentRole.Created, + didDoc, + }) const connection = new Connection({ did: didDoc.id, @@ -229,7 +235,7 @@ export class ConnectionService { connectionSig: await signData(connectionJson, agentContext.wallet, signingKey), }) - connectionRecord.did = didDocument.id + connectionRecord.did = peerDid await this.updateState(agentContext, connectionRecord, DidExchangeState.ResponseSent) this.logger.debug(`Create message ${ConnectionResponseMessage.type.messageTypeUri} end`, { @@ -305,9 +311,12 @@ export class ConnectionService { throw new AriesFrameworkError('DID Document is missing.') } - const didDocument = await this.storeReceivedPeerDidDocument(messageContext.agentContext, connection.didDoc) + const { did: peerDid } = await this.createDid(messageContext.agentContext, { + role: DidDocumentRole.Received, + didDoc: connection.didDoc, + }) - connectionRecord.theirDid = didDocument.id + connectionRecord.theirDid = peerDid connectionRecord.threadId = message.threadId await this.updateState(messageContext.agentContext, connectionRecord, DidExchangeState.ResponseReceived) @@ -592,6 +601,10 @@ export class ConnectionService { return this.connectionRepository.findByQuery(agentContext, { outOfBandId }) } + public async findAllByConnectionType(agentContext: AgentContext, connectionType: [ConnectionType | string]) { + return this.connectionRepository.findByQuery(agentContext, { connectionType }) + } + public async findByInvitationDid(agentContext: AgentContext, invitationDid: string) { return this.connectionRepository.findByQuery(agentContext, { invitationDid }) } @@ -619,58 +632,25 @@ export class ConnectionService { return null } + public async findAllByQuery(agentContext: AgentContext, query: Query): Promise { + return this.connectionRepository.findByQuery(agentContext, query) + } + public async createConnection(agentContext: AgentContext, options: ConnectionRecordProps): Promise { const connectionRecord = new ConnectionRecord(options) await this.connectionRepository.save(agentContext, connectionRecord) return connectionRecord } - private async registerCreatedPeerDidDocument(agentContext: AgentContext, didDoc: DidDoc) { + private async createDid(agentContext: AgentContext, { role, didDoc }: { role: DidDocumentRole; didDoc: DidDoc }) { // Convert the legacy did doc to a new did document const didDocument = convertToNewDidDocument(didDoc) - // Register did:peer document. This will generate the id property and save it to a did record - const result = await this.didRegistrarService.create(agentContext, { - method: 'peer', - didDocument, - options: { - numAlgo: PeerDidNumAlgo.GenesisDoc, - }, - }) - - if (result.didState?.state !== 'finished') { - throw new AriesFrameworkError(`Did document creation failed: ${JSON.stringify(result.didState)}`) - } - - this.logger.debug(`Did document with did ${result.didState.did} created.`, { - did: result.didState.did, - didDocument: result.didState.didDocument, - }) - - const didRecord = await this.didRepository.getById(agentContext, result.didState.did) - - // Store the unqualified did with the legacy did document in the metadata - // Can be removed at a later stage if we know for sure we don't need it anymore - didRecord.metadata.set(DidRecordMetadataKeys.LegacyDid, { - unqualifiedDid: didDoc.id, - didDocumentString: JsonTransformer.serialize(didDoc), - }) - - await this.didRepository.update(agentContext, didRecord) - return result.didState.didDocument - } - - private async storeReceivedPeerDidDocument(agentContext: AgentContext, didDoc: DidDoc) { - // Convert the legacy did doc to a new did document - const didDocument = convertToNewDidDocument(didDoc) - - // TODO: Move this into the didcomm module, and add a method called store received did document. - // This can be called from both the did exchange and the connection protocol. const peerDid = didDocumentJsonToNumAlgo1Did(didDocument.toJSON()) didDocument.id = peerDid const didRecord = new DidRecord({ id: peerDid, - role: DidDocumentRole.Received, + role, didDocument, tags: { // We need to save the recipientKeys, so we can find the associated did @@ -695,7 +675,7 @@ export class ConnectionService { await this.didRepository.save(agentContext, didRecord) this.logger.debug('Did record created.', didRecord) - return didDocument + return { did: peerDid, didDocument } } private createDidDoc(routing: Routing) { diff --git a/packages/core/src/modules/credentials/CredentialsApi.ts b/packages/core/src/modules/credentials/CredentialsApi.ts index 658f723ba8..e6f48e1b53 100644 --- a/packages/core/src/modules/credentials/CredentialsApi.ts +++ b/packages/core/src/modules/credentials/CredentialsApi.ts @@ -1,4 +1,5 @@ import type { AgentMessage } from '../../agent/AgentMessage' +import type { Query } from '../../storage/StorageService' import type { DeleteCredentialOptions } from './CredentialServiceOptions' import type { AcceptCredentialOptions, @@ -73,6 +74,7 @@ export interface CredentialsApi + findAllByQuery(query: Query): Promise getById(credentialRecordId: string): Promise findById(credentialRecordId: string): Promise deleteById(credentialRecordId: string, options?: DeleteCredentialOptions): Promise @@ -580,6 +582,15 @@ export class CredentialsApi< return this.credentialRepository.getAll(this.agentContext) } + /** + * Retrieve all credential records by specified query params + * + * @returns List containing all credential records matching specified query paramaters + */ + public findAllByQuery(query: Query) { + return this.credentialRepository.findByQuery(this.agentContext, query) + } + /** * Find a credential record by id * 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 3e68856131..fa84e9d439 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 @@ -822,6 +822,16 @@ describe('V1CredentialService', () => { expect(result).toEqual(expect.arrayContaining(expected)) }) + + it('findAllByQuery should return value from credentialRepository.findByQuery', async () => { + const expected = [mockCredentialRecord(), mockCredentialRecord()] + + mockFunction(credentialRepository.findByQuery).mockReturnValue(Promise.resolve(expected)) + const result = await credentialService.findAllByQuery(agentContext, { state: CredentialState.OfferSent }) + expect(credentialRepository.findByQuery).toBeCalledWith(agentContext, { state: CredentialState.OfferSent }) + + expect(result).toEqual(expect.arrayContaining(expected)) + }) }) describe('deleteCredential', () => { diff --git a/packages/core/src/modules/credentials/protocol/v2/__tests__/V2CredentialServiceCred.test.ts b/packages/core/src/modules/credentials/protocol/v2/__tests__/V2CredentialServiceCred.test.ts index 9383160f56..6b4f491304 100644 --- a/packages/core/src/modules/credentials/protocol/v2/__tests__/V2CredentialServiceCred.test.ts +++ b/packages/core/src/modules/credentials/protocol/v2/__tests__/V2CredentialServiceCred.test.ts @@ -781,6 +781,16 @@ describe('CredentialService', () => { expect(result).toEqual(expect.arrayContaining(expected)) }) + + it('findAllByQuery should return value from credentialRepository.findByQuery', async () => { + const expected = [mockCredentialRecord(), mockCredentialRecord()] + + mockFunction(credentialRepository.findByQuery).mockReturnValue(Promise.resolve(expected)) + const result = await credentialService.findAllByQuery(agentContext, { state: CredentialState.OfferSent }) + expect(credentialRepository.findByQuery).toBeCalledWith(agentContext, { state: CredentialState.OfferSent }) + + expect(result).toEqual(expect.arrayContaining(expected)) + }) }) describe('deleteCredential', () => { diff --git a/packages/core/src/modules/credentials/services/CredentialService.ts b/packages/core/src/modules/credentials/services/CredentialService.ts index 7642e4c4c5..356b93d2c4 100644 --- a/packages/core/src/modules/credentials/services/CredentialService.ts +++ b/packages/core/src/modules/credentials/services/CredentialService.ts @@ -5,6 +5,7 @@ import type { EventEmitter } from '../../../agent/EventEmitter' import type { InboundMessageContext } from '../../../agent/models/InboundMessageContext' import type { Logger } from '../../../logger' import type { DidCommMessageRepository } from '../../../storage' +import type { Query } from '../../../storage/StorageService' import type { ProblemReportMessage } from '../../problem-reports' import type { CredentialStateChangedEvent } from '../CredentialEvents' import type { @@ -220,6 +221,13 @@ export abstract class CredentialService + ): Promise { + return this.credentialRepository.findByQuery(agentContext, query) + } + /** * Find a credential record by id * diff --git a/packages/core/src/modules/didcomm/index.ts b/packages/core/src/modules/didcomm/index.ts new file mode 100644 index 0000000000..ff4d44346c --- /dev/null +++ b/packages/core/src/modules/didcomm/index.ts @@ -0,0 +1,2 @@ +export * from './types' +export * from './services' diff --git a/packages/core/src/modules/didcomm/services/DidCommDocumentService.ts b/packages/core/src/modules/didcomm/services/DidCommDocumentService.ts new file mode 100644 index 0000000000..4877113fa0 --- /dev/null +++ b/packages/core/src/modules/didcomm/services/DidCommDocumentService.ts @@ -0,0 +1,72 @@ +import type { AgentContext } from '../../../agent' +import type { Logger } from '../../../logger' +import type { ResolvedDidCommService } from '../types' + +import { AgentConfig } from '../../../agent/AgentConfig' +import { KeyType } from '../../../crypto' +import { injectable } from '../../../plugins' +import { DidResolverService } from '../../dids' +import { DidCommV1Service, IndyAgentService, keyReferenceToKey } from '../../dids/domain' +import { verkeyToInstanceOfKey } from '../../dids/helpers' +import { findMatchingEd25519Key } from '../util/matchingEd25519Key' + +@injectable() +export class DidCommDocumentService { + private logger: Logger + private didResolverService: DidResolverService + + public constructor(agentConfig: AgentConfig, didResolverService: DidResolverService) { + this.logger = agentConfig.logger + this.didResolverService = didResolverService + } + + public async resolveServicesFromDid(agentContext: AgentContext, did: string): Promise { + const didDocument = await this.didResolverService.resolveDidDocument(agentContext, did) + + const didCommServices: ResolvedDidCommService[] = [] + + // FIXME: we currently retrieve did documents for all didcomm services in the did document, and we don't have caching + // yet so this will re-trigger ledger resolves for each one. Should we only resolve the first service, then the second service, etc...? + for (const didCommService of didDocument.didCommServices) { + if (didCommService instanceof IndyAgentService) { + // IndyAgentService (DidComm v0) has keys encoded as raw publicKeyBase58 (verkeys) + didCommServices.push({ + id: didCommService.id, + recipientKeys: didCommService.recipientKeys.map(verkeyToInstanceOfKey), + routingKeys: didCommService.routingKeys?.map(verkeyToInstanceOfKey) || [], + serviceEndpoint: didCommService.serviceEndpoint, + }) + } else if (didCommService instanceof DidCommV1Service) { + // Resolve dids to DIDDocs to retrieve routingKeys + const routingKeys = [] + for (const routingKey of didCommService.routingKeys ?? []) { + const routingDidDocument = await this.didResolverService.resolveDidDocument(agentContext, routingKey) + routingKeys.push(keyReferenceToKey(routingDidDocument, routingKey)) + } + + // DidCommV1Service has keys encoded as key references + + // Dereference recipientKeys + const recipientKeys = didCommService.recipientKeys.map((recipientKeyReference) => { + const key = keyReferenceToKey(didDocument, recipientKeyReference) + + // try to find a matching Ed25519 key (https://sovrin-foundation.github.io/sovrin/spec/did-method-spec-template.html#did-document-notes) + if (key.keyType === KeyType.X25519) { + const matchingEd25519Key = findMatchingEd25519Key(key, didDocument) + if (matchingEd25519Key) return matchingEd25519Key + } + return key + }) + + didCommServices.push({ + id: didCommService.id, + recipientKeys, + routingKeys, + serviceEndpoint: didCommService.serviceEndpoint, + }) + } + } + + return didCommServices + } +} diff --git a/packages/core/src/modules/didcomm/services/__tests__/DidCommDocumentService.test.ts b/packages/core/src/modules/didcomm/services/__tests__/DidCommDocumentService.test.ts new file mode 100644 index 0000000000..ad57ed6372 --- /dev/null +++ b/packages/core/src/modules/didcomm/services/__tests__/DidCommDocumentService.test.ts @@ -0,0 +1,122 @@ +import type { AgentContext } from '../../../../agent' +import type { VerificationMethod } from '../../../dids' + +import { getAgentConfig, getAgentContext, mockFunction } from '../../../../../tests/helpers' +import { Key, KeyType } from '../../../../crypto' +import { DidCommV1Service, DidDocument, IndyAgentService } from '../../../dids' +import { verkeyToInstanceOfKey } from '../../../dids/helpers' +import { DidResolverService } from '../../../dids/services/DidResolverService' +import { DidCommDocumentService } from '../DidCommDocumentService' + +jest.mock('../../../dids/services/DidResolverService') +const DidResolverServiceMock = DidResolverService as jest.Mock + +describe('DidCommDocumentService', () => { + const agentConfig = getAgentConfig('DidCommDocumentService') + let didCommDocumentService: DidCommDocumentService + let didResolverService: DidResolverService + let agentContext: AgentContext + + beforeEach(async () => { + didResolverService = new DidResolverServiceMock() + didCommDocumentService = new DidCommDocumentService(agentConfig, didResolverService) + agentContext = getAgentContext() + }) + + describe('resolveServicesFromDid', () => { + test('throw error when resolveDidDocument fails', async () => { + const error = new Error('test') + mockFunction(didResolverService.resolveDidDocument).mockRejectedValue(error) + + await expect(didCommDocumentService.resolveServicesFromDid(agentContext, 'did')).rejects.toThrowError(error) + }) + + test('resolves IndyAgentService', async () => { + mockFunction(didResolverService.resolveDidDocument).mockResolvedValue( + new DidDocument({ + context: ['https://w3id.org/did/v1'], + id: 'did:sov:Q4zqM7aXqm7gDQkUVLng9h', + service: [ + new IndyAgentService({ + id: 'test-id', + serviceEndpoint: 'https://test.com', + recipientKeys: ['Q4zqM7aXqm7gDQkUVLng9h'], + routingKeys: ['DADEajsDSaksLng9h'], + priority: 5, + }), + ], + }) + ) + + const resolved = await didCommDocumentService.resolveServicesFromDid( + agentContext, + 'did:sov:Q4zqM7aXqm7gDQkUVLng9h' + ) + expect(didResolverService.resolveDidDocument).toHaveBeenCalledWith(agentContext, 'did:sov:Q4zqM7aXqm7gDQkUVLng9h') + + expect(resolved).toHaveLength(1) + expect(resolved[0]).toMatchObject({ + id: 'test-id', + serviceEndpoint: 'https://test.com', + recipientKeys: [verkeyToInstanceOfKey('Q4zqM7aXqm7gDQkUVLng9h')], + routingKeys: [verkeyToInstanceOfKey('DADEajsDSaksLng9h')], + }) + }) + + test('resolves DidCommV1Service', async () => { + const publicKeyBase58Ed25519 = 'GyYtYWU1vjwd5PFJM4VSX5aUiSV3TyZMuLBJBTQvfdF8' + const publicKeyBase58X25519 = 'S3AQEEKkGYrrszT9D55ozVVX2XixYp8uynqVm4okbud' + + const Ed25519VerificationMethod: VerificationMethod = { + type: 'Ed25519VerificationKey2018', + controller: 'did:sov:Q4zqM7aXqm7gDQkUVLng9h', + id: 'did:sov:Q4zqM7aXqm7gDQkUVLng9h#key-1', + publicKeyBase58: publicKeyBase58Ed25519, + } + const X25519VerificationMethod: VerificationMethod = { + type: 'X25519KeyAgreementKey2019', + controller: 'did:sov:Q4zqM7aXqm7gDQkUVLng9h', + id: 'did:sov:Q4zqM7aXqm7gDQkUVLng9h#key-agreement-1', + publicKeyBase58: publicKeyBase58X25519, + } + + mockFunction(didResolverService.resolveDidDocument).mockResolvedValue( + new DidDocument({ + context: [ + 'https://w3id.org/did/v1', + 'https://w3id.org/security/suites/ed25519-2018/v1', + 'https://w3id.org/security/suites/x25519-2019/v1', + ], + id: 'did:sov:Q4zqM7aXqm7gDQkUVLng9h', + verificationMethod: [Ed25519VerificationMethod, X25519VerificationMethod], + authentication: [Ed25519VerificationMethod.id], + keyAgreement: [X25519VerificationMethod.id], + service: [ + new DidCommV1Service({ + id: 'test-id', + serviceEndpoint: 'https://test.com', + recipientKeys: [X25519VerificationMethod.id], + routingKeys: [Ed25519VerificationMethod.id], + priority: 5, + }), + ], + }) + ) + + const resolved = await didCommDocumentService.resolveServicesFromDid( + agentContext, + 'did:sov:Q4zqM7aXqm7gDQkUVLng9h' + ) + expect(didResolverService.resolveDidDocument).toHaveBeenCalledWith(agentContext, 'did:sov:Q4zqM7aXqm7gDQkUVLng9h') + + const ed25519Key = Key.fromPublicKeyBase58(publicKeyBase58Ed25519, KeyType.Ed25519) + expect(resolved).toHaveLength(1) + expect(resolved[0]).toMatchObject({ + id: 'test-id', + serviceEndpoint: 'https://test.com', + recipientKeys: [ed25519Key], + routingKeys: [ed25519Key], + }) + }) + }) +}) diff --git a/packages/core/src/modules/didcomm/services/index.ts b/packages/core/src/modules/didcomm/services/index.ts new file mode 100644 index 0000000000..ae2cb50e2f --- /dev/null +++ b/packages/core/src/modules/didcomm/services/index.ts @@ -0,0 +1 @@ +export * from './DidCommDocumentService' diff --git a/packages/core/src/modules/didcomm/types.ts b/packages/core/src/modules/didcomm/types.ts new file mode 100644 index 0000000000..e8f9e9a9a8 --- /dev/null +++ b/packages/core/src/modules/didcomm/types.ts @@ -0,0 +1,8 @@ +import type { Key } from '../../crypto' + +export interface ResolvedDidCommService { + id: string + serviceEndpoint: string + recipientKeys: Key[] + routingKeys: Key[] +} diff --git a/packages/core/src/modules/didcomm/util/__tests__/matchingEd25519Key.test.ts b/packages/core/src/modules/didcomm/util/__tests__/matchingEd25519Key.test.ts new file mode 100644 index 0000000000..3987d045e3 --- /dev/null +++ b/packages/core/src/modules/didcomm/util/__tests__/matchingEd25519Key.test.ts @@ -0,0 +1,84 @@ +import type { VerificationMethod } from '../../../dids' + +import { Key, KeyType } from '../../../../crypto' +import { DidDocument } from '../../../dids' +import { findMatchingEd25519Key } from '../matchingEd25519Key' + +describe('findMatchingEd25519Key', () => { + const publicKeyBase58Ed25519 = 'GyYtYWU1vjwd5PFJM4VSX5aUiSV3TyZMuLBJBTQvfdF8' + const Ed25519VerificationMethod: VerificationMethod = { + type: 'Ed25519VerificationKey2018', + controller: 'did:sov:WJz9mHyW9BZksioQnRsrAo', + id: 'did:sov:WJz9mHyW9BZksioQnRsrAo#key-1', + publicKeyBase58: publicKeyBase58Ed25519, + } + + const publicKeyBase58X25519 = 'S3AQEEKkGYrrszT9D55ozVVX2XixYp8uynqVm4okbud' + const X25519VerificationMethod: VerificationMethod = { + type: 'X25519KeyAgreementKey2019', + controller: 'did:sov:WJz9mHyW9BZksioQnRsrAo', + id: 'did:sov:WJz9mHyW9BZksioQnRsrAo#key-agreement-1', + publicKeyBase58: publicKeyBase58X25519, + } + + describe('referenced verification method', () => { + const didDocument = new DidDocument({ + context: [ + 'https://w3id.org/did/v1', + 'https://w3id.org/security/suites/ed25519-2018/v1', + 'https://w3id.org/security/suites/x25519-2019/v1', + ], + id: 'did:sov:WJz9mHyW9BZksioQnRsrAo', + verificationMethod: [Ed25519VerificationMethod, X25519VerificationMethod], + authentication: [Ed25519VerificationMethod.id], + assertionMethod: [Ed25519VerificationMethod.id], + keyAgreement: [X25519VerificationMethod.id], + }) + + test('returns matching Ed25519 key if corresponding X25519 key supplied', () => { + const x25519Key = Key.fromPublicKeyBase58(publicKeyBase58X25519, KeyType.X25519) + const ed25519Key = findMatchingEd25519Key(x25519Key, didDocument) + expect(ed25519Key?.publicKeyBase58).toBe(Ed25519VerificationMethod.publicKeyBase58) + }) + + test('returns undefined if non-corresponding X25519 key supplied', () => { + const differentX25519Key = Key.fromPublicKeyBase58('Fbv17ZbnUSbafsiUBJbdGeC62M8v8GEscVMMcE59mRPt', KeyType.X25519) + expect(findMatchingEd25519Key(differentX25519Key, didDocument)).toBeUndefined() + }) + + test('returns undefined if ed25519 key supplied', () => { + const ed25519Key = Key.fromPublicKeyBase58(publicKeyBase58Ed25519, KeyType.Ed25519) + expect(findMatchingEd25519Key(ed25519Key, didDocument)).toBeUndefined() + }) + }) + + describe('non-referenced authentication', () => { + const didDocument = new DidDocument({ + context: [ + 'https://w3id.org/did/v1', + 'https://w3id.org/security/suites/ed25519-2018/v1', + 'https://w3id.org/security/suites/x25519-2019/v1', + ], + id: 'did:sov:WJz9mHyW9BZksioQnRsrAo', + authentication: [Ed25519VerificationMethod], + assertionMethod: [Ed25519VerificationMethod], + keyAgreement: [X25519VerificationMethod], + }) + + test('returns matching Ed25519 key if corresponding X25519 key supplied', () => { + const x25519Key = Key.fromPublicKeyBase58(publicKeyBase58X25519, KeyType.X25519) + const ed25519Key = findMatchingEd25519Key(x25519Key, didDocument) + expect(ed25519Key?.publicKeyBase58).toBe(Ed25519VerificationMethod.publicKeyBase58) + }) + + test('returns undefined if non-corresponding X25519 key supplied', () => { + const differentX25519Key = Key.fromPublicKeyBase58('Fbv17ZbnUSbafsiUBJbdGeC62M8v8GEscVMMcE59mRPt', KeyType.X25519) + expect(findMatchingEd25519Key(differentX25519Key, didDocument)).toBeUndefined() + }) + + test('returns undefined if ed25519 key supplied', () => { + const ed25519Key = Key.fromPublicKeyBase58(publicKeyBase58Ed25519, KeyType.Ed25519) + expect(findMatchingEd25519Key(ed25519Key, didDocument)).toBeUndefined() + }) + }) +}) diff --git a/packages/core/src/modules/didcomm/util/matchingEd25519Key.ts b/packages/core/src/modules/didcomm/util/matchingEd25519Key.ts new file mode 100644 index 0000000000..7ac297649c --- /dev/null +++ b/packages/core/src/modules/didcomm/util/matchingEd25519Key.ts @@ -0,0 +1,32 @@ +import type { DidDocument, VerificationMethod } from '../../dids' + +import { Key, KeyType } from '../../../crypto' +import { keyReferenceToKey } from '../../dids' +import { convertPublicKeyToX25519 } from '../../dids/domain/key-type/ed25519' + +/** + * Tries to find a matching Ed25519 key to the supplied X25519 key + * @param x25519Key X25519 key + * @param didDocument Did document containing all the keys + * @returns a matching Ed25519 key or `undefined` (if no matching key found) + */ +export function findMatchingEd25519Key(x25519Key: Key, didDocument: DidDocument): Key | undefined { + if (x25519Key.keyType !== KeyType.X25519) return undefined + + const verificationMethods = didDocument.verificationMethod ?? [] + const keyAgreements = didDocument.keyAgreement ?? [] + const authentications = didDocument.authentication ?? [] + const allKeyReferences: VerificationMethod[] = [ + ...verificationMethods, + ...authentications.filter((keyAgreement): keyAgreement is VerificationMethod => typeof keyAgreement !== 'string'), + ...keyAgreements.filter((keyAgreement): keyAgreement is VerificationMethod => typeof keyAgreement !== 'string'), + ] + + return allKeyReferences + .map((keyReference) => keyReferenceToKey(didDocument, keyReference.id)) + .filter((key) => key?.keyType === KeyType.Ed25519) + .find((keyEd25519) => { + const keyX25519 = Key.fromPublicKey(convertPublicKeyToX25519(keyEd25519.publicKey), KeyType.X25519) + return keyX25519.publicKeyBase58 === x25519Key.publicKeyBase58 + }) +} diff --git a/packages/core/src/modules/dids/helpers.ts b/packages/core/src/modules/dids/helpers.ts index ef3c68ab07..6170707c27 100644 --- a/packages/core/src/modules/dids/helpers.ts +++ b/packages/core/src/modules/dids/helpers.ts @@ -2,8 +2,12 @@ import { KeyType, Key } from '../../crypto' import { DidKey } from './methods/key' +export function isDidKey(key: string) { + return key.startsWith('did:key') +} + export function didKeyToVerkey(key: string) { - if (key.startsWith('did:key')) { + if (isDidKey(key)) { const publicKeyBase58 = DidKey.fromDid(key).key.publicKeyBase58 return publicKeyBase58 } @@ -11,7 +15,7 @@ export function didKeyToVerkey(key: string) { } export function verkeyToDidKey(key: string) { - if (key.startsWith('did:key')) { + if (isDidKey(key)) { return key } const publicKeyBase58 = key diff --git a/packages/core/src/modules/dids/methods/peer/createPeerDidDocumentFromServices.ts b/packages/core/src/modules/dids/methods/peer/createPeerDidDocumentFromServices.ts index 8f34254a92..184e678d76 100644 --- a/packages/core/src/modules/dids/methods/peer/createPeerDidDocumentFromServices.ts +++ b/packages/core/src/modules/dids/methods/peer/createPeerDidDocumentFromServices.ts @@ -1,4 +1,4 @@ -import type { ResolvedDidCommService } from '../../../../agent/MessageSender' +import type { ResolvedDidCommService } from '../../../didcomm' import { convertPublicKeyToX25519 } from '@stablelib/ed25519' diff --git a/packages/core/src/modules/generic-records/GenericRecordsApi.ts b/packages/core/src/modules/generic-records/GenericRecordsApi.ts index feae3a9758..56efe6667e 100644 --- a/packages/core/src/modules/generic-records/GenericRecordsApi.ts +++ b/packages/core/src/modules/generic-records/GenericRecordsApi.ts @@ -1,4 +1,5 @@ -import type { GenericRecord, GenericRecordTags, SaveGenericRecordOption } from './repository/GenericRecord' +import type { Query } from '../../storage/StorageService' +import type { GenericRecord, SaveGenericRecordOption } from './repository/GenericRecord' import { AgentContext } from '../../agent' import { InjectionSymbols } from '../../constants' @@ -79,7 +80,7 @@ export class GenericRecordsApi { return this.genericRecordsService.findById(this.agentContext, id) } - public async findAllByQuery(query: Partial): Promise { + public async findAllByQuery(query: Query): Promise { return this.genericRecordsService.findAllByQuery(this.agentContext, query) } diff --git a/packages/core/src/modules/generic-records/services/GenericRecordService.ts b/packages/core/src/modules/generic-records/services/GenericRecordService.ts index 861f0a002f..ccdf9d59d3 100644 --- a/packages/core/src/modules/generic-records/services/GenericRecordService.ts +++ b/packages/core/src/modules/generic-records/services/GenericRecordService.ts @@ -1,5 +1,6 @@ import type { AgentContext } from '../../../agent' -import type { GenericRecordTags, SaveGenericRecordOption } from '../repository/GenericRecord' +import type { Query } from '../../../storage/StorageService' +import type { SaveGenericRecordOption } from '../repository/GenericRecord' import { AriesFrameworkError } from '../../../error' import { injectable } from '../../../plugins' @@ -51,7 +52,7 @@ export class GenericRecordService { } } - public async findAllByQuery(agentContext: AgentContext, query: Partial) { + public async findAllByQuery(agentContext: AgentContext, query: Query) { return this.genericRecordsRepository.findByQuery(agentContext, query) } diff --git a/packages/core/src/modules/ledger/IndyPool.ts b/packages/core/src/modules/ledger/IndyPool.ts index 853b253b0e..c3d2249799 100644 --- a/packages/core/src/modules/ledger/IndyPool.ts +++ b/packages/core/src/modules/ledger/IndyPool.ts @@ -74,6 +74,7 @@ export class IndyPool { } this._poolHandle = undefined + this.poolConnected = undefined await this.indy.closePoolLedger(poolHandle) } diff --git a/packages/core/src/modules/ledger/__tests__/IndyLedgerService.test.ts b/packages/core/src/modules/ledger/__tests__/IndyLedgerService.test.ts index 84563e5344..43cc31f66e 100644 --- a/packages/core/src/modules/ledger/__tests__/IndyLedgerService.test.ts +++ b/packages/core/src/modules/ledger/__tests__/IndyLedgerService.test.ts @@ -27,7 +27,7 @@ const pools: IndyPoolConfig[] = [ indyNamespace: 'sovrin', isProduction: true, genesisTransactions: 'xxx', - transactionAuthorAgreement: { version: '1', acceptanceMechanism: 'accept' }, + transactionAuthorAgreement: { version: '1.0', acceptanceMechanism: 'accept' }, }, ] @@ -75,7 +75,7 @@ describe('IndyLedgerService', () => { // @ts-ignore jest.spyOn(ledgerService, 'getTransactionAuthorAgreement').mockResolvedValue({ digest: 'abcde', - version: 'abdcg', + version: '2.0', text: 'jhsdhbv', ratification_ts: 12345678, acceptanceMechanisms: { @@ -93,7 +93,7 @@ describe('IndyLedgerService', () => { 'Heinz57' ) ).rejects.toThrowError( - 'Unable to satisfy matching TAA with mechanism "accept" and version "1" in pool.\n Found ["accept"] and version 3 in pool.' + 'Unable to satisfy matching TAA with mechanism "accept" and version "1.0" in pool.\n Found ["accept"] and version 2.0 in pool.' ) }) @@ -102,7 +102,7 @@ describe('IndyLedgerService', () => { // @ts-ignore jest.spyOn(ledgerService, 'getTransactionAuthorAgreement').mockResolvedValue({ digest: 'abcde', - version: 'abdcg', + version: '1.0', text: 'jhsdhbv', ratification_ts: 12345678, acceptanceMechanisms: { @@ -120,7 +120,7 @@ describe('IndyLedgerService', () => { 'Heinz57' ) ).rejects.toThrowError( - 'Unable to satisfy matching TAA with mechanism "accept" and version "1" in pool.\n Found ["decline"] and version 1 in pool.' + 'Unable to satisfy matching TAA with mechanism "accept" and version "1.0" in pool.\n Found ["decline"] and version 1.0 in pool.' ) }) @@ -133,7 +133,7 @@ describe('IndyLedgerService', () => { // @ts-ignore jest.spyOn(ledgerService, 'getTransactionAuthorAgreement').mockResolvedValue({ digest: 'abcde', - version: 'abdcg', + version: '1.0', text: 'jhsdhbv', ratification_ts: 12345678, acceptanceMechanisms: { diff --git a/packages/core/src/modules/ledger/services/IndyLedgerService.ts b/packages/core/src/modules/ledger/services/IndyLedgerService.ts index c4c3f4a0f6..4a339e7add 100644 --- a/packages/core/src/modules/ledger/services/IndyLedgerService.ts +++ b/packages/core/src/modules/ledger/services/IndyLedgerService.ts @@ -519,7 +519,7 @@ export class IndyLedgerService { // Throw an error if the pool doesn't have the specified version and acceptance mechanism if ( - authorAgreement.acceptanceMechanisms.version !== taa.version || + authorAgreement.version !== taa.version || !(taa.acceptanceMechanism in authorAgreement.acceptanceMechanisms.aml) ) { // Throw an error with a helpful message @@ -527,7 +527,7 @@ export class IndyLedgerService { taa.acceptanceMechanism )} and version ${JSON.stringify(taa.version)} in pool.\n Found ${JSON.stringify( Object.keys(authorAgreement.acceptanceMechanisms.aml) - )} and version ${authorAgreement.acceptanceMechanisms.version} in pool.` + )} and version ${authorAgreement.version} in pool.` throw new LedgerError(errMessage) } diff --git a/packages/core/src/modules/oob/OutOfBandApi.ts b/packages/core/src/modules/oob/OutOfBandApi.ts index e1561edfe2..a23c163b42 100644 --- a/packages/core/src/modules/oob/OutOfBandApi.ts +++ b/packages/core/src/modules/oob/OutOfBandApi.ts @@ -2,6 +2,7 @@ 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 { Query } from '../../storage/StorageService' import type { PlaintextMessage } from '../../types' import type { ConnectionInvitationMessage, ConnectionRecord, Routing } from '../connections' import type { HandshakeReusedEvent } from './domain/OutOfBandEvents' @@ -12,7 +13,6 @@ import { AgentContext } from '../../agent' import { Dispatcher } from '../../agent/Dispatcher' import { EventEmitter } from '../../agent/EventEmitter' import { filterContextCorrelationId, AgentEventTypes } from '../../agent/Events' -import { FeatureRegistry } from '../../agent/FeatureRegistry' import { MessageSender } from '../../agent/MessageSender' import { createOutboundMessage } from '../../agent/helpers' import { InjectionSymbols } from '../../constants' @@ -25,9 +25,9 @@ import { JsonEncoder, JsonTransformer } from '../../utils' import { parseMessageType, supportsIncomingMessageType } from '../../utils/messageType' import { parseInvitationUrl, parseInvitationShortUrl } from '../../utils/parseInvitation' import { ConnectionsApi, DidExchangeState, HandshakeProtocol } from '../connections' +import { DidCommDocumentService } from '../didcomm' 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' @@ -45,7 +45,7 @@ const didCommProfiles = ['didcomm/aip1', 'didcomm/aip2;env=rfc19'] export interface CreateOutOfBandInvitationConfig { label?: string - alias?: string + alias?: string // alias for a connection record to be created imageUrl?: string goalCode?: string goal?: string @@ -60,7 +60,7 @@ export interface CreateOutOfBandInvitationConfig { export interface CreateLegacyInvitationConfig { label?: string - alias?: string + alias?: string // alias for a connection record to be created imageUrl?: string multiUseInvitation?: boolean autoAcceptConnection?: boolean @@ -84,7 +84,7 @@ export class OutOfBandApi { private connectionsApi: ConnectionsApi private didCommMessageRepository: DidCommMessageRepository private dispatcher: Dispatcher - private featureRegistry: FeatureRegistry + private didCommDocumentService: DidCommDocumentService private messageSender: MessageSender private eventEmitter: EventEmitter private agentContext: AgentContext @@ -92,7 +92,7 @@ export class OutOfBandApi { public constructor( dispatcher: Dispatcher, - featureRegistry: FeatureRegistry, + didCommDocumentService: DidCommDocumentService, outOfBandService: OutOfBandService, routingService: RoutingService, connectionsApi: ConnectionsApi, @@ -103,7 +103,7 @@ export class OutOfBandApi { agentContext: AgentContext ) { this.dispatcher = dispatcher - this.featureRegistry = featureRegistry + this.didCommDocumentService = didCommDocumentService this.agentContext = agentContext this.logger = logger this.outOfBandService = outOfBandService @@ -207,9 +207,15 @@ export class OutOfBandApi { mediatorId: routing.mediatorId, role: OutOfBandRole.Sender, state: OutOfBandState.AwaitResponse, + alias: config.alias, outOfBandInvitation: outOfBandInvitation, reusable: multiUseInvitation, autoAcceptConnection, + tags: { + recipientKeyFingerprints: services + .reduce((aggr, { recipientKeys }) => [...aggr, ...recipientKeys], []) + .map((didKey) => DidKey.fromDid(didKey).key.fingerprint), + }, }) await this.outOfBandService.save(this.agentContext, outOfBandRecord) @@ -354,12 +360,33 @@ export class OutOfBandApi { ) } + const recipientKeyFingerprints: string[] = [] + for (const service of outOfBandInvitation.getServices()) { + // Resolve dids to DIDDocs to retrieve services + if (typeof service === 'string') { + this.logger.debug(`Resolving services for did ${service}.`) + const resolvedDidCommServices = await this.didCommDocumentService.resolveServicesFromDid( + this.agentContext, + service + ) + recipientKeyFingerprints.push( + ...resolvedDidCommServices + .reduce((aggr, { recipientKeys }) => [...aggr, ...recipientKeys], []) + .map((key) => key.fingerprint) + ) + } else { + recipientKeyFingerprints.push(...service.recipientKeys.map((didKey) => DidKey.fromDid(didKey).key.fingerprint)) + } + } + outOfBandRecord = new OutOfBandRecord({ role: OutOfBandRole.Receiver, state: OutOfBandState.Initial, outOfBandInvitation: outOfBandInvitation, autoAcceptConnection, + tags: { recipientKeyFingerprints }, }) + await this.outOfBandService.save(this.agentContext, outOfBandRecord) this.outOfBandService.emitStateChangedEvent(this.agentContext, outOfBandRecord, null) @@ -407,10 +434,11 @@ export class OutOfBandApi { const { outOfBandInvitation } = outOfBandRecord const { label, alias, imageUrl, autoAcceptConnection, reuseConnection, routing } = config - const { handshakeProtocols, services } = outOfBandInvitation + const { handshakeProtocols } = outOfBandInvitation + const services = outOfBandInvitation.getServices() const messages = outOfBandInvitation.getRequests() - const existingConnection = await this.findExistingConnection(services) + const existingConnection = await this.findExistingConnection(outOfBandInvitation) await this.outOfBandService.updateState(this.agentContext, outOfBandRecord, OutOfBandState.PrepareResponse) @@ -510,6 +538,15 @@ export class OutOfBandApi { return this.outOfBandService.getAll(this.agentContext) } + /** + * Retrieve all out of bands records by specified query param + * + * @returns List containing all out of band records matching specified query params + */ + public findAllByQuery(query: Query) { + return this.outOfBandService.findAllByQuery(this.agentContext, query) + } + /** * Retrieve a out of band record by id * @@ -582,26 +619,20 @@ export class OutOfBandApi { 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.') - } + private async findExistingConnection(outOfBandInvitation: OutOfBandInvitation) { + this.logger.debug('Searching for an existing connection for out-of-band invitation.', { outOfBandInvitation }) - const did = outOfBandServiceToNumAlgo2Did(didOrService) - const connections = await this.connectionsApi.findByInvitationDid(did) - this.logger.debug(`Retrieved ${connections.length} connections for invitation did ${did}`) + for (const invitationDid of outOfBandInvitation.invitationDids) { + const connections = await this.connectionsApi.findByInvitationDid(invitationDid) + this.logger.debug(`Retrieved ${connections.length} connections for invitation did ${invitationDid}`) 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.`) + this.logger.warn( + `There is more than one connection created from invitationDid ${invitationDid}. Taking the first one.` + ) const [firstConnection] = connections return firstConnection } @@ -649,19 +680,36 @@ export class OutOfBandApi { this.logger.debug(`Message with type ${plaintextMessage['@type']} can be processed.`) + let serviceEndpoint: string | undefined + let recipientKeys: string[] | undefined + let routingKeys: string[] = [] + // 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 [didService] = await this.didCommDocumentService.resolveServicesFromDid(this.agentContext, service) + if (didService) { + serviceEndpoint = didService.serviceEndpoint + recipientKeys = didService.recipientKeys.map((key) => key.publicKeyBase58) + routingKeys = didService.routingKeys.map((key) => key.publicKeyBase58) || [] + } + } else { + serviceEndpoint = service.serviceEndpoint + recipientKeys = service.recipientKeys.map(didKeyToVerkey) + routingKeys = service.routingKeys?.map(didKeyToVerkey) || [] + } + + if (!serviceEndpoint || !recipientKeys) { + throw new AriesFrameworkError('Service not found') } const serviceDecorator = new ServiceDecorator({ - recipientKeys: service.recipientKeys.map(didKeyToVerkey), - routingKeys: service.routingKeys?.map(didKeyToVerkey) || [], - serviceEndpoint: service.serviceEndpoint, + recipientKeys, + routingKeys, + serviceEndpoint, }) plaintextMessage['~service'] = JsonTransformer.toJSON(serviceDecorator) diff --git a/packages/core/src/modules/oob/OutOfBandService.ts b/packages/core/src/modules/oob/OutOfBandService.ts index 0c78517f99..031052d2f3 100644 --- a/packages/core/src/modules/oob/OutOfBandService.ts +++ b/packages/core/src/modules/oob/OutOfBandService.ts @@ -1,6 +1,7 @@ import type { AgentContext } from '../../agent' import type { InboundMessageContext } from '../../agent/models/InboundMessageContext' import type { Key } from '../../crypto' +import type { Query } from '../../storage/StorageService' import type { ConnectionRecord } from '../connections' import type { HandshakeReusedEvent, OutOfBandStateChangedEvent } from './domain/OutOfBandEvents' import type { OutOfBandRecord } from './repository' @@ -178,6 +179,10 @@ export class OutOfBandService { return this.outOfBandRepository.getAll(agentContext) } + public async findAllByQuery(agentContext: AgentContext, query: Query) { + return this.outOfBandRepository.findByQuery(agentContext, query) + } + public async deleteById(agentContext: AgentContext, outOfBandId: string) { const outOfBandRecord = await this.getById(agentContext, outOfBandId) return this.outOfBandRepository.delete(agentContext, outOfBandRecord) diff --git a/packages/core/src/modules/oob/__tests__/OutOfBandService.test.ts b/packages/core/src/modules/oob/__tests__/OutOfBandService.test.ts index 553ed826b9..70a08b4d55 100644 --- a/packages/core/src/modules/oob/__tests__/OutOfBandService.test.ts +++ b/packages/core/src/modules/oob/__tests__/OutOfBandService.test.ts @@ -522,5 +522,15 @@ describe('OutOfBandService', () => { expect(result).toEqual(expect.arrayContaining(expected)) }) + + it('findAllByQuery should return value from outOfBandRepository.findByQuery', async () => { + const expected = [getMockOutOfBand(), getMockOutOfBand()] + + mockFunction(outOfBandRepository.findByQuery).mockReturnValue(Promise.resolve(expected)) + const result = await outOfBandService.findAllByQuery(agentContext, { state: OutOfBandState.Initial }) + expect(outOfBandRepository.findByQuery).toBeCalledWith(agentContext, { state: OutOfBandState.Initial }) + + expect(result).toEqual(expect.arrayContaining(expected)) + }) }) }) diff --git a/packages/core/src/modules/oob/helpers.ts b/packages/core/src/modules/oob/helpers.ts index e3677ee76d..be2fdc1b6e 100644 --- a/packages/core/src/modules/oob/helpers.ts +++ b/packages/core/src/modules/oob/helpers.ts @@ -37,7 +37,7 @@ export function convertToNewInvitation(oldInvitation: ConnectionInvitationMessag export function convertToOldInvitation(newInvitation: OutOfBandInvitation) { // Taking first service, as we can only include one service in a legacy invitation. - const [service] = newInvitation.services + const [service] = newInvitation.getServices() let options if (typeof service === 'string') { diff --git a/packages/core/src/modules/oob/messages/OutOfBandInvitation.ts b/packages/core/src/modules/oob/messages/OutOfBandInvitation.ts index 6e3f5d4018..39aec65941 100644 --- a/packages/core/src/modules/oob/messages/OutOfBandInvitation.ts +++ b/packages/core/src/modules/oob/messages/OutOfBandInvitation.ts @@ -1,4 +1,3 @@ -import type { Key } from '../../../crypto' import type { PlaintextMessage } from '../../../types' import type { HandshakeProtocol } from '../../connections' @@ -13,7 +12,6 @@ import { JsonEncoder } from '../../../utils/JsonEncoder' import { JsonTransformer } from '../../../utils/JsonTransformer' import { IsValidMessageType, parseMessageType, replaceLegacyDidSovPrefix } from '../../../utils/messageType' import { IsStringOrInstance } from '../../../utils/validators' -import { DidKey } from '../../dids' import { outOfBandServiceToNumAlgo2Did } from '../../dids/methods/peer/peerDidNumAlgo2' import { OutOfBandDidCommService } from '../domain/OutOfBandDidCommService' @@ -89,7 +87,7 @@ export class OutOfBandInvitation extends AgentMessage { } public get invitationDids() { - const dids = this.services.map((didOrService) => { + const dids = this.getServices().map((didOrService) => { if (typeof didOrService === 'string') { return didOrService } @@ -98,13 +96,18 @@ export class OutOfBandInvitation extends AgentMessage { return dids } - // TODO: this only takes into account inline didcomm services, won't work for public dids - public getRecipientKeys(): Key[] { - return this.services - .filter((s): s is OutOfBandDidCommService => typeof s !== 'string' && !(s instanceof String)) - .map((s) => s.recipientKeys) - .reduce((acc, curr) => [...acc, ...curr], []) - .map((didKey) => DidKey.fromDid(didKey).key) + // shorthand for services without the need to deal with the String DIDs + public getServices(): Array { + return this.services.map((service) => { + if (service instanceof String) return service.toString() + return service + }) + } + public getDidServices(): Array { + return this.getServices().filter((service): service is string => typeof service === 'string') + } + public getInlineServices(): Array { + return this.getServices().filter((service): service is OutOfBandDidCommService => typeof service !== 'string') } @Transform(({ value }) => replaceLegacyDidSovPrefix(value), { @@ -141,7 +144,8 @@ export class OutOfBandInvitation extends AgentMessage { @OutOfBandServiceTransformer() @IsStringOrInstance(OutOfBandDidCommService, { each: true }) @ValidateNested({ each: true }) - public services!: Array + // eslint-disable-next-line @typescript-eslint/ban-types + private services!: Array /** * Custom property. It is not part of the RFC. @@ -152,13 +156,8 @@ export class OutOfBandInvitation extends AgentMessage { } /** - * Decorator that transforms authentication json to corresponding class instances - * - * @example - * class Example { - * VerificationMethodTransformer() - * private authentication: VerificationMethod - * } + * Decorator that transforms services json to corresponding class instances + * @note Because of ValidateNested limitation, this produces instances of String for DID services except plain js string */ function OutOfBandServiceTransformer() { return Transform(({ value, type }: { value: Array; type: TransformationType }) => { diff --git a/packages/core/src/modules/oob/repository/OutOfBandRecord.ts b/packages/core/src/modules/oob/repository/OutOfBandRecord.ts index 02b821b004..ec291225c2 100644 --- a/packages/core/src/modules/oob/repository/OutOfBandRecord.ts +++ b/packages/core/src/modules/oob/repository/OutOfBandRecord.ts @@ -9,32 +9,37 @@ import { BaseRecord } from '../../../storage/BaseRecord' import { uuid } from '../../../utils/uuid' import { OutOfBandInvitation } from '../messages' +type DefaultOutOfBandRecordTags = { + role: OutOfBandRole + state: OutOfBandState + invitationId: string +} + +interface CustomOutOfBandRecordTags extends TagsBase { + recipientKeyFingerprints: string[] +} + export interface OutOfBandRecordProps { id?: string createdAt?: Date updatedAt?: Date - tags?: TagsBase + tags?: CustomOutOfBandRecordTags outOfBandInvitation: OutOfBandInvitation role: OutOfBandRole state: OutOfBandState + alias?: string autoAcceptConnection?: boolean reusable?: boolean mediatorId?: string reuseConnectionId?: string } -type DefaultOutOfBandRecordTags = { - role: OutOfBandRole - state: OutOfBandState - invitationId: string - recipientKeyFingerprints: string[] -} - -export class OutOfBandRecord extends BaseRecord { +export class OutOfBandRecord extends BaseRecord { @Type(() => OutOfBandInvitation) public outOfBandInvitation!: OutOfBandInvitation public role!: OutOfBandRole public state!: OutOfBandState + public alias?: string public reusable!: boolean public autoAcceptConnection?: boolean public mediatorId?: string @@ -52,11 +57,12 @@ export class OutOfBandRecord extends BaseRecord { this.outOfBandInvitation = props.outOfBandInvitation this.role = props.role this.state = props.state + this.alias = props.alias this.autoAcceptConnection = props.autoAcceptConnection this.reusable = props.reusable ?? false this.mediatorId = props.mediatorId this.reuseConnectionId = props.reuseConnectionId - this._tags = props.tags ?? {} + this._tags = props.tags ?? { recipientKeyFingerprints: [] } } } @@ -66,7 +72,6 @@ export class OutOfBandRecord extends BaseRecord { role: this.role, state: this.state, invitationId: this.outOfBandInvitation.id, - recipientKeyFingerprints: this.outOfBandInvitation.getRecipientKeys().map((key) => key.fingerprint), } } diff --git a/packages/core/src/modules/oob/repository/__tests__/OutOfBandRecord.test.ts b/packages/core/src/modules/oob/repository/__tests__/OutOfBandRecord.test.ts index ee649b7710..6c5cef483e 100644 --- a/packages/core/src/modules/oob/repository/__tests__/OutOfBandRecord.test.ts +++ b/packages/core/src/modules/oob/repository/__tests__/OutOfBandRecord.test.ts @@ -22,6 +22,9 @@ describe('OutOfBandRecord', () => { ], id: 'a-message-id', }), + tags: { + recipientKeyFingerprints: ['z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th'], + }, }) expect(outOfBandRecord.getTags()).toEqual({ diff --git a/packages/core/src/modules/proofs/ProofsApi.ts b/packages/core/src/modules/proofs/ProofsApi.ts index ff5fddaff7..b46b559e54 100644 --- a/packages/core/src/modules/proofs/ProofsApi.ts +++ b/packages/core/src/modules/proofs/ProofsApi.ts @@ -1,4 +1,5 @@ import type { AgentMessage } from '../../agent/AgentMessage' +import type { Query } from '../../storage/StorageService' import type { ProofService } from './ProofService' import type { AcceptPresentationOptions, @@ -81,6 +82,7 @@ export interface ProofsApi + findAllByQuery(query: Query): Promise getById(proofRecordId: string): Promise deleteById(proofId: string): Promise findById(proofRecordId: string): Promise @@ -491,6 +493,15 @@ export class ProofsApi< return this.proofRepository.getAll(this.agentContext) } + /** + * Retrieve all proof records by specified query params + * + * @returns List containing all proof records matching specified params + */ + public findAllByQuery(query: Query): Promise { + return this.proofRepository.findByQuery(this.agentContext, query) + } + /** * Retrieve a proof record by id * diff --git a/packages/core/src/modules/routing/RecipientApi.ts b/packages/core/src/modules/routing/RecipientApi.ts index 293208a4a5..c761826211 100644 --- a/packages/core/src/modules/routing/RecipientApi.ts +++ b/packages/core/src/modules/routing/RecipientApi.ts @@ -1,11 +1,11 @@ -import type { OutboundWebSocketClosedEvent } from '../../transport' +import type { OutboundWebSocketClosedEvent, OutboundWebSocketOpenedEvent } 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 { firstValueFrom, interval, merge, ReplaySubject, Subject, timer } from 'rxjs' import { delayWhen, filter, first, takeUntil, tap, throttleTime, timeout } from 'rxjs/operators' import { AgentContext } from '../../agent' @@ -52,6 +52,9 @@ export class RecipientApi { private agentContext: AgentContext private stop$: Subject + // stopMessagePickup$ is used for stop message pickup signal + private readonly stopMessagePickup$ = new Subject() + public constructor( dispatcher: Dispatcher, mediationRecipientService: MediationRecipientService, @@ -148,7 +151,22 @@ export class RecipientApi { } private async openWebSocketAndPickUp(mediator: MediationRecord, pickupStrategy: MediatorPickupStrategy) { - let interval = 50 + const { baseMediatorReconnectionIntervalMs, maximumMediatorReconnectionIntervalMs } = this.agentContext.config + let interval = baseMediatorReconnectionIntervalMs + + const stopConditions$ = merge(this.stop$, this.stopMessagePickup$).pipe() + + // Reset back off interval when the websocket is successfully opened again + this.eventEmitter + .observable(TransportEventTypes.OutboundWebSocketOpenedEvent) + .pipe( + // Stop when the agent shuts down or stop message pickup signal is received + takeUntil(stopConditions$), + filter((e) => e.payload.connectionId === mediator.connectionId) + ) + .subscribe(() => { + interval = baseMediatorReconnectionIntervalMs + }) // 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 @@ -162,30 +180,35 @@ export class RecipientApi { this.eventEmitter .observable(TransportEventTypes.OutboundWebSocketClosedEvent) .pipe( - // Stop when the agent shuts down - takeUntil(this.stop$), + // Stop when the agent shuts down or stop message pickup signal is received + takeUntil(stopConditions$), 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)) + delayWhen(() => timer(interval)), + // Increase the interval (recursive back-off) + tap(() => { + interval = Math.min(interval * 2, maximumMediatorReconnectionIntervalMs) + }) ) - .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) + .subscribe({ + next: 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 }) } - } catch (error) { - this.logger.warn('Unable to re-open websocket connection to mediator', { error }) - } + }, + complete: () => this.logger.info(`Stopping pickup of messages from mediator '${mediator.id}'`), }) try { if (pickupStrategy === MediatorPickupStrategy.Implicit) { @@ -196,38 +219,63 @@ export class RecipientApi { } } - public async initiateMessagePickup(mediator: MediationRecord) { + /** + * Start a Message Pickup flow with a registered Mediator. + * + * @param mediator optional {MediationRecord} corresponding to the mediator to pick messages from. It will use + * default mediator otherwise + * @param pickupStrategy optional {MediatorPickupStrategy} to use in the loop. It will use Agent's default + * strategy or attempt to find it by Discover Features otherwise + * @returns + */ + public async initiateMessagePickup(mediator?: MediationRecord, pickupStrategy?: MediatorPickupStrategy) { const { mediatorPollingInterval } = this.config - const mediatorPickupStrategy = await this.getPickupStrategyForMediator(mediator) - const mediatorConnection = await this.connectionService.getById(this.agentContext, mediator.connectionId) + const mediatorRecord = mediator ?? (await this.findDefaultMediator()) + if (!mediatorRecord) { + throw new AriesFrameworkError('There is no mediator to pickup messages from') + } + + const mediatorPickupStrategy = pickupStrategy ?? (await this.getPickupStrategyForMediator(mediatorRecord)) + const mediatorConnection = await this.connectionService.getById(this.agentContext, mediatorRecord.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 }) + this.logger.info(`Starting pickup of messages from mediator '${mediatorRecord.id}'`) + await this.openWebSocketAndPickUp(mediatorRecord, mediatorPickupStrategy) + await this.sendStatusRequest({ mediatorId: mediatorRecord.id }) break case MediatorPickupStrategy.PickUpV1: { + const stopConditions$ = merge(this.stop$, this.stopMessagePickup$).pipe() // Explicit means polling every X seconds with batch message - this.logger.info(`Starting explicit (batch) pickup of messages from mediator '${mediator.id}'`) + this.logger.info(`Starting explicit (batch) pickup of messages from mediator '${mediatorRecord.id}'`) const subscription = interval(mediatorPollingInterval) - .pipe(takeUntil(this.stop$)) - .subscribe(async () => { - await this.pickupMessages(mediatorConnection) + .pipe(takeUntil(stopConditions$)) + .subscribe({ + next: async () => { + await this.pickupMessages(mediatorConnection) + }, + complete: () => this.logger.info(`Stopping pickup of messages from mediator '${mediatorRecord.id}'`), }) 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) + this.logger.info(`Starting implicit pickup of messages from mediator '${mediatorRecord.id}'`) + await this.openWebSocketAndPickUp(mediatorRecord, mediatorPickupStrategy) break default: - this.logger.info(`Skipping pickup of messages from mediator '${mediator.id}' due to pickup strategy none`) + this.logger.info(`Skipping pickup of messages from mediator '${mediatorRecord.id}' due to pickup strategy none`) } } + /** + * Terminate all ongoing Message Pickup loops + */ + public async stopMessagePickup() { + this.stopMessagePickup$.next(true) + } + private async sendStatusRequest(config: { mediatorId: string; recipientKey?: string }) { const mediationRecord = await this.mediationRecipientService.getById(this.agentContext, config.mediatorId) diff --git a/packages/core/src/modules/routing/__tests__/mediation.test.ts b/packages/core/src/modules/routing/__tests__/mediation.test.ts index d3814e54b3..04b2b32ab3 100644 --- a/packages/core/src/modules/routing/__tests__/mediation.test.ts +++ b/packages/core/src/modules/routing/__tests__/mediation.test.ts @@ -1,5 +1,7 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import type { SubjectMessage } from '../../../../../../tests/transport/SubjectInboundTransport' +import type { AgentDependencies } from '../../../agent/AgentDependencies' +import type { InitConfig } from '../../../types' import { Subject } from 'rxjs' @@ -7,8 +9,6 @@ import { SubjectInboundTransport } from '../../../../../../tests/transport/Subje import { SubjectOutboundTransport } from '../../../../../../tests/transport/SubjectOutboundTransport' import { getAgentOptions, waitForBasicMessage } from '../../../../tests/helpers' import { Agent } from '../../../agent/Agent' -import { InjectionSymbols } from '../../../constants' -import { sleep } from '../../../utils/sleep' import { ConnectionRecord, HandshakeProtocol } from '../../connections' import { MediatorPickupStrategy } from '../MediatorPickupStrategy' import { MediationState } from '../models/MediationState' @@ -33,13 +33,6 @@ describe('mediator establishment', () => { let senderAgent: Agent afterEach(async () => { - // We want to stop the mediator polling before the agent is shutdown. - // FIXME: add a way to stop mediator polling from the public api, and make sure this is - // being handled in the agent shutdown so we don't get any errors with wallets being closed. - const stop$ = recipientAgent.injectionContainer.resolve>(InjectionSymbols.Stop$) - stop$.next(true) - await sleep(1000) - await recipientAgent?.shutdown() await recipientAgent?.wallet.delete() await mediatorAgent?.shutdown() @@ -48,14 +41,16 @@ describe('mediator establishment', () => { await senderAgent?.wallet.delete() }) - test(`Mediation end-to-end flow - 1. Start mediator agent and create invitation - 2. Start recipient agent with mediatorConnectionsInvite from mediator - 3. Assert mediator and recipient are connected and mediation state is Granted - 4. Start sender agent and create connection with recipient - 5. Assert endpoint in recipient invitation for sender is mediator endpoint - 6. Send basic message from sender to recipient and assert it is received on the recipient side -`, async () => { + const e2eMediationTest = async ( + mediatorAgentOptions: { + readonly config: InitConfig + readonly dependencies: AgentDependencies + }, + recipientAgentOptions: { + config: InitConfig + dependencies: AgentDependencies + } + ) => { const mediatorMessages = new Subject() const recipientMessages = new Subject() const senderMessages = new Subject() @@ -75,7 +70,7 @@ describe('mediator establishment', () => { const mediatorOutOfBandRecord = await mediatorAgent.oob.createInvitation({ label: 'mediator invitation', handshake: true, - handshakeProtocols: [HandshakeProtocol.DidExchange], + handshakeProtocols: [HandshakeProtocol.Connections], }) // Initialize recipient with mediation connections invitation @@ -86,7 +81,6 @@ describe('mediator establishment', () => { mediatorConnectionsInvite: mediatorOutOfBandRecord.outOfBandInvitation.toUrl({ domain: 'https://example.com/ssi', }), - mediatorPickupStrategy: MediatorPickupStrategy.PickUpV1, }, }) recipientAgent.registerOutboundTransport(new SubjectOutboundTransport(subjectMap)) @@ -145,6 +139,40 @@ describe('mediator establishment', () => { }) expect(basicMessage.content).toBe(message) + } + + test(`Mediation end-to-end flow + 1. Start mediator agent and create invitation + 2. Start recipient agent with mediatorConnectionsInvite from mediator + 3. Assert mediator and recipient are connected and mediation state is Granted + 4. Start sender agent and create connection with recipient + 5. Assert endpoint in recipient invitation for sender is mediator endpoint + 6. Send basic message from sender to recipient and assert it is received on the recipient side +`, async () => { + await e2eMediationTest(mediatorAgentOptions, { + config: { + ...recipientAgentOptions.config, + mediatorPickupStrategy: MediatorPickupStrategy.PickUpV1, + }, + dependencies: recipientAgentOptions.dependencies, + }) + }) + + test('Mediation end-to-end flow (use did:key in both sides)', async () => { + await e2eMediationTest( + { + config: { ...mediatorAgentOptions.config, useDidKeyInProtocols: true }, + dependencies: mediatorAgentOptions.dependencies, + }, + { + config: { + ...recipientAgentOptions.config, + mediatorPickupStrategy: MediatorPickupStrategy.PickUpV1, + useDidKeyInProtocols: true, + }, + dependencies: recipientAgentOptions.dependencies, + } + ) }) test('restart recipient agent and create connection through mediator after recipient agent is restarted', async () => { diff --git a/packages/core/src/modules/routing/messages/KeylistUpdateMessage.ts b/packages/core/src/modules/routing/messages/KeylistUpdateMessage.ts index e17a9edf79..b8d493881e 100644 --- a/packages/core/src/modules/routing/messages/KeylistUpdateMessage.ts +++ b/packages/core/src/modules/routing/messages/KeylistUpdateMessage.ts @@ -1,6 +1,5 @@ import { Expose, Type } from 'class-transformer' import { IsArray, ValidateNested, IsString, IsEnum, IsInstance } from 'class-validator' -import { Verkey } from 'indy-sdk' import { AgentMessage } from '../../../agent/AgentMessage' import { IsValidMessageType, parseMessageType } from '../../../utils/messageType' @@ -11,7 +10,7 @@ export enum KeylistUpdateAction { } export class KeylistUpdate { - public constructor(options: { recipientKey: Verkey; action: KeylistUpdateAction }) { + public constructor(options: { recipientKey: string; action: KeylistUpdateAction }) { if (options) { this.recipientKey = options.recipientKey this.action = options.action @@ -20,7 +19,7 @@ export class KeylistUpdate { @IsString() @Expose({ name: 'recipient_key' }) - public recipientKey!: Verkey + public recipientKey!: string @IsEnum(KeylistUpdateAction) public action!: KeylistUpdateAction diff --git a/packages/core/src/modules/routing/messages/KeylistUpdateResponseMessage.ts b/packages/core/src/modules/routing/messages/KeylistUpdateResponseMessage.ts index 7367184e7a..88b75c694c 100644 --- a/packages/core/src/modules/routing/messages/KeylistUpdateResponseMessage.ts +++ b/packages/core/src/modules/routing/messages/KeylistUpdateResponseMessage.ts @@ -1,6 +1,5 @@ import { Expose, Type } from 'class-transformer' import { IsArray, IsEnum, IsInstance, IsString, ValidateNested } from 'class-validator' -import { Verkey } from 'indy-sdk' import { AgentMessage } from '../../../agent/AgentMessage' import { IsValidMessageType, parseMessageType } from '../../../utils/messageType' @@ -15,7 +14,7 @@ export enum KeylistUpdateResult { } export class KeylistUpdated { - public constructor(options: { recipientKey: Verkey; action: KeylistUpdateAction; result: KeylistUpdateResult }) { + public constructor(options: { recipientKey: string; action: KeylistUpdateAction; result: KeylistUpdateResult }) { if (options) { this.recipientKey = options.recipientKey this.action = options.action @@ -25,7 +24,7 @@ export class KeylistUpdated { @IsString() @Expose({ name: 'recipient_key' }) - public recipientKey!: Verkey + public recipientKey!: string @IsEnum(KeylistUpdateAction) public action!: KeylistUpdateAction diff --git a/packages/core/src/modules/routing/services/MediationRecipientService.ts b/packages/core/src/modules/routing/services/MediationRecipientService.ts index ab90eeba66..b62db55446 100644 --- a/packages/core/src/modules/routing/services/MediationRecipientService.ts +++ b/packages/core/src/modules/routing/services/MediationRecipientService.ts @@ -2,11 +2,12 @@ import type { AgentContext } from '../../../agent' import type { AgentMessage } from '../../../agent/AgentMessage' import type { AgentMessageReceivedEvent } from '../../../agent/Events' import type { InboundMessageContext } from '../../../agent/models/InboundMessageContext' +import type { Query } from '../../../storage/StorageService' import type { EncryptedMessage } from '../../../types' import type { ConnectionRecord } from '../../connections' import type { Routing } from '../../connections/services/ConnectionService' import type { MediationStateChangedEvent, KeylistUpdatedEvent } from '../RoutingEvents' -import type { KeylistUpdateResponseMessage, MediationDenyMessage, MediationGrantMessage } from '../messages' +import type { MediationDenyMessage } from '../messages' import type { StatusMessage, MessageDeliveryMessage } from '../protocol' import type { GetRoutingOptions } from './RoutingService' @@ -21,13 +22,20 @@ import { Key, KeyType } from '../../../crypto' import { AriesFrameworkError } from '../../../error' import { injectable } from '../../../plugins' import { JsonTransformer } from '../../../utils' +import { ConnectionType } from '../../connections/models/ConnectionType' +import { ConnectionMetadataKeys } from '../../connections/repository/ConnectionMetadataTypes' import { ConnectionService } from '../../connections/services/ConnectionService' -import { didKeyToVerkey } from '../../dids/helpers' +import { didKeyToVerkey, isDidKey, verkeyToDidKey } 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' +import { + KeylistUpdateAction, + KeylistUpdateResponseMessage, + MediationRequestMessage, + MediationGrantMessage, +} from '../messages' import { KeylistUpdate, KeylistUpdateMessage } from '../messages/KeylistUpdateMessage' import { MediationRole, MediationState } from '../models' import { DeliveryRequestMessage, MessagesReceivedMessage, StatusRequestMessage } from '../protocol/pickup/v2/messages' @@ -85,6 +93,9 @@ export class MediationRecipientService { role: MediationRole.Recipient, connectionId: connection.id, }) + connection.setTag('connectionType', [ConnectionType.Mediator]) + await this.connectionService.update(agentContext, connection) + await this.mediationRepository.save(agentContext, mediationRecord) this.emitStateChangedEvent(agentContext, mediationRecord, null) @@ -105,6 +116,15 @@ export class MediationRecipientService { // Update record mediationRecord.endpoint = messageContext.message.endpoint + // Update connection metadata to use their key format in further protocol messages + const connectionUsesDidKey = messageContext.message.routingKeys.some(isDidKey) + await this.updateUseDidKeysFlag( + messageContext.agentContext, + connection, + MediationGrantMessage.type.protocolUri, + connectionUsesDidKey + ) + // According to RFC 0211 keys should be a did key, but base58 encoded verkey was used before // RFC was accepted. This converts the key to a public key base58 if it is a did key. mediationRecord.routingKeys = messageContext.message.routingKeys.map(didKeyToVerkey) @@ -123,12 +143,21 @@ export class MediationRecipientService { const keylist = messageContext.message.updated + // Update connection metadata to use their key format in further protocol messages + const connectionUsesDidKey = keylist.some((key) => isDidKey(key.recipientKey)) + await this.updateUseDidKeysFlag( + messageContext.agentContext, + connection, + KeylistUpdateResponseMessage.type.protocolUri, + connectionUsesDidKey + ) + // update keylist in mediationRecord for (const update of keylist) { if (update.action === KeylistUpdateAction.add) { - mediationRecord.addRecipientKey(update.recipientKey) + mediationRecord.addRecipientKey(didKeyToVerkey(update.recipientKey)) } else if (update.action === KeylistUpdateAction.remove) { - mediationRecord.removeRecipientKey(update.recipientKey) + mediationRecord.removeRecipientKey(didKeyToVerkey(update.recipientKey)) } } @@ -148,9 +177,18 @@ export class MediationRecipientService { verKey: string, timeoutMs = 15000 // TODO: this should be a configurable value in agent config ): Promise { - const message = this.createKeylistUpdateMessage(verKey) const connection = await this.connectionService.getById(agentContext, mediationRecord.connectionId) + // Use our useDidKey configuration unless we know the key formatting other party is using + let useDidKey = agentContext.config.useDidKeyInProtocols + + const useDidKeysConnectionMetadata = connection.metadata.get(ConnectionMetadataKeys.UseDidKeysForProtocol) + if (useDidKeysConnectionMetadata) { + useDidKey = useDidKeysConnectionMetadata[KeylistUpdateMessage.type.protocolUri] ?? useDidKey + } + + const message = this.createKeylistUpdateMessage(useDidKey ? verkeyToDidKey(verKey) : verKey) + mediationRecord.assertReady() mediationRecord.assertRole(MediationRole.Recipient) @@ -359,6 +397,13 @@ export class MediationRecipientService { return this.mediationRepository.getAll(agentContext) } + public async findAllMediatorsByQuery( + agentContext: AgentContext, + query: Query + ): Promise { + return await this.mediationRepository.findByQuery(agentContext, query) + } + public async findDefaultMediator(agentContext: AgentContext): Promise { return this.mediationRepository.findSingleByQuery(agentContext, { default: true }) } @@ -405,6 +450,18 @@ export class MediationRecipientService { await this.mediationRepository.update(agentContext, mediationRecord) } } + + private async updateUseDidKeysFlag( + agentContext: AgentContext, + connection: ConnectionRecord, + protocolUri: string, + connectionUsesDidKey: boolean + ) { + const useDidKeysForProtocol = connection.metadata.get(ConnectionMetadataKeys.UseDidKeysForProtocol) ?? {} + useDidKeysForProtocol[protocolUri] = connectionUsesDidKey + connection.metadata.set(ConnectionMetadataKeys.UseDidKeysForProtocol, useDidKeysForProtocol) + await this.connectionService.update(agentContext, connection) + } } export interface MediationProtocolMsgReturnType { diff --git a/packages/core/src/modules/routing/services/MediatorService.ts b/packages/core/src/modules/routing/services/MediatorService.ts index ed0d3a5485..af0c5cdb5f 100644 --- a/packages/core/src/modules/routing/services/MediatorService.ts +++ b/packages/core/src/modules/routing/services/MediatorService.ts @@ -1,8 +1,10 @@ import type { AgentContext } from '../../../agent' import type { InboundMessageContext } from '../../../agent/models/InboundMessageContext' +import type { Query } from '../../../storage/StorageService' import type { EncryptedMessage } from '../../../types' +import type { ConnectionRecord } from '../../connections' import type { MediationStateChangedEvent } from '../RoutingEvents' -import type { ForwardMessage, KeylistUpdateMessage, MediationRequestMessage } from '../messages' +import type { ForwardMessage, MediationRequestMessage } from '../messages' import { EventEmitter } from '../../../agent/EventEmitter' import { InjectionSymbols } from '../../../constants' @@ -10,9 +12,12 @@ import { AriesFrameworkError } from '../../../error' import { Logger } from '../../../logger' import { injectable, inject } from '../../../plugins' import { JsonTransformer } from '../../../utils/JsonTransformer' -import { didKeyToVerkey } from '../../dids/helpers' +import { ConnectionService } from '../../connections' +import { ConnectionMetadataKeys } from '../../connections/repository/ConnectionMetadataTypes' +import { didKeyToVerkey, isDidKey, verkeyToDidKey } from '../../dids/helpers' import { RoutingEventTypes } from '../RoutingEvents' import { + KeylistUpdateMessage, KeylistUpdateAction, KeylistUpdated, KeylistUpdateResponseMessage, @@ -32,17 +37,21 @@ export class MediatorService { private mediationRepository: MediationRepository private mediatorRoutingRepository: MediatorRoutingRepository private eventEmitter: EventEmitter + private connectionService: ConnectionService + private _mediatorRoutingRecord?: MediatorRoutingRecord public constructor( mediationRepository: MediationRepository, mediatorRoutingRepository: MediatorRoutingRepository, eventEmitter: EventEmitter, - @inject(InjectionSymbols.Logger) logger: Logger + @inject(InjectionSymbols.Logger) logger: Logger, + connectionService: ConnectionService ) { this.mediationRepository = mediationRepository this.mediatorRoutingRepository = mediatorRoutingRepository this.eventEmitter = eventEmitter this.logger = logger + this.connectionService = connectionService } private async getRoutingKeys(agentContext: AgentContext) { @@ -93,6 +102,15 @@ export class MediatorService { mediationRecord.assertReady() mediationRecord.assertRole(MediationRole.Mediator) + // Update connection metadata to use their key format in further protocol messages + const connectionUsesDidKey = message.updates.some((update) => isDidKey(update.recipientKey)) + await this.updateUseDidKeysFlag( + messageContext.agentContext, + connection, + KeylistUpdateMessage.type.protocolUri, + connectionUsesDidKey + ) + for (const update of message.updates) { const updated = new KeylistUpdated({ action: update.action, @@ -128,9 +146,14 @@ export class MediatorService { await this.updateState(agentContext, mediationRecord, MediationState.Granted) + // Use our useDidKey configuration, as this is the first interaction for this protocol + const useDidKey = agentContext.config.useDidKeyInProtocols + const message = new MediationGrantMessage({ endpoint: agentContext.config.endpoints[0], - routingKeys: await this.getRoutingKeys(agentContext), + routingKeys: useDidKey + ? (await this.getRoutingKeys(agentContext)).map(verkeyToDidKey) + : await this.getRoutingKeys(agentContext), threadId: mediationRecord.threadId, }) @@ -188,6 +211,10 @@ export class MediatorService { return routingRecord } + public async findAllByQuery(agentContext: AgentContext, query: Query): Promise { + return await this.mediationRepository.findByQuery(agentContext, query) + } + private async updateState(agentContext: AgentContext, mediationRecord: MediationRecord, newState: MediationState) { const previousState = mediationRecord.state @@ -212,4 +239,16 @@ export class MediatorService { }, }) } + + private async updateUseDidKeysFlag( + agentContext: AgentContext, + connection: ConnectionRecord, + protocolUri: string, + connectionUsesDidKey: boolean + ) { + const useDidKeysForProtocol = connection.metadata.get(ConnectionMetadataKeys.UseDidKeysForProtocol) ?? {} + useDidKeysForProtocol[protocolUri] = connectionUsesDidKey + connection.metadata.set(ConnectionMetadataKeys.UseDidKeysForProtocol, useDidKeysForProtocol) + await this.connectionService.update(agentContext, connection) + } } 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 fe7e16a128..25dd20538b 100644 --- a/packages/core/src/modules/routing/services/__tests__/MediationRecipientService.test.ts +++ b/packages/core/src/modules/routing/services/__tests__/MediationRecipientService.test.ts @@ -14,12 +14,19 @@ import { AriesFrameworkError } from '../../../../error' import { uuid } from '../../../../utils/uuid' import { IndyWallet } from '../../../../wallet/IndyWallet' import { DidExchangeState } from '../../../connections' +import { ConnectionMetadataKeys } from '../../../connections/repository/ConnectionMetadataTypes' import { ConnectionRepository } from '../../../connections/repository/ConnectionRepository' import { ConnectionService } from '../../../connections/services/ConnectionService' import { DidRepository } from '../../../dids/repository/DidRepository' import { DidRegistrarService } from '../../../dids/services/DidRegistrarService' import { RecipientModuleConfig } from '../../RecipientModuleConfig' -import { MediationGrantMessage } from '../../messages' +import { RoutingEventTypes } from '../../RoutingEvents' +import { + KeylistUpdateAction, + KeylistUpdateResponseMessage, + KeylistUpdateResult, + MediationGrantMessage, +} from '../../messages' import { MediationRole, MediationState } from '../../models' import { DeliveryRequestMessage, MessageDeliveryMessage, MessagesReceivedMessage, StatusMessage } from '../../protocol' import { MediationRecord } from '../../repository/MediationRecord' @@ -123,10 +130,17 @@ describe('MediationRecipientService', () => { threadId: 'threadId', }) - const messageContext = new InboundMessageContext(mediationGrant, { connection: mockConnection, agentContext }) + const connection = getMockConnection({ + state: DidExchangeState.Completed, + }) + + const messageContext = new InboundMessageContext(mediationGrant, { connection, agentContext }) await mediationRecipientService.processMediationGrant(messageContext) + expect(connection.metadata.get(ConnectionMetadataKeys.UseDidKeysForProtocol)).toEqual({ + 'https://didcomm.org/coordinate-mediation/1.0': false, + }) expect(mediationRecord.routingKeys).toEqual(['79CXkde3j8TNuMXxPdV7nLUrT2g7JAEjH5TreyVY7GEZ']) }) @@ -138,10 +152,17 @@ describe('MediationRecipientService', () => { threadId: 'threadId', }) - const messageContext = new InboundMessageContext(mediationGrant, { connection: mockConnection, agentContext }) + const connection = getMockConnection({ + state: DidExchangeState.Completed, + }) + + const messageContext = new InboundMessageContext(mediationGrant, { connection, agentContext }) await mediationRecipientService.processMediationGrant(messageContext) + expect(connection.metadata.get(ConnectionMetadataKeys.UseDidKeysForProtocol)).toEqual({ + 'https://didcomm.org/coordinate-mediation/1.0': true, + }) expect(mediationRecord.routingKeys).toEqual(['8HH5gYEeNc3z7PYXmd54d4x6qAfCNrqQqEB3nS7Zfu7K']) }) }) @@ -157,6 +178,63 @@ describe('MediationRecipientService', () => { recipientKey: 'a-key', }) }) + + it('it throws an error when the mediation record has incorrect role or state', async () => { + mediationRecord.role = MediationRole.Mediator + await expect(mediationRecipientService.createStatusRequest(mediationRecord)).rejects.toThrowError( + 'Mediation record has invalid role MEDIATOR. Expected role RECIPIENT.' + ) + + mediationRecord.role = MediationRole.Recipient + mediationRecord.state = MediationState.Requested + + await expect(mediationRecipientService.createStatusRequest(mediationRecord)).rejects.toThrowError( + 'Mediation record is not ready to be used. Expected granted, found invalid state requested' + ) + }) + }) + + describe('processKeylistUpdateResults', () => { + it('it stores did:key-encoded keys in base58 format', async () => { + const spyAddRecipientKey = jest.spyOn(mediationRecord, 'addRecipientKey') + + const connection = getMockConnection({ + state: DidExchangeState.Completed, + }) + + const keylist = [ + { + result: KeylistUpdateResult.Success, + recipientKey: 'did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th', + action: KeylistUpdateAction.add, + }, + ] + + const keyListUpdateResponse = new KeylistUpdateResponseMessage({ + threadId: uuid(), + keylist, + }) + + const messageContext = new InboundMessageContext(keyListUpdateResponse, { connection, agentContext }) + + expect(connection.metadata.get(ConnectionMetadataKeys.UseDidKeysForProtocol)).toBeNull() + + await mediationRecipientService.processKeylistUpdateResults(messageContext) + + expect(connection.metadata.get(ConnectionMetadataKeys.UseDidKeysForProtocol)).toEqual({ + 'https://didcomm.org/coordinate-mediation/1.0': true, + }) + + expect(eventEmitter.emit).toHaveBeenCalledWith(agentContext, { + type: RoutingEventTypes.RecipientKeylistUpdated, + payload: { + mediationRecord, + keylist, + }, + }) + expect(spyAddRecipientKey).toHaveBeenCalledWith('8HH5gYEeNc3z7PYXmd54d4x6qAfCNrqQqEB3nS7Zfu7K') + spyAddRecipientKey.mockClear() + }) }) describe('processStatus', () => { diff --git a/packages/core/src/modules/routing/services/__tests__/MediatorService.test.ts b/packages/core/src/modules/routing/services/__tests__/MediatorService.test.ts index 2d963ec106..531f4fe608 100644 --- a/packages/core/src/modules/routing/services/__tests__/MediatorService.test.ts +++ b/packages/core/src/modules/routing/services/__tests__/MediatorService.test.ts @@ -3,45 +3,71 @@ import { Subject } from 'rxjs' import { getAgentConfig, getAgentContext, getMockConnection, mockFunction } from '../../../../../tests/helpers' import { EventEmitter } from '../../../../agent/EventEmitter' import { InboundMessageContext } from '../../../../agent/models/InboundMessageContext' -import { IndyWallet } from '../../../../wallet/IndyWallet' -import { DidExchangeState } from '../../../connections' -import { KeylistUpdateAction, KeylistUpdateMessage } from '../../messages' +import { ConnectionService, DidExchangeState } from '../../../connections' +import { isDidKey } from '../../../dids/helpers' +import { KeylistUpdateAction, KeylistUpdateMessage, KeylistUpdateResult } from '../../messages' import { MediationRole, MediationState } from '../../models' -import { MediationRecord } from '../../repository' +import { MediationRecord, MediatorRoutingRecord } from '../../repository' import { MediationRepository } from '../../repository/MediationRepository' import { MediatorRoutingRepository } from '../../repository/MediatorRoutingRepository' import { MediatorService } from '../MediatorService' -const agentConfig = getAgentConfig('MediatorService') - jest.mock('../../repository/MediationRepository') const MediationRepositoryMock = MediationRepository as jest.Mock jest.mock('../../repository/MediatorRoutingRepository') const MediatorRoutingRepositoryMock = MediatorRoutingRepository as jest.Mock -jest.mock('../../../../wallet/IndyWallet') -const WalletMock = IndyWallet as jest.Mock - -const agentContext = getAgentContext({ - wallet: new WalletMock(), -}) +jest.mock('../../../connections/services/ConnectionService') +const ConnectionServiceMock = ConnectionService as jest.Mock const mediationRepository = new MediationRepositoryMock() const mediatorRoutingRepository = new MediatorRoutingRepositoryMock() - -const mediatorService = new MediatorService( - mediationRepository, - mediatorRoutingRepository, - new EventEmitter(agentConfig.agentDependencies, new Subject()), - agentConfig.logger -) +const connectionService = new ConnectionServiceMock() const mockConnection = getMockConnection({ state: DidExchangeState.Completed, }) -describe('MediatorService', () => { +describe('MediatorService - default config', () => { + const agentConfig = getAgentConfig('MediatorService') + + const agentContext = getAgentContext({ + agentConfig, + }) + + const mediatorService = new MediatorService( + mediationRepository, + mediatorRoutingRepository, + new EventEmitter(agentConfig.agentDependencies, new Subject()), + agentConfig.logger, + connectionService + ) + + describe('createGrantMediationMessage', () => { + test('sends base58 encoded recipient keys by default', async () => { + const mediationRecord = new MediationRecord({ + connectionId: 'connectionId', + role: MediationRole.Mediator, + state: MediationState.Requested, + threadId: 'threadId', + }) + + mockFunction(mediationRepository.getByConnectionId).mockResolvedValue(mediationRecord) + + mockFunction(mediatorRoutingRepository.findById).mockResolvedValue( + new MediatorRoutingRecord({ + routingKeys: ['8HH5gYEeNc3z7PYXmd54d4x6qAfCNrqQqEB3nS7Zfu7K'], + }) + ) + + const { message } = await mediatorService.createGrantMediationMessage(agentContext, mediationRecord) + + expect(message.routingKeys.length).toBe(1) + expect(isDidKey(message.routingKeys[0])).toBeFalsy() + }) + }) + describe('processKeylistUpdateRequest', () => { test('processes base58 encoded recipient keys', async () => { const mediationRecord = new MediationRecord({ @@ -68,39 +94,103 @@ describe('MediatorService', () => { }) const messageContext = new InboundMessageContext(keyListUpdate, { connection: mockConnection, agentContext }) - await mediatorService.processKeylistUpdateRequest(messageContext) + const response = await mediatorService.processKeylistUpdateRequest(messageContext) expect(mediationRecord.recipientKeys).toEqual(['79CXkde3j8TNuMXxPdV7nLUrT2g7JAEjH5TreyVY7GEZ']) + expect(response.updated).toEqual([ + { + action: KeylistUpdateAction.add, + recipientKey: '79CXkde3j8TNuMXxPdV7nLUrT2g7JAEjH5TreyVY7GEZ', + result: KeylistUpdateResult.Success, + }, + { + action: KeylistUpdateAction.remove, + recipientKey: '8HH5gYEeNc3z7PYXmd54d4x6qAfCNrqQqEB3nS7Zfu7K', + result: KeylistUpdateResult.Success, + }, + ]) + }) + }) + + test('processes did:key encoded recipient keys', async () => { + const mediationRecord = new MediationRecord({ + connectionId: 'connectionId', + role: MediationRole.Mediator, + state: MediationState.Granted, + threadId: 'threadId', + recipientKeys: ['8HH5gYEeNc3z7PYXmd54d4x6qAfCNrqQqEB3nS7Zfu7K'], }) - test('processes did:key encoded recipient keys', async () => { + mockFunction(mediationRepository.getByConnectionId).mockResolvedValue(mediationRecord) + + const keyListUpdate = new KeylistUpdateMessage({ + updates: [ + { + action: KeylistUpdateAction.add, + recipientKey: 'did:key:z6MkkbTaLstV4fwr1rNf5CSxdS2rGbwxi3V5y6NnVFTZ2V1w', + }, + { + action: KeylistUpdateAction.remove, + recipientKey: 'did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th', + }, + ], + }) + + const messageContext = new InboundMessageContext(keyListUpdate, { connection: mockConnection, agentContext }) + const response = await mediatorService.processKeylistUpdateRequest(messageContext) + + expect(mediationRecord.recipientKeys).toEqual(['79CXkde3j8TNuMXxPdV7nLUrT2g7JAEjH5TreyVY7GEZ']) + expect(response.updated).toEqual([ + { + action: KeylistUpdateAction.add, + recipientKey: 'did:key:z6MkkbTaLstV4fwr1rNf5CSxdS2rGbwxi3V5y6NnVFTZ2V1w', + result: KeylistUpdateResult.Success, + }, + { + action: KeylistUpdateAction.remove, + recipientKey: 'did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th', + result: KeylistUpdateResult.Success, + }, + ]) + }) +}) + +describe('MediatorService - useDidKeyInProtocols set to true', () => { + const agentConfig = getAgentConfig('MediatorService', { useDidKeyInProtocols: true }) + + const agentContext = getAgentContext({ + agentConfig, + }) + + const mediatorService = new MediatorService( + mediationRepository, + mediatorRoutingRepository, + new EventEmitter(agentConfig.agentDependencies, new Subject()), + agentConfig.logger, + connectionService + ) + + describe('createGrantMediationMessage', () => { + test('sends did:key encoded recipient keys when config is set', async () => { const mediationRecord = new MediationRecord({ connectionId: 'connectionId', role: MediationRole.Mediator, - state: MediationState.Granted, + state: MediationState.Requested, threadId: 'threadId', - recipientKeys: ['8HH5gYEeNc3z7PYXmd54d4x6qAfCNrqQqEB3nS7Zfu7K'], }) mockFunction(mediationRepository.getByConnectionId).mockResolvedValue(mediationRecord) - const keyListUpdate = new KeylistUpdateMessage({ - updates: [ - { - action: KeylistUpdateAction.add, - recipientKey: 'did:key:z6MkkbTaLstV4fwr1rNf5CSxdS2rGbwxi3V5y6NnVFTZ2V1w', - }, - { - action: KeylistUpdateAction.remove, - recipientKey: 'did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th', - }, - ], + const routingRecord = new MediatorRoutingRecord({ + routingKeys: ['8HH5gYEeNc3z7PYXmd54d4x6qAfCNrqQqEB3nS7Zfu7K'], }) - const messageContext = new InboundMessageContext(keyListUpdate, { connection: mockConnection, agentContext }) - await mediatorService.processKeylistUpdateRequest(messageContext) + mockFunction(mediatorRoutingRepository.findById).mockResolvedValue(routingRecord) - expect(mediationRecord.recipientKeys).toEqual(['79CXkde3j8TNuMXxPdV7nLUrT2g7JAEjH5TreyVY7GEZ']) + const { message } = await mediatorService.createGrantMediationMessage(agentContext, mediationRecord) + + expect(message.routingKeys.length).toBe(1) + expect(isDidKey(message.routingKeys[0])).toBeTruthy() }) }) }) diff --git a/packages/core/src/storage/StorageService.ts b/packages/core/src/storage/StorageService.ts index 790a470aa2..6ea701df56 100644 --- a/packages/core/src/storage/StorageService.ts +++ b/packages/core/src/storage/StorageService.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import type { AgentContext } from '../agent' import type { Constructor } from '../utils/mixins' import type { BaseRecord, TagsBase } from './BaseRecord' @@ -11,13 +12,12 @@ interface AdvancedQuery { $not?: Query } -export type Query = AdvancedQuery | SimpleQuery +export type Query> = AdvancedQuery | SimpleQuery export interface BaseRecordConstructor extends Constructor { type: string } -// eslint-disable-next-line @typescript-eslint/no-explicit-any export interface StorageService> { /** * Save record in storage diff --git a/packages/core/src/storage/migration/__tests__/__fixtures__/alice-8-connections-0.1.json b/packages/core/src/storage/migration/__tests__/__fixtures__/alice-8-connections-0.1.json index ea749feede..ece0f42270 100644 --- a/packages/core/src/storage/migration/__tests__/__fixtures__/alice-8-connections-0.1.json +++ b/packages/core/src/storage/migration/__tests__/__fixtures__/alice-8-connections-0.1.json @@ -66,6 +66,7 @@ "theirLabel": "Agent: PopulateWallet2", "state": "complete", "role": "invitee", + "alias": "connection alias", "invitation": { "@type": "https://didcomm.org/connections/1.0/invitation", "@id": "d56fd7af-852e-458e-b750-7a4f4e53d6e6", diff --git a/packages/core/src/storage/migration/__tests__/__snapshots__/0.1.test.ts.snap b/packages/core/src/storage/migration/__tests__/__snapshots__/0.1.test.ts.snap index 927b02b255..d71efef325 100644 --- a/packages/core/src/storage/migration/__tests__/__snapshots__/0.1.test.ts.snap +++ b/packages/core/src/storage/migration/__tests__/__snapshots__/0.1.test.ts.snap @@ -782,7 +782,12 @@ Object { }, "type": "OutOfBandRecord", "value": Object { - "_tags": Object {}, + "_tags": Object { + "recipientKeyFingerprints": Array [ + "z6MkfiPMPxCQeSDZGMkCvm1Y2rBoPsmw4ZHMv71jXtcWRRiM", + ], + }, + "alias": "connection alias", "autoAcceptConnection": undefined, "createdAt": "2022-04-30T13:02:21.577Z", "id": "1-4e4f-41d9-94c4-f49351b811f1", @@ -841,7 +846,12 @@ Object { }, "type": "OutOfBandRecord", "value": Object { - "_tags": Object {}, + "_tags": Object { + "recipientKeyFingerprints": Array [ + "z6MktCZAQNGvWb4WHAjwBqPtXhZdDYorbSJkGW9vj1uhw1HD", + ], + }, + "alias": undefined, "autoAcceptConnection": undefined, "createdAt": "2022-04-30T13:02:21.608Z", "id": "2-4e4f-41d9-94c4-f49351b811f1", @@ -900,7 +910,12 @@ Object { }, "type": "OutOfBandRecord", "value": Object { - "_tags": Object {}, + "_tags": Object { + "recipientKeyFingerprints": Array [ + "z6Mkt1tsp15cnDD7wBCFgehiR2SxHX1aPxt4sueE24twH9Bd", + ], + }, + "alias": undefined, "autoAcceptConnection": false, "createdAt": "2022-04-30T13:02:21.628Z", "id": "3-4e4f-41d9-94c4-f49351b811f1", @@ -959,7 +974,12 @@ Object { }, "type": "OutOfBandRecord", "value": Object { - "_tags": Object {}, + "_tags": Object { + "recipientKeyFingerprints": Array [ + "z6Mkmod8vp2nURVktVC5ceQeyr2VUz26iu2ZANLNVg9pMawa", + ], + }, + "alias": undefined, "autoAcceptConnection": undefined, "createdAt": "2022-04-30T13:02:21.635Z", "id": "4-4e4f-41d9-94c4-f49351b811f1", @@ -1018,7 +1038,12 @@ Object { }, "type": "OutOfBandRecord", "value": Object { - "_tags": Object {}, + "_tags": Object { + "recipientKeyFingerprints": Array [ + "z6MkjDJL4X7YGoH6gjamhZR2NzowPZqtJfX5kPuNuWiVdjMr", + ], + }, + "alias": undefined, "autoAcceptConnection": false, "createdAt": "2022-04-30T13:02:21.641Z", "id": "5-4e4f-41d9-94c4-f49351b811f1", @@ -1077,6 +1102,7 @@ Object { }, "type": "OutOfBandRecord", "value": Object { + "alias": undefined, "autoAcceptConnection": true, "createdAt": "2022-04-30T13:02:21.646Z", "id": "6-4e4f-41d9-94c4-f49351b811f1", @@ -1135,7 +1161,12 @@ Object { }, "type": "OutOfBandRecord", "value": Object { - "_tags": Object {}, + "_tags": Object { + "recipientKeyFingerprints": Array [ + "z6MkuWTEmH1mUo6W96zSWyH612hFHowRzNEscPYBL2CCMyC2", + ], + }, + "alias": undefined, "autoAcceptConnection": true, "createdAt": "2022-04-30T13:02:21.653Z", "id": "7-4e4f-41d9-94c4-f49351b811f1", @@ -1230,6 +1261,7 @@ Object { }, "type": "ConnectionRecord", "value": Object { + "alias": "connection alias", "createdAt": "2022-04-30T13:02:21.577Z", "did": "did:peer:1zQmRAfQ6J5qk4qcbHyoStFVkhusazLT9xQcFhdC9dhhQ1cJ", "id": "8f4908ee-15ad-4058-9106-eda26eae735c", diff --git a/packages/core/src/storage/migration/updates/0.1-0.2/__tests__/connection.test.ts b/packages/core/src/storage/migration/updates/0.1-0.2/__tests__/connection.test.ts index 520bf571aa..fe68a1d5a1 100644 --- a/packages/core/src/storage/migration/updates/0.1-0.2/__tests__/connection.test.ts +++ b/packages/core/src/storage/migration/updates/0.1-0.2/__tests__/connection.test.ts @@ -385,7 +385,7 @@ describe('0.1-0.2 | Connection', () => { expect(outOfBandRecord.toJSON()).toEqual({ id: expect.any(String), - _tags: {}, + _tags: { recipientKeyFingerprints: ['z6MksYU4MHtfmNhNm1uGMvANr9j4CBv2FymjiJtRgA36bSVH'] }, metadata: {}, // Checked below outOfBandInvitation: { diff --git a/packages/core/src/storage/migration/updates/0.1-0.2/connection.ts b/packages/core/src/storage/migration/updates/0.1-0.2/connection.ts index 8166818ef3..ff88c5e156 100644 --- a/packages/core/src/storage/migration/updates/0.1-0.2/connection.ts +++ b/packages/core/src/storage/migration/updates/0.1-0.2/connection.ts @@ -12,6 +12,7 @@ import { DidExchangeRole, } from '../../../../modules/connections' import { convertToNewDidDocument } from '../../../../modules/connections/services/helpers' +import { DidKey } from '../../../../modules/dids' import { DidDocumentRole } from '../../../../modules/dids/domain/DidDocumentRole' import { DidRecord, DidRepository } from '../../../../modules/dids/repository' import { DidRecordMetadataKeys } from '../../../../modules/dids/repository/didRecordMetadataTypes' @@ -313,9 +314,15 @@ export async function migrateToOobRecord( const outOfBandInvitation = convertToNewInvitation(oldInvitation) // If both the recipientKeys and the @id match we assume the connection was created using the same invitation. + const recipientKeyFingerprints = outOfBandInvitation + .getInlineServices() + .map((s) => s.recipientKeys) + .reduce((acc, curr) => [...acc, ...curr], []) + .map((didKey) => DidKey.fromDid(didKey).key.fingerprint) + const oobRecords = await oobRepository.findByQuery(agent.context, { invitationId: oldInvitation.id, - recipientKeyFingerprints: outOfBandInvitation.getRecipientKeys().map((key) => key.fingerprint), + recipientKeyFingerprints, }) let oobRecord: OutOfBandRecord | undefined = oobRecords[0] @@ -333,11 +340,13 @@ export async function migrateToOobRecord( oobRecord = new OutOfBandRecord({ role: oobRole, state: oobState, + alias: connectionRecord.alias, autoAcceptConnection: connectionRecord.autoAcceptConnection, outOfBandInvitation, reusable: oldMultiUseInvitation, mediatorId: connectionRecord.mediatorId, createdAt: connectionRecord.createdAt, + tags: { recipientKeyFingerprints }, }) await oobRepository.save(agent.context, oobRecord) diff --git a/packages/core/src/transport/TransportEventTypes.ts b/packages/core/src/transport/TransportEventTypes.ts index b0777883e1..8916724e86 100644 --- a/packages/core/src/transport/TransportEventTypes.ts +++ b/packages/core/src/transport/TransportEventTypes.ts @@ -2,6 +2,7 @@ import type { BaseEvent } from '../agent/Events' export enum TransportEventTypes { OutboundWebSocketClosedEvent = 'OutboundWebSocketClosedEvent', + OutboundWebSocketOpenedEvent = 'OutboundWebSocketOpenedEvent', } export interface OutboundWebSocketClosedEvent extends BaseEvent { @@ -11,3 +12,11 @@ export interface OutboundWebSocketClosedEvent extends BaseEvent { connectionId?: string } } + +export interface OutboundWebSocketOpenedEvent extends BaseEvent { + type: TransportEventTypes.OutboundWebSocketOpenedEvent + payload: { + socketId: string + connectionId?: string + } +} diff --git a/packages/core/src/transport/WsOutboundTransport.ts b/packages/core/src/transport/WsOutboundTransport.ts index b0cc8e8d28..8e97107141 100644 --- a/packages/core/src/transport/WsOutboundTransport.ts +++ b/packages/core/src/transport/WsOutboundTransport.ts @@ -3,7 +3,7 @@ import type { AgentMessageReceivedEvent } from '../agent/Events' import type { Logger } from '../logger' import type { OutboundPackage } from '../types' import type { OutboundTransport } from './OutboundTransport' -import type { OutboundWebSocketClosedEvent } from './TransportEventTypes' +import type { OutboundWebSocketClosedEvent, OutboundWebSocketOpenedEvent } from './TransportEventTypes' import type WebSocket from 'ws' import { AgentEventTypes } from '../agent/Events' @@ -136,6 +136,14 @@ export class WsOutboundTransport implements OutboundTransport { socket.onopen = () => { this.logger.debug(`Successfully connected to WebSocket ${endpoint}`) resolve(socket) + + this.agent.events.emit(this.agent.context, { + type: TransportEventTypes.OutboundWebSocketOpenedEvent, + payload: { + socketId, + connectionId: connectionId, + }, + }) } socket.onerror = (error) => { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 587443ea2d..9e886472df 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,13 +1,14 @@ import type { AgentMessage } from './agent/AgentMessage' -import type { ResolvedDidCommService } from './agent/MessageSender' import type { Key } from './crypto' import type { Logger } from './logger' import type { ConnectionRecord } from './modules/connections' import type { AutoAcceptCredential } from './modules/credentials/models/CredentialAutoAcceptType' +import type { ResolvedDidCommService } from './modules/didcomm' import type { IndyPoolConfig } from './modules/ledger/IndyPool' import type { OutOfBandRecord } from './modules/oob/repository' import type { AutoAcceptProof } from './modules/proofs' import type { MediatorPickupStrategy } from './modules/routing' +import type { BaseRecord } from './storage/BaseRecord' export enum KeyDerivationMethod { /** default value in indy-sdk. Will be used when no value is provided */ @@ -26,6 +27,7 @@ export interface WalletConfig { type: string [key: string]: unknown } + masterSecretId?: string } export interface WalletConfigRekey { @@ -75,6 +77,9 @@ export interface InitConfig { mediatorPollingInterval?: number mediatorPickupStrategy?: MediatorPickupStrategy maximumMessagePickup?: number + baseMediatorReconnectionIntervalMs?: number + maximumMediatorReconnectionIntervalMs?: number + useDidKeyInProtocols?: boolean useLegacyDidSovPrefix?: boolean connectionImageUrl?: string @@ -94,6 +99,7 @@ export interface OutboundMessage { connection: ConnectionRecord sessionId?: string outOfBand?: OutOfBandRecord + associatedRecord?: BaseRecord } export interface OutboundServiceMessage { diff --git a/packages/core/src/utils/__tests__/shortenedUrl.test.ts b/packages/core/src/utils/__tests__/shortenedUrl.test.ts index a6e2364f97..5e79621e96 100644 --- a/packages/core/src/utils/__tests__/shortenedUrl.test.ts +++ b/packages/core/src/utils/__tests__/shortenedUrl.test.ts @@ -7,7 +7,7 @@ import { OutOfBandInvitation } from '../../modules/oob' import { convertToNewInvitation } from '../../modules/oob/helpers' import { JsonTransformer } from '../JsonTransformer' import { MessageValidator } from '../MessageValidator' -import { oobInvitationfromShortUrl } from '../parseInvitation' +import { oobInvitationFromShortUrl } from '../parseInvitation' const mockOobInvite = { '@type': 'did:sov:BzCbsNYhMrjHiqZDTUASHg;spec/out-of-band/1.0/invitation', @@ -89,21 +89,32 @@ beforeAll(async () => { describe('shortened urls resolving to oob invitations', () => { test('Resolve a mocked response in the form of a oob invitation as a json object', async () => { - const short = await oobInvitationfromShortUrl(mockedResponseOobJson) + const short = await oobInvitationFromShortUrl(mockedResponseOobJson) expect(short).toEqual(outOfBandInvitationMock) }) test('Resolve a mocked response in the form of a oob invitation encoded in an url', async () => { - const short = await oobInvitationfromShortUrl(mockedResponseOobUrl) + const short = await oobInvitationFromShortUrl(mockedResponseOobUrl) + expect(short).toEqual(outOfBandInvitationMock) + }) + + test("Resolve a mocked response in the form of a oob invitation as a json object with header 'application/json; charset=utf-8'", async () => { + const short = await oobInvitationFromShortUrl({ + ...mockedResponseOobJson, + headers: new Headers({ + 'content-type': 'application/json; charset=utf-8', + }), + } as Response) expect(short).toEqual(outOfBandInvitationMock) }) }) + describe('shortened urls resolving to connection invitations', () => { test('Resolve a mocked response in the form of a connection invitation as a json object', async () => { - const short = await oobInvitationfromShortUrl(mockedResponseConnectionJson) + const short = await oobInvitationFromShortUrl(mockedResponseConnectionJson) expect(short).toEqual(connectionInvitationToNew) }) test('Resolve a mocked Response in the form of a connection invitation encoded in an url', async () => { - const short = await oobInvitationfromShortUrl(mockedResponseConnectionUrl) + const short = await oobInvitationFromShortUrl(mockedResponseConnectionUrl) expect(short).toEqual(connectionInvitationToNew) }) }) diff --git a/packages/core/src/utils/parseInvitation.ts b/packages/core/src/utils/parseInvitation.ts index 713360512e..6f3c9e8f3b 100644 --- a/packages/core/src/utils/parseInvitation.ts +++ b/packages/core/src/utils/parseInvitation.ts @@ -54,9 +54,9 @@ export const parseInvitationUrl = (invitationUrl: string): OutOfBandInvitation = } //This currently does not follow the RFC because of issues with fetch, currently uses a janky work around -export const oobInvitationfromShortUrl = async (response: Response): Promise => { +export const oobInvitationFromShortUrl = async (response: Response): Promise => { if (response) { - if (response.headers.get('Content-Type') === 'application/json' && response.ok) { + if (response.headers.get('Content-Type')?.startsWith('application/json') && response.ok) { const invitationJson = await response.json() const parsedMessageType = parseMessageType(invitationJson['@type']) if (supportsIncomingMessageType(parsedMessageType, OutOfBandInvitation.type)) { @@ -107,7 +107,7 @@ export const parseInvitationShortUrl = async ( return convertToNewInvitation(invitation) } else { try { - return oobInvitationfromShortUrl(await fetchShortUrl(invitationUrl, dependencies)) + return oobInvitationFromShortUrl(await fetchShortUrl(invitationUrl, dependencies)) } catch (error) { throw new AriesFrameworkError( 'InvitationUrl is invalid. It needs to contain one, and only one, of the following parameters: `oob`, `c_i` or `d_m`, or be valid shortened URL' diff --git a/packages/core/src/utils/validators.ts b/packages/core/src/utils/validators.ts index e81c5543bf..8e7240b5f2 100644 --- a/packages/core/src/utils/validators.ts +++ b/packages/core/src/utils/validators.ts @@ -9,12 +9,12 @@ export interface IsInstanceOrArrayOfInstancesValidationOptions extends Validatio } /** - * Checks if the value is an instance of the specified object. + * Checks if the value is a string or the specified instance */ export function IsStringOrInstance(targetType: Constructor, validationOptions?: ValidationOptions): PropertyDecorator { return ValidateBy( { - name: 'isStringOrVerificationMethod', + name: 'IsStringOrInstance', constraints: [targetType], validator: { validate: (value, args): boolean => isString(value) || isInstance(value, args?.constraints[0]), @@ -22,9 +22,7 @@ export function IsStringOrInstance(targetType: Constructor, validationOptions?: if (args?.constraints[0]) { return eachPrefix + `$property must be of type string or instance of ${args.constraints[0].name as string}` } else { - return ( - eachPrefix + `isStringOrVerificationMethod decorator expects and object as value, but got falsy value.` - ) + return eachPrefix + `IsStringOrInstance decorator expects an object as value, but got falsy value.` } }, validationOptions), }, diff --git a/packages/core/src/wallet/IndyWallet.test.ts b/packages/core/src/wallet/IndyWallet.test.ts index 04176f9b55..6ab30b6657 100644 --- a/packages/core/src/wallet/IndyWallet.test.ts +++ b/packages/core/src/wallet/IndyWallet.test.ts @@ -20,8 +20,17 @@ const walletConfig: WalletConfig = { keyDerivationMethod: KeyDerivationMethod.Raw, } +const walletConfigWithMasterSecretId: WalletConfig = { + id: 'Wallet: WalletTestWithMasterSecretId', + // generated using indy.generateWalletKey + key: 'CwNJroKHTSSj3XvE7ZAnuKiTn2C4QkFvxEqfm5rzhNrb', + keyDerivationMethod: KeyDerivationMethod.Raw, + masterSecretId: 'customMasterSecretId', +} + describe('IndyWallet', () => { let indyWallet: IndyWallet + const seed = 'sample-seed' const message = TypedArrayEncoder.fromString('sample-message') @@ -100,4 +109,25 @@ describe('IndyWallet', () => { }) await expect(indyWallet.verify({ key: ed25519Key, data: message, signature })).resolves.toStrictEqual(true) }) + + test('masterSecretId is equal to wallet ID by default', async () => { + expect(indyWallet.masterSecretId).toEqual(walletConfig.id) + }) +}) + +describe('IndyWallet with custom Master Secret Id', () => { + let indyWallet: IndyWallet + + beforeEach(async () => { + indyWallet = new IndyWallet(agentDependencies, testLogger, new SigningProviderRegistry([])) + await indyWallet.createAndOpen(walletConfigWithMasterSecretId) + }) + + afterEach(async () => { + await indyWallet.delete() + }) + + test('masterSecretId is set by config', async () => { + expect(indyWallet.masterSecretId).toEqual(walletConfigWithMasterSecretId.masterSecretId) + }) }) diff --git a/packages/core/src/wallet/IndyWallet.ts b/packages/core/src/wallet/IndyWallet.ts index ccf614351d..b8c54a2f71 100644 --- a/packages/core/src/wallet/IndyWallet.ts +++ b/packages/core/src/wallet/IndyWallet.ts @@ -78,13 +78,13 @@ export class IndyWallet implements Wallet { } public get masterSecretId() { - if (!this.isInitialized || !this.walletConfig?.id) { + if (!this.isInitialized || !(this.walletConfig?.id || this.walletConfig?.masterSecretId)) { throw new AriesFrameworkError( 'Wallet has not been initialized yet. Make sure to await agent.initialize() before using the agent.' ) } - return this.walletConfig.id + return this.walletConfig?.masterSecretId ?? this.walletConfig.id } /** @@ -155,7 +155,7 @@ export class IndyWallet implements Wallet { await this.open(walletConfig) // We need to open wallet before creating master secret because we need wallet handle here. - await this.createMasterSecret(this.handle, walletConfig.id) + await this.createMasterSecret(this.handle, this.masterSecretId) } catch (error) { // If an error ocurred while creating the master secret, we should close the wallet if (this.isInitialized) await this.close() diff --git a/packages/core/tests/helpers.ts b/packages/core/tests/helpers.ts index 6378f64600..45d62417ae 100644 --- a/packages/core/tests/helpers.ts +++ b/packages/core/tests/helpers.ts @@ -12,6 +12,7 @@ import type { SchemaTemplate, Wallet, } from '../src' +import type { AgentModulesInput } from '../src/agent/AgentModules' import type { IndyOfferCredentialFormat } from '../src/modules/credentials/formats/indy/IndyCredentialFormat' import type { RequestProofOptions } from '../src/modules/proofs/ProofsApiOptions' import type { ProofAttributeInfo, ProofPredicateInfo } from '../src/modules/proofs/formats/indy/models' @@ -73,7 +74,11 @@ export const genesisPath = process.env.GENESIS_TXN_PATH export const publicDidSeed = process.env.TEST_AGENT_PUBLIC_DID_SEED ?? '000000000000000000000000Trustee9' export { agentDependencies } -export function getAgentOptions(name: string, extraConfig: Partial = {}) { +export function getAgentOptions( + name: string, + extraConfig: Partial = {}, + modules?: AgentModules +) { const config: InitConfig = { label: `Agent: ${name}`, walletConfig: { @@ -98,7 +103,7 @@ export function getAgentOptions(name: string, extraConfig: Partial = ...extraConfig, } - return { config, dependencies: agentDependencies } as const + return { config, modules, dependencies: agentDependencies } as const } export function getPostgresAgentOptions(name: string, extraConfig: Partial = {}) { @@ -302,7 +307,9 @@ export function getMockConnection({ export function getMockOutOfBand({ label, serviceEndpoint, - recipientKeys, + recipientKeys = [ + new DidKey(Key.fromPublicKeyBase58('ByHnpUCFb1vAfh9CFZ8ZkmUZguURW8nSw889hy6rD8L7', KeyType.Ed25519)).did, + ], mediatorId, role, state, @@ -330,9 +337,7 @@ export function getMockOutOfBand({ id: `#inline-0`, priority: 0, serviceEndpoint: serviceEndpoint ?? 'http://example.com', - recipientKeys: recipientKeys || [ - new DidKey(Key.fromPublicKeyBase58('ByHnpUCFb1vAfh9CFZ8ZkmUZguURW8nSw889hy6rD8L7', KeyType.Ed25519)).did, - ], + recipientKeys, routingKeys: [], }), ], @@ -345,6 +350,9 @@ export function getMockOutOfBand({ outOfBandInvitation: outOfBandInvitation, reusable, reuseConnectionId, + tags: { + recipientKeyFingerprints: recipientKeys.map((didKey) => DidKey.fromDid(didKey).key.fingerprint), + }, }) return outOfBandRecord } diff --git a/packages/core/tests/oob-mediation.test.ts b/packages/core/tests/oob-mediation.test.ts index 091c62b1ed..b45254f29b 100644 --- a/packages/core/tests/oob-mediation.test.ts +++ b/packages/core/tests/oob-mediation.test.ts @@ -7,6 +7,7 @@ import { SubjectInboundTransport } from '../../../tests/transport/SubjectInbound import { SubjectOutboundTransport } from '../../../tests/transport/SubjectOutboundTransport' import { Agent } from '../src/agent/Agent' import { DidExchangeState, HandshakeProtocol } from '../src/modules/connections' +import { ConnectionType } from '../src/modules/connections/models/ConnectionType' import { MediationState, MediatorPickupStrategy } from '../src/modules/routing' import { getAgentOptions, waitForBasicMessage } from './helpers' @@ -90,8 +91,16 @@ describe('out of band with mediation', () => { mediatorAliceConnection = await mediatorAgent.connections.returnWhenIsConnected(mediatorAliceConnection!.id) expect(mediatorAliceConnection.state).toBe(DidExchangeState.Completed) - // ========== Set meadiation between Alice and Mediator agents ========== + // ========== Set mediation between Alice and Mediator agents ========== const mediationRecord = await aliceAgent.mediationRecipient.requestAndAwaitGrant(aliceMediatorConnection) + const connectonTypes = await aliceAgent.connections.getConnectionTypes(mediationRecord.connectionId) + expect(connectonTypes).toContain(ConnectionType.Mediator) + await aliceAgent.connections.addConnectionType(mediationRecord.connectionId, 'test') + expect(await aliceAgent.connections.getConnectionTypes(mediationRecord.connectionId)).toContain('test') + await aliceAgent.connections.removeConnectionType(mediationRecord.connectionId, 'test') + expect(await aliceAgent.connections.getConnectionTypes(mediationRecord.connectionId)).toEqual([ + ConnectionType.Mediator, + ]) expect(mediationRecord.state).toBe(MediationState.Granted) await aliceAgent.mediationRecipient.setDefaultMediator(mediationRecord) diff --git a/packages/core/tests/oob.test.ts b/packages/core/tests/oob.test.ts index 9172496c09..7a454bf2a4 100644 --- a/packages/core/tests/oob.test.ts +++ b/packages/core/tests/oob.test.ts @@ -42,6 +42,7 @@ describe('out of band', () => { goal: 'To make a connection', goalCode: 'p2p-messaging', label: 'Faber College', + alias: `Faber's connection with Alice`, } const issueCredentialConfig = { @@ -158,10 +159,11 @@ describe('out of band', () => { expect(outOfBandRecord.autoAcceptConnection).toBe(true) expect(outOfBandRecord.role).toBe(OutOfBandRole.Sender) expect(outOfBandRecord.state).toBe(OutOfBandState.AwaitResponse) + expect(outOfBandRecord.alias).toBe(makeConnectionConfig.alias) expect(outOfBandRecord.reusable).toBe(false) - expect(outOfBandRecord.outOfBandInvitation.goal).toBe('To make a connection') - expect(outOfBandRecord.outOfBandInvitation.goalCode).toBe('p2p-messaging') - expect(outOfBandRecord.outOfBandInvitation.label).toBe('Faber College') + expect(outOfBandRecord.outOfBandInvitation.goal).toBe(makeConnectionConfig.goal) + expect(outOfBandRecord.outOfBandInvitation.goalCode).toBe(makeConnectionConfig.goalCode) + expect(outOfBandRecord.outOfBandInvitation.label).toBe(makeConnectionConfig.label) }) test('create OOB message only with handshake', async () => { @@ -172,7 +174,7 @@ describe('out of band', () => { expect(outOfBandInvitation.getRequests()).toBeUndefined() // expect contains services - const [service] = outOfBandInvitation.services as OutOfBandDidCommService[] + const [service] = outOfBandInvitation.getInlineServices() expect(service).toMatchObject( new OutOfBandDidCommService({ id: expect.any(String), @@ -196,7 +198,7 @@ describe('out of band', () => { expect(outOfBandInvitation.getRequests()).toHaveLength(1) // expect contains services - const [service] = outOfBandInvitation.services + const [service] = outOfBandInvitation.getServices() expect(service).toMatchObject( new OutOfBandDidCommService({ id: expect.any(String), @@ -220,7 +222,7 @@ describe('out of band', () => { expect(outOfBandInvitation.getRequests()).toHaveLength(1) // expect contains services - const [service] = outOfBandInvitation.services as OutOfBandDidCommService[] + const [service] = outOfBandInvitation.getInlineServices() expect(service).toMatchObject( new OutOfBandDidCommService({ id: expect.any(String), @@ -293,6 +295,7 @@ describe('out of band', () => { expect(aliceFaberConnection).toBeConnectedWith(faberAliceConnection!) expect(faberAliceConnection).toBeConnectedWith(aliceFaberConnection) + expect(faberAliceConnection.alias).toBe(makeConnectionConfig.alias) }) test(`make a connection with ${HandshakeProtocol.Connections} based on OOB invitation encoded in URL`, async () => { @@ -314,6 +317,7 @@ describe('out of band', () => { expect(aliceFaberConnection).toBeConnectedWith(faberAliceConnection) expect(faberAliceConnection).toBeConnectedWith(aliceFaberConnection) + expect(faberAliceConnection.alias).toBe(makeConnectionConfig.alias) }) test('make a connection based on old connection invitation encoded in URL', async () => { @@ -470,8 +474,8 @@ describe('out of band', () => { const outOfBandRecord2 = await faberAgent.oob.createInvitation(makeConnectionConfig) // Take over the recipientKeys from the first invitation so they match when encoded - const firstInvitationService = outOfBandRecord.outOfBandInvitation.services[0] as OutOfBandDidCommService - const secondInvitationService = outOfBandRecord2.outOfBandInvitation.services[0] as OutOfBandDidCommService + const [firstInvitationService] = outOfBandRecord.outOfBandInvitation.getInlineServices() + const [secondInvitationService] = outOfBandRecord2.outOfBandInvitation.getInlineServices() secondInvitationService.recipientKeys = firstInvitationService.recipientKeys aliceAgent.events.on(OutOfBandEventTypes.HandshakeReused, aliceReuseListener) @@ -689,19 +693,6 @@ describe('out of band', () => { new AriesFrameworkError('There is no message in requests~attach supported by agent.') ) }) - - test('throw an error when a did is used in the out of band message', async () => { - const { message } = await faberAgent.credentials.createOffer(credentialTemplate) - const { outOfBandInvitation } = await faberAgent.oob.createInvitation({ - ...issueCredentialConfig, - messages: [message], - }) - outOfBandInvitation.services = ['somedid'] - - await expect(aliceAgent.oob.receiveInvitation(outOfBandInvitation, receiveInvitationConfig)).rejects.toEqual( - new AriesFrameworkError('Dids are not currently supported in out-of-band invitation services attribute.') - ) - }) }) describe('createLegacyConnectionlessInvitation', () => { diff --git a/packages/core/tests/v1-connectionless-proofs.test.ts b/packages/core/tests/v1-connectionless-proofs.test.ts index 447304229f..1e2c7eb187 100644 --- a/packages/core/tests/v1-connectionless-proofs.test.ts +++ b/packages/core/tests/v1-connectionless-proofs.test.ts @@ -8,7 +8,6 @@ import { Subject, ReplaySubject } from 'rxjs' import { SubjectInboundTransport } from '../../../tests/transport/SubjectInboundTransport' import { SubjectOutboundTransport } from '../../../tests/transport/SubjectOutboundTransport' -import { InjectionSymbols } from '../src' import { Agent } from '../src/agent/Agent' import { Attachment, AttachmentData } from '../src/decorators/attachment/Attachment' import { HandshakeProtocol } from '../src/modules/connections' @@ -25,7 +24,6 @@ import { } from '../src/modules/proofs' import { MediatorPickupStrategy } from '../src/modules/routing' import { LinkedAttachment } from '../src/utils/LinkedAttachment' -import { sleep } from '../src/utils/sleep' import { uuid } from '../src/utils/uuid' import { @@ -398,13 +396,7 @@ describe('Present Proof', () => { await faberProofRecordPromise - // We want to stop the mediator polling before the agent is shutdown. - // FIXME: add a way to stop mediator polling from the public api, and make sure this is - // being handled in the agent shutdown so we don't get any errors with wallets being closed. - const faberStop$ = faberAgent.injectionContainer.resolve>(InjectionSymbols.Stop$) - const aliceStop$ = aliceAgent.injectionContainer.resolve>(InjectionSymbols.Stop$) - faberStop$.next(true) - aliceStop$.next(true) - await sleep(2000) + await aliceAgent.mediationRecipient.stopMessagePickup() + await faberAgent.mediationRecipient.stopMessagePickup() }) }) diff --git a/packages/module-bbs/package.json b/packages/module-bbs/package.json index a80985b584..bed4a703c8 100644 --- a/packages/module-bbs/package.json +++ b/packages/module-bbs/package.json @@ -30,10 +30,10 @@ "@stablelib/random": "^1.0.2" }, "peerDependencies": { - "@aries-framework/core": "0.2.2" + "@aries-framework/core": "0.2.4" }, "devDependencies": { - "@aries-framework/node": "0.2.2", + "@aries-framework/node": "0.2.4", "reflect-metadata": "^0.1.13", "rimraf": "~3.0.2", "typescript": "~4.3.0" diff --git a/packages/module-tenants/package.json b/packages/module-tenants/package.json index 0a26b432f7..dacfdc4142 100644 --- a/packages/module-tenants/package.json +++ b/packages/module-tenants/package.json @@ -24,11 +24,11 @@ "test": "jest" }, "dependencies": { - "@aries-framework/core": "0.2.2", + "@aries-framework/core": "0.2.4", "async-mutex": "^0.3.2" }, "devDependencies": { - "@aries-framework/node": "0.2.2", + "@aries-framework/node": "0.2.4", "reflect-metadata": "^0.1.13", "rimraf": "~3.0.2", "typescript": "~4.3.0" diff --git a/packages/module-tenants/src/TenantsModule.ts b/packages/module-tenants/src/TenantsModule.ts index c96dbf92db..c4948dd4e6 100644 --- a/packages/module-tenants/src/TenantsModule.ts +++ b/packages/module-tenants/src/TenantsModule.ts @@ -1,5 +1,5 @@ import type { TenantsModuleConfigOptions } from './TenantsModuleConfig' -import type { ModulesMap, DependencyManager, Module, EmptyModuleMap, Constructor } from '@aries-framework/core' +import type { Constructor, ModulesMap, DependencyManager, Module, EmptyModuleMap } from '@aries-framework/core' import { InjectionSymbols } from '@aries-framework/core' diff --git a/packages/node/CHANGELOG.md b/packages/node/CHANGELOG.md index 2f19ede363..1a01719007 100644 --- a/packages/node/CHANGELOG.md +++ b/packages/node/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.2.4](https://github.com/hyperledger/aries-framework-javascript/compare/v0.2.3...v0.2.4) (2022-09-10) + +**Note:** Version bump only for package @aries-framework/node + +## [0.2.3](https://github.com/hyperledger/aries-framework-javascript/compare/v0.2.2...v0.2.3) (2022-08-30) + +**Note:** Version bump only for package @aries-framework/node + ## [0.2.2](https://github.com/hyperledger/aries-framework-javascript/compare/v0.2.1...v0.2.2) (2022-07-15) **Note:** Version bump only for package @aries-framework/node diff --git a/packages/node/package.json b/packages/node/package.json index d7cb789c2c..e4faa84365 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -2,7 +2,7 @@ "name": "@aries-framework/node", "main": "build/index", "types": "build/index", - "version": "0.2.2", + "version": "0.2.4", "files": [ "build", "bin" @@ -28,7 +28,7 @@ "test": "jest" }, "dependencies": { - "@aries-framework/core": "0.2.2", + "@aries-framework/core": "0.2.4", "express": "^4.17.1", "ffi-napi": "^4.0.3", "indy-sdk": "^1.16.0-dev-1636", diff --git a/packages/question-answer/package.json b/packages/question-answer/package.json index e4023a2d3d..650edab91e 100644 --- a/packages/question-answer/package.json +++ b/packages/question-answer/package.json @@ -25,14 +25,14 @@ "test": "jest" }, "dependencies": { - "@aries-framework/core": "0.2.2", + "@aries-framework/core": "0.2.4", "rxjs": "^7.2.0", "class-transformer": "0.5.1", "class-validator": "0.13.1" }, "devDependencies": { - "@aries-framework/core": "0.2.2", - "@aries-framework/node": "0.2.2", + "@aries-framework/core": "0.2.4", + "@aries-framework/node": "0.2.4", "reflect-metadata": "^0.1.13", "rimraf": "~3.0.2", "typescript": "~4.3.0" diff --git a/packages/question-answer/src/QuestionAnswerApi.ts b/packages/question-answer/src/QuestionAnswerApi.ts index 2acd0e10b5..52167ebf95 100644 --- a/packages/question-answer/src/QuestionAnswerApi.ts +++ b/packages/question-answer/src/QuestionAnswerApi.ts @@ -1,4 +1,5 @@ -import type { ValidResponse } from './models' +import type { QuestionAnswerRecord } from './repository' +import type { Query } from '@aries-framework/core' import { AgentContext, @@ -10,6 +11,7 @@ import { } from '@aries-framework/core' import { AnswerMessageHandler, QuestionMessageHandler } from './handlers' +import { ValidResponse } from './models' import { QuestionAnswerService } from './services' @injectable() @@ -57,7 +59,7 @@ export class QuestionAnswerApi { connectionId, { question: config.question, - validResponses: config.validResponses, + validResponses: config.validResponses.map((item) => new ValidResponse(item)), detail: config?.detail, } ) @@ -100,6 +102,26 @@ export class QuestionAnswerApi { return this.questionAnswerService.getAll(this.agentContext) } + /** + * Get all QuestionAnswer records by specified query params + * + * @returns list containing all QuestionAnswer records matching specified query params + */ + public findAllByQuery(query: Query) { + return this.questionAnswerService.findAllByQuery(this.agentContext, query) + } + + /** + * Retrieve a question answer record by id + * + * @param questionAnswerId The questionAnswer record id + * @return The question answer record or null if not found + * + */ + public findById(questionAnswerId: string) { + return this.questionAnswerService.findById(this.agentContext, questionAnswerId) + } + private registerHandlers(dispatcher: Dispatcher) { dispatcher.registerHandler(new QuestionMessageHandler(this.questionAnswerService)) dispatcher.registerHandler(new AnswerMessageHandler(this.questionAnswerService)) diff --git a/packages/question-answer/tests/QuestionAnswerModule.test.ts b/packages/question-answer/src/__tests__/QuestionAnswerModule.test.ts similarity index 100% rename from packages/question-answer/tests/QuestionAnswerModule.test.ts rename to packages/question-answer/src/__tests__/QuestionAnswerModule.test.ts diff --git a/packages/question-answer/src/__tests__/QuestionAnswerService.test.ts b/packages/question-answer/src/__tests__/QuestionAnswerService.test.ts index cea8a64986..ae6432ffb9 100644 --- a/packages/question-answer/src/__tests__/QuestionAnswerService.test.ts +++ b/packages/question-answer/src/__tests__/QuestionAnswerService.test.ts @@ -1,10 +1,18 @@ import type { AgentConfig, AgentContext, Repository } from '@aries-framework/core' import type { QuestionAnswerStateChangedEvent, ValidResponse } from '@aries-framework/question-answer' -import { EventEmitter, IndyWallet, SigningProviderRegistry } from '@aries-framework/core' +import { + EventEmitter, + IndyWallet, + SigningProviderRegistry, + InboundMessageContext, + DidExchangeState, +} from '@aries-framework/core' import { agentDependencies } from '@aries-framework/node' import { Subject } from 'rxjs' +import { mockFunction } from '../../../core/tests/helpers' + import { getAgentConfig, getAgentContext, getMockConnection } from './utils' import { @@ -15,6 +23,7 @@ import { QuestionAnswerService, QuestionAnswerState, QuestionMessage, + AnswerMessage, } from '@aries-framework/question-answer' jest.mock('../repository/QuestionAnswerRepository') @@ -24,6 +33,7 @@ describe('QuestionAnswerService', () => { const mockConnectionRecord = getMockConnection({ id: 'd3849ac3-c981-455b-a1aa-a10bea6cead8', did: 'did:sov:C2SsBf5QUQpqSAQfhu3sd2', + state: DidExchangeState.Completed, }) let wallet: IndyWallet @@ -138,7 +148,7 @@ describe('QuestionAnswerService', () => { eventListenerMock ) - jest.fn(questionAnswerRepository.getSingleByQuery).mockReturnValue(Promise.resolve(mockRecord)) + mockFunction(questionAnswerRepository.findSingleByQuery).mockReturnValue(Promise.resolve(mockRecord)) await questionAnswerService.createAnswer(agentContext, mockRecord, 'Yes') @@ -159,4 +169,165 @@ describe('QuestionAnswerService', () => { }) }) }) + + describe('processReceiveQuestion', () => { + let mockRecord: QuestionAnswerRecord + + beforeAll(() => { + mockRecord = mockQuestionAnswerRecord({ + questionText: 'Alice, are you on the phone with Bob?', + connectionId: mockConnectionRecord.id, + role: QuestionAnswerRole.Responder, + signatureRequired: false, + state: QuestionAnswerState.QuestionReceived, + threadId: '123', + validResponses: [{ text: 'Yes' }, { text: 'No' }], + }) + }) + + it('creates record when no previous question with that thread exists', async () => { + const questionMessage = new QuestionMessage({ + questionText: 'Alice, are you on the phone with Bob?', + validResponses: [{ text: 'Yes' }, { text: 'No' }], + }) + + const messageContext = new InboundMessageContext(questionMessage, { + agentContext, + connection: mockConnectionRecord, + }) + + const questionAnswerRecord = await questionAnswerService.processReceiveQuestion(messageContext) + + expect(questionAnswerRecord).toMatchObject( + expect.objectContaining({ + role: QuestionAnswerRole.Responder, + state: QuestionAnswerState.QuestionReceived, + threadId: questionMessage.id, + questionText: 'Alice, are you on the phone with Bob?', + validResponses: [{ text: 'Yes' }, { text: 'No' }], + }) + ) + }) + + it(`throws an error when question from the same thread exists `, async () => { + mockFunction(questionAnswerRepository.findSingleByQuery).mockReturnValue(Promise.resolve(mockRecord)) + + const questionMessage = new QuestionMessage({ + id: '123', + questionText: 'Alice, are you on the phone with Bob?', + validResponses: [{ text: 'Yes' }, { text: 'No' }], + }) + + const messageContext = new InboundMessageContext(questionMessage, { + agentContext, + connection: mockConnectionRecord, + }) + + expect(questionAnswerService.processReceiveQuestion(messageContext)).rejects.toThrowError( + `Question answer record with thread Id ${questionMessage.id} already exists.` + ) + jest.resetAllMocks() + }) + }) + + describe('receiveAnswer', () => { + let mockRecord: QuestionAnswerRecord + + beforeAll(() => { + mockRecord = mockQuestionAnswerRecord({ + questionText: 'Alice, are you on the phone with Bob?', + connectionId: mockConnectionRecord.id, + role: QuestionAnswerRole.Questioner, + signatureRequired: false, + state: QuestionAnswerState.QuestionReceived, + threadId: '123', + validResponses: [{ text: 'Yes' }, { text: 'No' }], + }) + }) + + it('updates state and emits event when valid response is received', async () => { + mockRecord.state = QuestionAnswerState.QuestionSent + mockFunction(questionAnswerRepository.findSingleByQuery).mockReturnValue(Promise.resolve(mockRecord)) + + const answerMessage = new AnswerMessage({ + response: 'Yes', + threadId: '123', + }) + + const messageContext = new InboundMessageContext(answerMessage, { + agentContext, + connection: mockConnectionRecord, + }) + + const questionAnswerRecord = await questionAnswerService.receiveAnswer(messageContext) + + expect(questionAnswerRecord).toMatchObject( + expect.objectContaining({ + role: QuestionAnswerRole.Questioner, + state: QuestionAnswerState.AnswerReceived, + threadId: '123', + questionText: 'Alice, are you on the phone with Bob?', + validResponses: [{ text: 'Yes' }, { text: 'No' }], + }) + ) + jest.resetAllMocks() + }) + + it(`throws an error when no existing question is found`, async () => { + const answerMessage = new AnswerMessage({ + response: 'Yes', + threadId: '123', + }) + + const messageContext = new InboundMessageContext(answerMessage, { + agentContext, + connection: mockConnectionRecord, + }) + + expect(questionAnswerService.receiveAnswer(messageContext)).rejects.toThrowError( + `Question Answer record with thread Id ${answerMessage.threadId} not found.` + ) + }) + + it(`throws an error when record is in invalid state`, async () => { + mockRecord.state = QuestionAnswerState.AnswerReceived + mockFunction(questionAnswerRepository.findSingleByQuery).mockReturnValue(Promise.resolve(mockRecord)) + + const answerMessage = new AnswerMessage({ + response: 'Yes', + threadId: '123', + }) + + const messageContext = new InboundMessageContext(answerMessage, { + agentContext, + connection: mockConnectionRecord, + }) + + expect(questionAnswerService.receiveAnswer(messageContext)).rejects.toThrowError( + `Question answer record is in invalid state ${mockRecord.state}. Valid states are: ${QuestionAnswerState.QuestionSent}` + ) + jest.resetAllMocks() + }) + + it(`throws an error when record is in invalid role`, async () => { + mockRecord.state = QuestionAnswerState.QuestionSent + mockRecord.role = QuestionAnswerRole.Responder + mockFunction(questionAnswerRepository.findSingleByQuery).mockReturnValue(Promise.resolve(mockRecord)) + + const answerMessage = new AnswerMessage({ + response: 'Yes', + threadId: '123', + }) + + const messageContext = new InboundMessageContext(answerMessage, { + agentContext, + connection: mockConnectionRecord, + }) + + expect(questionAnswerService.receiveAnswer(messageContext)).rejects.toThrowError( + `Invalid question answer record role ${mockRecord.role}, expected is ${QuestionAnswerRole.Questioner}` + ) + }) + jest.resetAllMocks() + }) }) diff --git a/packages/question-answer/src/repository/QuestionAnswerRecord.ts b/packages/question-answer/src/repository/QuestionAnswerRecord.ts index 5763787bbe..58175470fd 100644 --- a/packages/question-answer/src/repository/QuestionAnswerRecord.ts +++ b/packages/question-answer/src/repository/QuestionAnswerRecord.ts @@ -74,6 +74,12 @@ export class QuestionAnswerRecord extends BaseRecord { + return this.questionAnswerRepository.findSingleByQuery(agentContext, { + connectionId, + threadId, + }) + } + + /** + * Retrieve a question answer record by id * * @param questionAnswerId The questionAnswer record id * @throws {RecordNotFoundError} If no record is found - * @return The connection record + * @return The question answer record * */ public getById(agentContext: AgentContext, questionAnswerId: string): Promise { @@ -243,15 +267,28 @@ export class QuestionAnswerService { } /** - * Retrieve all QuestionAnswer records + * Retrieve a question answer record by id + * + * @param questionAnswerId The questionAnswer record id + * @return The question answer record or null if not found + * + */ + public findById(agentContext: AgentContext, questionAnswerId: string): Promise { + return this.questionAnswerRepository.findById(agentContext, questionAnswerId) + } + + /** + * Retrieve a question answer record by id + * + * @param questionAnswerId The questionAnswer record id + * @return The question answer record or null if not found * - * @returns List containing all QuestionAnswer records */ public getAll(agentContext: AgentContext) { return this.questionAnswerRepository.getAll(agentContext) } - public async findAllByQuery(agentContext: AgentContext, query: Partial) { + public async findAllByQuery(agentContext: AgentContext, query: Query) { return this.questionAnswerRepository.findByQuery(agentContext, query) } } diff --git a/packages/question-answer/tests/helpers.ts b/packages/question-answer/tests/helpers.ts new file mode 100644 index 0000000000..cd2c9cc9e5 --- /dev/null +++ b/packages/question-answer/tests/helpers.ts @@ -0,0 +1,66 @@ +import type { Agent } from '@aries-framework/core' +import type { + QuestionAnswerRole, + QuestionAnswerState, + QuestionAnswerStateChangedEvent, +} from '@aries-framework/question-answer' +import type { Observable } from 'rxjs' + +import { catchError, filter, firstValueFrom, map, ReplaySubject, timeout } from 'rxjs' + +import { QuestionAnswerEventTypes } from '@aries-framework/question-answer' + +export async function waitForQuestionAnswerRecord( + agent: Agent, + options: { + threadId?: string + role?: QuestionAnswerRole + state?: QuestionAnswerState + previousState?: QuestionAnswerState | null + timeoutMs?: number + } +) { + const observable = agent.events.observable( + QuestionAnswerEventTypes.QuestionAnswerStateChanged + ) + + return waitForQuestionAnswerRecordSubject(observable, options) +} + +export function waitForQuestionAnswerRecordSubject( + subject: ReplaySubject | Observable, + { + threadId, + role, + state, + previousState, + timeoutMs = 10000, + }: { + threadId?: string + role?: QuestionAnswerRole + state?: QuestionAnswerState + previousState?: QuestionAnswerState | null + timeoutMs?: number + } +) { + const observable = subject instanceof ReplaySubject ? subject.asObservable() : subject + return firstValueFrom( + observable.pipe( + filter((e) => previousState === undefined || e.payload.previousState === previousState), + filter((e) => threadId === undefined || e.payload.questionAnswerRecord.threadId === threadId), + filter((e) => role === undefined || e.payload.questionAnswerRecord.role === role), + filter((e) => state === undefined || e.payload.questionAnswerRecord.state === state), + timeout(timeoutMs), + catchError(() => { + throw new Error( + `QuestionAnswerChangedEvent event not emitted within specified timeout: { + previousState: ${previousState}, + threadId: ${threadId}, + state: ${state} + }` + ) + }), + map((e) => e.payload.questionAnswerRecord) + ) + ) +} diff --git a/packages/question-answer/tests/question-answer.e2e.test.ts b/packages/question-answer/tests/question-answer.e2e.test.ts new file mode 100644 index 0000000000..e8d6d5a0c3 --- /dev/null +++ b/packages/question-answer/tests/question-answer.e2e.test.ts @@ -0,0 +1,109 @@ +import type { SubjectMessage } from '../../../tests/transport/SubjectInboundTransport' +import type { ConnectionRecord } from '@aries-framework/core' + +import { Agent } from '@aries-framework/core' +import { Subject } from 'rxjs' + +import { SubjectInboundTransport } from '../../../tests/transport/SubjectInboundTransport' +import { SubjectOutboundTransport } from '../../../tests/transport/SubjectOutboundTransport' +import { getAgentOptions, makeConnection } from '../../core/tests/helpers' +import testLogger from '../../core/tests/logger' + +import { waitForQuestionAnswerRecord } from './helpers' + +import { QuestionAnswerModule, QuestionAnswerRole, QuestionAnswerState } from '@aries-framework/question-answer' + +const bobAgentOptions = getAgentOptions( + 'Bob Question Answer', + { + endpoints: ['rxjs:bob'], + }, + { + questionAnswer: new QuestionAnswerModule(), + } +) + +const aliceAgentOptions = getAgentOptions( + 'Alice Question Answer', + { + endpoints: ['rxjs:alice'], + }, + { + questionAnswer: new QuestionAnswerModule(), + } +) + +describe('Question Answer', () => { + let bobAgent: Agent<{ + questionAnswer: QuestionAnswerModule + }> + let aliceAgent: Agent<{ + questionAnswer: QuestionAnswerModule + }> + let aliceConnection: ConnectionRecord + + beforeEach(async () => { + const bobMessages = new Subject() + const aliceMessages = new Subject() + const subjectMap = { + 'rxjs:bob': bobMessages, + 'rxjs:alice': aliceMessages, + } + + bobAgent = new Agent(bobAgentOptions) + bobAgent.registerInboundTransport(new SubjectInboundTransport(bobMessages)) + bobAgent.registerOutboundTransport(new SubjectOutboundTransport(subjectMap)) + await bobAgent.initialize() + + aliceAgent = new Agent(aliceAgentOptions) + + aliceAgent.registerInboundTransport(new SubjectInboundTransport(aliceMessages)) + aliceAgent.registerOutboundTransport(new SubjectOutboundTransport(subjectMap)) + await aliceAgent.initialize() + ;[aliceConnection] = await makeConnection(aliceAgent, bobAgent) + }) + + afterEach(async () => { + await bobAgent.shutdown() + await bobAgent.wallet.delete() + await aliceAgent.shutdown() + await aliceAgent.wallet.delete() + }) + + test('Alice sends a question and Bob answers', async () => { + testLogger.test('Alice sends question to Bob') + let aliceQuestionAnswerRecord = await aliceAgent.modules.questionAnswer.sendQuestion(aliceConnection.id, { + question: 'Do you want to play?', + validResponses: [{ text: 'Yes' }, { text: 'No' }], + }) + + testLogger.test('Bob waits for question from Alice') + const bobQuestionAnswerRecord = await waitForQuestionAnswerRecord(bobAgent, { + threadId: aliceQuestionAnswerRecord.threadId, + state: QuestionAnswerState.QuestionReceived, + }) + + expect(bobQuestionAnswerRecord.questionText).toEqual('Do you want to play?') + expect(bobQuestionAnswerRecord.validResponses).toEqual([{ text: 'Yes' }, { text: 'No' }]) + testLogger.test('Bob sends answer to Alice') + await bobAgent.modules.questionAnswer.sendAnswer(bobQuestionAnswerRecord.id, 'Yes') + + testLogger.test('Alice waits until Bob answers') + aliceQuestionAnswerRecord = await waitForQuestionAnswerRecord(aliceAgent, { + threadId: aliceQuestionAnswerRecord.threadId, + state: QuestionAnswerState.AnswerReceived, + }) + + expect(aliceQuestionAnswerRecord.response).toEqual('Yes') + + const retrievedRecord = await aliceAgent.modules.questionAnswer.findById(aliceQuestionAnswerRecord.id) + expect(retrievedRecord).toMatchObject( + expect.objectContaining({ + id: aliceQuestionAnswerRecord.id, + threadId: aliceQuestionAnswerRecord.threadId, + state: QuestionAnswerState.AnswerReceived, + role: QuestionAnswerRole.Questioner, + }) + ) + }) +}) diff --git a/packages/react-native/CHANGELOG.md b/packages/react-native/CHANGELOG.md index 5035d17fab..d693eee5df 100644 --- a/packages/react-native/CHANGELOG.md +++ b/packages/react-native/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.2.4](https://github.com/hyperledger/aries-framework-javascript/compare/v0.2.3...v0.2.4) (2022-09-10) + +**Note:** Version bump only for package @aries-framework/react-native + +## [0.2.3](https://github.com/hyperledger/aries-framework-javascript/compare/v0.2.2...v0.2.3) (2022-08-30) + +**Note:** Version bump only for package @aries-framework/react-native + ## [0.2.2](https://github.com/hyperledger/aries-framework-javascript/compare/v0.2.1...v0.2.2) (2022-07-15) **Note:** Version bump only for package @aries-framework/react-native diff --git a/packages/react-native/package.json b/packages/react-native/package.json index e350c12d6d..bd8067a326 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -2,7 +2,7 @@ "name": "@aries-framework/react-native", "main": "build/index", "types": "build/index", - "version": "0.2.2", + "version": "0.2.4", "files": [ "build" ], @@ -24,7 +24,7 @@ "test": "jest" }, "dependencies": { - "@aries-framework/core": "0.2.2", + "@aries-framework/core": "0.2.4", "@azure/core-asynciterator-polyfill": "^1.0.0", "events": "^3.3.0" }, diff --git a/samples/extension-module/dummy/DummyApi.ts b/samples/extension-module/dummy/DummyApi.ts index 82ac700f7b..eeb50b469b 100644 --- a/samples/extension-module/dummy/DummyApi.ts +++ b/samples/extension-module/dummy/DummyApi.ts @@ -1,4 +1,5 @@ import type { DummyRecord } from './repository/DummyRecord' +import type { Query } from '@aries-framework/core' import { AgentContext, ConnectionService, Dispatcher, injectable, MessageSender } from '@aries-framework/core' @@ -73,6 +74,15 @@ export class DummyApi { return this.dummyService.getAll(this.agentContext) } + /** + * Retrieve all dummy records + * + * @returns List containing all records + */ + public findAllByQuery(query: Query): Promise { + return this.dummyService.findAllByQuery(this.agentContext, query) + } + private registerHandlers(dispatcher: Dispatcher) { dispatcher.registerHandler(new DummyRequestHandler(this.dummyService)) dispatcher.registerHandler(new DummyResponseHandler(this.dummyService)) diff --git a/samples/extension-module/dummy/services/DummyService.ts b/samples/extension-module/dummy/services/DummyService.ts index 2defd9d393..22b99fc399 100644 --- a/samples/extension-module/dummy/services/DummyService.ts +++ b/samples/extension-module/dummy/services/DummyService.ts @@ -1,5 +1,5 @@ import type { DummyStateChangedEvent } from './DummyEvents' -import type { AgentContext, ConnectionRecord, InboundMessageContext } from '@aries-framework/core' +import type { Query, AgentContext, ConnectionRecord, InboundMessageContext } from '@aries-framework/core' import { injectable, JsonTransformer, EventEmitter } from '@aries-framework/core' @@ -119,6 +119,15 @@ export class DummyService { return this.dummyRepository.getAll(agentContext) } + /** + * Retrieve dummy records by query + * + * @returns List containing all dummy records matching query + */ + public findAllByQuery(agentContext: AgentContext, query: Query): Promise { + return this.dummyRepository.findByQuery(agentContext, query) + } + /** * Retrieve a dummy record by id * diff --git a/tests/e2e-test.ts b/tests/e2e-test.ts index 7c42b6a13d..86fb6dfe83 100644 --- a/tests/e2e-test.ts +++ b/tests/e2e-test.ts @@ -1,11 +1,9 @@ import type { Agent } from '@aries-framework/core' -import type { Subject } from 'rxjs' import { sleep } from '../packages/core/src/utils/sleep' import { issueCredential, makeConnection, prepareForIssuance, presentProof } from '../packages/core/tests/helpers' import { - InjectionSymbols, V1CredentialPreview, AttributeFilter, CredentialState, @@ -95,9 +93,6 @@ export async function e2eTest({ expect(verifierProof.state).toBe(ProofState.Done) // We want to stop the mediator polling before the agent is shutdown. - // FIXME: add a way to stop mediator polling from the public api, and make sure this is - // being handled in the agent shutdown so we don't get any errors with wallets being closed. - const recipientStop$ = recipientAgent.injectionContainer.resolve>(InjectionSymbols.Stop$) - recipientStop$.next(true) + await recipientAgent.mediationRecipient.stopMessagePickup() await sleep(2000) } diff --git a/yarn.lock b/yarn.lock index 700134c89b..9187c4a305 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2281,14 +2281,6 @@ "@stablelib/binary" "^1.0.1" "@stablelib/wipe" "^1.0.1" -"@stablelib/random@^1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@stablelib/random/-/random-1.0.2.tgz#2dece393636489bf7e19c51229dd7900eddf742c" - integrity sha512-rIsE83Xpb7clHPVRlBj8qNe5L8ISQOzjghYQm/dZ7VaM2KHYwMW5adjQjrzTZCchFnNCNhkwtnOBa9HTMJCI8w== - dependencies: - "@stablelib/binary" "^1.0.1" - "@stablelib/wipe" "^1.0.1" - "@stablelib/sha256@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@stablelib/sha256/-/sha256-1.0.1.tgz#77b6675b67f9b0ea081d2e31bda4866297a3ae4f"