Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/vs-agent/src/admin.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
V2DidcommController,
V2DidcommCredentialExchangesController,
V2DidcommPresentationsController,
V2DidcommReceiptsController,
V2Openid4vcController,
V2VtServiceEndpointsController,
V1VsAgentController,
Expand Down Expand Up @@ -78,6 +79,7 @@ export class VsAgentModule {
V2DidcommPresentationsController,
V2DidcommConnectionsController,
V2DidcommCredentialExchangesController,
V2DidcommReceiptsController,
V2Openid4vcController,
V2AnoncredsController,
V2AnoncredsCredentialDefinitionsController,
Expand Down
8 changes: 8 additions & 0 deletions apps/vs-agent/src/common/AdminApiError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ export class AdminApiError extends Error {
}
}

export function unknownConnection(connectionId: string): AdminApiError {
return new AdminApiError(
AdminApiErrorCode.UnknownId,
HttpStatus.NOT_FOUND,
`no connection with id "${connectionId}"`,
)
}

export type TrustDecisionSubject = 'agent' | 'peer'

export function trustDecisionError(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
ApiTags,
} from '@nestjs/swagger'

import { AdminApiError, AdminApiErrorCode, createdAtKey, mapPage, Page, paginate } from '../../../../common'
import { createdAtKey, mapPage, Page, paginate, unknownConnection } from '../../../../common'
import { VsAgentService } from '../../../../services/VsAgentService'

import { ConnectionRecordDto, ConnectionRecordPageDto, ListConnectionsQueryDto } from './dto'
Expand Down Expand Up @@ -114,11 +114,3 @@ export class V2DidcommConnectionsController {
}
}
}

