From 8ab2a1c94f0e7a45ff15a955f31ea4bbcce81b24 Mon Sep 17 00:00:00 2001
From: Konstantin Tsabolov <konstantin.tsabolov@spherity.com>
Date: Tue, 23 Jan 2024 16:07:10 +0100
Subject: [PATCH] feat: implement proof-manager base CRUD

---
 apps/proof-manager/jest.config.js             |   5 +-
 apps/proof-manager/package.json               |   4 +-
 apps/proof-manager/src/application.ts         |  30 +-
 apps/proof-manager/src/config/http.config.ts  |   4 +-
 apps/proof-manager/src/config/nats.config.ts  |   6 +-
 apps/proof-manager/src/config/ssi.config.ts   |   2 +-
 apps/proof-manager/src/config/validation.ts   |  12 +-
 apps/proof-manager/src/main.ts                |  31 +-
 .../__tests__/proofs.controller.spec.ts       | 158 ++++++
 .../proofs/__tests__/proofs.module.spec.ts    |  35 ++
 .../proofs/__tests__/proofs.service.spec.ts   | 170 ++++++
 .../src/proofs/dto/get-by-id.dto.ts           |   7 +
 .../src/proofs/dto/register.dto.ts            | 115 ++++
 .../src/proofs/proofs.controller.ts           | 492 ++++++++++++++++++
 .../proof-manager/src/proofs/proofs.module.ts |  10 +
 .../src/proofs/proofs.service.ts              |  84 +++
 16 files changed, 1135 insertions(+), 30 deletions(-)
 create mode 100644 apps/proof-manager/src/proofs/__tests__/proofs.controller.spec.ts
 create mode 100644 apps/proof-manager/src/proofs/__tests__/proofs.module.spec.ts
 create mode 100644 apps/proof-manager/src/proofs/__tests__/proofs.service.spec.ts
 create mode 100644 apps/proof-manager/src/proofs/dto/get-by-id.dto.ts
 create mode 100644 apps/proof-manager/src/proofs/dto/register.dto.ts
 create mode 100644 apps/proof-manager/src/proofs/proofs.controller.ts
 create mode 100644 apps/proof-manager/src/proofs/proofs.module.ts
 create mode 100644 apps/proof-manager/src/proofs/proofs.service.ts

diff --git a/apps/proof-manager/jest.config.js b/apps/proof-manager/jest.config.js
index c4d3f93..ccdd468 100644
--- a/apps/proof-manager/jest.config.js
+++ b/apps/proof-manager/jest.config.js
@@ -7,7 +7,7 @@ export default {
   moduleFileExtensions: ['js', 'ts'],
   testEnvironment: 'node',
   transform: {
-    '^.+\\.(ts|js)$': [
+    '^.+\\.(js|ts)$': [
       '@swc/jest',
       {
         ...swcConfig,
@@ -31,10 +31,9 @@ export default {
       : ['text-summary', 'html'],
   coveragePathIgnorePatterns: [
     '<rootDir>/node_modules/',
-    '<rootDir>/test/',
     '<rootDir>/coverage/',
     '<rootDir>/dist/',
-    '<rootDir>/**/test',
+    '__tests__',
     '@types',
     '.dto.(t|j)s',
     '.enum.ts',
diff --git a/apps/proof-manager/package.json b/apps/proof-manager/package.json
index 819f35c..c78bda6 100644
--- a/apps/proof-manager/package.json
+++ b/apps/proof-manager/package.json
@@ -16,8 +16,7 @@
     "prisma:generate": "prisma generate --schema=./src/prisma/schema.prisma",
     "prisma:migrate": "prisma migrate deploy --schema=./src/prisma/schema.prisma",
     "prisma:studio": "prisma studio",
-    "start": "nest start",
-    "start:dev": "nest start --watch --preserveWatchOutput",
+    "start": "nest start --watch --preserveWatchOutput",
     "test": "jest",
     "test:watch": "jest --watch",
     "test:cov": "jest --coverage",
@@ -34,6 +33,7 @@
     "@nestjs/swagger": "^7.1.16",
     "@ocm/shared": "workspace:*",
     "class-validator": "^0.14.0",
+    "class-transformer": "^0.5.1",
     "express": "^4.17.3",
     "joi": "^17.11.0",
     "nats": "^2.18.0",
diff --git a/apps/proof-manager/src/application.ts b/apps/proof-manager/src/application.ts
index a7e650c..0c71d7c 100644
--- a/apps/proof-manager/src/application.ts
+++ b/apps/proof-manager/src/application.ts
@@ -1,4 +1,5 @@
 import type { ConfigType } from '@nestjs/config';
+import type { ClientProvider } from '@nestjs/microservices';
 
 import { Module } from '@nestjs/common';
 import { ConfigModule } from '@nestjs/config';
@@ -11,6 +12,7 @@ import { httpConfig } from './config/http.config.js';
 import { natsConfig } from './config/nats.config.js';
 import { ssiConfig } from './config/ssi.config.js';
 import { validationSchema } from './config/validation.js';
+import { ProofsModule } from './proofs/proofs.module.js';
 
 @Module({
   imports: [
@@ -32,12 +34,21 @@ import { validationSchema } from './config/validation.js';
         {
           name: NATS_CLIENT,
           inject: [natsConfig.KEY],
-          useFactory: (config: ConfigType<typeof natsConfig>) => ({
-            transport: Transport.NATS,
-            options: {
-              url: config.url as string,
-            },
-          }),
+          useFactory: (config: ConfigType<typeof natsConfig>) => {
+            const provider: Required<ClientProvider> = {
+              transport: Transport.NATS,
+              options: {
+                servers: config.url as string,
+              },
+            };
+
+            if ('user' in config && 'password' in config) {
+              provider.options.user = config.user as string;
+              provider.options.pass = config.password as string;
+            }
+
+            return provider;
+          },
         },
       ],
     }),
@@ -57,7 +68,12 @@ import { validationSchema } from './config/validation.js';
       },
     }),
 
-    RouterModule.register([{ module: HealthModule, path: '/health' }]),
+    ProofsModule,
+
+    RouterModule.register([
+      { module: HealthModule, path: '/health' },
+      { module: ProofsModule, path: '/proofs' },
+    ]),
   ],
 })
 export class Application {}
diff --git a/apps/proof-manager/src/config/http.config.ts b/apps/proof-manager/src/config/http.config.ts
index 0bb4d4c..fc63fd9 100644
--- a/apps/proof-manager/src/config/http.config.ts
+++ b/apps/proof-manager/src/config/http.config.ts
@@ -1,6 +1,6 @@
 import { registerAs } from '@nestjs/config';
 
 export const httpConfig = registerAs('http', () => ({
-  host: process.env.HOST,
-  port: Number(process.env.PORT),
+  host: process.env.HOST || '0.0.0.0',
+  port: Number(process.env.PORT) || 3000,
 }));
diff --git a/apps/proof-manager/src/config/nats.config.ts b/apps/proof-manager/src/config/nats.config.ts
index 023e923..194053c 100644
--- a/apps/proof-manager/src/config/nats.config.ts
+++ b/apps/proof-manager/src/config/nats.config.ts
@@ -1,6 +1,8 @@
 import { registerAs } from '@nestjs/config';
 
 export const natsConfig = registerAs('nats', () => ({
-  url: process.env.NATS_URL,
-  monitoringUrl: process.env.NATS_MONITORING_URL,
+  url: process.env.NATS_URL || 'nats://localhost:4222',
+  user: process.env.NATS_USER,
+  password: process.env.NATS_PASSWORD,
+  monitoringUrl: process.env.NATS_MONITORING_URL || 'http://localhost:8222',
 }));
diff --git a/apps/proof-manager/src/config/ssi.config.ts b/apps/proof-manager/src/config/ssi.config.ts
index 1779919..408bf27 100644
--- a/apps/proof-manager/src/config/ssi.config.ts
+++ b/apps/proof-manager/src/config/ssi.config.ts
@@ -1,5 +1,5 @@
 import { registerAs } from '@nestjs/config';
 
 export const ssiConfig = registerAs('ssi', () => ({
-  agentUrl: process.env.SSI_AGENT_URL,
+  agentUrl: process.env.SSI_AGENT_URL || 'http://localhost:3010',
 }));
diff --git a/apps/proof-manager/src/config/validation.ts b/apps/proof-manager/src/config/validation.ts
index a7fe2f8..fef0d7c 100644
--- a/apps/proof-manager/src/config/validation.ts
+++ b/apps/proof-manager/src/config/validation.ts
@@ -1,11 +1,13 @@
 import Joi from 'joi';
 
 export const validationSchema = Joi.object({
-  HTTP_HOST: Joi.string().default('0.0.0.0'),
-  HTTP_PORT: Joi.number().default(3000),
+  HTTP_HOST: Joi.string(),
+  HTTP_PORT: Joi.number(),
 
-  NATS_URL: Joi.string().uri().default('nats://localhost:4222'),
-  NATS_MONITORING_URL: Joi.string().uri().default('http://localhost:8222'),
+  NATS_URL: Joi.string().uri(),
+  NATS_USER: Joi.string().optional(),
+  NATS_PASSWORD: Joi.string().optional(),
+  NATS_MONITORING_URL: Joi.string().uri(),
 
-  SSI_AGENT_URL: Joi.string().default('http://localhost:3010'),
+  SSI_AGENT_URL: Joi.string().uri(),
 });
diff --git a/apps/proof-manager/src/main.ts b/apps/proof-manager/src/main.ts
index 84f089d..fe4aad7 100644
--- a/apps/proof-manager/src/main.ts
+++ b/apps/proof-manager/src/main.ts
@@ -1,24 +1,36 @@
 /* c8 ignore start */
-import type { MicroserviceOptions } from '@nestjs/microservices';
+import type { ConfigType } from '@nestjs/config';
+import type { MicroserviceOptions, NatsOptions } from '@nestjs/microservices';
 
-import { VersioningType } from '@nestjs/common';
-import { ConfigService } from '@nestjs/config';
+import { Logger, VersioningType } from '@nestjs/common';
 import { NestFactory } from '@nestjs/core';
 import { Transport } from '@nestjs/microservices';
 import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
 
 import { Application } from './application.js';
+import { httpConfig } from './config/http.config.js';
+import { natsConfig } from './config/nats.config.js';
 
 const app = await NestFactory.create(Application);
-const configService = app.get(ConfigService);
 app.enableCors();
 
-app.connectMicroservice<MicroserviceOptions>({
+const { url, user, password } = app.get(natsConfig.KEY) as ConfigType<
+  typeof natsConfig
+>;
+
+const microserviceOptions: Required<NatsOptions> = {
   transport: Transport.NATS,
   options: {
-    servers: [configService.get('nats').url],
+    servers: [url],
   },
-});
+};
+
+if (user && password) {
+  microserviceOptions.options.user = user;
+  microserviceOptions.options.pass = password;
+}
+
+app.connectMicroservice<MicroserviceOptions>(microserviceOptions);
 
 app.enableVersioning({
   defaultVersion: ['1'],
@@ -36,5 +48,8 @@ const document = SwaggerModule.createDocument(app, swaggerConfig);
 SwaggerModule.setup('/swagger', app, document);
 await app.startAllMicroservices();
 
-await app.listen(configService.get('PORT') || 3000);
+const { host, port } = app.get(httpConfig.KEY) as ConfigType<typeof httpConfig>;
+await app.listen(port as number, host as string);
+
+Logger.log(`Application is running on: ${await app.getUrl()}`);
 /* c8 ignore stop */
diff --git a/apps/proof-manager/src/proofs/__tests__/proofs.controller.spec.ts b/apps/proof-manager/src/proofs/__tests__/proofs.controller.spec.ts
new file mode 100644
index 0000000..93b991f
--- /dev/null
+++ b/apps/proof-manager/src/proofs/__tests__/proofs.controller.spec.ts
@@ -0,0 +1,158 @@
+import type { TestingModule } from '@nestjs/testing';
+import type {
+  EventAnonCredsProofsDeleteById,
+  EventAnonCredsProofsGetAll,
+  EventAnonCredsProofsGetById,
+  EventDidcommAnonCredsProofsRequest,
+} from '@ocm/shared';
+
+import { Test } from '@nestjs/testing';
+import { Subject, of, takeUntil } from 'rxjs';
+
+import { NATS_CLIENT } from '../../common/constants.js';
+import { ProofsController } from '../proofs.controller.js';
+import { ProofsService } from '../proofs.service.js';
+
+describe('ProofsController', () => {
+  const natsClientMock = {};
+
+  let controller: ProofsController;
+  let service: ProofsService;
+
+  beforeEach(async () => {
+    const module: TestingModule = await Test.createTestingModule({
+      controllers: [ProofsController],
+      providers: [
+        { provide: NATS_CLIENT, useValue: natsClientMock },
+        ProofsService,
+      ],
+    }).compile();
+
+    controller = module.get<ProofsController>(ProofsController);
+    service = module.get<ProofsService>(ProofsService);
+  });
+
+  describe('find', () => {
+    it('should return a list of schemas', (done) => {
+      const unsubscribe$ = new Subject<void>();
+      const tenantId = 'exampleTenantId';
+      const expectedResult: EventAnonCredsProofsGetAll['data'] = [];
+
+      jest.spyOn(service, 'find').mockReturnValue(of(expectedResult));
+
+      controller
+        .find({ tenantId })
+        .pipe(takeUntil(unsubscribe$))
+        .subscribe((result) => {
+          expect(result).toStrictEqual(expectedResult);
+
+          unsubscribe$.next();
+          unsubscribe$.complete();
+
+          done();
+        });
+    });
+  });
+
+  describe('getById', () => {
+    it('should return a schema by id', (done) => {
+      const unsubscribe$ = new Subject<void>();
+      const proofRecordId = 'exampleProofRecordId';
+      const tenantId = 'exampleTenantId';
+      const expectedResult = {} as NonNullable<
+        EventAnonCredsProofsGetById['data']
+      >;
+
+      jest.spyOn(service, 'getById').mockReturnValue(of(expectedResult));
+
+      controller
+        .get({ proofRecordId }, { tenantId })
+        .pipe(takeUntil(unsubscribe$))
+        .subscribe((result) => {
+          expect(result).toStrictEqual(expectedResult);
+
+          unsubscribe$.next();
+          unsubscribe$.complete();
+
+          done();
+        });
+    });
+
+    it('should throw a NotFoundException if the service returned null', (done) => {
+      const unsubscribe$ = new Subject<void>();
+      const proofRecordId = 'exampleProofRecordId';
+      const tenantId = 'exampleTenantId';
+
+      jest.spyOn(service, 'getById').mockReturnValue(of(null));
+
+      controller
+        .get({ proofRecordId }, { tenantId })
+        .pipe(takeUntil(unsubscribe$))
+        .subscribe({
+          error: (error) => {
+            expect(error.status).toBe(404);
+            expect(error.message).toBe(
+              `Presentation proof with id ${proofRecordId} not found`,
+            );
+
+            unsubscribe$.next();
+            unsubscribe$.complete();
+
+            done();
+          },
+        });
+    });
+  });
+
+  describe('request', () => {
+    it('should return a proof record', (done) => {
+      const unsubscribe$ = new Subject<void>();
+      const tenantId = 'exampleTenantId';
+      const name = 'exampleName';
+      const connectionId = 'exampleConnectionId';
+      const requestedAttributes = {};
+      const requestedPredicates = {};
+      const expectedResult = {} as EventDidcommAnonCredsProofsRequest['data'];
+
+      jest.spyOn(service, 'request').mockReturnValue(of(expectedResult));
+
+      controller
+        .request(
+          { tenantId },
+          { name, connectionId, requestedAttributes, requestedPredicates },
+        )
+        .pipe(takeUntil(unsubscribe$))
+        .subscribe((result) => {
+          expect(result).toStrictEqual(expectedResult);
+
+          unsubscribe$.next();
+          unsubscribe$.complete();
+
+          done();
+        });
+    });
+  });
+
+  describe('delete', () => {
+    it('should return a proof record', (done) => {
+      const unsubscribe$ = new Subject<void>();
+      const tenantId = 'exampleTenantId';
+      const proofRecordId = 'exampleProofRecordId';
+      const expectedResult = {} as EventAnonCredsProofsDeleteById['data'];
+
+      jest.spyOn(service, 'delete').mockReturnValue(of(expectedResult));
+
+      controller
+        .delete({ proofRecordId }, { tenantId })
+        .pipe(takeUntil(unsubscribe$))
+        .subscribe((result) => {
+          expect(result).toStrictEqual(expectedResult);
+
+          unsubscribe$.next();
+          unsubscribe$.complete();
+
+          done();
+        });
+    });
+  });
+});
diff --git a/apps/proof-manager/src/proofs/__tests__/proofs.module.spec.ts b/apps/proof-manager/src/proofs/__tests__/proofs.module.spec.ts
new file mode 100644
index 0000000..b6a2059
--- /dev/null
+++ b/apps/proof-manager/src/proofs/__tests__/proofs.module.spec.ts
@@ -0,0 +1,35 @@
+import { ClientsModule } from '@nestjs/microservices';
+import { Test } from '@nestjs/testing';
+
+import { NATS_CLIENT } from '../../common/constants.js';
+import { ProofsController } from '../proofs.controller.js';
+import { ProofsModule } from '../proofs.module.js';
+import { ProofsService } from '../proofs.service.js';
+
+describe('Proofs Module', () => {
+  let proofsController: ProofsController;
+  let proofsService: ProofsService;
+
+  beforeEach(async () => {
+    const moduleRef = await Test.createTestingModule({
+      imports: [
+        ClientsModule.registerAsync({
+          isGlobal: true,
+          clients: [{ name: NATS_CLIENT, useFactory: () => ({}) }],
+        }),
+        ProofsModule,
+      ],
+    }).compile();
+
+    proofsController = moduleRef.get<ProofsController>(ProofsController);
+    proofsService = moduleRef.get<ProofsService>(ProofsService);
+  });
+
+  it('should be defined', () => {
+    expect(proofsController).toBeDefined();
+    expect(proofsController).toBeInstanceOf(ProofsController);
+
+    expect(proofsService).toBeDefined();
+    expect(proofsService).toBeInstanceOf(ProofsService);
+  });
+});
diff --git a/apps/proof-manager/src/proofs/__tests__/proofs.service.spec.ts b/apps/proof-manager/src/proofs/__tests__/proofs.service.spec.ts
new file mode 100644
index 0000000..d12e7c0
--- /dev/null
+++ b/apps/proof-manager/src/proofs/__tests__/proofs.service.spec.ts
@@ -0,0 +1,170 @@
+import type { TestingModule } from '@nestjs/testing';
+
+import { Test } from '@nestjs/testing';
+import {
+  EventAnonCredsProofsDeleteById,
+  EventAnonCredsProofsGetAll,
+  EventAnonCredsProofsGetById,
+  EventDidcommAnonCredsProofsRequest,
+} from '@ocm/shared';
+import { Subject, of, takeUntil } from 'rxjs';
+
+import { NATS_CLIENT } from '../../common/constants.js';
+import { ProofsService } from '../proofs.service.js';
+
+describe('ProofsService', () => {
+  let service: ProofsService;
+  const natsClientMock = { send: jest.fn() };
+
+  beforeEach(async () => {
+    const module: TestingModule = await Test.createTestingModule({
+      providers: [
+        { provide: NATS_CLIENT, useValue: natsClientMock },
+        ProofsService,
+      ],
+    }).compile();
+
+    service = module.get<ProofsService>(ProofsService);
+
+    jest.resetAllMocks();
+  });
+
+  describe('getAll', () => {
+    it('should return the data from NATS client', (done) => {
+      const unsubscribe$ = new Subject<void>();
+      const tenantId = 'mocked tenantId';
+      const expectedResult: EventAnonCredsProofsGetAll['data'] = [];
+
+      natsClientMock.send.mockReturnValueOnce(
+        of(new EventAnonCredsProofsGetAll([], tenantId)),
+      );
+
+      service
+        .find(tenantId)
+        .pipe(takeUntil(unsubscribe$))
+        .subscribe((result) => {
+          expect(natsClientMock.send).toHaveBeenCalledWith(
+            EventAnonCredsProofsGetAll.token,
+            { tenantId },
+          );
+
+          expect(result).toStrictEqual(expectedResult);
+
+          unsubscribe$.next();
+          unsubscribe$.complete();
+
+          done();
+        });
+    });
+  });
+
+  describe('getById', () => {
+    it('should return the data from NATS client', (done) => {
+      const unsubscribe$ = new Subject<void>();
+      const tenantId = 'mocked tenantId';
+      const proofRecordId = 'mocked id';
+      const expectedResult = {} as EventAnonCredsProofsGetById['data'];
+
+      natsClientMock.send.mockReturnValueOnce(
+        of(new EventAnonCredsProofsGetById(expectedResult, tenantId)),
+      );
+
+      service
+        .getById(tenantId, proofRecordId)
+        .pipe(takeUntil(unsubscribe$))
+        .subscribe((result) => {
+          expect(natsClientMock.send).toHaveBeenCalledWith(
+            EventAnonCredsProofsGetById.token,
+            { tenantId, proofRecordId },
+          );
+
+          expect(result).toStrictEqual(expectedResult);
+
+          unsubscribe$.next();
+          unsubscribe$.complete();
+
+          done();
+        });
+    });
+  });
+
+  describe('request', () => {
+    it('should return the data from NATS client', (done) => {
+      const unsubscribe$ = new Subject<void>();
+      const tenantId = 'mocked tenantId';
+      const name = 'mocked name';
+      const connectionId = 'mocked connectionId';
+      const requestedAttributes = {};
+      const requestedPredicates = {};
+      const expectedResult = {
+        name,
+        connectionId,
+        requestedAttributes,
+        requestedPredicates,
+      } as unknown as EventDidcommAnonCredsProofsRequest['data'];
+
+      natsClientMock.send.mockReturnValueOnce(
+        of(new EventDidcommAnonCredsProofsRequest(expectedResult, tenantId)),
+      );
+
+      service
+        .request(
+          tenantId,
+          name,
+          connectionId,
+          requestedAttributes,
+          requestedPredicates,
+        )
+        .pipe(takeUntil(unsubscribe$))
+        .subscribe((result) => {
+          expect(natsClientMock.send).toHaveBeenCalledWith(
+            EventDidcommAnonCredsProofsRequest.token,
+            {
+              name,
+              connectionId,
+              requestedAttributes,
+              requestedPredicates,
+              tenantId,
+            },
+          );
+
+          expect(result).toStrictEqual(expectedResult);
+
+          unsubscribe$.next();
+          unsubscribe$.complete();
+
+          done();
+        });
+    });
+  });
+
+  describe('delete', () => {
+    it('should return the data from NATS client', (done) => {
+      const unsubscribe$ = new Subject<void>();
+      const tenantId = 'mocked tenantId';
+      const proofRecordId = 'mocked id';
+      const expectedResult = {} as EventAnonCredsProofsGetById['data'];
+
+      natsClientMock.send.mockReturnValueOnce(
+        of(new EventAnonCredsProofsGetById(expectedResult, tenantId)),
+      );
+
+      service
+        .delete(tenantId, proofRecordId)
+        .pipe(takeUntil(unsubscribe$))
+        .subscribe((result) => {
+          expect(natsClientMock.send).toHaveBeenCalledWith(
+            EventAnonCredsProofsDeleteById.token,
+            { tenantId, proofRecordId },
+          );
+
+          expect(result).toStrictEqual(expectedResult);
+
+          unsubscribe$.next();
+          unsubscribe$.complete();
+
+          done();
+        });
+    });
+  });
+});
diff --git a/apps/proof-manager/src/proofs/dto/get-by-id.dto.ts b/apps/proof-manager/src/proofs/dto/get-by-id.dto.ts
new file mode 100644
index 0000000..43d5b40
--- /dev/null
+++ b/apps/proof-manager/src/proofs/dto/get-by-id.dto.ts
@@ -0,0 +1,7 @@
+import { IsNotEmpty, IsString } from 'class-validator';
+
+export class GetByIdParams {
+  @IsString()
+  @IsNotEmpty()
+  public readonly proofRecordId: string;
+}
diff --git a/apps/proof-manager/src/proofs/dto/register.dto.ts b/apps/proof-manager/src/proofs/dto/register.dto.ts
new file mode 100644
index 0000000..3f9653c
--- /dev/null
+++ b/apps/proof-manager/src/proofs/dto/register.dto.ts
@@ -0,0 +1,115 @@
+import { Type } from 'class-transformer';
+import {
+  IsArray,
+  IsEnum,
+  IsNotEmpty,
+  IsNumber,
+  IsObject,
+  IsOptional,
+  IsString,
+  ValidateNested,
+} from 'class-validator';
+
+export class RequestPayload {
+  @IsString()
+  @IsNotEmpty()
+  public readonly name: string;
+
+  @IsString()
+  @IsNotEmpty()
+  public readonly connectionId: string;
+
+  @IsObject()
+  @ValidateNested({ each: true })
+  @Type(() => RequestedAttribute)
+  public readonly requestedAttributes: Record<string, RequestedAttribute>;
+
+  @IsObject()
+  @ValidateNested({ each: true })
+  @Type(() => RequestedPredicate)
+  public readonly requestedPredicates: Record<string, RequestedPredicate>;
+}
+
+class RequestRestriction {
+  @IsString()
+  @IsNotEmpty()
+  @IsOptional()
+  public schema_id?: string;
+
+  @IsString()
+  @IsNotEmpty()
+  @IsOptional()
+  public schema_issuer_id?: string;
+
+  @IsString()
+  @IsNotEmpty()
+  @IsOptional()
+  public schema_name?: string;
+
+  @IsString()
+  @IsNotEmpty()
+  @IsOptional()
+  public schema_version?: string;
+
+  @IsString()
+  @IsNotEmpty()
+  @IsOptional()
+  public issuer_id?: string;
+
+  @IsString()
+  @IsNotEmpty()
+  @IsOptional()
+  public cred_def_id?: string;
+
+  @IsString()
+  @IsNotEmpty()
+  @IsOptional()
+  public rev_reg_id?: string;
+
+  @IsString()
+  @IsNotEmpty()
+  @IsOptional()
+  public schema_issuer_did?: string;
+
+  @IsString()
+  @IsNotEmpty()
+  @IsOptional()
+  public issuer_did?: string;
+
+  [key: `attr::${string}::marker`]: '1' | '0';
+  [key: `attr::${string}::value`]: string;
+}
+
+class RequestedAttribute {
+  @IsArray()
+  @IsString({ each: true })
+  @IsNotEmpty({ each: true })
+  public names: string[];
+
+  @IsArray()
+  @IsOptional()
+  @ValidateNested({ each: true })
+  @Type(() => RequestRestriction)
+  public restrictions?: RequestRestriction[];
+}
+
+const predicateType = ['>=', '>', '<=', '<'] as const;
+
+class RequestedPredicate {
+  @IsString()
+  @IsNotEmpty()
+  public name: string;
+
+  @IsString()
+  @IsEnum(predicateType)
+  public predicateType: (typeof predicateType)[number];
+
+  @IsNumber()
+  public predicateValue: number;
+
+  @IsArray()
+  @IsOptional()
+  @ValidateNested({ each: true })
+  @Type(() => RequestRestriction)
+  public restrictions?: RequestRestriction[];
+}
diff --git a/apps/proof-manager/src/proofs/proofs.controller.ts b/apps/proof-manager/src/proofs/proofs.controller.ts
new file mode 100644
index 0000000..c7602b7
--- /dev/null
+++ b/apps/proof-manager/src/proofs/proofs.controller.ts
@@ -0,0 +1,492 @@
+import {
+  Body,
+  Controller,
+  Delete,
+  Get,
+  HttpCode,
+  HttpStatus,
+  NotFoundException,
+  Param,
+  Post,
+  Query,
+  UseInterceptors,
+  UsePipes,
+  ValidationPipe,
+} from '@nestjs/common';
+import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
+import { MultitenancyParams, ResponseFormatInterceptor } from '@ocm/shared';
+import { of, switchMap } from 'rxjs';
+
+import { GetByIdParams } from './dto/get-by-id.dto.js';
+import { RequestPayload } from './dto/register.dto.js';
+import { ProofsService } from './proofs.service.js';
+
+@Controller()
+@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
+@UseInterceptors(ResponseFormatInterceptor)
+@ApiTags('Presentation Proofs')
+export class ProofsController {
+  public constructor(private readonly service: ProofsService) {}
+
+  @Get()
+  @ApiOperation({
+    summary: 'Fetch a list of presentation proofs',
+    description:
+      'This call provides a list of presentation proofs for a given tenant',
+  })
+  @ApiResponse({
+    status: HttpStatus.OK,
+    description: 'Presentation proofs fetched successfully',
+    content: {
+      'application/json': {
+        schema: {},
+        examples: {
+          'Presentation proofs fetched successfully': {
+            value: {
+              statusCode: 200,
+              message: 'Presentation proofs fetched successfully',
+              data: [],
+            },
+          },
+        },
+      },
+    },
+  })
+  @ApiResponse({
+    status: HttpStatus.NOT_FOUND,
+    description: 'Tenant not found',
+    content: {
+      'application/json': {
+        schema: {},
+        examples: {
+          'Tenant not found': {
+            value: {
+              statusCode: 404,
+              message: 'Tenant not found',
+              data: null,
+            },
+          },
+        },
+      },
+    },
+  })
+  @ApiResponse({
+    status: HttpStatus.INTERNAL_SERVER_ERROR,
+    description: 'Internal server error',
+    content: {
+      'application/json': {
+        schema: {},
+        examples: {
+          'Internal server error': {
+            value: {
+              statusCode: 500,
+              message: 'Internal server error',
+              data: null,
+            },
+          },
+        },
+      },
+    },
+  })
+  public find(
+    @Query() { tenantId }: MultitenancyParams,
+  ): ReturnType<ProofsService['find']> {
+    return this.service.find(tenantId);
+  }
+
+  @Get(':id')
+  @ApiOperation({
+    summary: 'Fetch a presentation proof by id',
+    description:
+      'This call provides a presentation proof for a given tenant and id',
+  })
+  @ApiResponse({
+    status: HttpStatus.OK,
+    description: 'Presentation proof fetched successfully',
+    content: {
+      'application/json': {
+        schema: {},
+        examples: {
+          'Presentation proof fetched successfully': {
+            value: {
+              statusCode: 200,
+              message: 'Presentation proof fetched successfully',
+              data: {},
+            },
+          },
+        },
+      },
+    },
+  })
+  @ApiResponse({
+    status: HttpStatus.NOT_FOUND,
+    description: 'Tenant not found',
+    content: {
+      'application/json': {
+        schema: {},
+        examples: {
+          'Tenant not found': {
+            value: {
+              statusCode: 404,
+              message: 'Tenant not found',
+              data: null,
+            },
+          },
+        },
+      },
+    },
+  })
+  @ApiResponse({
+    status: HttpStatus.BAD_REQUEST,
+    description: 'Invalid presentation proof id',
+    content: {
+      'application/json': {
+        schema: {},
+        examples: {
+          'Invalid presentation proof id': {
+            value: {
+              statusCode: 400,
+              message: 'Invalid presentation proof id',
+              data: null,
+            },
+          },
+        },
+      },
+    },
+  })
+  @ApiResponse({
+    status: HttpStatus.NOT_FOUND,
+    description: 'Presentation proof not found',
+    content: {
+      'application/json': {
+        schema: {},
+        examples: {
+          'Presentation proof not found': {
+            value: {
+              statusCode: 404,
+              message: 'Presentation proof not found',
+              data: null,
+            },
+          },
+        },
+      },
+    },
+  })
+  @ApiResponse({
+    status: HttpStatus.INTERNAL_SERVER_ERROR,
+    description: 'Internal server error',
+    content: {
+      'application/json': {
+        schema: {},
+        examples: {
+          'Internal server error': {
+            value: {
+              statusCode: 500,
+              message: 'Internal server error',
+              data: null,
+            },
+          },
+        },
+      },
+    },
+  })
+  public get(
+    @Param() { proofRecordId }: GetByIdParams,
+    @Query() { tenantId }: MultitenancyParams,
+  ): ReturnType<ProofsService['getById']> {
+    return this.service.getById(tenantId, proofRecordId).pipe(
+      switchMap((proofRecord) => {
+        if (!proofRecord) {
+          throw new NotFoundException(
+            `Presentation proof with id ${proofRecordId} not found`,
+          );
+        }
+
+        return of(proofRecord);
+      }),
+    );
+  }
+
+  @Post()
+  @ApiOperation({
+    summary: 'Request a presentation proof',
+    description: 'This call requests a presentation proof for a given tenant',
+  })
+  @ApiResponse({
+    status: HttpStatus.CREATED,
+    description: 'Presentation proof requested successfully',
+    content: {
+      'application/json': {
+        schema: {},
+        examples: {
+          'Presentation proof requested successfully': {
+            value: {
+              statusCode: 201,
+              message: 'Presentation proof requested successfully',
+              data: {},
+            },
+          },
+        },
+      },
+    },
+  })
+  @ApiResponse({
+    status: HttpStatus.NOT_FOUND,
+    description: 'Tenant not found',
+    content: {
+      'application/json': {
+        schema: {},
+        examples: {
+          'Tenant not found': {
+            value: {
+              statusCode: 404,
+              message: 'Tenant not found',
+              data: null,
+            },
+          },
+        },
+      },
+    },
+  })
+  @ApiResponse({
+    status: HttpStatus.BAD_REQUEST,
+    description: 'Invalid request payload',
+    content: {
+      'application/json': {
+        schema: {},
+        examples: {
+          'Invalid request payload': {
+            value: {
+              statusCode: 400,
+              message: 'Invalid request payload',
+              data: null,
+            },
+          },
+        },
+      },
+    },
+  })
+  @ApiResponse({
+    status: HttpStatus.INTERNAL_SERVER_ERROR,
+    description: 'Internal server error',
+    content: {
+      'application/json': {
+        schema: {},
+        examples: {
+          'Internal server error': {
+            value: {
+              statusCode: 500,
+              message: 'Internal server error',
+              data: null,
+            },
+          },
+        },
+      },
+    },
+  })
+  @ApiBody({
+    schema: {
+      type: 'object',
+      properties: {
+        name: {
+          type: 'string',
+          example: 'Proof of Vaccination',
+        },
+        connectionId: {
+          type: 'string',
+          example: '1234567890',
+        },
+        requestedAttributes: {
+          type: 'object',
+          additionalProperties: {
+            type: 'object',
+            properties: {
+              names: {
+                type: 'array',
+                items: {
+                  type: 'string',
+                },
+              },
+              restrictions: {
+                type: 'array',
+                items: {
+                  type: 'object',
+                  properties: {
+                    schema_id: { type: 'string' },
+                    schema_issuer_id: { type: 'string' },
+                    schema_name: { type: 'string' },
+                    schema_version: { type: 'string' },
+                    issuer_id: { type: 'string' },
+                    cred_def_id: { type: 'string' },
+                    rev_reg_id: { type: 'string' },
+                    schema_issuer_did: { type: 'string' },
+                    issuer_did: { type: 'string' },
+                  },
+                  patternProperties: {
+                    '^attr::.*?::marker$': { enum: ['1', '0'] },
+                    '^attr::.*?::value$': { type: 'string' },
+                  },
+                  additionalProperties: {
+                    type: 'string',
+                    anyOf: [{ enum: ['1', '0'] }, { type: 'string' }],
+                  },
+                },
+              },
+            },
+            required: ['names'],
+          },
+        },
+        requestedPredicates: {
+          type: 'object',
+          properties: {
+            name: { type: 'string' },
+            predicateType: { enum: ['>=', '>', '<=', '<'] },
+            predicateValue: { type: 'number' },
+            restrictions: {
+              type: 'array',
+              items: {
+                type: 'object',
+                properties: {
+                  schema_id: { type: 'string' },
+                  schema_issuer_id: { type: 'string' },
+                  schema_name: { type: 'string' },
+                  schema_version: { type: 'string' },
+                  issuer_id: { type: 'string' },
+                  cred_def_id: { type: 'string' },
+                  rev_reg_id: { type: 'string' },
+                  schema_issuer_did: { type: 'string' },
+                  issuer_did: { type: 'string' },
+                },
+                patternProperties: {
+                  '^attr::.*?::marker$': { enum: ['1', '0'] },
+                  '^attr::.*?::value$': { type: 'string' },
+                },
+                additionalProperties: {
+                  type: 'string',
+                  anyOf: [{ enum: ['1', '0'] }, { type: 'string' }],
+                },
+              },
+            },
+          },
+          required: ['name', 'predicateType', 'predicateValue'],
+        },
+      },
+      required: [
+        'name',
+        'connectionId',
+        'requestedAttributes',
+        'requestedPredicates',
+      ],
+    },
+  })
+  public request(
+    @Query() { tenantId }: MultitenancyParams,
+    @Body()
+    {
+      name,
+      connectionId,
+      requestedAttributes,
+      requestedPredicates,
+    }: RequestPayload,
+  ): ReturnType<ProofsService['request']> {
+    return this.service.request(
+      tenantId,
+      name,
+      connectionId,
+      requestedAttributes,
+      requestedPredicates,
+    );
+  }
+
+  @Delete(':id')
+  @HttpCode(HttpStatus.OK)
+  @ApiOperation({
+    summary: 'Delete a presentation proof',
+    description: 'This call deletes a presentation proof for a given tenant',
+  })
+  @ApiResponse({
+    status: HttpStatus.OK,
+    description: 'Presentation proof deleted successfully',
+    content: {
+      'application/json': {
+        schema: {},
+        examples: {
+          'Presentation proof deleted successfully': {
+            value: {
+              statusCode: 200,
+              message: 'Presentation proof deleted successfully',
+              data: null,
+            },
+          },
+        },
+      },
+    },
+  })
+  @ApiResponse({
+    status: HttpStatus.NOT_FOUND,
+    description: 'Tenant not found',
+    content: {
+      'application/json': {
+        schema: {},
+        examples: {
+          'Tenant not found': {
+            value: {
+              statusCode: 404,
+              message: 'Tenant not found',
+              data: null,
+            },
+          },
+          'Presentation proof not found': {
+            value: {
+              statusCode: 404,
+              message: 'Presentation proof not found',
+              data: null,
+            },
+          },
+        },
+      },
+    },
+  })
+  @ApiResponse({
+    status: HttpStatus.BAD_REQUEST,
+    description: 'Invalid presentation proof id',
+    content: {
+      'application/json': {
+        schema: {},
+        examples: {
+          'Invalid presentation proof id': {
+            value: {
+              statusCode: 400,
+              message: 'Invalid presentation proof id',
+              data: null,
+            },
+          },
+        },
+      },
+    },
+  })
+  @ApiResponse({
+    status: HttpStatus.INTERNAL_SERVER_ERROR,
+    description: 'Internal server error',
+    content: {
+      'application/json': {
+        schema: {},
+        examples: {
+          'Internal server error': {
+            value: {
+              statusCode: 500,
+              message: 'Internal server error',
+              data: null,
+            },
+          },
+        },
+      },
+    },
+  })
+  public delete(
+    @Param() { proofRecordId }: GetByIdParams,
+    @Query() { tenantId }: MultitenancyParams,
+  ): ReturnType<ProofsService['delete']> {
+    return this.service.delete(tenantId, proofRecordId);
+  }
+}
diff --git a/apps/proof-manager/src/proofs/proofs.module.ts b/apps/proof-manager/src/proofs/proofs.module.ts
new file mode 100644
index 0000000..29690bb
--- /dev/null
+++ b/apps/proof-manager/src/proofs/proofs.module.ts
@@ -0,0 +1,10 @@
+import { Module } from '@nestjs/common';
+
+import { ProofsController } from './proofs.controller.js';
+import { ProofsService } from './proofs.service.js';
+
+@Module({
+  providers: [ProofsService],
+  controllers: [ProofsController],
+})
+export class ProofsModule {}
diff --git a/apps/proof-manager/src/proofs/proofs.service.ts b/apps/proof-manager/src/proofs/proofs.service.ts
new file mode 100644
index 0000000..8e69544
--- /dev/null
+++ b/apps/proof-manager/src/proofs/proofs.service.ts
@@ -0,0 +1,84 @@
+import type {
+  EventAnonCredsProofsDeleteByIdInput,
+  EventAnonCredsProofsGetAllInput,
+  EventAnonCredsProofsGetByIdInput,
+  EventDidcommAnonCredsProofsRequestInput,
+} from '@ocm/shared';
+
+import { Inject, Injectable } from '@nestjs/common';
+import { ClientProxy } from '@nestjs/microservices';
+import {
+  EventAnonCredsProofsDeleteById,
+  EventAnonCredsProofsGetById,
+  EventDidcommAnonCredsProofsRequest,
+  EventAnonCredsProofsGetAll,
+} from '@ocm/shared';
+import { map, type Observable } from 'rxjs';
+
+import { NATS_CLIENT } from '../common/constants.js';
+
+@Injectable()
+export class ProofsService {
+  public constructor(
+    @Inject(NATS_CLIENT) private readonly natsClient: ClientProxy,
+  ) {}
+
+  public find(
+    tenantId: string,
+  ): Observable<EventAnonCredsProofsGetAll['data']> {
+    return this.natsClient
+      .send<
+        EventAnonCredsProofsGetAll,
+        EventAnonCredsProofsGetAllInput
+      >(EventAnonCredsProofsGetAll.token, { tenantId })
+      .pipe(map((result) => result.data));
+  }
+
+  public getById(
+    tenantId: string,
+    proofRecordId: string,
+  ): Observable<EventAnonCredsProofsGetById['data']> {
+    return this.natsClient
+      .send<EventAnonCredsProofsGetById, EventAnonCredsProofsGetByIdInput>(
+        EventAnonCredsProofsGetById.token,
+        {
+          tenantId,
+          proofRecordId,
+        },
+      )
+      .pipe(map((results) => results.data));
+  }
+
+  public request(
+    tenantId: string,
+    name: EventDidcommAnonCredsProofsRequestInput['name'],
+    connectionId: EventDidcommAnonCredsProofsRequestInput['connectionId'],
+    requestedAttributes: EventDidcommAnonCredsProofsRequestInput['requestedAttributes'],
+    requestedPredicates: EventDidcommAnonCredsProofsRequestInput['requestedPredicates'],
+  ): Observable<EventDidcommAnonCredsProofsRequest['data']> {
+    return this.natsClient
+      .send<
+        EventDidcommAnonCredsProofsRequest,
+        EventDidcommAnonCredsProofsRequestInput
+      >(EventDidcommAnonCredsProofsRequest.token, {
+        tenantId,
+        name,
+        connectionId,
+        requestedAttributes,
+        requestedPredicates,
+      })
+      .pipe(map((results) => results.data));
+  }
+
+  public delete(
+    tenantId: string,
+    proofRecordId: string,
+  ): Observable<EventAnonCredsProofsDeleteById['data']> {
+    return this.natsClient
+      .send<
+        EventAnonCredsProofsDeleteById,
+        EventAnonCredsProofsDeleteByIdInput
+      >(EventAnonCredsProofsDeleteById.token, { tenantId, proofRecordId })
+      .pipe(map((results) => results.data));
+  }
+}
-- 
GitLab