From 1190cb4e455c97425d46f603329cd028a7fb9177 Mon Sep 17 00:00:00 2001
From: Berend Sliedrecht <berend@animo.id>
Date: Mon, 11 Dec 2023 14:50:46 +0100
Subject: [PATCH] feat(ssi): credentials module

Signed-off-by: Berend Sliedrecht <berend@animo.id>
---
 apps/shared/src/events/credentialEvents.ts    |  99 +++++++++++
 apps/shared/src/index.ts                      |   1 +
 .../src/agent/agent.service.ts                |  15 +-
 .../anoncredsCredentials.controller.spec.ts   |  98 +++++++++++
 .../anoncredsCredentials.controller.ts        |  59 +++++++
 .../anoncredsCredentials.module.ts            |  13 ++
 .../anoncredsCredentials.service.ts           |  91 ++++++++++
 .../__tests__/connections.controller.spec.ts  |  17 +-
 .../agent/connections/connections.service.ts  |   7 +-
 .../credentialDefinitions.service.ts          |   6 +-
 .../src/agent/dids/dids.service.ts            |  12 +-
 .../src/agent/schemas/schemas.service.ts      |   6 +-
 .../src/agent/tenants/tenants.service.ts      |   2 +-
 apps/ssi-abstraction/src/app.module.ts        |   2 +
 .../test/anoncredsCredentials.e2e-spec.ts     | 157 ++++++++++++++++++
 apps/ssi-abstraction/test/jest.config.js      |   2 +-
 16 files changed, 552 insertions(+), 35 deletions(-)
 create mode 100644 apps/shared/src/events/credentialEvents.ts
 create mode 100644 apps/ssi-abstraction/src/agent/anoncredsCredentials/__tests__/anoncredsCredentials.controller.spec.ts
 create mode 100644 apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.controller.ts
 create mode 100644 apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.module.ts
 create mode 100644 apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.service.ts
 create mode 100644 apps/ssi-abstraction/test/anoncredsCredentials.e2e-spec.ts

diff --git a/apps/shared/src/events/credentialEvents.ts b/apps/shared/src/events/credentialEvents.ts
new file mode 100644
index 0000000..32ed92a
--- /dev/null
+++ b/apps/shared/src/events/credentialEvents.ts
@@ -0,0 +1,99 @@
+import type { BaseEventInput } from './baseEvents.js';
+
+import {
+  CredentialExchangeRecord,
+  JsonTransformer,
+} from '@aries-framework/core';
+
+import { BaseEvent } from './baseEvents.js';
+
+export type EventDidcommAnonCredsCredentialsGetAllInput = BaseEventInput;
+export class EventDidcommAnonCredsCredentialsGetAll extends BaseEvent<
+  Array<CredentialExchangeRecord>
+> {
+  public static token = 'didcomm.anoncreds.credentials.getAll';
+
+  public get instance() {
+    return this.data.map((d) =>
+      JsonTransformer.fromJSON(d, CredentialExchangeRecord),
+    );
+  }
+
+  public static fromEvent(e: EventDidcommAnonCredsCredentialsGetAll) {
+    return new EventDidcommAnonCredsCredentialsGetAll(
+      e.data,
+      e.tenantId,
+      e.id,
+      e.type,
+      e.timestamp,
+    );
+  }
+}
+
+export type EventDidcommAnonCredsCredentialsGetByIdInput = BaseEventInput<{
+  credentialRecordId: string;
+}>;
+export class EventDidcommAnonCredsCredentialsGetById extends BaseEvent<CredentialExchangeRecord | null> {
+  public static token = 'didcomm.anoncreds.credentials.getById';
+
+  public get instance() {
+    return this.data
+      ? JsonTransformer.fromJSON(this.data, CredentialExchangeRecord)
+      : null;
+  }
+
+  public static fromEvent(e: EventDidcommAnonCredsCredentialsGetById) {
+    return new EventDidcommAnonCredsCredentialsGetById(
+      e.data,
+      e.tenantId,
+      e.id,
+      e.type,
+      e.timestamp,
+    );
+  }
+}
+
+export type EventDidcommAnonCredsCredentialsOfferInput = BaseEventInput<{
+  connectionId: string;
+  credentialDefinitionId: string;
+  attributes: Array<{ name: string; value: string; mimeType?: string }>;
+}>;
+export class EventDidcommAnonCredsCredentialsOffer extends BaseEvent<CredentialExchangeRecord> {
+  public static token = 'didcomm.anoncreds.credentials.offer';
+
+  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 EventDidcommAnonCredsCredentialsOfferToSelfInput = Omit<
+  EventDidcommAnonCredsCredentialsOfferInput,
+  'connectionId'
+>;
+export class EventDidcommAnonCredsCredentialsOfferToSelf extends BaseEvent<CredentialExchangeRecord> {
+  public static token = 'didcomm.anoncreds.credentials.offerToSelf';
+
+  public get instance() {
+    return JsonTransformer.fromJSON(this.data, CredentialExchangeRecord);
+  }
+
+  public static fromEvent(e: EventDidcommAnonCredsCredentialsOfferToSelf) {
+    return new EventDidcommAnonCredsCredentialsOfferToSelf(
+      e.data,
+      e.tenantId,
+      e.id,
+      e.type,
+      e.timestamp,
+    );
+  }
+}
diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts
index 8fa24d3..aae2837 100644
--- a/apps/shared/src/index.ts
+++ b/apps/shared/src/index.ts
@@ -9,3 +9,4 @@ export * from './events/didEvents.js';
 export * from './events/tenantEvents.js';
 export * from './events/schemaEvents.js';
 export * from './events/credentialDefinitionEvents.js';
+export * from './events/credentialEvents.js';
diff --git a/apps/ssi-abstraction/src/agent/agent.service.ts b/apps/ssi-abstraction/src/agent/agent.service.ts
index 9df1a70..b1d9bd9 100644
--- a/apps/ssi-abstraction/src/agent/agent.service.ts
+++ b/apps/ssi-abstraction/src/agent/agent.service.ts
@@ -3,7 +3,11 @@ import type { InitConfig } from '@aries-framework/core';
 import type { IndyVdrPoolConfig } from '@aries-framework/indy-vdr';
 import type { OnApplicationShutdown } from '@nestjs/common';
 
-import { AnonCredsModule } from '@aries-framework/anoncreds';
+import {
+  AnonCredsCredentialFormatService,
+  AnonCredsModule,
+  LegacyIndyCredentialFormatService,
+} from '@aries-framework/anoncreds';
 import { AnonCredsRsModule } from '@aries-framework/anoncreds-rs';
 import { AskarModule } from '@aries-framework/askar';
 import {
@@ -19,6 +23,7 @@ import {
   LogLevel,
   PeerDidRegistrar,
   PeerDidResolver,
+  V2CredentialProtocol,
   WebDidResolver,
 } from '@aries-framework/core';
 import {
@@ -94,6 +99,14 @@ export class AgentService implements OnApplicationShutdown {
       }),
       credentials: new CredentialsModule({
         autoAcceptCredentials: autoAcceptCredential,
+        credentialProtocols: [
+          new V2CredentialProtocol({
+            credentialFormats: [
+              new AnonCredsCredentialFormatService(),
+              new LegacyIndyCredentialFormatService(),
+            ],
+          }),
+        ],
       }),
 
       anoncredsRs: new AnonCredsRsModule({ anoncreds }),
diff --git a/apps/ssi-abstraction/src/agent/anoncredsCredentials/__tests__/anoncredsCredentials.controller.spec.ts b/apps/ssi-abstraction/src/agent/anoncredsCredentials/__tests__/anoncredsCredentials.controller.spec.ts
new file mode 100644
index 0000000..4652f57
--- /dev/null
+++ b/apps/ssi-abstraction/src/agent/anoncredsCredentials/__tests__/anoncredsCredentials.controller.spec.ts
@@ -0,0 +1,98 @@
+import {
+  CredentialExchangeRecord,
+  CredentialState,
+} from '@aries-framework/core';
+import { Test } from '@nestjs/testing';
+
+import { mockConfigModule } from '../../../config/__tests__/mockConfig.js';
+import { AgentModule } from '../../agent.module.js';
+import { AnonCredsCredentialsController } from '../anoncredsCredentials.controller.js';
+import { AnonCredsCredentialsService } from '../anoncredsCredentials.service.js';
+
+describe('AnonCredsCredentialsController', () => {
+  let credentialsController: AnonCredsCredentialsController;
+  let credentialsService: AnonCredsCredentialsService;
+
+  beforeEach(async () => {
+    const moduleRef = await Test.createTestingModule({
+      imports: [mockConfigModule(), AgentModule],
+      controllers: [AnonCredsCredentialsController],
+      providers: [AnonCredsCredentialsService],
+    }).compile();
+
+    credentialsService = moduleRef.get(AnonCredsCredentialsService);
+    credentialsController = moduleRef.get(AnonCredsCredentialsController);
+  });
+
+  describe('get all', () => {
+    it('should get all the credential records of the agent', async () => {
+      const result: Array<CredentialExchangeRecord> = [];
+      jest.spyOn(credentialsService, 'getAll').mockResolvedValue(result);
+
+      const event = await credentialsController.getAll({
+        tenantId: 'some-id',
+      });
+
+      expect(event.data).toStrictEqual(result);
+    });
+  });
+
+  describe('get by id', () => {
+    it('should get a credential record by id', async () => {
+      const result: CredentialExchangeRecord | null = null;
+      jest.spyOn(credentialsService, 'getById').mockResolvedValue(result);
+
+      const event = await credentialsController.getById({
+        tenantId: 'some-id',
+        credentialRecordId: 'some-id',
+      });
+
+      expect(event.data).toStrictEqual(result);
+    });
+  });
+
+  describe('offer', () => {
+    it('should offer a credential', async () => {
+      const result: CredentialExchangeRecord = new CredentialExchangeRecord({
+        state: CredentialState.Done,
+        threadId: 'some-id',
+        protocolVersion: 'v2',
+      });
+      jest.spyOn(credentialsService, 'offer').mockResolvedValue(result);
+
+      const event = await credentialsController.offer({
+        tenantId: 'some-id',
+        connectionId: 'some-id',
+        credentialDefinitionId: 'some-id',
+        attributes: [
+          { name: 'Name', value: 'Berend', mimeType: 'application/text' },
+          { name: 'Age', value: '25' },
+        ],
+      });
+
+      expect(event.data).toStrictEqual(result);
+    });
+  });
+
+  describe('offer to self', () => {
+    it('should offer a credential to self', async () => {
+      const result: CredentialExchangeRecord = new CredentialExchangeRecord({
+        state: CredentialState.Done,
+        threadId: 'some-id',
+        protocolVersion: 'v2',
+      });
+      jest.spyOn(credentialsService, 'offerToSelf').mockResolvedValue(result);
+
+      const event = await credentialsController.offerToSelf({
+        tenantId: 'some-id',
+        credentialDefinitionId: 'some-id',
+        attributes: [
+          { name: 'Name', value: 'Berend', mimeType: 'application/text' },
+          { name: 'Age', value: '25' },
+        ],
+      });
+
+      expect(event.data).toStrictEqual(result);
+    });
+  });
+});
diff --git a/apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.controller.ts b/apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.controller.ts
new file mode 100644
index 0000000..1cef75b
--- /dev/null
+++ b/apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.controller.ts
@@ -0,0 +1,59 @@
+import { Controller } from '@nestjs/common';
+import { MessagePattern } from '@nestjs/microservices';
+import {
+  EventDidcommAnonCredsCredentialsGetAll,
+  EventDidcommAnonCredsCredentialsGetAllInput,
+  EventDidcommAnonCredsCredentialsGetById,
+  EventDidcommAnonCredsCredentialsGetByIdInput,
+  EventDidcommAnonCredsCredentialsOffer,
+  EventDidcommAnonCredsCredentialsOfferInput,
+  EventDidcommAnonCredsCredentialsOfferToSelfInput,
+  EventDidcommAnonCredsCredentialsOfferToSelf,
+} from '@ocm/shared';
+
+import { AnonCredsCredentialsService } from './anoncredsCredentials.service.js';
+
+@Controller('anoncredsCredentials')
+export class AnonCredsCredentialsController {
+  public constructor(private credentialsService: AnonCredsCredentialsService) {}
+
+  @MessagePattern(EventDidcommAnonCredsCredentialsGetAll.token)
+  public async getAll(
+    options: EventDidcommAnonCredsCredentialsGetAllInput,
+  ): Promise<EventDidcommAnonCredsCredentialsGetAll> {
+    return new EventDidcommAnonCredsCredentialsGetAll(
+      await this.credentialsService.getAll(options),
+      options.tenantId,
+    );
+  }
+
+  @MessagePattern(EventDidcommAnonCredsCredentialsGetById.token)
+  public async getById(
+    options: EventDidcommAnonCredsCredentialsGetByIdInput,
+  ): Promise<EventDidcommAnonCredsCredentialsGetById> {
+    return new EventDidcommAnonCredsCredentialsGetById(
+      await this.credentialsService.getById(options),
+      options.tenantId,
+    );
+  }
+
+  @MessagePattern(EventDidcommAnonCredsCredentialsOffer.token)
+  public async offer(
+    options: EventDidcommAnonCredsCredentialsOfferInput,
+  ): Promise<EventDidcommAnonCredsCredentialsOffer> {
+    return new EventDidcommAnonCredsCredentialsOffer(
+      await this.credentialsService.offer(options),
+      options.tenantId,
+    );
+  }
+
+  @MessagePattern(EventDidcommAnonCredsCredentialsOfferToSelf.token)
+  public async offerToSelf(
+    options: EventDidcommAnonCredsCredentialsOfferToSelfInput,
+  ): Promise<EventDidcommAnonCredsCredentialsOfferToSelf> {
+    return new EventDidcommAnonCredsCredentialsOfferToSelf(
+      await this.credentialsService.offerToSelf(options),
+      options.tenantId,
+    );
+  }
+}
diff --git a/apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.module.ts b/apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.module.ts
new file mode 100644
index 0000000..f0d9fc8
--- /dev/null
+++ b/apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.module.ts
@@ -0,0 +1,13 @@
+import { Module } from '@nestjs/common';
+
+import { AgentModule } from '../agent.module.js';
+
+import { AnonCredsCredentialsController } from './anoncredsCredentials.controller.js';
+import { AnonCredsCredentialsService } from './anoncredsCredentials.service.js';
+
+@Module({
+  imports: [AgentModule],
+  providers: [AnonCredsCredentialsService],
+  controllers: [AnonCredsCredentialsController],
+})
+export class AnonCredsCredentialsModule {}
diff --git a/apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.service.ts b/apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.service.ts
new file mode 100644
index 0000000..3eb52b6
--- /dev/null
+++ b/apps/ssi-abstraction/src/agent/anoncredsCredentials/anoncredsCredentials.service.ts
@@ -0,0 +1,91 @@
+import type {
+  EventDidcommAnonCredsCredentialsGetAllInput,
+  EventDidcommAnonCredsCredentialsGetByIdInput,
+  EventDidcommAnonCredsCredentialsOfferInput,
+  EventDidcommAnonCredsCredentialsOfferToSelfInput,
+} from '@ocm/shared';
+
+import {
+  AutoAcceptCredential,
+  type CredentialExchangeRecord,
+} from '@aries-framework/core';
+import { Injectable } from '@nestjs/common';
+
+import { MetadataTokens } from '../../common/constants.js';
+import { WithTenantService } from '../withTenantService.js';
+
+@Injectable()
+export class AnonCredsCredentialsService {
+  public constructor(private withTenantService: WithTenantService) {}
+
+  public async getAll({
+    tenantId,
+  }: EventDidcommAnonCredsCredentialsGetAllInput): Promise<
+    Array<CredentialExchangeRecord>
+  > {
+    return this.withTenantService.invoke(tenantId, (t) =>
+      t.credentials.getAll(),
+    );
+  }
+
+  public async getById({
+    tenantId,
+    credentialRecordId,
+  }: EventDidcommAnonCredsCredentialsGetByIdInput): Promise<CredentialExchangeRecord | null> {
+    return this.withTenantService.invoke(tenantId, (t) =>
+      t.credentials.findById(credentialRecordId),
+    );
+  }
+
+  public async offer({
+    tenantId,
+    connectionId,
+    credentialDefinitionId,
+    attributes,
+  }: EventDidcommAnonCredsCredentialsOfferInput): Promise<CredentialExchangeRecord> {
+    return this.withTenantService.invoke(tenantId, (t) =>
+      t.credentials.offerCredential({
+        protocolVersion: 'v2',
+        connectionId,
+        credentialFormats: {
+          anoncreds: { credentialDefinitionId, attributes },
+        },
+      }),
+    );
+  }
+
+  public async offerToSelf({
+    tenantId,
+    credentialDefinitionId,
+    attributes,
+  }: EventDidcommAnonCredsCredentialsOfferToSelfInput): Promise<CredentialExchangeRecord> {
+    return this.withTenantService.invoke(tenantId, async (t) => {
+      const connections = await t.connections.getAll();
+      const connection = connections.find((c) => {
+        const metadata = c.metadata.get<{ withSelf: boolean }>(
+          MetadataTokens.GAIA_X_CONNECTION_METADATA_KEY,
+        );
+        return metadata && metadata.withSelf === true;
+      });
+
+      if (!connection) {
+        throw new Error(
+          'Cannot offer a credential to yourself as there is no connection',
+        );
+      }
+
+      if (!connection.isReady) {
+        throw new Error('Connection with yourself is not ready, yet');
+      }
+
+      return t.credentials.offerCredential({
+        protocolVersion: 'v2',
+        autoAcceptCredential: AutoAcceptCredential.Always,
+        connectionId: connection.id,
+        credentialFormats: {
+          anoncreds: { credentialDefinitionId, attributes },
+        },
+      });
+    });
+  }
+}
diff --git a/apps/ssi-abstraction/src/agent/connections/__tests__/connections.controller.spec.ts b/apps/ssi-abstraction/src/agent/connections/__tests__/connections.controller.spec.ts
index 235511f..f93c53d 100644
--- a/apps/ssi-abstraction/src/agent/connections/__tests__/connections.controller.spec.ts
+++ b/apps/ssi-abstraction/src/agent/connections/__tests__/connections.controller.spec.ts
@@ -30,11 +30,11 @@ describe('ConnectionsController', () => {
       const result: Array<ConnectionRecord> = [];
       jest.spyOn(connectionsService, 'getAll').mockResolvedValue(result);
 
-      const connectionsEvent = await connectionsController.getAll({
+      const event = await connectionsController.getAll({
         tenantId: 'some-id',
       });
 
-      expect(connectionsEvent.data).toStrictEqual(result);
+      expect(event.data).toStrictEqual(result);
     });
   });
 
@@ -43,12 +43,12 @@ describe('ConnectionsController', () => {
       const result: ConnectionRecord | null = null;
       jest.spyOn(connectionsService, 'getById').mockResolvedValue(result);
 
-      const connectionsEvent = await connectionsController.getById({
+      const event = await connectionsController.getById({
         id: 'id',
         tenantId: 'some-id',
       });
 
-      expect(connectionsEvent.data).toStrictEqual(result);
+      expect(event.data).toStrictEqual(result);
     });
   });
 
@@ -63,12 +63,11 @@ describe('ConnectionsController', () => {
         .spyOn(connectionsService, 'createConnectionWithSelf')
         .mockResolvedValue(result);
 
-      const connectionsEvent =
-        await connectionsController.createConnectionWithSelf({
-          tenantId: 'some-id',
-        });
+      const event = await connectionsController.createConnectionWithSelf({
+        tenantId: 'some-id',
+      });
 
-      expect(connectionsEvent.data).toStrictEqual(result);
+      expect(event.data).toStrictEqual(result);
     });
   });
 });