function unknownConnection(connectionId: string): AdminApiError {
return new AdminApiError(
AdminApiErrorCode.UnknownId,
HttpStatus.NOT_FOUND,
`no connection with id "${connectionId}"`,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { ChatAgentModules } from '@verana-labs/vs-agent-plugin-chat'
import type { VsAgent } from '@verana-labs/vs-agent-sdk'

import { Body, Controller, HttpStatus, Inject, Post, UsePipes, ValidationPipe } from '@nestjs/common'
import { ApiCreatedResponse, ApiNotFoundResponse, ApiOperation, ApiTags } from '@nestjs/swagger'

import { AdminApiError, AdminApiErrorCode, unknownConnection } from '../../../../common'
import { VsAgentService } from '../../../../services/VsAgentService'

import { SendReceiptsBodyDto, SendReceiptsResponseDto } from './dto'

/**
* The module stores no record, so there is no list or get method: an inbound `message-receipts`
* message reaches the caller as an event.
*/
@ApiTags('v2/didcomm')
@Controller({ path: 'didcomm/receipts', version: '2' })
export class V2DidcommReceiptsController {
public constructor(@Inject(VsAgentService) private readonly vsAgentService: VsAgentService) {}

@Post()
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
@ApiOperation({
summary: 'Send message receipts',
description: 'Sends message receipts on an established connection.',
})
@ApiCreatedResponse({ description: 'The sent message', type: SendReceiptsResponseDto })
@ApiNotFoundResponse({ description: 'No connection with the given id' })
public async sendReceipts(@Body() body: SendReceiptsBodyDto): Promise<SendReceiptsResponseDto> {
const agent = await this.vsAgentService.getAgent()
const { modules } = agent as unknown as VsAgent<ChatAgentModules>

if (!('receipts' in modules)) {
throw new AdminApiError(
AdminApiErrorCode.UnknownId,
HttpStatus.NOT_FOUND,
'this deployment does not serve the receipts module',
)
}

const connection = await agent.didcomm.connections.findById(body.connectionId)
if (!connection) throw unknownConnection(body.connectionId)

const { messageId } = await modules.receipts.send({
connectionId: body.connectionId,
receipts: body.receipts.map(receipt => ({
messageId: receipt.messageId,
state: receipt.state,
timestamp: receipt.timestamp ? new Date(receipt.timestamp) : undefined,
})),
})

return { id: messageId }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './presentation.dto'
export * from './connection.dto'
export * from './credential-exchange.dto'
export * from './decline.dto'
export * from './receipts.dto'
67 changes: 67 additions & 0 deletions apps/vs-agent/src/controllers/admin/v2/didcomm/dto/receipts.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
import { Type } from 'class-transformer'
import {
ArrayNotEmpty,
IsArray,
IsEnum,
IsISO8601,
IsNotEmpty,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator'

export enum MessageState {
Created = 'created',
Submitted = 'submitted',
Received = 'received',
Viewed = 'viewed',
Deleted = 'deleted',
}

/**
* One entry of [VSA-ADM-DC-RC-SEND] sendReceipts.
*/
export class MessageReceiptDto {
@ApiProperty({ description: 'Identifier of the message the receipt refers to', example: 'msg-1234' })
@IsString()
@IsNotEmpty()
messageId!: string

@ApiProperty({ enum: MessageState, description: 'State reported for the message' })
@IsEnum(MessageState)
state!: MessageState

@ApiPropertyOptional({ description: 'When the state was reached', example: '2026-09-07T12:00:00.000Z' })
@IsOptional()
@IsISO8601()
timestamp?: string
}

/**
* Body of [VSA-ADM-DC-RC-SEND] sendReceipts.
*/
export class SendReceiptsBodyDto {
@ApiProperty({ description: 'Connection to send the receipts on', example: 'conn-1234-5678' })
@IsString()
@IsNotEmpty()
connectionId!: string

@ApiProperty({ type: [MessageReceiptDto], description: 'The receipts to send' })
@IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => MessageReceiptDto)
receipts!: MessageReceiptDto[]
}

/**
* Response of [VSA-ADM-DC-RC-SEND] sendReceipts.
*/
export class SendReceiptsResponseDto {
@ApiProperty({
description: 'Identifier of the sent message',
example: 'a1b2c3d4-5678-90ab-cdef-1234567890ab',
})
id!: string
}
1 change: 1 addition & 0 deletions apps/vs-agent/src/controllers/admin/v2/didcomm/index.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please review the listProtocols requirement, as it has not been implemented yet and is explicitly required by the issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left out on purpose, it does not exist yet. #666 carries the same task for seven more modules, so building it here would mean a hardcoded list that #666 rewrites. Raised it as #683.

Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './V2DidcommConnectionsController'
export * from './V2DidcommController'
export * from './V2DidcommCredentialExchangesController'
export * from './V2DidcommPresentationsController'
export * from './V2DidcommReceiptsController'
44 changes: 44 additions & 0 deletions apps/vs-agent/tests/receiptsDto.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { plainToInstance } from 'class-transformer'
import { validateSync } from 'class-validator'
import { describe, expect, it } from 'vitest'

import { SendReceiptsBodyDto } from '../src/controllers/admin/v2/didcomm/dto'

const check = (body: unknown) =>
validateSync(plainToInstance(SendReceiptsBodyDto, body), {
whitelist: true,
forbidNonWhitelisted: true,
})

describe('sendReceipts body validation', () => {
it('accepts a valid body', () => {
expect(
check({
connectionId: 'conn-1',
receipts: [{ messageId: 'm1', state: 'viewed', timestamp: '2026-09-07T12:00:00.000Z' }],
}),
).toHaveLength(0)
})

it('refuses every shape the spec forbids', () => {
expect(check({ receipts: [{ messageId: 'm', state: 'viewed' }] }).length).toBeGreaterThan(0)
expect(check({ connectionId: 'c' }).length).toBeGreaterThan(0)
expect(check({ connectionId: 'c', receipts: [] }).length).toBeGreaterThan(0)
expect(
check({ connectionId: 'c', receipts: [{ messageId: 'm', state: 'nonsense' }] }).length,
).toBeGreaterThan(0)
expect(check({ connectionId: 'c', receipts: [{ state: 'viewed' }] }).length).toBeGreaterThan(0)
expect(
check({ connectionId: 'c', receipts: [{ messageId: 'm', state: 'viewed', timestamp: 'nope' }] }).length,
).toBeGreaterThan(0)
expect(
check({ connectionId: 'c', receipts: [{ messageId: 'm', state: 'viewed' }], extra: 1 }).length,
).toBeGreaterThan(0)
})

it('accepts every state the spec names', () => {
for (const state of ['created', 'submitted', 'received', 'viewed', 'deleted']) {
expect(check({ connectionId: 'c', receipts: [{ messageId: 'm', state }] })).toHaveLength(0)
}
})
})
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"packageManager": "pnpm@9.15.3",
"pnpm": {
"patchedDependencies": {
"@2060.io/credo-ts-didcomm-receipts@0.1.1": "patches/@2060.io__credo-ts-didcomm-receipts@0.1.1.patch",
"@credo-ts/anoncreds@0.7.1-pr-2704-20260909134930": "patches/@credo-ts__anoncreds@0.7.1-pr-2704-20260909134930.patch",
"@credo-ts/core@0.7.1-pr-2704-20260909134930": "patches/@credo-ts__core@0.7.1-pr-2704-20260909134930.patch",
"@credo-ts/didcomm@0.7.1-pr-2704-20260909134930": "patches/@credo-ts__didcomm@0.7.1-pr-2704-20260909134930.patch"
Expand Down
34 changes: 34 additions & 0 deletions patches/@2060.io__credo-ts-didcomm-receipts@0.1.1.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
--- a/build/index.mjs
+++ b/build/index.mjs
@@ -177,6 +177,7 @@
agentContext: this.agentContext,
connection
}));
+ return { messageId: message.id };
}
async request(options) {
const connection = await this.connectionService.findById(this.agentContext, options.connectionId);
@@ -186,6 +187,7 @@
agentContext: this.agentContext,
connection
}));
+ return { messageId: message.id };
}
};
DidCommReceiptsApi = __decorate([injectable(), __decorateMetadata("design:paramtypes", [
--- a/build/index.d.mts
+++ b/build/index.d.mts
@@ -94,11 +94,11 @@
send(options: {
connectionId: string;
receipts: DidCommMessageReceiptOptions[];
- }): Promise<void>;
+ }): Promise<{ messageId: string; }>;
request(options: {
connectionId: string;
requestedReceipts: DidCommRequestedReceiptOptions[];
- }): Promise<void>;
+ }): Promise<{ messageId: string; }>;
}
//#endregion
//#region src/DidCommReceiptsModule.d.ts
13 changes: 9 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading