diff --git a/apps/shared/src/events/credentialEvents.ts b/apps/shared/src/events/credentialEvents.ts index e52cdbf812ad908da705d976dbf5eaab48be7ad3..a3980307cdaf6f99c66dbd35a8069cfafb6e1a13 100644 --- a/apps/shared/src/events/credentialEvents.ts +++ b/apps/shared/src/events/credentialEvents.ts @@ -50,6 +50,27 @@ export class EventAnonCredsCredentialsGetById extends BaseEvent<CredentialExchan } } +export type EventDidcommAnonCredsCredentialsAcceptOfferInput = BaseEventInput<{ + credentialId: string; +}>; +export class EventDidcommAnonCredsCredentialsAcceptOffer extends BaseEvent<CredentialExchangeRecord> { + public static token = 'didcomm.anoncreds.credentials.acceptOffer'; + + public get instance() { + return JsonTransformer.fromJSON(this.data, CredentialExchangeRecord); + } + + public static fromEvent(e: EventDidcommAnonCredsCredentialsOffer) { + return new EventDidcommAnonCredsCredentialsOffer( + e.data, + e.tenantId, + e.id, + e.type, + e.timestamp, + ); + } +} + export type EventDidcommAnonCredsCredentialsOfferInput = BaseEventInput<{ connectionId: string; credentialDefinitionId: string; diff --git a/apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.controller.ts b/apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.controller.ts index b4a7841072c40c3f344c31e35f09e6b8f1ffe9ec..963794535c06317035b136e331ee1883c053389e 100644 --- a/apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.controller.ts +++ b/apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.controller.ts @@ -15,6 +15,8 @@ import { EventAnonCredsCredentialsGetAllInput, EventAnonCredsCredentialsGetById, EventAnonCredsCredentialsGetByIdInput, + EventDidcommAnonCredsCredentialsAcceptOffer, + EventDidcommAnonCredsCredentialsAcceptOfferInput, EventDidcommAnonCredsCredentialsOffer, EventDidcommAnonCredsCredentialsOfferInput, EventDidcommAnonCredsCredentialsOfferToSelf, @@ -97,6 +99,16 @@ export class AnonCredsCredentialsController { ); } + @MessagePattern(EventDidcommAnonCredsCredentialsAcceptOffer.token) + public async acceptOffer( + options: EventDidcommAnonCredsCredentialsAcceptOfferInput, + ): Promise<EventDidcommAnonCredsCredentialsAcceptOffer> { + return new EventDidcommAnonCredsCredentialsAcceptOffer( + await this.credentialsService.acceptOffer(options), + options.tenantId, + ); + } + @MessagePattern(EventDidcommAnonCredsCredentialsOffer.token) public async offer( options: EventDidcommAnonCredsCredentialsOfferInput, diff --git a/apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.service.ts b/apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.service.ts index 72fa3311d28cf1149d297781a35d75a3b754466a..8c92094f8027661c2b4d0b86ae8c1a0831dce147 100644 --- a/apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.service.ts +++ b/apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.service.ts @@ -1,5 +1,8 @@ import type { TenantAgent } from '../agent.service.js'; -import type { CredentialExchangeRecord } from '@credo-ts/core'; +import type { + CredentialExchangeRecord, + CredentialStateChangedEvent, +} from '@credo-ts/core'; import type { EventAnonCredsCredentialOfferGetAll, EventAnonCredsCredentialOfferGetAllInput, @@ -13,13 +16,19 @@ import type { EventAnonCredsCredentialsDeleteByIdInput, EventAnonCredsCredentialsGetAllInput, EventAnonCredsCredentialsGetByIdInput, + EventDidcommAnonCredsCredentialsAcceptOffer, + EventDidcommAnonCredsCredentialsAcceptOfferInput, EventDidcommAnonCredsCredentialsOffer, EventDidcommAnonCredsCredentialsOfferInput, EventDidcommAnonCredsCredentialsOfferToSelf, EventDidcommAnonCredsCredentialsOfferToSelfInput, } from '@ocm/shared'; -import { AutoAcceptCredential, CredentialState } from '@credo-ts/core'; +import { + AutoAcceptCredential, + CredentialEventTypes, + CredentialState, +} from '@credo-ts/core'; import { GenericRecord } from '@credo-ts/core/build/modules/generic-records/repository/GenericRecord.js'; import { Injectable } from '@nestjs/common'; import { logger } from '@ocm/shared'; @@ -138,6 +147,19 @@ export class AnonCredsCredentialsService { }); } + public async acceptOffer({ + tenantId, + credentialId, + }: EventDidcommAnonCredsCredentialsAcceptOfferInput): Promise< + EventDidcommAnonCredsCredentialsAcceptOffer['data'] + > { + return this.withTenantService.invoke(tenantId, (t) => + t.credentials.acceptOffer({ + credentialRecordId: credentialId, + }), + ); + } + public async offer({ tenantId, connectionId, @@ -193,7 +215,36 @@ export class AnonCredsCredentialsService { const revocationRegistryIndex = await this.getNextRevocationIdx(t); - return t.credentials.offerCredential({ + const acceptOfferListener: Promise<CredentialExchangeRecord> = + new Promise((resolve) => + t.events.on<CredentialStateChangedEvent>( + CredentialEventTypes.CredentialStateChanged, + async ({ payload: { credentialRecord } }) => { + const connection = connections.find( + (c) => c.id === credentialRecord.connectionId, + ); + + const withSelf = connection?.metadata.get<{ withSelf: boolean }>( + MetadataTokens.CONNECTION_METADATA_KEY, + ); + + const isWithSelf = withSelf?.withSelf ?? false; + + if ( + credentialRecord.state === CredentialState.OfferReceived && + isWithSelf + ) { + resolve( + await t.credentials.acceptOffer({ + credentialRecordId: credentialRecord.id, + }), + ); + } + }, + ), + ); + + await t.credentials.offerCredential({ protocolVersion: 'v2', autoAcceptCredential: AutoAcceptCredential.Always, connectionId: connection.id, @@ -206,6 +257,8 @@ export class AnonCredsCredentialsService { }, }, }); + + return acceptOfferListener; }); } diff --git a/apps/ssi-abstraction/src/agent/connections/connections.service.ts b/apps/ssi-abstraction/src/agent/connections/connections.service.ts index 1b6f8c4d84531aea50a7b4700a1cafb71b05815a..7ee1261a4e037a81b21b9f722ac33f4d8f3912b5 100644 --- a/apps/ssi-abstraction/src/agent/connections/connections.service.ts +++ b/apps/ssi-abstraction/src/agent/connections/connections.service.ts @@ -145,7 +145,16 @@ export class ConnectionsService { const outOfBandRecord = await t.oob.createInvitation(); const invitation = outOfBandRecord.outOfBandInvitation; - void t.oob.receiveInvitation(invitation); + const { connectionRecord } = await t.oob.receiveInvitation(invitation); + + if (connectionRecord) { + connectionRecord.metadata.set(MetadataTokens.CONNECTION_METADATA_KEY, { + trusted: true, + withSelf: true, + }); + const connRepo = t.dependencyManager.resolve(ConnectionRepository); + await connRepo.update(t.context, connectionRecord); + } return new Promise((resolve) => this.agent.events.on<ConnectionStateChangedEvent>( diff --git a/apps/ssi-abstraction/test/anoncredsCredentials.e2e-spec.ts b/apps/ssi-abstraction/test/anoncredsCredentials.e2e-spec.ts index 672778b11dc0f226e4c035975d43ec3c9cd32f62..e90864892823e43c0af05a087c5e4c3ee1486b2f 100644 --- a/apps/ssi-abstraction/test/anoncredsCredentials.e2e-spec.ts +++ b/apps/ssi-abstraction/test/anoncredsCredentials.e2e-spec.ts @@ -1,28 +1,36 @@ import type { INestApplication } from '@nestjs/common'; import type { ClientProxy } from '@nestjs/microservices'; import type { - EventAnonCredsCredentialRequestGetAllInput, - EventAnonCredsCredentialsGetAllInput, - EventAnonCredsCredentialsGetByIdInput, - EventDidcommAnonCredsCredentialsOfferToSelfInput, EventAnonCredsCredentialOfferGetAllInput, EventAnonCredsCredentialOfferGetByIdInput, + EventAnonCredsCredentialRequestGetAllInput, EventAnonCredsCredentialRequestGetByIdInput, EventAnonCredsCredentialsDeleteByIdInput, + EventAnonCredsCredentialsGetAllInput, + EventAnonCredsCredentialsGetByIdInput, + EventDidcommAnonCredsCredentialsAcceptOffer, + EventDidcommAnonCredsCredentialsAcceptOfferInput, + EventDidcommAnonCredsCredentialsOfferInput, + EventDidcommAnonCredsCredentialsOfferToSelfInput, } from '@ocm/shared'; -import { AutoAcceptCredential, CredentialExchangeRecord } from '@credo-ts/core'; +import { + AutoAcceptCredential, + CredentialExchangeRecord, + CredentialState, +} from '@credo-ts/core'; import { ClientsModule, Transport } from '@nestjs/microservices'; import { Test } from '@nestjs/testing'; import { - EventAnonCredsCredentialsDeleteById, EventAnonCredsCredentialOfferGetAll, EventAnonCredsCredentialOfferGetById, EventAnonCredsCredentialRequestGetAll, EventAnonCredsCredentialRequestGetById, + EventAnonCredsCredentialsDeleteById, EventAnonCredsCredentialsGetAll, EventAnonCredsCredentialsGetById, EventAnonCredsProofsDeleteById, + EventDidcommAnonCredsCredentialsOffer, EventDidcommAnonCredsCredentialsOfferToSelf, } from '@ocm/shared'; import { randomBytes } from 'crypto'; @@ -50,6 +58,7 @@ describe('Credentials', () => { let issuerDid: string; let credentialDefinitionId: string; + let connectionId: string; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ @@ -111,6 +120,18 @@ describe('Credentials', () => { }); credentialDefinitionId = cdi; + + const connectionService = app.get(ConnectionsService); + const { invitationUrl } = await connectionService.createInvitation({ + tenantId, + }); + + const { id: cId } = await connectionService.receiveInvitationFromUrl({ + tenantId, + invitationUrl, + }); + + connectionId = cId; }); afterAll(async () => { @@ -197,6 +218,45 @@ describe('Credentials', () => { expect(eventInstance.instance).toEqual(null); }); + it(EventDidcommAnonCredsCredentialsOffer.token, async () => { + const attributes = [ + { name: 'Name', value: 'Berend' }, + { name: 'Age', value: '25' }, + ]; + + const response$ = client.send< + EventDidcommAnonCredsCredentialsOffer, + EventDidcommAnonCredsCredentialsOfferInput + >(EventDidcommAnonCredsCredentialsOffer.token, { + tenantId, + connectionId, + attributes, + credentialDefinitionId, + }); + + const response = await firstValueFrom(response$); + const eventInstance = + EventDidcommAnonCredsCredentialsOffer.fromEvent(response); + + await new Promise((r) => setTimeout(r, 2000)); + + const acceptResponse$ = client.send< + EventDidcommAnonCredsCredentialsAcceptOffer, + EventDidcommAnonCredsCredentialsAcceptOfferInput + >(EventDidcommAnonCredsCredentialsOffer.token, { + tenantId, + credentialId: eventInstance.instance.id, + }); + + const acceptResponse = await firstValueFrom(acceptResponse$); + const acceptEventInstance = + EventAnonCredsCredentialsGetById.fromEvent(acceptResponse); + + expect(acceptEventInstance.instance).toMatchObject({ + state: CredentialState.RequestSent, + }); + }); + it(EventDidcommAnonCredsCredentialsOfferToSelf.token, async () => { const attributes = [ { name: 'Name', value: 'Berend' },