diff --git a/apps/ssi-abstraction/src/agent/connections/connections.service.ts b/apps/ssi-abstraction/src/agent/connections/connections.service.ts
index 027474a..2e2b3c9 100644
--- a/apps/ssi-abstraction/src/agent/connections/connections.service.ts
+++ b/apps/ssi-abstraction/src/agent/connections/connections.service.ts
@@ -24,15 +24,13 @@ import { WithTenantService } from '../withTenantService.js';
 
 @Injectable()
 export class ConnectionsService {
-  public agent: AppAgent;
-  public withTenantService: WithTenantService;
+  private agent: AppAgent;
 
   public constructor(
     agentService: AgentService,
-    withTenantService: WithTenantService,
+    private withTenantService: WithTenantService,
   ) {
     this.agent = agentService.agent;
-    this.withTenantService = withTenantService;
   }
 
   public async getAll({
@@ -104,6 +102,7 @@ export class ConnectionsService {
               MetadataTokens.GAIA_X_CONNECTION_METADATA_KEY,
               {
                 trusted: true,
+                withSelf: true,
               },
             );
 
diff --git a/apps/ssi-abstraction/src/agent/credentialDefinitions/credentialDefinitions.service.ts b/apps/ssi-abstraction/src/agent/credentialDefinitions/credentialDefinitions.service.ts
index 32b2b61..53acf4e 100644
--- a/apps/ssi-abstraction/src/agent/credentialDefinitions/credentialDefinitions.service.ts
+++ b/apps/ssi-abstraction/src/agent/credentialDefinitions/credentialDefinitions.service.ts
@@ -12,11 +12,7 @@ import { WithTenantService } from '../withTenantService.js';
 
 @Injectable()
 export class CredentialDefinitionsService {
-  public withTenantService: WithTenantService;
-
-  public constructor(withTenantService: WithTenantService) {
-    this.withTenantService = withTenantService;
-  }
+  public constructor(private withTenantService: WithTenantService) {}
 
   public async getAll({
     tenantId,
diff --git a/apps/ssi-abstraction/src/agent/dids/dids.service.ts b/apps/ssi-abstraction/src/agent/dids/dids.service.ts
index cf78072..7b0ad1e 100644
--- a/apps/ssi-abstraction/src/agent/dids/dids.service.ts
+++ b/apps/ssi-abstraction/src/agent/dids/dids.service.ts
@@ -9,16 +9,10 @@ import { WithTenantService } from '../withTenantService.js';
 
 @Injectable()
 export class DidsService {
-  private withTenantService: WithTenantService;
-  private configService: ConfigService;
-
   public constructor(
-    withTenantService: WithTenantService,
-    configService: ConfigService,
-  ) {
-    this.withTenantService = withTenantService;
-    this.configService = configService;
-  }
+    private withTenantService: WithTenantService,
+    private configService: ConfigService,
+  ) {}
 
   public async resolve({ did, tenantId }: EventDidsResolveInput) {
     return this.withTenantService.invoke(tenantId, async (t) => {
diff --git a/apps/ssi-abstraction/src/agent/schemas/schemas.service.ts b/apps/ssi-abstraction/src/agent/schemas/schemas.service.ts
index e72d2c4..d043590 100644
--- a/apps/ssi-abstraction/src/agent/schemas/schemas.service.ts
+++ b/apps/ssi-abstraction/src/agent/schemas/schemas.service.ts
@@ -12,11 +12,7 @@ import { WithTenantService } from '../withTenantService.js';
 
 @Injectable()
 export class SchemasService {
-  public withTenantService: WithTenantService;
-
-  public constructor(withTenantService: WithTenantService) {
-    this.withTenantService = withTenantService;
-  }
+  public constructor(private withTenantService: WithTenantService) {}
 
   public async getAll({
     tenantId,
diff --git a/apps/ssi-abstraction/src/agent/tenants/tenants.service.ts b/apps/ssi-abstraction/src/agent/tenants/tenants.service.ts
index 784e1cf..483a64a 100644
--- a/apps/ssi-abstraction/src/agent/tenants/tenants.service.ts
+++ b/apps/ssi-abstraction/src/agent/tenants/tenants.service.ts
@@ -6,7 +6,7 @@ import { AgentService } from '../agent.service.js';
 
 @Injectable()
 export class TenantsService {
-  public agent: AppAgent;
+  private agent: AppAgent;
 
   public constructor(agentService: AgentService) {
     this.agent = agentService.agent;
diff --git a/apps/ssi-abstraction/src/app.module.ts b/apps/ssi-abstraction/src/app.module.ts
index 876afc5..e9d5f47 100644
--- a/apps/ssi-abstraction/src/app.module.ts
+++ b/apps/ssi-abstraction/src/app.module.ts
@@ -5,6 +5,7 @@ import { TerminusModule } from '@nestjs/terminus';
 import { HealthController } from '@ocm/shared';
 
 import { AgentModule } from './agent/agent.module.js';
+import { AnonCredsCredentialsModule } from './agent/anoncredsCredentials/anoncredsCredentials.module.js';
 import { ConnectionsModule } from './agent/connections/connections.module.js';
 import { CredentialDefinitionsModule } from './agent/credentialDefinitions/credentialDefinitions.module.js';
 import { SchemasModule } from './agent/schemas/schemas.module.js';
@@ -26,6 +27,7 @@ import { validationSchema } from './config/validation.js';
     CredentialDefinitionsModule,
     DidsModule,
     SchemasModule,
+    AnonCredsCredentialsModule,
     TenantsModule,
   ],
   controllers: [HealthController],
diff --git a/apps/ssi-abstraction/test/anoncredsCredentials.e2e-spec.ts b/apps/ssi-abstraction/test/anoncredsCredentials.e2e-spec.ts
new file mode 100644
index 0000000..12250e6
--- /dev/null
+++ b/apps/ssi-abstraction/test/anoncredsCredentials.e2e-spec.ts
@@ -0,0 +1,157 @@
+import type { INestApplication } from '@nestjs/common';
+import type { ClientProxy } from '@nestjs/microservices';
+import type {
+  EventDidcommAnonCredsCredentialsGetAllInput,
+  EventDidcommAnonCredsCredentialsGetByIdInput,
+  EventDidcommAnonCredsCredentialsOfferToSelfInput,
+} from '@ocm/shared';
+
+import { AutoAcceptCredential } from '@aries-framework/core';
+import { ClientsModule, Transport } from '@nestjs/microservices';
+import { Test } from '@nestjs/testing';
+import {
+  EventDidcommAnonCredsCredentialsGetAll,
+  EventDidcommAnonCredsCredentialsGetById,
+  EventDidcommAnonCredsCredentialsOfferToSelf,
+} from '@ocm/shared';
+import { firstValueFrom } from 'rxjs';
+
+import { AgentModule } from '../src/agent/agent.module.js';
+import { AnonCredsCredentialsModule } from '../src/agent/anoncredsCredentials/anoncredsCredentials.module.js';
+import { ConnectionsModule } from '../src/agent/connections/connections.module.js';
+import { ConnectionsService } from '../src/agent/connections/connections.service.js';
+import { CredentialDefinitionsModule } from '../src/agent/credentialDefinitions/credentialDefinitions.module.js';
+import { CredentialDefinitionsService } from '../src/agent/credentialDefinitions/credentialDefinitions.service.js';
+import { DidsModule } from '../src/agent/dids/dids.module.js';
+import { DidsService } from '../src/agent/dids/dids.service.js';
+import { SchemasModule } from '../src/agent/schemas/schemas.module.js';
+import { SchemasService } from '../src/agent/schemas/schemas.service.js';
+import { TenantsModule } from '../src/agent/tenants/tenants.module.js';
+import { TenantsService } from '../src/agent/tenants/tenants.service.js';
+import { mockConfigModule } from '../src/config/__tests__/mockConfig.js';
+
+describe('Credentials', () => {
+  const TOKEN = 'CREDENTIALS_CLIENT_SERVICE';
+  let app: INestApplication;
+  let client: ClientProxy;
+  let tenantId: string;
+
+  let issuerDid: string;
+  let credentialDefinitionId: string;
+
+  beforeAll(async () => {
+    const moduleRef = await Test.createTestingModule({
+      imports: [
+        mockConfigModule(3004, true),
+        AgentModule,
+        ConnectionsModule,
+        SchemasModule,
+        CredentialDefinitionsModule,
+        AnonCredsCredentialsModule,
+        TenantsModule,
+        DidsModule,
+        ClientsModule.register([{ name: TOKEN, transport: Transport.NATS }]),
+      ],
+    }).compile();
+
+    app = moduleRef.createNestApplication();
+
+    app.connectMicroservice({ transport: Transport.NATS });
+
+    await app.startAllMicroservices();
+    await app.init();
+
+    client = app.get(TOKEN);
+    await client.connect();
+
+    const tenantsService = app.get(TenantsService);
+    const { id } = await tenantsService.create(TOKEN);
+    tenantId = id;
+
+    const connectionsService = app.get(ConnectionsService);
+    await connectionsService.createConnectionWithSelf({ tenantId });
+
+    const didsService = app.get(DidsService);
+    const [did] = await didsService.registerDidIndyFromSeed({
+      tenantId,
+      seed: '12312367897123300000000000000000',
+    });
+    issuerDid = did;
+
+    const schemaService = app.get(SchemasService);
+    const { schemaId } = await schemaService.register({
+      issuerDid,
+      tenantId,
+      name: 'test-schema-name',
+      version: `1.${new Date().getTime()}`,
+      attributeNames: ['Name', 'Age'],
+    });
+
+    const credentialDefinitionService = app.get(CredentialDefinitionsService);
+    const { credentialDefinitionId: cdi } =
+      await credentialDefinitionService.register({
+        tenantId,
+        issuerDid,
+        schemaId,
+        tag: `default-${new Date().getTime()}`,
+      });
+
+    credentialDefinitionId = cdi;
+  });
+
+  afterAll(async () => {
+    await app.close();
+    client.close();
+  });
+
+  it(EventDidcommAnonCredsCredentialsGetAll.token, async () => {
+    const response$ = client.send<
+      EventDidcommAnonCredsCredentialsGetAll,
+      EventDidcommAnonCredsCredentialsGetAllInput
+    >(EventDidcommAnonCredsCredentialsGetAll.token, { tenantId });
+    const response = await firstValueFrom(response$);
+    const eventInstance =
+      EventDidcommAnonCredsCredentialsGetAll.fromEvent(response);
+
+    expect(eventInstance.instance).toEqual(expect.arrayContaining([]));
+  });
+
+  it(EventDidcommAnonCredsCredentialsGetById.token, async () => {
+    const response$ = client.send<
+      EventDidcommAnonCredsCredentialsGetById,
+      EventDidcommAnonCredsCredentialsGetByIdInput
+    >(EventDidcommAnonCredsCredentialsGetById.token, {
+      tenantId,
+      credentialRecordId: 'some-id',
+    });
+    const response = await firstValueFrom(response$);
+    const eventInstance =
+      EventDidcommAnonCredsCredentialsGetById.fromEvent(response);
+
+    expect(eventInstance.instance).toEqual(null);
+  });
+
+  it(EventDidcommAnonCredsCredentialsOfferToSelf.token, async () => {
+    const attributes = [
+      { name: 'Name', value: 'Berend' },
+      { name: 'Age', value: '25' },
+    ];
+
+    const response$ = client.send<
+      EventDidcommAnonCredsCredentialsOfferToSelf,
+      EventDidcommAnonCredsCredentialsOfferToSelfInput
+    >(EventDidcommAnonCredsCredentialsOfferToSelf.token, {
+      tenantId,
+      credentialDefinitionId,
+      attributes,
+    });
+
+    const response = await firstValueFrom(response$);
+    const eventInstance =
+      EventDidcommAnonCredsCredentialsOfferToSelf.fromEvent(response);
+
+    expect(eventInstance.instance).toMatchObject({
+      autoAcceptCredential: AutoAcceptCredential.Always,
+    });
+  });
+});
diff --git a/apps/ssi-abstraction/test/jest.config.js b/apps/ssi-abstraction/test/jest.config.js
index fb2852f..b18652e 100644
--- a/apps/ssi-abstraction/test/jest.config.js
+++ b/apps/ssi-abstraction/test/jest.config.js
@@ -3,7 +3,7 @@ import config from '../jest.config.js';
 /** @type {import('jest').Config} */
 export default {
   ...config,
-  testTimeout: 24000,
+  testTimeout: 36000,
   rootDir: '.',
   testRegex: '.*\\.e2e-spec\\.ts$',
 };
-- 
GitLab